Skip to content

Commit 8944cbc

Browse files
t3dotggguzmonne
authored andcommitted
feat(codex): submit thread feedback to OpenAI (pingdotgg#7949)
1 parent 3b407ff commit 8944cbc

36 files changed

Lines changed: 1152 additions & 30 deletions

‎apps/mobile/src/features/threads/ThreadComposer.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
552552
if (inFlightThreadIdsRef.current.has(threadKey)) return;
553553
inFlightThreadIdsRef.current.add(threadKey);
554554
try {
555-
await onSendMessage();
555+
const messageId = await onSendMessage();
556+
if (messageId === null) {
557+
return;
558+
}
556559
// Sending a prompt starts agent work: arm the lock-screen card while the
557560
// app is foregrounded and the activity token can be registered. Armed
558561
// after the send so its preference read and native Activity start don't

‎apps/mobile/src/lib/threadActivity.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vite-plus/test";
2+
import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
23

34
import {
45
EventId,
@@ -22,6 +23,34 @@ import {
2223
type ThreadFeedEntry,
2324
} from "./threadActivity";
2425

26+
describe("Codex feedback pseudo-messages", () => {
27+
it("keeps pending and completed feedback messages in the mobile thread body", () => {
28+
const pending = {
29+
id: MessageId.make("feedback-command"),
30+
command: "/feedback The agent stopped early.",
31+
createdAt: "2026-08-23T00:00:00.000Z",
32+
status: "uploading" as const,
33+
};
34+
const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map(
35+
(message) => ({
36+
type: "message" as const,
37+
id: message.id,
38+
createdAt: message.createdAt,
39+
message,
40+
}),
41+
);
42+
43+
expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries);
44+
expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI...");
45+
46+
const completed = codexFeedbackMessage(
47+
{ ...pending, status: "sent", feedbackId: "codex-thread-1" },
48+
"assistant",
49+
);
50+
expect(completed.text).toContain("codex-thread-1");
51+
});
52+
});
53+
2554
const singleSelectQuestion = {
2655
id: "runtime",
2756
header: "Runtime",
@@ -151,6 +180,44 @@ function makeThread(
151180
}
152181

153182
describe("buildThreadFeed", () => {
183+
it("keeps older local feedback before newer messages returned by the server", () => {
184+
const submission = {
185+
id: MessageId.make("feedback-command-ordering"),
186+
command: "/feedback The agent stopped early.",
187+
createdAt: "2026-08-23T00:00:01.000Z",
188+
status: "sent" as const,
189+
feedbackId: "codex-thread-1",
190+
};
191+
const laterMessage = {
192+
id: MessageId.make("later-server-message"),
193+
role: "assistant" as const,
194+
text: "Newer server response",
195+
turnId: null,
196+
createdAt: "2026-08-23T00:00:02.000Z",
197+
updatedAt: "2026-08-23T00:00:02.000Z",
198+
streaming: false,
199+
};
200+
const thread = makeThread({
201+
id: ThreadId.make("thread-feedback-ordering"),
202+
projectId: ProjectId.make("project-1"),
203+
title: "Feedback ordering",
204+
messages: [laterMessage],
205+
});
206+
207+
const feed = buildThreadFeed(thread, {
208+
localMessages: [
209+
codexFeedbackMessage(submission),
210+
codexFeedbackMessage(submission, "assistant"),
211+
],
212+
});
213+
214+
expect(feed.map((entry) => entry.id)).toEqual([
215+
"feedback-command-ordering",
216+
"feedback-command-ordering:feedback",
217+
"later-server-message",
218+
]);
219+
});
220+
154221
it("keeps historic work entries attributed to their turns", () => {
155222
const thread = makeThread({
156223
id: ThreadId.make("thread-1"),

‎apps/mobile/src/lib/threadActivity.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1526,15 +1526,19 @@ export function buildThreadFeed(
15261526
thread: OrchestrationThread,
15271527
options?: {
15281528
readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
1529+
readonly localMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
15291530
},
15301531
): ThreadFeedEntry[] {
15311532
const loadedMessages = options?.loadedMessages ?? thread.messages;
1533+
const messages = options?.localMessages
1534+
? [...loadedMessages, ...options.localMessages]
1535+
: loadedMessages;
15321536
const oldestLoadedMessageCreatedAt =
15331537
options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
15341538
const workLogEntries = deriveWorkLogEntries(thread.activities);
15351539
const entries = Arr.sortWith(
15361540
[
1537-
...loadedMessages.map<RawThreadFeedEntry>((message) => ({
1541+
...messages.map<RawThreadFeedEntry>((message) => ({
15381542
type: "message",
15391543
id: message.id,
15401544
createdAt: message.createdAt,

‎apps/mobile/src/state/use-thread-composer-state.ts‎

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useAtomValue } from "@effect/atom-react";
2-
import { useCallback, useEffect, useMemo } from "react";
2+
import { useCallback, useEffect, useMemo, useState } from "react";
3+
import { Alert } from "react-native";
4+
import * as Cause from "effect/Cause";
35

46
import {
57
CommandId,
@@ -11,6 +13,13 @@ import {
1113
type ThreadId,
1214
} from "@t3tools/contracts";
1315
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
16+
import {
17+
codexFeedbackMessage,
18+
parseCodexFeedbackCommand,
19+
submitCodexFeedback,
20+
type CodexFeedbackSubmission,
21+
} from "@t3tools/client-runtime/state/threads";
22+
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
1423
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
1524

1625
import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
@@ -21,6 +30,7 @@ import {
2130
} from "../lib/composerImages";
2231
import type { DraftComposerImageAttachment } from "../lib/composerImages";
2332
import { scopedThreadKey } from "../lib/scopedEntities";
33+
import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
2434
import { buildThreadFeed } from "../lib/threadActivity";
2535
import { appAtomRegistry } from "../state/atom-registry";
2636
import {
@@ -41,6 +51,8 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail";
4151
import { useThreadSelection } from "../state/use-thread-selection";
4252
import { enqueueThreadOutboxMessage } from "./thread-outbox";
4353
import { useThreadOutboxMessages } from "./use-thread-outbox";
54+
import { threadEnvironment } from "./threads";
55+
import { useAtomCommand } from "./use-atom-command";
4456

4557
export function appendReviewCommentToDraft(input: {
4658
readonly environmentId: EnvironmentId;
@@ -74,10 +86,16 @@ export function useThreadDraftForThread(input: {
7486
}
7587

7688
export function useThreadComposerState() {
77-
const { selectedThread: selectedThreadShell } = useThreadSelection();
89+
const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection();
7890
const selectedThreadDetail = useSelectedThreadDetail();
7991
const composerDrafts = useAtomValue(composerDraftsAtom);
8092
const queuedMessagesByThreadKey = useThreadOutboxMessages();
93+
const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
94+
Record<string, ReadonlyArray<CodexFeedbackSubmission>>
95+
>({});
96+
const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
97+
reportFailure: false,
98+
});
8199

82100
useEffect(() => {
83101
ensureComposerDraftsLoaded();
@@ -90,10 +108,21 @@ export function useThreadComposerState() {
90108
() => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
91109
[queuedMessagesByThreadKey, selectedThreadKey],
92110
);
93-
const selectedThreadFeed = useMemo(
94-
() => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),
95-
[selectedThreadDetail],
96-
);
111+
const selectedThreadFeed = useMemo(() => {
112+
if (!selectedThreadDetail) {
113+
return [];
114+
}
115+
const submissions = selectedThreadKey
116+
? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? [])
117+
: [];
118+
return buildThreadFeed(selectedThreadDetail, {
119+
localMessages: submissions.flatMap((submission) =>
120+
submission.status === "interrupted"
121+
? []
122+
: [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
123+
),
124+
});
125+
}, [feedbackSubmissionsByThreadKey, selectedThreadDetail, selectedThreadKey]);
97126

98127
const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
99128
const draftMessage = selectedDraft?.text ?? "";
@@ -143,6 +172,70 @@ export function useThreadComposerState() {
143172
return null;
144173
}
145174

175+
const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
176+
(entry) => entry.instanceId === thread.modelSelection.instanceId,
177+
);
178+
const feedbackCommand =
179+
attachments.length === 0 &&
180+
(provider?.driver === "codex" || thread.session?.providerName === "codex")
181+
? parseCodexFeedbackCommand(text)
182+
: null;
183+
if (feedbackCommand) {
184+
if (thread.session === null) {
185+
Alert.alert("Start a Codex thread first", "Send a message before you submit feedback.");
186+
return null;
187+
}
188+
const metadata = makeQueuedMessageMetadata();
189+
const result = await submitCodexFeedback({
190+
submission: {
191+
id: MessageId.make(metadata.messageId),
192+
command: text,
193+
createdAt: metadata.createdAt,
194+
},
195+
clearDraft: () => clearComposerDraftContent(threadKey),
196+
onUpdate: (submission) => {
197+
setFeedbackSubmissionsByThreadKey((current) => {
198+
const existing = current[threadKey] ?? [];
199+
const found = existing.some((entry) => entry.id === submission.id);
200+
return {
201+
...current,
202+
[threadKey]: found
203+
? existing.map((entry) => (entry.id === submission.id ? submission : entry))
204+
: [...existing, submission],
205+
};
206+
});
207+
},
208+
upload: () =>
209+
uploadThreadFeedback({
210+
environmentId: selectedThreadShell.environmentId,
211+
input: {
212+
threadId: selectedThreadShell.id,
213+
...feedbackCommand,
214+
},
215+
}),
216+
});
217+
if (result._tag === "Failure") {
218+
if (isAtomCommandInterrupted(result)) {
219+
return null;
220+
}
221+
const error = Cause.squash(result.cause);
222+
Alert.alert(
223+
"Could not send feedback to OpenAI",
224+
error instanceof Error ? error.message : "An error occurred.",
225+
);
226+
return null;
227+
}
228+
const feedbackId = result.value.feedbackId;
229+
Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [
230+
{ text: "OK", style: "cancel" },
231+
{
232+
text: "Copy ID",
233+
onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }),
234+
},
235+
]);
236+
return null;
237+
}
238+
146239
const metadata = makeQueuedMessageMetadata();
147240
const messageId = MessageId.make(metadata.messageId);
148241
// Enqueue publishes the queued atom synchronously (the durable write
@@ -175,7 +268,12 @@ export function useThreadComposerState() {
175268
);
176269
});
177270
return messageId;
178-
}, [selectedThreadDetail, selectedThreadShell]);
271+
}, [
272+
selectedEnvironmentRuntime?.serverConfig?.providers,
273+
selectedThreadDetail,
274+
selectedThreadShell,
275+
uploadThreadFeedback,
276+
]);
179277

180278
const onChangeDraftMessage = useCallback(
181279
(value: string) => {

‎apps/server/integration/orphanedProviderSessionStartup.integration.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll(
116116
getCapabilities: () => Effect.die("unused"),
117117
getInstanceInfo: () => Effect.die("unused"),
118118
rollbackConversation: () => Effect.die("unused"),
119+
uploadFeedback: () => Effect.die("unused"),
119120
streamEvents: Stream.empty,
120121
}),
121122
);

‎apps/server/src/auth/RpcAuthorization.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ describe("RPC authorization scopes", () => {
3737
expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope);
3838
});
3939

40+
it("requires permission to operate on a thread before uploading feedback", () => {
41+
expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe(
42+
AuthOrchestrationOperateScope,
43+
);
44+
});
45+
4046
it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {
4147
// The candidate list is a read like the detail beside it, and asking somebody for a review is
4248
// a write like every other pull request operation.

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const RPC_REQUIRED_SCOPES = {
8484
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
8585
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
8686
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
87+
[WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
8788
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
8889
[WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
8990
[WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,

‎apps/server/src/orchestration/Layers/CheckpointReactor.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ function createProviderServiceHarness(
125125
},
126126
}),
127127
rollbackConversation,
128+
uploadFeedback: () => unsupported(),
128129
get streamEvents() {
129130
return Stream.fromPubSub(runtimeEventPubSub);
130131
},

‎apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ describe("ProviderCommandReactor", () => {
340340
});
341341
},
342342
rollbackConversation: () => unsupported(),
343+
uploadFeedback: () => unsupported(),
343344
get streamEvents() {
344345
return Stream.fromPubSub(runtimeEventPubSub);
345346
},

‎apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ function createProviderServiceHarness() {
125125
});
126126
},
127127
rollbackConversation: () => unsupported(),
128+
uploadFeedback: () => unsupported(),
128129
get streamEvents() {
129130
return Stream.fromPubSub(runtimeEventPubSub);
130131
},

0 commit comments

Comments
 (0)