Open History to see the last commit
1import { describe, expect, it, vi } from "vite-plus/test";
2import { Atom } from "effect/reactivity";
3import {
4 ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
5 restoreAttachmentOnlyBootstrapPrompt,
6} from "@t3tools/shared/composerContextReferences";
7import {
8 ChatAttachmentId,
9 ComposerContextId,
10 MessageId,
11 RunId,
12 type ChatAttachment,
13 type OrchestrationMessageContext,
14} from "@t3tools/contracts";
15
16import { appAtomRegistry } from "./atom-registry";
17import {
18 activeComposerDraftKey,
19 beginQueuedRunEdit,
20 getQueuedRunEdit,
21 queuedEditDraftKey,
22 removeQueuedRunEditAttachment,
23 resolveQueuedEditPayload,
24 type QueuedRunEdit,
25} from "./queued-run-edit";
26import { threadComposerErrorsAtom } from "./thread-composer-error";
27import {
28 flushComposerDrafts,
29 getComposerDraftSnapshot,
30 setComposerDraftText,
31 waitForComposerDraftsLoaded,
32} from "./use-composer-drafts";
33
34vi.mock("expo-file-system", () => ({
35 Directory: class {
36 create() {}
37 },
38 File: class {
39 exists = false;
40 create() {}
41 write() {}
42 moveSync() {}
43 },
44 Paths: { document: { uri: "file:///documents" } },
45}));
46
47const image = (id: string): ChatAttachment => ({
48 type: "image",
49 id: ChatAttachmentId.make(id),
50 name: `${id}.png`,
51 mimeType: "image/png",
52 sizeBytes: 10,
53});
54
55function edit(attachments: ReadonlyArray<ChatAttachment>): QueuedRunEdit {
56 return {
57 runId: RunId.make("run-1"),
58 messageId: MessageId.make("message-1"),
59 originalText: "original",
60 existingAttachments: attachments,
61 };
62}
63
64function context(attachmentIds: ReadonlyArray<string>): OrchestrationMessageContext {
65 return {
66 version: 1,
67 records: attachmentIds.map((attachmentId, index) => ({
68 version: 1,
69 kind: "image",
70 contextId: ComposerContextId.make(`ctx-${index}`),
71 label: `${attachmentId}.png`,
72 attachmentId,
73 name: `${attachmentId}.png`,
74 mimeType: "image/png",
75 sizeBytes: 10,
76 })),
77 };
78}
79
80describe("resolveQueuedEditPayload", () => {
81 it("resends the kept server attachments ahead of the newly uploaded ones", () => {
82 const payload = resolveQueuedEditPayload({
83 edit: edit([image("kept")]),
84 draftContext: undefined,
85 draftAttachments: [{ id: "draft-1" }],
86 uploaded: [image("uploaded")],
87 });
88
89 expect(payload.attachments.map((attachment) => attachment.id)).toEqual(["kept", "uploaded"]);
90 });
91
92 it("rebinds context records from the draft's ids to the uploaded ones", () => {
93 const payload = resolveQueuedEditPayload({
94 edit: edit([]),
95 draftContext: context(["draft-1"]),
96 draftAttachments: [{ id: "draft-1" }],
97 uploaded: [image("uploaded")],
98 });
99
100 expect(payload.context?.records).toEqual([
101 expect.objectContaining({ attachmentId: "uploaded" }),
102 ]);
103 });
104
105 it("drops the record of an attachment the user removed", () => {
106 const payload = resolveQueuedEditPayload({
107 edit: edit([image("kept")]),
108 draftContext: context(["kept", "gone"]),
109 draftAttachments: [],
110 uploaded: [],
111 });
112
113 expect(payload.attachments.map((attachment) => attachment.id)).toEqual(["kept"]);
114 expect(payload.context?.records).toEqual([expect.objectContaining({ attachmentId: "kept" })]);
115 });
116});
117
118describe("beginQueuedRunEdit", () => {
119 it.each(["", "Ultrathink:\n"])(
120 "opens attachment-only edits without the placeholder and treats them as untouched (%j)",
121 (prefix) => {
122 const threadKey = `env-exit:attachment-only-${prefix.length}`;
123 const original = {
124 ...edit([image("kept")]),
125 originalText: prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
126 };
127 const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>(
128 [{ id: original.runId, status: "queued" }],
129 );
130 beginQueuedRunEdit(threadKey, original, runs);
131 const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, original.runId));
132 expect(draft.text).toBe(prefix.trimEnd());
133 expect(
134 restoreAttachmentOnlyBootstrapPrompt(draft.text, original.existingAttachments.length > 0),
135 ).toBe(original.originalText);
136 expect(getQueuedRunEdit(threadKey)).toEqual({ ...original, originalText: prefix.trimEnd() });
137 appAtomRegistry.set(runs, [{ id: original.runId, status: "running" }]);
138 expect(getQueuedRunEdit(threadKey)).toBeNull();
139 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined();
140 },
141 );
142
143 it("silently drops an untouched edit when its run starts", () => {
144 const threadKey = "env-exit:thread-untouched";
145 const runId = RunId.make("run-1");
146 const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>([
147 { id: runId, status: "queued" },
148 ]);
149 beginQueuedRunEdit(threadKey, edit([]), runs);
150
151 appAtomRegistry.set(runs, [{ id: runId, status: "running" }]);
152
153 expect(getQueuedRunEdit(threadKey)).toBeNull();
154 expect(getComposerDraftSnapshot(threadKey).text).toBe("");
155 expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe("");
156 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined();
157 });
158
159 it("recovers text after a server attachment was removed", async () => {
160 await waitForComposerDraftsLoaded();
161 const threadKey = "env-exit:thread-removed-recover";
162 const runId = RunId.make("run-1");
163 const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>([
164 { id: runId, status: "queued" },
165 ]);
166 beginQueuedRunEdit(threadKey, edit([image("removed")]), runs);
167 removeQueuedRunEditAttachment(threadKey, "removed");
168
169 appAtomRegistry.set(runs, [{ id: runId, status: "running" }]);
170 await flushComposerDrafts();
171
172 expect(getQueuedRunEdit(threadKey)).toBeNull();
173 expect(getComposerDraftSnapshot(threadKey).text).toBe("original");
174 expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe("");
175 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe(
176 "That message already started. Your edit is back in the composer.",
177 );
178 });
179
180 it("recovers changed text into an empty thread draft", async () => {
181 await waitForComposerDraftsLoaded();
182 const threadKey = "env-exit:thread-changed-recover";
183 const runId = RunId.make("run-1");
184 const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>([
185 { id: runId, status: "queued" },
186 ]);
187 beginQueuedRunEdit(threadKey, edit([]), runs);
188 setComposerDraftText(queuedEditDraftKey(threadKey, runId), "revised");
189
190 appAtomRegistry.set(runs, [{ id: runId, status: "running" }]);
191 await flushComposerDrafts();
192
193 expect(getQueuedRunEdit(threadKey)).toBeNull();
194 expect(getComposerDraftSnapshot(threadKey).text).toBe("revised");
195 expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe("");
196 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe(
197 "That message already started. Your edit is back in the composer.",
198 );
199 });
200
201 it("ends a changed edit when its run starts, without the thread's composer mounted", () => {
202 const threadKey = "env-exit:thread-exit";
203 const runId = RunId.make("run-1");
204 const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>([
205 { id: runId, status: "queued" },
206 ]);
207 setComposerDraftText(threadKey, "other work");
208 beginQueuedRunEdit(threadKey, edit([]), runs);
209 setComposerDraftText(queuedEditDraftKey(threadKey, runId), "changed");
210
211 appAtomRegistry.set(runs, [{ id: runId, status: "running" }]);
212
213 expect(getQueuedRunEdit(threadKey)).toBeNull();
214 expect(getComposerDraftSnapshot(threadKey).text).toBe("other work");
215 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe(
216 "That message already started, so the edit was discarded.",
217 );
218 });
219
220 it("reports a cleared edit as discarded when the run starts", () => {
221 const threadKey = "env-exit:thread-cleared";
222 const runId = RunId.make("run-1");
223 const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>([
224 { id: runId, status: "queued" },
225 ]);
226 beginQueuedRunEdit(threadKey, edit([]), runs);
227 setComposerDraftText(queuedEditDraftKey(threadKey, runId), "");
228
229 appAtomRegistry.set(runs, [{ id: runId, status: "running" }]);
230
231 expect(getQueuedRunEdit(threadKey)).toBeNull();
232 expect(getComposerDraftSnapshot(threadKey).text).toBe("");
233 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe(
234 "That message already started, so the edit was discarded.",
235 );
236 });
237
238 it("treats a removed server attachment as a change when the run starts", () => {
239 const threadKey = "env-exit:thread-removed";
240 const runId = RunId.make("run-1");
241 const runs = Atom.make<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | null>([
242 { id: runId, status: "queued" },
243 ]);
244 setComposerDraftText(threadKey, "other work");
245 beginQueuedRunEdit(threadKey, edit([image("kept")]), runs);
246 removeQueuedRunEditAttachment(threadKey, "kept");
247
248 appAtomRegistry.set(runs, [{ id: runId, status: "running" }]);
249
250 expect(getQueuedRunEdit(threadKey)).toBeNull();
251 expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe(
252 "That message already started, so the edit was discarded.",
253 );
254 });
255});
256
257describe("activeComposerDraftKey", () => {
258 const threadKey = "env:thread";
259 const editKey = queuedEditDraftKey(threadKey, RunId.make("run-1"));
260
261 it("keeps a queued edit's draft while that edit is open", () => {
262 expect(activeComposerDraftKey(threadKey, editKey, edit([]))).toBe(editKey);
263 });
264
265 it("falls back to the thread's draft once the edit has ended", () => {
266 expect(activeComposerDraftKey(threadKey, editKey, null)).toBe(threadKey);
267 });
268});