import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId,} from "@t3tools/contracts";import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types";import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, toolGroupConsumesUpwardNavigation,} from "./ChatView.logic"; describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); });}); describe("toolGroupConsumesUpwardNavigation", () => { class ScrollElement extends EventTarget { scrollTop = 0; scrollHeight = 100; clientHeight = 100; overflowY = "visible"; constructor( readonly parentElement: ScrollElement | null = null, readonly isToolGroup = false, ) { super(); } closest(selector: string): ScrollElement | null { if (selector !== "[data-tool-group-scroll]") return null; return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); } } beforeEach(() => { vi.stubGlobal("Element", ScrollElement); vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ overflowY: element.overflowY, })); }); afterEach(() => vi.unstubAllGlobals()); it("releases upward navigation when an overflowing group is at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each([ { overflowY: "auto", scrollTop: 1 }, { overflowY: "auto", scrollTop: 0.25 }, { overflowY: "scroll", scrollTop: 80 }, ])("consumes upward navigation within a scrolled group: %j", (scroll) => { const group = Object.assign(new ScrollElement(null, true), { scrollHeight: 300, ...scroll, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); }); it.each([100, 300])( "consumes scrolling in a nested result with a group content height of %i", (scrollHeight) => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight, }); const result = Object.assign(new ScrollElement(group), { overflowY: "auto", scrollHeight: 300, scrollTop: 0.25, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); }, ); it("releases upward navigation when the group and nested result are both at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); const result = Object.assign(new ScrollElement(group), { overflowY: "scroll", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }); it("ignores targets outside a tool group and non-element targets", () => { const outside = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); }); it("does not consume scrolling from an ancestor beyond the tool group", () => { const timeline = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); const group = new ScrollElement(timeline, true); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each(["hidden", "clip", "visible"])( "ignores a non-scrollable child with overflow-y %s", (overflowY) => { const group = new ScrollElement(null, true); const result = Object.assign(new ScrollElement(group), { overflowY, scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }, ); it("does not consume programmatic scrolling on an overflow-hidden group", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "hidden", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); });}); const environmentId = EnvironmentId.make("environment-local");const projectId = ProjectId.make("project-1");const threadId = ThreadId.make("thread-1");const now = "2026-03-29T00:00:00.000Z";const helloWorldTemplate: CodexArtifactTemplate = { artifactKind: "document", displayName: "Hello World", skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", skillName: "artifact-template-hello-world",}; describe("artifact template composer insertion", () => { it("does not insert an already-present prompt", () => { const prompt = "Create a document using this $artifact-template-hello-world about…"; expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); });}); describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( shouldDockDraftHeroForSubmission({ isDraftHeroState: true, activeThreadKey: "environment-local:thread-1", submissionIntent: "background", }), ).toBe(false); }); it("keeps the composer in the hero layout until navigation after server promotion", () => { expect( resolveDraftHeroState({ isLocalDraftThread: false, hasTimelineEntries: true, isWorking: true, draftHeroDockRequested: false, backgroundSubmissionPending: true, }), ).toBe(true); }); it("does not auto-navigate a background submission after server promotion", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, serverThread: makeThread({ latestTurn: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); });}); describe("shouldReleaseTimelineAnchorForToolActivity", () => { const activeTurnId = TurnId.make("active-turn"); const anchorMessageId = MessageId.make("anchored-message"); const activeToolEntry = { id: "tool-entry", kind: "work" as const, createdAt: now, entry: { id: "active-tool", createdAt: now, turnId: activeTurnId, label: "Run command", tone: "tool" as const, command: "git status", }, }; it("releases the send anchor for tool activity in the active turn", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(true); }); it("keeps the anchor while the user reads history", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: false, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(false); }); it("ignores tool activity from earlier turns", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { ...activeToolEntry.entry, turnId: TurnId.make("previous-turn"), }, }, ], }), ).toBe(false); }); it("ignores thinking and error rows without tool activity", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { id: "thinking-entry", createdAt: now, turnId: activeTurnId, label: "Thinking", tone: "thinking", }, }, { ...activeToolEntry, id: "error-entry", entry: { id: "error-entry", createdAt: now, turnId: activeTurnId, label: "Provider error", tone: "error", }, }, ], }), ).toBe(false); }); it("does nothing without an anchor or running turn", () => { const input = { anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }; expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( false, ); expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( false, ); });}); describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); it("shows a persistent reconnect after the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); scheduleEnvironmentReconnectWarning(showWarning); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); expect(showWarning).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(showWarning).toHaveBeenCalledOnce(); }); it("cancels the warning when the connection recovers during the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); const cancel = scheduleEnvironmentReconnectWarning(showWarning); cancel(); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); expect(showWarning).not.toHaveBeenCalled(); }); it("does not reuse elapsed grace from another environment", () => { const anotherEnvironmentId = EnvironmentId.make("environment-remote"); expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( false, ); });}); function makeThread(overrides: Partial<Thread> = {}): Thread { return { id: threadId, environmentId, projectId, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", session: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestTurn: null, branch: null, worktreePath: null, ...overrides, };} const completedTurn = { turnId: TurnId.make("turn-1"), state: "completed" as const, requestedAt: now, startedAt: "2026-03-29T00:00:01.000Z", completedAt: "2026-03-29T00:00:10.000Z", assistantMessageId: null,}; const readySession = { threadId, status: "ready" as const, providerName: "codex", providerInstanceId: ProviderInstanceId.make("codex"), runtimeMode: "full-access" as const, activeTurnId: null, lastError: null, updatedAt: "2026-03-29T00:00:10.000Z",}; describe("draft promotion during worktree setup", () => { const serverThreadRef = { environmentId, threadId }; it.each([null, "idle", "starting", "ready"] as const)( "keeps the draft mounted while the first turn waits with session %s", (status) => { const serverThread = makeThread({ messages: [ { id: MessageId.make("submitted-message"), role: "user", text: "Start in a new worktree", turnId: null, createdAt: now, updatedAt: now, streaming: false, }, ], session: status ? { ...readySession, status } : null, }); expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread, backgroundSubmissionPending: false, }), ).toBeNull(); }, ); it("promotes when the provider starts the first turn", () => { const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestTurn, session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId }, }), backgroundSubmissionPending: false, }), ).toEqual(serverThreadRef); }); it.each(["error", "stopped", "interrupted"] as const)( "promotes a startup that ends as %s before a turn starts", (status) => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ session: { ...readySession, status } }), backgroundSubmissionPending: false, }), ).toEqual(serverThreadRef); }, );}); describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { environmentId, id: threadId, projectId, title: "Loading thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", },Lines 1–500 of 1146. Load more
import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, toolGroupConsumesUpwardNavigation, } from "./ChatView.logic"; describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); }); }); describe("toolGroupConsumesUpwardNavigation", () => { class ScrollElement extends EventTarget { scrollTop = 0; scrollHeight = 100; clientHeight = 100; overflowY = "visible"; constructor( readonly parentElement: ScrollElement | null = null, readonly isToolGroup = false, ) { super(); } closest(selector: string): ScrollElement | null { if (selector !== "[data-tool-group-scroll]") return null; return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); } } beforeEach(() => { vi.stubGlobal("Element", ScrollElement); vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ overflowY: element.overflowY, })); }); afterEach(() => vi.unstubAllGlobals()); it("releases upward navigation when an overflowing group is at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each([ { overflowY: "auto", scrollTop: 1 }, { overflowY: "auto", scrollTop: 0.25 }, { overflowY: "scroll", scrollTop: 80 }, ])("consumes upward navigation within a scrolled group: %j", (scroll) => { const group = Object.assign(new ScrollElement(null, true), { scrollHeight: 300, ...scroll, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); }); it.each([100, 300])( "consumes scrolling in a nested result with a group content height of %i", (scrollHeight) => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight, }); const result = Object.assign(new ScrollElement(group), { overflowY: "auto", scrollHeight: 300, scrollTop: 0.25, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); }, ); it("releases upward navigation when the group and nested result are both at the top", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "auto", scrollHeight: 300, }); const result = Object.assign(new ScrollElement(group), { overflowY: "scroll", scrollHeight: 300, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }); it("ignores targets outside a tool group and non-element targets", () => { const outside = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); }); it("does not consume scrolling from an ancestor beyond the tool group", () => { const timeline = Object.assign(new ScrollElement(), { overflowY: "auto", scrollHeight: 300, scrollTop: 40, }); const group = new ScrollElement(timeline, true); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); }); it.each(["hidden", "clip", "visible"])( "ignores a non-scrollable child with overflow-y %s", (overflowY) => { const group = new ScrollElement(null, true); const result = Object.assign(new ScrollElement(group), { overflowY, scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); }, ); it("does not consume programmatic scrolling on an overflow-hidden group", () => { const group = Object.assign(new ScrollElement(null, true), { overflowY: "hidden", scrollHeight: 300, scrollTop: 40, }); expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); }); }); const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; const helloWorldTemplate: CodexArtifactTemplate = { artifactKind: "document", displayName: "Hello World", skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", skillName: "artifact-template-hello-world", }; describe("artifact template composer insertion", () => { it("does not insert an already-present prompt", () => { const prompt = "Create a document using this $artifact-template-hello-world about…"; expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); }); }); describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( shouldDockDraftHeroForSubmission({ isDraftHeroState: true, activeThreadKey: "environment-local:thread-1", submissionIntent: "background", }), ).toBe(false); }); it("keeps the composer in the hero layout until navigation after server promotion", () => { expect( resolveDraftHeroState({ isLocalDraftThread: false, hasTimelineEntries: true, isWorking: true, draftHeroDockRequested: false, backgroundSubmissionPending: true, }), ).toBe(true); }); it("does not auto-navigate a background submission after server promotion", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, serverThread: makeThread({ latestTurn: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); }); }); describe("shouldReleaseTimelineAnchorForToolActivity", () => { const activeTurnId = TurnId.make("active-turn"); const anchorMessageId = MessageId.make("anchored-message"); const activeToolEntry = { id: "tool-entry", kind: "work" as const, createdAt: now, entry: { id: "active-tool", createdAt: now, turnId: activeTurnId, label: "Run command", tone: "tool" as const, command: "git status", }, }; it("releases the send anchor for tool activity in the active turn", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(true); }); it("keeps the anchor while the user reads history", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: false, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }), ).toBe(false); }); it("ignores tool activity from earlier turns", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { ...activeToolEntry.entry, turnId: TurnId.make("previous-turn"), }, }, ], }), ).toBe(false); }); it("ignores thinking and error rows without tool activity", () => { expect( shouldReleaseTimelineAnchorForToolActivity({ anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [ { ...activeToolEntry, entry: { id: "thinking-entry", createdAt: now, turnId: activeTurnId, label: "Thinking", tone: "thinking", }, }, { ...activeToolEntry, id: "error-entry", entry: { id: "error-entry", createdAt: now, turnId: activeTurnId, label: "Provider error", tone: "error", }, }, ], }), ).toBe(false); }); it("does nothing without an anchor or running turn", () => { const input = { anchorMessageId, liveFollowEnabled: true, runningTurnId: activeTurnId, timelineEntries: [activeToolEntry], }; expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( false, ); expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( false, ); }); }); describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); it("shows a persistent reconnect after the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); scheduleEnvironmentReconnectWarning(showWarning); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); expect(showWarning).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(showWarning).toHaveBeenCalledOnce(); }); it("cancels the warning when the connection recovers during the grace period", () => { vi.useFakeTimers(); const showWarning = vi.fn(); const cancel = scheduleEnvironmentReconnectWarning(showWarning); cancel(); vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); expect(showWarning).not.toHaveBeenCalled(); }); it("does not reuse elapsed grace from another environment", () => { const anotherEnvironmentId = EnvironmentId.make("environment-remote"); expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( false, ); }); }); function makeThread(overrides: Partial<Thread> = {}): Thread { return { id: threadId, environmentId, projectId, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, runtimeMode: "full-access", interactionMode: "default", session: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, deletedAt: null, latestTurn: null, branch: null, worktreePath: null, ...overrides, }; } const completedTurn = { turnId: TurnId.make("turn-1"), state: "completed" as const, requestedAt: now, startedAt: "2026-03-29T00:00:01.000Z", completedAt: "2026-03-29T00:00:10.000Z", assistantMessageId: null, }; const readySession = { threadId, status: "ready" as const, providerName: "codex", providerInstanceId: ProviderInstanceId.make("codex"), runtimeMode: "full-access" as const, activeTurnId: null, lastError: null, updatedAt: "2026-03-29T00:00:10.000Z", }; describe("draft promotion during worktree setup", () => { const serverThreadRef = { environmentId, threadId }; it.each([null, "idle", "starting", "ready"] as const)( "keeps the draft mounted while the first turn waits with session %s", (status) => { const serverThread = makeThread({ messages: [ { id: MessageId.make("submitted-message"), role: "user", text: "Start in a new worktree", turnId: null, createdAt: now, updatedAt: now, streaming: false, }, ], session: status ? { ...readySession, status } : null, }); expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread, backgroundSubmissionPending: false, }), ).toBeNull(); }, ); it("promotes when the provider starts the first turn", () => { const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ latestTurn, session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId }, }), backgroundSubmissionPending: false, }), ).toEqual(serverThreadRef); }); it.each(["error", "stopped", "interrupted"] as const)( "promotes a startup that ends as %s before a turn starts", (status) => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef, serverThread: makeThread({ session: { ...readySession, status } }), backgroundSubmissionPending: false, }), ).toEqual(serverThreadRef); }, ); }); describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { environmentId, id: threadId, projectId, title: "Loading thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", },