1import { describe, expect, it } from "vite-plus/test";
2import { MessageId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/reactivity";
5
6import {
7 beginCodexFeedbackSubmission,
8 codexFeedbackNotice,
9 codexFeedbackMessage,
10 parseCodexFeedbackCommand,
11 submitCodexFeedback,
12 type CodexFeedbackSubmission,
13} from "./threadFeedback.ts";
14
15describe("beginCodexFeedbackSubmission", () => {
16 it("allows only one upload per thread until the active upload releases its guard", () => {
17 const inFlight = new Set<string>();
18 const finish = beginCodexFeedbackSubmission(inFlight, "environment:thread");
19
20 expect(finish).not.toBeNull();
21 expect(beginCodexFeedbackSubmission(inFlight, "environment:thread")).toBeNull();
22 expect(beginCodexFeedbackSubmission(inFlight, "environment:other-thread")).not.toBeNull();
23
24 finish?.();
25 expect(beginCodexFeedbackSubmission(inFlight, "environment:thread")).not.toBeNull();
26 });
27});
28
29describe("parseCodexFeedbackCommand", () => {
30 it("accepts feedback without a reason", () => {
31 expect(parseCodexFeedbackCommand(" /feedback ")).toEqual({});
32 });
33
34 it("preserves a feedback description", () => {
35 expect(parseCodexFeedbackCommand("/feedback The agent stopped early.")).toEqual({
36 reason: "The agent stopped early.",
37 });
38 });
39
40 it("accepts mixed-case feedback commands", () => {
41 expect(parseCodexFeedbackCommand("/Feedback Retry failed.")).toEqual({
42 reason: "Retry failed.",
43 });
44 });
45
46 it("ignores other slash commands and ordinary messages", () => {
47 expect(parseCodexFeedbackCommand("/feedback-status")).toBeNull();
48 expect(parseCodexFeedbackCommand("Please send /feedback")).toBeNull();
49 });
50});
51
52describe("submitCodexFeedback", () => {
53 const submission = {
54 id: MessageId.make("feedback-message-1"),
55 command: "/feedback The agent stopped early.",
56 createdAt: "2026-08-23T00:00:00.000Z",
57 } as const;
58
59 it("reports upload progress and clears the draft before the upload finishes", async () => {
60 let draft: string = submission.command;
61 let finishUpload:
62 | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void)
63 | undefined;
64 const states: CodexFeedbackSubmission[] = [];
65 const upload = new Promise<ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>>(
66 (resolve) => {
67 finishUpload = resolve;
68 },
69 );
70
71 const result = submitCodexFeedback({
72 submission,
73 clearDraft: () => {
74 draft = "";
75 },
76 onUpdate: (state) => states.push(state),
77 upload: () => {
78 expect(draft).toBe("");
79 return upload;
80 },
81 });
82
83 expect(draft).toBe("");
84 expect(states).toEqual([{ ...submission, status: "uploading" }]);
85 expect(codexFeedbackNotice(states[0]!)).toEqual({
86 title: "Sending feedback to OpenAI...",
87 description: undefined,
88 });
89
90 draft = "Keep this newer message.";
91 finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));
92 await result;
93
94 expect(draft).toBe("Keep this newer message.");
95 expect(states.at(-1)).toEqual({
96 ...submission,
97 status: "sent",
98 feedbackId: "codex-thread-1",
99 });
100 expect(codexFeedbackNotice(states.at(-1)!)?.description).toContain("codex-thread-1");
101 });
102
103 it("records a failed upload without losing its user-facing error", async () => {
104 const states: CodexFeedbackSubmission[] = [];
105 const error = new Error("Upload rejected.");
106
107 await submitCodexFeedback({
108 submission,
109 clearDraft: () => undefined,
110 onUpdate: (state) => states.push(state),
111 upload: () =>
112 Promise.resolve(AsyncResult.failure<{ feedbackId: string }, Error>(Cause.fail(error))),
113 });
114
115 expect(states.at(-1)).toEqual({
116 ...submission,
117 status: "failed",
118 errorMessage: "Upload rejected.",
119 });
120 expect(codexFeedbackNotice(states.at(-1)!)?.description).toBe("Upload rejected.");
121 });
122
123 it("marks interruptions without reporting them as upload failures", async () => {
124 const states: CodexFeedbackSubmission[] = [];
125
126 await submitCodexFeedback({
127 submission,
128 clearDraft: () => undefined,
129 onUpdate: (state) => states.push(state),
130 upload: () =>
131 Promise.resolve(AsyncResult.failure<{ feedbackId: string }, never>(Cause.interrupt(1))),
132 });
133
134 expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" });
135 expect(codexFeedbackNotice(states.at(-1)!)).toBeNull();
136 });
137
138 it("lets another feedback submission finish while the first remains in flight", async () => {
139 let finishFirstUpload:
140 | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void)
141 | undefined;
142 const firstUpload = new Promise<ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>>(
143 (resolve) => {
144 finishFirstUpload = resolve;
145 },
146 );
147 const firstStates: CodexFeedbackSubmission[] = [];
148 const secondStates: CodexFeedbackSubmission[] = [];
149
150 const first = submitCodexFeedback({
151 submission,
152 clearDraft: () => undefined,
153 onUpdate: (state) => firstStates.push(state),
154 upload: () => firstUpload,
155 });
156 const second = await submitCodexFeedback({
157 submission: {
158 ...submission,
159 id: MessageId.make("feedback-message-2"),
160 },
161 clearDraft: () => undefined,
162 onUpdate: (state) => secondStates.push(state),
163 upload: () => Promise.resolve(AsyncResult.success({ feedbackId: "codex-thread-2" })),
164 });
165
166 expect(firstStates.at(-1)?.status).toBe("uploading");
167 expect(second._tag).toBe("Success");
168 expect(secondStates.at(-1)).toMatchObject({
169 status: "sent",
170 feedbackId: "codex-thread-2",
171 });
172
173 finishFirstUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));
174 await first;
175 });
176});