authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-23 00:00:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-23 00:00:36-07:00
log3db38b8814c49cfd783543371fad8cd4adbc19fd
tree6353390a9c7f81f3a3afb96b958b36af7c78e4d0
parentafa83098064e7dca524a1e42dea3de03a883a0b6
signature Signed by PGP key B5690EEEBB952194

feat(codex): submit thread feedback to OpenAI (#7949)


36 files changed, 1152 insertions(+), 30 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+4-1
...@@ -552,7 +552,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -552,7 +552,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
552 if (inFlightThreadIdsRef.current.has(threadKey)) return;552 if (inFlightThreadIdsRef.current.has(threadKey)) return;
553 inFlightThreadIdsRef.current.add(threadKey);553 inFlightThreadIdsRef.current.add(threadKey);
554 try {554 try {
555 await onSendMessage();555 const messageId = await onSendMessage();
556 if (messageId === null) {
557 return;
558 }
556 // Sending a prompt starts agent work: arm the lock-screen card while the559 // Sending a prompt starts agent work: arm the lock-screen card while the
557 // app is foregrounded and the activity token can be registered. Armed560 // app is foregrounded and the activity token can be registered. Armed
558 // after the send so its preference read and native Activity start don't561 // after the send so its preference read and native Activity start don't
apps/mobile/src/lib/threadActivity.test.ts+67
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
23
3import {4import {
4 EventId,5 EventId,
...@@ -22,6 +23,34 @@ import {...@@ -22,6 +23,34 @@ import {
22 type ThreadFeedEntry,23 type ThreadFeedEntry,
23} from "./threadActivity";24} from "./threadActivity";
2425
26describe("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
25const singleSelectQuestion = {54const singleSelectQuestion = {
26 id: "runtime",55 id: "runtime",
27 header: "Runtime",56 header: "Runtime",
...@@ -151,6 +180,44 @@ function makeThread(...@@ -151,6 +180,44 @@ function makeThread(
151}180}
152181
153describe("buildThreadFeed", () => {182describe("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
154 it("keeps historic work entries attributed to their turns", () => {221 it("keeps historic work entries attributed to their turns", () => {
155 const thread = makeThread({222 const thread = makeThread({
156 id: ThreadId.make("thread-1"),223 id: ThreadId.make("thread-1"),
apps/mobile/src/lib/threadActivity.ts+5-1
...@@ -1526,15 +1526,19 @@ export function buildThreadFeed(...@@ -1526,15 +1526,19 @@ export function buildThreadFeed(
1526 thread: OrchestrationThread,1526 thread: OrchestrationThread,
1527 options?: {1527 options?: {
1528 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;1528 readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
1529 readonly localMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>;
1529 },1530 },
1530): ThreadFeedEntry[] {1531): ThreadFeedEntry[] {
1531 const loadedMessages = options?.loadedMessages ?? thread.messages;1532 const loadedMessages = options?.loadedMessages ?? thread.messages;
1533 const messages = options?.localMessages
1534 ? [...loadedMessages, ...options.localMessages]
1535 : loadedMessages;
1532 const oldestLoadedMessageCreatedAt =1536 const oldestLoadedMessageCreatedAt =
1533 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;1537 options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
1534 const workLogEntries = deriveWorkLogEntries(thread.activities);1538 const workLogEntries = deriveWorkLogEntries(thread.activities);
1535 const entries = Arr.sortWith(1539 const entries = Arr.sortWith(
1536 [1540 [
1537 ...loadedMessages.map<RawThreadFeedEntry>((message) => ({1541 ...messages.map<RawThreadFeedEntry>((message) => ({
1538 type: "message",1542 type: "message",
1539 id: message.id,1543 id: message.id,
1540 createdAt: message.createdAt,1544 createdAt: message.createdAt,
apps/mobile/src/state/use-thread-composer-state.ts+105-7
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import { Alert } from "react-native";
4import * as Cause from "effect/Cause";
35
4import {6import {
5 CommandId,7 CommandId,
...@@ -11,6 +13,13 @@ import {...@@ -11,6 +13,13 @@ import {
11 type ThreadId,13 type ThreadId,
12} from "@t3tools/contracts";14} from "@t3tools/contracts";
13import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";15import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
16import {
17 codexFeedbackMessage,
18 parseCodexFeedbackCommand,
19 submitCodexFeedback,
20 type CodexFeedbackSubmission,
21} from "@t3tools/client-runtime/state/threads";
22import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
14import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";23import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
1524
16import { makeQueuedMessageMetadata } from "../lib/commandMetadata";25import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
...@@ -21,6 +30,7 @@ import {...@@ -21,6 +30,7 @@ import {
21} from "../lib/composerImages";30} from "../lib/composerImages";
22import type { DraftComposerImageAttachment } from "../lib/composerImages";31import type { DraftComposerImageAttachment } from "../lib/composerImages";
23import { scopedThreadKey } from "../lib/scopedEntities";32import { scopedThreadKey } from "../lib/scopedEntities";
33import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
24import { buildThreadFeed } from "../lib/threadActivity";34import { buildThreadFeed } from "../lib/threadActivity";
25import { appAtomRegistry } from "../state/atom-registry";35import { appAtomRegistry } from "../state/atom-registry";
26import {36import {
...@@ -41,6 +51,8 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail";...@@ -41,6 +51,8 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail";
41import { useThreadSelection } from "../state/use-thread-selection";51import { useThreadSelection } from "../state/use-thread-selection";
42import { enqueueThreadOutboxMessage } from "./thread-outbox";52import { enqueueThreadOutboxMessage } from "./thread-outbox";
43import { useThreadOutboxMessages } from "./use-thread-outbox";53import { useThreadOutboxMessages } from "./use-thread-outbox";
54import { threadEnvironment } from "./threads";
55import { useAtomCommand } from "./use-atom-command";
4456
45export function appendReviewCommentToDraft(input: {57export function appendReviewCommentToDraft(input: {
46 readonly environmentId: EnvironmentId;58 readonly environmentId: EnvironmentId;
...@@ -74,10 +86,16 @@ export function useThreadDraftForThread(input: {...@@ -74,10 +86,16 @@ export function useThreadDraftForThread(input: {
74}86}
7587
76export function useThreadComposerState() {88export function useThreadComposerState() {
77 const { selectedThread: selectedThreadShell } = useThreadSelection();89 const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection();
78 const selectedThreadDetail = useSelectedThreadDetail();90 const selectedThreadDetail = useSelectedThreadDetail();
79 const composerDrafts = useAtomValue(composerDraftsAtom);91 const composerDrafts = useAtomValue(composerDraftsAtom);
80 const queuedMessagesByThreadKey = useThreadOutboxMessages();92 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
82 useEffect(() => {100 useEffect(() => {
83 ensureComposerDraftsLoaded();101 ensureComposerDraftsLoaded();
...@@ -90,10 +108,21 @@ export function useThreadComposerState() {...@@ -90,10 +108,21 @@ export function useThreadComposerState() {
90 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),108 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
91 [queuedMessagesByThreadKey, selectedThreadKey],109 [queuedMessagesByThreadKey, selectedThreadKey],
92 );110 );
93 const selectedThreadFeed = useMemo(111 const selectedThreadFeed = useMemo(() => {
94 () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),112 if (!selectedThreadDetail) {
95 [selectedThreadDetail],113 return [];
96 );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
98 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;127 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
99 const draftMessage = selectedDraft?.text ?? "";128 const draftMessage = selectedDraft?.text ?? "";
...@@ -143,6 +172,70 @@ export function useThreadComposerState() {...@@ -143,6 +172,70 @@ export function useThreadComposerState() {
143 return null;172 return null;
144 }173 }
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
146 const metadata = makeQueuedMessageMetadata();239 const metadata = makeQueuedMessageMetadata();
147 const messageId = MessageId.make(metadata.messageId);240 const messageId = MessageId.make(metadata.messageId);
148 // Enqueue publishes the queued atom synchronously (the durable write241 // Enqueue publishes the queued atom synchronously (the durable write
...@@ -175,7 +268,12 @@ export function useThreadComposerState() {...@@ -175,7 +268,12 @@ export function useThreadComposerState() {
175 );268 );
176 });269 });
177 return messageId;270 return messageId;
178 }, [selectedThreadDetail, selectedThreadShell]);271 }, [
272 selectedEnvironmentRuntime?.serverConfig?.providers,
273 selectedThreadDetail,
274 selectedThreadShell,
275 uploadThreadFeedback,
276 ]);
179277
180 const onChangeDraftMessage = useCallback(278 const onChangeDraftMessage = useCallback(
181 (value: string) => {279 (value: string) => {
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1
...@@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll(...@@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll(
116 getCapabilities: () => Effect.die("unused"),116 getCapabilities: () => Effect.die("unused"),
117 getInstanceInfo: () => Effect.die("unused"),117 getInstanceInfo: () => Effect.die("unused"),
118 rollbackConversation: () => Effect.die("unused"),118 rollbackConversation: () => Effect.die("unused"),
119 uploadFeedback: () => Effect.die("unused"),
119 streamEvents: Stream.empty,120 streamEvents: Stream.empty,
120 }),121 }),
121);122);
apps/server/src/auth/RpcAuthorization.test.ts+6
...@@ -37,6 +37,12 @@ describe("RPC authorization scopes", () => {...@@ -37,6 +37,12 @@ describe("RPC authorization scopes", () => {
37 expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope);37 expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope);
38 });38 });
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
40 it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {46 it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {
41 // The candidate list is a read like the detail beside it, and asking somebody for a review is47 // The candidate list is a read like the detail beside it, and asking somebody for a review is
42 // a write like every other pull request operation.48 // a write like every other pull request operation.
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -84,6 +84,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -84,6 +84,7 @@ export const RPC_REQUIRED_SCOPES = {
84 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,84 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
85 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,85 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
86 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,86 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
87 [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
87 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,88 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
88 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,89 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
89 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,90 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1
...@@ -125,6 +125,7 @@ function createProviderServiceHarness(...@@ -125,6 +125,7 @@ function createProviderServiceHarness(
125 },125 },
126 }),126 }),
127 rollbackConversation,127 rollbackConversation,
128 uploadFeedback: () => unsupported(),
128 get streamEvents() {129 get streamEvents() {
129 return Stream.fromPubSub(runtimeEventPubSub);130 return Stream.fromPubSub(runtimeEventPubSub);
130 },131 },
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1
...@@ -340,6 +340,7 @@ describe("ProviderCommandReactor", () => {...@@ -340,6 +340,7 @@ describe("ProviderCommandReactor", () => {
340 });340 });
341 },341 },
342 rollbackConversation: () => unsupported(),342 rollbackConversation: () => unsupported(),
343 uploadFeedback: () => unsupported(),
343 get streamEvents() {344 get streamEvents() {
344 return Stream.fromPubSub(runtimeEventPubSub);345 return Stream.fromPubSub(runtimeEventPubSub);
345 },346 },
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1
...@@ -125,6 +125,7 @@ function createProviderServiceHarness() {...@@ -125,6 +125,7 @@ function createProviderServiceHarness() {
125 });125 });
126 },126 },
127 rollbackConversation: () => unsupported(),127 rollbackConversation: () => unsupported(),
128 uploadFeedback: () => unsupported(),
128 get streamEvents() {129 get streamEvents() {
129 return Stream.fromPubSub(runtimeEventPubSub);130 return Stream.fromPubSub(runtimeEventPubSub);
130 },131 },
apps/server/src/provider/Layers/CodexAdapter.test.ts+44
...@@ -104,6 +104,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {...@@ -104,6 +104,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
104 }),104 }),
105 );105 );
106106
107 public readonly uploadFeedbackImpl = vi.fn((_reason?: string) =>
108 Promise.resolve({ threadId: "provider-thread-1" }),
109 );
110
107 public readonly respondToRequestImpl = vi.fn(111 public readonly respondToRequestImpl = vi.fn(
108 (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise<void> =>112 (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise<void> =>
109 Promise.resolve(undefined),113 Promise.resolve(undefined),
...@@ -142,6 +146,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {...@@ -142,6 +146,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
142 return Effect.promise(() => this.rollbackThreadImpl(numTurns));146 return Effect.promise(() => this.rollbackThreadImpl(numTurns));
143 }147 }
144148
149 uploadFeedback(reason?: string) {
150 return Effect.promise(() => this.uploadFeedbackImpl(reason));
151 }
152
145 respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) {153 respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) {
146 return Effect.promise(() => this.respondToRequestImpl(requestId, decision));154 return Effect.promise(() => this.respondToRequestImpl(requestId, decision));
147 }155 }
...@@ -328,6 +336,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -328,6 +336,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
328 }),336 }),
329 );337 );
330338
339 it.effect("uploads feedback for the active Codex thread", () =>
340 Effect.gen(function* () {
341 const adapter = yield* CodexAdapter;
342 const threadId = asThreadId("thread-feedback");
343 yield* adapter.startSession({
344 provider: ProviderDriverKind.make("codex"),
345 threadId,
346 runtimeMode: "full-access",
347 });
348 const runtime = sessionRuntimeFactory.lastRuntime;
349 NodeAssert.ok(runtime);
350
351 const result = yield* adapter.uploadFeedback({
352 threadId,
353 reason: "The agent stopped early.",
354 });
355
356 NodeAssert.deepStrictEqual(result, { feedbackId: "provider-thread-1" });
357 NodeAssert.deepStrictEqual(runtime.uploadFeedbackImpl.mock.calls, [
358 ["The agent stopped early."],
359 ]);
360 }),
361 );
362
363 it.effect("rejects feedback for an unknown Codex thread", () =>
364 Effect.gen(function* () {
365 const adapter = yield* CodexAdapter;
366 const result = yield* adapter
367 .uploadFeedback({ threadId: asThreadId("thread-feedback-missing") })
368 .pipe(Effect.result);
369
370 NodeAssert.equal(result._tag, "Failure");
371 NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError");
372 }),
373 );
374
331 it.effect("maps codex model options before sending a turn", () =>375 it.effect("maps codex model options before sending a turn", () =>
332 Effect.gen(function* () {376 Effect.gen(function* () {
333 const adapter = yield* CodexAdapter;377 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+12
...@@ -1891,6 +1891,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -1891,6 +1891,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
1891 );1891 );
1892 };1892 };
18931893
1894 const uploadFeedback: CodexAdapterShape["uploadFeedback"] = (input) =>
1895 requireSession(input.threadId).pipe(
1896 Effect.flatMap((session) => session.runtime.uploadFeedback(input.reason)),
1897 Effect.map(({ threadId }) => ({ feedbackId: threadId })),
1898 Effect.mapError((cause) =>
1899 cause._tag === "ProviderAdapterSessionNotFoundError"
1900 ? cause
1901 : mapCodexRuntimeError(input.threadId, "feedback/upload", cause),
1902 ),
1903 );
1904
1894 const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) =>1905 const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) =>
1895 requireSession(threadId).pipe(1906 requireSession(threadId).pipe(
1896 Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)),1907 Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)),
...@@ -1978,6 +1989,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -1978,6 +1989,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
1978 interruptTurn,1989 interruptTurn,
1979 readThread,1990 readThread,
1980 rollbackThread,1991 rollbackThread,
1992 uploadFeedback,
1981 respondToRequest,1993 respondToRequest,
1982 respondToUserInput,1994 respondToUserInput,
1983 stopSession,1995 stopSession,
apps/server/src/provider/Layers/CodexProvider.ts+7
...@@ -607,6 +607,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -607,6 +607,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
607 checkedAt,607 checkedAt,
608 models: snapshot.models,608 models: snapshot.models,
609 skills: snapshot.skills,609 skills: snapshot.skills,
610 slashCommands: [
611 {
612 name: "feedback",
613 description: "Send this thread and Codex logs to OpenAI",
614 input: { hint: "Describe the issue (optional)" },
615 },
616 ],
610 probe: {617 probe: {
611 installed: true,618 installed: true,
612 version: snapshot.version ?? null,619 version: snapshot.version ?? null,
apps/server/src/provider/Layers/CodexSessionRuntime.ts+13
...@@ -142,6 +142,9 @@ export interface CodexSessionRuntimeShape {...@@ -142,6 +142,9 @@ export interface CodexSessionRuntimeShape {
142 readonly rollbackThread: (142 readonly rollbackThread: (
143 numTurns: number,143 numTurns: number,
144 ) => Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;144 ) => Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;
145 readonly uploadFeedback: (
146 reason?: string,
147 ) => Effect.Effect<EffectCodexSchema.V2FeedbackUploadResponse, CodexSessionRuntimeError>;
145 readonly respondToRequest: (148 readonly respondToRequest: (
146 requestId: ApprovalRequestId,149 requestId: ApprovalRequestId,
147 decision: ProviderApprovalDecision,150 decision: ProviderApprovalDecision,
...@@ -1914,6 +1917,16 @@ export const makeCodexSessionRuntime = (...@@ -1914,6 +1917,16 @@ export const makeCodexSessionRuntime = (
1914 });1917 });
1915 return parseThreadSnapshot(response);1918 return parseThreadSnapshot(response);
1916 }),1919 }),
1920 uploadFeedback: (reason) =>
1921 Effect.gen(function* () {
1922 const providerThreadId = yield* readProviderThreadId;
1923 return yield* client.request("feedback/upload", {
1924 classification: "bug",
1925 includeLogs: true,
1926 ...(reason ? { reason } : {}),
1927 threadId: providerThreadId,
1928 });
1929 }),
1917 respondToRequest: (requestId, decision) =>1930 respondToRequest: (requestId, decision) =>
1918 Effect.gen(function* () {1931 Effect.gen(function* () {
1919 const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId);1932 const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId);
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1
...@@ -40,6 +40,7 @@ const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = {...@@ -40,6 +40,7 @@ const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = {
40 hasSession: vi.fn(),40 hasSession: vi.fn(),
41 readThread: vi.fn(),41 readThread: vi.fn(),
42 rollbackThread: vi.fn(),42 rollbackThread: vi.fn(),
43 uploadFeedback: vi.fn(),
43 stopAll: vi.fn(),44 stopAll: vi.fn(),
44 streamEvents: Stream.empty,45 streamEvents: Stream.empty,
45};46};
apps/server/src/provider/Layers/ProviderRegistry.test.ts+7
...@@ -381,6 +381,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -381,6 +381,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
381 shortDescription: "Debug failing GitHub Actions checks",381 shortDescription: "Debug failing GitHub Actions checks",
382 },382 },
383 ]);383 ]);
384 assert.deepStrictEqual(status.slashCommands, [
385 {
386 name: "feedback",
387 description: "Send this thread and Codex logs to OpenAI",
388 input: { hint: "Describe the issue (optional)" },
389 },
390 ]);
384 }),391 }),
385 );392 );
386393
apps/server/src/provider/Layers/ProviderService.test.ts+160
...@@ -9,6 +9,8 @@ import type {...@@ -9,6 +9,8 @@ import type {
9 ProviderSendTurnInput,9 ProviderSendTurnInput,
10 ProviderSession,10 ProviderSession,
11 ProviderTurnStartResult,11 ProviderTurnStartResult,
12 ProviderUploadFeedbackInput,
13 ProviderUploadFeedbackResult,
12} from "@t3tools/contracts";14} from "@t3tools/contracts";
13import {15import {
14 ApprovalRequestId,16 ApprovalRequestId,
...@@ -197,6 +199,13 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {...@@ -197,6 +199,13 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
197 Effect.succeed({ threadId, turns: [] }),199 Effect.succeed({ threadId, turns: [] }),
198 );200 );
199201
202 const uploadFeedback = vi.fn(
203 (
204 input: ProviderUploadFeedbackInput,
205 ): Effect.Effect<ProviderUploadFeedbackResult, ProviderAdapterError> =>
206 Effect.succeed({ feedbackId: `feedback-${input.threadId}` }),
207 );
208
200 const stopAll = vi.fn(209 const stopAll = vi.fn(
201 (): Effect.Effect<void, ProviderAdapterError> =>210 (): Effect.Effect<void, ProviderAdapterError> =>
202 Effect.sync(() => {211 Effect.sync(() => {
...@@ -219,6 +228,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {...@@ -219,6 +228,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
219 hasSession,228 hasSession,
220 readThread,229 readThread,
221 rollbackThread,230 rollbackThread,
231 ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}),
222 stopAll,232 stopAll,
223 get streamEvents() {233 get streamEvents() {
224 return Stream.fromPubSub(runtimeEventPubSub);234 return Stream.fromPubSub(runtimeEventPubSub);
...@@ -254,6 +264,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {...@@ -254,6 +264,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
254 hasSession,264 hasSession,
255 readThread,265 readThread,
256 rollbackThread,266 rollbackThread,
267 uploadFeedback,
257 stopAll,268 stopAll,
258 };269 };
259}270}
...@@ -595,6 +606,68 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance...@@ -595,6 +606,68 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
595606
596const routing = makeProviderServiceLayer();607const routing = makeProviderServiceLayer();
597608
609it.effect(
610 "ProviderServiceLive uploads feedback through the adapter that recovered the session",
611 () =>
612 Effect.gen(function* () {
613 const original = makeFakeCodexAdapter();
614 const replacement = makeFakeCodexAdapter();
615 const baseRegistry = makeAdapterRegistryMock({ [CODEX_DRIVER]: original.adapter });
616 let swapAfterFirstLookup = false;
617 let feedbackLookupCount = 0;
618 const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = {
619 ...baseRegistry,
620 getByInstance: (instanceId) => {
621 if (instanceId !== codexInstanceId) {
622 return baseRegistry.getByInstance(instanceId);
623 }
624 const useReplacement = swapAfterFirstLookup && feedbackLookupCount++ > 0;
625 return Effect.succeed(useReplacement ? replacement.adapter : original.adapter);
626 },
627 };
628 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
629 Layer.provide(SqlitePersistenceMemory),
630 );
631 const directoryLayer = ProviderSessionDirectoryLive.pipe(
632 Layer.provide(runtimeRepositoryLayer),
633 );
634 const providerLayer = makeProviderServiceLive().pipe(
635 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
636 Layer.provide(directoryLayer),
637 Layer.provide(defaultServerSettingsLayer),
638 Layer.provide(serverConfigTestLayer),
639 Layer.provide(AnalyticsService.layerTest),
640 Layer.provide(
641 Layer.succeed(
642 ProviderEventLoggers.ProviderEventLoggers,
643 ProviderEventLoggers.NoOpProviderEventLoggers,
644 ),
645 ),
646 );
647
648 yield* Effect.gen(function* () {
649 const provider = yield* ProviderService.ProviderService;
650 const threadId = asThreadId("thread-feedback-adapter-replacement");
651 yield* provider.startSession(threadId, {
652 provider: CODEX_DRIVER,
653 providerInstanceId: codexInstanceId,
654 threadId,
655 runtimeMode: "full-access",
656 });
657 yield* original.stopSession(threadId);
658 original.uploadFeedback.mockClear();
659 replacement.uploadFeedback.mockClear();
660 swapAfterFirstLookup = true;
661
662 const result = yield* provider.uploadFeedback({ threadId });
663
664 assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` });
665 assert.strictEqual(original.uploadFeedback.mock.calls.length, 0);
666 assert.deepStrictEqual(replacement.uploadFeedback.mock.calls, [[{ threadId }]]);
667 }).pipe(Effect.provide(providerLayer));
668 }).pipe(Effect.provide(NodeServices.layer)),
669);
670
598it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () =>671it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () =>
599 Effect.gen(function* () {672 Effect.gen(function* () {
600 const codex = makeFakeCodexAdapter();673 const codex = makeFakeCodexAdapter();
...@@ -941,6 +1014,93 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -941,6 +1014,93 @@ routing.layer("ProviderServiceLive routing", (it) => {
941 }),1014 }),
942 );1015 );
9431016
1017 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>
1018 Effect.gen(function* () {
1019 const provider = yield* ProviderService.ProviderService;
1020 const threadId = asThreadId("thread-feedback-route");
1021 yield* provider.startSession(threadId, {
1022 provider: CODEX_DRIVER,
1023 providerInstanceId: codexInstanceId,
1024 threadId,
1025 runtimeMode: "full-access",
1026 });
1027 routing.codex.uploadFeedback.mockClear();
1028
1029 const result = yield* provider.uploadFeedback({
1030 threadId,
1031 reason: "The agent stopped early.",
1032 });
1033
1034 assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` });
1035 assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [
1036 [{ threadId, reason: "The agent stopped early." }],
1037 ]);
1038 }),
1039 );
1040
1041 it.effect("recovers a stopped Codex session before uploading feedback", () =>
1042 Effect.gen(function* () {
1043 const provider = yield* ProviderService.ProviderService;
1044 const threadId = asThreadId("thread-feedback-recover");
1045 yield* provider.startSession(threadId, {
1046 provider: CODEX_DRIVER,
1047 providerInstanceId: codexInstanceId,
1048 threadId,
1049 cwd: "/tmp/feedback-project",
1050 runtimeMode: "full-access",
1051 });
1052 yield* routing.codex.stopSession(threadId);
1053 routing.codex.startSession.mockClear();
1054 routing.codex.uploadFeedback.mockClear();
1055
1056 const result = yield* provider.uploadFeedback({ threadId });
1057
1058 assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` });
1059 assert.strictEqual(routing.codex.startSession.mock.calls.length, 1);
1060 assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [[{ threadId }]]);
1061 }),
1062 );
1063
1064 it.effect("rejects feedback for providers that do not support uploads", () =>
1065 Effect.gen(function* () {
1066 const provider = yield* ProviderService.ProviderService;
1067 const threadId = asThreadId("thread-feedback-claude");
1068 yield* provider.startSession(threadId, {
1069 provider: CLAUDE_AGENT_DRIVER,
1070 providerInstanceId: claudeAgentInstanceId,
1071 threadId,
1072 runtimeMode: "full-access",
1073 });
1074
1075 const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip);
1076
1077 assert.instanceOf(error, ProviderValidationError);
1078 assert.include(error.issue, "does not support feedback uploads");
1079 routing.claude.startSession.mockClear();
1080 }),
1081 );
1082
1083 it.effect("does not restart an unsupported provider before rejecting feedback", () =>
1084 Effect.gen(function* () {
1085 const provider = yield* ProviderService.ProviderService;
1086 const threadId = asThreadId("thread-feedback-unsupported-stopped");
1087 yield* provider.startSession(threadId, {
1088 provider: CLAUDE_AGENT_DRIVER,
1089 providerInstanceId: claudeAgentInstanceId,
1090 threadId,
1091 runtimeMode: "full-access",
1092 });
1093 yield* routing.claude.stopSession(threadId);
1094 routing.claude.startSession.mockClear();
1095
1096 const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip);
1097
1098 assert.instanceOf(error, ProviderValidationError);
1099 assert.include(error.issue, "does not support feedback uploads");
1100 assert.strictEqual(routing.claude.startSession.mock.calls.length, 0);
1101 }),
1102 );
1103
944 it.effect("appends attachment file paths to the turn input text", () =>1104 it.effect("appends attachment file paths to the turn input text", () =>
945 Effect.gen(function* () {1105 Effect.gen(function* () {
946 const provider = yield* ProviderService.ProviderService;1106 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+43
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 ProviderSendTurnInput,19 ProviderSendTurnInput,
20 ProviderSessionStartInput,20 ProviderSessionStartInput,
21 ProviderStopSessionInput,21 ProviderStopSessionInput,
22 ProviderUploadFeedbackInput,
22 type ProviderInstanceId,23 type ProviderInstanceId,
23 type ProviderDriverKind,24 type ProviderDriverKind,
24 type ProviderRuntimeEvent,25 type ProviderRuntimeEvent,
...@@ -1117,6 +1118,47 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1117,6 +1118,47 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1117 );1118 );
1118 });1119 });
11191120
1121 const uploadFeedback: ProviderServiceMethod<"uploadFeedback"> = Effect.fn("uploadFeedback")(
1122 function* (rawInput) {
1123 const input = yield* decodeInputOrValidationError({
1124 operation: "ProviderService.uploadFeedback",
1125 schema: ProviderUploadFeedbackInput,
1126 payload: rawInput,
1127 });
1128 let routed = yield* resolveRoutableSession({
1129 threadId: input.threadId,
1130 operation: "ProviderService.uploadFeedback",
1131 allowRecovery: false,
1132 });
1133 if (routed.adapter.uploadFeedback === undefined) {
1134 return yield* toValidationError(
1135 "ProviderService.uploadFeedback",
1136 `Provider '${routed.adapter.provider}' does not support feedback uploads.`,
1137 );
1138 }
1139 if (!routed.isActive) {
1140 routed = yield* resolveRoutableSession({
1141 threadId: input.threadId,
1142 operation: "ProviderService.uploadFeedback",
1143 allowRecovery: true,
1144 });
1145 }
1146 const uploadFeedback = routed.adapter.uploadFeedback;
1147 if (uploadFeedback === undefined) {
1148 return yield* toValidationError(
1149 "ProviderService.uploadFeedback",
1150 `Provider '${routed.adapter.provider}' does not support feedback uploads.`,
1151 );
1152 }
1153 yield* Effect.annotateCurrentSpan({
1154 "provider.operation": "upload-feedback",
1155 "provider.kind": routed.adapter.provider,
1156 "provider.thread_id": input.threadId,
1157 });
1158 return yield* uploadFeedback(input);
1159 },
1160 );
1161
1120 const runStopAll = Effect.fn("runStopAll")(function* () {1162 const runStopAll = Effect.fn("runStopAll")(function* () {
1121 const threadIds = yield* directory.listThreadIds();1163 const threadIds = yield* directory.listThreadIds();
1122 const currentAdapters = yield* getAdapterEntries;1164 const currentAdapters = yield* getAdapterEntries;
...@@ -1188,6 +1230,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1188,6 +1230,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1188 getCapabilities,1230 getCapabilities,
1189 getInstanceInfo,1231 getInstanceInfo,
1190 rollbackConversation,1232 rollbackConversation,
1233 uploadFeedback,
1191 // Each access creates a fresh PubSub subscription so that multiple1234 // Each access creates a fresh PubSub subscription so that multiple
1192 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each1235 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each
1193 // independently receive all runtime events.1236 // independently receive all runtime events.
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -184,6 +184,7 @@ describe("ProviderSessionReaper", () => {...@@ -184,6 +184,7 @@ describe("ProviderSessionReaper", () => {
184 });184 });
185 },185 },
186 rollbackConversation: () => unsupported(),186 rollbackConversation: () => unsupported(),
187 uploadFeedback: () => unsupported(),
187 streamEvents: Stream.empty,188 streamEvents: Stream.empty,
188 };189 };
189190
apps/server/src/provider/Services/CodexAdapter.ts+5-1
...@@ -16,4 +16,8 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts";...@@ -16,4 +16,8 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
16 * CodexAdapterShape — per-instance Codex adapter contract. Carries16 * CodexAdapterShape — per-instance Codex adapter contract. Carries
17 * a branded driver kind as the nominal discriminant.17 * a branded driver kind as the nominal discriminant.
18 */18 */
19export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {}19export interface CodexAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
20 readonly uploadFeedback: NonNullable<
21 ProviderAdapterShape<ProviderAdapterError>["uploadFeedback"]
22 >;
23}
apps/server/src/provider/Services/ProviderAdapter.ts+9
...@@ -16,6 +16,8 @@ import type {...@@ -16,6 +16,8 @@ import type {
16 ProviderSendTurnInput,16 ProviderSendTurnInput,
17 ProviderSession,17 ProviderSession,
18 ProviderSessionStartInput,18 ProviderSessionStartInput,
19 ProviderUploadFeedbackInput,
20 ProviderUploadFeedbackResult,
19 ThreadId,21 ThreadId,
20 ProviderTurnStartResult,22 ProviderTurnStartResult,
21 TurnId,23 TurnId,
...@@ -114,6 +116,13 @@ export interface ProviderAdapterShape<TError> {...@@ -114,6 +116,13 @@ export interface ProviderAdapterShape<TError> {
114 numTurns: number,116 numTurns: number,
115 ) => Effect.Effect<ProviderThreadSnapshot, TError>;117 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
116118
119 /**
120 * Upload a thread to the provider when the adapter supports feedback.
121 */
122 readonly uploadFeedback?: (
123 input: ProviderUploadFeedbackInput,
124 ) => Effect.Effect<ProviderUploadFeedbackResult, TError>;
125
117 /**126 /**
118 * Stop all sessions owned by this adapter.127 * Stop all sessions owned by this adapter.
119 */128 */
apps/server/src/provider/Services/ProviderService.ts+9
...@@ -21,6 +21,8 @@ import type {...@@ -21,6 +21,8 @@ import type {
21 ProviderSession,21 ProviderSession,
22 ProviderSessionStartInput,22 ProviderSessionStartInput,
23 ProviderStopSessionInput,23 ProviderStopSessionInput,
24 ProviderUploadFeedbackInput,
25 ProviderUploadFeedbackResult,
24 ThreadId,26 ThreadId,
25 ProviderTurnStartResult,27 ProviderTurnStartResult,
26} from "@t3tools/contracts";28} from "@t3tools/contracts";
...@@ -105,6 +107,13 @@ export interface ProviderServiceShape {...@@ -105,6 +107,13 @@ export interface ProviderServiceShape {
105 readonly numTurns: number;107 readonly numTurns: number;
106 }) => Effect.Effect<void, ProviderServiceError>;108 }) => Effect.Effect<void, ProviderServiceError>;
107109
110 /**
111 * Upload a thread and return the provider's shareable feedback identifier.
112 */
113 readonly uploadFeedback: (
114 input: ProviderUploadFeedbackInput,
115 ) => Effect.Effect<ProviderUploadFeedbackResult, ProviderServiceError>;
116
108 /**117 /**
109 * Canonical provider runtime event stream.118 * Canonical provider runtime event stream.
110 *119 *
apps/server/src/server.test.ts+80-12
...@@ -114,6 +114,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna...@@ -114,6 +114,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
114import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";114import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
115import { PersistenceSqlError } from "./persistence/Errors.ts";115import { PersistenceSqlError } from "./persistence/Errors.ts";
116import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";116import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
117import * as ProviderService from "./provider/Services/ProviderService.ts";
118import { ProviderAdapterRequestError } from "./provider/Errors.ts";
117import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";119import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
118import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";120import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
119import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";121import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
...@@ -387,6 +389,7 @@ const buildAppUnderTest = (options?: {...@@ -387,6 +389,7 @@ const buildAppUnderTest = (options?: {
387 layers?: {389 layers?: {
388 keybindings?: Partial<Keybindings.Keybindings["Service"]>;390 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
389 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;391 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
392 providerService?: Partial<ProviderService.ProviderService["Service"]>;
390 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;393 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
391 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;394 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;
392 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;395 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;
...@@ -629,18 +632,24 @@ const buildAppUnderTest = (options?: {...@@ -629,18 +632,24 @@ const buildAppUnderTest = (options?: {
629 }),632 }),
630 ),633 ),
631 Layer.provide(634 Layer.provide(
632 Layer.mock(ProviderRegistry.ProviderRegistry)({635 Layer.mergeAll(
633 getProviders: Effect.succeed([]),636 Layer.mock(ProviderRegistry.ProviderRegistry)({
634 refresh: () => Effect.succeed([]),637 getProviders: Effect.succeed([]),
635 refreshInstance: () => Effect.succeed([]),638 refresh: () => Effect.succeed([]),
636 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>639 refreshInstance: () => Effect.succeed([]),
637 Effect.succeed(640 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
638 makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),641 Effect.succeed(
639 ),642 makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
640 setProviderMaintenanceActionState: () => Effect.succeed([]),643 ),
641 streamChanges: Stream.empty,644 setProviderMaintenanceActionState: () => Effect.succeed([]),
642 ...options?.layers?.providerRegistry,645 streamChanges: Stream.empty,
643 }),646 ...options?.layers?.providerRegistry,
647 }),
648 Layer.mock(ProviderService.ProviderService)({
649 uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"),
650 ...options?.layers?.providerService,
651 }),
652 ),
644 ),653 ),
645 Layer.provide(654 Layer.provide(
646 Layer.mock(ServerSettings.ServerSettingsService)({655 Layer.mock(ServerSettings.ServerSettingsService)({
...@@ -4452,6 +4461,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4452,6 +4461,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4452 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4461 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4453 );4462 );
44544463
4464 it.effect("uploads Codex thread feedback through websocket rpc", () =>
4465 Effect.gen(function* () {
4466 const input = {
4467 threadId: ThreadId.make("thread-feedback"),
4468 reason: "The agent stopped early.",
4469 };
4470 const uploadFeedback = vi.fn<ProviderService.ProviderService["Service"]["uploadFeedback"]>(
4471 () => Effect.succeed({ feedbackId: "codex-thread-feedback" }),
4472 );
4473 yield* buildAppUnderTest({
4474 layers: {
4475 providerService: { uploadFeedback },
4476 },
4477 });
4478
4479 const wsUrl = yield* getWsServerUrl("/ws");
4480 const response = yield* Effect.scoped(
4481 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.providerUploadFeedback](input)),
4482 );
4483
4484 assert.deepStrictEqual(response, { feedbackId: "codex-thread-feedback" });
4485 assert.deepStrictEqual(uploadFeedback.mock.calls, [[input]]);
4486 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4487 );
4488
4489 it.effect("keeps feedback errors structured across websocket rpc", () =>
4490 Effect.gen(function* () {
4491 const threadId = ThreadId.make("thread-feedback-failure");
4492 yield* buildAppUnderTest({
4493 layers: {
4494 providerService: {
4495 uploadFeedback: () =>
4496 Effect.fail(
4497 new ProviderAdapterRequestError({
4498 provider: "codex",
4499 method: "feedback/upload",
4500 detail: "private provider detail",
4501 }),
4502 ),
4503 },
4504 },
4505 });
4506
4507 const wsUrl = yield* getWsServerUrl("/ws");
4508 const error = yield* Effect.scoped(
4509 withWsRpcClient(wsUrl, (client) =>
4510 client[WS_METHODS.providerUploadFeedback]({ threadId }).pipe(Effect.flip),
4511 ),
4512 );
4513
4514 assert.strictEqual(error._tag, "ProviderUploadFeedbackError");
4515 if (error._tag === "ProviderUploadFeedbackError") {
4516 assert.strictEqual(error.threadId, threadId);
4517 assert.strictEqual(error.message, `Failed to upload feedback for thread ${threadId}.`);
4518 assert.isDefined(error.cause);
4519 }
4520 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4521 );
4522
4455 it.effect("shares one preview automation broker across websocket sessions", () =>4523 it.effect("shares one preview automation broker across websocket sessions", () =>
4456 Effect.scoped(4524 Effect.scoped(
4457 Effect.gen(function* () {4525 Effect.gen(function* () {
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
...@@ -55,6 +55,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>...@@ -55,6 +55,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
55 getCapabilities: () => Effect.die("unused"),55 getCapabilities: () => Effect.die("unused"),
56 getInstanceInfo: () => Effect.die("unused"),56 getInstanceInfo: () => Effect.die("unused"),
57 rollbackConversation: () => Effect.die("unused"),57 rollbackConversation: () => Effect.die("unused"),
58 uploadFeedback: () => Effect.die("unused"),
58 streamEvents: Stream.empty,59 streamEvents: Stream.empty,
59 }) satisfies ProviderService.ProviderService["Service"];60 }) satisfies ProviderService.ProviderService["Service"];
6061
apps/server/src/ws.ts+17
...@@ -42,6 +42,7 @@ import {...@@ -42,6 +42,7 @@ import {
42 ProjectSearchContentsError,42 ProjectSearchContentsError,
43 ProjectSearchEntriesError,43 ProjectSearchEntriesError,
44 ProjectWriteFileError,44 ProjectWriteFileError,
45 ProviderUploadFeedbackError,
45 RelayClientInstallFailedError,46 RelayClientInstallFailedError,
46 type RelayClientInstallProgressEvent,47 type RelayClientInstallProgressEvent,
47 type ServerSelfUpdateError,48 type ServerSelfUpdateError,
...@@ -81,6 +82,7 @@ import {...@@ -81,6 +82,7 @@ import {
81 observeRpcStreamEffect as instrumentRpcStreamEffect,82 observeRpcStreamEffect as instrumentRpcStreamEffect,
82} from "./observability/RpcInstrumentation.ts";83} from "./observability/RpcInstrumentation.ts";
83import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";84import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
85import * as ProviderService from "./provider/Services/ProviderService.ts";
84import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";86import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
85import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";87import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
86import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";88import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
...@@ -432,6 +434,7 @@ const makeWsRpcLayer = (...@@ -432,6 +434,7 @@ const makeWsRpcLayer = (
432 const previewManager = yield* PreviewManager.PreviewManager;434 const previewManager = yield* PreviewManager.PreviewManager;
433 const portDiscovery = yield* PortScanner.PortDiscovery;435 const portDiscovery = yield* PortScanner.PortDiscovery;
434 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;436 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
437 const providerService = yield* ProviderService.ProviderService;
435 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;438 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
436 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;439 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
437 const config = yield* ServerConfig.ServerConfig;440 const config = yield* ServerConfig.ServerConfig;
...@@ -1534,6 +1537,20 @@ const makeWsRpcLayer = (...@@ -1534,6 +1537,20 @@ const makeWsRpcLayer = (
1534 ).pipe(Effect.map((providers) => ({ providers }))),1537 ).pipe(Effect.map((providers) => ({ providers }))),
1535 { "rpc.aggregate": "server" },1538 { "rpc.aggregate": "server" },
1536 ),1539 ),
1540 [WS_METHODS.providerUploadFeedback]: (input) =>
1541 observeRpcEffect(
1542 WS_METHODS.providerUploadFeedback,
1543 providerService.uploadFeedback(input).pipe(
1544 Effect.mapError(
1545 (cause) =>
1546 new ProviderUploadFeedbackError({
1547 threadId: input.threadId,
1548 cause,
1549 }),
1550 ),
1551 ),
1552 { "rpc.aggregate": "provider" },
1553 ),
1537 [WS_METHODS.serverUpdateProvider]: (input) =>1554 [WS_METHODS.serverUpdateProvider]: (input) =>
1538 observeRpcEffect(1555 observeRpcEffect(
1539 WS_METHODS.serverUpdateProvider,1556 WS_METHODS.serverUpdateProvider,
apps/web/src/components/ChatView.tsx+141-5
...@@ -33,6 +33,12 @@ import {...@@ -33,6 +33,12 @@ import {
33 effectiveSnoozed,33 effectiveSnoozed,
34 threadWokeAt,34 threadWokeAt,
35} from "@t3tools/client-runtime/state/thread-settled";35} from "@t3tools/client-runtime/state/thread-settled";
36import {
37 codexFeedbackMessage,
38 parseCodexFeedbackCommand,
39 submitCodexFeedback,
40 type CodexFeedbackSubmission,
41} from "@t3tools/client-runtime/state/threads";
36import {42import {
37 parseScopedThreadKey,43 parseScopedThreadKey,
38 scopedThreadKey,44 scopedThreadKey,
...@@ -124,6 +130,7 @@ import {...@@ -124,6 +130,7 @@ import {
124 type TurnDiffSummary,130 type TurnDiffSummary,
125} from "../types";131} from "../types";
126import { useTheme } from "../hooks/useTheme";132import { useTheme } from "../hooks/useTheme";
133import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
127import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";134import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
128import { isCommandPaletteOpen } from "../commandPaletteBus";135import { isCommandPaletteOpen } from "../commandPaletteBus";
129import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";136import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
...@@ -1268,6 +1275,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1268,6 +1275,9 @@ function ChatViewContent(props: ChatViewProps) {
1268 reportFailure: false,1275 reportFailure: false,
1269 });1276 });
1270 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });1277 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1278 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
1279 reportFailure: false,
1280 });
1271 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {1281 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
1272 reportFailure: false,1282 reportFailure: false,
1273 });1283 });
...@@ -1383,6 +1393,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1383,6 +1393,16 @@ function ChatViewContent(props: ChatViewProps) {
1383 const [showScrollToBottom, setShowScrollToBottom] = useState(false);1393 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
1384 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);1394 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
1385 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);1395 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
1396 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
1397 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
1398 >({});
1399 const feedbackSubmissions = useMemo(
1400 () => feedbackSubmissionsByThreadKey[routeThreadKey] ?? [],
1401 [feedbackSubmissionsByThreadKey, routeThreadKey],
1402 );
1403 const feedbackUploading = feedbackSubmissions.some(
1404 (submission) => submission.status === "uploading",
1405 );
1386 const optimisticUserMessagesRef = useRef(optimisticUserMessages);1406 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
1387 optimisticUserMessagesRef.current = optimisticUserMessages;1407 optimisticUserMessagesRef.current = optimisticUserMessages;
1388 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<1408 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
...@@ -1444,6 +1464,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1444,6 +1464,7 @@ function ChatViewContent(props: ChatViewProps) {
1444 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});1464 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
1445 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});1465 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
1446 const sendInFlightRef = useRef(false);1466 const sendInFlightRef = useRef(false);
1467 const feedbackUploadsInFlightRef = useRef(new Set<string>());
1447 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});1468 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});
14481469
1449 useLayoutEffect(() => {1470 useLayoutEffect(() => {
...@@ -2603,16 +2624,29 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2603,16 +2624,29 @@ function ChatViewContent(props: ChatViewProps) {
2603 return changed ? { ...message, attachments } : message;2624 return changed ? { ...message, attachments } : message;
2604 });2625 });
26052626
2606 if (optimisticUserMessages.length === 0) {2627 const localMessages = [
2628 ...optimisticUserMessages,
2629 ...feedbackSubmissions.flatMap((submission) =>
2630 submission.status === "interrupted"
2631 ? []
2632 : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
2633 ),
2634 ];
2635 if (localMessages.length === 0) {
2607 return serverMessagesWithPreviewHandoff;2636 return serverMessagesWithPreviewHandoff;
2608 }2637 }
2609 const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id));2638 const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id));
2610 const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));2639 const pendingMessages = localMessages.filter((message) => !serverIds.has(message.id));
2611 if (pendingMessages.length === 0) {2640 if (pendingMessages.length === 0) {
2612 return serverMessagesWithPreviewHandoff;2641 return serverMessagesWithPreviewHandoff;
2613 }2642 }
2614 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];2643 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
2615 }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);2644 }, [
2645 attachmentPreviewHandoffByMessageId,
2646 displayServerMessages,
2647 feedbackSubmissions,
2648 optimisticUserMessages,
2649 ]);
2616 const timelineEntries = useMemo(2650 const timelineEntries = useMemo(
2617 () =>2651 () =>
2618 deriveTimelineEntries(2652 deriveTimelineEntries(
...@@ -5061,7 +5095,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5061,7 +5095,8 @@ function ChatViewContent(props: ChatViewProps) {
5061 isSendBusy ||5095 isSendBusy ||
5062 isConnecting ||5096 isConnecting ||
5063 threadDetailLoading ||5097 threadDetailLoading ||
5064 sendInFlightRef.current5098 sendInFlightRef.current ||
5099 feedbackUploadsInFlightRef.current.has(routeThreadKey)
5065 ) {5100 ) {
5066 notifyDirectAnnotationAttached();5101 notifyDirectAnnotationAttached();
5067 return;5102 return;
...@@ -5136,6 +5171,101 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5136,6 +5171,101 @@ function ChatViewContent(props: ChatViewProps) {
5136 composerPreviewAnnotations.length +5171 composerPreviewAnnotations.length +
5137 composerReviewComments.length,5172 composerReviewComments.length,
5138 });5173 });
5174 const feedbackCommand =
5175 ctxSelectedProvider === "codex" &&
5176 composerImages.length === 0 &&
5177 sendableComposerTerminalContexts.length === 0 &&
5178 composerElementContexts.length === 0 &&
5179 composerPreviewAnnotations.length === 0 &&
5180 composerReviewComments.length === 0
5181 ? parseCodexFeedbackCommand(trimmed)
5182 : null;
5183 if (feedbackCommand) {
5184 if (!isServerThread || activeThread.session === null) {
5185 toastManager.add(
5186 stackedThreadToast({
5187 type: "warning",
5188 title: "Start a Codex thread first",
5189 description: "Send a message before you submit feedback.",
5190 }),
5191 );
5192 return;
5193 }
5194 feedbackUploadsInFlightRef.current.add(routeThreadKey);
5195 const result = await submitCodexFeedback({
5196 submission: {
5197 id: newMessageId(),
5198 command: trimmed,
5199 createdAt: new Date().toISOString(),
5200 },
5201 clearDraft: () => {
5202 promptRef.current = "";
5203 clearComposerDraftContent(composerDraftTarget);
5204 composerRef.current?.resetCursorState();
5205 scrollToEnd();
5206 },
5207 onUpdate: (submission) => {
5208 setFeedbackSubmissionsByThreadKey((current) => {
5209 const existing = current[routeThreadKey] ?? [];
5210 const found = existing.some((entry) => entry.id === submission.id);
5211 return {
5212 ...current,
5213 [routeThreadKey]: found
5214 ? existing.map((entry) => (entry.id === submission.id ? submission : entry))
5215 : [...existing, submission],
5216 };
5217 });
5218 },
5219 upload: () =>
5220 uploadThreadFeedback({
5221 environmentId,
5222 input: {
5223 threadId: activeThread.id,
5224 ...feedbackCommand,
5225 },
5226 }),
5227 }).finally(() => {
5228 feedbackUploadsInFlightRef.current.delete(routeThreadKey);
5229 });
5230 if (result._tag === "Failure") {
5231 if (!isAtomCommandInterrupted(result)) {
5232 toastManager.add(
5233 stackedThreadToast({
5234 type: "error",
5235 title: "Could not send feedback to OpenAI",
5236 description: chatActionErrorMessage(squashAtomCommandFailure(result)),
5237 }),
5238 );
5239 }
5240 return;
5241 }
5242 const feedbackId = result.value.feedbackId;
5243 toastManager.add(
5244 stackedThreadToast({
5245 type: "success",
5246 title: "Feedback sent to OpenAI",
5247 description: `Thread ID: ${feedbackId}`,
5248 timeout: 0,
5249 actionProps: {
5250 children: "Copy ID",
5251 onClick: () => {
5252 void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch(
5253 (error: unknown) => {
5254 toastManager.add(
5255 stackedThreadToast({
5256 type: "error",
5257 title: "Could not copy thread ID",
5258 description: chatActionErrorMessage(error),
5259 }),
5260 );
5261 },
5262 );
5263 },
5264 },
5265 }),
5266 );
5267 return;
5268 }
5139 if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {5269 if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {
5140 const followUp = resolvePlanFollowUpSubmission({5270 const followUp = resolvePlanFollowUpSubmission({
5141 draftText: trimmed,5271 draftText: trimmed,
...@@ -6633,7 +6763,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6633,7 +6763,13 @@ function ChatViewContent(props: ChatViewProps) {
6633 phase={phase}6763 phase={phase}
6634 isConnecting={isConnecting}6764 isConnecting={isConnecting}
6635 isSendBusy={isSendBusy}6765 isSendBusy={isSendBusy}
6636 sendDisabledReason={threadDetailLoading ? "Messages loading" : null}6766 sendDisabledReason={
6767 feedbackUploading
6768 ? "Sending feedback"
6769 : threadDetailLoading
6770 ? "Messages loading"
6771 : null
6772 }
6637 isPreparingWorktree={isPreparingWorktree}6773 isPreparingWorktree={isPreparingWorktree}
6638 externalDrawerAttached={externalComposerDrawerAttached}6774 externalDrawerAttached={externalComposerDrawerAttached}
6639 environmentUnavailable={activeEnvironmentUnavailableState}6775 environmentUnavailable={activeEnvironmentUnavailableState}
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+9-2
...@@ -87,7 +87,7 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent:...@@ -87,7 +87,7 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent:
87 );87 );
88}88}
8989
90function renderSendButton() {90function renderSendButton(sendDisabledReason: string | null = null) {
91 return renderToStaticMarkup(91 return renderToStaticMarkup(
92 createElement(ComposerPrimaryActions, {92 createElement(ComposerPrimaryActions, {
93 compact: true,93 compact: true,
...@@ -96,7 +96,7 @@ function renderSendButton() {...@@ -96,7 +96,7 @@ function renderSendButton() {
96 showPlanFollowUpPrompt: false,96 showPlanFollowUpPrompt: false,
97 promptHasText: true,97 promptHasText: true,
98 isSendBusy: false,98 isSendBusy: false,
99 sendDisabledReason: null,99 sendDisabledReason,
100 isConnecting: false,100 isConnecting: false,
101 isEnvironmentUnavailable: false,101 isEnvironmentUnavailable: false,
102 isPreparingWorktree: false,102 isPreparingWorktree: false,
...@@ -204,6 +204,13 @@ describe("formatPendingPrimaryActionLabel", () => {...@@ -204,6 +204,13 @@ describe("formatPendingPrimaryActionLabel", () => {
204});204});
205205
206describe("ComposerPrimaryActions", () => {206describe("ComposerPrimaryActions", () => {
207 it("disables and labels the send button while feedback is uploading", () => {
208 const markup = renderSendButton("Sending feedback");
209
210 expect(markup).toContain("disabled");
211 expect(markup).toContain('aria-label="Sending feedback"');
212 });
213
207 it("offers Stop generation while a running turn is waiting for user input", () => {214 it("offers Stop generation while a running turn is waiting for user input", () => {
208 expect(renderPendingActions(true)).toContain('aria-label="Stop generation"');215 expect(renderPendingActions(true)).toContain('aria-label="Stop generation"');
209 });216 });
apps/web/src/components/chat/MessagesTimeline.test.tsx+56
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
2import { createRef, type ReactNode, type Ref } from "react";3import { createRef, type ReactNode, type Ref } from "react";
3import { renderToStaticMarkup } from "react-dom/server";4import { renderToStaticMarkup } from "react-dom/server";
4import { beforeAll, describe, expect, it, vi } from "vite-plus/test";5import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
...@@ -236,6 +237,61 @@ function buildAssistantTimelineEntry(text: string) {...@@ -236,6 +237,61 @@ function buildAssistantTimelineEntry(text: string) {
236}237}
237238
238describe("MessagesTimeline", () => {239describe("MessagesTimeline", () => {
240 it("renders a feedback command and its pending response as normal thread messages", () => {
241 const submission = {
242 id: MessageId.make("feedback-command"),
243 command: "/feedback The agent stopped early.",
244 createdAt: MESSAGE_CREATED_AT,
245 status: "uploading" as const,
246 };
247 const messages = [
248 codexFeedbackMessage(submission),
249 codexFeedbackMessage(submission, "assistant"),
250 ];
251 const markup = renderToStaticMarkup(
252 <MessagesTimeline
253 {...buildProps()}
254 timelineEntries={messages.map((message) => ({
255 id: message.id,
256 kind: "message" as const,
257 createdAt: message.createdAt,
258 message,
259 }))}
260 />,
261 );
262
263 expect(markup).toContain("/feedback The agent stopped early.");
264 expect(markup).toContain("Sending feedback to OpenAI...");
265 });
266
267 it("renders the returned Codex thread ID in the feedback response", () => {
268 const submission = {
269 id: MessageId.make("feedback-command"),
270 command: "/feedback The agent stopped early.",
271 createdAt: MESSAGE_CREATED_AT,
272 status: "sent" as const,
273 feedbackId: "codex-thread-1",
274 };
275 const messages = [
276 codexFeedbackMessage(submission),
277 codexFeedbackMessage(submission, "assistant"),
278 ];
279 const markup = renderToStaticMarkup(
280 <MessagesTimeline
281 {...buildProps()}
282 timelineEntries={messages.map((message) => ({
283 id: message.id,
284 kind: "message" as const,
285 createdAt: message.createdAt,
286 message,
287 }))}
288 />,
289 );
290
291 expect(markup).toContain("Feedback sent to OpenAI.");
292 expect(markup).toContain("codex-thread-1");
293 });
294
239 it("renders the worked-for row at assistant response text size", () => {295 it("renders the worked-for row at assistant response text size", () => {
240 const turnId = TurnId.make("turn-with-fold");296 const turnId = TurnId.make("turn-with-fold");
241 const assistantEntry = buildAssistantTimelineEntry("Done.");297 const assistantEntry = buildAssistantTimelineEntry("Done.");
docs/user/providers-codex.md+6
...@@ -28,6 +28,12 @@ Log in with Codex normally:...@@ -28,6 +28,12 @@ Log in with Codex normally:
28codex login28codex login
29```29```
3030
31## Send feedback to OpenAI
32
33In an existing Codex thread, send `/feedback` or `/feedback` followed by a description of the
34issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy
35and share with OpenAI employees.
36
31## I Want Work And Personal Codex Accounts37## I Want Work And Personal Codex Accounts
3238
33Use one real Codex home and one shadow home.39Use one real Codex home and one shadow home.
packages/client-runtime/src/state/threadCommands.ts+12-1
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import * as Crypto from "effect/Crypto";1import * as Crypto from "effect/Crypto";
2import { Atom } from "effect/unstable/reactivity";2import { Atom } from "effect/unstable/reactivity";
3import { WS_METHODS } from "@t3tools/contracts";
34
4import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts";5import {
6 createAtomCommandScheduler,
7 createEnvironmentCommand,
8 createEnvironmentRpcCommand,
9} from "./runtime.ts";
5import {10import {
6 type ArchiveThreadInput,11 type ArchiveThreadInput,
7 type CreateThreadInput,12 type CreateThreadInput,
...@@ -199,5 +204,11 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -199,5 +204,11 @@ export function createThreadEnvironmentAtoms<R, E>(
199 scheduler,204 scheduler,
200 concurrency,205 concurrency,
201 }),206 }),
207 uploadFeedback: createEnvironmentRpcCommand(runtime, {
208 label: "environment-data:commands:thread:upload-feedback",
209 tag: WS_METHODS.providerUploadFeedback,
210 scheduler,
211 concurrency,
212 }),
202 };213 };
203}214}
packages/client-runtime/src/state/threadFeedback.test.ts created+162
...@@ -0,0 +1,162 @@
1import { describe, expect, it } from "vite-plus/test";
2import { MessageId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";
5
6import {
7 codexFeedbackMessage,
8 parseCodexFeedbackCommand,
9 submitCodexFeedback,
10 type CodexFeedbackSubmission,
11} from "./threadFeedback.ts";
12
13describe("parseCodexFeedbackCommand", () => {
14 it("accepts feedback without a reason", () => {
15 expect(parseCodexFeedbackCommand(" /feedback ")).toEqual({});
16 });
17
18 it("preserves a feedback description", () => {
19 expect(parseCodexFeedbackCommand("/feedback The agent stopped early.")).toEqual({
20 reason: "The agent stopped early.",
21 });
22 });
23
24 it("accepts mixed-case feedback commands", () => {
25 expect(parseCodexFeedbackCommand("/Feedback Retry failed.")).toEqual({
26 reason: "Retry failed.",
27 });
28 });
29
30 it("ignores other slash commands and ordinary messages", () => {
31 expect(parseCodexFeedbackCommand("/feedback-status")).toBeNull();
32 expect(parseCodexFeedbackCommand("Please send /feedback")).toBeNull();
33 });
34});
35
36describe("submitCodexFeedback", () => {
37 const submission = {
38 id: MessageId.make("feedback-message-1"),
39 command: "/feedback The agent stopped early.",
40 createdAt: "2026-08-23T00:00:00.000Z",
41 } as const;
42
43 it("shows the command and clears the draft before the upload finishes", async () => {
44 let draft: string = submission.command;
45 let finishUpload:
46 | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void)
47 | undefined;
48 const states: CodexFeedbackSubmission[] = [];
49 const upload = new Promise<ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>>(
50 (resolve) => {
51 finishUpload = resolve;
52 },
53 );
54
55 const result = submitCodexFeedback({
56 submission,
57 clearDraft: () => {
58 draft = "";
59 },
60 onUpdate: (state) => states.push(state),
61 upload: () => {
62 expect(draft).toBe("");
63 return upload;
64 },
65 });
66
67 expect(draft).toBe("");
68 expect(states).toEqual([{ ...submission, status: "uploading" }]);
69 expect(codexFeedbackMessage(states[0]!)).toMatchObject({
70 id: submission.id,
71 role: "user",
72 text: submission.command,
73 });
74 expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe(
75 "Sending feedback to OpenAI...",
76 );
77
78 draft = "Keep this newer message.";
79 finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));
80 await result;
81
82 expect(draft).toBe("Keep this newer message.");
83 expect(states.at(-1)).toEqual({
84 ...submission,
85 status: "sent",
86 feedbackId: "codex-thread-1",
87 });
88 expect(codexFeedbackMessage(states.at(-1)!, "assistant").text).toContain("codex-thread-1");
89 });
90
91 it("records a failed upload without losing its user-facing error", async () => {
92 const states: CodexFeedbackSubmission[] = [];
93 const error = new Error("Upload rejected.");
94
95 await submitCodexFeedback({
96 submission,
97 clearDraft: () => undefined,
98 onUpdate: (state) => states.push(state),
99 upload: () =>
100 Promise.resolve(AsyncResult.failure<{ feedbackId: string }, Error>(Cause.fail(error))),
101 });
102
103 expect(states.at(-1)).toEqual({
104 ...submission,
105 status: "failed",
106 errorMessage: "Upload rejected.",
107 });
108 });
109
110 it("marks interruptions without reporting them as upload failures", async () => {
111 const states: CodexFeedbackSubmission[] = [];
112
113 await submitCodexFeedback({
114 submission,
115 clearDraft: () => undefined,
116 onUpdate: (state) => states.push(state),
117 upload: () =>
118 Promise.resolve(AsyncResult.failure<{ feedbackId: string }, never>(Cause.interrupt(1))),
119 });
120
121 expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" });
122 });
123
124 it("lets another feedback submission finish while the first remains in flight", async () => {
125 let finishFirstUpload:
126 | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void)
127 | undefined;
128 const firstUpload = new Promise<ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>>(
129 (resolve) => {
130 finishFirstUpload = resolve;
131 },
132 );
133 const firstStates: CodexFeedbackSubmission[] = [];
134 const secondStates: CodexFeedbackSubmission[] = [];
135
136 const first = submitCodexFeedback({
137 submission,
138 clearDraft: () => undefined,
139 onUpdate: (state) => firstStates.push(state),
140 upload: () => firstUpload,
141 });
142 const second = await submitCodexFeedback({
143 submission: {
144 ...submission,
145 id: MessageId.make("feedback-message-2"),
146 },
147 clearDraft: () => undefined,
148 onUpdate: (state) => secondStates.push(state),
149 upload: () => Promise.resolve(AsyncResult.success({ feedbackId: "codex-thread-2" })),
150 });
151
152 expect(firstStates.at(-1)?.status).toBe("uploading");
153 expect(second._tag).toBe("Success");
154 expect(secondStates.at(-1)).toMatchObject({
155 status: "sent",
156 feedbackId: "codex-thread-2",
157 });
158
159 finishFirstUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));
160 await first;
161 });
162});
packages/client-runtime/src/state/threadFeedback.ts created+87
...@@ -0,0 +1,87 @@
1import {
2 MessageId,
3 type OrchestrationMessage,
4 type ProviderUploadFeedbackResult,
5} from "@t3tools/contracts";
6
7import {
8 isAtomCommandInterrupted,
9 squashAtomCommandFailure,
10 type AtomCommandResult,
11} from "./runtime.ts";
12
13type CodexFeedbackSubmissionDetails = {
14 readonly id: MessageId;
15 readonly command: string;
16 readonly createdAt: string;
17};
18
19export type CodexFeedbackSubmission = CodexFeedbackSubmissionDetails &
20 (
21 | { readonly status: "uploading" | "interrupted" }
22 | { readonly status: "sent"; readonly feedbackId: string }
23 | { readonly status: "failed"; readonly errorMessage: string }
24 );
25
26export function parseCodexFeedbackCommand(text: string): { readonly reason?: string } | null {
27 const match = /^\/feedback(?:\s+([\s\S]*))?$/iu.exec(text.trim());
28 if (!match) {
29 return null;
30 }
31 const reason = match[1]?.trim();
32 return reason ? { reason } : {};
33}
34
35export function codexFeedbackMessage(
36 submission: CodexFeedbackSubmission,
37 role: "user" | "assistant" = "user",
38): OrchestrationMessage {
39 const text =
40 role === "user"
41 ? submission.command
42 : submission.status === "sent"
43 ? `Feedback sent to OpenAI.\n\nThread ID: \`${submission.feedbackId}\``
44 : submission.status === "failed"
45 ? `Could not send feedback to OpenAI.\n\n${submission.errorMessage}`
46 : "Sending feedback to OpenAI...";
47
48 return {
49 id: role === "user" ? submission.id : MessageId.make(`${submission.id}:feedback`),
50 role,
51 text,
52 turnId: null,
53 streaming: false,
54 createdAt: submission.createdAt,
55 updatedAt: submission.createdAt,
56 };
57}
58
59export async function submitCodexFeedback<E>(input: {
60 readonly submission: CodexFeedbackSubmissionDetails;
61 readonly clearDraft: () => void;
62 readonly onUpdate: (submission: CodexFeedbackSubmission) => void;
63 readonly upload: () => Promise<AtomCommandResult<ProviderUploadFeedbackResult, E>>;
64}): Promise<AtomCommandResult<ProviderUploadFeedbackResult, E>> {
65 input.onUpdate({ ...input.submission, status: "uploading" });
66 input.clearDraft();
67
68 const result = await input.upload();
69 if (result._tag === "Success") {
70 input.onUpdate({
71 ...input.submission,
72 status: "sent",
73 feedbackId: result.value.feedbackId,
74 });
75 } else if (isAtomCommandInterrupted(result)) {
76 input.onUpdate({ ...input.submission, status: "interrupted" });
77 } else {
78 const error = squashAtomCommandFailure(result);
79 input.onUpdate({
80 ...input.submission,
81 status: "failed",
82 errorMessage: error instanceof Error ? error.message : "An error occurred.",
83 });
84 }
85
86 return result;
87}
packages/client-runtime/src/state/threads.ts+1
...@@ -729,6 +729,7 @@ export * from "./checkpointDiff.ts";...@@ -729,6 +729,7 @@ export * from "./checkpointDiff.ts";
729export * from "./threadSnapshotHttp.ts";729export * from "./threadSnapshotHttp.ts";
730export * from "./composerPathSearch.ts";730export * from "./composerPathSearch.ts";
731export * from "./threadCommands.ts";731export * from "./threadCommands.ts";
732export * from "./threadFeedback.ts";
732export * from "./threadDetail.ts";733export * from "./threadDetail.ts";
733export * from "./threadReducer.ts";734export * from "./threadReducer.ts";
734export * from "./threadShell.ts";735export * from "./threadShell.ts";
packages/contracts/src/provider.test.ts+39
...@@ -1,17 +1,23 @@...@@ -1,17 +1,23 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import { ThreadId } from "./baseSchemas.ts";
4import {5import {
5 ProviderEvent,6 ProviderEvent,
6 ProviderSendTurnInput,7 ProviderSendTurnInput,
7 ProviderSession,8 ProviderSession,
8 ProviderSessionStartInput,9 ProviderSessionStartInput,
10 ProviderUploadFeedbackError,
11 ProviderUploadFeedbackInput,
12 ProviderUploadFeedbackResult,
9} from "./provider.ts";13} from "./provider.ts";
1014
11const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);15const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
12const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);16const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
13const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession);17const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession);
14const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent);18const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent);
19const decodeProviderUploadFeedbackInput = Schema.decodeUnknownSync(ProviderUploadFeedbackInput);
20const decodeProviderUploadFeedbackResult = Schema.decodeUnknownSync(ProviderUploadFeedbackResult);
1521
16function getOptionValue(22function getOptionValue(
17 options: ReadonlyArray<{ id: string; value: unknown }> | undefined,23 options: ReadonlyArray<{ id: string; value: unknown }> | undefined,
...@@ -153,6 +159,39 @@ describe("ProviderSendTurnInput", () => {...@@ -153,6 +159,39 @@ describe("ProviderSendTurnInput", () => {
153 });159 });
154});160});
155161
162describe("provider feedback", () => {
163 it("accepts a thread and an optional feedback reason", () => {
164 expect(
165 decodeProviderUploadFeedbackInput({
166 threadId: "thread-1",
167 reason: "The agent stopped early.",
168 }),
169 ).toEqual({ threadId: "thread-1", reason: "The agent stopped early." });
170 expect(decodeProviderUploadFeedbackInput({ threadId: "thread-1" })).toEqual({
171 threadId: "thread-1",
172 });
173 });
174
175 it("returns the shareable Codex feedback identifier", () => {
176 expect(decodeProviderUploadFeedbackResult({ feedbackId: "provider-thread-1" })).toEqual({
177 feedbackId: "provider-thread-1",
178 });
179 });
180
181 it("keeps the failed thread and original cause without exposing upstream text", () => {
182 const cause = new Error("provider request secret");
183 const error = new ProviderUploadFeedbackError({
184 threadId: ThreadId.make("thread-1"),
185 cause,
186 });
187
188 expect(error.threadId).toBe("thread-1");
189 expect(error.cause).toBe(cause);
190 expect(error.message).toBe("Failed to upload feedback for thread thread-1.");
191 expect(error.message).not.toContain("provider request secret");
192 });
193});
194
156describe("providerInstanceId routing key (slice-2 invariant)", () => {195describe("providerInstanceId routing key (slice-2 invariant)", () => {
157 it("decodes a ProviderSessionStartInput without providerInstanceId (legacy producer)", () => {196 it("decodes a ProviderSessionStartInput without providerInstanceId (legacy producer)", () => {
158 const parsed = decodeProviderSessionStartInput({197 const parsed = decodeProviderSessionStartInput({
packages/contracts/src/provider.ts+23
...@@ -110,6 +110,29 @@ export const ProviderRespondToUserInputInput = Schema.Struct({...@@ -110,6 +110,29 @@ export const ProviderRespondToUserInputInput = Schema.Struct({
110});110});
111export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;111export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type;
112112
113export const ProviderUploadFeedbackInput = Schema.Struct({
114 threadId: ThreadId,
115 reason: Schema.optional(TrimmedNonEmptyString),
116});
117export type ProviderUploadFeedbackInput = typeof ProviderUploadFeedbackInput.Type;
118
119export const ProviderUploadFeedbackResult = Schema.Struct({
120 feedbackId: TrimmedNonEmptyString,
121});
122export type ProviderUploadFeedbackResult = typeof ProviderUploadFeedbackResult.Type;
123
124export class ProviderUploadFeedbackError extends Schema.TaggedErrorClass<ProviderUploadFeedbackError>()(
125 "ProviderUploadFeedbackError",
126 {
127 threadId: ThreadId,
128 cause: Schema.optional(Schema.Defect()),
129 },
130) {
131 override get message(): string {
132 return `Failed to upload feedback for thread ${this.threadId}.`;
133 }
134}
135
113const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);136const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);
114137
115export const ProviderEvent = Schema.Struct({138export const ProviderEvent = Schema.Struct({
packages/contracts/src/rpc.ts+15
...@@ -66,6 +66,11 @@ import {...@@ -66,6 +66,11 @@ import {
66 OrchestrationRpcSchemas,66 OrchestrationRpcSchemas,
67 OrchestrationGetWorkflowScriptError,67 OrchestrationGetWorkflowScriptError,
68} from "./orchestration.ts";68} from "./orchestration.ts";
69import {
70 ProviderUploadFeedbackError,
71 ProviderUploadFeedbackInput,
72 ProviderUploadFeedbackResult,
73} from "./provider.ts";
69import { ProviderInstanceId } from "./providerInstance.ts";74import { ProviderInstanceId } from "./providerInstance.ts";
70import {75import {
71 PullRequestActionInput,76 PullRequestActionInput,
...@@ -211,6 +216,9 @@ export const WS_METHODS = {...@@ -211,6 +216,9 @@ export const WS_METHODS = {
211 filesystemBrowse: "filesystem.browse",216 filesystemBrowse: "filesystem.browse",
212 assetsCreateUrl: "assets.createUrl",217 assetsCreateUrl: "assets.createUrl",
213218
219 // Provider methods
220 providerUploadFeedback: "provider.uploadFeedback",
221
214 // VCS methods222 // VCS methods
215 vcsPull: "vcs.pull",223 vcsPull: "vcs.pull",
216 vcsRefreshStatus: "vcs.refreshStatus",224 vcsRefreshStatus: "vcs.refreshStatus",
...@@ -665,6 +673,12 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {...@@ -665,6 +673,12 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
665 error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),673 error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),
666});674});
667675
676export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, {
677 payload: ProviderUploadFeedbackInput,
678 success: ProviderUploadFeedbackResult,
679 error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]),
680});
681
668export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {682export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
669 payload: VcsStatusInput,683 payload: VcsStatusInput,
670 success: VcsStatusStreamEvent,684 success: VcsStatusStreamEvent,
...@@ -1034,6 +1048,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1034,6 +1048,7 @@ export const WsRpcGroup = RpcGroup.make(
1034 WsShellOpenInEditorRpc,1048 WsShellOpenInEditorRpc,
1035 WsFilesystemBrowseRpc,1049 WsFilesystemBrowseRpc,
1036 WsAssetsCreateUrlRpc,1050 WsAssetsCreateUrlRpc,
1051 WsProviderUploadFeedbackRpc,
1037 WsSubscribeVcsStatusRpc,1052 WsSubscribeVcsStatusRpc,
1038 WsVcsPullRpc,1053 WsVcsPullRpc,
1039 WsVcsRefreshStatusRpc,1054 WsVcsRefreshStatusRpc,