1import { findRecordedWorktreeSetup, resolveVisibleWorktreeSetup } from "./ChatView.logic";
2import {
3 recallCheckoutIsRepo,
4 rememberCheckoutIsRepo,
5 threadShellHasStarted,
6} from "./ChatView.logic";
7import {
8 ANTIGRAVITY_DEFAULT_MODEL,
9 ProviderDriverKind,
10 type ServerProvider,
11} from "@t3tools/contracts";
12import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
13import type { RightPanelSurface } from "../rightPanelStore";
14import {
15 CommandId,
16 EnvironmentId,
17 EventId,
18 MessageId,
19 ProjectId,
20 ProviderInstanceId,
21 ThreadId,
22 RunId,
23 TurnItemId,
24 type OrchestrationV2ProjectedTurnItem,
25 type WorktreeSetupSnapshot,
26} from "@t3tools/contracts";
27import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
28import * as DateTime from "effect/DateTime";
29import * as Option from "effect/Option";
30import { afterEach, describe, expect, it, vi } from "vite-plus/test";
31import { Atom, AsyncResult } from "effect/reactivity";
32import { appAtomRegistry } from "../rpc/atomRegistry";
33import { environmentThreadDetails } from "../state/threads";
34
35import type { Thread, TurnDiffSummary } from "../types";
36import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures";
37import {
38 agentControlledBrowserCloseConfirmation,
39 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
40 getAntigravitySendBlockReason,
41 resolveBackgroundDraftWorkspaceOptions,
42 resolveComposerInteractionMode,
43 restorePlanFollowUpComposer,
44 resolveComposerProviderSelection,
45 resolveProactiveTurnDiffAction,
46 resolveDraftHeroState,
47 resolveWorktreeSetupProgress,
48 isPaintOnlyThreadTimeline,
49 peekHeldThreadTimeline,
50 peekRememberedThreadTimeline,
51 rememberReadyThreadTimeline,
52 resetHeldThreadTimeline,
53 resolveThreadSwitchTimeline,
54 threadKeysShareEnvironment,
55 timelineHasEphemeralPreviewUrls,
56 scheduleEnvironmentReconnectWarning,
57 codexArtifactTemplatePromptToAppend,
58 shouldDockDraftHeroForSubmission,
59 shouldReleaseTimelineAnchorForToolActivity,
60 shouldRefocusComposerOnWindowFocus,
61 shouldOpenProactivePullRequest,
62 shouldRetargetThreadPullRequestPanel,
63 shouldOpenProactiveTurnDiff,
64 shouldRenderPreviewMiniPlayer,
65 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
66 branchMismatchKey,
67 buildExpiredTerminalContextToastCopy,
68 createLocalDispatchSnapshot,
69 deriveCommittedServerUserMessageIds,
70 deriveComposerSendState,
71 deriveLockedProvider,
72 dismissBranchMismatchForSession,
73 getStartedThreadModelChangeBlockReason,
74 hasEnvironmentReconnectWarningGraceElapsed,
75 hasServerAcknowledgedLocalDispatch,
76 isBranchMismatchDismissedForSession,
77 reconcileMountedTerminalThreadIds,
78 resolveDraftPromotionNavigationTarget,
79 resolveEffectiveInteractionMode,
80 resolveThreadMetadataUpdateForNextTurn,
81 resolveSendEnvMode,
82 startNewThreadForProject,
83 shouldShowBranchMismatchBanner,
84 shouldShowPlanFollowUpPrompt,
85 shouldWriteThreadErrorToCurrentServerThread,
86 waitForRevertedMessage,
87 prepareRevertedMessageAttachments,
88} from "./ChatView.logic";
89
90const environmentId = EnvironmentId.make("environment-local");
91const projectId = ProjectId.make("project-1");
92const threadId = ThreadId.make("thread-1");
93const now = "2026-03-29T00:00:00.000Z";
94const helloWorldTemplate: CodexArtifactTemplate = {
95 artifactKind: "document",
96 displayName: "Hello World",
97 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
98 skillName: "artifact-template-hello-world",
99};
100
101function makeThread(overrides: Partial<Thread> = {}): Thread {
102 return makeThreadFixture({
103 id: threadId,
104 environmentId,
105 projectId,
106 title: "Thread",
107 modelSelection: {
108 instanceId: ProviderInstanceId.make("codex"),
109 model: "gpt-5.4",
110 },
111 runtimeMode: "full-access",
112 interactionMode: "default",
113 runtime: null,
114 createdAt: now,
115 updatedAt: now,
116 archivedAt: null,
117 settledOverride: null,
118 settledAt: null,
119 deletedAt: null,
120 latestRun: null,
121 branch: null,
122 worktreePath: null,
123 ...overrides,
124 });
125}
126
127const completedTurn = {
128 runId: RunId.make("turn-1"),
129 status: "completed" as const,
130 requestedAt: now,
131 startedAt: "2026-03-29T00:00:01.000Z",
132 completedAt: "2026-03-29T00:00:10.000Z",
133 assistantMessageId: null,
134};
135
136const readySession = {
137 status: "completed" as const,
138 providerName: "codex",
139 providerInstanceId: ProviderInstanceId.make("codex"),
140 activeRunId: null,
141 lastError: null,
142 updatedAt: "2026-03-29T00:00:10.000Z",
143};
144
145describe("resolveDraftPromotionNavigationTarget", () => {
146 const serverThreadRef = { environmentId, threadId };
147 const preparingRun = {
148 ...completedTurn,
149 status: "preparing" as const,
150 startedAt: null,
151 completedAt: null,
152 };
153
154 it("stays on the draft until the server owns the send", () => {
155 expect(
156 resolveDraftPromotionNavigationTarget({
157 serverThreadRef,
158 serverThread: makeThread({ latestRun: preparingRun }),
159 backgroundSubmissionPending: false,
160 }),
161 ).toBeNull();
162 expect(
163 resolveDraftPromotionNavigationTarget({
164 serverThreadRef,
165 serverThread: makeThread(),
166 backgroundSubmissionPending: false,
167 }),
168 ).toBeNull();
169 });
170
171 it("promotes a persisted send while its worktree is still preparing", () => {
172 const serverThread = makeThread({ latestRun: preparingRun, latestUserMessageAt: now });
173 expect(
174 resolveDraftPromotionNavigationTarget({
175 serverThreadRef,
176 serverThread,
177 backgroundSubmissionPending: false,
178 }),
179 ).toBe(serverThreadRef);
180 expect(
181 resolveDraftPromotionNavigationTarget({
182 serverThreadRef,
183 serverThread,
184 backgroundSubmissionPending: true,
185 }),
186 ).toBeNull();
187 });
188
189 it("navigates once the run starts or startup stops", () => {
190 expect(
191 resolveDraftPromotionNavigationTarget({
192 serverThreadRef,
193 serverThread: makeThread({ latestRun: completedTurn }),
194 backgroundSubmissionPending: false,
195 }),
196 ).toBe(serverThreadRef);
197 expect(
198 resolveDraftPromotionNavigationTarget({
199 serverThreadRef,
200 serverThread: makeThread({
201 latestRun: { ...preparingRun, status: "failed" as const },
202 }),
203 backgroundSubmissionPending: false,
204 }),
205 ).toBe(serverThreadRef);
206 });
207
208 it("defers while a background submission is pending", () => {
209 expect(
210 resolveDraftPromotionNavigationTarget({
211 serverThreadRef,
212 serverThread: makeThread({ latestRun: completedTurn }),
213 backgroundSubmissionPending: true,
214 }),
215 ).toBeNull();
216 });
217});
218
219describe("resolveEffectiveInteractionMode", () => {
220 it("forces build mode when legacy plan mode is disabled", () => {
221 expect(
222 resolveEffectiveInteractionMode({
223 planModeEnabled: false,
224 composerInteractionMode: "plan",
225 threadInteractionMode: "plan",
226 }),
227 ).toBe("default");
228 });
229
230 it("uses the saved mode while legacy plan mode is enabled", () => {
231 expect(
232 resolveEffectiveInteractionMode({
233 planModeEnabled: true,
234 composerInteractionMode: null,
235 threadInteractionMode: "plan",
236 }),
237 ).toBe("plan");
238 });
239});
240
241describe("resolveThreadMetadataUpdateForNextTurn", () => {
242 const modelSelection = {
243 instanceId: ProviderInstanceId.make("codex"),
244 model: "gpt-5.4",
245 };
246
247 it("updates a stale local thread branch to the active checkout", () => {
248 expect(
249 resolveThreadMetadataUpdateForNextTurn({
250 currentModelSelection: modelSelection,
251 currentBranch: "feature/thread",
252 nextBranch: "feature/checkout",
253 }),
254 ).toEqual({ branch: "feature/checkout", worktreePath: null });
255 });
256
257 it("does not write metadata when the model and branch are unchanged", () => {
258 expect(
259 resolveThreadMetadataUpdateForNextTurn({
260 currentModelSelection: modelSelection,
261 nextModelSelection: modelSelection,
262 currentBranch: "feature/current",
263 nextBranch: "feature/current",
264 }),
265 ).toBeNull();
266 });
267});
268
269describe("deriveComposerSendState", () => {
270 it("treats expired terminal pills as non-sendable content", () => {
271 const state = deriveComposerSendState({
272 prompt: "[Terminal 1](t3-context://v1/terminal/ctx-expired)",
273 imageCount: 0,
274 terminalContexts: [
275 {
276 id: "ctx-expired",
277 threadId,
278 terminalId: "default",
279 terminalLabel: "Terminal 1",
280 lineStart: 4,
281 lineEnd: 4,
282 text: "",
283 createdAt: now,
284 },
285 ],
286 });
287
288 expect(state.trimmedPrompt).toBe("");
289 expect(state.sendableTerminalContexts).toEqual([]);
290 expect(state.expiredTerminalContextCount).toBe(1);
291 expect(state.hasSendableContent).toBe(false);
292 });
293
294 it("keeps text sendable while excluding expired terminal pills", () => {
295 const state = deriveComposerSendState({
296 prompt: `yoo [Terminal 1](t3-context://v1/terminal/ctx-expired) waddup`,
297 imageCount: 0,
298 terminalContexts: [
299 {
300 id: "ctx-expired",
301 threadId,
302 terminalId: "default",
303 terminalLabel: "Terminal 1",
304 lineStart: 4,
305 lineEnd: 4,
306 text: "",
307 createdAt: now,
308 },
309 ],
310 });
311
312 expect(state.trimmedPrompt).toBe("yoo waddup");
313 expect(state.expiredTerminalContextCount).toBe(1);
314 expect(state.hasSendableContent).toBe(true);
315 });
316
317 it("treats element contexts as sendable content (no text, no images, no terminals)", () => {
318 const state = deriveComposerSendState({
319 prompt: "",
320 imageCount: 0,
321 terminalContexts: [],
322 elementContextCount: 1,
323 });
324
325 expect(state.trimmedPrompt).toBe("");
326 expect(state.expiredTerminalContextCount).toBe(0);
327 expect(state.hasSendableContent).toBe(true);
328 });
329
330 it("does NOT treat zero element contexts as sendable", () => {
331 expect(
332 deriveComposerSendState({
333 prompt: "",
334 imageCount: 0,
335 terminalContexts: [],
336 elementContextCount: 0,
337 }).hasSendableContent,
338 ).toBe(false);
339 });
340});
341
342describe("buildExpiredTerminalContextToastCopy", () => {
343 it("formats empty and omission guidance", () => {
344 expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({
345 title: "Expired terminal context won't be sent",
346 description: "Remove it or re-add it to include terminal output.",
347 });
348 expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({
349 title: "Expired terminal contexts omitted from message",
350 description: "Re-add it if you want that terminal output included.",
351 });
352 });
353});
354
355describe("getStartedThreadModelChangeBlockReason", () => {
356 const providers = [
357 {
358 instanceId: ProviderInstanceId.make("codex"),
359 },
360 {
361 instanceId: ProviderInstanceId.make("grok"),
362 requiresNewThreadForModelChange: true,
363 },
364 ];
365
366 it("allows model changes before a provider session has started", () => {
367 expect(
368 getStartedThreadModelChangeBlockReason({
369 providers,
370 hasStartedSession: false,
371 currentModelSelection: {
372 instanceId: ProviderInstanceId.make("grok"),
373 model: "grok-build",
374 },
375 nextModelSelection: {
376 instanceId: ProviderInstanceId.make("grok"),
377 model: "grok-other",
378 },
379 }),
380 ).toBeNull();
381 });
382
383 it("allows unchanged model selections for restricted providers", () => {
384 expect(
385 getStartedThreadModelChangeBlockReason({
386 providers,
387 hasStartedSession: true,
388 currentModelSelection: {
389 instanceId: ProviderInstanceId.make("grok"),
390 model: "grok-build",
391 },
392 nextModelSelection: {
393 instanceId: ProviderInstanceId.make("grok"),
394 model: "grok-build",
395 },
396 }),
397 ).toBeNull();
398 });
399
400 it("blocks started-session model changes for providers that require a new thread", () => {
401 expect(
402 getStartedThreadModelChangeBlockReason({
403 providers,
404 hasStartedSession: true,
405 currentModelSelection: {
406 instanceId: ProviderInstanceId.make("grok"),
407 model: "grok-build",
408 },
409 nextModelSelection: {
410 instanceId: ProviderInstanceId.make("grok"),
411 model: "grok-other",
412 },
413 }),
414 ).toEqual({
415 title: "Start a new chat to change models",
416 description:
417 "This provider does not allow switching models after a conversation has started.",
418 });
419 });
420});
421
422describe("resolveSendEnvMode", () => {
423 it("keeps worktree mode only for git repositories", () => {
424 expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree");
425 expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local");
426 });
427});
428
429describe("branchMismatchKey", () => {
430 it("builds a key from thread id and both branches", () => {
431 expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
432 "thread-1:feat/a:feat/b",
433 );
434 });
435
436 it("returns null without a thread or mismatch", () => {
437 expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull();
438 expect(branchMismatchKey("thread-1", null)).toBeNull();
439 });
440});
441
442describe("shouldShowBranchMismatchBanner", () => {
443 const base = {
444 hasMismatch: true,
445 isDismissed: false,
446 composerHasContent: false,
447 wasShownForCurrentMismatch: false,
448 };
449
450 it("stays hidden during passive browsing (even though the composer autofocuses)", () => {
451 expect(shouldShowBranchMismatchBanner(base)).toBe(false);
452 });
453
454 it("shows once the composer has draft content", () => {
455 expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true);
456 });
457
458 it("stays mounted after the draft clears once shown for the current mismatch", () => {
459 expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe(
460 true,
461 );
462 });
463
464 it("never shows when dismissed or without a mismatch", () => {
465 expect(
466 shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }),
467 ).toBe(false);
468 expect(
469 shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }),
470 ).toBe(false);
471 });
472});
473
474describe("shouldShowPlanFollowUpPrompt", () => {
475 const base = {
476 pendingUserInputCount: 0,
477 interactionMode: "plan" as const,
478 latestTurnSettled: true,
479 hasActionableProposedPlan: true,
480 hasComposerAttachments: false,
481 };
482
483 it("shows plan actions for a settled actionable plan without attachments", () => {
484 expect(shouldShowPlanFollowUpPrompt(base)).toBe(true);
485 });
486
487 it("hides plan actions while the composer has staged attachments", () => {
488 expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false);
489 });
490
491 it("preserves the existing plan follow-up gates", () => {
492 expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false);
493 expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false);
494 expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false);
495 expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false);
496 });
497});
498
499describe("session branch mismatch dismissal", () => {
500 it("tracks dismissed keys and treats other keys as active", () => {
501 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false);
502 dismissBranchMismatchForSession("t1:a:b");
503 expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true);
504 expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false);
505 expect(isBranchMismatchDismissedForSession(null)).toBe(false);
506 });
507});
508
509describe("reconcileMountedTerminalThreadIds", () => {
510 it("keeps open threads and makes the active thread most recent", () => {
511 expect(
512 reconcileMountedTerminalThreadIds({
513 currentThreadIds: ["thread-a", "thread-b", "thread-c"],
514 openThreadIds: ["thread-a", "thread-b", "thread-c"],
515 activeThreadId: "thread-a",
516 activeThreadTerminalOpen: true,
517 maxHiddenThreadCount: 2,
518 }),
519 ).toEqual(["thread-b", "thread-c", "thread-a"]);
520 });
521
522 it("drops closed threads and enforces the hidden mounted cap", () => {
523 const ids = Array.from(
524 { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 },
525 (_, index) => `thread-${index}`,
526 );
527 expect(
528 reconcileMountedTerminalThreadIds({
529 currentThreadIds: ids,
530 openThreadIds: ids.slice(1),
531 activeThreadId: null,
532 activeThreadTerminalOpen: false,
533 }),
534 ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS));
535 });
536});
537
538describe("shouldWriteThreadErrorToCurrentServerThread", () => {
539 it("requires the environment, route thread, and target thread to match", () => {
540 const routeThreadRef = { environmentId, threadId };
541
542 expect(
543 shouldWriteThreadErrorToCurrentServerThread({
544 serverThread: { environmentId, id: threadId },
545 routeThreadRef,
546 targetThreadId: threadId,
547 }),
548 ).toBe(true);
549 expect(
550 shouldWriteThreadErrorToCurrentServerThread({
551 serverThread: null,
552 routeThreadRef,
553 targetThreadId: threadId,
554 }),
555 ).toBe(false);
556 });
557});
558
559describe("startNewThreadForProject", () => {
560 it("starts a thread through the supplied shared handler for the active project", () => {
561 const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = [];
562 const projectRef = { environmentId, projectId };
563
564 expect(
565 startNewThreadForProject(projectRef, (nextProjectRef) => {
566 calls.push(nextProjectRef);
567 return Promise.resolve();
568 }),
569 ).toBe(true);
570 expect(calls).toEqual([projectRef]);
571 });
572
573 it("does nothing when the active project is unavailable", () => {
574 let called = false;
575
576 expect(
577 startNewThreadForProject(null, () => {
578 called = true;
579 return Promise.resolve();
580 }),
581 ).toBe(false);
582 expect(called).toBe(false);
583 });
584});
585
586describe("hasServerAcknowledgedLocalDispatch", () => {
587 it("does not acknowledge unchanged server state", () => {
588 const localDispatch = createLocalDispatchSnapshot(
589 makeThread({ latestRun: completedTurn, runtime: readySession }),
590 );
591
592 expect(
593 hasServerAcknowledgedLocalDispatch({
594 localDispatch,
595 phase: "ready",
596 latestRun: completedTurn,
597 runtime: readySession,
598 hasPendingApproval: false,
599 hasPendingUserInput: false,
600 threadError: null,
601 }),
602 ).toBe(false);
603 });
604
605 it("acknowledges a settled newer background turn", () => {
606 const localDispatch = createLocalDispatchSnapshot(
607 makeThread({ latestRun: completedTurn, runtime: readySession }),
608 { submissionIntent: "background" },
609 );
610 const newerTurn = {
611 ...completedTurn,
612 runId: RunId.make("turn-2"),
613 requestedAt: "2026-03-29T00:01:00.000Z",
614 startedAt: "2026-03-29T00:01:01.000Z",
615 completedAt: "2026-03-29T00:01:30.000Z",
616 };
617
618 expect(
619 hasServerAcknowledgedLocalDispatch({
620 localDispatch,
621 phase: "ready",
622 latestRun: newerTurn,
623 runtime: { ...readySession, updatedAt: newerTurn.completedAt },
624 hasPendingApproval: false,
625 hasPendingUserInput: false,
626 threadError: null,
627 }),
628 ).toBe(true);
629 });
630
631 it("holds a first send while the thread shell still reports a preparing run", () => {
632 // The draft had no run. The server thread's shell shows the new run before
633 // the detail projection behind `phase` loads.
634 const localDispatch = createLocalDispatchSnapshot(makeThread());
635 const preparingRun = {
636 ...completedTurn,
637 status: "preparing" as const,
638 startedAt: null,
639 completedAt: null,
640 };
641
642 expect(
643 hasServerAcknowledgedLocalDispatch({
644 localDispatch,
645 phase: "disconnected",
646 latestRun: preparingRun,
647 runtime: { ...readySession, status: "preparing", activeRunId: preparingRun.runId },
648 hasPendingApproval: false,
649 hasPendingUserInput: false,
650 threadError: null,
651 }),
652 ).toBe(false);
653 });
654
655 it("waits for the matching running turn before acknowledging", () => {
656 const localDispatch = createLocalDispatchSnapshot(
657 makeThread({ latestRun: completedTurn, runtime: readySession }),
658 );
659 const runningTurn = {
660 ...completedTurn,
661 runId: RunId.make("turn-2"),
662 status: "running" as const,
663 requestedAt: "2026-03-29T00:01:00.000Z",
664 startedAt: "2026-03-29T00:01:01.000Z",
665 completedAt: null,
666 };
667
668 expect(
669 hasServerAcknowledgedLocalDispatch({
670 localDispatch,
671 phase: "running",
672 latestRun: runningTurn,
673 runtime: {
674 ...readySession,
675 status: "running",
676 activeRunId: RunId.make("turn-other"),
677 },
678 hasPendingApproval: false,
679 hasPendingUserInput: false,
680 threadError: null,
681 }),
682 ).toBe(false);
683 expect(
684 hasServerAcknowledgedLocalDispatch({
685 localDispatch,
686 phase: "running",
687 latestRun: runningTurn,
688 runtime: {
689 ...readySession,
690 status: "running",
691 activeRunId: runningTurn.runId,
692 },
693 hasPendingApproval: false,
694 hasPendingUserInput: false,
695 threadError: null,
696 }),
697 ).toBe(true);
698 });
699
700 it("acknowledges a steering message projected onto the current running run", () => {
701 const runningRun = {
702 ...completedTurn,
703 status: "running" as const,
704 completedAt: null,
705 };
706 const runningRuntime = {
707 ...readySession,
708 status: "running" as const,
709 activeRunId: runningRun.runId,
710 };
711 const localDispatch = createLocalDispatchSnapshot(
712 makeThread({ latestRun: runningRun, runtime: runningRuntime }),
713 { latestUserMessageId: MessageId.make("message-before-steer") },
714 );
715
716 expect(
717 hasServerAcknowledgedLocalDispatch({
718 localDispatch,
719 phase: "running",
720 latestRun: runningRun,
721 latestUserMessageId: MessageId.make("message-steer"),
722 runtime: runningRuntime,
723 hasPendingApproval: false,
724 hasPendingUserInput: false,
725 threadError: null,
726 }),
727 ).toBe(true);
728 });
729
730 it("acknowledges pending user interaction and errors immediately", () => {
731 const localDispatch = createLocalDispatchSnapshot(makeThread());
732 const common = {
733 localDispatch,
734 phase: "ready" as const,
735 latestRun: null,
736 runtime: null,
737 hasPendingApproval: false,
738 hasPendingUserInput: false,
739 threadError: null,
740 };
741
742 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true);
743 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true);
744 expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);
745 });
746});
747
748describe("deriveCommittedServerUserMessageIds", () => {
749 it("tracks only committed user turn items, not assistant rows or projection-only messages", () => {
750 const turnStartId = MessageId.make("message-turn-start");
751 const steerId = MessageId.make("message-steer");
752 const assistantId = MessageId.make("message-assistant");
753 const committedAt = DateTime.makeUnsafe("2026-06-26T17:50:15.180Z");
754 const runId = RunId.make("run:thread:thread-1:ordinal:1");
755 const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [
756 {
757 position: 0,
758 visibility: "local",
759 sourceThreadId: threadId,
760 sourceItemId: TurnItemId.make("turn-item:message-turn-start"),
761 item: {
762 id: TurnItemId.make("turn-item:message-turn-start"),
763 threadId,
764 runId,
765 nodeId: null,
766 providerThreadId: null,
767 providerTurnId: null,
768 nativeItemRef: null,
769 parentItemId: null,
770 ordinal: 1,
771 status: "completed",
772 title: null,
773 startedAt: committedAt,
774 completedAt: committedAt,
775 updatedAt: committedAt,
776 createdBy: "user",
777 creationSource: "web",
778 type: "user_message",
779 messageId: turnStartId,
780 inputIntent: "turn_start",
781 text: "start",
782 attachments: [],
783 },
784 },
785 {
786 position: 1,
787 visibility: "local",
788 sourceThreadId: threadId,
789 sourceItemId: TurnItemId.make("turn-item:message-assistant"),
790 item: {
791 id: TurnItemId.make("turn-item:message-assistant"),
792 threadId,
793 runId,
794 nodeId: null,
795 providerThreadId: null,
796 providerTurnId: null,
797 nativeItemRef: null,
798 parentItemId: null,
799 ordinal: 2,
800 status: "completed",
801 title: null,
802 startedAt: committedAt,
803 completedAt: committedAt,
804 updatedAt: committedAt,
805 type: "assistant_message",
806 messageId: assistantId,
807 text: "working",
808 streaming: false,
809 },
810 },
811 {
812 position: 2,
813 visibility: "local",
814 sourceThreadId: threadId,
815 sourceItemId: TurnItemId.make("turn-item:message-steer"),
816 item: {
817 id: TurnItemId.make("turn-item:message-steer"),
818 threadId,
819 runId,
820 nodeId: null,
821 providerThreadId: null,
822 providerTurnId: null,
823 nativeItemRef: null,
824 parentItemId: null,
825 ordinal: 3,
826 status: "completed",
827 title: null,
828 startedAt: committedAt,
829 completedAt: committedAt,
830 updatedAt: committedAt,
831 createdBy: "user",
832 creationSource: "web",
833 type: "user_message",
834 messageId: steerId,
835 inputIntent: "steer",
836 text: "continue",
837 attachments: [],
838 },
839 },
840 ];
841
842 expect(deriveCommittedServerUserMessageIds(visibleTurnItems)).toEqual(
843 new Set([turnStartId, steerId]),
844 );
845 });
846});
847
848describe("agent browser close confirmation", () => {
849 const surfaces = [
850 { id: "browser:one", kind: "preview", resourceId: "tab-1" },
851 { id: "browser:two", kind: "preview", resourceId: "tab-2" },
852 { id: "diff", kind: "diff" },
853 ] satisfies RightPanelSurface[];
854
855 it("only warns for browsers under active agent control", () => {
856 expect(
857 agentControlledBrowserCloseConfirmation(surfaces, {
858 "tab-1": { controller: "none" },
859 "tab-2": { controller: "human" },
860 }),
861 ).toBeNull();
862
863 expect(
864 agentControlledBrowserCloseConfirmation([surfaces[0]!], {
865 "tab-1": { controller: "agent" },
866 }),
867 ).toBe(
868 [
869 "Close browser while the agent is using it?",
870 "The agent is actively controlling this browser. Closing it may interrupt the current browser action.",
871 ].join("\n"),
872 );
873 });
874
875 it("counts every agent-controlled browser in a bulk close", () => {
876 expect(
877 agentControlledBrowserCloseConfirmation(surfaces, {
878 "tab-1": { controller: "agent" },
879 "tab-2": { controller: "agent" },
880 }),
881 ).toContain("Close 2 browsers");
882 });
883});
884
885describe("floating browser preview", () => {
886 it("only hides the duplicate while the same browser is rendered in the panel", () => {
887 expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false);
888 expect(
889 shouldRenderPreviewMiniPlayer(
890 { kind: "browser", tabId: "tab-1" },
891 {
892 id: "browser:one",
893 kind: "preview",
894 resourceId: "tab-1",
895 },
896 ),
897 ).toBe(false);
898 expect(
899 shouldRenderPreviewMiniPlayer(
900 { kind: "browser", tabId: "tab-1" },
901 {
902 id: "browser:two",
903 kind: "preview",
904 resourceId: "tab-2",
905 },
906 ),
907 ).toBe(true);
908 expect(
909 shouldRenderPreviewMiniPlayer(
910 { kind: "browser", tabId: "tab-1" },
911 { id: "diff", kind: "diff" },
912 ),
913 ).toBe(true);
914 });
915});
916
917describe("proactive panels", () => {
918 it("opens an existing pull request on entry and follows newly observed links", () => {
919 expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(true);
920 expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true);
921 expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false);
922 expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false);
923 });
924
925 it("opens a completed diff on entry or when the observed running turn settles", () => {
926 const turnId = RunId.make("turn-1");
927 expect(
928 shouldOpenProactiveTurnDiff({
929 previousRunningTurnId: undefined,
930 runningTurnId: null,
931 settledTurnId: turnId,
932 turnCompleted: true,
933 }),
934 ).toBe(true);
935 expect(
936 shouldOpenProactiveTurnDiff({
937 previousRunningTurnId: turnId,
938 runningTurnId: null,
939 settledTurnId: turnId,
940 turnCompleted: true,
941 }),
942 ).toBe(true);
943 expect(
944 shouldOpenProactiveTurnDiff({
945 previousRunningTurnId: turnId,
946 runningTurnId: RunId.make("turn-2"),
947 settledTurnId: turnId,
948 turnCompleted: true,
949 }),
950 ).toBe(false);
951 expect(
952 shouldOpenProactiveTurnDiff({
953 previousRunningTurnId: turnId,
954 runningTurnId: null,
955 settledTurnId: turnId,
956 turnCompleted: false,
957 }),
958 ).toBe(false);
959 });
960});
961
962describe("artifact template composer insertion", () => {
963 it("does not insert an already-present prompt", () => {
964 const prompt = "Create a document using this $artifact-template-hello-world about…";
965
966 expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull();
967 });
968});
969
970describe("draft hero submission transition", () => {
971 it("does not dock the composer before a background submission", () => {
972 expect(
973 shouldDockDraftHeroForSubmission({
974 isDraftHeroState: true,
975 activeThreadKey: "environment-local:thread-1",
976 submissionIntent: "background",
977 }),
978 ).toBe(false);
979 });
980
981 it("leaves the hero layout while a worktree setup card is on the timeline", () => {
982 expect(
983 resolveDraftHeroState({
984 isLocalDraftThread: true,
985 hasTimelineEntries: false,
986 isWorking: false,
987 draftHeroDockRequested: false,
988 backgroundSubmissionPending: false,
989 hasWorktreeSetupCard: true,
990 }),
991 ).toBe(false);
992 // A background submission normally pins the hero, but never over the card.
993 expect(
994 resolveDraftHeroState({
995 isLocalDraftThread: true,
996 hasTimelineEntries: false,
997 isWorking: false,
998 draftHeroDockRequested: false,
999 backgroundSubmissionPending: true,
1000 hasWorktreeSetupCard: true,
1001 }),
1002 ).toBe(false);
1003 });
1004
1005 it("keeps the composer in the hero layout until navigation after server promotion", () => {
1006 expect(
1007 resolveDraftHeroState({
1008 isLocalDraftThread: false,
1009 hasTimelineEntries: true,
1010 isWorking: true,
1011 draftHeroDockRequested: false,
1012 backgroundSubmissionPending: true,
1013 }),
1014 ).toBe(true);
1015 });
1016
1017 it("does not auto-navigate a background submission after server promotion", () => {
1018 expect(
1019 resolveDraftPromotionNavigationTarget({
1020 serverThreadRef: { environmentId, threadId },
1021 serverThread: makeThread({ latestRun: completedTurn }),
1022 backgroundSubmissionPending: true,
1023 }),
1024 ).toBeNull();
1025 });
1026});
1027
1028describe("resolveThreadSwitchTimeline", () => {
1029 afterEach(() => {
1030 resetHeldThreadTimeline();
1031 });
1032
1033 const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] };
1034
1035 it("keeps the previous thread's entries while the next thread is loading", () => {
1036 expect(
1037 resolveThreadSwitchTimeline({
1038 loading: true,
1039 activeThreadKey: "env-1:thread-b",
1040 nextEntries: [],
1041 lastReady: held,
1042 }),
1043 ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
1044 });
1045
1046 it("shows the new thread once its detail is ready", () => {
1047 expect(
1048 resolveThreadSwitchTimeline({
1049 loading: false,
1050 activeThreadKey: "env-1:thread-b",
1051 nextEntries: ["b1"],
1052 lastReady: held,
1053 }),
1054 ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" });
1055 });
1056
1057 it("does not invent a timeline on the first open of a thread", () => {
1058 expect(
1059 resolveThreadSwitchTimeline({
1060 loading: true,
1061 activeThreadKey: "env-1:thread-a",
1062 nextEntries: [],
1063 lastReady: null,
1064 }),
1065 ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" });
1066 });
1067
1068 it("keeps the held thread workspace cwd with the snapshot", () => {
1069 rememberReadyThreadTimeline({
1070 ...held,
1071 markdownCwd: "/repo/a",
1072 workspaceRoot: "/repo/a",
1073 });
1074 expect(peekHeldThreadTimeline<string[]>()).toEqual({
1075 ...held,
1076 markdownCwd: "/repo/a",
1077 workspaceRoot: "/repo/a",
1078 });
1079 });
1080
1081 it("survives a ChatView remount by remembering the last ready timeline", () => {
1082 rememberReadyThreadTimeline(held);
1083 expect(peekHeldThreadTimeline<string[]>()).toEqual(held);
1084 expect(
1085 resolveThreadSwitchTimeline({
1086 loading: true,
1087 activeThreadKey: "env-1:thread-b",
1088 nextEntries: [],
1089 }),
1090 ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
1091 });
1092
1093 it("paints a remembered destination instead of the last-viewed thread", () => {
1094 rememberReadyThreadTimeline(held);
1095 rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] });
1096 expect(peekRememberedThreadTimeline<string[]>("env-1:thread-a")).toEqual(["a1", "a2"]);
1097 expect(
1098 resolveThreadSwitchTimeline({
1099 loading: true,
1100 activeThreadKey: "env-1:thread-a",
1101 nextEntries: [],
1102 }),
1103 ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
1104 });
1105
1106 it("prefers live entries over a remembered snapshot", () => {
1107 rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] });
1108 expect(
1109 resolveThreadSwitchTimeline({
1110 loading: false,
1111 activeThreadKey: "env-1:thread-b",
1112 nextEntries: ["fresh-b"],
1113 }),
1114 ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" });
1115 });
1116
1117 it("does not keep a remembered snapshot on a resolved empty thread", () => {
1118 rememberReadyThreadTimeline(held);
1119 expect(
1120 resolveThreadSwitchTimeline({
1121 loading: false,
1122 activeThreadKey: "env-1:thread-a",
1123 nextEntries: [],
1124 }),
1125 ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" });
1126 });
1127
1128 it("does not hold another environment's timeline across a jump", () => {
1129 expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false);
1130 expect(
1131 resolveThreadSwitchTimeline({
1132 loading: true,
1133 activeThreadKey: "env-2:thread-b",
1134 nextEntries: [],
1135 lastReady: held,
1136 }),
1137 ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" });
1138 });
1139
1140 it("treats a foreign held timeline as paint-only", () => {
1141 expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true);
1142 expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false);
1143 });
1144
1145 it("does not remember a timeline that still has handoff blob previews", () => {
1146 expect(
1147 timelineHasEphemeralPreviewUrls([
1148 {
1149 kind: "message",
1150 message: {
1151 id: MessageId.make("preview-message"),
1152 role: "user",
1153 text: "Preview",
1154 runId: null,
1155 streaming: false,
1156 createdAt: "2026-09-10T12:00:00.000Z",
1157 updatedAt: "2026-09-10T12:00:00.000Z",
1158 attachments: [
1159 {
1160 type: "image",
1161 id: "preview",
1162 name: "preview.png",
1163 mimeType: "image/png",
1164 sizeBytes: 1,
1165 previewUrl: "blob:handoff",
1166 },
1167 ],
1168 },
1169 },
1170 ]),
1171 ).toBe(true);
1172 expect(
1173 timelineHasEphemeralPreviewUrls([
1174 {
1175 kind: "message",
1176 message: {
1177 id: MessageId.make("preview-message"),
1178 role: "user",
1179 text: "Preview",
1180 runId: null,
1181 streaming: false,
1182 createdAt: "2026-09-10T12:00:00.000Z",
1183 updatedAt: "2026-09-10T12:00:00.000Z",
1184 attachments: [
1185 {
1186 type: "image",
1187 id: "preview",
1188 name: "preview.png",
1189 mimeType: "image/png",
1190 sizeBytes: 1,
1191 previewUrl: "https://cdn.example/a.png",
1192 },
1193 ],
1194 },
1195 },
1196 ]),
1197 ).toBe(false);
1198 });
1199});
1200
1201describe("shouldReleaseTimelineAnchorForToolActivity", () => {
1202 const activeTurnId = RunId.make("active-turn");
1203 const anchorMessageId = MessageId.make("anchored-message");
1204 const activeToolEntry = {
1205 id: "tool-entry",
1206 kind: "work" as const,
1207 createdAt: now,
1208 entry: {
1209 id: "active-tool",
1210 createdAt: now,
1211 runId: activeTurnId,
1212 label: "Run command",
1213 tone: "tool" as const,
1214 command: "git status",
1215 },
1216 };
1217
1218 it("releases the send anchor for tool activity in the active turn", () => {
1219 expect(
1220 shouldReleaseTimelineAnchorForToolActivity({
1221 anchorMessageId,
1222 liveFollowEnabled: true,
1223 runningTurnId: activeTurnId,
1224 timelineEntries: [activeToolEntry],
1225 }),
1226 ).toBe(true);
1227 });
1228
1229 it("keeps the anchor while the user reads history", () => {
1230 expect(
1231 shouldReleaseTimelineAnchorForToolActivity({
1232 anchorMessageId,
1233 liveFollowEnabled: false,
1234 runningTurnId: activeTurnId,
1235 timelineEntries: [activeToolEntry],
1236 }),
1237 ).toBe(false);
1238 });
1239
1240 it("ignores tool activity from earlier turns", () => {
1241 expect(
1242 shouldReleaseTimelineAnchorForToolActivity({
1243 anchorMessageId,
1244 liveFollowEnabled: true,
1245 runningTurnId: activeTurnId,
1246 timelineEntries: [
1247 {
1248 ...activeToolEntry,
1249 entry: {
1250 ...activeToolEntry.entry,
1251 runId: RunId.make("previous-turn"),
1252 },
1253 },
1254 ],
1255 }),
1256 ).toBe(false);
1257 });
1258
1259 it("ignores thinking and error rows without tool activity", () => {
1260 expect(
1261 shouldReleaseTimelineAnchorForToolActivity({
1262 anchorMessageId,
1263 liveFollowEnabled: true,
1264 runningTurnId: activeTurnId,
1265 timelineEntries: [
1266 {
1267 ...activeToolEntry,
1268 entry: {
1269 id: "thinking-entry",
1270 createdAt: now,
1271 runId: activeTurnId,
1272 label: "Thinking",
1273 tone: "thinking",
1274 },
1275 },
1276 {
1277 ...activeToolEntry,
1278 id: "error-entry",
1279 entry: {
1280 id: "error-entry",
1281 createdAt: now,
1282 runId: activeTurnId,
1283 label: "Provider error",
1284 tone: "error",
1285 },
1286 },
1287 ],
1288 }),
1289 ).toBe(false);
1290 });
1291
1292 it("does nothing without an anchor or running turn", () => {
1293 const input = {
1294 anchorMessageId,
1295 liveFollowEnabled: true,
1296 runningTurnId: activeTurnId,
1297 timelineEntries: [activeToolEntry],
1298 };
1299
1300 expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe(
1301 false,
1302 );
1303 expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe(
1304 false,
1305 );
1306 });
1307});
1308
1309describe("environment reconnect warning grace", () => {
1310 afterEach(() => vi.useRealTimers());
1311
1312 it("shows a persistent reconnect after the grace period", () => {
1313 vi.useFakeTimers();
1314 const showWarning = vi.fn();
1315
1316 scheduleEnvironmentReconnectWarning(showWarning);
1317 vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1);
1318 expect(showWarning).not.toHaveBeenCalled();
1319
1320 vi.advanceTimersByTime(1);
1321 expect(showWarning).toHaveBeenCalledOnce();
1322 });
1323
1324 it("cancels the warning when the connection recovers during the grace period", () => {
1325 vi.useFakeTimers();
1326 const showWarning = vi.fn();
1327
1328 const cancel = scheduleEnvironmentReconnectWarning(showWarning);
1329 cancel();
1330 vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
1331
1332 expect(showWarning).not.toHaveBeenCalled();
1333 });
1334
1335 it("does not reuse elapsed grace from another environment", () => {
1336 const anotherEnvironmentId = EnvironmentId.make("environment-remote");
1337
1338 expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true);
1339 expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe(
1340 false,
1341 );
1342 });
1343});
1344
1345describe("resolveComposerProviderSelection", () => {
1346 const catalogModels: ServerProvider["models"] = [
1347 { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null },
1348 ];
1349
1350 function entry(driver: string, instanceId = driver, overrides: Partial<ServerProvider> = {}) {
1351 return deriveProviderInstanceEntries([
1352 {
1353 driver: ProviderDriverKind.make(driver),
1354 instanceId: ProviderInstanceId.make(instanceId),
1355 enabled: true,
1356 installed: true,
1357 status: "ready",
1358 auth: { status: "authenticated" },
1359 version: null,
1360 checkedAt: now,
1361 models: [],
1362 slashCommands: [],
1363 skills: [],
1364 ...overrides,
1365 },
1366 ])[0]!;
1367 }
1368
1369 function importedThread(instanceId: ProviderInstanceId) {
1370 return makeThread({
1371 modelSelection: { instanceId, model: "default" },
1372 itemCount: 1,
1373 });
1374 }
1375
1376 it.each([
1377 ["claudeAgent", "claude_work"],
1378 ["codex", "codex_work"],
1379 ["ollama", "local_models"],
1380 ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => {
1381 const importedEntry = entry(driver, instanceId);
1382 const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry];
1383 const thread = importedThread(importedEntry.instanceId);
1384 const lockedProvider = deriveLockedProvider({
1385 thread,
1386 selectedProvider: entries[0]!.instanceId,
1387 threadProvider: thread.modelSelection.instanceId,
1388 providers: entries.map((entry) => entry.snapshot),
1389 });
1390
1391 expect(thread.runtime).toBeNull();
1392 expect(lockedProvider).toBe(driver);
1393 expect(
1394 resolveComposerProviderSelection({
1395 entries,
1396 candidateInstanceIds: [thread.modelSelection.instanceId],
1397 lockedProvider,
1398 lockedInstanceId: thread.modelSelection.instanceId,
1399 }).selectedProviderEntry?.instanceId,
1400 ).toBe(importedEntry.instanceId);
1401 });
1402
1403 it("keeps the session driver authoritative over instance and draft selections", () => {
1404 const selected = entry("claudeAgent", "claude_work");
1405 const sessionEntry = entry("ollama", "local_models");
1406 const thread = importedThread(selected.instanceId);
1407
1408 expect(
1409 deriveLockedProvider({
1410 thread: {
1411 ...thread,
1412 runtime: {
1413 ...readySession,
1414 providerName: sessionEntry.driverKind,
1415 providerInstanceId: sessionEntry.instanceId,
1416 },
1417 },
1418 selectedProvider: selected.instanceId,
1419 threadProvider: thread.modelSelection.instanceId,
1420 providers: [selected.snapshot, sessionEntry.snapshot],
1421 }),
1422 ).toBe(sessionEntry.driverKind);
1423 });
1424
1425 it.each(["missing", "disabled"] as const)(
1426 "does not move imported history to another driver when its instance is %s",
1427 (state) => {
1428 const imported = entry("claudeAgent", "claude_work", { enabled: false });
1429 const other = entry("codex");
1430 const entries = state === "missing" ? [other] : [other, imported];
1431 const thread = importedThread(imported.instanceId);
1432 const lockedProvider = deriveLockedProvider({
1433 thread,
1434 selectedProvider: other.instanceId,
1435 threadProvider: thread.modelSelection.instanceId,
1436 providers: entries.map((entry) => entry.snapshot),
1437 });
1438
1439 expect(lockedProvider).not.toBeNull();
1440 expect(
1441 resolveComposerProviderSelection({
1442 entries,
1443 candidateInstanceIds: [other.instanceId, imported.instanceId],
1444 lockedProvider,
1445 lockedInstanceId: imported.instanceId,
1446 }).selectedProviderEntry,
1447 ).toBeUndefined();
1448 },
1449 );
1450
1451 it("leaves a new draft free to select a different driver", () => {
1452 const original = entry("claudeAgent", "claude_work");
1453 const selected = entry("codex", "codex_work");
1454 expect(
1455 deriveLockedProvider({
1456 thread: makeThread({
1457 modelSelection: { instanceId: original.instanceId, model: "default" },
1458 }),
1459 selectedProvider: selected.instanceId,
1460 threadProvider: original.instanceId,
1461 providers: [original.snapshot, selected.snapshot],
1462 }),
1463 ).toBeNull();
1464 });
1465
1466 it("uses the custom instance's capability instead of the default instance", () => {
1467 const defaultEntry = entry("antigravity", "antigravity", {
1468 showInteractionModeToggle: true,
1469 });
1470 const customEntry = entry("antigravity", "google_work", {
1471 showInteractionModeToggle: false,
1472 });
1473 const selection = resolveComposerProviderSelection({
1474 entries: [defaultEntry, customEntry],
1475 candidateInstanceIds: [customEntry.instanceId],
1476 lockedProvider: null,
1477 lockedInstanceId: null,
1478 });
1479
1480 expect(selection.selectedProviderEntry?.instanceId).toBe(customEntry.instanceId);
1481 expect(
1482 resolveComposerInteractionMode({
1483 provider: selection.selectedProviderEntry?.snapshot,
1484 planModeEnabled: true,
1485 interactionMode: "plan",
1486 }),
1487 ).toEqual({ enabled: false, interactionMode: "default" });
1488 });
1489
1490 it("uses the fallback provider's plan capability after the draft's instance is disabled", () => {
1491 const disabledEntry = entry("antigravity", "antigravity", {
1492 enabled: false,
1493 showInteractionModeToggle: false,
1494 });
1495 const fallbackEntry = entry("codex");
1496 const selection = resolveComposerProviderSelection({
1497 entries: [disabledEntry, fallbackEntry],
1498 candidateInstanceIds: [disabledEntry.instanceId],
1499 lockedProvider: null,
1500 lockedInstanceId: null,
1501 });
1502
1503 expect(selection.selectedProviderEntry?.instanceId).toBe(fallbackEntry.instanceId);
1504 expect(
1505 resolveComposerInteractionMode({
1506 provider: selection.selectedProviderEntry?.snapshot,
1507 planModeEnabled: true,
1508 interactionMode: "plan",
1509 }),
1510 ).toEqual({ enabled: true, interactionMode: "plan" });
1511 });
1512
1513 it("keeps a signed-out selection instead of silently switching providers", () => {
1514 const signedOutEntry = entry("antigravity", "google_work", {
1515 status: "error",
1516 auth: { status: "unauthenticated" },
1517 models: catalogModels,
1518 });
1519 const selection = resolveComposerProviderSelection({
1520 entries: [entry("codex"), signedOutEntry],
1521 candidateInstanceIds: [signedOutEntry.instanceId],
1522 lockedProvider: null,
1523 lockedInstanceId: null,
1524 });
1525
1526 expect(selection.selectedProviderEntry?.instanceId).toBe(signedOutEntry.instanceId);
1527 expect(
1528 getAntigravitySendBlockReason(selection.selectedProviderEntry?.snapshot, "gemini-pro"),
1529 ).toBe("Sign in to Antigravity in provider settings before sending.");
1530 });
1531
1532 it("blocks sends until the selected Antigravity profile is installed", () => {
1533 const provider = entry("antigravity", "google_work", {
1534 installed: false,
1535 models: catalogModels,
1536 }).snapshot;
1537
1538 expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe(
1539 "Install Antigravity in provider settings before sending.",
1540 );
1541 });
1542
1543 it("lets Antigravity check saved credentials when resuming after a restart", () => {
1544 const provider = entry("antigravity", "google_work", {
1545 status: "warning",
1546 auth: { status: "unknown" },
1547 models: [],
1548 }).snapshot;
1549
1550 expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull();
1551 expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull();
1552 expect(
1553 getAntigravitySendBlockReason({ ...provider, models: catalogModels }, "gemini-pro"),
1554 ).toBeNull();
1555 expect(getAntigravitySendBlockReason(provider, "")).toBe(
1556 "Choose an Antigravity model before sending.",
1557 );
1558 });
1559
1560 it("blocks saved model sends until Antigravity loads its account catalog", () => {
1561 expect(getAntigravitySendBlockReason(entry("antigravity").snapshot, "gemini-pro")).toBe(
1562 "Refresh Antigravity models in provider settings before sending.",
1563 );
1564 });
1565
1566 it("blocks an empty Antigravity selection after the catalog has loaded", () => {
1567 const provider = entry("antigravity", "google_work", { models: catalogModels }).snapshot;
1568
1569 expect(getAntigravitySendBlockReason(provider, "")).toBe(
1570 "Choose an Antigravity model before sending.",
1571 );
1572 });
1573
1574 it("blocks a saved model that a ready catalog no longer lists", () => {
1575 const provider = entry("antigravity", "google_work", {
1576 status: "ready",
1577 models: catalogModels,
1578 }).snapshot;
1579
1580 expect(getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog")).toBe(
1581 "That Antigravity model is no longer available. Choose another model.",
1582 );
1583 expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull();
1584 });
1585
1586 it("allows a saved native model to retry after a provider error without changing it", () => {
1587 const provider = entry("antigravity", "google_work", {
1588 status: "error",
1589 models: catalogModels,
1590 }).snapshot;
1591
1592 expect(
1593 getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog"),
1594 ).toBeNull();
1595 });
1596
1597 it("keeps existing send behavior for other providers", () => {
1598 const provider = entry("codex", "codex", {
1599 installed: false,
1600 auth: { status: "unknown" },
1601 models: [],
1602 }).snapshot;
1603
1604 expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull();
1605 });
1606
1607 it("does not continue an existing Antigravity thread in another profile after deletion", () => {
1608 const missingInstanceId = ProviderInstanceId.make("google_work");
1609 const selection = resolveComposerProviderSelection({
1610 entries: [entry("antigravity")],
1611 candidateInstanceIds: [missingInstanceId],
1612 lockedProvider: ProviderDriverKind.make("antigravity"),
1613 lockedInstanceId: missingInstanceId,
1614 });
1615
1616 expect(selection.selectedProviderEntry).toBeUndefined();
1617 expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId);
1618 });
1619
1620 it("does not treat the empty draft placeholder as a provider setup target", () => {
1621 const selection = resolveComposerProviderSelection({
1622 entries: [entry("antigravity", "antigravity", { enabled: false })],
1623 candidateInstanceIds: [NO_PROVIDER_MODEL_SELECTION.instanceId],
1624 lockedProvider: null,
1625 lockedInstanceId: null,
1626 });
1627
1628 expect(selection.selectedProviderEntry).toBeUndefined();
1629 expect(selection.unavailableProviderInstanceId).toBeUndefined();
1630 });
1631
1632 it("keeps the session's continuation group when another instance was selected", () => {
1633 const sessionEntry = entry("antigravity", "google_work", {
1634 enabled: false,
1635 continuation: { groupKey: "work-profile" },
1636 });
1637 const anotherEntry = entry("antigravity", "google_personal", {
1638 continuation: { groupKey: "personal-profile" },
1639 });
1640 const selection = resolveComposerProviderSelection({
1641 entries: [sessionEntry, anotherEntry],
1642 candidateInstanceIds: [anotherEntry.instanceId, sessionEntry.instanceId],
1643 lockedProvider: ProviderDriverKind.make("antigravity"),
1644 lockedInstanceId: sessionEntry.instanceId,
1645 });
1646
1647 expect(selection.selectedProviderEntry).toBeUndefined();
1648 });
1649});
1650
1651describe("resolveComposerInteractionMode", () => {
1652 it("resets a restored plan draft when the selected instance does not support plan mode", () => {
1653 expect(
1654 resolveComposerInteractionMode({
1655 planModeEnabled: true,
1656 provider: { showInteractionModeToggle: false },
1657 interactionMode: "plan",
1658 }),
1659 ).toEqual({ enabled: false, interactionMode: "default" });
1660 });
1661
1662 it("keeps legacy plan behavior for providers that omit the capability", () => {
1663 expect(
1664 resolveComposerInteractionMode({
1665 planModeEnabled: true,
1666 provider: {},
1667 interactionMode: "plan",
1668 }),
1669 ).toEqual({ enabled: true, interactionMode: "plan" });
1670 });
1671
1672 it("resets a restored plan draft when the beta setting is off", () => {
1673 expect(
1674 resolveComposerInteractionMode({
1675 planModeEnabled: false,
1676 provider: { showInteractionModeToggle: true },
1677 interactionMode: "plan",
1678 }),
1679 ).toEqual({ enabled: false, interactionMode: "default" });
1680 });
1681
1682 it("disables plan mode until the selected provider is available", () => {
1683 expect(
1684 resolveComposerInteractionMode({
1685 planModeEnabled: true,
1686 provider: null,
1687 interactionMode: "plan",
1688 }),
1689 ).toEqual({ enabled: false, interactionMode: "default" });
1690 });
1691});
1692
1693describe("resolveBackgroundDraftWorkspaceOptions", () => {
1694 it("keeps New worktree selected without reusing the launched worktree", () => {
1695 expect(
1696 resolveBackgroundDraftWorkspaceOptions({
1697 envMode: "worktree",
1698 branch: "main",
1699 startFromOrigin: true,
1700 }),
1701 ).toEqual({
1702 envMode: "worktree",
1703 branch: "main",
1704 worktreePath: null,
1705 startFromOrigin: true,
1706 });
1707 });
1708});
1709
1710describe("proactive completed diff guard", () => {
1711 it.each([
1712 { files: 0, additions: 0, deletions: 0, action: "ignore" },
1713 { files: 1, additions: 1, deletions: 0, action: "ignore" },
1714 { files: 2, additions: 12, deletions: 12, action: "ignore" },
1715 { files: 1, additions: 25, deletions: 24, action: "ignore" },
1716 { files: 1, additions: 25, deletions: 25, action: "open" },
1717 { files: 1, additions: 0, deletions: 50, action: "open" },
1718 { files: 3, additions: 1, deletions: 0, action: "open" },
1719 ])(
1720 "uses change size for automatic diffs: $files files, +$additions/-$deletions",
1721 ({ files, additions, deletions, action }) => {
1722 const changedCheckpoint = {
1723 status: "ready",
1724 files: Array.from({ length: files }, (_, index) => ({
1725 path: `src/app-${index}.ts`,
1726 kind: "modified" as const,
1727 additions,
1728 deletions,
1729 })),
1730 } satisfies Pick<TurnDiffSummary, "status" | "files">;
1731
1732 expect(
1733 resolveProactiveTurnDiffAction({
1734 checkpoint: changedCheckpoint,
1735 isGitRepo: true,
1736 activeSurfaceKind: null,
1737 }),
1738 ).toBe(action);
1739 },
1740 );
1741
1742 it("waits for definitive checkpoint and repository state", () => {
1743 const missingCheckpoint = {
1744 status: "missing",
1745 files: [],
1746 } satisfies Pick<TurnDiffSummary, "status" | "files">;
1747 const changedCheckpoint = {
1748 status: "ready",
1749 files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }],
1750 } satisfies Pick<TurnDiffSummary, "status" | "files">;
1751
1752 expect(
1753 resolveProactiveTurnDiffAction({
1754 checkpoint: undefined,
1755 isGitRepo: true,
1756 activeSurfaceKind: null,
1757 }),
1758 ).toBe("defer");
1759 expect(
1760 resolveProactiveTurnDiffAction({
1761 checkpoint: missingCheckpoint,
1762 isGitRepo: true,
1763 activeSurfaceKind: null,
1764 }),
1765 ).toBe("defer");
1766 expect(
1767 resolveProactiveTurnDiffAction({
1768 checkpoint: changedCheckpoint,
1769 isGitRepo: undefined,
1770 activeSurfaceKind: null,
1771 }),
1772 ).toBe("defer");
1773 });
1774
1775 it("keeps an active pull request above a completed turn diff", () => {
1776 const changedCheckpoint = {
1777 status: "ready",
1778 files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }],
1779 } satisfies Pick<TurnDiffSummary, "status" | "files">;
1780
1781 expect(
1782 resolveProactiveTurnDiffAction({
1783 checkpoint: changedCheckpoint,
1784 isGitRepo: true,
1785 activeSurfaceKind: "pull-request",
1786 }),
1787 ).toBe("ignore");
1788 });
1789
1790 it("leaves an already open diff and its chosen scope alone", () => {
1791 const largeCheckpoint = {
1792 status: "ready",
1793 files: Array.from({ length: 3 }, (_, index) => ({
1794 path: `src/app-${index}.ts`,
1795 kind: "modified" as const,
1796 additions: 20,
1797 deletions: 0,
1798 })),
1799 } satisfies Pick<TurnDiffSummary, "status" | "files">;
1800
1801 expect(
1802 resolveProactiveTurnDiffAction({
1803 checkpoint: largeCheckpoint,
1804 isGitRepo: true,
1805 activeSurfaceKind: "diff",
1806 }),
1807 ).toBe("ignore");
1808 });
1809});
1810
1811describe("shouldRefocusComposerOnWindowFocus", () => {
1812 function element(
1813 tagName: string,
1814 options?: { editable?: boolean; role?: string; within?: string },
1815 ) {
1816 return {
1817 tagName,
1818 isContentEditable: options?.editable ?? false,
1819 getAttribute: (name: string) => (name === "role" ? (options?.role ?? null) : null),
1820 closest: (selector: string) =>
1821 options?.within !== undefined && selector.includes(options.within) ? ({} as Element) : null,
1822 };
1823 }
1824
1825 it("refocuses when nothing or the body holds focus", () => {
1826 expect(shouldRefocusComposerOnWindowFocus(null)).toBe(true);
1827 expect(shouldRefocusComposerOnWindowFocus(element("BODY"))).toBe(true);
1828 });
1829
1830 it("refocuses away from a plain button, such as a pull request tab", () => {
1831 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON"))).toBe(true);
1832 });
1833
1834 it("leaves other text fields alone", () => {
1835 expect(shouldRefocusComposerOnWindowFocus(element("INPUT"))).toBe(false);
1836 expect(shouldRefocusComposerOnWindowFocus(element("TEXTAREA"))).toBe(false);
1837 expect(shouldRefocusComposerOnWindowFocus(element("DIV", { editable: true }))).toBe(false);
1838 expect(shouldRefocusComposerOnWindowFocus(element("DIV", { role: "textbox" }))).toBe(false);
1839 });
1840
1841 it.each(["IFRAME", "WEBVIEW"])("leaves a focused %s preview alone", (tagName) => {
1842 expect(shouldRefocusComposerOnWindowFocus(element(tagName))).toBe(false);
1843 });
1844
1845 it("leaves a focused terminal alone in the drawer and the right panel", () => {
1846 expect(
1847 shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "data-terminal-owner" })),
1848 ).toBe(false);
1849 });
1850
1851 it("leaves focus inside a dialog or popup alone", () => {
1852 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "dialog" }))).toBe(false);
1853 expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false);
1854 });
1855});
1856
1857describe("checkout Git memory", () => {
1858 it("answers from the last status seen for the same checkout", () => {
1859 rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false);
1860 expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false);
1861 rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true);
1862 expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true);
1863 });
1864
1865 it("does not answer for a checkout it has not seen", () => {
1866 expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined();
1867 expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined();
1868 });
1869
1870 it("keeps environments apart", () => {
1871 rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false);
1872 expect(
1873 recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"),
1874 ).toBeUndefined();
1875 });
1876
1877 it("does not confuse an environment id containing the separator with a path", () => {
1878 rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false);
1879 expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined();
1880 });
1881});
1882
1883describe("threadShellHasStarted", () => {
1884 it("counts a thread that has a user message but no latest turn", () => {
1885 expect(
1886 threadShellHasStarted({ latestRun: null, latestUserMessageAt: now, runtime: null }),
1887 ).toBe(true);
1888 });
1889
1890 it("counts a thread with a live runtime and nothing else", () => {
1891 expect(
1892 threadShellHasStarted({
1893 latestRun: null,
1894 latestUserMessageAt: null,
1895 runtime: {
1896 providerInstanceId: ProviderInstanceId.make("codex"),
1897 status: "starting",
1898 providerName: "codex",
1899 activeRunId: null,
1900 lastError: null,
1901 updatedAt: now,
1902 },
1903 }),
1904 ).toBe(true);
1905 });
1906
1907 it("does not count a thread that never sent anything", () => {
1908 expect(
1909 threadShellHasStarted({ latestRun: null, latestUserMessageAt: null, runtime: null }),
1910 ).toBe(false);
1911 expect(threadShellHasStarted(null)).toBe(false);
1912 });
1913});
1914
1915it("follows a changed server PR link without replacing an unrelated open panel", () => {
1916 const previous = {
1917 projectId: ProjectId.make("project-1"),
1918 repository: "pingdotgg/t3code",
1919 number: 42,
1920 url: "https://github.com/pingdotgg/t3code/pull/42",
1921 };
1922 const current = {
1923 ...previous,
1924 number: 43,
1925 url: "https://github.com/pingdotgg/t3code/pull/43",
1926 };
1927 const surface = {
1928 id: "pull-request:previous",
1929 kind: "pull-request",
1930 projectId: previous.projectId,
1931 repository: "PingDotGG/T3Code",
1932 number: previous.number,
1933 } satisfies RightPanelSurface;
1934
1935 expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true);
1936 expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false);
1937 expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false);
1938 expect(shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 })).toBe(
1939 false,
1940 );
1941 expect(
1942 shouldRetargetThreadPullRequestPanel(previous, current, {
1943 ...surface,
1944 projectId: "another-project",
1945 }),
1946 ).toBe(false);
1947});
1948
1949describe("worktree setup visibility", () => {
1950 const stage = (
1951 id: "fetch" | "checkout" | "submodules" | "setup-script" | "agent",
1952 status: "done" | "running" | "failed" | "pending",
1953 ) => ({
1954 id,
1955 status,
1956 startedAt: now,
1957 endedAt: status === "running" || status === "pending" ? null : now,
1958 percent: null,
1959 detail: null,
1960 tail: [],
1961 });
1962 const base = {
1963 threadId,
1964 phase: "running" as const,
1965 startedAt: now,
1966 endedAt: null,
1967 branch: "feature",
1968 baseRef: "main",
1969 worktreePath: null,
1970 setupScript: null,
1971 stages: [stage("checkout", "running"), stage("agent", "pending")],
1972 error: null,
1973 sequence: 1,
1974 };
1975 const settledDone = {
1976 ...base,
1977 phase: "done" as const,
1978 endedAt: now,
1979 stages: [stage("checkout", "done"), stage("setup-script", "done"), stage("agent", "done")],
1980 };
1981
1982 it("keeps setup presentation continuous until the provider handoff", () => {
1983 const progress = (
1984 localPreparing: boolean,
1985 runStatus: NonNullable<Thread["latestRun"]>["status"] | undefined,
1986 latest: WorktreeSetupSnapshot | null,
1987 held: WorktreeSetupSnapshot | null = null,
1988 ) => resolveWorktreeSetupProgress({ threadId, localPreparing, runStatus, latest, held });
1989
1990 // The local send, its durable acknowledgement, and the stream arrive separately.
1991 expect(progress(true, undefined, null).isPreparingWorktree).toBe(true);
1992 expect(progress(false, "preparing", null).isPreparingWorktree).toBe(true);
1993 expect(progress(false, "preparing", base).snapshot).toBe(base);
1994 // Releasing the prepared run precedes the tracker marking the agent started.
1995 expect(progress(false, "starting", base).isPreparingWorktree).toBe(true);
1996 const handedOff = {
1997 ...base,
1998 sequence: 2,
1999 stages: [stage("setup-script", "running"), stage("agent", "done")],
2000 };
2001 expect(progress(false, "starting", handedOff, base)).toEqual({
2002 snapshot: handedOff,
2003 isPreparingWorktree: false,
2004 });
2005 expect(progress(false, "running", null, handedOff).snapshot).toBe(handedOff);
2006 });
2007
2008 it("uses streamed setup progress immediately without reverting to an older held snapshot", () => {
2009 const newest = { ...settledDone, sequence: 9 };
2010 const resolve = (latest: WorktreeSetupSnapshot | null, held: WorktreeSetupSnapshot | null) =>
2011 resolveWorktreeSetupProgress({
2012 threadId,
2013 localPreparing: false,
2014 runStatus: "running",
2015 latest,
2016 held,
2017 });
2018 expect(resolve(newest, base)).toEqual({ snapshot: newest, isPreparingWorktree: false });
2019 expect(resolve(base, newest)).toEqual({ snapshot: newest, isPreparingWorktree: false });
2020 const other = { ...base, threadId: ThreadId.make("another-thread") };
2021 expect(resolve(other, other)).toEqual({ snapshot: null, isPreparingWorktree: false });
2022 });
2023
2024 it.each(["failed", "cancelled"] as const)(
2025 "does not keep %s setup in the preparing state",
2026 (phase) => {
2027 const snapshot = { ...base, phase };
2028 expect(
2029 resolveWorktreeSetupProgress({
2030 threadId,
2031 localPreparing: false,
2032 runStatus: "failed",
2033 latest: snapshot,
2034 held: base,
2035 }),
2036 ).toEqual({ snapshot, isPreparingWorktree: false });
2037 },
2038 );
2039
2040 it("reads the settled snapshot back from the thread's activities", () => {
2041 const activities = [
2042 { kind: "setup-script.started", payload: {} },
2043 { kind: "worktree-setup", payload: settledDone },
2044 { kind: "worktree-setup", payload: { not: "a snapshot" } },
2045 ];
2046 expect(findRecordedWorktreeSetup(activities, threadId)).toEqual(settledDone);
2047 expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull();
2048 });
2049
2050 it("shows a running setup and drops a clean one once the turn started", () => {
2051 const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) =>
2052 resolveVisibleWorktreeSetup({
2053 live: null,
2054 recorded: snapshot,
2055 turnStarted,
2056 followUpSent: false,
2057 });
2058 expect(
2059 resolveVisibleWorktreeSetup({
2060 live: base,
2061 recorded: null,
2062 turnStarted: false,
2063 followUpSent: false,
2064 }),
2065 ).toEqual(base);
2066 expect(visible(settledDone, false)).toEqual(settledDone);
2067 expect(visible(settledDone, true)).toBeNull();
2068 expect(visible(null, true)).toBeNull();
2069 });
2070
2071 it("keeps a failed script, a failed setup, and a cancelled setup visible", () => {
2072 const scriptFailed = {
2073 ...settledDone,
2074 stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")],
2075 };
2076 const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) =>
2077 resolveVisibleWorktreeSetup({
2078 live: null,
2079 recorded: snapshot,
2080 turnStarted: true,
2081 followUpSent,
2082 });
2083 expect(visible(scriptFailed)).toEqual(scriptFailed);
2084 const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" };
2085 expect(visible(failed)).toEqual(failed);
2086 const cancelled = { ...settledDone, phase: "cancelled" as const };
2087 expect(visible(cancelled)).toEqual(cancelled);
2088
2089 // The setup belongs to the first turn. A follow-up send retires every
2090 // settled outcome; only a script that is still running stays.
2091 expect(visible(scriptFailed, true)).toBeNull();
2092 expect(visible(failed, true)).toBeNull();
2093 expect(visible(cancelled, true)).toBeNull();
2094 expect(visible(settledDone, true)).toBeNull();
2095 const stillRunning = {
2096 ...base,
2097 stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")],
2098 };
2099 expect(visible(stillRunning, true)).toEqual(stillRunning);
2100 });
2101
2102 it("prefers whichever snapshot is newer by sequence", () => {
2103 const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) =>
2104 resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false });
2105 expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({
2106 ...settledDone,
2107 sequence: 7,
2108 });
2109 expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({
2110 ...settledDone,
2111 sequence: 9,
2112 });
2113 });
2114});
2115
2116describe("waitForRevertedMessage", () => {
2117 const threadRef = { environmentId: EnvironmentId.make("env-1"), threadId: ThreadId.make("t") };
2118 const messageId = MessageId.make("message-2");
2119 const requestId = CommandId.make("rollback-1");
2120
2121 function projectionAtom() {
2122 const base = makeThreadProjectionFixture();
2123 const projection = {
2124 ...base,
2125 messages: [
2126 {
2127 id: messageId,
2128 threadId: base.thread.id,
2129 runId: RunId.make("run-2"),
2130 nodeId: null,
2131 role: "user",
2132 text: "second",
2133 attachments: [],
2134 streaming: false,
2135 createdAt: base.updatedAt,
2136 updatedAt: base.updatedAt,
2137 },
2138 ],
2139 } as unknown as ReturnType<typeof makeThreadProjectionFixture>;
2140 const state = Atom.make({ data: Option.some(projection) });
2141 vi.spyOn(environmentThreadDetails, "stateAtom").mockReturnValue(state as never);
2142 return { state, projection };
2143 }
2144
2145 afterEach(() => vi.restoreAllMocks());
2146
2147 it("rejects with the projected reason when the rollback fails for good", async () => {
2148 const { state, projection } = projectionAtom();
2149 const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => {});
2150 await Promise.resolve();
2151 appAtomRegistry.set(state, {
2152 data: Option.some({
2153 ...projection,
2154 thread: {
2155 ...projection.thread,
2156 rollbackFailure: { requestId, message: "The provider could not roll back." },
2157 },
2158 }),
2159 });
2160
2161 await expect(waiting).rejects.toThrow("The provider could not roll back.");
2162 });
2163
2164 it("ignores a failure recorded for an earlier rollback", async () => {
2165 vi.useFakeTimers();
2166 const { state, projection } = projectionAtom();
2167 const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => {}, 50);
2168 const settled = expect(waiting).rejects.toThrow("Timed out waiting for the thread to rewind.");
2169 appAtomRegistry.set(state, {
2170 data: Option.some({
2171 ...projection,
2172 thread: {
2173 ...projection.thread,
2174 rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." },
2175 },
2176 }),
2177 });
2178 await vi.advanceTimersByTimeAsync(50);
2179 await settled;
2180 vi.useRealTimers();
2181 });
2182});