1import {
2 scopedProjectKey,
3 scopedThreadKey,
4 scopeProjectRef,
5 scopeThreadRef,
6} from "@t3tools/client-runtime/environment";
7import * as Schema from "effect/Schema";
8import {
9 defaultInstanceIdForDriver,
10 EnvironmentId,
11 MessageId,
12 ProjectId,
13 ProviderDriverKind,
14 ProviderInstanceId,
15 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
16 ThreadId,
17 type ModelSelection,
18 type PreviewAnnotationPayload,
19 type ProviderOptionSelection,
20} from "@t3tools/contracts";
21import { createModelSelection } from "@t3tools/shared/model";
22import {
23 collectAssistantCitations,
24 serializeAssistantCitation,
25} from "@t3tools/shared/assistantCitations";
26
27// The composer draft's `modelSelectionByProvider` and
28// `stickyModelSelectionByProvider` maps are keyed by `ProviderInstanceId`
29// in production; these aliases keep the legacy-key migration tests concise.
30const CODEX_INSTANCE = ProviderInstanceId.make("codex");
31const CODEX_SECONDARY_INSTANCE = ProviderInstanceId.make("codex_secondary");
32const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");
33const CURSOR_INSTANCE = ProviderInstanceId.make("cursor");
34const CODEX_DRIVER = ProviderDriverKind.make("codex");
35const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
36const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
37
38type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>;
39type ProviderOptionSelectionsByProvider = Partial<Record<string, ProviderOptionSelectionBag>>;
40
41function toSelections(
42 options: Record<string, string | boolean | undefined> | undefined,
43): ReadonlyArray<ProviderOptionSelection> {
44 const result: Array<ProviderOptionSelection> = [];
45 if (!options) return result;
46 for (const [id, value] of Object.entries(options)) {
47 if (typeof value === "string" || typeof value === "boolean") {
48 result.push({ id, value });
49 }
50 }
51 return result;
52}
53
54function selectionsByProvider(
55 options: Partial<Record<ProviderDriverKind, Record<string, string | boolean | undefined>>>,
56): ProviderOptionSelectionsByProvider {
57 const result: ProviderOptionSelectionsByProvider = {};
58 for (const [provider, bag] of Object.entries(options) as Array<
59 [ProviderDriverKind, Record<string, string | boolean | undefined>]
60 >) {
61 result[provider] = toSelections(bag);
62 }
63 return result;
64}
65import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
66
67import {
68 COMPOSER_DRAFT_STORAGE_KEY,
69 clearComposerDraftsEnvironment,
70 composerDraftHasUserContent,
71 beginBackgroundDraftSubmissionByRef,
72 clearBackgroundDraftSubmissionByRef,
73 finalizePromotedDraftThreadByRef,
74 markPromotedDraftThreadByRef,
75 restoreFailedBackgroundDraftThread,
76 type ComposerFileAttachment,
77 type ComposerImageAttachment,
78 composerFileNeedsReattach,
79 partializeComposerDraftStoreState,
80 useComposerDraftStore,
81 DraftId,
82} from "./composerDraftStore";
83import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
84import { insertInlineContextReference } from "./lib/composerContextReferences";
85import { terminalContextReference, threadContextRecord } from "./lib/composerContextRecords";
86import {
87 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
88 formatTerminalContextReference,
89 type TerminalContextDraft,
90} from "./lib/terminalContext";
91import { createDeferredStorage } from "./lib/storage";
92import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore";
93
94function makeImage(input: {
95 id: string;
96 previewUrl: string;
97 name?: string;
98 mimeType?: string;
99 sizeBytes?: number;
100 lastModified?: number;
101}): ComposerImageAttachment {
102 const name = input.name ?? "image.png";
103 const mimeType = input.mimeType ?? "image/png";
104 const sizeBytes = input.sizeBytes ?? 4;
105 const lastModified = input.lastModified ?? 1_700_000_000_000;
106 const file = new File([new Uint8Array(sizeBytes).fill(1)], name, {
107 type: mimeType,
108 lastModified,
109 });
110 return {
111 type: "image",
112 id: input.id,
113 name,
114 mimeType,
115 sizeBytes: file.size,
116 previewUrl: input.previewUrl,
117 file,
118 };
119}
120
121function makeFile(id: string): ComposerFileAttachment {
122 const file = new File(["report"], "report.pdf", { type: "application/pdf" });
123 return {
124 type: "file",
125 id,
126 name: file.name,
127 mimeType: file.type,
128 sizeBytes: file.size,
129 file,
130 };
131}
132
133function makeTerminalContext(input: {
134 id: string;
135 text?: string;
136 terminalId?: string;
137 terminalLabel?: string;
138 lineStart?: number;
139 lineEnd?: number;
140}): TerminalContextDraft {
141 return {
142 id: input.id,
143 threadId: ThreadId.make("thread-dedupe"),
144 terminalId: input.terminalId ?? "default",
145 terminalLabel: input.terminalLabel ?? "Terminal 1",
146 lineStart: input.lineStart ?? 4,
147 lineEnd: input.lineEnd ?? 5,
148 text: input.text ?? "git status\nOn branch main",
149 createdAt: "2026-03-13T12:00:00.000Z",
150 };
151}
152
153function resetComposerDraftStore() {
154 useComposerDraftStore.setState({
155 rewindingThreadKeys: new Set(),
156 draftsByThreadKey: {},
157 draftThreadsByThreadKey: {},
158 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
159 stickyModelSelectionByProvider: {},
160 stickyOptionsByModelByProvider: {},
161 stickyActiveProvider: null,
162 });
163}
164
165function modelSelection(
166 provider: ProviderDriverKind,
167 model: string,
168 options?: Record<string, string | boolean | undefined>,
169): ModelSelection {
170 return createModelSelection(defaultInstanceIdForDriver(provider), model, toSelections(options));
171}
172
173function providerModelOptions(
174 options: Partial<Record<string, Record<string, string | boolean | undefined>>>,
175): ProviderOptionSelectionsByProvider {
176 return selectionsByProvider(options);
177}
178
179const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
180const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
181const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.make("__legacy__");
182
183function threadKeyFor(
184 threadId: ThreadId,
185 environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID,
186): string {
187 if (environmentId === LEGACY_TEST_ENVIRONMENT_ID) {
188 return threadId;
189 }
190 return scopedThreadKey(scopeThreadRef(environmentId, threadId));
191}
192
193function draftFor(threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID) {
194 const store = useComposerDraftStore.getState().draftsByThreadKey;
195 return store[threadKeyFor(threadId, environmentId)] ?? store[threadId] ?? undefined;
196}
197
198function draftByKey(key: string) {
199 return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined;
200}
201
202describe("composerDraftStore assistant citations", () => {
203 beforeEach(resetComposerDraftStore);
204 afterEach(resetComposerDraftStore);
205
206 it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", async () => {
207 await useComposerDraftStore.persist.clearStorage();
208 vi.useFakeTimers();
209 try {
210 const threadId = ThreadId.make("citation-draft");
211 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
212 const citation = {
213 version: 1 as const,
214 environmentId: EnvironmentId.make("remote-source"),
215 threadId: ThreadId.make("source-thread"),
216 messageId: MessageId.make("source-message"),
217 text: "Preserve the selected text after reload.",
218 comment: "Why is this important?\nPlease show an example.",
219 start: 12,
220 end: 52,
221 prefix: "Before. ",
222 suffix: " After.",
223 };
224 const prompt = `Explain ${serializeAssistantCitation(citation)} further.`;
225 useComposerDraftStore.getState().setPrompt(threadRef, prompt);
226 await vi.advanceTimersByTimeAsync(300);
227 resetComposerDraftStore();
228 await useComposerDraftStore.persist.rehydrate();
229 const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? "";
230 expect(restored).toBe(prompt);
231 expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([
232 citation,
233 ]);
234 useComposerDraftStore.getState().clearComposerContent(threadRef);
235 expect(
236 collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""),
237 ).toEqual([]);
238 } finally {
239 await useComposerDraftStore.persist.clearStorage();
240 vi.useRealTimers();
241 }
242 });
243});
244
245describe("composerDraftStore addImages", () => {
246 const threadId = ThreadId.make("thread-dedupe");
247 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
248 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
249 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
250
251 beforeEach(() => {
252 resetComposerDraftStore();
253 originalRevokeObjectUrl = URL.revokeObjectURL;
254 revokeSpy = vi.fn();
255 URL.revokeObjectURL = revokeSpy;
256 });
257
258 afterEach(() => {
259 URL.revokeObjectURL = originalRevokeObjectUrl;
260 });
261
262 it("deduplicates identical images in one batch by file signature", () => {
263 const first = makeImage({
264 id: "img-1",
265 previewUrl: "blob:first",
266 name: "same.png",
267 mimeType: "image/png",
268 sizeBytes: 12,
269 lastModified: 12345,
270 });
271 const duplicate = makeImage({
272 id: "img-2",
273 previewUrl: "blob:duplicate",
274 name: "same.png",
275 mimeType: "image/png",
276 sizeBytes: 12,
277 lastModified: 12345,
278 });
279
280 useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]);
281
282 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
283 expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]);
284 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
285 });
286
287 it("restores images with matching metadata without replacing unsent bytes", () => {
288 const store = useComposerDraftStore.getState();
289 const unsent = makeImage({ id: "unsent", previewUrl: "blob:unsent" });
290 const restored = {
291 ...unsent,
292 id: "restored",
293 previewUrl: "blob:restored",
294 file: new File([new Uint8Array(unsent.sizeBytes).fill(2)], unsent.name, {
295 type: unsent.mimeType,
296 lastModified: unsent.file.lastModified,
297 }),
298 };
299 store.addImages(threadRef, [unsent]);
300 store.addImages(threadRef, [restored, restored], { allowDuplicates: true });
301
302 const images = store.getComposerDraft(threadRef)?.images;
303 expect(images?.map((image) => image.id)).toEqual(["unsent", "restored"]);
304 expect(images?.[0]?.file).toBe(unsent.file);
305 expect(images?.[1]?.file).toBe(restored.file);
306 expect(revokeSpy).not.toHaveBeenCalled();
307 });
308
309 it("deduplicates against existing images across calls by file signature", () => {
310 const first = makeImage({
311 id: "img-a",
312 previewUrl: "blob:a",
313 name: "same.png",
314 mimeType: "image/png",
315 sizeBytes: 9,
316 lastModified: 777,
317 });
318 const duplicateLater = makeImage({
319 id: "img-b",
320 previewUrl: "blob:b",
321 name: "same.png",
322 mimeType: "image/png",
323 sizeBytes: 9,
324 lastModified: 999,
325 });
326
327 expect(useComposerDraftStore.getState().addImage(threadRef, first)).toBe(true);
328 expect(useComposerDraftStore.getState().addImage(threadRef, duplicateLater)).toBe(false);
329
330 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
331 expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]);
332 expect(revokeSpy).toHaveBeenCalledWith("blob:b");
333 });
334
335 it("returns false when addImage receives an existing image id", () => {
336 const first = makeImage({ id: "img-same", previewUrl: "blob:first" });
337 const duplicate = makeImage({
338 id: "img-same",
339 previewUrl: "blob:duplicate",
340 name: "different.png",
341 });
342
343 expect(useComposerDraftStore.getState().addImage(threadRef, first)).toBe(true);
344 expect(useComposerDraftStore.getState().addImage(threadRef, duplicate)).toBe(false);
345 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.images).toEqual([first]);
346 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
347 });
348
349 it("does not revoke blob URLs that are still used by an accepted duplicate image", () => {
350 const first = makeImage({
351 id: "img-shared",
352 previewUrl: "blob:shared",
353 });
354 const duplicateSameUrl = makeImage({
355 id: "img-shared",
356 previewUrl: "blob:shared",
357 });
358
359 useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]);
360
361 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
362 expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]);
363 expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared");
364 });
365});
366
367describe("composerDraftStore clearComposerContent", () => {
368 const threadId = ThreadId.make("thread-clear");
369 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
370 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
371 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
372
373 beforeEach(() => {
374 resetComposerDraftStore();
375 originalRevokeObjectUrl = URL.revokeObjectURL;
376 revokeSpy = vi.fn();
377 URL.revokeObjectURL = revokeSpy;
378 });
379
380 afterEach(() => {
381 URL.revokeObjectURL = originalRevokeObjectUrl;
382 });
383
384 it("does not revoke blob preview URLs when clearing composer content", () => {
385 const first = makeImage({
386 id: "img-optimistic",
387 previewUrl: "blob:optimistic",
388 });
389 useComposerDraftStore.getState().addImage(threadRef, first);
390
391 useComposerDraftStore.getState().clearComposerContent(threadRef);
392
393 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
394 expect(draft).toBeUndefined();
395 expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic");
396 });
397});
398
399describe("composerDraftStore unsent draft marker", () => {
400 const threadId = ThreadId.make("thread-unsent-marker");
401 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
402
403 beforeEach(() => {
404 resetComposerDraftStore();
405 });
406
407 it("reports content for typed text and clears when the composer is emptied", () => {
408 const hasDraft = () =>
409 composerDraftHasUserContent(useComposerDraftStore.getState().getComposerDraft(threadRef));
410
411 expect(hasDraft()).toBe(false);
412
413 useComposerDraftStore.getState().setPrompt(threadRef, " ");
414 expect(hasDraft()).toBe(false);
415
416 useComposerDraftStore.getState().setPrompt(threadRef, "follow up on the relay case");
417 expect(hasDraft()).toBe(true);
418
419 useComposerDraftStore.getState().clearComposerContent(threadRef);
420 expect(hasDraft()).toBe(false);
421 });
422
423 it("does not persist active rewind locks", () => {
424 const threadKey = threadKeyFor(threadId, TEST_ENVIRONMENT_ID);
425 useComposerDraftStore.setState({ rewindingThreadKeys: new Set([threadKey]) });
426
427 expect(useComposerDraftStore.getState().rewindingThreadKeys.has(threadKey)).toBe(true);
428 expect(partializeComposerDraftStoreState(useComposerDraftStore.getState())).not.toHaveProperty(
429 "rewindingThreadKeys",
430 );
431 });
432});
433
434describe("composerDraftStore file attachments", () => {
435 const threadId = ThreadId.make("thread-files");
436 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
437
438 beforeEach(() => {
439 resetComposerDraftStore();
440 });
441
442 it("persists uploaded file references without including file contents", () => {
443 const store = useComposerDraftStore.getState();
444 store.addFiles(threadRef, [
445 { ...makeFile("file-1"), source: { _tag: "pasted-text" as const } },
446 ]);
447 store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf");
448
449 const persistApi = useComposerDraftStore.persist as unknown as {
450 getOptions: () => {
451 merge: (
452 persistedState: unknown,
453 currentState: ReturnType<typeof useComposerDraftStore.getState>,
454 ) => ReturnType<typeof useComposerDraftStore.getState>;
455 };
456 };
457 const options = persistApi.getOptions();
458 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
459
460 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
461 [
462 {
463 id: "file-1",
464 name: "report.pdf",
465 mimeType: "application/pdf",
466 sizeBytes: 6,
467 attachmentId: "pending-report-pdf",
468 environmentId: TEST_ENVIRONMENT_ID,
469 source: { _tag: "pasted-text" },
470 },
471 ],
472 );
473
474 const hydrated = options.merge(persisted, useComposerDraftStore.getState());
475 expect(hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual([
476 {
477 type: "file",
478 id: "file-1",
479 name: "report.pdf",
480 mimeType: "application/pdf",
481 sizeBytes: 6,
482 file: null,
483 uploadedAttachmentId: "pending-report-pdf",
484 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
485 source: { _tag: "pasted-text" },
486 },
487 ]);
488 });
489
490 it("persists a pending file as a needs-reattach marker instead of dropping it", () => {
491 const store = useComposerDraftStore.getState();
492 // No setFileUpload: the upload never finished, so there is no attachment
493 // id and the File handle cannot serialize.
494 store.addFiles(threadRef, [makeFile("file-pending")]);
495
496 const persistApi = useComposerDraftStore.persist as unknown as {
497 getOptions: () => {
498 merge: (
499 persistedState: unknown,
500 currentState: ReturnType<typeof useComposerDraftStore.getState>,
501 ) => ReturnType<typeof useComposerDraftStore.getState>;
502 };
503 };
504 const options = persistApi.getOptions();
505 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
506
507 expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual(
508 [
509 {
510 id: "file-pending",
511 name: "report.pdf",
512 mimeType: "application/pdf",
513 sizeBytes: 6,
514 },
515 ],
516 );
517
518 const hydrated = options.merge(persisted, useComposerDraftStore.getState());
519 const hydratedFiles =
520 hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files;
521 expect(hydratedFiles).toEqual([
522 {
523 type: "file",
524 id: "file-pending",
525 name: "report.pdf",
526 mimeType: "application/pdf",
527 sizeBytes: 6,
528 file: null,
529 },
530 ]);
531 expect(hydratedFiles?.every(composerFileNeedsReattach)).toBe(true);
532 });
533
534 it("marks only the matching byte-less upload as missing", () => {
535 const store = useComposerDraftStore.getState();
536 const hydrated: ComposerFileAttachment = {
537 ...makeFile("file-hydrated"),
538 file: null,
539 uploadedAttachmentId: "pending-old",
540 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
541 };
542 const local: ComposerFileAttachment = {
543 ...makeFile("file-local"),
544 name: "local.txt",
545 mimeType: "text/plain",
546 };
547 store.addFiles(threadRef, [hydrated, local]);
548 store.setFileUpload(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new");
549 store.setFileUpload(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local");
550
551 expect(
552 store.markFileUploadMissing(threadRef, hydrated.id, OTHER_TEST_ENVIRONMENT_ID, "pending-new"),
553 ).toBe(false);
554 expect(
555 store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-old"),
556 ).toBe(false);
557 expect(
558 store.markFileUploadMissing(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local"),
559 ).toBe(false);
560
561 expect(store.getComposerDraft(threadRef)?.files).toMatchObject([
562 {
563 id: hydrated.id,
564 uploadedAttachmentId: "pending-new",
565 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
566 },
567 {
568 id: local.id,
569 file: local.file,
570 uploadedAttachmentId: "pending-local",
571 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
572 },
573 ]);
574
575 expect(
576 store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new"),
577 ).toBe(true);
578 const marker = store.getComposerDraft(threadRef)?.files[0];
579 expect(marker && composerFileNeedsReattach(marker)).toBe(true);
580 expect(marker?.uploadedAttachmentId).toBeUndefined();
581 expect(marker?.uploadEnvironmentId).toBeUndefined();
582 });
583
584 it("removes generic files when the composer is cleared", () => {
585 const store = useComposerDraftStore.getState();
586 store.addFiles(threadRef, [makeFile("file-clear")]);
587
588 store.clearComposerContent(threadRef);
589
590 expect(store.getComposerDraft(threadRef)).toBeNull();
591 });
592
593 it("removes generic files when a prompt is moved into the stash", () => {
594 const store = useComposerDraftStore.getState();
595 store.setPrompt(threadRef, "Review the report");
596 store.addFiles(threadRef, [makeFile("file-stash")]);
597
598 store.clearComposerPromptAndImages(threadRef);
599
600 expect(store.getComposerDraft(threadRef)).toBeNull();
601 });
602
603 it("enforces the combined file and image limit across separate updates", () => {
604 const store = useComposerDraftStore.getState();
605 const images = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) =>
606 makeImage({
607 id: `image-${index}`,
608 name: `image-${index}.png`,
609 previewUrl: `blob:image-${index}`,
610 }),
611 );
612 store.addImages(threadRef, images);
613 store.addFiles(threadRef, [
614 makeFile("file-accepted"),
615 { ...makeFile("file-overflow"), name: "other.pdf" },
616 ]);
617 expect(
618 store.addImage(
619 threadRef,
620 makeImage({ id: "image-overflow", name: "overflow.png", previewUrl: "blob:overflow" }),
621 ),
622 ).toBe(false);
623
624 const draft = store.getComposerDraft(threadRef);
625 expect(draft?.images).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1);
626 expect(draft?.files.map((file) => file.id)).toEqual(["file-accepted"]);
627 });
628
629 it("replaces a needs-reattach marker when the same file is picked again", () => {
630 const store = useComposerDraftStore.getState();
631 // A hydrated marker: same metadata as the original pick, no bytes and no
632 // server-side upload.
633 const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null };
634 store.addFiles(threadRef, [marker]);
635 expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true);
636
637 // Following the "Attach again" instruction produces a fresh id with the
638 // exact metadata the dedup key hashes.
639 const repicked = makeFile("file-repicked");
640 store.addFiles(threadRef, [repicked]);
641
642 const files = store.getComposerDraft(threadRef)?.files;
643 expect(files?.map((file) => file.id)).toEqual(["file-repicked"]);
644 expect(files?.[0]?.file).not.toBeNull();
645 expect(files?.some(composerFileNeedsReattach)).toBe(false);
646 });
647
648 it("replaces a legacy video marker after its MIME type is normalized", () => {
649 const store = useComposerDraftStore.getState();
650 const marker: ComposerFileAttachment = {
651 type: "file",
652 id: "file-marker",
653 name: "clip.mkv",
654 mimeType: "application/octet-stream",
655 sizeBytes: 6,
656 file: null,
657 };
658 store.addFiles(threadRef, [marker]);
659
660 const file = new File(["report"], marker.name, { type: "video/x-matroska" });
661 const repicked: ComposerFileAttachment = {
662 type: "file",
663 id: "file-repicked",
664 name: file.name,
665 mimeType: file.type,
666 sizeBytes: file.size,
667 file,
668 };
669 store.addFiles(threadRef, [repicked, { ...repicked, id: "file-repicked-duplicate" }]);
670
671 const files = store.getComposerDraft(threadRef)?.files;
672 expect(files?.map((entry) => entry.id)).toEqual(["file-repicked"]);
673 expect(files?.some(composerFileNeedsReattach)).toBe(false);
674 });
675
676 it("replaces a needs-reattach marker with a stash-restored uploaded file", () => {
677 const store = useComposerDraftStore.getState();
678 const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null };
679 store.addFiles(threadRef, [marker]);
680 expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true);
681
682 // A stash restore carries a finished server-side upload instead of bytes.
683 // Matching metadata must replace the marker, not be dropped as a
684 // duplicate: the marker cannot send, and the restored ids are the only
685 // valid copy.
686 const restored: ComposerFileAttachment = {
687 ...makeFile("file-restored"),
688 file: null,
689 uploadedAttachmentId: "pending-stash-pdf",
690 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
691 };
692 store.addFiles(threadRef, [restored]);
693
694 const files = store.getComposerDraft(threadRef)?.files;
695 expect(files?.map((file) => file.id)).toEqual(["file-restored"]);
696 expect(files?.[0]?.uploadedAttachmentId).toBe("pending-stash-pdf");
697 expect(files?.[0]?.uploadEnvironmentId).toBe(TEST_ENVIRONMENT_ID);
698 expect(files?.some(composerFileNeedsReattach)).toBe(false);
699 });
700
701 it("still dedupes a re-pick against a file that does not need reattaching", () => {
702 const store = useComposerDraftStore.getState();
703 store.addFiles(threadRef, [makeFile("file-original")]);
704
705 store.addFiles(threadRef, [makeFile("file-duplicate")]);
706
707 expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([
708 "file-original",
709 ]);
710 });
711
712 it("restores files with matching metadata without replacing unsent bytes", () => {
713 const store = useComposerDraftStore.getState();
714 const unsent = makeFile("unsent");
715 const restored = {
716 ...unsent,
717 id: "restored",
718 file: new File(["edited"], unsent.name, { type: unsent.mimeType }),
719 };
720 store.addFiles(threadRef, [unsent]);
721 store.addFiles(threadRef, [restored, restored], { allowDuplicates: true });
722
723 const files = store.getComposerDraft(threadRef)?.files;
724 expect(files?.map((file) => file.id)).toEqual(["unsent", "restored"]);
725 expect(files?.[0]?.file).toBe(unsent.file);
726 expect(files?.[1]?.file).toBe(restored.file);
727 });
728
729 it("keeps same-name videos with different MIME types", () => {
730 const store = useComposerDraftStore.getState();
731 const mp4 = new File(["report"], "clip", { type: "video/mp4" });
732 const webm = new File(["report"], "clip", { type: "video/webm" });
733
734 store.addFiles(threadRef, [
735 {
736 type: "file",
737 id: "video-mp4",
738 name: mp4.name,
739 mimeType: mp4.type,
740 sizeBytes: mp4.size,
741 file: mp4,
742 },
743 {
744 type: "file",
745 id: "video-webm",
746 name: webm.name,
747 mimeType: webm.type,
748 sizeBytes: webm.size,
749 file: webm,
750 },
751 ]);
752
753 expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([
754 "video-mp4",
755 "video-webm",
756 ]);
757 });
758
759 it("keeps the remaining file slot available after a duplicate is skipped", () => {
760 const store = useComposerDraftStore.getState();
761 store.addImages(
762 threadRef,
763 Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2 }, (_, index) =>
764 makeImage({
765 id: `image-${index}`,
766 name: `image-${index}.png`,
767 previewUrl: `blob:image-${index}`,
768 }),
769 ),
770 );
771 store.addFiles(threadRef, [makeFile("file-original")]);
772 store.addFiles(threadRef, [
773 makeFile("file-duplicate"),
774 { ...makeFile("file-unique"), name: "unique.pdf" },
775 ]);
776
777 expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([
778 "file-original",
779 "file-unique",
780 ]);
781 });
782});
783
784describe("composerDraftStore syncPersistedAttachments", () => {
785 const threadId = ThreadId.make("thread-sync-persisted");
786 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
787
788 beforeEach(() => {
789 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
790 useComposerDraftStore.setState({
791 draftsByThreadKey: {},
792 draftThreadsByThreadKey: {},
793 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
794 stickyModelSelectionByProvider: {},
795 stickyActiveProvider: null,
796 });
797 });
798
799 afterEach(() => {
800 removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY);
801 });
802
803 it("treats malformed persisted draft storage as empty", async () => {
804 const image = makeImage({
805 id: "img-persisted",
806 previewUrl: "blob:persisted",
807 });
808 useComposerDraftStore.getState().addImage(threadRef, image);
809 setLocalStorageItem(
810 COMPOSER_DRAFT_STORAGE_KEY,
811 {
812 version: 2,
813 state: {
814 draftsByThreadId: {
815 [threadId]: {
816 attachments: "not-an-array",
817 },
818 },
819 },
820 },
821 Schema.Unknown,
822 );
823
824 useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [
825 {
826 id: image.id,
827 name: image.name,
828 mimeType: image.mimeType,
829 sizeBytes: image.sizeBytes,
830 dataUrl: image.previewUrl,
831 },
832 ]);
833 await Promise.resolve();
834
835 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]);
836 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]);
837 });
838});
839
840describe("composerDraftStore terminal contexts", () => {
841 const threadId = ThreadId.make("thread-dedupe");
842 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
843
844 beforeEach(() => {
845 useComposerDraftStore.setState({
846 draftsByThreadKey: {},
847 draftThreadsByThreadKey: {},
848 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
849 stickyModelSelectionByProvider: {},
850 stickyActiveProvider: null,
851 });
852 });
853
854 it("deduplicates identical terminal contexts by selection signature", () => {
855 const first = makeTerminalContext({ id: "ctx-1" });
856 const duplicate = makeTerminalContext({ id: "ctx-2" });
857
858 useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]);
859
860 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
861 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);
862 });
863
864 it("inserts terminal selections at the caret without overwriting the updated prompt", () => {
865 const store = useComposerDraftStore.getState();
866 const context = makeTerminalContext({ id: "caret-context" });
867 const reference = formatTerminalContextReference(context);
868 store.setPrompt(threadRef, "before after");
869 const unregister = store.setContextInsertionHandler(threadRef, () => {
870 store.setPrompt(threadRef, `before ${reference} after`);
871 return true;
872 });
873 store.addTerminalContexts(threadRef, [context]);
874 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(`before ${reference} after`);
875 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.terminalContexts).toHaveLength(1);
876 unregister?.();
877 });
878
879 it.each([false, true])(
880 "appends terminal selections with insertAtCaret=%s when not placed",
881 (insertAtCaret) => {
882 const store = useComposerDraftStore.getState();
883 const context = makeTerminalContext({ id: "append-context" });
884 const handler = vi.fn(() => false);
885 const unregister = store.setContextInsertionHandler(threadRef, handler);
886 store.setPrompt(threadRef, "before");
887 store.addTerminalContexts(threadRef, [context], { insertAtCaret });
888 expect(handler).toHaveBeenCalledTimes(insertAtCaret ? 1 : 0);
889 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(
890 `before ${formatTerminalContextReference(context)} `,
891 );
892 unregister?.();
893 },
894 );
895
896 it("normalizes legacy terminal ids before storing and removing their references", () => {
897 const store = useComposerDraftStore.getState();
898 store.setTerminalContexts(threadRef, [makeTerminalContext({ id: "old terminal:one" })]);
899 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID)!;
900 const id = draft.terminalContexts[0]!.id;
901 expect(id).toMatch(/^[a-z0-9_-]+$/i);
902 expect(draft.prompt).toContain(`/terminal/terminal_${id})`);
903 store.removeTerminalContext(threadRef, id);
904 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
905 });
906
907 it.each(["replace", "remove", "clear"])("removes terminal links on %s", (operation) => {
908 const store = useComposerDraftStore.getState();
909 store.setPrompt(threadRef, "Explain");
910 store.addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" }));
911 if (operation === "replace") store.setTerminalContexts(threadRef, []);
912 else if (operation === "remove") store.removeTerminalContext(threadRef, "ctx-1");
913 else store.clearTerminalContexts(threadRef);
914 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("Explain");
915 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.terminalContexts).toEqual([]);
916 });
917
918 it("clears terminal contexts when clearing composer content", () => {
919 useComposerDraftStore
920 .getState()
921 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" }));
922
923 useComposerDraftStore.getState().clearComposerContent(threadRef);
924
925 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
926 });
927
928 it("inserts terminal contexts at the requested inline prompt position", () => {
929 const first = makeTerminalContext({ id: "ctx-1" });
930 const second = makeTerminalContext({
931 id: "ctx-2",
932 terminalLabel: "Terminal 2",
933 lineStart: 9,
934 lineEnd: 10,
935 });
936 const firstInsertion = insertInlineContextReference(
937 "alpha beta",
938 6,
939 terminalContextReference(first),
940 );
941 const secondInsertion = insertInlineContextReference(
942 firstInsertion.prompt,
943 0,
944 terminalContextReference(second),
945 );
946
947 expect(
948 useComposerDraftStore
949 .getState()
950 .insertTerminalContext(threadRef, firstInsertion.prompt, first, 0),
951 ).toBe(true);
952 expect(
953 useComposerDraftStore
954 .getState()
955 .insertTerminalContext(threadRef, secondInsertion.prompt, second, 0),
956 ).toBe(true);
957
958 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
959 expect(draft?.prompt).toBe(
960 `${formatTerminalContextReference(second)} alpha ${formatTerminalContextReference(first)} beta`,
961 );
962 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-2", "ctx-1"]);
963 });
964
965 it("persists terminal context snapshot text", () => {
966 useComposerDraftStore
967 .getState()
968 .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" }));
969
970 const persistedState = partializeComposerDraftStoreState(
971 useComposerDraftStore.getState(),
972 ) as unknown as {
973 draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
974 };
975
976 expect(
977 persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
978 ?.terminalContexts?.[0],
979 "Expected terminal context metadata to be persisted.",
980 ).toMatchObject({
981 id: "ctx-persist",
982 terminalId: "default",
983 terminalLabel: "Terminal 1",
984 lineStart: 4,
985 lineEnd: 5,
986 });
987 expect(
988 persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
989 ?.terminalContexts?.[0]?.text,
990 ).toBe(makeTerminalContext({ id: "ctx-persist" }).text);
991 });
992
993 it("keeps legacy terminal contexts without saved snapshot text unavailable", () => {
994 const persistApi = useComposerDraftStore.persist as unknown as {
995 getOptions: () => {
996 merge: (
997 persistedState: unknown,
998 currentState: ReturnType<typeof useComposerDraftStore.getState>,
999 ) => ReturnType<typeof useComposerDraftStore.getState>;
1000 };
1001 };
1002 const mergedState = persistApi.getOptions().merge(
1003 {
1004 draftsByThreadId: {
1005 [threadId]: {
1006 prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
1007 attachments: [],
1008 terminalContexts: [
1009 {
1010 id: "ctx-rehydrated",
1011 threadId,
1012 createdAt: "2026-03-13T12:00:00.000Z",
1013 terminalId: "default",
1014 terminalLabel: "Terminal 1",
1015 lineStart: 4,
1016 lineEnd: 5,
1017 },
1018 ],
1019 },
1020 },
1021 draftThreadsByThreadId: {},
1022 projectDraftThreadIdByProjectKey: {},
1023 },
1024 useComposerDraftStore.getInitialState(),
1025 );
1026
1027 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.prompt).toBe(
1028 "[Terminal 1 lines 4-5](t3-context://v1/terminal/terminal_ctx-rehydrated)",
1029 );
1030 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([
1031 {
1032 id: "ctx-rehydrated",
1033 terminalId: "default",
1034 terminalLabel: "Terminal 1",
1035 lineStart: 4,
1036 lineEnd: 5,
1037 text: "",
1038 },
1039 ]);
1040 });
1041
1042 it("sanitizes malformed persisted drafts during merge", () => {
1043 const persistApi = useComposerDraftStore.persist as unknown as {
1044 getOptions: () => {
1045 merge: (
1046 persistedState: unknown,
1047 currentState: ReturnType<typeof useComposerDraftStore.getState>,
1048 ) => ReturnType<typeof useComposerDraftStore.getState>;
1049 };
1050 };
1051 const mergedState = persistApi.getOptions().merge(
1052 {
1053 draftsByThreadId: {
1054 [threadId]: {
1055 prompt: "",
1056 attachments: "not-an-array",
1057 terminalContexts: "not-an-array",
1058 provider: "bogus-provider",
1059 modelOptions: "not-an-object",
1060 },
1061 },
1062 draftThreadsByThreadId: "not-an-object",
1063 projectDraftThreadIdByProjectKey: "not-an-object",
1064 },
1065 useComposerDraftStore.getInitialState(),
1066 );
1067
1068 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined();
1069 expect(mergedState.draftThreadsByThreadKey).toEqual({});
1070 expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({});
1071 });
1072});
1073
1074describe("composerDraftStore context persistence", () => {
1075 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("context-persistence"));
1076 const annotation: PreviewAnnotationPayload = {
1077 id: "retry-note",
1078 pageUrl: "http://localhost:3000/checkout",
1079 pageTitle: "Checkout",
1080 comment: "Keep the cart after a failed payment",
1081 elements: [],
1082 regions: [{ id: "error-region", rect: { x: 10, y: 20, width: 100, height: 50 } }],
1083 strokes: [],
1084 styleChanges: [],
1085 screenshot: null,
1086 createdAt: "2026-09-06T10:00:00.000Z",
1087 };
1088
1089 beforeEach(resetComposerDraftStore);
1090
1091 it("preserves snapshots and inline positions through repeated persistence and hydration", () => {
1092 const terminal = {
1093 ...makeTerminalContext({ id: "checkout-console" }),
1094 threadId: threadRef.threadId,
1095 };
1096 const store = useComposerDraftStore.getState();
1097 store.addTerminalContext(threadRef, terminal);
1098 store.addPreviewAnnotation(threadRef, annotation);
1099 const terminalLink = formatTerminalContextReference(terminal);
1100 const prompt = `Inspect ${terminalLink}. Apply [Retry note](t3-context://v1/preview-annotation/preview-annotation_retry-note). Compare ${terminalLink} again.`;
1101 store.setPrompt(threadRef, prompt);
1102 let state = useComposerDraftStore.getState();
1103 const merge = useComposerDraftStore.persist.getOptions().merge!;
1104
1105 for (let reload = 0; reload < 3; reload++) {
1106 state = merge(
1107 JSON.parse(JSON.stringify(partializeComposerDraftStoreState(state))),
1108 useComposerDraftStore.getInitialState(),
1109 );
1110 const draft = state.draftsByThreadKey[scopedThreadKey(threadRef)];
1111 expect(draft?.prompt).toBe(prompt);
1112 expect(draft?.terminalContexts).toEqual([terminal]);
1113 expect(draft?.previewAnnotations).toEqual([annotation]);
1114 }
1115 });
1116
1117 it("retains annotation-only drafts and filters malformed annotations", () => {
1118 const merge = useComposerDraftStore.persist.getOptions().merge!;
1119 const state = merge(
1120 {
1121 draftsByThreadKey: {
1122 [scopedThreadKey(threadRef)]: {
1123 prompt: "",
1124 attachments: [],
1125 previewAnnotations: [annotation, { id: "invalid" }, null],
1126 },
1127 },
1128 },
1129 useComposerDraftStore.getInitialState(),
1130 );
1131 const draft = state.draftsByThreadKey[scopedThreadKey(threadRef)];
1132 expect(draft?.previewAnnotations).toEqual([annotation]);
1133 expect(draft?.prompt).toContain(
1134 "t3-context://v1/preview-annotation/preview-annotation_retry-note",
1135 );
1136 });
1137});
1138
1139describe("composerDraftStore review comments", () => {
1140 const threadId = ThreadId.make("thread-review-comment");
1141 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1142 const comment = {
1143 id: "comment-1",
1144 sectionId: "file:src/app.ts",
1145 sectionTitle: "File comment",
1146 filePath: "src/app.ts",
1147 startIndex: 1,
1148 endIndex: 2,
1149 rangeLabel: "L2 to L3",
1150 text: "Keep this configurable.",
1151 diff: "@@ -2,2 +2,2 @@\n two\n three",
1152 } as const;
1153
1154 beforeEach(() => {
1155 resetComposerDraftStore();
1156 });
1157
1158 it("upserts and removes review comments by id", () => {
1159 const store = useComposerDraftStore.getState();
1160 store.addReviewComment(threadRef, comment);
1161 store.addReviewComment(threadRef, { ...comment, text: "Updated comment." });
1162
1163 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.reviewComments).toEqual([
1164 { ...comment, text: "Updated comment." },
1165 ]);
1166
1167 store.removeReviewComment(threadRef, comment.id);
1168 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
1169 });
1170
1171 it("persists review comments and clears them with composer content", () => {
1172 const store = useComposerDraftStore.getState();
1173 store.addReviewComment(threadRef, comment);
1174 const persisted = partializeComposerDraftStoreState(
1175 useComposerDraftStore.getState(),
1176 ) as unknown as {
1177 draftsByThreadKey?: Record<string, { reviewComments?: Array<Record<string, unknown>> }>;
1178 };
1179
1180 expect(
1181 persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]
1182 ?.reviewComments?.[0],
1183 ).toMatchObject(comment);
1184
1185 store.clearComposerContent(threadRef);
1186 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
1187 });
1188
1189 it("stores review comments against a new-thread draft id", () => {
1190 const draftId = DraftId.make("draft-review-comment");
1191 useComposerDraftStore.getState().addReviewComment(draftId, comment);
1192
1193 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.reviewComments).toEqual([
1194 comment,
1195 ]);
1196 });
1197});
1198
1199describe("composerDraftStore thread contexts", () => {
1200 const threadId = ThreadId.make("thread-with-context");
1201 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1202 const attached = threadContextRecord(
1203 scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("attached-thread")),
1204 "Fix [login] flow",
1205 );
1206
1207 beforeEach(resetComposerDraftStore);
1208
1209 it("attaches once per thread, appends one chip, and survives persistence", () => {
1210 const store = useComposerDraftStore.getState();
1211 store.setPrompt(threadRef, "Compare with");
1212 store.addThreadContexts(threadRef, [attached]);
1213 store.addThreadContexts(threadRef, [attached, { ...attached, title: "renamed" }]);
1214
1215 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
1216 expect(draft?.threadContexts).toEqual([attached]);
1217 expect(attached.label).toBe("Fix login flow");
1218 expect(draft?.prompt).toBe(
1219 `Compare with [${attached.label}](t3-context://v1/thread/${attached.contextId}) `,
1220 );
1221
1222 const merge = useComposerDraftStore.persist.getOptions().merge!;
1223 const hydrated = merge(
1224 JSON.parse(
1225 JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())),
1226 ),
1227 useComposerDraftStore.getInitialState(),
1228 );
1229 expect(hydrated.draftsByThreadKey[scopedThreadKey(threadRef)]?.threadContexts).toEqual([
1230 attached,
1231 ]);
1232 expect(hydrated.draftsByThreadKey[scopedThreadKey(threadRef)]?.prompt).toBe(draft?.prompt);
1233 });
1234
1235 it("drops the chip with the record and removes an otherwise empty draft", () => {
1236 const store = useComposerDraftStore.getState();
1237 store.addThreadContexts(threadRef, [attached]);
1238 store.setThreadContexts(threadRef, []);
1239 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
1240 });
1241});
1242
1243describe("composerDraftStore project draft thread mapping", () => {
1244 const projectId = ProjectId.make("project-a");
1245 const otherProjectId = ProjectId.make("project-b");
1246 const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId);
1247 const otherProjectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, otherProjectId);
1248 const remoteProjectRef = scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, projectId);
1249 const threadId = ThreadId.make("thread-a");
1250 const otherThreadId = ThreadId.make("thread-b");
1251 const draftId = DraftId.make("draft-a");
1252 const otherDraftId = DraftId.make("draft-b");
1253 const sharedDraftId = DraftId.make("draft-shared");
1254 const localDraftId = DraftId.make("draft-local");
1255 const remoteDraftId = DraftId.make("draft-remote");
1256
1257 beforeEach(() => {
1258 resetComposerDraftStore();
1259 });
1260
1261 it("clears composer data for one environment without touching another", () => {
1262 const store = useComposerDraftStore.getState();
1263 const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1264 const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, otherThreadId);
1265 const originalRevokeObjectUrl = URL.revokeObjectURL;
1266 const revokeSpy = vi.fn<(url: string) => void>();
1267 URL.revokeObjectURL = revokeSpy;
1268
1269 try {
1270 store.setProjectDraftThreadId(projectRef, localDraftId, { threadId });
1271 store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, {
1272 threadId: otherThreadId,
1273 });
1274 store.setPrompt(localDraftId, "local draft");
1275 store.setPrompt(remoteDraftId, "remote draft");
1276 store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" }));
1277 store.setPrompt(localThreadRef, "local thread draft");
1278 store.setPrompt(remoteThreadRef, "remote thread draft");
1279 useComposerDraftStore.setState({
1280 rewindingThreadKeys: new Set([
1281 threadKeyFor(threadId, TEST_ENVIRONMENT_ID),
1282 threadKeyFor(otherThreadId, OTHER_TEST_ENVIRONMENT_ID),
1283 ]),
1284 });
1285
1286 clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID);
1287
1288 const next = useComposerDraftStore.getState();
1289 expect([...next.rewindingThreadKeys]).toEqual([
1290 threadKeyFor(otherThreadId, OTHER_TEST_ENVIRONMENT_ID),
1291 ]);
1292 expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull();
1293 expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull();
1294 expect(next.getComposerDraft(localDraftId)).toBeNull();
1295 expect(next.getComposerDraft(remoteDraftId)?.prompt).toBe("remote thread draft");
1296 expect(next.getComposerDraft(localThreadRef)).toBeNull();
1297 expect(next.getComposerDraft(remoteThreadRef)?.prompt).toBe("remote thread draft");
1298 expect(revokeSpy).toHaveBeenCalledWith("blob:local-draft");
1299 } finally {
1300 URL.revokeObjectURL = originalRevokeObjectUrl;
1301 }
1302 });
1303
1304 it("stores and reads project draft thread ids via actions", () => {
1305 const store = useComposerDraftStore.getState();
1306 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
1307 expect(store.getDraftThread(draftId)).toBeNull();
1308
1309 store.setProjectDraftThreadId(projectRef, draftId, {
1310 threadId,
1311 branch: "feature/test",
1312 worktreePath: "/tmp/worktree-test",
1313 createdAt: "2026-01-01T00:00:00.000Z",
1314 });
1315 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toMatchObject({
1316 threadId,
1317 environmentId: TEST_ENVIRONMENT_ID,
1318 projectId,
1319 logicalProjectKey: scopedProjectKey(projectRef),
1320 branch: "feature/test",
1321 worktreePath: "/tmp/worktree-test",
1322 envMode: "worktree",
1323 runtimeMode: "full-access",
1324 interactionMode: "default",
1325 createdAt: "2026-01-01T00:00:00.000Z",
1326 });
1327 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
1328 environmentId: TEST_ENVIRONMENT_ID,
1329 projectId,
1330 logicalProjectKey: scopedProjectKey(projectRef),
1331 branch: "feature/test",
1332 worktreePath: "/tmp/worktree-test",
1333 envMode: "worktree",
1334 runtimeMode: "full-access",
1335 interactionMode: "default",
1336 createdAt: "2026-01-01T00:00:00.000Z",
1337 });
1338 });
1339
1340 it("removes a draft's previous project mapping when retargeted in place", () => {
1341 const store = useComposerDraftStore.getState();
1342 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1343 store.setPrompt(draftId, "keep this prompt");
1344
1345 store.setProjectDraftThreadId(otherProjectRef, draftId, { threadId });
1346
1347 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
1348 expect(store.getDraftThreadByProjectRef(otherProjectRef)?.draftId).toBe(draftId);
1349 expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt");
1350 });
1351
1352 it("rotates a failed bootstrap thread id without losing its draft", () => {
1353 const store = useComposerDraftStore.getState();
1354 const retryThreadId = ThreadId.make("thread-retry");
1355 store.setProjectDraftThreadId(projectRef, draftId, {
1356 threadId,
1357 branch: "feature/test",
1358 worktreePath: null,
1359 createdAt: "2026-01-01T00:00:00.000Z",
1360 envMode: "worktree",
1361 startFromOrigin: true,
1362 runtimeMode: "approval-required",
1363 interactionMode: "plan",
1364 });
1365 store.setPrompt(draftId, "keep this prompt");
1366 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1367
1368 store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, {
1369 threadId: retryThreadId,
1370 createdAt: "2026-01-01T00:01:00.000Z",
1371 });
1372
1373 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
1374 threadId: retryThreadId,
1375 branch: "feature/test",
1376 worktreePath: null,
1377 createdAt: "2026-01-01T00:01:00.000Z",
1378 envMode: "worktree",
1379 startFromOrigin: true,
1380 runtimeMode: "approval-required",
1381 interactionMode: "plan",
1382 promotedTo: null,
1383 });
1384 expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toBe(
1385 "keep this prompt",
1386 );
1387 });
1388
1389 it("clears only matching project draft mapping entries", () => {
1390 const store = useComposerDraftStore.getState();
1391 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1392 store.setPrompt(draftId, "hello");
1393
1394 store.clearProjectDraftThreadById(projectRef, otherDraftId);
1395 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
1396 threadId,
1397 );
1398
1399 store.clearProjectDraftThreadById(projectRef, draftId);
1400 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1401 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1402 expect(draftByKey(draftId)).toBeUndefined();
1403 });
1404
1405 it("clears project draft mapping by project id", () => {
1406 const store = useComposerDraftStore.getState();
1407 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1408 store.setPrompt(draftId, "hello");
1409 store.clearProjectDraftThreadId(projectRef);
1410 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1411 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1412 expect(draftByKey(draftId)).toBeUndefined();
1413 });
1414
1415 it("revokes draft image blob URLs when clearing a project's draft thread", () => {
1416 const store = useComposerDraftStore.getState();
1417 const originalRevokeObjectUrl = URL.revokeObjectURL;
1418 const revokeSpy = vi.fn<(url: string) => void>();
1419 URL.revokeObjectURL = revokeSpy;
1420
1421 try {
1422 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1423 store.addImage(draftId, makeImage({ id: "img-project-clear", previewUrl: "blob:clear" }));
1424
1425 store.clearProjectDraftThreadId(projectRef);
1426
1427 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1428 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1429 expect(revokeSpy).toHaveBeenCalledWith("blob:clear");
1430 } finally {
1431 URL.revokeObjectURL = originalRevokeObjectUrl;
1432 }
1433 });
1434
1435 it("revokes draft image blob URLs when clearing a matching project draft thread by id", () => {
1436 const store = useComposerDraftStore.getState();
1437 const originalRevokeObjectUrl = URL.revokeObjectURL;
1438 const revokeSpy = vi.fn<(url: string) => void>();
1439 URL.revokeObjectURL = revokeSpy;
1440
1441 try {
1442 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1443 store.addImage(
1444 draftId,
1445 makeImage({ id: "img-project-clear-by-id", previewUrl: "blob:clear-by-id" }),
1446 );
1447
1448 store.clearProjectDraftThreadById(projectRef, draftId);
1449
1450 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1451 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1452 expect(revokeSpy).toHaveBeenCalledWith("blob:clear-by-id");
1453 } finally {
1454 URL.revokeObjectURL = originalRevokeObjectUrl;
1455 }
1456 });
1457
1458 it("clears empty composer drafts when remapping a project to a new draft thread", () => {
1459 const store = useComposerDraftStore.getState();
1460 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1461
1462 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
1463
1464 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
1465 otherThreadId,
1466 );
1467 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1468 expect(draftByKey(draftId)).toBeUndefined();
1469 });
1470
1471 it("keeps a draft with an open page when remapping a project to a new draft thread", () => {
1472 const store = useComposerDraftStore.getState();
1473 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1474 // A link another app opened: the composer is empty, but the page is not.
1475 applyPreviewServerSnapshot(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), {
1476 threadId,
1477 tabId: "tab-link",
1478 navStatus: { _tag: "Success", url: "https://example.com/", title: "Example" },
1479 canGoBack: false,
1480 canGoForward: false,
1481 updatedAt: "2026-10-09T00:00:00.000Z",
1482 });
1483 try {
1484 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
1485
1486 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId);
1487 // It survives a reload too, so the page is still there when the app comes back.
1488 const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState());
1489 expect(persisted.draftThreadsByThreadKey[draftId]?.threadId).toBe(threadId);
1490 } finally {
1491 resetPreviewStateForTests();
1492 }
1493 });
1494
1495 it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => {
1496 const store = useComposerDraftStore.getState();
1497 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1498 store.setPrompt(draftId, "keep me around");
1499
1500 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
1501
1502 // The mapping moved to the fresh draft...
1503 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
1504 otherThreadId,
1505 );
1506 // ...but the invested draft survives with its content for the sidebar
1507 // draft rows to surface.
1508 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId);
1509 expect(draftByKey(draftId)?.prompt).toBe("keep me around");
1510 });
1511
1512 it("clears every session for a project, including unmapped invested drafts", () => {
1513 const store = useComposerDraftStore.getState();
1514 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1515 store.setPrompt(draftId, "invested");
1516 // The remap leaves the invested draft alive unmapped; project removal
1517 // must still sweep it, or its sidebar row outlives the project.
1518 store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId });
1519
1520 store.clearProjectDraftThreadId(projectRef);
1521
1522 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1523 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1524 expect(useComposerDraftStore.getState().getDraftThread(otherDraftId)).toBeNull();
1525 expect(draftByKey(draftId)).toBeUndefined();
1526 });
1527
1528 it("keeps composer drafts when the thread is still mapped by another project", () => {
1529 const store = useComposerDraftStore.getState();
1530 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1531 store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId });
1532 store.setPrompt(sharedDraftId, "keep me");
1533
1534 store.clearProjectDraftThreadId(projectRef);
1535
1536 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1537 expect(
1538 useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
1539 ).toBe(threadId);
1540 expect(draftByKey(sharedDraftId)?.prompt).toBe("keep me");
1541 });
1542
1543 it("clears draft registration independently", () => {
1544 const store = useComposerDraftStore.getState();
1545 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1546 store.setPrompt(draftId, "remove me");
1547 store.clearDraftThread(draftId);
1548 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1549 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1550 expect(draftByKey(draftId)).toBeUndefined();
1551 });
1552
1553 it("marks a promoted draft by scoped ref without deleting composer state", () => {
1554 const store = useComposerDraftStore.getState();
1555 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1556 store.setPrompt(draftId, "promote me");
1557
1558 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1559
1560 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1561 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
1562 scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
1563 );
1564 expect(draftByKey(draftId)?.prompt).toBe("promote me");
1565 });
1566
1567 it("reads local draft composer state through a scoped thread ref", () => {
1568 const store = useComposerDraftStore.getState();
1569 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1570
1571 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1572 store.setPrompt(draftId, "scoped access");
1573
1574 expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access");
1575 expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access");
1576 });
1577
1578 it("does not clear composer drafts for existing server threads during promotion cleanup", () => {
1579 const store = useComposerDraftStore.getState();
1580 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1581 store.setPrompt(threadRef, "keep me");
1582
1583 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1584
1585 expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull();
1586 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me");
1587 });
1588
1589 it("promotes a draft without changing another thread's draft", () => {
1590 const store = useComposerDraftStore.getState();
1591 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1592 store.setPrompt(draftId, "promote me");
1593 store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId });
1594 store.setPrompt(otherDraftId, "keep me");
1595
1596 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1597
1598 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual(
1599 scopeThreadRef(TEST_ENVIRONMENT_ID, threadId),
1600 );
1601 expect(draftByKey(draftId)?.prompt).toBe("promote me");
1602 expect(
1603 useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId,
1604 ).toBe(otherThreadId);
1605 expect(draftByKey(otherDraftId)?.prompt).toBe("keep me");
1606 });
1607
1608 it("promotes matching thread ids separately for each environment", () => {
1609 const store = useComposerDraftStore.getState();
1610 const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1611 const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId);
1612
1613 store.setProjectDraftThreadId(projectRef, localDraftId, { threadId });
1614 store.setPrompt(localDraftId, "local draft");
1615 store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId });
1616 store.setPrompt(remoteDraftId, "remote draft");
1617
1618 markPromotedDraftThreadByRef(localThreadRef);
1619
1620 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
1621 expect(store.getDraftThreadByProjectRef(remoteProjectRef)?.threadId).toBe(threadId);
1622 expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef);
1623 expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toBeNull();
1624 expect(draftByKey(localDraftId)?.prompt).toBe("local draft");
1625 expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft");
1626
1627 markPromotedDraftThreadByRef(remoteThreadRef);
1628
1629 expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull();
1630 expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull();
1631 expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef);
1632 expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toEqual(remoteThreadRef);
1633 expect(draftByKey(localDraftId)?.prompt).toBe("local draft");
1634 expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft");
1635 });
1636
1637 it("only marks promoted drafts for the matching environment ref", () => {
1638 const store = useComposerDraftStore.getState();
1639 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1640 store.setPrompt(draftId, "promote me");
1641
1642 markPromotedDraftThreadByRef(scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId));
1643
1644 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
1645 threadId,
1646 );
1647 expect(draftByKey(draftId)?.prompt).toBe("promote me");
1648 });
1649
1650 it("keeps background submission pending until navigation or failure releases it", () => {
1651 const store = useComposerDraftStore.getState();
1652 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1653 const ref = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1654 const key = scopedThreadKey(ref);
1655 beginBackgroundDraftSubmissionByRef(ref);
1656 expect(useComposerDraftStore.getState().backgroundSubmissionThreadKeys[key]).toBe(true);
1657 clearBackgroundDraftSubmissionByRef(ref);
1658 expect(useComposerDraftStore.getState().backgroundSubmissionThreadKeys[key]).toBeUndefined();
1659 expect(useComposerDraftStore.getState().getDraftSession(draftId)).not.toBeNull();
1660 beginBackgroundDraftSubmissionByRef(ref);
1661 finalizePromotedDraftThreadByRef(ref);
1662 expect(useComposerDraftStore.getState().backgroundSubmissionThreadKeys[key]).toBeUndefined();
1663 expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull();
1664 });
1665
1666 it("restores a failed background draft without changing the fresh draft", () => {
1667 const store = useComposerDraftStore.getState();
1668 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1669 const sentDraft = useComposerDraftStore.getState().getDraftSession(draftId)!;
1670 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1671 const freshId = DraftId.make("fresh-background-draft");
1672 store.setProjectDraftThreadId(projectRef, freshId, { threadId: ThreadId.make("fresh-thread") });
1673 store.setPrompt(freshId, "new work");
1674 restoreFailedBackgroundDraftThread(draftId, sentDraft, ThreadId.make("retry-thread"));
1675 store.setPrompt(draftId, "retry work");
1676 expect(useComposerDraftStore.getState().getDraftSession(draftId)?.promotedTo).toBeNull();
1677 expect(useComposerDraftStore.getState().getDraftSession(draftId)?.threadId).toBe(
1678 "retry-thread",
1679 );
1680 expect(draftByKey(freshId)?.prompt).toBe("new work");
1681 expect(draftByKey(draftId)?.prompt).toBe("retry work");
1682 });
1683
1684 it("moves composer edits made during promotion to the canonical thread", () => {
1685 const store = useComposerDraftStore.getState();
1686 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1687 markPromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1688 store.setPrompt(draftId, "typed during setup");
1689
1690 finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1691
1692 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1693 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1694 expect(draftByKey(draftId)).toBeUndefined();
1695 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup");
1696 });
1697
1698 it("finalizes a matching materialized draft even when promotion was not pre-marked", () => {
1699 const store = useComposerDraftStore.getState();
1700 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1701 store.setPrompt(draftId, "promote me");
1702
1703 finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId));
1704
1705 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull();
1706 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull();
1707 expect(draftByKey(draftId)).toBeUndefined();
1708 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("promote me");
1709 });
1710
1711 it("updates branch context on an existing draft thread", () => {
1712 const store = useComposerDraftStore.getState();
1713 store.setProjectDraftThreadId(projectRef, draftId, {
1714 threadId,
1715 branch: "main",
1716 worktreePath: null,
1717 });
1718 store.setDraftThreadContext(draftId, {
1719 branch: "feature/next",
1720 worktreePath: "/tmp/feature-next",
1721 });
1722 expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe(
1723 threadId,
1724 );
1725 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
1726 environmentId: TEST_ENVIRONMENT_ID,
1727 projectId,
1728 branch: "feature/next",
1729 worktreePath: "/tmp/feature-next",
1730 envMode: "worktree",
1731 });
1732 });
1733
1734 it("stores the start-from-origin choice with the draft thread", () => {
1735 const store = useComposerDraftStore.getState();
1736 store.setProjectDraftThreadId(projectRef, draftId, {
1737 threadId,
1738 envMode: "worktree",
1739 startFromOrigin: true,
1740 });
1741
1742 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(true);
1743
1744 store.setDraftThreadContext(draftId, { startFromOrigin: false });
1745
1746 expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(false);
1747 });
1748
1749 it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {
1750 const store = useComposerDraftStore.getState();
1751 store.setProjectDraftThreadId(projectRef, draftId, {
1752 threadId,
1753 branch: "main",
1754 worktreePath: "/tmp/main-worktree",
1755 });
1756 const runtimeUndefinedOptions = {
1757 branch: undefined,
1758 worktreePath: undefined,
1759 } as unknown as {
1760 branch?: string | null;
1761 worktreePath?: string | null;
1762 };
1763 store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
1764
1765 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
1766 environmentId: TEST_ENVIRONMENT_ID,
1767 projectId,
1768 branch: "main",
1769 worktreePath: "/tmp/main-worktree",
1770 envMode: "worktree",
1771 });
1772 });
1773
1774 it("preserves worktree env mode without a worktree path", () => {
1775 const store = useComposerDraftStore.getState();
1776 store.setProjectDraftThreadId(projectRef, draftId, {
1777 threadId,
1778 branch: "feature/base",
1779 worktreePath: null,
1780 envMode: "worktree",
1781 });
1782 const runtimeUndefinedOptions = {
1783 branch: undefined,
1784 worktreePath: undefined,
1785 envMode: undefined,
1786 } as unknown as {
1787 branch?: string | null;
1788 worktreePath?: string | null;
1789 envMode?: "local" | "worktree";
1790 };
1791 store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions);
1792
1793 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
1794 environmentId: TEST_ENVIRONMENT_ID,
1795 projectId,
1796 branch: "feature/base",
1797 worktreePath: null,
1798 envMode: "worktree",
1799 });
1800 });
1801
1802 it("clears branch and worktree but keeps env mode when remapping a draft to another environment", () => {
1803 const store = useComposerDraftStore.getState();
1804 store.setProjectDraftThreadId(projectRef, draftId, {
1805 threadId,
1806 branch: "feature/local-only",
1807 worktreePath: "/tmp/local-worktree",
1808 envMode: "worktree",
1809 startFromOrigin: true,
1810 });
1811
1812 store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), remoteProjectRef, draftId, {
1813 threadId,
1814 });
1815
1816 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
1817 environmentId: OTHER_TEST_ENVIRONMENT_ID,
1818 projectId,
1819 branch: null,
1820 worktreePath: null,
1821 envMode: "worktree",
1822 startFromOrigin: true,
1823 });
1824 });
1825
1826 it("clears stale upload metadata when retargeting a draft to another environment", () => {
1827 const store = useComposerDraftStore.getState();
1828 const hydratedFile: ComposerFileAttachment = {
1829 ...makeFile("file-cross-environment"),
1830 file: null,
1831 uploadedAttachmentId: "local-environment-upload",
1832 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
1833 };
1834
1835 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1836 store.addFiles(draftId, [hydratedFile]);
1837
1838 store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId });
1839
1840 const file = store.getComposerDraft(draftId)?.files[0];
1841 expect(file).toMatchObject({
1842 id: hydratedFile.id,
1843 file: null,
1844 });
1845 expect(file?.uploadedAttachmentId).toBeUndefined();
1846 expect(file?.uploadEnvironmentId).toBeUndefined();
1847 expect(file && composerFileNeedsReattach(file)).toBe(true);
1848 });
1849
1850 it("rechecks balancing when an empty draft is remapped to another project member", () => {
1851 const store = useComposerDraftStore.getState();
1852 store.setProjectDraftThreadId(projectRef, draftId, {
1853 threadId,
1854 environmentSelection: "auto",
1855 loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID,
1856 });
1857 store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId });
1858 expect(store.getDraftThread(draftId)).toMatchObject({
1859 environmentSelection: "auto",
1860 loadBalancedEnvironmentId: null,
1861 });
1862 store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID });
1863 store.setDraftThreadContext(draftId, { projectRef });
1864 expect(store.getDraftThread(draftId)).toMatchObject({
1865 environmentSelection: "auto",
1866 loadBalancedEnvironmentId: null,
1867 });
1868 });
1869
1870 it("does not opt a legacy branch choice into balancing when runtime mode changes", () => {
1871 const store = useComposerDraftStore.getState();
1872 store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" });
1873 store.setDraftThreadContext(draftId, { runtimeMode: "full-access" });
1874 expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined();
1875 expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned");
1876 });
1877
1878 it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => {
1879 const store = useComposerDraftStore.getState();
1880 store.setProjectDraftThreadId(projectRef, draftId, { threadId });
1881 store.setPrompt(draftId, "keep this prompt");
1882 store.setDraftThreadContext(draftId, {
1883 projectRef: remoteProjectRef,
1884 environmentSelection: "auto",
1885 loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID,
1886 });
1887 expect(store.getDraftThread(draftId)).toMatchObject({
1888 environmentId: OTHER_TEST_ENVIRONMENT_ID,
1889 environmentSelection: "auto",
1890 loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID,
1891 });
1892 store.setDraftThreadContext(draftId, { branch: "feature/pinned" });
1893 expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual");
1894 store.setDraftThreadContext(draftId, {
1895 branch: null,
1896 environmentSelection: "auto",
1897 loadBalancedEnvironmentId: null,
1898 });
1899 expect(store.getDraftThread(draftId)).toMatchObject({
1900 branch: null,
1901 environmentSelection: "auto",
1902 loadBalancedEnvironmentId: null,
1903 });
1904 expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt");
1905 });
1906
1907 it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => {
1908 const store = useComposerDraftStore.getState();
1909 store.setProjectDraftThreadId(projectRef, draftId, {
1910 threadId,
1911 branch: "feature/local-only",
1912 worktreePath: "/tmp/local-worktree",
1913 envMode: "worktree",
1914 startFromOrigin: true,
1915 });
1916
1917 store.setDraftThreadContext(draftId, {
1918 projectRef: remoteProjectRef,
1919 });
1920
1921 expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({
1922 environmentId: OTHER_TEST_ENVIRONMENT_ID,
1923 projectId,
1924 branch: null,
1925 worktreePath: null,
1926 envMode: "worktree",
1927 startFromOrigin: true,
1928 });
1929 });
1930});
1931
1932describe("composerDraftStore modelSelection", () => {
1933 const threadId = ThreadId.make("thread-model-options");
1934 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
1935
1936 beforeEach(() => {
1937 resetComposerDraftStore();
1938 });
1939
1940 it("stores a model selection in the draft", () => {
1941 const store = useComposerDraftStore.getState();
1942 store.setModelSelection(
1943 threadRef,
1944 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
1945 reasoningEffort: "xhigh",
1946 fastMode: true,
1947 }),
1948 );
1949
1950 expect(
1951 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
1952 ).toEqual(
1953 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
1954 reasoningEffort: "xhigh",
1955 fastMode: true,
1956 }),
1957 );
1958 });
1959
1960 it("keeps default-only model selections on the draft", () => {
1961 const store = useComposerDraftStore.getState();
1962 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
1963
1964 expect(
1965 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
1966 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4"));
1967 });
1968
1969 it("marks picker writes explicit and seeding writes non-explicit", () => {
1970 const store = useComposerDraftStore.getState();
1971 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
1972 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined();
1973
1974 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), {
1975 explicit: true,
1976 });
1977 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true);
1978
1979 // Last writer defines intent: a later seed clears the marker.
1980 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), {
1981 replaceOptions: true,
1982 });
1983 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined();
1984 });
1985
1986 it("persists the explicit marker through storage round-trips", async () => {
1987 vi.useFakeTimers();
1988 try {
1989 useComposerDraftStore
1990 .getState()
1991 .setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), {
1992 explicit: true,
1993 });
1994 // Land the debounced persist write.
1995 await vi.advanceTimersByTimeAsync(300);
1996
1997 // Hydrate from the same storage the store persists into and verify the
1998 // marker survives the partialize → decode → merge path.
1999 resetComposerDraftStore();
2000 await useComposerDraftStore.persist.rehydrate();
2001 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true);
2002 expect(
2003 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
2004 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4"));
2005 } finally {
2006 vi.useRealTimers();
2007 }
2008 });
2009
2010 it("replaces only the targeted provider options on the current model selection", () => {
2011 const store = useComposerDraftStore.getState();
2012
2013 store.setModelSelection(
2014 threadRef,
2015 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2016 effort: "max",
2017 fastMode: true,
2018 }),
2019 );
2020 store.setStickyModelSelection(
2021 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2022 effort: "max",
2023 fastMode: true,
2024 }),
2025 );
2026
2027 store.setProviderModelOptions(
2028 threadRef,
2029 CLAUDE_AGENT_DRIVER,
2030 toSelections({ thinking: false }),
2031 {
2032 persistSticky: true,
2033 },
2034 );
2035
2036 expect(
2037 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
2038 ).toEqual(
2039 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2040 thinking: false,
2041 }),
2042 );
2043 expect(
2044 useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
2045 ).toEqual(
2046 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2047 thinking: false,
2048 }),
2049 );
2050 });
2051
2052 it("marks trait edits as explicit model intent", () => {
2053 const store = useComposerDraftStore.getState();
2054 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
2055 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined();
2056
2057 store.setProviderModelOptions(
2058 threadRef,
2059 CODEX_DRIVER,
2060 toSelections({ reasoningEffort: "xhigh" }),
2061 );
2062
2063 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true);
2064 expect(
2065 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
2066 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4", { reasoningEffort: "xhigh" }));
2067 });
2068
2069 it("keeps explicit default-state overrides on the selection", () => {
2070 const store = useComposerDraftStore.getState();
2071
2072 store.setModelSelection(
2073 threadRef,
2074 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2075 effort: "max",
2076 }),
2077 );
2078
2079 store.setProviderModelOptions(threadRef, CLAUDE_AGENT_DRIVER, toSelections({ thinking: true }));
2080
2081 expect(
2082 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
2083 ).toEqual(
2084 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2085 thinking: true,
2086 }),
2087 );
2088 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({});
2089 });
2090
2091 it("keeps explicit off/default codex overrides on the selection", () => {
2092 const store = useComposerDraftStore.getState();
2093
2094 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4", { fastMode: true }));
2095
2096 store.setProviderModelOptions(
2097 threadRef,
2098 CODEX_DRIVER,
2099 toSelections({ reasoningEffort: "high", fastMode: false }),
2100 );
2101
2102 expect(
2103 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
2104 ).toEqual(
2105 modelSelection(CODEX_DRIVER, "gpt-5.4", {
2106 reasoningEffort: "high",
2107 fastMode: false,
2108 }),
2109 );
2110 });
2111
2112 it("keeps explicit Cursor reset overrides on the selection", () => {
2113 const store = useComposerDraftStore.getState();
2114
2115 store.setModelSelection(
2116 threadRef,
2117 modelSelection(CURSOR_DRIVER, "claude-opus-4-6", {
2118 reasoning: "xhigh",
2119 fastMode: true,
2120 thinking: false,
2121 }),
2122 );
2123
2124 store.setProviderModelOptions(
2125 threadRef,
2126 CURSOR_DRIVER,
2127 toSelections({ reasoning: "medium", fastMode: false, thinking: true }),
2128 );
2129
2130 expect(
2131 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CURSOR_INSTANCE],
2132 ).toEqual(
2133 modelSelection(CURSOR_DRIVER, "claude-opus-4-6", {
2134 reasoning: "medium",
2135 fastMode: false,
2136 thinking: true,
2137 }),
2138 );
2139 });
2140
2141 it("preserves the selected Cursor model when only traits change", () => {
2142 const store = useComposerDraftStore.getState();
2143
2144 store.setProviderModelOptions(threadRef, CURSOR_DRIVER, toSelections({ reasoning: "high" }), {
2145 model: "gpt-5.4",
2146 persistSticky: true,
2147 });
2148
2149 expect(
2150 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CURSOR_INSTANCE],
2151 ).toEqual(
2152 modelSelection(CURSOR_DRIVER, "gpt-5.4", {
2153 reasoning: "high",
2154 }),
2155 );
2156 expect(
2157 useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
2158 ).toEqual(
2159 modelSelection(CURSOR_DRIVER, "gpt-5.4", {
2160 reasoning: "high",
2161 }),
2162 );
2163 });
2164
2165 it("updates only the draft when sticky persistence is omitted", () => {
2166 const store = useComposerDraftStore.getState();
2167
2168 store.setStickyModelSelection(
2169 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
2170 );
2171 store.setModelSelection(
2172 threadRef,
2173 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
2174 );
2175
2176 store.setProviderModelOptions(
2177 threadRef,
2178 CLAUDE_AGENT_DRIVER,
2179 toSelections({ thinking: false }),
2180 );
2181
2182 expect(
2183 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
2184 ).toEqual(
2185 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2186 thinking: false,
2187 }),
2188 );
2189 expect(
2190 useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
2191 ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }));
2192 });
2193
2194 it("does not clear other provider options when setting options for a single provider", () => {
2195 const store = useComposerDraftStore.getState();
2196
2197 // Set options for both providers
2198 store.setModelOptions(
2199 threadRef,
2200 providerModelOptions({
2201 codex: { fastMode: true },
2202 claudeAgent: { effort: "max" },
2203 }),
2204 );
2205
2206 // Now set options for only codex — claudeAgent should be untouched
2207 store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
2208
2209 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
2210 expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]?.options).toEqual(
2211 createModelSelection(CODEX_INSTANCE, "gpt-5.4", toSelections({ reasoningEffort: "xhigh" }))
2212 .options,
2213 );
2214 expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]?.options).toEqual(
2215 createModelSelection(
2216 CLAUDE_AGENT_INSTANCE,
2217 "claude-opus-4-6",
2218 toSelections({ effort: "max" }),
2219 ).options,
2220 );
2221 });
2222
2223 it("preserves other provider options when switching the active model selection", () => {
2224 const store = useComposerDraftStore.getState();
2225
2226 store.setModelOptions(
2227 threadRef,
2228 providerModelOptions({
2229 codex: { fastMode: true },
2230 claudeAgent: { effort: "max" },
2231 }),
2232 );
2233
2234 store.setModelSelection(threadRef, modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"));
2235
2236 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
2237 expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]).toEqual(
2238 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
2239 );
2240 expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]?.options).toEqual(
2241 createModelSelection(CODEX_INSTANCE, "gpt-5.4", toSelections({ fastMode: true })).options,
2242 );
2243 expect(draft?.activeProvider).toBe("claudeAgent");
2244 });
2245
2246 it("creates the first sticky snapshot from provider option changes", () => {
2247 const store = useComposerDraftStore.getState();
2248
2249 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
2250
2251 store.setProviderModelOptions(threadRef, CODEX_DRIVER, toSelections({ fastMode: true }), {
2252 persistSticky: true,
2253 });
2254
2255 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual(
2256 modelSelection(CODEX_DRIVER, "gpt-5.4", {
2257 fastMode: true,
2258 }),
2259 );
2260 });
2261
2262 it("stores provider option changes on a selected custom instance", () => {
2263 const store = useComposerDraftStore.getState();
2264
2265 store.setProviderModelOptions(
2266 threadRef,
2267 CODEX_DRIVER,
2268 toSelections({ reasoningEffort: "low" }),
2269 {
2270 instanceId: CODEX_SECONDARY_INSTANCE,
2271 model: "gpt-5-codex",
2272 persistSticky: true,
2273 },
2274 );
2275
2276 expect(
2277 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_SECONDARY_INSTANCE],
2278 ).toEqual(
2279 expect.objectContaining({
2280 instanceId: CODEX_SECONDARY_INSTANCE,
2281 options: [{ id: "reasoningEffort", value: "low" }],
2282 }),
2283 );
2284 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.activeProvider).toBe(CODEX_SECONDARY_INSTANCE);
2285 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe(CODEX_SECONDARY_INSTANCE);
2286 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toBe(
2287 undefined,
2288 );
2289 expect(
2290 useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_SECONDARY_INSTANCE],
2291 ).toEqual(
2292 expect.objectContaining({
2293 instanceId: CODEX_SECONDARY_INSTANCE,
2294 options: [{ id: "reasoningEffort", value: "low" }],
2295 }),
2296 );
2297 });
2298
2299 it("updates only the draft when sticky persistence is disabled", () => {
2300 const store = useComposerDraftStore.getState();
2301
2302 store.setStickyModelSelection(
2303 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
2304 );
2305 store.setModelSelection(
2306 threadRef,
2307 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
2308 );
2309
2310 store.setProviderModelOptions(
2311 threadRef,
2312 CLAUDE_AGENT_DRIVER,
2313 toSelections({ thinking: false }),
2314 {
2315 persistSticky: false,
2316 },
2317 );
2318
2319 expect(
2320 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
2321 ).toEqual(
2322 modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
2323 thinking: false,
2324 }),
2325 );
2326 expect(
2327 useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
2328 ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }));
2329 });
2330});
2331
2332describe("composerDraftStore per-model sticky options", () => {
2333 const threadId = ThreadId.make("thread-per-model-options");
2334 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
2335
2336 beforeEach(() => {
2337 resetComposerDraftStore();
2338 });
2339
2340 it("remembers sticky options per model and restores them on model switch", () => {
2341 const store = useComposerDraftStore.getState();
2342
2343 store.setProviderModelOptions(
2344 threadRef,
2345 CODEX_DRIVER,
2346 toSelections({ reasoningEffort: "xhigh" }),
2347 { instanceId: CODEX_INSTANCE, model: "gpt-5.3-codex", persistSticky: true },
2348 );
2349 store.setProviderModelOptions(
2350 threadRef,
2351 CODEX_DRIVER,
2352 toSelections({ reasoningEffort: "high" }),
2353 { instanceId: CODEX_INSTANCE, model: "gpt-5.4", persistSticky: true },
2354 );
2355
2356 expect(useComposerDraftStore.getState().stickyOptionsByModelByProvider[CODEX_INSTANCE]).toEqual(
2357 {
2358 "gpt-5.3-codex": toSelections({ reasoningEffort: "xhigh" }),
2359 "gpt-5.4": toSelections({ reasoningEffort: "high" }),
2360 },
2361 );
2362 });
2363
2364 it("drops the remembered options for a model when its sticky options are cleared", () => {
2365 const store = useComposerDraftStore.getState();
2366
2367 store.setProviderModelOptions(
2368 threadRef,
2369 CODEX_DRIVER,
2370 toSelections({ reasoningEffort: "xhigh" }),
2371 { instanceId: CODEX_INSTANCE, model: "gpt-5.3-codex", persistSticky: true },
2372 );
2373 store.setProviderModelOptions(threadRef, CODEX_DRIVER, null, {
2374 instanceId: CODEX_INSTANCE,
2375 persistSticky: true,
2376 });
2377
2378 const remembered = useComposerDraftStore.getState().stickyOptionsByModelByProvider;
2379 expect(remembered[CODEX_INSTANCE]?.["gpt-5.3-codex"]).toBeUndefined();
2380 });
2381
2382 it("keeps other models' remembered options when one model is cleared", () => {
2383 const store = useComposerDraftStore.getState();
2384
2385 store.setProviderModelOptions(
2386 threadRef,
2387 CODEX_DRIVER,
2388 toSelections({ reasoningEffort: "xhigh" }),
2389 { instanceId: CODEX_INSTANCE, model: "gpt-5.3-codex", persistSticky: true },
2390 );
2391 store.setProviderModelOptions(
2392 threadRef,
2393 CODEX_DRIVER,
2394 toSelections({ reasoningEffort: "high" }),
2395 { instanceId: CODEX_INSTANCE, model: "gpt-5.4", persistSticky: true },
2396 );
2397 store.setProviderModelOptions(threadRef, CODEX_DRIVER, null, {
2398 instanceId: CODEX_INSTANCE,
2399 persistSticky: true,
2400 });
2401
2402 expect(useComposerDraftStore.getState().stickyOptionsByModelByProvider[CODEX_INSTANCE]).toEqual(
2403 {
2404 "gpt-5.4": toSelections({ reasoningEffort: "high" }),
2405 },
2406 );
2407 });
2408
2409 it("does not record options when sticky persistence is omitted", () => {
2410 const store = useComposerDraftStore.getState();
2411
2412 store.setProviderModelOptions(
2413 threadRef,
2414 CODEX_DRIVER,
2415 toSelections({ reasoningEffort: "low" }),
2416 );
2417
2418 expect(useComposerDraftStore.getState().stickyOptionsByModelByProvider).toEqual({});
2419 });
2420
2421 it("seeds per-model memory from a persisted sticky selection on upgrade", () => {
2422 const persistApi = useComposerDraftStore.persist as unknown as {
2423 getOptions: () => {
2424 merge: (
2425 persistedState: unknown,
2426 currentState: ReturnType<typeof useComposerDraftStore.getState>,
2427 ) => Pick<
2428 ReturnType<typeof useComposerDraftStore.getState>,
2429 "stickyModelSelectionByProvider" | "stickyOptionsByModelByProvider"
2430 >;
2431 };
2432 };
2433 const mergedState = persistApi.getOptions().merge(
2434 {
2435 stickyModelSelectionByProvider: {
2436 [CODEX_INSTANCE]: {
2437 instanceId: CODEX_INSTANCE,
2438 model: "gpt-5.3-codex",
2439 options: [{ id: "reasoningEffort", value: "low" }],
2440 },
2441 },
2442 },
2443 useComposerDraftStore.getInitialState(),
2444 );
2445
2446 expect(mergedState.stickyOptionsByModelByProvider).toEqual({
2447 [CODEX_INSTANCE]: { "gpt-5.3-codex": [{ id: "reasoningEffort", value: "low" }] },
2448 });
2449 });
2450});
2451
2452describe("composerDraftStore setModelSelection", () => {
2453 const threadId = ThreadId.make("thread-model");
2454 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
2455
2456 beforeEach(() => {
2457 resetComposerDraftStore();
2458 });
2459
2460 it("keeps explicit model overrides instead of coercing to null", () => {
2461 const store = useComposerDraftStore.getState();
2462
2463 store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.3-codex"));
2464
2465 expect(
2466 draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE],
2467 ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.3-codex"));
2468 });
2469});
2470
2471describe("composerDraftStore sticky composer settings", () => {
2472 beforeEach(() => {
2473 resetComposerDraftStore();
2474 });
2475
2476 it("stores a sticky model selection", () => {
2477 const store = useComposerDraftStore.getState();
2478
2479 store.setStickyModelSelection(
2480 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
2481 reasoningEffort: "medium",
2482 fastMode: true,
2483 }),
2484 );
2485
2486 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual(
2487 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
2488 reasoningEffort: "medium",
2489 fastMode: true,
2490 }),
2491 );
2492 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
2493 });
2494
2495 it("normalizes empty sticky model options by dropping selection options", () => {
2496 const store = useComposerDraftStore.getState();
2497
2498 store.setStickyModelSelection(modelSelection(CODEX_DRIVER, "gpt-5.4"));
2499
2500 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual(
2501 modelSelection(CODEX_DRIVER, "gpt-5.4"),
2502 );
2503 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
2504 });
2505
2506 it("drops empty cursor model options when normalizing sticky state", () => {
2507 const store = useComposerDraftStore.getState();
2508
2509 store.setStickyModelSelection(
2510 modelSelection(CURSOR_DRIVER, "gpt-5.4", {
2511 reasoning: undefined,
2512 fastMode: undefined,
2513 thinking: undefined,
2514 contextWindow: undefined,
2515 }),
2516 );
2517
2518 expect(
2519 useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
2520 ).toEqual(modelSelection(CURSOR_DRIVER, "gpt-5.4"));
2521 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor");
2522 });
2523
2524 it("preserves sticky provider options when model selection omits options", () => {
2525 const store = useComposerDraftStore.getState();
2526
2527 store.setStickyModelSelection(
2528 modelSelection(CURSOR_DRIVER, "composer-2", {
2529 fastMode: false,
2530 }),
2531 );
2532 store.setStickyModelSelection(modelSelection(CURSOR_DRIVER, "composer-2.5"));
2533
2534 expect(
2535 useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
2536 ).toEqual(
2537 modelSelection(CURSOR_DRIVER, "composer-2.5", {
2538 fastMode: false,
2539 }),
2540 );
2541 });
2542
2543 it("applies sticky activeProvider to new drafts", () => {
2544 const store = useComposerDraftStore.getState();
2545 const threadId = ThreadId.make("thread-sticky-active-provider");
2546 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
2547
2548 store.setStickyModelSelection(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"));
2549 store.applyStickyState(threadRef);
2550
2551 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({
2552 modelSelectionByProvider: {
2553 claudeAgent: modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"),
2554 },
2555 activeProvider: "claudeAgent",
2556 });
2557 });
2558
2559 it("replaces a non-explicit stale model and its options with sticky state", () => {
2560 const store = useComposerDraftStore.getState();
2561 const draftId = DraftId.make("draft-stale-sticky-seed");
2562
2563 store.setModelSelection(
2564 draftId,
2565 modelSelection(CODEX_DRIVER, "stale-model", { reasoningEffort: "low" }),
2566 );
2567 store.setStickyModelSelection(
2568 modelSelection(CODEX_DRIVER, "sticky-model", { reasoningEffort: "xhigh" }),
2569 );
2570 store.applyStickyState(draftId);
2571
2572 expect(draftByKey(draftId)).toMatchObject({
2573 activeProvider: CODEX_INSTANCE,
2574 modelSelectionByProvider: {
2575 [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "sticky-model", {
2576 reasoningEffort: "xhigh",
2577 }),
2578 },
2579 });
2580 });
2581
2582 it("clears a non-explicit stale model when there is no sticky state", () => {
2583 const store = useComposerDraftStore.getState();
2584 const draftId = DraftId.make("draft-stale-without-sticky");
2585
2586 store.setModelSelection(draftId, modelSelection(CODEX_DRIVER, "stale-model"));
2587 store.applyStickyState(draftId);
2588
2589 expect(draftByKey(draftId)).toBeUndefined();
2590 });
2591});
2592
2593describe("composerDraftStore model seed migration", () => {
2594 const staleDraftId = DraftId.make("draft-legacy-stale-model");
2595 const explicitDraftId = DraftId.make("draft-legacy-explicit-model");
2596 const typedDraftId = DraftId.make("draft-legacy-typed-model");
2597 const staleThreadId = ThreadId.make("thread-legacy-stale-model");
2598 const explicitThreadId = ThreadId.make("thread-legacy-explicit-model");
2599 const typedThreadId = ThreadId.make("thread-legacy-typed-model");
2600 const serverThreadId = ThreadId.make("thread-server-model");
2601 const serverThreadKey = scopedThreadKey(scopeThreadRef(TEST_ENVIRONMENT_ID, serverThreadId));
2602 const projectId = ProjectId.make("project-model-migration");
2603 const logicalProjectKey = `${TEST_ENVIRONMENT_ID}:/tmp/project-model-migration`;
2604
2605 const draftThread = (threadId: ThreadId) => ({
2606 threadId,
2607 environmentId: TEST_ENVIRONMENT_ID,
2608 projectId,
2609 logicalProjectKey,
2610 createdAt: "2026-08-01T00:00:00.000Z",
2611 runtimeMode: "full-access",
2612 interactionMode: "default",
2613 branch: null,
2614 worktreePath: null,
2615 envMode: "local",
2616 startFromOrigin: false,
2617 promotedTo: null,
2618 });
2619
2620 beforeEach(async () => {
2621 resetComposerDraftStore();
2622 await useComposerDraftStore.persist.clearStorage();
2623 });
2624
2625 afterEach(async () => {
2626 await useComposerDraftStore.persist.clearStorage();
2627 });
2628
2629 it.each([1, 2])(
2630 "keeps the legacy sticky Codex selection when v%s storage omitted the provider",
2631 async (version) => {
2632 vi.useFakeTimers();
2633 try {
2634 const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", {
2635 reasoningEffort: "xhigh",
2636 });
2637 const storage = useComposerDraftStore.persist.getOptions().storage;
2638 expect(storage).toBeDefined();
2639 storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
2640 version,
2641 state: {
2642 draftsByThreadId: {},
2643 draftThreadsByThreadId: {},
2644 projectDraftThreadIdByProjectId: {},
2645 stickyModel: stickySelection.model,
2646 stickyModelOptions: providerModelOptions({
2647 [CODEX_DRIVER]: { reasoningEffort: "xhigh" },
2648 }),
2649 },
2650 } as never);
2651 await vi.advanceTimersByTimeAsync(300);
2652
2653 await useComposerDraftStore.persist.rehydrate();
2654
2655 expect(useComposerDraftStore.getState()).toMatchObject({
2656 stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection },
2657 stickyActiveProvider: null,
2658 });
2659 } finally {
2660 vi.useRealTimers();
2661 }
2662 },
2663 );
2664
2665 it("strips seeded models only from empty draft sessions when upgrading storage", async () => {
2666 vi.useFakeTimers();
2667 try {
2668 const staleSelection = modelSelection(CODEX_DRIVER, "gpt-5.4");
2669 const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", {
2670 reasoningEffort: "xhigh",
2671 });
2672 const storage = useComposerDraftStore.persist.getOptions().storage;
2673 expect(storage).toBeDefined();
2674 storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
2675 version: 8,
2676 state: {
2677 draftsByThreadKey: {
2678 [staleDraftId]: {
2679 prompt: "",
2680 attachments: [],
2681 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2682 activeProvider: CODEX_INSTANCE,
2683 runtimeMode: "approval-required",
2684 },
2685 [typedDraftId]: {
2686 prompt: "keep this prompt",
2687 attachments: [],
2688 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2689 activeProvider: CODEX_INSTANCE,
2690 },
2691 [explicitDraftId]: {
2692 prompt: "",
2693 attachments: [],
2694 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2695 activeProvider: CODEX_INSTANCE,
2696 modelSelectionExplicit: true,
2697 },
2698 [serverThreadKey]: {
2699 prompt: "",
2700 attachments: [],
2701 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2702 activeProvider: CODEX_INSTANCE,
2703 },
2704 },
2705 draftThreadsByThreadKey: {
2706 [staleDraftId]: draftThread(staleThreadId),
2707 [explicitDraftId]: draftThread(explicitThreadId),
2708 [typedDraftId]: draftThread(typedThreadId),
2709 },
2710 logicalProjectDraftThreadKeyByLogicalProjectKey: {
2711 [logicalProjectKey]: staleDraftId,
2712 },
2713 stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection },
2714 stickyActiveProvider: CODEX_INSTANCE,
2715 },
2716 } as never);
2717 await vi.advanceTimersByTimeAsync(300);
2718
2719 await useComposerDraftStore.persist.rehydrate();
2720
2721 expect(draftByKey(staleDraftId)).toMatchObject({
2722 modelSelectionByProvider: {},
2723 activeProvider: null,
2724 runtimeMode: "approval-required",
2725 });
2726 expect(draftByKey(typedDraftId)).toMatchObject({
2727 prompt: "keep this prompt",
2728 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2729 activeProvider: CODEX_INSTANCE,
2730 });
2731 expect(draftByKey(explicitDraftId)).toMatchObject({
2732 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2733 activeProvider: CODEX_INSTANCE,
2734 modelSelectionExplicit: true,
2735 });
2736 expect(draftByKey(serverThreadKey)).toMatchObject({
2737 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2738 activeProvider: CODEX_INSTANCE,
2739 });
2740 expect(useComposerDraftStore.getState().draftThreadsByThreadKey[staleDraftId]).toMatchObject({
2741 environmentId: TEST_ENVIRONMENT_ID,
2742 projectId,
2743 logicalProjectKey,
2744 });
2745 expect(useComposerDraftStore.getState()).toMatchObject({
2746 stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection },
2747 stickyActiveProvider: CODEX_INSTANCE,
2748 });
2749 } finally {
2750 vi.useRealTimers();
2751 }
2752 });
2753
2754 it("keeps v8 file-only draft sessions and their seeded models", async () => {
2755 vi.useFakeTimers();
2756 try {
2757 const uploadedDraftId = DraftId.make("draft-legacy-uploaded-file");
2758 const markerDraftId = DraftId.make("draft-legacy-file-marker");
2759 const uploadedThreadId = ThreadId.make("thread-legacy-uploaded-file");
2760 const markerThreadId = ThreadId.make("thread-legacy-file-marker");
2761 const staleSelection = modelSelection(CODEX_DRIVER, "gpt-5.4");
2762 const storage = useComposerDraftStore.persist.getOptions().storage;
2763 expect(storage).toBeDefined();
2764 storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, {
2765 version: 8,
2766 state: {
2767 draftsByThreadKey: {
2768 [uploadedDraftId]: {
2769 prompt: "",
2770 attachments: [],
2771 files: [
2772 {
2773 id: "file-uploaded",
2774 name: "uploaded-report.pdf",
2775 mimeType: "application/pdf",
2776 sizeBytes: 128,
2777 attachmentId: "attachment-uploaded",
2778 environmentId: TEST_ENVIRONMENT_ID,
2779 },
2780 ],
2781 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2782 activeProvider: CODEX_INSTANCE,
2783 },
2784 [markerDraftId]: {
2785 prompt: "",
2786 attachments: [],
2787 files: [
2788 {
2789 id: "file-needs-reattach",
2790 name: "local-notes.txt",
2791 mimeType: "text/plain",
2792 sizeBytes: 64,
2793 },
2794 ],
2795 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2796 activeProvider: CODEX_INSTANCE,
2797 runtimeMode: "approval-required",
2798 },
2799 },
2800 draftThreadsByThreadKey: {
2801 [uploadedDraftId]: draftThread(uploadedThreadId),
2802 [markerDraftId]: draftThread(markerThreadId),
2803 },
2804 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
2805 stickyModelSelectionByProvider: {},
2806 stickyActiveProvider: null,
2807 },
2808 } as never);
2809 await vi.advanceTimersByTimeAsync(300);
2810
2811 await useComposerDraftStore.persist.rehydrate();
2812
2813 expect(draftByKey(uploadedDraftId)).toMatchObject({
2814 files: [
2815 {
2816 id: "file-uploaded",
2817 name: "uploaded-report.pdf",
2818 uploadedAttachmentId: "attachment-uploaded",
2819 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
2820 },
2821 ],
2822 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2823 activeProvider: CODEX_INSTANCE,
2824 });
2825 expect(draftByKey(markerDraftId)).toMatchObject({
2826 files: [
2827 {
2828 id: "file-needs-reattach",
2829 name: "local-notes.txt",
2830 file: null,
2831 },
2832 ],
2833 modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection },
2834 activeProvider: CODEX_INSTANCE,
2835 runtimeMode: "approval-required",
2836 });
2837 expect(draftByKey(markerDraftId)?.files.every(composerFileNeedsReattach)).toBe(true);
2838 } finally {
2839 vi.useRealTimers();
2840 }
2841 });
2842});
2843
2844describe("composerDraftStore provider-scoped option updates", () => {
2845 const threadId = ThreadId.make("thread-provider");
2846 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
2847
2848 beforeEach(() => {
2849 resetComposerDraftStore();
2850 });
2851
2852 it("retains off-provider option memory without changing the active selection", () => {
2853 const store = useComposerDraftStore.getState();
2854 store.setModelSelection(
2855 threadRef,
2856 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
2857 reasoningEffort: "medium",
2858 }),
2859 );
2860 store.setProviderModelOptions(threadRef, CLAUDE_AGENT_DRIVER, toSelections({ effort: "max" }));
2861 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
2862 expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual(
2863 modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { reasoningEffort: "medium" }),
2864 );
2865 expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]?.options).toEqual(
2866 createModelSelection(
2867 CLAUDE_AGENT_INSTANCE,
2868 "claude-opus-4-6",
2869 toSelections({ effort: "max" }),
2870 ).options,
2871 );
2872 expect(draft?.activeProvider).toBe("codex");
2873 });
2874});
2875
2876describe("composerDraftStore runtime and interaction settings", () => {
2877 const threadId = ThreadId.make("thread-settings");
2878 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
2879
2880 beforeEach(() => {
2881 resetComposerDraftStore();
2882 });
2883
2884 it("stores runtime mode overrides in the composer draft", () => {
2885 const store = useComposerDraftStore.getState();
2886
2887 store.setRuntimeMode(threadRef, "approval-required");
2888
2889 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required");
2890 });
2891
2892 it("stores interaction mode overrides in the composer draft", () => {
2893 const store = useComposerDraftStore.getState();
2894
2895 store.setInteractionMode(threadRef, "plan");
2896
2897 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan");
2898 });
2899
2900 it("removes empty settings-only drafts when overrides are cleared", () => {
2901 const store = useComposerDraftStore.getState();
2902
2903 store.setRuntimeMode(threadRef, "approval-required");
2904 store.setInteractionMode(threadRef, "plan");
2905 store.setRuntimeMode(threadRef, null);
2906 store.setInteractionMode(threadRef, null);
2907
2908 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
2909 });
2910});
2911
2912// ---------------------------------------------------------------------------
2913// createDeferredStorage
2914// ---------------------------------------------------------------------------
2915
2916function createMockStorage() {
2917 const store = new Map<string, string>();
2918 return {
2919 getItem: vi.fn((name: string) => store.get(name) ?? null),
2920 setItem: vi.fn((name: string, value: string) => {
2921 store.set(name, value);
2922 }),
2923 removeItem: vi.fn((name: string) => {
2924 store.delete(name);
2925 }),
2926 };
2927}
2928
2929describe("composer draft persistence", () => {
2930 it("defers attachment reads and serialization until typing stops, then restores the last draft", async () => {
2931 await useComposerDraftStore.persist.clearStorage();
2932 vi.useFakeTimers();
2933 const stringify = vi.spyOn(JSON, "stringify");
2934 try {
2935 resetComposerDraftStore();
2936 const heavyThreadId = ThreadId.make("heavy-draft");
2937 const typingThreadId = ThreadId.make("typing-draft");
2938 const heavyRef = scopeThreadRef(TEST_ENVIRONMENT_ID, heavyThreadId);
2939 const typingRef = scopeThreadRef(TEST_ENVIRONMENT_ID, typingThreadId);
2940 const heavyKey = scopedThreadKey(heavyRef);
2941 useComposerDraftStore.getState().setPrompt(heavyRef, "Keep this image");
2942 const attachments = [
2943 {
2944 id: "heavy-image",
2945 name: "image.png",
2946 mimeType: "image/png",
2947 sizeBytes: 49_152,
2948 dataUrl: `data:image/png;base64,${"AQID".repeat(16_384)}`,
2949 },
2950 ];
2951 let attachmentReads = 0;
2952 useComposerDraftStore.setState((state) => ({
2953 draftsByThreadKey: {
2954 ...state.draftsByThreadKey,
2955 [heavyKey]: {
2956 ...state.draftsByThreadKey[heavyKey]!,
2957 get persistedAttachments() {
2958 attachmentReads += 1;
2959 return attachments;
2960 },
2961 },
2962 },
2963 }));
2964 attachmentReads = 0;
2965 stringify.mockClear();
2966
2967 for (let index = 1; index <= 20; index++) {
2968 useComposerDraftStore.getState().setPrompt(typingRef, `Draft ${index}`);
2969 }
2970 expect(attachmentReads).toBe(0);
2971 expect(stringify).not.toHaveBeenCalled();
2972
2973 await vi.advanceTimersByTimeAsync(300);
2974 expect(attachmentReads).toBe(1);
2975 expect(stringify).toHaveBeenCalledTimes(1);
2976
2977 resetComposerDraftStore();
2978 await useComposerDraftStore.persist.rehydrate();
2979 expect(draftFor(typingThreadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("Draft 20");
2980 expect(draftFor(heavyThreadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual(
2981 attachments,
2982 );
2983 } finally {
2984 stringify.mockRestore();
2985 await useComposerDraftStore.persist.clearStorage();
2986 vi.useRealTimers();
2987 resetComposerDraftStore();
2988 }
2989 });
2990});
2991
2992describe("createDeferredStorage", () => {
2993 const serialize = vi.fn((value: string) => `s:${value}`);
2994
2995 beforeEach(() => {
2996 vi.useFakeTimers();
2997 serialize.mockClear();
2998 });
2999
3000 afterEach(() => {
3001 vi.useRealTimers();
3002 });
3003
3004 it("delegates getItem immediately", () => {
3005 const base = createMockStorage();
3006 base.getItem.mockReturnValueOnce("value");
3007 const storage = createDeferredStorage(base, serialize);
3008
3009 expect(storage.getItem("key")).toBe("value");
3010 expect(base.getItem).toHaveBeenCalledWith("key");
3011 });
3012
3013 it("neither serializes nor writes until the debounce fires", () => {
3014 const base = createMockStorage();
3015 const storage = createDeferredStorage(base, serialize);
3016
3017 storage.setItem("key", "v1");
3018 expect(serialize).not.toHaveBeenCalled();
3019 expect(base.setItem).not.toHaveBeenCalled();
3020
3021 vi.advanceTimersByTime(299);
3022 expect(serialize).not.toHaveBeenCalled();
3023 expect(base.setItem).not.toHaveBeenCalled();
3024
3025 vi.advanceTimersByTime(1);
3026 expect(base.setItem).toHaveBeenCalledWith("key", "s:v1");
3027 });
3028
3029 it("serializes and writes only the last value when setItem is called rapidly", () => {
3030 const base = createMockStorage();
3031 const storage = createDeferredStorage(base, serialize);
3032
3033 storage.setItem("key", "v1");
3034 storage.setItem("key", "v2");
3035 storage.setItem("key", "v3");
3036
3037 vi.advanceTimersByTime(300);
3038 expect(serialize).toHaveBeenCalledTimes(1);
3039 expect(base.setItem).toHaveBeenCalledTimes(1);
3040 expect(base.setItem).toHaveBeenCalledWith("key", "s:v3");
3041 });
3042
3043 it("removeItem cancels a pending setItem write", () => {
3044 const base = createMockStorage();
3045 const storage = createDeferredStorage(base, serialize);
3046
3047 storage.setItem("key", "v1");
3048 storage.removeItem("key");
3049
3050 vi.advanceTimersByTime(300);
3051 expect(serialize).not.toHaveBeenCalled();
3052 expect(base.setItem).not.toHaveBeenCalled();
3053 expect(base.removeItem).toHaveBeenCalledWith("key");
3054 });
3055
3056 it("flush serializes and writes the pending value immediately", () => {
3057 const base = createMockStorage();
3058 const storage = createDeferredStorage(base, serialize);
3059
3060 storage.setItem("key", "v1");
3061 expect(base.setItem).not.toHaveBeenCalled();
3062
3063 storage.flush();
3064 expect(base.setItem).toHaveBeenCalledWith("key", "s:v1");
3065
3066 // Timer should be cancelled; no duplicate write.
3067 vi.advanceTimersByTime(300);
3068 expect(serialize).toHaveBeenCalledTimes(1);
3069 expect(base.setItem).toHaveBeenCalledTimes(1);
3070 });
3071
3072 it("flush is a no-op when nothing is pending", () => {
3073 const base = createMockStorage();
3074 const storage = createDeferredStorage(base, serialize);
3075
3076 storage.flush();
3077 expect(base.setItem).not.toHaveBeenCalled();
3078 });
3079
3080 it("flush after removeItem is a no-op", () => {
3081 const base = createMockStorage();
3082 const storage = createDeferredStorage(base, serialize);
3083
3084 storage.setItem("key", "v1");
3085 storage.removeItem("key");
3086 storage.flush();
3087
3088 expect(base.setItem).not.toHaveBeenCalled();
3089 });
3090
3091 it("setItem works normally after removeItem cancels a pending write", () => {
3092 const base = createMockStorage();
3093 const storage = createDeferredStorage(base, serialize);
3094
3095 storage.setItem("key", "v1");
3096 storage.removeItem("key");
3097 storage.setItem("key", "v2");
3098
3099 vi.advanceTimersByTime(300);
3100 expect(base.setItem).toHaveBeenCalledTimes(1);
3101 expect(base.setItem).toHaveBeenCalledWith("key", "s:v2");
3102 });
3103});
3104
3105describe("composerDraftStore inline context references", () => {
3106 const threadId = ThreadId.make("thread-inline-context");
3107 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
3108 const reviewComment = {
3109 id: "rc-1",
3110 sectionId: "file:a/b.ts",
3111 sectionTitle: "File comment",
3112 filePath: "a/b.ts",
3113 startIndex: 3,
3114 endIndex: 3,
3115 rangeLabel: "L4",
3116 text: "Why?",
3117 diff: "const x = 1;",
3118 };
3119 const reviewLink = "[b.ts L4](t3-context://v1/review-comment/review-comment_rc-1)";
3120 const annotation = {
3121 id: "ann-1",
3122 pageUrl: "http://localhost:3000/",
3123 pageTitle: "Home",
3124 comment: "Bigger",
3125 elements: [],
3126 regions: [],
3127 strokes: [],
3128 styleChanges: [],
3129 screenshot: null,
3130 createdAt: "2026-01-01T00:00:00.000Z",
3131 };
3132 const annotationLink = "[Bigger](t3-context://v1/preview-annotation/preview-annotation_ann-1)";
3133
3134 beforeEach(() => {
3135 resetComposerDraftStore();
3136 });
3137
3138 it("appends a link when a review comment is added and strips it on removal", () => {
3139 const store = useComposerDraftStore.getState();
3140 store.setPrompt(threadRef, "look");
3141 store.addReviewComment(threadRef, reviewComment);
3142 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(`look ${reviewLink} `);
3143 // Re-adding the same comment replaces the record without a second link.
3144 store.addReviewComment(threadRef, { ...reviewComment, text: "edited" });
3145 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(`look ${reviewLink} `);
3146 store.removeReviewComment(threadRef, "rc-1");
3147 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("look");
3148 });
3149
3150 it("can add another reference while upserting one backing review record", () => {
3151 const store = useComposerDraftStore.getState();
3152 store.addReviewComment(threadRef, reviewComment);
3153 const insertionHandler = vi.fn(() => true);
3154 store.setContextInsertionHandler(threadRef, insertionHandler);
3155 store.addReviewComment(
3156 threadRef,
3157 { ...reviewComment, text: "edited" },
3158 {
3159 allowDuplicateReference: true,
3160 insertAtCaret: false,
3161 },
3162 );
3163
3164 expect(insertionHandler).not.toHaveBeenCalled();
3165 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(`${reviewLink} ${reviewLink} `);
3166 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.reviewComments).toEqual([
3167 { ...reviewComment, text: "edited" },
3168 ]);
3169 });
3170
3171 it("keeps bulk-set review records and their inline references in sync", () => {
3172 const store = useComposerDraftStore.getState();
3173 store.setPrompt(threadRef, "Explain this pull request.");
3174 store.setReviewComments(threadRef, [reviewComment]);
3175
3176 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(
3177 `Explain this pull request. ${reviewLink} `,
3178 );
3179 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.reviewComments).toEqual([reviewComment]);
3180
3181 store.setReviewComments(threadRef, []);
3182 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("Explain this pull request.");
3183 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.reviewComments).toEqual([]);
3184 });
3185
3186 it("hands new review comments to a registered caret handler instead of appending", () => {
3187 const store = useComposerDraftStore.getState();
3188 store.setPrompt(threadRef, "look");
3189 const seen: string[] = [];
3190 store.setContextInsertionHandler(threadRef, (references) => {
3191 seen.push(...references.map((reference) => reference.contextId));
3192 return true;
3193 });
3194 store.addReviewComment(threadRef, reviewComment);
3195 expect(seen).toEqual(["review-comment_rc-1"]);
3196 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("look");
3197 store.setContextInsertionHandler(threadRef, null);
3198 store.addReviewComment(threadRef, { ...reviewComment, id: "rc-2" });
3199 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(
3200 "look [b.ts L4](t3-context://v1/review-comment/review-comment_rc-2) ",
3201 );
3202 });
3203
3204 it("does not overwrite a prompt update made by the caret handler", () => {
3205 const store = useComposerDraftStore.getState();
3206 store.setPrompt(threadRef, "before after");
3207 store.setContextInsertionHandler(threadRef, (references) => {
3208 const reference = references[0];
3209 if (!reference) return false;
3210 useComposerDraftStore
3211 .getState()
3212 .setPrompt(
3213 threadRef,
3214 `before [${reference.label}](t3-context://v1/${reference.kind}/${reference.contextId}) after`,
3215 );
3216 return true;
3217 });
3218
3219 store.addReviewComment(threadRef, reviewComment);
3220
3221 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(`before ${reviewLink} after`);
3222 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.reviewComments).toEqual([reviewComment]);
3223 store.setContextInsertionHandler(threadRef, null);
3224 });
3225
3226 it("appends a link when a preview annotation is added and strips it on removal", () => {
3227 const store = useComposerDraftStore.getState();
3228 store.addPreviewAnnotation(threadRef, annotation);
3229 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(`${annotationLink} `);
3230 store.removePreviewAnnotation(threadRef, "ann-1");
3231 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
3232 });
3233
3234 it("keeps bulk-set preview annotations and their inline references in sync", () => {
3235 const store = useComposerDraftStore.getState();
3236 store.setPrompt(threadRef, "Explain this preview.");
3237 store.setPreviewAnnotations(threadRef, [annotation]);
3238 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(
3239 `Explain this preview. ${annotationLink} `,
3240 );
3241 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.previewAnnotations).toEqual([annotation]);
3242
3243 const edited = { ...annotation, comment: "Updated comment" };
3244 store.setPreviewAnnotations(threadRef, [edited]);
3245 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(
3246 `Explain this preview. ${annotationLink} `,
3247 );
3248 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.previewAnnotations).toEqual([edited]);
3249
3250 store.setPreviewAnnotations(threadRef, []);
3251 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("Explain this preview.");
3252 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.previewAnnotations).toEqual([]);
3253 });
3254
3255 it("removes an annotation-only draft when bulk-cleared", () => {
3256 const store = useComposerDraftStore.getState();
3257 store.setPreviewAnnotations(threadRef, [annotation]);
3258 store.setPreviewAnnotations(threadRef, []);
3259 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined();
3260 });
3261
3262 it("keeps links for retained context when clearing text and attachments", () => {
3263 const store = useComposerDraftStore.getState();
3264 store.addReviewComment(threadRef, reviewComment);
3265 store.addPreviewAnnotation(threadRef, annotation);
3266 store.clearComposerPromptAndImages(threadRef);
3267 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
3268 expect(draft?.prompt).toContain(reviewLink);
3269 expect(draft?.prompt).toContain(annotationLink);
3270 expect(draft?.reviewComments).toEqual([reviewComment]);
3271 expect(draft?.previewAnnotations).toEqual([annotation]);
3272 });
3273
3274 it("rehydrates annotation-only drafts with their references", () => {
3275 const merged = useComposerDraftStore.persist.getOptions().merge!(
3276 {
3277 draftsByThreadKey: {
3278 [threadKeyFor(threadId)]: {
3279 prompt: "",
3280 attachments: [],
3281 previewAnnotations: [annotation],
3282 },
3283 },
3284 },
3285 useComposerDraftStore.getInitialState(),
3286 );
3287 expect(merged.draftsByThreadKey[threadKeyFor(threadId)]?.previewAnnotations).toEqual([
3288 annotation,
3289 ]);
3290 expect(merged.draftsByThreadKey[threadKeyFor(threadId)]?.prompt).toContain(annotationLink);
3291 });
3292
3293 it("adds links for persisted review comments that predate references", () => {
3294 const persistApi = useComposerDraftStore.persist as unknown as {
3295 getOptions: () => {
3296 merge: (
3297 persistedState: unknown,
3298 currentState: ReturnType<typeof useComposerDraftStore.getState>,
3299 ) => ReturnType<typeof useComposerDraftStore.getState>;
3300 };
3301 };
3302 const mergedState = persistApi.getOptions().merge(
3303 {
3304 draftsByThreadId: {
3305 [threadId]: {
3306 prompt: "old draft",
3307 attachments: [],
3308 reviewComments: [reviewComment],
3309 },
3310 },
3311 draftThreadsByThreadId: {},
3312 projectDraftThreadIdByProjectKey: {},
3313 },
3314 useComposerDraftStore.getInitialState(),
3315 );
3316 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.prompt).toBe(
3317 `old draft ${reviewLink} `,
3318 );
3319 });
3320});
3321
3322describe("composerDraftStore attachment references", () => {
3323 const threadId = ThreadId.make("thread-attachment-refs");
3324 const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
3325 const fileLink = "[notes.txt](t3-context://v1/file/file_file-1)";
3326 const imageLink = "[shot.png](t3-context://v1/image/image_img-1)";
3327
3328 beforeEach(() => {
3329 resetComposerDraftStore();
3330 });
3331
3332 it("reports which attachments it accepted so only those get chips", () => {
3333 const store = useComposerDraftStore.getState();
3334 const file = {
3335 type: "file" as const,
3336 id: "file-1",
3337 name: "notes.txt",
3338 mimeType: "text/plain",
3339 sizeBytes: 3,
3340 file: null,
3341 uploadedAttachmentId: "p",
3342 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
3343 };
3344 expect(store.addFiles(threadRef, [file])).toEqual(["file-1"]);
3345 expect(store.addFiles(threadRef, [{ ...file, id: "file-2" }])).toEqual([]);
3346 const image = makeImage({ id: "img-1", previewUrl: "blob:img-1", name: "shot.png" });
3347 expect(store.addImages(threadRef, [image])).toEqual(["img-1"]);
3348 expect(
3349 store.addImages(threadRef, [
3350 makeImage({ id: "img-2", previewUrl: "blob:img-2", name: "shot.png" }),
3351 ]),
3352 ).toEqual([]);
3353 });
3354
3355 it("strips references when an image or file is removed", () => {
3356 const store = useComposerDraftStore.getState();
3357 store.setPrompt(threadRef, `see ${imageLink} and ${fileLink} ok`);
3358 store.addImages(threadRef, [
3359 makeImage({ id: "img-1", previewUrl: "blob:img-1", name: "shot.png" }),
3360 ]);
3361 store.addFiles(threadRef, [
3362 {
3363 type: "file",
3364 id: "file-1",
3365 name: "notes.txt",
3366 mimeType: "text/plain",
3367 sizeBytes: 3,
3368 file: null,
3369 uploadedAttachmentId: "pending-1",
3370 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
3371 },
3372 ]);
3373 store.removeImage(threadRef, "img-1");
3374 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe(`see and ${fileLink} ok`);
3375 store.removeFile(threadRef, "file-1");
3376 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("see and ok");
3377 });
3378
3379 it("moves a needs-reattach marker's chip to the re-picked file", () => {
3380 const store = useComposerDraftStore.getState();
3381 store.setPrompt(threadRef, "see [notes.txt](t3-context://v1/file/file_marker-1) ok");
3382 store.addFiles(threadRef, [
3383 {
3384 type: "file",
3385 id: "marker-1",
3386 name: "notes.txt",
3387 mimeType: "text/plain",
3388 sizeBytes: 3,
3389 file: null,
3390 },
3391 ]);
3392 const acceptedIds = store.addFiles(threadRef, [
3393 {
3394 type: "file",
3395 id: "fresh-1",
3396 name: "notes.txt",
3397 mimeType: "text/plain",
3398 sizeBytes: 3,
3399 file: new File(["abc"], "notes.txt", { type: "text/plain" }),
3400 },
3401 ]);
3402 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID);
3403 expect(acceptedIds).toEqual([]);
3404 expect(draft?.files.map((file) => file.id)).toEqual(["fresh-1"]);
3405 expect(draft?.prompt).toBe("see [notes.txt](t3-context://v1/file/file_fresh-1) ok");
3406 });
3407
3408 it("restores a file-only draft with a reference and preserves it through prompt edits", () => {
3409 const store = useComposerDraftStore.getState();
3410 store.addFiles(
3411 threadRef,
3412 [
3413 {
3414 type: "file",
3415 id: "restored",
3416 name: "notes.txt",
3417 mimeType: "text/plain",
3418 sizeBytes: 3,
3419 file: null,
3420 uploadedAttachmentId: "uploaded",
3421 uploadEnvironmentId: TEST_ENVIRONMENT_ID,
3422 },
3423 ],
3424 { appendReference: true },
3425 );
3426 const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)!;
3427 expect(restored.prompt).toBe("[notes.txt](t3-context://v1/file/file_restored) ");
3428 store.setPrompt(threadRef, `${restored.prompt}explain this`);
3429 const edited = draftFor(threadId, TEST_ENVIRONMENT_ID)!;
3430 expect(edited.files.map((file) => file.id)).toEqual(["restored"]);
3431 expect(edited.prompt).toContain("t3-context://v1/file/file_restored");
3432 });
3433
3434 it.each(["old.file:1", "file-1"])(
3435 "rewrites persisted attachment references before removal: %s",
3436 (id) => {
3437 const merged = useComposerDraftStore.persist.getOptions().merge!(
3438 {
3439 draftsByThreadKey: {
3440 [threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]: {
3441 prompt: `before [notes.txt](t3-context://v1/file/${id}) after`,
3442 attachments: [],
3443 files: [{ id, name: "notes.txt", mimeType: "text/plain", sizeBytes: 3 }],
3444 },
3445 },
3446 },
3447 useComposerDraftStore.getInitialState(),
3448 );
3449 useComposerDraftStore.setState(merged);
3450 const draft = draftFor(threadId, TEST_ENVIRONMENT_ID)!;
3451 expect(draft.prompt.match(/t3-context:/g)).toHaveLength(1);
3452 expect(draft.prompt).not.toContain(`/file/${id})`);
3453 useComposerDraftStore.getState().removeFile(threadRef, id);
3454 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("before after");
3455 },
3456 );
3457
3458 it.each(["", "Fix this"])("migrates saved element context with prompt %j", (prompt) => {
3459 const element = {
3460 id: "old-element",
3461 threadId,
3462 pickedAt: "2026-01-01T00:00:00Z",
3463 pageUrl: "https://example.com",
3464 pageTitle: "Example",
3465 tagName: "button",
3466 selector: "#save",
3467 htmlPreview: "<button>Save</button>",
3468 componentName: "SaveButton",
3469 source: null,
3470 styles: "color: red;",
3471 };
3472 const merged = useComposerDraftStore.persist.getOptions().merge!(
3473 {
3474 draftsByThreadKey: {
3475 [threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]: {
3476 prompt,
3477 attachments: [],
3478 elementContexts: [element],
3479 },
3480 },
3481 },
3482 useComposerDraftStore.getInitialState(),
3483 );
3484 const draft = merged.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]!;
3485 expect(draft.previewAnnotations[0]?.elements[0]?.element).toMatchObject({
3486 htmlPreview: element.htmlPreview,
3487 styles: element.styles,
3488 selector: element.selector,
3489 });
3490 expect(draft.prompt).toContain("t3-context://v1/preview-annotation/");
3491 expect(draft.prompt).toContain(prompt);
3492 });
3493
3494 it("appends chips for persisted files that predate references", () => {
3495 const persistApi = useComposerDraftStore.persist as unknown as {
3496 getOptions: () => {
3497 merge: (
3498 persistedState: unknown,
3499 currentState: ReturnType<typeof useComposerDraftStore.getState>,
3500 ) => ReturnType<typeof useComposerDraftStore.getState>;
3501 };
3502 };
3503 const mergedState = persistApi.getOptions().merge(
3504 {
3505 draftsByThreadId: {
3506 [threadId]: {
3507 prompt: "old",
3508 attachments: [],
3509 files: [{ id: "file-1", name: "notes.txt", mimeType: "text/plain", sizeBytes: 3 }],
3510 },
3511 },
3512 draftThreadsByThreadId: {},
3513 projectDraftThreadIdByProjectKey: {},
3514 },
3515 useComposerDraftStore.getInitialState(),
3516 );
3517 expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.prompt).toBe(`old ${fileLink} `);
3518 });
3519
3520 it("migrates legacy image references without doubling their image marker", () => {
3521 const merged = useComposerDraftStore.persist.getOptions().merge!(
3522 {
3523 draftsByThreadKey: {
3524 [threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]: {
3525 prompt: "see ![shot.png](t3-context://v1/image/img-1) after",
3526 attachments: [
3527 {
3528 id: "img-1",
3529 name: "shot.png",
3530 mimeType: "image/png",
3531 sizeBytes: 1,
3532 dataUrl: "data:image/png;base64,YQ==",
3533 },
3534 ],
3535 },
3536 },
3537 },
3538 useComposerDraftStore.getInitialState(),
3539 );
3540 expect(merged.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.prompt).toBe(
3541 "see ![shot.png](t3-context://v1/image/image_img-1) after",
3542 );
3543 });
3544
3545 it("preserves canonical references when another producer ID matches their namespace", () => {
3546 const prompt =
3547 "![x.png](t3-context://v1/image/image_x) ![image_x.png](t3-context://v1/image/image_image_x)";
3548 const merged = useComposerDraftStore.persist.getOptions().merge!(
3549 {
3550 draftsByThreadKey: {
3551 [threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]: {
3552 prompt,
3553 attachments: ["x", "image_x"].map((id) => ({
3554 id,
3555 name: `${id}.png`,
3556 mimeType: "image/png",
3557 sizeBytes: 1,
3558 dataUrl: "data:image/png;base64,YQ==",
3559 })),
3560 },
3561 },
3562 },
3563 useComposerDraftStore.getInitialState(),
3564 );
3565 expect(merged.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.prompt).toBe(
3566 prompt,
3567 );
3568 });
3569});