import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId,} from "@t3tools/contracts";import { afterEach, 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, loadVideoPreviewUrl, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread,} from "./ChatView.logic"; describe("loadVideoPreviewUrl", () => { it("loads video bytes into an object URL", async () => { const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA=="); expect(objectUrl).toMatch(/^blob:/); URL.revokeObjectURL(objectUrl); }); it("stops loading when the preview request is cancelled", async () => { const controller = new AbortController(); controller.abort(); await expect( loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal), ).rejects.toMatchObject({ name: "AbortError" }); });}); 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); });}); 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", }, runtimeMode: "full-access", interactionMode: "default", branch: "main", worktreePath: null, latestTurn: null, createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, snoozedUntil: null, snoozedAt: null, session: null, latestUserMessageAt: now, hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, } satisfies ThreadShell; expect(buildLoadingThreadFromShell(shell)).toMatchObject({ environmentId, id: threadId, projectId, title: "Loading thread", branch: "main", deletedAt: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], }); });}); describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }; it("updates a stale local thread branch to the active checkout", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, currentBranch: "feature/thread", nextBranch: "feature/checkout", }), ).toEqual({ branch: "feature/checkout", worktreePath: null }); }); it("does not write metadata when the model and branch are unchanged", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, nextModelSelection: modelSelection, currentBranch: "feature/current", nextBranch: "feature/current", }), ).toBeNull(); });}); describe("buildThreadTurnInterruptInput", () => { it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); expect( buildThreadTurnInterruptInput( makeThread({ session: { ...readySession, status: "running", activeTurnId, }, }), ), ).toEqual({ threadId, turnId: activeTurnId }); }); it("omits a turn id when the session is not running", () => { expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ threadId, }); });}); describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ prompt: "\uFFFC", imageCount: 0, terminalContexts: [ { id: "ctx-expired", threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", createdAt: now, }, ], }); expect(state.trimmedPrompt).toBe(""); expect(state.sendableTerminalContexts).toEqual([]); expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(false); }); Lines 1–500 of 1035. Load more
import { EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, TurnId, } from "@t3tools/contracts"; import { afterEach, 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, loadVideoPreviewUrl, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; describe("loadVideoPreviewUrl", () => { it("loads video bytes into an object URL", async () => { const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA=="); expect(objectUrl).toMatch(/^blob:/); URL.revokeObjectURL(objectUrl); }); it("stops loading when the preview request is cancelled", async () => { const controller = new AbortController(); controller.abort(); await expect( loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal), ).rejects.toMatchObject({ name: "AbortError" }); }); }); 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); }); }); 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", }, runtimeMode: "full-access", interactionMode: "default", branch: "main", worktreePath: null, latestTurn: null, createdAt: now, updatedAt: now, archivedAt: null, settledOverride: null, settledAt: null, snoozedUntil: null, snoozedAt: null, session: null, latestUserMessageAt: now, hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, } satisfies ThreadShell; expect(buildLoadingThreadFromShell(shell)).toMatchObject({ environmentId, id: threadId, projectId, title: "Loading thread", branch: "main", deletedAt: null, messages: [], proposedPlans: [], activities: [], checkpoints: [], }); }); }); describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }; it("updates a stale local thread branch to the active checkout", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, currentBranch: "feature/thread", nextBranch: "feature/checkout", }), ).toEqual({ branch: "feature/checkout", worktreePath: null }); }); it("does not write metadata when the model and branch are unchanged", () => { expect( resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: modelSelection, nextModelSelection: modelSelection, currentBranch: "feature/current", nextBranch: "feature/current", }), ).toBeNull(); }); }); describe("buildThreadTurnInterruptInput", () => { it("targets the session's active running turn", () => { const activeTurnId = TurnId.make("turn-running"); expect( buildThreadTurnInterruptInput( makeThread({ session: { ...readySession, status: "running", activeTurnId, }, }), ), ).toEqual({ threadId, turnId: activeTurnId }); }); it("omits a turn id when the session is not running", () => { expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ threadId, }); }); }); describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ prompt: "\uFFFC", imageCount: 0, terminalContexts: [ { id: "ctx-expired", threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", createdAt: now, }, ], }); expect(state.trimmedPrompt).toBe(""); expect(state.sendableTerminalContexts).toEqual([]); expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(false); });