Skip to content
Source
Sign inSign up

ChatView.logic.test.ts

1,419 lines45.1 KB

1import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic";
2import {
3 boundAttachmentIds,
4 buildExpiredTerminalContextToastCopy,
5 mergeDirectPreviewAnnotation,
6 planComposerTurn,
7 snapshotComposerSend,
8 withMessageContext,
9 deriveAvailableRevertTurnCounts,
10 deriveThreadPendingBackgroundTasks,
11} from "./ChatView.logic";
12import {
13 isThreadEnvironmentLocked,
14 recallCheckoutIsRepo,
15 rememberCheckoutIsRepo,
16} from "./ChatView.logic";
17import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
18import { deriveProviderInstanceEntries } from "../providerInstances";
19import { ensureInlineContextReferences } from "../lib/composerContextReferences";
20import {
21 responseContextRecord,
22 responseContextReference,
23 terminalContextReference,
24} from "../lib/composerContextRecords";
25import {
26 CommandId,
27 EnvironmentId,
28 MessageId,
29 ProjectId,
30 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
31 ProviderInstanceId,
32 ProviderSessionId,
33 ProviderThreadId,
34 ThreadId,
35 RunId,
36 TurnItemId,
37 type OrchestrationV2ProjectedTurnItem,
38 type WorktreeSetupSnapshot,
39} from "@t3tools/contracts";
40import * as DateTime from "effect/DateTime";
41import * as Option from "effect/Option";
42import { afterEach, describe, expect, it, vi } from "vite-plus/test";
43import { Atom } from "effect/reactivity";
44import { appAtomRegistry } from "../rpc/atomRegistry";
45import { environmentThreadDetails } from "../state/threads";
46​
47import type { Thread } from "../types";
48import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures";
49import {
50 getAntigravitySendBlockReason,
51 resolveComposerProviderSelection,
52 resolveWorktreeSetupProgress,
53 peekRememberedThreadTimeline,
54 rememberReadyThreadTimeline,
55 resetHeldThreadTimeline,
56 resolveThreadSwitchTimeline,
57 timelineHasEphemeralPreviewUrls,
58 createLocalDispatchSnapshot,
59 deriveCommittedServerUserMessageIds,
60 deriveComposerSendState,
61 deriveLockedProvider,
62 hasServerAcknowledgedLocalDispatch,
63 waitForRevertedMessage,
64} from "./ChatView.logic";
65​
66const environmentId = EnvironmentId.make("environment-local");
67const projectId = ProjectId.make("project-1");
68const threadId = ThreadId.make("thread-1");
69const now = "2026-03-29T00:00:00.000Z";
70​
71function makeThread(overrides: Partial<Thread> = {}): Thread {
72 return makeThreadFixture({
73 id: threadId,
74 environmentId,
75 projectId,
76 title: "Thread",
77 modelSelection: {
78 instanceId: ProviderInstanceId.make("codex"),
79 model: "gpt-5.4",
80 },
81 runtimeMode: "full-access",
82 interactionMode: "default",
83 runtime: null,
84 createdAt: now,
85 updatedAt: now,
86 archivedAt: null,
87 settledOverride: null,
88 settledAt: null,
89 deletedAt: null,
90 latestRun: null,
91 branch: null,
92 worktreePath: null,
93 ...overrides,
94 });
96​
97const completedTurn = {
98 runId: RunId.make("turn-1"),
99 status: "completed" as const,
100 requestedAt: now,
101 startedAt: "2026-03-29T00:00:01.000Z",
102 completedAt: "2026-03-29T00:00:10.000Z",
103 assistantMessageId: null,
104};
105​
106const readySession = {
107 status: "completed" as const,
108 providerName: "codex",
109 providerInstanceId: ProviderInstanceId.make("codex"),
110 activeRunId: null,
111 lastError: null,
112 updatedAt: "2026-03-29T00:00:10.000Z",
113};
114​
115describe("deriveComposerSendState", () => {
116 it("keeps text sendable while excluding expired terminal pills", () => {
117 const state = deriveComposerSendState({
118 prompt: `yoo [Terminal 1](t3-context://v1/terminal/ctx-expired) waddup`,
119 imageCount: 0,
120 terminalContexts: [
121 {
122 id: "ctx-expired",
123 threadId,
124 terminalId: "default",
125 terminalLabel: "Terminal 1",
126 lineStart: 4,
127 lineEnd: 4,
128 text: "",
129 createdAt: now,
130 },
131 ],
132 });
133​
134 expect(state.trimmedPrompt).toBe("yoo waddup");
135 expect(state.expiredTerminalContextCount).toBe(1);
136 expect(state.hasSendableContent).toBe(true);
137 });
138});
139​
140describe("isThreadEnvironmentLocked", () => {
141 it.each([
142 { name: "an empty unbound thread", providerThread: null, expected: false },
143 {
144 name: "an imported session bound before its first run",
145 providerThread: "imported",
146 expected: true,
147 },
148 ])("locks $name: $expected", ({ providerThread, expected }) => {
149 expect(
150 isThreadEnvironmentLocked({
151 messageCount: 0,
152 hasRuntime: false,
153 activeProviderThreadId:
154 providerThread === null ? null : ProviderThreadId.make(providerThread),
155 }),
156 ).toBe(expected);
157 });
158});
159​
160describe("buildExpiredTerminalContextToastCopy", () => {
161 it("refers to several expired contexts as them", () => {
162 expect(buildExpiredTerminalContextToastCopy(2, "empty")).toEqual({
163 title: "Expired terminal contexts won't be sent",
164 description: "Remove them or re-add them to include terminal output.",
165 });
166 expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({
167 title: "Expired terminal contexts omitted from message",
168 description: "Re-add them if you want that terminal output included.",
169 });
170 expect(buildExpiredTerminalContextToastCopy(1, "omitted").description).toBe(
171 "Re-add it if you want that terminal output included.",
172 );
173 });
174});
175​
176describe("hasServerAcknowledgedLocalDispatch", () => {
177 it("acknowledges a settled newer background turn", () => {
178 const localDispatch = createLocalDispatchSnapshot(
179 makeThread({ latestRun: completedTurn, runtime: readySession }),
180 { submissionIntent: "background" },
181 );
182 const newerTurn = {
183 ...completedTurn,
184 runId: RunId.make("turn-2"),
185 requestedAt: "2026-03-29T00:01:00.000Z",
186 startedAt: "2026-03-29T00:01:01.000Z",
187 completedAt: "2026-03-29T00:01:30.000Z",
188 };
189​
190 expect(
191 hasServerAcknowledgedLocalDispatch({
192 localDispatch,
193 phase: "ready",
194 latestRun: newerTurn,
195 runtime: { ...readySession, updatedAt: newerTurn.completedAt },
196 hasPendingApproval: false,
197 hasPendingUserInput: false,
198 threadError: null,
199 }),
200 ).toBe(true);
201 });
202​
203 it("holds a first send while the thread shell still reports a preparing run", () => {
204 const localDispatch = createLocalDispatchSnapshot(makeThread());
205 const preparingRun = {
206 ...completedTurn,
207 status: "preparing" as const,
208 startedAt: null,
209 completedAt: null,
210 };
211​
212 expect(
213 hasServerAcknowledgedLocalDispatch({
214 localDispatch,
215 phase: "disconnected",
216 latestRun: preparingRun,
217 runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId },
218 hasPendingApproval: false,
219 hasPendingUserInput: false,
220 threadError: null,
221 }),
222 ).toBe(false);
223 });
224​
225 it.each([
226 ["queued", "message-queued", true],
227 ["queued", "message-before-send", false],
228 ["preparing", "message-queued", false],
229 ["starting", "message-queued", false],
230 ] as const)("acknowledges %s with latest message %s: %s", (status, messageId, acknowledged) => {
231 const localDispatch = createLocalDispatchSnapshot(
232 makeThread({ latestRun: completedTurn, runtime: readySession }),
233 { latestUserMessageId: MessageId.make("message-before-send") },
234 );
235 // The shell hides the silent compaction run, so its runtime reports the queued message's run.
236 const queuedRun = {
237 ...completedTurn,
238 runId: RunId.make("turn-queued"),
239 status: "queued" as const,
240 requestedAt: "2026-03-29T00:01:00.000Z",
241 startedAt: null,
242 completedAt: null,
243 };
244​
245 expect(
246 hasServerAcknowledgedLocalDispatch({
247 localDispatch,
248 phase: "running",
249 latestRun: queuedRun,
250 latestUserMessageId: MessageId.make(messageId),
251 runtime: { ...readySession, status },
252 hasPendingApproval: false,
253 hasPendingUserInput: false,
254 threadError: null,
255 }),
256 ).toBe(acknowledged);
257 });
258​
259 it("waits for the matching running turn before acknowledging", () => {
260 const localDispatch = createLocalDispatchSnapshot(
261 makeThread({ latestRun: completedTurn, runtime: readySession }),
262 );
263 const runningTurn = {
264 ...completedTurn,
265 runId: RunId.make("turn-2"),
266 status: "running" as const,
267 requestedAt: "2026-03-29T00:01:00.000Z",
268 startedAt: "2026-03-29T00:01:01.000Z",
269 completedAt: null,
270 };
271​
272 expect(
273 hasServerAcknowledgedLocalDispatch({
274 localDispatch,
275 phase: "running",
276 latestRun: runningTurn,
277 runtime: {
278 ...readySession,
279 status: "running",
280 activeRunId: RunId.make("turn-other"),
281 },
282 hasPendingApproval: false,
283 hasPendingUserInput: false,
284 threadError: null,
285 }),
286 ).toBe(false);
287 expect(
288 hasServerAcknowledgedLocalDispatch({
289 localDispatch,
290 phase: "running",
291 latestRun: runningTurn,
292 runtime: {
293 ...readySession,
294 status: "running",
295 activeRunId: runningTurn.runId,
296 },
297 hasPendingApproval: false,
298 hasPendingUserInput: false,
299 threadError: null,
300 }),
301 ).toBe(true);
302 });
303​
304 it("acknowledges a steering message projected onto the current running run", () => {
305 const runningRun = {
306 ...completedTurn,
307 status: "running" as const,
308 completedAt: null,
309 };
310 const runningRuntime = {
311 ...readySession,
312 status: "running" as const,
313 activeRunId: runningRun.runId,
314 };
315 const localDispatch = createLocalDispatchSnapshot(
316 makeThread({ latestRun: runningRun, runtime: runningRuntime }),
317 { latestUserMessageId: MessageId.make("message-before-steer") },
318 );
319​
320 expect(
321 hasServerAcknowledgedLocalDispatch({
322 localDispatch,
323 phase: "running",
324 latestRun: runningRun,
325 latestUserMessageId: MessageId.make("message-steer"),
326 runtime: runningRuntime,
327 hasPendingApproval: false,
328 hasPendingUserInput: false,
329 threadError: null,
330 }),
331 ).toBe(true);
332 });
333});
334​
335describe("deriveCommittedServerUserMessageIds", () => {
336 it("tracks only committed user turn items, not assistant rows or projection-only messages", () => {
337 const turnStartId = MessageId.make("message-turn-start");
338 const steerId = MessageId.make("message-steer");
339 const assistantId = MessageId.make("message-assistant");
340 const committedAt = DateTime.makeUnsafe("2026-06-26T17:50:15.180Z");
341 const runId = RunId.make("run:thread:thread-1:ordinal:1");
342 const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [
343 {
344 position: 0,
345 visibility: "local",
346 sourceThreadId: threadId,
347 sourceItemId: TurnItemId.make("turn-item:message-turn-start"),
348 item: {
349 id: TurnItemId.make("turn-item:message-turn-start"),
350 threadId,
351 runId,
352 nodeId: null,
353 providerThreadId: null,
354 providerTurnId: null,
355 nativeItemRef: null,
356 parentItemId: null,
357 ordinal: 1,
358 status: "completed",
359 title: null,
360 startedAt: committedAt,
361 completedAt: committedAt,
362 updatedAt: committedAt,
363 createdBy: "user",
364 creationSource: "web",
365 type: "user_message",
366 messageId: turnStartId,
367 inputIntent: "turn_start",
368 text: "start",
369 attachments: [],
370 },
371 },
372 {
373 position: 1,
374 visibility: "local",
375 sourceThreadId: threadId,
376 sourceItemId: TurnItemId.make("turn-item:message-assistant"),
377 item: {
378 id: TurnItemId.make("turn-item:message-assistant"),
379 threadId,
380 runId,
381 nodeId: null,
382 providerThreadId: null,
383 providerTurnId: null,
384 nativeItemRef: null,
385 parentItemId: null,
386 ordinal: 2,
387 status: "completed",
388 title: null,
389 startedAt: committedAt,
390 completedAt: committedAt,
391 updatedAt: committedAt,
392 type: "assistant_message",
393 messageId: assistantId,
394 text: "working",
395 streaming: false,
396 },
397 },
398 {
399 position: 2,
400 visibility: "local",
401 sourceThreadId: threadId,
402 sourceItemId: TurnItemId.make("turn-item:message-steer"),
403 item: {
404 id: TurnItemId.make("turn-item:message-steer"),
405 threadId,
406 runId,
407 nodeId: null,
408 providerThreadId: null,
409 providerTurnId: null,
410 nativeItemRef: null,
411 parentItemId: null,
412 ordinal: 3,
413 status: "completed",
414 title: null,
415 startedAt: committedAt,
416 completedAt: committedAt,
417 updatedAt: committedAt,
418 createdBy: "user",
419 creationSource: "web",
420 type: "user_message",
421 messageId: steerId,
422 inputIntent: "steer",
423 text: "continue",
424 attachments: [],
425 },
426 },
427 ];
428​
429 expect(deriveCommittedServerUserMessageIds(visibleTurnItems)).toEqual(
430 new Set([turnStartId, steerId]),
431 );
432 });
433});
434​
435describe("resolveThreadSwitchTimeline", () => {
436 afterEach(() => {
437 resetHeldThreadTimeline();
438 });
439​
440 const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] };
441​
442 it("paints a remembered destination instead of the last-viewed thread", () => {
443 rememberReadyThreadTimeline(held);
444 rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] });
445 expect(peekRememberedThreadTimeline<string[]>("env-1:thread-a")).toEqual(["a1", "a2"]);
446 expect(
447 resolveThreadSwitchTimeline({
448 loading: true,
449 activeThreadKey: "env-1:thread-a",
450 nextEntries: [],
451 }),
452 ).toEqual(["a1", "a2"]);
453 });
454​
455 it("does not paint the last-viewed thread for a destination with nothing remembered", () => {
456 rememberReadyThreadTimeline(held);
457 expect(
458 resolveThreadSwitchTimeline({
459 loading: true,
460 activeThreadKey: "env-2:thread-b",
461 nextEntries: [],
462 }),
463 ).toEqual([]);
464 });
465​
466 it("does not remember a timeline that still has handoff blob previews", () => {
467 expect(
468 timelineHasEphemeralPreviewUrls([
469 {
470 kind: "message",
471 message: {
472 id: MessageId.make("preview-message"),
473 role: "user",
474 text: "Preview",
475 runId: null,
476 streaming: false,
477 createdAt: "2026-09-10T12:00:00.000Z",
478 updatedAt: "2026-09-10T12:00:00.000Z",
479 attachments: [
480 {
481 type: "image",
482 id: "preview",
483 name: "preview.png",
484 mimeType: "image/png",
485 sizeBytes: 1,
486 previewUrl: "blob:handoff",
487 },
488 ],
489 },
490 },
491 ]),
492 ).toBe(true);
493 expect(
494 timelineHasEphemeralPreviewUrls([
495 {
496 kind: "message",
497 message: {
498 id: MessageId.make("preview-message"),
499 role: "user",
500 text: "Preview",
501 runId: null,
502 streaming: false,
503 createdAt: "2026-09-10T12:00:00.000Z",
504 updatedAt: "2026-09-10T12:00:00.000Z",
505 attachments: [
506 {
507 type: "image",
508 id: "preview",
509 name: "preview.png",
510 mimeType: "image/png",
511 sizeBytes: 1,
512 previewUrl: "https://cdn.example/a.png",
513 },
514 ],
515 },
516 },
517 ]),
518 ).toBe(false);
519 });
520});
521​
522describe("resolveComposerProviderSelection", () => {
523 const catalogModels: ServerProvider["models"] = [
524 { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null },
525 ];
526​
527 function entry(driver: string, instanceId = driver, overrides: Partial<ServerProvider> = {}) {
528 return deriveProviderInstanceEntries([
529 {
530 driver: ProviderDriverKind.make(driver),
531 instanceId: ProviderInstanceId.make(instanceId),
532 enabled: true,
533 installed: true,
534 status: "ready",
535 auth: { status: "authenticated" },
536 version: null,
537 checkedAt: now,
538 models: [],
539 slashCommands: [],
540 skills: [],
541 ...overrides,
542 },
543 ])[0]!;
544 }
545​
546 function importedThread(instanceId: ProviderInstanceId) {
547 return makeThread({
548 modelSelection: { instanceId, model: "default" },
549 itemCount: 1,
550 });
551 }
552​
553 it.each([
554 ["claudeAgent", "claude_work"],
555 ["codex", "codex_work"],
556 ["ollama", "local_models"],
557 ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => {
558 const importedEntry = entry(driver, instanceId);
559 const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry];
560 const thread = importedThread(importedEntry.instanceId);
561 const lockedProvider = deriveLockedProvider({
562 thread,
563 selectedProvider: entries[0]!.instanceId,
564 threadProvider: thread.modelSelection.instanceId,
565 providers: entries.map((entry) => entry.snapshot),
566 });
567​
568 expect(thread.runtime).toBeNull();
569 expect(lockedProvider).toBe(driver);
570 expect(
571 resolveComposerProviderSelection({
572 entries,
573 candidateInstanceIds: [thread.modelSelection.instanceId],
574 lockedProvider,
575 lockedInstanceId: thread.modelSelection.instanceId,
576 }).selectedProviderEntry?.instanceId,
577 ).toBe(importedEntry.instanceId);
578 });
579​
580 it("keeps the session driver authoritative over instance and draft selections", () => {
581 const selected = entry("claudeAgent", "claude_work");
582 const sessionEntry = entry("ollama", "local_models");
583 const thread = importedThread(selected.instanceId);
584​
585 expect(
586 deriveLockedProvider({
587 thread: {
588 ...thread,
589 runtime: {
590 ...readySession,
591 providerName: sessionEntry.driverKind,
592 providerInstanceId: sessionEntry.instanceId,
593 },
594 },
595 selectedProvider: selected.instanceId,
596 threadProvider: thread.modelSelection.instanceId,
597 providers: [selected.snapshot, sessionEntry.snapshot],
598 }),
599 ).toBe(sessionEntry.driverKind);
600 });
601​
602 it.each(["missing", "disabled"] as const)(
603 "does not move imported history to another driver when its instance is %s",
604 (state) => {
605 const imported = entry("claudeAgent", "claude_work", { enabled: false });
606 const other = entry("codex");
607 const entries = state === "missing" ? [other] : [other, imported];
608 const thread = importedThread(imported.instanceId);
609 const lockedProvider = deriveLockedProvider({
610 thread,
611 selectedProvider: other.instanceId,
612 threadProvider: thread.modelSelection.instanceId,
613 providers: entries.map((entry) => entry.snapshot),
614 });
615​
616 expect(lockedProvider).not.toBeNull();
617 expect(
618 resolveComposerProviderSelection({
619 entries,
620 candidateInstanceIds: [other.instanceId, imported.instanceId],
621 lockedProvider,
622 lockedInstanceId: imported.instanceId,
623 }).selectedProviderEntry,
624 ).toBeUndefined();
625 },
626 );
627​
628 it("keeps a signed-out selection instead of silently switching providers", () => {
629 const signedOutEntry = entry("antigravity", "google_work", {
630 status: "error",
631 auth: { status: "unauthenticated" },
632 models: catalogModels,
633 });
634 const selection = resolveComposerProviderSelection({
635 entries: [entry("codex"), signedOutEntry],
636 candidateInstanceIds: [signedOutEntry.instanceId],
637 lockedProvider: null,
638 lockedInstanceId: null,
639 });
640​
641 expect(selection.selectedProviderEntry?.instanceId).toBe(signedOutEntry.instanceId);
642 expect(
643 getAntigravitySendBlockReason(selection.selectedProviderEntry?.snapshot, "gemini-pro"),
644 ).toBe("Sign in to Antigravity in provider settings before sending.");
645 });
646​
647 it("blocks a saved model that a ready catalog no longer lists", () => {
648 const provider = entry("antigravity", "google_work", {
649 status: "ready",
650 models: catalogModels,
651 }).snapshot;
652​
653 expect(getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog")).toBe(
654 "That Antigravity model is no longer available. Choose another model.",
655 );
656 expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull();
657 });
658​
659 it("allows a saved native model to retry after a provider error without changing it", () => {
660 const provider = entry("antigravity", "google_work", {
661 status: "error",
662 models: catalogModels,
663 }).snapshot;
664​
665 expect(
666 getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog"),
667 ).toBeNull();
668 });
669​
670 it("does not continue an existing Antigravity thread in another profile after deletion", () => {
671 const missingInstanceId = ProviderInstanceId.make("google_work");
672 const selection = resolveComposerProviderSelection({
673 entries: [entry("antigravity")],
674 candidateInstanceIds: [missingInstanceId],
675 lockedProvider: ProviderDriverKind.make("antigravity"),
676 lockedInstanceId: missingInstanceId,
677 });
678​
679 expect(selection.selectedProviderEntry).toBeUndefined();
680 expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId);
681 });
682​
683 it("keeps the session's continuation group when another instance was selected", () => {
684 const sessionEntry = entry("antigravity", "google_work", {
685 enabled: false,
686 continuation: { groupKey: "work-profile" },
687 });
688 const anotherEntry = entry("antigravity", "google_personal", {
689 continuation: { groupKey: "personal-profile" },
690 });
691 const selection = resolveComposerProviderSelection({
692 entries: [sessionEntry, anotherEntry],
693 candidateInstanceIds: [anotherEntry.instanceId, sessionEntry.instanceId],
694 lockedProvider: ProviderDriverKind.make("antigravity"),
695 lockedInstanceId: sessionEntry.instanceId,
696 });
697​
698 expect(selection.selectedProviderEntry).toBeUndefined();
699 });
700});
701​
702describe("checkout Git memory", () => {
703 it("does not confuse an environment id containing the separator with a path", () => {
704 rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false);
705 expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined();
706 });
707});
708​
709describe("worktree setup visibility", () => {
710 const stage = (
711 id: "fetch" | "checkout" | "submodules" | "setup-script" | "agent",
712 status: "done" | "running" | "failed" | "pending",
713 ) => ({
714 id,
715 status,
716 startedAt: now,
717 endedAt: status === "running" || status === "pending" ? null : now,
718 percent: null,
719 detail: null,
720 tail: [],
721 });
722 const base = {
723 threadId,
724 phase: "running" as const,
725 startedAt: now,
726 endedAt: null,
727 branch: "feature",
728 baseRef: "main",
729 worktreePath: null,
730 setupScript: null,
731 stages: [stage("checkout", "running"), stage("agent", "pending")],
732 error: null,
733 sequence: 1,
734 };
735 const settledDone = {
736 ...base,
737 phase: "done" as const,
738 endedAt: now,
739 stages: [stage("checkout", "done"), stage("setup-script", "done"), stage("agent", "done")],
740 };
741​
742 it("uses streamed setup progress immediately without reverting to an older held snapshot", () => {
743 const newest = { ...settledDone, sequence: 9 };
744 const resolve = (latest: WorktreeSetupSnapshot | null, held: WorktreeSetupSnapshot | null) =>
745 resolveWorktreeSetupProgress({
746 threadId,
747 localPreparing: false,
748 runStatus: "running",
749 latest,
750 held,
751 });
752 expect(resolve(newest, base)).toEqual({ snapshot: newest, isPreparingWorktree: false });
753 expect(resolve(base, newest)).toEqual({ snapshot: newest, isPreparingWorktree: false });
754 const other = { ...base, threadId: ThreadId.make("another-thread") };
755 expect(resolve(other, other)).toEqual({ snapshot: null, isPreparingWorktree: false });
756 });
757});
758​
759describe("waitForRevertedMessage", () => {
760 const threadRef = { environmentId: EnvironmentId.make("env-1"), threadId: ThreadId.make("t") };
761 const messageId = MessageId.make("message-2");
762 const requestId = CommandId.make("rollback-1");
763​
764 function projectionAtom() {
765 const base = makeThreadProjectionFixture();
766 const providerThreadId = ProviderThreadId.make("provider-thread");
767 const projection = {
768 ...base,
769 thread: { ...base.thread, activeProviderThreadId: providerThreadId },
770 providerThreads: [
771 {
772 id: providerThreadId,
773 driver: "codex",
774 providerInstanceId: base.thread.providerInstanceId,
775 providerSessionId: ProviderSessionId.make("provider-session"),
776 appThreadId: base.thread.id,
777 ownerNodeId: null,
778 nativeThreadRef: null,
779 nativeConversationHeadRef: null,
780 status: "idle",
781 firstRunOrdinal: 1,
782 lastRunOrdinal: 4,
783 handoffIds: [],
784 forkedFrom: null,
785 createdAt: base.updatedAt,
786 updatedAt: base.updatedAt,
787 },
788 ],
789 runs: [1, 2, 3, 4].map((ordinal) => ({
790 id: RunId.make(`run-${ordinal}`),
791 threadId: base.thread.id,
792 ordinal,
793 providerInstanceId: base.thread.providerInstanceId,
794 modelSelection: base.thread.modelSelection,
795 providerThreadId,
796 userMessageId: MessageId.make(`message-${ordinal}`),
797 rootNodeId: null,
798 activeAttemptId: null,
799 status: "completed",
800 requestedAt: base.updatedAt,
801 startedAt: base.updatedAt,
802 completedAt: base.updatedAt,
803 checkpointId: null,
804 contextHandoffId: null,
805 })),
806 messages: [
807 {
808 id: messageId,
809 threadId: base.thread.id,
810 runId: RunId.make("run-2"),
811 nodeId: null,
812 role: "user",
813 text: "second",
814 attachments: [],
815 streaming: false,
816 createdAt: base.updatedAt,
817 updatedAt: base.updatedAt,
818 },
819 ],
820 } as unknown as ReturnType<typeof makeThreadProjectionFixture>;
821 const state = Atom.make({ data: Option.some(projection) });
822 vi.spyOn(environmentThreadDetails, "stateAtom").mockReturnValue(state as never);
823 return { state, projection };
824 }
825​
826 afterEach(() => vi.restoreAllMocks());
827​
828 it("rejects with the projected reason when the rollback fails for good", async () => {
829 const { state, projection } = projectionAtom();
830 const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {});
831 await Promise.resolve();
832 appAtomRegistry.set(state, {
833 data: Option.some({
834 ...projection,
835 thread: {
836 ...projection.thread,
837 rollbackFailure: { requestId, message: "The provider could not roll back." },
838 },
839 }),
840 });
841​
842 await expect(waiting).rejects.toThrow("The provider could not roll back.");
843 });
844​
845 it.each([
846 ["same", "streamed"],
847 ["replacement", "streamed"],
848 ["replacement", "refetched"],
849 ] as const)(
850 "waits for the retained rollback suffix when the edited message and run are paged out (%s provider, %s)",
851 async (providerIdentity, projectionSource) => {
852 vi.useFakeTimers();
853 const { state, projection } = projectionAtom();
854 const latestRunId = RunId.make("latest-run-9");
855 const latestRun = {
856 ...projection.runs[3]!,
857 id: latestRunId,
858 ordinal: 9,
859 userMessageId: MessageId.make("latest-message-9"),
860 status: "cancelled" as const,
861 };
862 const bounded = {
863 ...projection,
864 messages: [],
865 providerThreads: projection.providerThreads.map((thread) => ({
866 ...thread,
867 lastRunOrdinal: 9,
868 })),
869 runs: [
870 {
871 ...latestRun,
872 id: RunId.make("old-rewound-run-3"),
873 userMessageId: MessageId.make("old-rewound-message-3"),
874 ordinal: 3,
875 status: "rolled_back" as const,
876 },
877 latestRun,
878 ],
879 };
880 const oldProviderThread = bounded.providerThreads[0]!;
881 const completedProviderThread = {
882 ...oldProviderThread,
883 ...(providerIdentity === "replacement"
884 ? { id: ProviderThreadId.make("replacement-provider-thread"), firstRunOrdinal: null }
885 : {}),
886 lastRunOrdinal: null,
887 };
888 appAtomRegistry.set(state, { data: Option.some(bounded) });
889 let outcome = "pending";
890 const waiting = waitForRevertedMessage(threadRef, 0, requestId, async () => {}).then(
891 () => {
892 outcome = "reverted";
893 },
894 (error: Error) => {
895 outcome = error.message;
896 },
897 );
898​
899 try {
900 await vi.advanceTimersByTimeAsync(0);
901 expect(outcome).toBe("pending");
902​
903 appAtomRegistry.set(state, {
904 data: Option.some({
905 ...bounded,
906 thread: {
907 ...bounded.thread,
908 activeProviderThreadId:
909 projectionSource === "refetched"
910 ? completedProviderThread.id
911 : oldProviderThread.id,
912 },
913 providerThreads:
914 providerIdentity === "replacement"
915 ? [oldProviderThread, completedProviderThread]
916 : [completedProviderThread],
917 runs: bounded.runs.map((run) =>
918 run.id === latestRunId ? { ...run, status: "rolled_back" as const } : run,
919 ),
920 }),
921 });
922 await vi.advanceTimersByTimeAsync(0);
923 expect(outcome).toBe("reverted");
924 await waiting;
925 } finally {
926 vi.clearAllTimers();
927 vi.useRealTimers();
928 }
929 },
930 );
931​
932 it("waits for the requested target when another client finishes a shallower rewind", async () => {
933 vi.useFakeTimers();
934 const { state, projection } = projectionAtom();
935 let outcome = "pending";
936 const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {}).then(
937 () => {
938 outcome = "reverted";
939 },
940 (error: Error) => {
941 outcome = error.message;
942 },
943 );
944​
945 try {
946 await vi.advanceTimersByTimeAsync(0);
947 expect(outcome).toBe("pending");
948​
949 // Our command is accepted while the earlier target-3 effect is still running.
950 const shallowerRewind = {
951 ...projection,
952 thread: { ...projection.thread, rollbackRequestId: requestId, rollbackFailure: null },
953 providerThreads: projection.providerThreads.map((thread) => ({
954 ...thread,
955 lastRunOrdinal: 3,
956 })),
957 runs: projection.runs.map((run) =>
958 run.ordinal > 3 ? { ...run, status: "rolled_back" as const } : run,
959 ),
960 };
961 appAtomRegistry.set(state, { data: Option.some(shallowerRewind) });
962 await vi.advanceTimersByTimeAsync(0);
963 expect(outcome).toBe("pending");
964​
965 appAtomRegistry.set(state, {
966 data: Option.some({
967 ...shallowerRewind,
968 providerThreads: shallowerRewind.providerThreads.map((thread) => ({
969 ...thread,
970 lastRunOrdinal: 1,
971 })),
972 runs: shallowerRewind.runs.map((run) =>
973 run.ordinal > 1 ? { ...run, status: "rolled_back" as const } : run,
974 ),
975 }),
976 });
977 await vi.advanceTimersByTimeAsync(0);
978 expect(outcome).toBe("reverted");
979 await waiting;
980 } finally {
981 vi.clearAllTimers();
982 vi.useRealTimers();
983 }
984 });
985​
986 it("rejects a stale edit already rewound by another client during attachment preparation", async () => {
987 vi.useFakeTimers();
988 const { state, projection } = projectionAtom();
989 appAtomRegistry.set(state, {
990 data: Option.some({
991 ...projection,
992 thread: { ...projection.thread, rollbackRequestId: CommandId.make("other-client-rewind") },
993 messages: [],
994 providerThreads: projection.providerThreads.map((thread) => ({
995 ...thread,
996 lastRunOrdinal: 1,
997 })),
998 runs: projection.runs.map((run) =>
999 run.ordinal > 1 ? { ...run, status: "rolled_back" as const } : run,
1000 ),
1001 }),
1002 });
1003 const revert = vi.fn(async () => {});
1004 let outcome = "pending";
1005 const waiting = waitForRevertedMessage(threadRef, 1, requestId, revert).then(
1006 () => {
1007 outcome = "reverted";
1008 },
1009 () => {
1010 outcome = "rejected";
1011 },
1012 );
1013​
1014 try {
1015 await vi.advanceTimersByTimeAsync(0);
1016 expect(outcome).toBe("rejected");
1017 expect(revert).not.toHaveBeenCalled();
1018 await waiting;
1019 } finally {
1020 vi.clearAllTimers();
1021 vi.useRealTimers();
1023 });
1024});
1025​
1026describe("mergeDirectPreviewAnnotation", () => {
1027 const annotation = (id: string) =>
1028 ({
1029 id,
1030 pageUrl: "http://localhost:5173/",
1031 pageTitle: null,
1032 comment: "tighten this",
1033 elements: [],
1034 regions: [],
1035 strokes: [],
1036 styleChanges: [],
1037 screenshot: {
1038 dataUrl: "data:image/png;base64,AAAA",
1039 width: 10,
1040 height: 10,
1041 cropRect: { x: 0, y: 0, width: 10, height: 10 },
1042 },
1043 createdAt: "2026-10-16T00:00:00.000Z",
1044 }) as const;
1045 const image = (id: string) =>
1046 ({ id, type: "image", name: `${id}.png`, mimeType: "image/png", sizeBytes: 1 }) as never;
1047​
1048 it("drops the screenshot claim when the attachment cap is full", () => {
1049 const result = mergeDirectPreviewAnnotation({
1050 images: [],
1051 fileCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1052 previewAnnotations: [],
1053 direct: { annotation: annotation("a"), image: image("crop") },
1054 });
1055 expect(result.images).toEqual([]);
1056 expect(result.previewAnnotations[0]?.screenshot).toBeNull();
1057 });
1058});
1059​
1060describe("snapshotComposerSend", () => {
1061 const expired = {
1062 id: "ctx-expired",
1063 threadId,
1064 terminalId: "default",
1065 terminalLabel: "Terminal 1",
1066 lineStart: 4,
1067 lineEnd: 4,
1068 text: "",
1069 createdAt: now,
1070 };
1071 const live = { ...expired, id: "ctx-live", text: "pnpm test", lineStart: 9, lineEnd: 9 };
1072​
1073 it("drops expired terminal contexts from the contexts and from the prompt", () => {
1074 const snapshot = snapshotComposerSend({
1075 prompt: ensureInlineContextReferences("run", [
1076 terminalContextReference(expired),
1077 terminalContextReference(live),
1078 ]),
1079 images: [],
1080 files: [],
1081 terminalContexts: [expired, live],
1082 sendableTerminalContexts: [live],
1083 previewAnnotations: [],
1084 reviewComments: [],
1085 threadContexts: [],
1086 environmentContexts: [],
1087 replyTo: null,
1088 });
1089​
1090 expect(snapshot.terminalContexts).toEqual([live]);
1091 expect(snapshot.messageText).toBe(
1092 ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(),
1093 );
1094 });
1095​
1096 it("sends a reply as a reference and restores it without one", () => {
1097 const replyTo = responseContextRecord({
1098 citation: {
1099 version: 1,
1100 environmentId: EnvironmentId.make("environment-1"),
1101 threadId: ThreadId.make("thread-1"),
1102 messageId: MessageId.make("message-1"),
1103 text: "Done. I added clamp next to sum.",
1104 start: 0,
1105 end: 32,
1106 prefix: "",
1107 suffix: "",
1108 },
1109 itemId: TurnItemId.make("item-1"),
1110 subagents: [],
1111 });
1112 const reference = responseContextReference(replyTo);
1113 const snapshot = snapshotComposerSend({
1114 prompt: ensureInlineContextReferences("", [reference]) + " why there?",
1115 images: [],
1116 files: [],
1117 terminalContexts: [],
1118 sendableTerminalContexts: [],
1119 previewAnnotations: [],
1120 reviewComments: [],
1121 threadContexts: [],
1122 environmentContexts: [],
1123 replyTo,
1124 });
1125​
1126 expect(snapshot.messageText).toContain(`t3-context://v1/response/${replyTo.contextId}`);
1127 expect(snapshot.draftText).toBe("why there?");
1128 expect(snapshot.replyTo).toBe(replyTo);
1129 });
1130});
1131​
1132describe("planComposerTurn", () => {
1133 const base = {
1134 messageText: "hello",
1135 sendQueuesBehindSetup: false,
1136 isServerThread: false,
1137 activeMessageCount: 0,
1138 sendEnvMode: "worktree" as const,
1139 worktreePath: null,
1140 activeThreadBranch: "main",
1141 resumeCompactionTokens: null,
1142 keepFullHistory: false,
1143 phase: "ready" as const,
1144 dispatchMode: "auto" as const,
1145 freshContext: false,
1146 };
1147​
1148 it("prepares a worktree from the selected branch on a new thread", () => {
1149 expect(planComposerTurn(base)).toMatchObject({
1150 missingWorktreeBaseBranch: false,
1151 baseBranchForWorktree: "main",
1152 shouldQueueBehindActiveRun: false,
1153 });
1154 });
1155​
1156 it("refuses a new worktree without a base branch", () => {
1157 expect(planComposerTurn({ ...base, activeThreadBranch: null })).toMatchObject({
1158 missingWorktreeBaseBranch: true,
1159 baseBranchForWorktree: null,
1160 });
1161 });
1162​
1163 it("queues behind a compaction of a stale session, except for /compact itself", () => {
1164 const stale = { ...base, resumeCompactionTokens: 120_000 };
1165 for (const messageText of ["hello", "/compactfoo"]) {
1166 expect(planComposerTurn({ ...stale, messageText })).toMatchObject({
1167 compactBeforeSend: true,
1168 turnDispatchMode: "queue",
1169 shouldQueueBehindActiveRun: true,
1170 });
1172 for (const messageText of ["/COMPACT", "/compact keep the API notes", "/compact\n tests"]) {
1173 expect(planComposerTurn({ ...stale, messageText })).toMatchObject({
1174 compactBeforeSend: false,
1175 turnDispatchMode: "auto",
1176 shouldQueueBehindActiveRun: false,
1177 });
1179 });
1180​
1181 it("queues behind a running turn only when asked to", () => {
1182 const running = {
1183 ...base,
1184 isServerThread: true,
1185 activeMessageCount: 3,
1186 phase: "running" as const,
1187 };
1188 expect(planComposerTurn({ ...running, dispatchMode: "queue" }).shouldQueueBehindActiveRun).toBe(
1189 true,
1190 );
1191 expect(planComposerTurn({ ...running, dispatchMode: "steer" })).toMatchObject({
1192 shouldQueueBehindActiveRun: false,
1193 baseBranchForWorktree: null,
1194 });
1195 expect(
1196 planComposerTurn({ ...running, sendQueuesBehindSetup: true, dispatchMode: "steer" }),
1197 ).toMatchObject({
1198 turnDispatchMode: "steer",
1199 shouldQueueBehindActiveRun: true,
1200 });
1201 // A fresh session cannot join the running turn, and a stale one is not compacted first.
1202 expect(
1203 planComposerTurn({
1204 ...running,
1205 dispatchMode: "steer",
1206 freshContext: true,
1207 resumeCompactionTokens: 120_000,
1208 }),
1209 ).toMatchObject({
1210 compactBeforeSend: false,
1211 turnDispatchMode: "queue",
1212 shouldQueueBehindActiveRun: true,
1213 });
1214 });
1215});
1216​
1217describe("withMessageContext", () => {
1218 const context = { records: [] } as unknown as NonNullable<
1219 Parameters<typeof withMessageContext>[0]["context"]
1220 >;
1221​
1222 it("sends the context inline", () => {
1223 expect(withMessageContext({ text: "hi", context })).toEqual({ text: "hi", context });
1224 });
1225​
1226 it("sends the text alone without context", () => {
1227 expect(withMessageContext({ text: "hi", context: undefined })).toEqual({ text: "hi" });
1228 });
1229});
1230​
1231describe("boundAttachmentIds", () => {
1232 it("prefers the uploaded id and falls back to the local one", () => {
1233 expect(
1234 boundAttachmentIds([{ id: "upload-1" }, {}], [{ id: "local-1" }, { id: "local-2" }]),
1235 ).toEqual(["upload-1", "local-2"]);
1236 });
1237});
1238​
1239describe("deriveAvailableRevertTurnCounts", () => {
1240 const providerThreadId = ProviderThreadId.make("provider-thread");
1241 const otherProviderThreadId = ProviderThreadId.make("other-provider-thread");
1242​
1243 function projection(input: { readonly providerSessionId: ProviderSessionId | null }) {
1244 const base = makeThreadProjectionFixture();
1245 const run = (ordinal: number) => ({
1246 id: RunId.make(`run-${ordinal}`),
1247 ordinal,
1248 activeAttemptId: `attempt-${ordinal}`,
1249 });
1250 const turn = (ordinal: number, attempt: number, threadId: ProviderThreadId) => ({
1251 id: `turn-${attempt}-${ordinal}`,
1252 runAttemptId: `attempt-${attempt}`,
1253 ordinal,
1254 providerThreadId: threadId,
1255 });
1256 return {
1257 ...base,
1258 thread: { ...base.thread, activeProviderThreadId: providerThreadId },
1259 providerThreads: [
1261 id: providerThreadId,
1262 providerInstanceId: base.thread.modelSelection.instanceId,
1263 providerSessionId: input.providerSessionId,
1264 },
1265 ],
1266 runs: [run(1), run(2), run(3)],
1267 attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, providerTurnId: null })),
1268 // Run 2's latest turn moved to another provider thread, so its checkpoint cannot restore
1269 // this conversation.
1270 providerTurns: [
1271 turn(1, 1, providerThreadId),
1272 turn(1, 2, providerThreadId),
1273 turn(2, 2, otherProviderThreadId),
1274 turn(1, 3, providerThreadId),
1275 ],
1276 } as unknown as ReturnType<typeof makeThreadProjectionFixture>;
1278​
1279 it("offers the thread start and runs whose latest turn is on the active provider thread", () => {
1280 const available = deriveAvailableRevertTurnCounts(
1281 projection({ providerSessionId: ProviderSessionId.make("session") }),
1282 );
1283 expect([...available].toSorted()).toEqual([0, 1, 3]);
1284 });
1285​
1286 it("offers nothing without a provider session to rewind", () => {
1287 expect(deriveAvailableRevertTurnCounts(projection({ providerSessionId: null })).size).toBe(0);
1288 expect(deriveAvailableRevertTurnCounts(null).size).toBe(0);
1289 });
1290});
1291​
1292describe("deriveThreadPendingBackgroundTasks", () => {
1293 const base = makeThreadProjectionFixture();
1294 const run = (ordinal: number, status: string) => ({
1295 id: RunId.make(`run-${ordinal}`),
1296 ordinal,
1297 status,
1298 });
1299 const item = (ordinal: number, type: string, nativeId: string, input: string) => ({
1300 id: TurnItemId.make(`item-${nativeId}`),
1301 type,
1302 status: "running",
1303 title: null,
1304 runId: RunId.make(`run-${ordinal}`),
1305 nativeItemRef: { nativeId },
1306 input,
1307 });
1308 const projection = (overrides: object) =>
1309 ({ ...base, ...overrides }) as unknown as ReturnType<typeof makeThreadProjectionFixture>;
1310​
1311 it.each(["queued", "preparing", "running"])(
1312 "keeps an earlier run's background shell while a %s follow-up run has a foreground command",
1313 (followUpStatus) => {
1314 expect(
1315 deriveThreadPendingBackgroundTasks(
1316 projection({
1317 runs: [run(1, "completed"), run(2, followUpStatus)],
1318 turnItems: [
1319 item(1, "command_execution", "shell-1", "sleep 600"),
1320 item(2, "command_execution", "shell-2", "sleep 15"),
1321 ],
1322 }),
1323 ),
1324 ).toEqual([{ taskId: "shell-1", description: "sleep 600", kind: "command" }]);
1325 },
1326 );
1327​
1328 it("does not list a live run's child from the provider roster as background", () => {
1329 // The roster is not run-tied; Muse keys it by the child session id its rows carry.
1330 expect(
1331 deriveThreadPendingBackgroundTasks(
1332 projection({
1333 runs: [run(1, "completed"), run(2, "running")],
1334 providerThreads: [
1336 id: "provider-thread-1",
1337 providerInstanceId: base.thread.providerInstanceId,
1338 pendingBackgroundTasks: [
1339 { taskId: "child-session-1", kind: "subagent" },
1340 { taskId: "child-session-2", kind: "subagent" },
1341 ],
1342 },
1343 ],
1344 turnItems: [
1345 { ...item(1, "subagent", "child-session-1", ""), prompt: "child 1" },
1346 { ...item(2, "subagent", "child-session-2", ""), prompt: "child 2" },
1347 ],
1348 }),
1349 ),
1350 ).toEqual([
1352 taskId: "child-session-1",
1353 kind: "subagent",
1354 providerInstanceId: base.thread.providerInstanceId,
1355 },
1356 ]);
1357 });
1358​
1359 it("keeps an earlier run's roster-only monitor while a follow-up run executes", () => {
1360 // Claude completes the Monitor launch row on success; only the roster tracks the task.
1361 expect(
1362 deriveThreadPendingBackgroundTasks(
1363 projection({
1364 runs: [run(1, "completed"), run(2, "running")],
1365 providerThreads: [
1367 id: "provider-thread-1",
1368 providerInstanceId: base.thread.providerInstanceId,
1369 pendingBackgroundTasks: [{ taskId: "b1htjkjev", kind: "monitor" }],
1370 },
1371 ],
1372 turnItems: [
1373 { ...item(1, "dynamic_tool", "toolu-monitor", ""), status: "completed" },
1374 item(2, "command_execution", "shell-2", "sleep 15"),
1375 ],
1376 }),
1377 ),
1378 ).toEqual([{ taskId: "b1htjkjev", kind: "monitor" }]);
1379 });
1380​
1381 it("keeps the provider roster while a follow-up run only waits in the queue", () => {
1382 expect(
1383 deriveThreadPendingBackgroundTasks(
1384 projection({
1385 runs: [run(1, "waiting"), run(2, "queued")],
1386 providerThreads: [
1388 id: "provider-thread-1",
1389 providerInstanceId: base.thread.providerInstanceId,
1390 pendingBackgroundTasks: [{ taskId: "task-1", kind: "background_task" }],
1391 },
1392 ],
1393 }),
1394 ),
1395 ).toEqual([{ taskId: "task-1", kind: "background_task" }]);
1396 });
1397});
1398​
1399describe("Open in for the host environment", () => {
1400 it("shows both local environments when the primary has a separate WSL home", () => {
1401 const primaryEnvironmentId = EnvironmentId.make("wsl");
1402 const hostEnvironmentId = EnvironmentId.make("host");
1403 for (const [activeThreadEnvironmentId, visible] of [
1404 [primaryEnvironmentId, true],
1405 [hostEnvironmentId, true],
1406 [EnvironmentId.make("other"), false],
1407 ] as const) {
1408 expect(
1409 shouldShowOpenInPicker({
1410 activeProjectName: "Project",
1411 activeThreadEnvironmentId,
1412 primaryEnvironmentId,
1413 hostEnvironmentId,
1414 remoteOpenMode: "local-exec",
1415 }),
1416 ).toBe(visible);
1418 });
1419});