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
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+67
......@@ -1,4 +1,5 @@
11import { describe, expect, it } from "vite-plus/test";
2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
23
34import {
45 EventId,
......@@ -22,6 +23,34 @@ import {
2223 type ThreadFeedEntry,
2324} 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
2554const singleSelectQuestion = {
2655 id: "runtime",
2756 header: "Runtime",
......@@ -151,6 +180,44 @@ function makeThread(
151180}
152181
153182describe("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+5-1
......@@ -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+105-7
......@@ -1,5 +1,7 @@
11import { 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
46import {
57 CommandId,
......@@ -11,6 +13,13 @@ import {
1113 type ThreadId,
1214} from "@t3tools/contracts";
1315import { 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";
1423import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
1524
1625import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
......@@ -21,6 +30,7 @@ import {
2130} from "../lib/composerImages";
2231import type { DraftComposerImageAttachment } from "../lib/composerImages";
2332import { scopedThreadKey } from "../lib/scopedEntities";
33import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
2434import { buildThreadFeed } from "../lib/threadActivity";
2535import { appAtomRegistry } from "../state/atom-registry";
2636import {
......@@ -41,6 +51,8 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail";
4151import { useThreadSelection } from "../state/use-thread-selection";
4252import { enqueueThreadOutboxMessage } from "./thread-outbox";
4353import { useThreadOutboxMessages } from "./use-thread-outbox";
54import { threadEnvironment } from "./threads";
55import { useAtomCommand } from "./use-atom-command";
4456
4557export function appendReviewCommentToDraft(input: {
4658 readonly environmentId: EnvironmentId;
......@@ -74,10 +86,16 @@ export function useThreadDraftForThread(input: {
7486}
7587
7688export 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+1
......@@ -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+6
......@@ -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+1
......@@ -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+1
......@@ -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+1
......@@ -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+1
......@@ -125,6 +125,7 @@ function createProviderServiceHarness() {
125125 });
126126 },
127127 rollbackConversation: () => unsupported(),
128 uploadFeedback: () => unsupported(),
128129 get streamEvents() {
129130 return Stream.fromPubSub(runtimeEventPubSub);
130131 },
apps/server/src/provider/Layers/CodexAdapter.test.ts+44
......@@ -104,6 +104,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
104104 }),
105105 );
106106
107 public readonly uploadFeedbackImpl = vi.fn((_reason?: string) =>
108 Promise.resolve({ threadId: "provider-thread-1" }),
109 );
110
107111 public readonly respondToRequestImpl = vi.fn(
108112 (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise<void> =>
109113 Promise.resolve(undefined),
......@@ -142,6 +146,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
142146 return Effect.promise(() => this.rollbackThreadImpl(numTurns));
143147 }
144148
149 uploadFeedback(reason?: string) {
150 return Effect.promise(() => this.uploadFeedbackImpl(reason));
151 }
152
145153 respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) {
146154 return Effect.promise(() => this.respondToRequestImpl(requestId, decision));
147155 }
......@@ -328,6 +336,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
328336 }),
329337 );
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
331375 it.effect("maps codex model options before sending a turn", () =>
332376 Effect.gen(function* () {
333377 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+12
......@@ -1891,6 +1891,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
18911891 );
18921892 };
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
18941905 const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) =>
18951906 requireSession(threadId).pipe(
18961907 Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)),
......@@ -1978,6 +1989,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
19781989 interruptTurn,
19791990 readThread,
19801991 rollbackThread,
1992 uploadFeedback,
19811993 respondToRequest,
19821994 respondToUserInput,
19831995 stopSession,
apps/server/src/provider/Layers/CodexProvider.ts+7
......@@ -607,6 +607,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
607607 checkedAt,
608608 models: snapshot.models,
609609 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 ],
610617 probe: {
611618 installed: true,
612619 version: snapshot.version ?? null,
apps/server/src/provider/Layers/CodexSessionRuntime.ts+13
......@@ -142,6 +142,9 @@ export interface CodexSessionRuntimeShape {
142142 readonly rollbackThread: (
143143 numTurns: number,
144144 ) => Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;
145 readonly uploadFeedback: (
146 reason?: string,
147 ) => Effect.Effect<EffectCodexSchema.V2FeedbackUploadResponse, CodexSessionRuntimeError>;
145148 readonly respondToRequest: (
146149 requestId: ApprovalRequestId,
147150 decision: ProviderApprovalDecision,
......@@ -1914,6 +1917,16 @@ export const makeCodexSessionRuntime = (
19141917 });
19151918 return parseThreadSnapshot(response);
19161919 }),
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 }),
19171930 respondToRequest: (requestId, decision) =>
19181931 Effect.gen(function* () {
19191932 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 = {
4040 hasSession: vi.fn(),
4141 readThread: vi.fn(),
4242 rollbackThread: vi.fn(),
43 uploadFeedback: vi.fn(),
4344 stopAll: vi.fn(),
4445 streamEvents: Stream.empty,
4546};
apps/server/src/provider/Layers/ProviderRegistry.test.ts+7
......@@ -381,6 +381,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
381381 shortDescription: "Debug failing GitHub Actions checks",
382382 },
383383 ]);
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 ]);
384391 }),
385392 );
386393
apps/server/src/provider/Layers/ProviderService.test.ts+160
......@@ -9,6 +9,8 @@ import type {
99 ProviderSendTurnInput,
1010 ProviderSession,
1111 ProviderTurnStartResult,
12 ProviderUploadFeedbackInput,
13 ProviderUploadFeedbackResult,
1214} from "@t3tools/contracts";
1315import {
1416 ApprovalRequestId,
......@@ -197,6 +199,13 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
197199 Effect.succeed({ threadId, turns: [] }),
198200 );
199201
202 const uploadFeedback = vi.fn(
203 (
204 input: ProviderUploadFeedbackInput,
205 ): Effect.Effect<ProviderUploadFeedbackResult, ProviderAdapterError> =>
206 Effect.succeed({ feedbackId: `feedback-${input.threadId}` }),
207 );
208
200209 const stopAll = vi.fn(
201210 (): Effect.Effect<void, ProviderAdapterError> =>
202211 Effect.sync(() => {
......@@ -219,6 +228,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
219228 hasSession,
220229 readThread,
221230 rollbackThread,
231 ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}),
222232 stopAll,
223233 get streamEvents() {
224234 return Stream.fromPubSub(runtimeEventPubSub);
......@@ -254,6 +264,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
254264 hasSession,
255265 readThread,
256266 rollbackThread,
267 uploadFeedback,
257268 stopAll,
258269 };
259270}
......@@ -595,6 +606,68 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
595606
596607const 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
598671it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () =>
599672 Effect.gen(function* () {
600673 const codex = makeFakeCodexAdapter();
......@@ -941,6 +1014,93 @@ routing.layer("ProviderServiceLive routing", (it) => {
9411014 }),
9421015 );
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
9441104 it.effect("appends attachment file paths to the turn input text", () =>
9451105 Effect.gen(function* () {
9461106 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+43
......@@ -19,6 +19,7 @@ import {
1919 ProviderSendTurnInput,
2020 ProviderSessionStartInput,
2121 ProviderStopSessionInput,
22 ProviderUploadFeedbackInput,
2223 type ProviderInstanceId,
2324 type ProviderDriverKind,
2425 type ProviderRuntimeEvent,
......@@ -1117,6 +1118,47 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
11171118 );
11181119 });
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
11201162 const runStopAll = Effect.fn("runStopAll")(function* () {
11211163 const threadIds = yield* directory.listThreadIds();
11221164 const currentAdapters = yield* getAdapterEntries;
......@@ -1188,6 +1230,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
11881230 getCapabilities,
11891231 getInstanceInfo,
11901232 rollbackConversation,
1233 uploadFeedback,
11911234 // Each access creates a fresh PubSub subscription so that multiple
11921235 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each
11931236 // independently receive all runtime events.
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -184,6 +184,7 @@ describe("ProviderSessionReaper", () => {
184184 });
185185 },
186186 rollbackConversation: () => unsupported(),
187 uploadFeedback: () => unsupported(),
187188 streamEvents: Stream.empty,
188189 };
189190
apps/server/src/provider/Services/CodexAdapter.ts+5-1
......@@ -16,4 +16,8 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
1616 * CodexAdapterShape — per-instance Codex adapter contract. Carries
1717 * a branded driver kind as the nominal discriminant.
1818 */
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 {
1616 ProviderSendTurnInput,
1717 ProviderSession,
1818 ProviderSessionStartInput,
19 ProviderUploadFeedbackInput,
20 ProviderUploadFeedbackResult,
1921 ThreadId,
2022 ProviderTurnStartResult,
2123 TurnId,
......@@ -114,6 +116,13 @@ export interface ProviderAdapterShape<TError> {
114116 numTurns: number,
115117 ) => 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
117126 /**
118127 * Stop all sessions owned by this adapter.
119128 */
apps/server/src/provider/Services/ProviderService.ts+9
......@@ -21,6 +21,8 @@ import type {
2121 ProviderSession,
2222 ProviderSessionStartInput,
2323 ProviderStopSessionInput,
24 ProviderUploadFeedbackInput,
25 ProviderUploadFeedbackResult,
2426 ThreadId,
2527 ProviderTurnStartResult,
2628} from "@t3tools/contracts";
......@@ -105,6 +107,13 @@ export interface ProviderServiceShape {
105107 readonly numTurns: number;
106108 }) => 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
108117 /**
109118 * Canonical provider runtime event stream.
110119 *
apps/server/src/server.test.ts+80-12
......@@ -114,6 +114,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
114114import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
115115import { PersistenceSqlError } from "./persistence/Errors.ts";
116116import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
117import * as ProviderService from "./provider/Services/ProviderService.ts";
118import { ProviderAdapterRequestError } from "./provider/Errors.ts";
117119import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
118120import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
119121import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
......@@ -387,6 +389,7 @@ const buildAppUnderTest = (options?: {
387389 layers?: {
388390 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
389391 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
392 providerService?: Partial<ProviderService.ProviderService["Service"]>;
390393 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
391394 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;
392395 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;
......@@ -629,18 +632,24 @@ const buildAppUnderTest = (options?: {
629632 }),
630633 ),
631634 Layer.provide(
632 Layer.mock(ProviderRegistry.ProviderRegistry)({
633 getProviders: Effect.succeed([]),
634 refresh: () => Effect.succeed([]),
635 refreshInstance: () => Effect.succeed([]),
636 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
637 Effect.succeed(
638 makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
639 ),
640 setProviderMaintenanceActionState: () => Effect.succeed([]),
641 streamChanges: Stream.empty,
642 ...options?.layers?.providerRegistry,
643 }),
635 Layer.mergeAll(
636 Layer.mock(ProviderRegistry.ProviderRegistry)({
637 getProviders: Effect.succeed([]),
638 refresh: () => Effect.succeed([]),
639 refreshInstance: () => Effect.succeed([]),
640 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
641 Effect.succeed(
642 makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }),
643 ),
644 setProviderMaintenanceActionState: () => Effect.succeed([]),
645 streamChanges: Stream.empty,
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 ),
644653 ),
645654 Layer.provide(
646655 Layer.mock(ServerSettings.ServerSettingsService)({
......@@ -4452,6 +4461,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
44524461 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
44534462 );
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
44554523 it.effect("shares one preview automation broker across websocket sessions", () =>
44564524 Effect.scoped(
44574525 Effect.gen(function* () {
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
......@@ -55,6 +55,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
5555 getCapabilities: () => Effect.die("unused"),
5656 getInstanceInfo: () => Effect.die("unused"),
5757 rollbackConversation: () => Effect.die("unused"),
58 uploadFeedback: () => Effect.die("unused"),
5859 streamEvents: Stream.empty,
5960 }) satisfies ProviderService.ProviderService["Service"];
6061
apps/server/src/ws.ts+17
......@@ -42,6 +42,7 @@ import {
4242 ProjectSearchContentsError,
4343 ProjectSearchEntriesError,
4444 ProjectWriteFileError,
45 ProviderUploadFeedbackError,
4546 RelayClientInstallFailedError,
4647 type RelayClientInstallProgressEvent,
4748 type ServerSelfUpdateError,
......@@ -81,6 +82,7 @@ import {
8182 observeRpcStreamEffect as instrumentRpcStreamEffect,
8283} from "./observability/RpcInstrumentation.ts";
8384import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
85import * as ProviderService from "./provider/Services/ProviderService.ts";
8486import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
8587import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
8688import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
......@@ -432,6 +434,7 @@ const makeWsRpcLayer = (
432434 const previewManager = yield* PreviewManager.PreviewManager;
433435 const portDiscovery = yield* PortScanner.PortDiscovery;
434436 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
437 const providerService = yield* ProviderService.ProviderService;
435438 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
436439 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
437440 const config = yield* ServerConfig.ServerConfig;
......@@ -1534,6 +1537,20 @@ const makeWsRpcLayer = (
15341537 ).pipe(Effect.map((providers) => ({ providers }))),
15351538 { "rpc.aggregate": "server" },
15361539 ),
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 ),
15371554 [WS_METHODS.serverUpdateProvider]: (input) =>
15381555 observeRpcEffect(
15391556 WS_METHODS.serverUpdateProvider,
apps/web/src/components/ChatView.tsx+141-5
......@@ -33,6 +33,12 @@ import {
3333 effectiveSnoozed,
3434 threadWokeAt,
3535} from "@t3tools/client-runtime/state/thread-settled";
36import {
37 codexFeedbackMessage,
38 parseCodexFeedbackCommand,
39 submitCodexFeedback,
40 type CodexFeedbackSubmission,
41} from "@t3tools/client-runtime/state/threads";
3642import {
3743 parseScopedThreadKey,
3844 scopedThreadKey,
......@@ -124,6 +130,7 @@ import {
124130 type TurnDiffSummary,
125131} from "../types";
126132import { useTheme } from "../hooks/useTheme";
133import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
127134import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
128135import { isCommandPaletteOpen } from "../commandPaletteBus";
129136import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
......@@ -1268,6 +1275,9 @@ function ChatViewContent(props: ChatViewProps) {
12681275 reportFailure: false,
12691276 });
12701277 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1278 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
1279 reportFailure: false,
1280 });
12711281 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
12721282 reportFailure: false,
12731283 });
......@@ -1383,6 +1393,16 @@ function ChatViewContent(props: ChatViewProps) {
13831393 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
13841394 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
13851395 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 );
13861406 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
13871407 optimisticUserMessagesRef.current = optimisticUserMessages;
13881408 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
......@@ -1444,6 +1464,7 @@ function ChatViewContent(props: ChatViewProps) {
14441464 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
14451465 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
14461466 const sendInFlightRef = useRef(false);
1467 const feedbackUploadsInFlightRef = useRef(new Set<string>());
14471468 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});
14481469
14491470 useLayoutEffect(() => {
......@@ -2603,16 +2624,29 @@ function ChatViewContent(props: ChatViewProps) {
26032624 return changed ? { ...message, attachments } : message;
26042625 });
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) {
26072636 return serverMessagesWithPreviewHandoff;
26082637 }
26092638 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));
26112640 if (pendingMessages.length === 0) {
26122641 return serverMessagesWithPreviewHandoff;
26132642 }
26142643 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
2615 }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);
2644 }, [
2645 attachmentPreviewHandoffByMessageId,
2646 displayServerMessages,
2647 feedbackSubmissions,
2648 optimisticUserMessages,
2649 ]);
26162650 const timelineEntries = useMemo(
26172651 () =>
26182652 deriveTimelineEntries(
......@@ -5061,7 +5095,8 @@ function ChatViewContent(props: ChatViewProps) {
50615095 isSendBusy ||
50625096 isConnecting ||
50635097 threadDetailLoading ||
5064 sendInFlightRef.current
5098 sendInFlightRef.current ||
5099 feedbackUploadsInFlightRef.current.has(routeThreadKey)
50655100 ) {
50665101 notifyDirectAnnotationAttached();
50675102 return;
......@@ -5136,6 +5171,101 @@ function ChatViewContent(props: ChatViewProps) {
51365171 composerPreviewAnnotations.length +
51375172 composerReviewComments.length,
51385173 });
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 }
51395269 if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) {
51405270 const followUp = resolvePlanFollowUpSubmission({
51415271 draftText: trimmed,
......@@ -6633,7 +6763,13 @@ function ChatViewContent(props: ChatViewProps) {
66336763 phase={phase}
66346764 isConnecting={isConnecting}
66356765 isSendBusy={isSendBusy}
6636 sendDisabledReason={threadDetailLoading ? "Messages loading" : null}
6766 sendDisabledReason={
6767 feedbackUploading
6768 ? "Sending feedback"
6769 : threadDetailLoading
6770 ? "Messages loading"
6771 : null
6772 }
66376773 isPreparingWorktree={isPreparingWorktree}
66386774 externalDrawerAttached={externalComposerDrawerAttached}
66396775 environmentUnavailable={activeEnvironmentUnavailableState}
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+9-2
......@@ -87,7 +87,7 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent:
8787 );
8888}
8989
90function renderSendButton() {
90function renderSendButton(sendDisabledReason: string | null = null) {
9191 return renderToStaticMarkup(
9292 createElement(ComposerPrimaryActions, {
9393 compact: true,
......@@ -96,7 +96,7 @@ function renderSendButton() {
9696 showPlanFollowUpPrompt: false,
9797 promptHasText: true,
9898 isSendBusy: false,
99 sendDisabledReason: null,
99 sendDisabledReason,
100100 isConnecting: false,
101101 isEnvironmentUnavailable: false,
102102 isPreparingWorktree: false,
......@@ -204,6 +204,13 @@ describe("formatPendingPrimaryActionLabel", () => {
204204});
205205
206206describe("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
207214 it("offers Stop generation while a running turn is waiting for user input", () => {
208215 expect(renderPendingActions(true)).toContain('aria-label="Stop generation"');
209216 });
apps/web/src/components/chat/MessagesTimeline.test.tsx+56
......@@ -1,4 +1,5 @@
11import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
23import { createRef, type ReactNode, type Ref } from "react";
34import { renderToStaticMarkup } from "react-dom/server";
45import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
......@@ -236,6 +237,61 @@ function buildAssistantTimelineEntry(text: string) {
236237}
237238
238239describe("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
239295 it("renders the worked-for row at assistant response text size", () => {
240296 const turnId = TurnId.make("turn-with-fold");
241297 const assistantEntry = buildAssistantTimelineEntry("Done.");
docs/user/providers-codex.md+6
......@@ -28,6 +28,12 @@ Log in with Codex normally:
2828codex login
2929```
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
3137## I Want Work And Personal Codex Accounts
3238
3339Use one real Codex home and one shadow home.
packages/client-runtime/src/state/threadCommands.ts+12-1
......@@ -1,7 +1,12 @@
11import * as Crypto from "effect/Crypto";
22import { 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";
510import {
611 type ArchiveThreadInput,
712 type CreateThreadInput,
......@@ -199,5 +204,11 @@ export function createThreadEnvironmentAtoms<R, E>(
199204 scheduler,
200205 concurrency,
201206 }),
207 uploadFeedback: createEnvironmentRpcCommand(runtime, {
208 label: "environment-data:commands:thread:upload-feedback",
209 tag: WS_METHODS.providerUploadFeedback,
210 scheduler,
211 concurrency,
212 }),
202213 };
203214}
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";
729729export * from "./threadSnapshotHttp.ts";
730730export * from "./composerPathSearch.ts";
731731export * from "./threadCommands.ts";
732export * from "./threadFeedback.ts";
732733export * from "./threadDetail.ts";
733734export * from "./threadReducer.ts";
734735export * from "./threadShell.ts";
packages/contracts/src/provider.test.ts+39
......@@ -1,17 +1,23 @@
11import { describe, expect, it } from "vite-plus/test";
22import * as Schema from "effect/Schema";
33
4import { ThreadId } from "./baseSchemas.ts";
45import {
56 ProviderEvent,
67 ProviderSendTurnInput,
78 ProviderSession,
89 ProviderSessionStartInput,
10 ProviderUploadFeedbackError,
11 ProviderUploadFeedbackInput,
12 ProviderUploadFeedbackResult,
913} from "./provider.ts";
1014
1115const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
1216const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
1317const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession);
1418const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent);
19const decodeProviderUploadFeedbackInput = Schema.decodeUnknownSync(ProviderUploadFeedbackInput);
20const decodeProviderUploadFeedbackResult = Schema.decodeUnknownSync(ProviderUploadFeedbackResult);
1521
1622function getOptionValue(
1723 options: ReadonlyArray<{ id: string; value: unknown }> | undefined,
......@@ -153,6 +159,39 @@ describe("ProviderSendTurnInput", () => {
153159 });
154160});
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
156195describe("providerInstanceId routing key (slice-2 invariant)", () => {
157196 it("decodes a ProviderSessionStartInput without providerInstanceId (legacy producer)", () => {
158197 const parsed = decodeProviderSessionStartInput({
packages/contracts/src/provider.ts+23
......@@ -110,6 +110,29 @@ export const ProviderRespondToUserInputInput = Schema.Struct({
110110});
111111export 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
113136const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);
114137
115138export const ProviderEvent = Schema.Struct({
packages/contracts/src/rpc.ts+15
......@@ -66,6 +66,11 @@ import {
6666 OrchestrationRpcSchemas,
6767 OrchestrationGetWorkflowScriptError,
6868} from "./orchestration.ts";
69import {
70 ProviderUploadFeedbackError,
71 ProviderUploadFeedbackInput,
72 ProviderUploadFeedbackResult,
73} from "./provider.ts";
6974import { ProviderInstanceId } from "./providerInstance.ts";
7075import {
7176 PullRequestActionInput,
......@@ -211,6 +216,9 @@ export const WS_METHODS = {
211216 filesystemBrowse: "filesystem.browse",
212217 assetsCreateUrl: "assets.createUrl",
213218
219 // Provider methods
220 providerUploadFeedback: "provider.uploadFeedback",
221
214222 // VCS methods
215223 vcsPull: "vcs.pull",
216224 vcsRefreshStatus: "vcs.refreshStatus",
......@@ -665,6 +673,12 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
665673 error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),
666674});
667675
676export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, {
677 payload: ProviderUploadFeedbackInput,
678 success: ProviderUploadFeedbackResult,
679 error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]),
680});
681
668682export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
669683 payload: VcsStatusInput,
670684 success: VcsStatusStreamEvent,
......@@ -1034,6 +1048,7 @@ export const WsRpcGroup = RpcGroup.make(
10341048 WsShellOpenInEditorRpc,
10351049 WsFilesystemBrowseRpc,
10361050 WsAssetsCreateUrlRpc,
1051 WsProviderUploadFeedbackRpc,
10371052 WsSubscribeVcsStatusRpc,
10381053 WsVcsPullRpc,
10391054 WsVcsRefreshStatusRpc,