1import {
2 ORCHESTRATION_CACHE_SCHEMA_VERSION,
3 ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
4 StoredOrchestrationShellSnapshot,
5} from "@t3tools/client-runtime/platform";
6import {
7 CommandId,
8 EnvironmentId,
9 ProjectId,
10 ProviderInstanceId,
11 ThreadId,
12 type OrchestrationV2ShellSnapshot,
13 type OrchestrationV2ThreadDetailSnapshot,
14 type ThreadPullRequestLink,
15 type VcsListRefsResult,
16} from "@t3tools/contracts";
17import { describe, expect, it } from "@effect/vitest";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as Fiber from "effect/Fiber";
21import * as Logger from "effect/Logger";
22import * as Schema from "effect/Schema";
23import { vi } from "vite-plus/test";
24import * as Deferred from "effect/Deferred";
25import * as Option from "effect/Option";
26
27import * as MobileDatabase from "../persistence/mobile-database";
28import { make } from "./environment-cache-store";
29import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";
30
31const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
32const PROJECT_ID = ProjectId.make("project-1");
33const THREAD_ID = ThreadId.make("thread-1");
34const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("codex");
35const NOW = DateTime.makeUnsafe("2026-07-29T12:00:00.000Z");
36const SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = {
37 schemaVersion: 1,
38 snapshotSequence: 3,
39 projects: [
40 {
41 id: PROJECT_ID,
42 title: "Project",
43 workspaceRoot: "/repo",
44 repositoryIdentity: null,
45 defaultModelSelection: null,
46 scripts: [],
47 createdAt: "2026-07-29T12:00:00.000Z",
48 updatedAt: "2026-07-29T12:00:00.000Z",
49 },
50 ],
51 threads: [
52 {
53 id: THREAD_ID,
54 projectId: PROJECT_ID,
55 title: "Thread",
56 providerInstanceId: PROVIDER_INSTANCE_ID,
57 modelSelection: {
58 instanceId: PROVIDER_INSTANCE_ID,
59 model: "gpt-5.4",
60 },
61 runtimeMode: "full-access",
62 interactionMode: "default",
63 branch: null,
64 worktreePath: null,
65 activeProviderThreadId: null,
66 lineage: {
67 rootThreadId: THREAD_ID,
68 parentThreadId: null,
69 relationshipToParent: null,
70 },
71 forkedFrom: null,
72 createdBy: "user",
73 creationSource: "mobile",
74 latestRunId: null,
75 activeRunId: null,
76 status: "idle",
77 pendingRuntimeRequest: null,
78 latestVisibleMessage: null,
79 latestUserMessageAt: NOW,
80 activityRunStartedAt: NOW,
81 activityRunStatus: "running",
82 hasActionableProposedPlan: false,
83 itemCount: 0,
84 visibleItemCount: 0,
85 createdAt: NOW,
86 updatedAt: NOW,
87 archivedAt: null,
88 settledOverride: null,
89 settledAt: NOW,
90 unsettledAt: NOW,
91 lastVisitedAt: null,
92 titleRegeneration: {
93 requestId: CommandId.make("title-regeneration-1"),
94 startedAt: NOW,
95 },
96 deletedAt: null,
97 },
98 ],
99 archivedThreads: [],
100};
101const THREAD_SNAPSHOT: OrchestrationV2ThreadDetailSnapshot = {
102 snapshotSequence: 4,
103 projection: {
104 thread: {
105 id: THREAD_ID,
106 projectId: PROJECT_ID,
107 title: "Thread",
108 providerInstanceId: PROVIDER_INSTANCE_ID,
109 modelSelection: {
110 instanceId: PROVIDER_INSTANCE_ID,
111 model: "gpt-5.4",
112 },
113 runtimeMode: "full-access",
114 interactionMode: "default",
115 branch: null,
116 worktreePath: null,
117 activeProviderThreadId: null,
118 lineage: {
119 rootThreadId: THREAD_ID,
120 parentThreadId: null,
121 relationshipToParent: null,
122 },
123 forkedFrom: null,
124 createdBy: "user",
125 creationSource: "mobile",
126 createdAt: NOW,
127 updatedAt: NOW,
128 archivedAt: null,
129 settledOverride: null,
130 settledAt: NOW,
131 lastVisitedAt: null,
132 deletedAt: null,
133 },
134 runs: [],
135 attempts: [],
136 nodes: [],
137 subagents: [],
138 providerSessions: [],
139 providerThreads: [],
140 providerTurns: [],
141 runtimeRequests: [],
142 messages: [],
143 plans: [],
144 turnItems: [],
145 checkpointScopes: [],
146 checkpoints: [],
147 contextHandoffs: [],
148 contextTransfers: [],
149 visibleTurnItems: [],
150 updatedAt: NOW,
151 },
152};
153const REFS: VcsListRefsResult = {
154 refs: [
155 {
156 name: "main",
157 current: true,
158 isDefault: true,
159 worktreePath: "/repo",
160 },
161 ],
162 isRepo: true,
163 hasPrimaryRemote: true,
164 nextCursor: null,
165 totalCount: 1,
166};
167
168function cacheId(
169 environmentId: EnvironmentId,
170 kind: MobileDatabase.ClientCacheKind,
171 cacheKey: string,
172) {
173 return `${environmentId}:${kind}:${cacheKey}`;
174}
175
176function makeDatabase() {
177 const values = new Map<string, string>();
178 const schemaVersions = new Map<string, number>();
179 const removed: Array<string> = [];
180 const database = MobileDatabase.MobileDatabase.of({
181 loadCache: (environmentId, kind, cacheKey) =>
182 Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),
183 listCache: (kind) =>
184 Effect.sync(() =>
185 [...values.entries()]
186 .filter(([key]) => key.split(":")[1] === kind)
187 .map(([, payload]) => payload),
188 ),
189 saveCache: (environmentId, kind, cacheKey, schemaVersion, payload) =>
190 Effect.sync(() => {
191 const id = cacheId(environmentId, kind, cacheKey);
192 values.set(id, payload);
193 schemaVersions.set(id, schemaVersion);
194 }),
195 removeCache: (environmentId, kind, cacheKey) =>
196 Effect.sync(() => {
197 const id = cacheId(environmentId, kind, cacheKey);
198 removed.push(id);
199 values.delete(id);
200 }),
201 clearCacheKind: (environmentId, kind) =>
202 Effect.sync(() => {
203 for (const key of values.keys()) {
204 if (key.startsWith(`${environmentId}:${kind}:`)) values.delete(key);
205 }
206 }),
207 clearEnvironmentCache: (environmentId) =>
208 Effect.sync(() => {
209 for (const key of values.keys()) {
210 if (key.startsWith(`${environmentId}:`)) values.delete(key);
211 }
212 }),
213 clearAllCaches: Effect.sync(() => values.clear()),
214 inspectCaches: Effect.succeed([]),
215 loadPreferencesJson: Effect.succeed(Option.none()),
216 savePreferencesJson: () => Effect.void,
217 });
218 return { database, removed, schemaVersions, values };
219}
220
221describe("mobile SQLite environment cache store", () => {
222 it.effect("round-trips V2 shell and thread DateTime fields with the shared cache schema", () =>
223 Effect.gen(function* () {
224 const memory = makeDatabase();
225 const store = yield* make().pipe(
226 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
227 );
228
229 yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT);
230 yield* store.saveThread(ENVIRONMENT_ID, THREAD_SNAPSHOT);
231
232 const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
233 const thread = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID));
234
235 expect(DateTime.formatIso(shell.threads[0]!.updatedAt)).toBe("2026-07-29T12:00:00.000Z");
236 expect(DateTime.formatIso(shell.threads[0]!.latestUserMessageAt!)).toBe(
237 "2026-07-29T12:00:00.000Z",
238 );
239 expect(DateTime.formatIso(shell.threads[0]!.titleRegeneration!.startedAt)).toBe(
240 "2026-07-29T12:00:00.000Z",
241 );
242 // Working and unsettled threads are exactly what a backgrounded app
243 // caches; a decode failure here discards the whole shell on relaunch.
244 expect(DateTime.formatIso(shell.threads[0]!.activityRunStartedAt!)).toBe(
245 "2026-07-29T12:00:00.000Z",
246 );
247 expect(DateTime.formatIso(shell.threads[0]!.unsettledAt!)).toBe("2026-07-29T12:00:00.000Z");
248 expect(DateTime.formatIso(thread.projection.thread.updatedAt)).toBe(
249 "2026-07-29T12:00:00.000Z",
250 );
251 expect(memory.schemaVersions.get(cacheId(ENVIRONMENT_ID, "shell", "snapshot"))).toBe(
252 ORCHESTRATION_CACHE_SCHEMA_VERSION,
253 );
254 expect(memory.schemaVersions.get(cacheId(ENVIRONMENT_ID, "thread", THREAD_ID))).toBe(
255 ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
256 );
257 }),
258 );
259
260 it.live("returns cached threads before their pull request links, which load after", () =>
261 Effect.gen(function* () {
262 const memory = makeDatabase();
263 const store = yield* make().pipe(
264 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
265 );
266 const link: ThreadPullRequestLink = {
267 host: "github.com",
268 repository: "pingdotgg/t3code",
269 number: 42,
270 url: "https://github.com/pingdotgg/t3code/pull/42",
271 source: "agent",
272 linkedAt: "2026-07-29T12:00:00.000Z",
273 snapshot: null,
274 stack: null,
275 };
276 const linked = { ...SHELL_SNAPSHOT.threads[0]!, pullRequests: [link] };
277 const unlinked = { ...SHELL_SNAPSHOT.threads[0]!, id: ThreadId.make("thread-2") };
278 yield* store.saveShell(ENVIRONMENT_ID, {
279 ...SHELL_SNAPSHOT,
280 threads: [linked, unlinked],
281 });
282
283 const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
284 expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]);
285 const links = yield* shell.loadPullRequests!;
286 expect([...links]).toEqual([[THREAD_ID, [link]]]);
287 }),
288 );
289
290 it.live("keeps cached threads when their pull request links cannot be read", () => {
291 const messages: Array<unknown> = [];
292 const logger = Logger.make(({ message }) => {
293 messages.push(message);
294 });
295 return Effect.gen(function* () {
296 const memory = makeDatabase();
297 const store = yield* make().pipe(
298 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
299 );
300 const link: ThreadPullRequestLink = {
301 host: "github.com",
302 repository: "pingdotgg/t3code",
303 number: 5,
304 url: "https://github.com/pingdotgg/t3code/pull/5",
305 source: "agent",
306 linkedAt: "2026-07-29T12:00:00.000Z",
307 snapshot: null,
308 stack: null,
309 };
310 const readable = {
311 ...SHELL_SNAPSHOT.threads[0]!,
312 id: ThreadId.make("thread-2"),
313 pullRequests: [link],
314 };
315 yield* store.saveShell(ENVIRONMENT_ID, {
316 ...SHELL_SNAPSHOT,
317 threads: [SHELL_SNAPSHOT.threads[0]!, readable],
318 });
319 const id = cacheId(ENVIRONMENT_ID, "shell", "snapshot");
320 const payload = JSON.parse(memory.values.get(id)!);
321 payload.snapshot.threads[0].pullRequests = [{ number: -1 }];
322 memory.values.set(id, JSON.stringify(payload));
323
324 const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
325 expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]);
326 // Only the thread with the unreadable link loses its links.
327 expect([...(yield* shell.loadPullRequests!)]).toEqual([[readable.id, [link]]]);
328 expect(messages).toHaveLength(1);
329 expect(memory.removed).toEqual([]);
330 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
331 });
332
333 it.effect("round-trips schema-validated VCS refs", () =>
334 Effect.gen(function* () {
335 const memory = makeDatabase();
336 const store = yield* make().pipe(
337 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
338 );
339
340 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
341
342 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS));
343 }),
344 );
345
346 it.effect("deletes a corrupt cache record and treats it as a miss", () =>
347 Effect.gen(function* () {
348 const memory = makeDatabase();
349 const store = yield* make().pipe(
350 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
351 );
352 const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo");
353 memory.values.set(id, "{not-json");
354
355 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
356 expect(memory.removed).toEqual([id]);
357 }),
358 );
359
360 it.effect("removes one persisted VCS ref snapshot", () =>
361 Effect.gen(function* () {
362 const memory = makeDatabase();
363 const store = yield* make().pipe(
364 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
365 );
366 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
367
368 yield* store.removeVcsRefs(ENVIRONMENT_ID, "/repo");
369
370 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
371 expect(memory.removed).toContain(cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo"));
372 }),
373 );
374
375 it.effect("clears every persisted VCS ref snapshot in one environment", () =>
376 Effect.gen(function* () {
377 const memory = makeDatabase();
378 const store = yield* make().pipe(
379 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
380 );
381 const otherEnvironmentId = EnvironmentId.make("environment-2");
382 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
383 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo-worktree", REFS);
384 yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS);
385
386 yield* store.clearVcsRefs(ENVIRONMENT_ID);
387
388 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
389 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo-worktree")).toEqual(Option.none());
390 expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS));
391 }),
392 );
393
394 it.effect("clears one environment without touching another", () =>
395 Effect.gen(function* () {
396 const memory = makeDatabase();
397 const store = yield* make().pipe(
398 Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
399 );
400 const otherEnvironmentId = EnvironmentId.make("environment-2");
401 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
402 yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS);
403
404 yield* store.clear(ENVIRONMENT_ID);
405
406 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
407 expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS));
408 }),
409 );
410});
411
412describe("cooperative shell cache encoding", () => {
413 // A fresh encoder per call keeps these cases cold; the cache has its own cases below.
414 const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) =>
415 makeStoredShellSnapshotEncoder()(stored);
416 const encodeOriginal = Schema.encodeEffect(
417 Schema.fromJsonString(StoredOrchestrationShellSnapshot),
418 );
419 const stored = {
420 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
421 environmentId: ENVIRONMENT_ID,
422 snapshot: {
423 ...SHELL_SNAPSHOT,
424 projects: SHELL_SNAPSHOT.projects.map((project) => ({
425 ...project,
426 title: " Project ",
427 unexpected: "drop this field",
428 })),
429 threads: Array.from({ length: 64 }, (_, index) => ({
430 ...SHELL_SNAPSHOT.threads[0]!,
431 id: ThreadId.make(`thread-${index}`),
432 branch: " main ",
433 activityRunStartedAt: index % 2 === 0 ? NOW : null,
434 unsettledAt: index % 2 === 0 ? NOW : null,
435 modelSelection: {
436 instanceId: PROVIDER_INSTANCE_ID,
437 model: " gpt-5.4 ",
438 unexpected: "drop this field",
439 },
440 unexpected: "drop this field",
441 })),
442 archivedThreads: [{ ...SHELL_SNAPSHOT.threads[0]!, archivedAt: NOW }],
443 unexpected: "drop this field",
444 },
445 unexpected: "drop this field",
446 };
447
448 it.effect("preserves exact JSON, transforms, nulls, dates, and unknown-field stripping", () =>
449 Effect.gen(function* () {
450 const expected = yield* encodeOriginal(stored);
451 const actual = yield* encodeStoredShellSnapshot(stored);
452 expect(actual).toBe(expected);
453 const parsed = JSON.parse(actual);
454 expect(parsed).not.toHaveProperty("unexpected");
455 expect(parsed.snapshot).not.toHaveProperty("unexpected");
456 expect(parsed.snapshot.projects[0]).not.toHaveProperty("unexpected");
457 expect(parsed.snapshot.projects[0].title).toBe("Project");
458 expect(parsed.snapshot.threads[0]).not.toHaveProperty("unexpected");
459 expect(parsed.snapshot.threads[0].branch).toBe("main");
460 expect(parsed.snapshot.threads[0].modelSelection).toEqual({
461 instanceId: "codex",
462 model: "gpt-5.4",
463 });
464 expect(parsed.snapshot.threads[0].activityRunStartedAt).toBe(DateTime.formatIso(NOW));
465 expect(parsed.snapshot.threads[1].activityRunStartedAt).toBeNull();
466 expect(parsed.snapshot.archivedThreads[0].archivedAt).toBe(DateTime.formatIso(NOW));
467 }),
468 );
469
470 it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () =>
471 Effect.gen(function* () {
472 const base = SHELL_SNAPSHOT.threads[0]!;
473 const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base;
474 const row = (index: number) =>
475 index % 3 === 0
476 ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) }
477 : {
478 ...base,
479 id: ThreadId.make(`row-${index}`),
480 snoozedUntil: DateTime.add(NOW, { minutes: index }),
481 pinnedAt: index % 2 === 0 ? null : NOW,
482 };
483 const mixed = {
484 ...stored,
485 snapshot: {
486 ...stored.snapshot,
487 threads: Array.from({ length: 65 }, (_, index) => row(index)),
488 archivedThreads: Array.from({ length: 33 }, (_, index) => ({
489 ...row(index + 100),
490 archivedAt: DateTime.add(NOW, { hours: index }),
491 })),
492 },
493 };
494 const actual = yield* encodeStoredShellSnapshot(mixed);
495 expect(actual).toBe(yield* encodeOriginal(mixed));
496 const parsed = JSON.parse(actual);
497 expect(parsed.snapshot.threads).toHaveLength(65);
498 expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration");
499 expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt");
500 expect(parsed.snapshot.threads[64].snoozedUntil).toBe(
501 DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })),
502 );
503 expect(parsed.snapshot.archivedThreads).toHaveLength(33);
504 expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe(
505 DateTime.formatIso(DateTime.add(NOW, { hours: 32 })),
506 );
507 }),
508 );
509
510 it.effect("yields to a host timer between every 32-row chunk", () =>
511 Effect.gen(function* () {
512 const setTimer = vi.spyOn(globalThis, "setTimeout");
513 try {
514 yield* encodeStoredShellSnapshot({
515 ...stored,
516 snapshot: {
517 ...stored.snapshot,
518 threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })),
519 archivedThreads: Array.from({ length: 33 }, () => ({
520 ...stored.snapshot.archivedThreads[0]!,
521 })),
522 },
523 });
524 // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope.
525 expect(setTimer).toHaveBeenCalledTimes(5);
526 } finally {
527 vi.restoreAllMocks();
528 }
529 }),
530 );
531
532 it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () =>
533 Effect.gen(function* () {
534 const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 };
535 const invalidActive = {
536 ...stored,
537 snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] },
538 };
539 const invalidArchived = {
540 ...stored,
541 snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] },
542 };
543 const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } };
544 expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true);
545 expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true);
546 expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true);
547 expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true);
548 }),
549 );
550
551 it.effect("keeps concurrent saves independent", () =>
552 Effect.gen(function* () {
553 const other = {
554 ...stored,
555 environmentId: EnvironmentId.make("environment-2"),
556 snapshot: {
557 ...stored.snapshot,
558 threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({
559 ...thread,
560 title: `Other ${thread.id}`,
561 })),
562 archivedThreads: [],
563 },
564 };
565 const [first, second] = yield* Effect.all(
566 [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)],
567 { concurrency: "unbounded" },
568 );
569 expect(first).toBe(yield* encodeOriginal(stored));
570 expect(second).toBe(yield* encodeOriginal(other));
571 }),
572 );
573
574 it.effect("cancels the pending host timer when encoding is interrupted", () =>
575 Effect.gen(function* () {
576 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
577 const fakeSetTimeout = globalThis.setTimeout;
578 const scheduled = yield* Deferred.make<ReturnType<typeof setTimeout>>();
579 vi.spyOn(globalThis, "setTimeout").mockImplementation((...args) => {
580 const timer = fakeSetTimeout(...args);
581 Deferred.doneUnsafe(scheduled, Effect.succeed(timer));
582 return timer;
583 });
584 const clearTimer = vi.spyOn(globalThis, "clearTimeout");
585 const fiber = yield* Effect.forkScoped(
586 Effect.suspend(() => encodeStoredShellSnapshot(stored)),
587 );
588 try {
589 const pendingTimer = yield* Deferred.await(scheduled);
590 expect(vi.getTimerCount()).toBe(1);
591 yield* Fiber.interrupt(fiber);
592 expect(clearTimer).toHaveBeenCalledWith(pendingTimer);
593 expect(vi.getTimerCount()).toBe(0);
594 } finally {
595 yield* Fiber.interrupt(fiber);
596 vi.restoreAllMocks();
597 vi.useRealTimers();
598 }
599 }),
600 );
601
602 it.effect("reuses encoded rows across saves without changing the payload", () =>
603 Effect.gen(function* () {
604 const encode = makeStoredShellSnapshotEncoder();
605 expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored));
606
607 const setTimer = vi.spyOn(globalThis, "setTimeout");
608 try {
609 // Same row references, new envelope: no row work, so no host yields.
610 const resaved = {
611 ...stored,
612 snapshot: { ...stored.snapshot, snapshotSequence: 4 },
613 };
614 expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved));
615 expect(setTimer).not.toHaveBeenCalled();
616 } finally {
617 vi.restoreAllMocks();
618 }
619 }),
620 );
621
622 it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () =>
623 Effect.gen(function* () {
624 const encode = makeStoredShellSnapshotEncoder();
625 yield* encode(stored);
626 const [first, second, ...rest] = stored.snapshot.threads;
627 const next = {
628 ...stored,
629 snapshot: {
630 ...stored.snapshot,
631 threads: [
632 { ...first!, title: "Renamed", itemCount: 9 },
633 ...rest,
634 { ...first!, id: ThreadId.make("thread-new"), branch: " feature " },
635 ],
636 archivedThreads: [
637 ...stored.snapshot.archivedThreads,
638 { ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) },
639 ],
640 },
641 };
642 const actual = yield* encode(next);
643 expect(actual).toBe(yield* encodeOriginal(next));
644 const parsed = JSON.parse(actual);
645 expect(parsed.snapshot.threads[0].title).toBe("Renamed");
646 expect(parsed.snapshot.threads.at(-1).branch).toBe("feature");
647 expect(parsed.snapshot.archivedThreads).toHaveLength(2);
648 }),
649 );
650
651 it.effect("does not cache rows from a failed encode", () =>
652 Effect.gen(function* () {
653 const encode = makeStoredShellSnapshotEncoder();
654 const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 };
655 const invalid = {
656 ...stored,
657 snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] },
658 };
659 expect(yield* Effect.isFailure(encode(invalid))).toBe(true);
660 expect(yield* Effect.isFailure(encode(invalid))).toBe(true);
661 // Valid rows from the failed save still encode correctly afterwards.
662 expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored));
663 }),
664 );
665});