Skip to content
Source
Sign inSign up

promptStashStore.test.ts

304 lines9.5 KB

1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
3import {
4 collectAssistantCitations,
5 serializeAssistantCitation,
6} from "@t3tools/shared/assistantCitations";
7​
8import { removeLocalStorageItem } from "./hooks/useLocalStorage";
9import { createMemoryStorage } from "./lib/storage";
10​
11import {
12 MAX_STASH_ENTRIES,
13 PROMPT_STASH_STORAGE_KEY,
14 MAX_STASH_ENTRY_ATTACHMENT_CHARS,
15 partitionStashAttachments,
16 usePromptStashStore,
17 writePromptStashStorageForTest,
18 type PromptStashEntry,
19} from "./promptStashStore";
20​
21function makeEntry(input: {
22 id: string;
23 prompt?: string;
24 attachmentChars?: number;
25}): PromptStashEntry {
26 return {
27 id: input.id,
28 createdAt: "2026-07-24T12:00:00.000Z",
29 prompt: input.prompt ?? `prompt ${input.id}`,
30 attachments:
31 input.attachmentChars !== undefined
32 ? [
33 {
34 id: `${input.id}-img`,
35 name: "shot.png",
36 mimeType: "image/png",
37 sizeBytes: input.attachmentChars,
38 dataUrl: "x".repeat(input.attachmentChars),
39 },
40 ]
41 : [],
42 droppedImageNames: [],
43 };
45​
46function resetPromptStashStore() {
47 usePromptStashStore.setState({ entries: [] });
48 writePromptStashStorageForTest("");
49 removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);
51​
52describe("partitionStashAttachments", () => {
53 it("keeps attachments within the budget and reports dropped names in order", () => {
54 const small = {
55 id: "a",
56 name: "small.png",
57 mimeType: "image/png",
58 sizeBytes: 10,
59 dataUrl: "x".repeat(10),
60 };
61 const huge = {
62 id: "b",
63 name: "huge.png",
64 mimeType: "image/png",
65 sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS,
66 dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS),
67 };
68 const alsoSmall = {
69 id: "c",
70 name: "also-small.png",
71 mimeType: "image/png",
72 sizeBytes: 10,
73 dataUrl: "x".repeat(10),
74 };
75 const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]);
76 expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]);
77 expect(droppedNames).toEqual(["huge.png"]);
78 });
79});
80​
81describe("promptStashStore", () => {
82 beforeEach(() => {
83 resetPromptStashStore();
84 });
85​
86 afterEach(() => {
87 resetPromptStashStore();
88 });
89​
90 it("restores citations and bound comments from a persisted stash", () => {
91 const citation = {
92 version: 1 as const,
93 environmentId: EnvironmentId.make("remote-source"),
94 threadId: ThreadId.make("source-thread"),
95 messageId: MessageId.make("source-message"),
96 text: "A quote to revisit later.",
97 comment: "Revisit this tomorrow.\nCheck whether the example still applies.",
98 start: 0,
99 end: 24,
100 prefix: "",
101 suffix: " Next.",
102 };
103 const prompt = `Revisit ${serializeAssistantCitation(citation)}`;
104 writePromptStashStorageForTest(
105 JSON.stringify({
106 version: 2,
107 state: { entries: [makeEntry({ id: "citation-stash", prompt })] },
108 }),
109 );
110 const restored = usePromptStashStore.getState().takeEntry("citation-stash").entry;
111 expect(restored?.prompt).toBe(prompt);
112 expect(
113 collectAssistantCitations(restored?.prompt ?? "").map((entry) => entry.citation),
114 ).toEqual([citation]);
115 expect(usePromptStashStore.getState().takeEntry("citation-stash").entry).toBeNull();
116 });
117​
118 it("evicts the oldest entry past the cap and returns it", () => {
119 const store = usePromptStashStore.getState();
120 for (let index = 0; index < MAX_STASH_ENTRIES; index += 1) {
121 expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull();
122 }
123 const { evicted } = store.stashEntry(makeEntry({ id: "overflow" }));
124 expect(evicted?.id).toBe("entry-0");
125 const entries = usePromptStashStore.getState().entries;
126 expect(entries).toHaveLength(MAX_STASH_ENTRIES);
127 expect(entries[0]?.id).toBe("overflow");
128 });
129​
130 // regression: #4787 stashed prompts were lost when switching providers
131 it("distinguishes a memory-only write (written, not durable) from a failed one", () => {
132 const store = usePromptStashStore.getState();
133 const result = store.stashEntry(makeEntry({ id: "memory-only" }));
134 expect(result.written).toBe(true);
135 expect(result.durable).toBe(false);
136 expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([
137 "memory-only",
138 ]);
139 });
140​
141 it("takeEntry returns images and drop metadata finalized after a menu snapshot", () => {
142 const store = usePromptStashStore.getState();
143 store.stashEntry({ ...makeEntry({ id: "pending-restore" }), pendingImageCount: 1 });
144 const menuSnapshot = usePromptStashStore.getState().entries[0];
145 expect(menuSnapshot?.attachments).toEqual([]);
146​
147 store.finalizeEntryImages("pending-restore", {
148 attachments: [
149 {
150 id: "img-finalized",
151 name: "finalized.webp",
152 mimeType: "image/webp",
153 sizeBytes: 12,
154 dataUrl: "data:image/webp;base64,BBBB",
155 },
156 ],
157 droppedImageNames: ["too-large.png"],
158 unreadableImageNames: ["unreadable.png"],
159 });
160​
161 const { entry: taken } = store.takeEntry("pending-restore");
162 expect(taken).not.toBe(menuSnapshot);
163 expect(taken).toMatchObject({
164 attachments: [
165 {
166 id: "img-finalized",
167 name: "finalized.webp",
168 },
169 ],
170 droppedImageNames: ["too-large.png"],
171 unreadableImageNames: ["unreadable.png"],
172 pendingImageCount: 0,
173 });
174 });
175​
176 it("preserves uploaded file references without storing file contents", () => {
177 const store = usePromptStashStore.getState();
178 const file = {
179 id: "file-1",
180 name: "report.pdf",
181 mimeType: "application/pdf",
182 sizeBytes: 42,
183 attachmentId: "pending-report-pdf",
184 environmentId: EnvironmentId.make("environment-1"),
185 };
186​
187 writePromptStashStorageForTest(
188 JSON.stringify({
189 version: 2,
190 state: { entries: [{ ...makeEntry({ id: "with-file" }), files: [file] }] },
191 }),
192 );
193 store.finalizeEntryImages("with-file", {
194 attachments: [],
195 droppedImageNames: [],
196 unreadableImageNames: [],
197 });
198​
199 expect(usePromptStashStore.getState().entries[0]?.files).toEqual([file]);
200 });
201​
202 it("finalizeEntryImages reports false when the entry was already taken", () => {
203 const store = usePromptStashStore.getState();
204 store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });
205 store.takeEntry("racing");
206​
207 const { attached } = store.finalizeEntryImages("racing", {
208 attachments: [],
209 droppedImageNames: [],
210 unreadableImageNames: [],
211 });
212​
213 expect(attached).toBe(false);
214 });
215​
216 it("settles a pending count left behind by a crashed or closed session", () => {
217 writePromptStashStorageForTest(
218 JSON.stringify({
219 version: 2,
220 state: {
221 entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }],
222 },
223 }),
224 );
225​
226 const entry = usePromptStashStore.getState().entries[0];
227 expect(entry?.pendingImageCount).toBe(0);
228 expect(entry?.unreadableImageNames).toHaveLength(2);
229 });
230​
231 it("ignores an unreadable v1 payload seeded under the current key", () => {
232 writePromptStashStorageForTest(
233 JSON.stringify({
234 version: 1,
235 state: { queuesByScopeKey: { "provider:claudeAgent": [] } },
236 }),
237 );
238 expect(usePromptStashStore.getState().entries).toEqual([]);
239 });
240​
241 // regression: #35010 stashing in one tab erased what another tab stashed after it loaded
242 it("keeps an entry another tab stashed after this tab loaded", async () => {
243 const sharedStorage = createMemoryStorage();
244 vi.stubGlobal("localStorage", sharedStorage);
245 vi.stubGlobal("window", new EventTarget());
246 vi.resetModules();
247 try {
248 const thisTab = await import("./promptStashStore");
249 sharedStorage.setItem(
250 PROMPT_STASH_STORAGE_KEY,
251 JSON.stringify({ version: 2, state: { entries: [makeEntry({ id: "other-tab" })] } }),
252 );
253 window.dispatchEvent(Object.assign(new Event("storage"), { key: PROMPT_STASH_STORAGE_KEY }));
254​
255 thisTab.usePromptStashStore.getState().stashEntry(makeEntry({ id: "this-tab" }));
256​
257 const saved = JSON.parse(sharedStorage.getItem(PROMPT_STASH_STORAGE_KEY) as string);
258 expect(saved.state.entries.map((entry: PromptStashEntry) => entry.id)).toEqual([
259 "this-tab",
260 "other-tab",
261 ]);
262 } finally {
263 vi.unstubAllGlobals();
264 vi.resetModules();
265 }
266 });
267});
268​
269describe("prompt stash context records", () => {
270 it("restores the records behind a persisted prompt's chips", () => {
271 const records = [
272 {
273 version: 1 as const,
274 contextId: "ctx-1" as never,
275 kind: "terminal" as const,
276 label: "Terminal 1 line 4",
277 terminalId: "default",
278 terminalLabel: "Terminal 1",
279 lineStart: 4,
280 lineEnd: 4,
281 text: "boom",
282 },
283 ];
284 writePromptStashStorageForTest(
285 JSON.stringify({
286 version: 2,
287 state: {
288 entries: [
289 {
290 id: "entry-records",
291 createdAt: "2026-01-01T00:00:00.000Z",
292 prompt: "see [Terminal 1 line 4](t3-context://v1/terminal/ctx-1)",
293 attachments: [],
294 droppedImageNames: [],
295 records,
296 },
297 ],
298 },
299 }),
300 );
301 const taken = usePromptStashStore.getState().takeEntry("entry-records");
302 expect(taken.entry?.records).toEqual(records);
303 });
304});