From aa04e55c526cc5df47bb3fd913c5ad2eec77abd4 Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 28 May 2026 16:40:34 +0900 Subject: [PATCH] Model chat turn lifecycle --- src/features/chat/chat-composer-controller.ts | 4 +- src/features/chat/chat-controller.ts | 29 +++-- src/features/chat/chat-message-renderer.ts | 19 ++- src/features/chat/chat-state.ts | 64 ++++++---- src/features/chat/thread-actions.ts | 8 +- src/features/chat/view.ts | 105 ++++++++++------ tests/features/chat/chat-controller.test.ts | 117 +++++++++++++----- .../features/chat/chat-state-reducer.test.ts | 93 +++++++++++++- .../features/chat/ui/message-stream.test.tsx | 4 +- tests/features/chat/ui/shell.test.tsx | 6 +- tests/features/chat/view-connection.test.ts | 69 +++++++++++ 11 files changed, 392 insertions(+), 126 deletions(-) diff --git a/src/features/chat/chat-composer-controller.ts b/src/features/chat/chat-composer-controller.ts index dadf0b0f..d650be4d 100644 --- a/src/features/chat/chat-composer-controller.ts +++ b/src/features/chat/chat-composer-controller.ts @@ -14,7 +14,7 @@ import { import { userInputWithWikiLinkMentionsAndSkills } from "./composer/wikilink-context"; import type { UserInput } from "../../generated/app-server/v2/UserInput"; import { renderComposerShell, renderComposerSuggestions, syncComposerHeight } from "./ui/composer"; -import type { ChatAction, ChatState, ChatStateStore } from "./chat-state"; +import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state"; import { unmountReactRoot } from "../../shared/ui/react-root"; export interface ChatComposerControllerOptions { @@ -72,7 +72,7 @@ export class ChatComposerController { parent, this.options.viewId, state.composerDraft, - state.busy, + chatTurnBusy(state), this.options.canInterrupt(), this.options.composerPlaceholder(), { diff --git a/src/features/chat/chat-controller.ts b/src/features/chat/chat-controller.ts index 793b97d0..a5ab6646 100644 --- a/src/features/chat/chat-controller.ts +++ b/src/features/chat/chat-controller.ts @@ -25,7 +25,13 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest"; import type { FileUpdateChange } from "../../generated/app-server/v2/FileUpdateChange"; import type { ThreadItem } from "../../generated/app-server/v2/ThreadItem"; import type { Turn } from "../../generated/app-server/v2/Turn"; -import type { ChatAction, ChatState, ChatStateStore } from "./chat-state"; +import { + activeTurnId, + pendingTurnStart as pendingTurnStartForState, + type ChatAction, + type ChatState, + type ChatStateStore, +} from "./chat-state"; import { userInputResponse, type PendingUserInput } from "./user-input/model"; import { jsonPreview } from "../../utils"; import { classifyAppServerLog } from "./app-server-logs"; @@ -222,7 +228,7 @@ export class ChatController { }); } else if (method === "guardianWarning") { const item = createReviewResultItem(this.localItemId("review"), params.message); - if (!isUnstructuredAutoReviewWarning(item) || !hasStructuredAutoReviewResult(this.state.displayItems, this.state.activeTurnId)) { + if (!isUnstructuredAutoReviewWarning(item) || !hasStructuredAutoReviewResult(this.state.displayItems, activeTurnId(this.state))) { this.dispatch({ type: "display/item-upserted", item }); } } @@ -236,9 +242,9 @@ export class ChatController { threadId: params.threadId, turnId: params.turn.id, displayItems: this.displayItemsWithPendingPromptSubmitHooks(params.turn.id), - pendingTurnStart: null, }); } else if (method === "turn/completed") { + if (activeTurnId(this.state) !== params.turn.id) return; this.dispatch({ type: "turn/completed", turnId: params.turn.id, @@ -322,7 +328,7 @@ export class ChatController { private activeRouteScope(): { activeThreadId: string | null; activeTurnId: string | null } { return { activeThreadId: this.state.activeThreadId, - activeTurnId: this.state.activeTurnId, + activeTurnId: activeTurnId(this.state), }; } @@ -406,12 +412,13 @@ export class ChatController { const resolvedTurnId = this.hookRunTurnId(run, turnId); const item = hookRunDisplayItem(run, resolvedTurnId, status); if (!item) return; - let pendingTurnStart = this.state.pendingTurnStart; - if (!resolvedTurnId && this.state.pendingTurnStart && run.eventName === "userPromptSubmit") { - const hookIds = this.state.pendingTurnStart.promptSubmitHookItemIds; + const currentPendingTurnStart = pendingTurnStartForState(this.state); + let pendingTurnStart = currentPendingTurnStart; + if (!resolvedTurnId && currentPendingTurnStart && run.eventName === "userPromptSubmit") { + const hookIds = currentPendingTurnStart.promptSubmitHookItemIds; pendingTurnStart = hookIds.includes(item.id) - ? this.state.pendingTurnStart - : { ...this.state.pendingTurnStart, promptSubmitHookItemIds: [...hookIds, item.id] }; + ? currentPendingTurnStart + : { ...currentPendingTurnStart, promptSubmitHookItemIds: [...hookIds, item.id] }; } this.dispatch({ type: "display/pending-turn-item-upserted", @@ -425,12 +432,12 @@ export class ChatController { turnId: string | null, ): string | null { if (turnId) return turnId; - if (run.eventName === "userPromptSubmit" && !this.state.pendingTurnStart) return this.state.activeTurnId; + if (run.eventName === "userPromptSubmit" && !pendingTurnStartForState(this.state)) return activeTurnId(this.state); return null; } private displayItemsWithPendingPromptSubmitHooks(turnId: string): readonly DisplayItem[] { - const pending = this.state.pendingTurnStart; + const pending = pendingTurnStartForState(this.state); if (!pending) return this.state.displayItems; return attachHookRunsToTurn(this.state.displayItems, turnId, pending.promptSubmitHookItemIds, pending.anchorItemId); } diff --git a/src/features/chat/chat-message-renderer.ts b/src/features/chat/chat-message-renderer.ts index 4d2e29a8..ab31dba2 100644 --- a/src/features/chat/chat-message-renderer.ts +++ b/src/features/chat/chat-message-renderer.ts @@ -8,7 +8,7 @@ import { MessageScrollController, type MessageScrollIntent } from "./ui/scroll"; import type { ChatTurnDiffViewState } from "./ui/turn-diff"; import { MarkdownMessageRenderer } from "./markdown-message-renderer"; import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback"; -import type { ChatAction, ChatState, ChatStateStore } from "./chat-state"; +import { activeTurnId, chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state"; import { unmountReactRoot } from "../../shared/ui/react-root"; export interface ChatMessageRendererOptions { @@ -62,15 +62,17 @@ export class ChatMessageRenderer { const state = this.state; this.messagesEl = messagesEl; const scrollPlan = this.scrollController.prepareRender(messagesEl, this.options.consumeScrollIntent()); - const rollbackCandidate = state.busy ? null : rollbackCandidateFromItems(state.displayItems); + const busy = chatTurnBusy(state); + const activeTurn = activeTurnId(state); + const rollbackCandidate = busy ? null : rollbackCandidateFromItems(state.displayItems); const implementPlanCandidate = implementPlanCandidateFromState(state); const blocks = messageStreamBlocks({ activeThreadId: state.activeThreadId, - activeTurnId: state.activeTurnId, + activeTurnId: activeTurn, historyCursor: state.historyCursor, loadingHistory: state.loadingHistory, - busy: state.busy, + busy, displayItems: state.displayItems, turnDiffs: state.turnDiffs, workspaceRoot: state.activeThreadCwd ?? this.options.vaultPath, @@ -128,9 +130,14 @@ export class ChatMessageRenderer { } export function implementPlanCandidateFromState( - state: Pick, + state: Pick, ): DisplayItem | null { - if (!state.activeThreadId || state.busy || state.composerDraft.trim().length > 0 || state.requestedCollaborationMode !== "plan") { + if ( + !state.activeThreadId || + chatTurnBusy(state) || + state.composerDraft.trim().length > 0 || + state.requestedCollaborationMode !== "plan" + ) { return null; } return ( diff --git a/src/features/chat/chat-state.ts b/src/features/chat/chat-state.ts index 06884800..04fd90a3 100644 --- a/src/features/chat/chat-state.ts +++ b/src/features/chat/chat-state.ts @@ -24,13 +24,18 @@ export interface PendingTurnStart { promptSubmitHookItemIds: string[]; } +export type ChatTurnLifecycleState = + | { kind: "idle" } + | { kind: "starting"; pendingTurnStart: PendingTurnStart } + | { kind: "running"; turnId: string }; + export interface ChatState { status: string; effectiveConfig: ConfigReadResponse | null; initializeResponse: InitializeResponse | null; activeThreadId: string | null; activeThreadCwd: string | null; - activeTurnId: string | null; + turnLifecycle: ChatTurnLifecycleState; activeModel: string | null; activeReasoningEffort: ReasoningEffort | null; activeCollaborationMode: ModeKind; @@ -45,9 +50,7 @@ export interface ChatState { requestedServiceTier: ServiceTier | null; tokenUsage: ThreadTokenUsage | null; rateLimit: RateLimitSnapshot | null; - busy: boolean; displayItems: readonly DisplayItem[]; - pendingTurnStart: PendingTurnStart | null; turnDiffs: ReadonlyMap; approvals: readonly PendingApproval[]; pendingUserInputs: readonly PendingUserInput[]; @@ -108,7 +111,6 @@ export type ChatAction = threadId: string; turnId: string; displayItems?: readonly DisplayItem[]; - pendingTurnStart?: PendingTurnStart | null; } | { type: "turn/completed"; turnId: string; status: string; displayItems: readonly DisplayItem[] } | { type: "request/approval-queued"; approval: PendingApproval } @@ -166,7 +168,7 @@ export type ChatAction = | { type: "turn/local-cleared" } | { type: "turn/optimistic-started"; item: DisplayItem; pendingTurnStart: PendingTurnStart } | { type: "turn/start-acknowledged"; turnId: string; displayItems: readonly DisplayItem[] } - | { type: "turn/start-failed"; displayItems: readonly DisplayItem[]; pendingTurnStart: PendingTurnStart | null } + | { type: "turn/start-failed"; displayItems: readonly DisplayItem[] } | { type: "display/pending-turn-item-upserted"; item: DisplayItem; pendingTurnStart: PendingTurnStart | null }; export function createChatState(): ChatState { @@ -176,7 +178,7 @@ export function createChatState(): ChatState { initializeResponse: null, activeThreadId: null, activeThreadCwd: null, - activeTurnId: null, + turnLifecycle: { kind: "idle" }, activeModel: null, activeReasoningEffort: null, activeCollaborationMode: "default", @@ -191,9 +193,7 @@ export function createChatState(): ChatState { requestedServiceTier: null, tokenUsage: null, rateLimit: null, - busy: false, displayItems: [], - pendingTurnStart: null, turnDiffs: new Map(), approvals: [], pendingUserInputs: [], @@ -256,7 +256,7 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { return patchChatState(state, { activeThreadId: action.thread.id, activeThreadCwd: action.cwd, - activeTurnId: null, + turnLifecycle: { kind: "idle" }, activeModel: action.model, activeReasoningEffort: action.reasoningEffort, activeServiceTier: action.serviceTier, @@ -283,17 +283,15 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { case "turn/started": return patchChatState(state, { activeThreadId: action.threadId, - activeTurnId: action.turnId, - busy: true, + turnLifecycle: { kind: "running", turnId: action.turnId }, status: "Turn running...", displayItems: action.displayItems ?? state.displayItems, - pendingTurnStart: action.pendingTurnStart === undefined ? state.pendingTurnStart : action.pendingTurnStart, }); case "turn/completed": + if (activeTurnId(state) !== action.turnId) return state; return patchChatState(state, { + turnLifecycle: { kind: "idle" }, displayItems: action.displayItems, - busy: false, - activeTurnId: null, status: `Turn ${action.status}.`, }); case "request/approval-queued": @@ -397,27 +395,32 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { return clearActiveTurnState(state); case "turn/optimistic-started": return patchChatState(state, { + turnLifecycle: { kind: "starting", pendingTurnStart: action.pendingTurnStart }, displayItems: [...state.displayItems, action.item], - pendingTurnStart: action.pendingTurnStart, - busy: true, }); case "turn/start-acknowledged": - return patchChatState(state, { activeTurnId: action.turnId, displayItems: action.displayItems, pendingTurnStart: null }); + if (state.turnLifecycle.kind === "idle") return state; + if (state.turnLifecycle.kind === "running" && state.turnLifecycle.turnId !== action.turnId) return state; + return patchChatState(state, { + turnLifecycle: { kind: "running", turnId: action.turnId }, + displayItems: action.displayItems, + }); case "turn/start-failed": - return patchChatState(state, { busy: false, displayItems: action.displayItems, pendingTurnStart: action.pendingTurnStart }); + return patchChatState(state, { + turnLifecycle: { kind: "idle" }, + displayItems: action.displayItems, + }); case "display/pending-turn-item-upserted": return patchChatState(state, { displayItems: upsertDisplayItem(state.displayItems, action.item), - pendingTurnStart: action.pendingTurnStart, + turnLifecycle: withPendingTurnStart(state.turnLifecycle, action.pendingTurnStart), }); } } export function clearActiveTurnState(state: ChatState): ChatState { return patchChatState(state, { - activeTurnId: null, - busy: false, - pendingTurnStart: null, + turnLifecycle: { kind: "idle" }, approvals: [], pendingUserInputs: [], userInputDrafts: new Map(), @@ -500,6 +503,23 @@ function resolveRequest(state: ChatState, requestId: PendingApproval["requestId" }); } +export function chatTurnBusy(state: Pick): boolean { + return state.turnLifecycle.kind !== "idle"; +} + +export function activeTurnId(state: Pick): string | null { + return state.turnLifecycle.kind === "running" ? state.turnLifecycle.turnId : null; +} + +export function pendingTurnStart(state: Pick): PendingTurnStart | null { + return state.turnLifecycle.kind === "starting" ? state.turnLifecycle.pendingTurnStart : null; +} + +function withPendingTurnStart(lifecycle: ChatTurnLifecycleState, nextPendingTurnStart: PendingTurnStart | null): ChatTurnLifecycleState { + if (nextPendingTurnStart) return { kind: "starting", pendingTurnStart: nextPendingTurnStart }; + return lifecycle.kind === "starting" ? { kind: "idle" } : lifecycle; +} + function updatedTurnDiffs(turnDiffs: ReadonlyMap, turnId: string, diff: string): ReadonlyMap { const next = new Map(turnDiffs); if (diff.trim().length > 0) { diff --git a/src/features/chat/thread-actions.ts b/src/features/chat/thread-actions.ts index 2e4c82cf..fa8a39e5 100644 --- a/src/features/chat/thread-actions.ts +++ b/src/features/chat/thread-actions.ts @@ -5,7 +5,7 @@ import { exportArchivedThreadMarkdown } from "../../domain/threads/export"; import { inheritedForkThreadName, upsertThread } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; import type { ArchiveExportAdapter } from "../../domain/threads/export"; -import type { ChatAction, ChatState, ChatStateStore } from "./chat-state"; +import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state"; import type { ThreadHistoryLoader } from "./thread-history"; import { rollbackCandidateFromItems } from "./rollback"; @@ -40,7 +40,7 @@ export class ChatThreadActionController { } async archiveThread(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise { - if (this.state.busy) { + if (chatTurnBusy(this.state)) { this.host.addSystemMessage("Finish or interrupt the current turn before archiving threads."); return; } @@ -65,7 +65,7 @@ export class ChatThreadActionController { } async forkThread(threadId: string): Promise { - if (this.state.busy) { + if (chatTurnBusy(this.state)) { this.host.addSystemMessage("Finish or interrupt the current turn before forking threads."); return; } @@ -98,7 +98,7 @@ export class ChatThreadActionController { } async rollbackThread(threadId: string): Promise { - if (this.state.busy) { + if (chatTurnBusy(this.state)) { this.host.addSystemMessage("Interrupt the current turn before rolling back."); return; } diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 89f66ef9..3e002f27 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -53,7 +53,14 @@ import type { CodexPanelSettings } from "../../settings/model"; import { questionDefaultAnswer, type PendingUserInput } from "./user-input/model"; import { ChatComposerController } from "./chat-composer-controller"; import { attachHookRunsToTurn } from "./hook-display"; -import { createChatStateStore, type ChatAction, type ChatState } from "./chat-state"; +import { + activeTurnId, + chatTurnBusy, + createChatStateStore, + pendingTurnStart as pendingTurnStartForState, + type ChatAction, + type ChatState, +} from "./chat-state"; import { codexPanelDisplayTitle, explicitThreadName, getThreadTitle, upsertThread } from "../../domain/threads/model"; import { referencedThreadDisplay, @@ -152,7 +159,7 @@ export class CodexChatView extends ItemView { stateStore: this.chatState, viewId: this.viewId, sendShortcut: () => this.plugin.settings.sendShortcut, - canInterrupt: () => this.state.busy && Boolean(this.state.activeThreadId && this.state.activeTurnId), + canInterrupt: () => this.turnBusy && Boolean(this.state.activeThreadId && this.activeTurnId), composerPlaceholder: () => this.composerPlaceholder(), currentModelForSuggestions: () => currentModel(this.runtimeSnapshot()), renderIfDetached: () => { @@ -299,6 +306,18 @@ export class CodexChatView extends ItemView { return this.chatState.getState(); } + private get turnBusy(): boolean { + return chatTurnBusy(this.state); + } + + private get activeTurnId(): string | null { + return activeTurnId(this.state); + } + + private get pendingTurnStart(): ReturnType { + return pendingTurnStartForState(this.state); + } + private dispatch(action: ChatAction): void { this.chatState.dispatch(action); } @@ -369,8 +388,8 @@ export class CodexChatView extends ItemView { return { viewId: this.viewId, threadId: this.closing ? null : this.state.activeThreadId, - busy: this.state.busy, - activeTurnId: this.state.activeTurnId, + busy: this.turnBusy, + activeTurnId: this.activeTurnId, pendingApprovals: this.state.approvals.length, pendingUserInputs: this.state.pendingUserInputs.length, hasComposerDraft: this.state.composerDraft.trim().length > 0, @@ -529,7 +548,7 @@ export class CodexChatView extends ItemView { } async startNewThread(): Promise { - if (this.state.busy) return; + if (this.turnBusy) return; this.invalidateResumeWork(); this.restoredThread = null; @@ -578,7 +597,7 @@ export class CodexChatView extends ItemView { } private async resumeThread(threadId: string): Promise { - if (this.state.busy && threadId !== this.state.activeThreadId) { + if (this.turnBusy && threadId !== this.state.activeThreadId) { this.addSystemMessage("Finish or interrupt the current turn before switching threads."); return; } @@ -686,7 +705,7 @@ export class CodexChatView extends ItemView { const client = this.client; if (!client) return; - if (this.state.busy) { + if (this.turnBusy) { await this.steerCurrentTurn(text, codexInputOverride, referencedThread); return; } @@ -726,31 +745,35 @@ export class CodexChatView extends ItemView { this.render(); const response = await client.startTurn(activeThreadId, this.plugin.vaultPath, codexInput); - const pendingTurnStart = this.state.pendingTurnStart; - let displayItems = this.state.displayItems.map((item) => - item.id === optimisticUserId ? { ...item, turnId: response.turn.id } : item, - ); - if (pendingTurnStart) { - displayItems = attachHookRunsToTurn( - displayItems, - response.turn.id, - pendingTurnStart.promptSubmitHookItemIds, - pendingTurnStart.anchorItemId, + const pendingTurnStart = this.pendingTurnStart; + const currentTurnId = this.activeTurnId; + const responseMatchesCurrentStart = + pendingTurnStart?.anchorItemId === optimisticUserId || (!pendingTurnStart && currentTurnId === response.turn.id); + if (responseMatchesCurrentStart) { + let displayItems = this.state.displayItems.map((item) => + item.id === optimisticUserId ? { ...item, turnId: response.turn.id } : item, ); + if (pendingTurnStart) { + displayItems = attachHookRunsToTurn( + displayItems, + response.turn.id, + pendingTurnStart.promptSubmitHookItemIds, + pendingTurnStart.anchorItemId, + ); + } + this.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems }); + this.setStatus("Turn running..."); } - this.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems }); - this.setStatus("Turn running..."); } catch (error) { let displayItems = optimisticUserId ? this.state.displayItems.filter((item) => item.id !== optimisticUserId) : this.state.displayItems; - let pendingTurnStart = this.state.pendingTurnStart; - if (this.state.pendingTurnStart) { - const hookIds = new Set(this.state.pendingTurnStart.promptSubmitHookItemIds); + const pendingTurnStart = this.pendingTurnStart; + if (pendingTurnStart) { + const hookIds = new Set(pendingTurnStart.promptSubmitHookItemIds); displayItems = displayItems.filter((item) => !hookIds.has(item.id)); - pendingTurnStart = null; } - this.dispatch({ type: "turn/start-failed", displayItems, pendingTurnStart }); + this.dispatch({ type: "turn/start-failed", displayItems }); this.composerController.setDraft(text); this.addSystemMessage(error instanceof Error ? error.message : String(error)); } @@ -762,13 +785,13 @@ export class CodexChatView extends ItemView { codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay, ): Promise { - if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) { + if (!this.client || !this.state.activeThreadId || !this.activeTurnId) { this.addSystemMessage("Current turn is not steerable yet."); return; } const threadId = this.state.activeThreadId; - const expectedTurnId = this.state.activeTurnId; + const expectedTurnId = this.activeTurnId; const codexInput = codexInputOverride ?? this.composerController.codexInput(text); const mentionedFiles = fileMentionsFromInput(codexInput); @@ -811,9 +834,9 @@ export class CodexChatView extends ItemView { } private async interruptTurn(): Promise { - if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) return; + if (!this.client || !this.state.activeThreadId || !this.activeTurnId) return; try { - await this.client.interruptTurn(this.state.activeThreadId, this.state.activeTurnId); + await this.client.interruptTurn(this.state.activeThreadId, this.activeTurnId); this.setStatus("Interrupt requested."); } catch (error) { this.addSystemMessage(error instanceof Error ? error.message : String(error)); @@ -822,7 +845,7 @@ export class CodexChatView extends ItemView { private async submitComposerAction(): Promise { const draft = this.composerController.trimmedDraft; - if (this.state.busy && this.state.activeThreadId && this.state.activeTurnId && draft.length === 0) { + if (this.turnBusy && this.state.activeThreadId && this.activeTurnId && draft.length === 0) { await this.interruptTurn(); return; } @@ -843,7 +866,7 @@ export class CodexChatView extends ItemView { await this.client?.compactThread(threadId); }, archiveThread: (threadId) => this.threadActions.archiveThread(threadId), - busy: this.state.busy, + busy: this.turnBusy, toggleFastMode: () => this.toggleFastMode(), toggleCollaborationMode: () => this.toggleCollaborationMode(), toggleAutoReview: () => void this.toggleAutoReview(), @@ -968,7 +991,7 @@ export class CodexChatView extends ItemView { private canImplementPlanItem(item: DisplayItem): boolean { if (item.kind !== "message" || item.role !== "assistant" || item.proposedPlan !== true) return false; - if (!this.state.activeThreadId || this.state.busy || this.state.composerDraft.trim().length > 0) return false; + if (!this.state.activeThreadId || this.turnBusy || this.state.composerDraft.trim().length > 0) return false; if (this.state.requestedCollaborationMode !== "plan") return false; return latestProposedPlanItem(this.state.displayItems)?.id === item.id; } @@ -1180,9 +1203,9 @@ export class CodexChatView extends ItemView { private readonly toolbarSnapshot = (state: ChatState): ChatPanelSlotSnapshot => signatureParts( state.status, - state.busy, + chatTurnBusy(state), state.activeThreadId, - state.activeTurnId, + activeTurnId(state), state.activeModel, state.activeReasoningEffort, state.activeCollaborationMode, @@ -1208,11 +1231,11 @@ export class CodexChatView extends ItemView { private readonly messagesSnapshot = (state: ChatState): ChatPanelSlotSnapshot => signatureParts( state.activeThreadId, - state.activeTurnId, + activeTurnId(state), state.activeThreadCwd, state.historyCursor, state.loadingHistory, - state.busy, + chatTurnBusy(state), state.messagesPinnedToBottom, state.composerDraft, state.requestedCollaborationMode, @@ -1225,9 +1248,9 @@ export class CodexChatView extends ItemView { private readonly composerSnapshot = (state: ChatState): ChatPanelSlotSnapshot => signatureParts( state.composerDraft, - state.busy, + chatTurnBusy(state), state.activeThreadId, - state.activeTurnId, + activeTurnId(state), currentModel(this.runtimeSnapshotForState(state), readRuntimeConfig(state.effectiveConfig)), state.availableSkills.length, skillsSignature(state.availableSkills), @@ -1281,7 +1304,7 @@ export class CodexChatView extends ItemView { void this.refreshThreads(); }, resumeThread: (threadId) => { - if (this.state.busy && threadId !== this.state.activeThreadId) return; + if (this.turnBusy && threadId !== this.state.activeThreadId) return; this.dispatch({ type: "ui/panel-set", panel: null }); void this.selectThread(threadId); }, @@ -1311,7 +1334,7 @@ export class CodexChatView extends ItemView { const historyOpen = this.state.openDetails.has("history"); const statusPanelOpen = this.state.openDetails.has("status-panel"); const runtimeOpen = this.state.runtimePicker !== null; - const statusState = this.state.busy ? "running" : this.connection.isConnected() ? "connected" : "offline"; + const statusState = this.turnBusy ? "running" : this.connection.isConnected() ? "connected" : "offline"; const model = currentModel(snapshot, config); const effort = currentReasoningEffort(snapshot, config); const threads = this.state.listedThreads; @@ -1338,7 +1361,7 @@ export class CodexChatView extends ItemView { title: getThreadTitle(thread), threadId, selected: threadId === this.state.activeThreadId, - disabled: this.state.busy && threadId !== this.state.activeThreadId, + disabled: this.turnBusy && threadId !== this.state.activeThreadId, canArchive: true, archiveConfirm: { active: this.archiveConfirmThreadId === threadId, @@ -1416,7 +1439,7 @@ export class CodexChatView extends ItemView { } private async selectThread(threadId: string): Promise { - if (this.state.busy && threadId !== this.state.activeThreadId) { + if (this.turnBusy && threadId !== this.state.activeThreadId) { this.addSystemMessage("Finish or interrupt the current turn before switching threads."); return; } diff --git a/tests/features/chat/chat-controller.test.ts b/tests/features/chat/chat-controller.test.ts index 7801a0ee..0df9ab23 100644 --- a/tests/features/chat/chat-controller.test.ts +++ b/tests/features/chat/chat-controller.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it, vi } from "vitest"; import { ChatController } from "../../../src/features/chat/chat-controller"; import { attachHookRunsToTurn } from "../../../src/features/chat/hook-display"; -import { chatReducer, createChatState, type ChatAction, type ChatState, type ChatStateStore } from "../../../src/features/chat/chat-state"; +import { + activeTurnId, + chatReducer, + chatTurnBusy, + createChatState, + pendingTurnStart, + type ChatAction, + type ChatState, + type ChatStateStore, +} from "../../../src/features/chat/chat-state"; import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification"; import type { ServerRequest } from "../../../src/generated/app-server/ServerRequest"; import type { Thread } from "../../../src/generated/app-server/v2/Thread"; @@ -49,7 +58,7 @@ describe("ChatController", () => { it("applies matching streaming deltas as assistant markdown", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -63,8 +72,7 @@ describe("ChatController", () => { it("marks active reasoning completed when assistant text starts", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; - state.busy = true; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; state.displayItems = [{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "turn-active" }]; const controller = controllerForState(state); @@ -84,7 +92,7 @@ describe("ChatController", () => { it("streams plan deltas as plain assistant text until completion", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -98,7 +106,7 @@ describe("ChatController", () => { it("updates structured turn plan progress", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -139,7 +147,7 @@ describe("ChatController", () => { it("stores the latest aggregated turn diff for the active turn", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -157,7 +165,7 @@ describe("ChatController", () => { it("ignores aggregated turn diffs outside the active scope", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -175,7 +183,7 @@ describe("ChatController", () => { it("formats hook runs as compact summaries with details", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -228,7 +236,7 @@ describe("ChatController", () => { it("omits hook duration details while duration is unavailable", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -271,8 +279,7 @@ describe("ChatController", () => { it("attaches unscoped hook runs to the active turn while streaming", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; - state.busy = true; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -305,8 +312,7 @@ describe("ChatController", () => { it("leaves non-prompt unscoped hook runs outside the active turn", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; - state.busy = true; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -340,7 +346,7 @@ describe("ChatController", () => { it("keeps repeated hook runs with the same run id as separate display items", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); const baseRun: Extract["params"]["run"] = { id: "hook-1", @@ -374,7 +380,10 @@ describe("ChatController", () => { it("attaches pre-turn prompt submit hook runs when the turn starts", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] }; + state.turnLifecycle = { + kind: "starting", + pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] }, + }; state.displayItems = [ { id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true }, { @@ -407,7 +416,7 @@ describe("ChatController", () => { expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); expect(state.displayItems[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" }); - expect(state.pendingTurnStart).toBeNull(); + expect(pendingTurnStart(state)).toBeNull(); }); it("moves pre-turn hook runs after the optimistic user message when a turn id is assigned", () => { @@ -444,7 +453,7 @@ describe("ChatController", () => { it("captures only prompt-submit hooks observed during the pending turn start", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] }; + state.turnLifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } }; const controller = controllerForState(state); controller.handleNotification({ @@ -473,13 +482,13 @@ describe("ChatController", () => { expect(expectPresent(state.displayItems[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" }); expect(expectPresent(state.displayItems[0]).turnId).toBeUndefined(); - expect(expectPresent(state.pendingTurnStart).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); + expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); }); it("keeps pre-turn prompt submit hooks through turn start and completed-turn reconciliation", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] }; + state.turnLifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } }; state.displayItems = [{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true }]; const controller = controllerForState(state); @@ -490,7 +499,7 @@ describe("ChatController", () => { expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); expect(expectPresent(state.displayItems[1]).turnId).toBeUndefined(); - expect(expectPresent(state.pendingTurnStart).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); + expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); controller.handleNotification({ method: "turn/started", @@ -512,7 +521,7 @@ describe("ChatController", () => { expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); expect(state.displayItems.find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId"); expect(state.displayItems.find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active" }); - expect(state.pendingTurnStart).toBeNull(); + expect(pendingTurnStart(state)).toBeNull(); controller.handleNotification({ method: "turn/completed", @@ -545,6 +554,51 @@ describe("ChatController", () => { expect(state.displayItems.some((item) => item.id === "local-user-1")).toBe(false); }); + it("ignores completed turn notifications while a new turn is still starting", () => { + const state = createChatState(); + state.activeThreadId = "thread-active"; + state.turnLifecycle = { + kind: "starting", + pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] }, + }; + state.displayItems = [ + { id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true }, + { + id: "hook-hook-1-1", + kind: "hook", + role: "tool", + text: "userPromptSubmit: Saving jj baseline", + toolLabel: "hook", + status: "completed", + }, + ]; + const maybeNameThread = vi.fn(); + const refreshThreads = vi.fn(); + const controller = controllerForState(state, { maybeNameThread, refreshThreads }); + + controller.handleNotification({ + method: "turn/completed", + params: { + threadId: "thread-active", + turn: { + id: "stale-turn", + status: "completed", + startedAt: 1, + completedAt: 2, + durationMs: 1, + error: null, + itemsView: "full", + items: [{ type: "agentMessage", id: "a1", text: "stale", phase: "final_answer", memoryCitation: null }], + }, + }, + } satisfies Extract); + + expect(pendingTurnStart(state)).toEqual({ anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] }); + expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); + expect(maybeNameThread).not.toHaveBeenCalled(); + expect(refreshThreads).not.toHaveBeenCalled(); + }); + it("stores account rate limit updates outside thread scope", () => { const state = createChatState(); const publishSessionMetadata = vi.fn(); @@ -740,7 +794,7 @@ describe("ChatController", () => { it("rejects server requests scoped to a different active thread or turn", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const rejectServerRequest = vi.fn(() => true); const controller = controllerForState(state, { rejectServerRequest }); @@ -855,7 +909,7 @@ describe("ChatController", () => { it("clears all active-thread scoped state when the active thread is archived", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; state.activeModel = "gpt-5.5"; state.activeServiceTier = "fast"; state.activeThreadCreationCliVersion = "codex-cli 1.0.0"; @@ -869,7 +923,6 @@ describe("ChatController", () => { state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "stale" }]; state.turnDiffs = new Map([["turn-active", "@@\n-stale\n+stale"]]); state.composerDraft = "keep local draft"; - state.busy = true; state.approvals = [ { requestId: 10, @@ -902,7 +955,7 @@ describe("ChatController", () => { } satisfies Extract); expect(state.activeThreadId).toBeNull(); - expect(state.activeTurnId).toBeNull(); + expect(activeTurnId(state)).toBeNull(); expect(state.activeModel).toBeNull(); expect(state.activeServiceTier).toBeNull(); expect(state.activeThreadCreationCliVersion).toBeNull(); @@ -912,7 +965,7 @@ describe("ChatController", () => { expect(state.displayItems).toEqual([]); expect(state.turnDiffs.size).toBe(0); expect(state.composerDraft).toBe("keep local draft"); - expect(state.busy).toBe(false); + expect(chatTurnBusy(state)).toBe(false); expect(state.approvals).toEqual([]); expect(state.pendingUserInputs).toEqual([]); expect(state.userInputDrafts.size).toBe(0); @@ -949,7 +1002,7 @@ describe("ChatController", () => { it("replaces optimistic user echoes when completed turns are reconciled", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; state.displayItems = [ { id: "local-user-1", kind: "message", role: "user", text: "hello", turnId: "turn-active", markdown: true }, { @@ -994,7 +1047,7 @@ describe("ChatController", () => { it("asks the view to auto-name completed turns", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const maybeNameThread = vi.fn(); const controller = controllerForState(state, { maybeNameThread }); const turn: Turn = { @@ -1171,7 +1224,7 @@ describe("ChatController", () => { it("renders auto approval review notifications as upserted review results", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -1218,7 +1271,7 @@ describe("ChatController", () => { it("replaces guardian auto-review warnings when structured auto-review notifications arrive", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ @@ -1252,7 +1305,7 @@ describe("ChatController", () => { it("ignores guardian auto-review warnings after structured auto-review notifications", () => { const state = createChatState(); state.activeThreadId = "thread-active"; - state.activeTurnId = "turn-active"; + state.turnLifecycle = { kind: "running", turnId: "turn-active" }; const controller = controllerForState(state); controller.handleNotification({ diff --git a/tests/features/chat/chat-state-reducer.test.ts b/tests/features/chat/chat-state-reducer.test.ts index 0052119e..3db06edc 100644 --- a/tests/features/chat/chat-state-reducer.test.ts +++ b/tests/features/chat/chat-state-reducer.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it, vi } from "vitest"; -import { chatReducer, createChatState, createChatStateStore, type ChatState } from "../../../src/features/chat/chat-state"; +import { + activeTurnId, + chatReducer, + chatTurnBusy, + createChatState, + createChatStateStore, + pendingTurnStart, + type ChatState, +} from "../../../src/features/chat/chat-state"; import type { DisplayItem } from "../../../src/features/chat/display/types"; import type { Thread } from "../../../src/generated/app-server/v2/Thread"; @@ -8,7 +16,7 @@ describe("chatReducer", () => { it("clears active turn and thread state without dropping local composer draft", () => { const state = createChatState(); state.activeThreadId = "thread"; - state.activeTurnId = "turn"; + state.turnLifecycle = { kind: "running", turnId: "turn" }; state.activeModel = "gpt-5.1"; state.historyCursor = "cursor"; state.loadingHistory = true; @@ -23,7 +31,7 @@ describe("chatReducer", () => { expect(next).not.toBe(state); expect(next.activeThreadId).toBeNull(); - expect(next.activeTurnId).toBeNull(); + expect(activeTurnId(next)).toBeNull(); expect(next.displayItems).toEqual([]); expect(next.turnDiffs.size).toBe(0); expect(next.historyCursor).toBeNull(); @@ -70,6 +78,85 @@ describe("chatReducer", () => { expect(second).toBe(first); }); + it("keeps turn lifecycle fields synchronized through start and completion", () => { + const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: [] }; + const optimisticItem = { id: "local-user", kind: "message", role: "user", text: "hello" } satisfies DisplayItem; + const acknowledgedItem = { ...optimisticItem, turnId: "turn" } satisfies DisplayItem; + + const optimistic = chatReducer(createChatState(), { + type: "turn/optimistic-started", + item: optimisticItem, + pendingTurnStart: pending, + }); + expect(chatTurnBusy(optimistic)).toBe(true); + expect(activeTurnId(optimistic)).toBeNull(); + expect(pendingTurnStart(optimistic)).toEqual(pending); + + const running = chatReducer(optimistic, { + type: "turn/start-acknowledged", + turnId: "turn", + displayItems: [acknowledgedItem], + }); + expect(chatTurnBusy(running)).toBe(true); + expect(activeTurnId(running)).toBe("turn"); + expect(pendingTurnStart(running)).toBeNull(); + + const completed = chatReducer(running, { + type: "turn/completed", + turnId: "turn", + status: "completed", + displayItems: [acknowledgedItem], + }); + expect(chatTurnBusy(completed)).toBe(false); + expect(activeTurnId(completed)).toBeNull(); + expect(pendingTurnStart(completed)).toBeNull(); + }); + + it("clears running state when a turn start fails", () => { + const state = createChatState(); + state.turnLifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] } }; + + const next = chatReducer(state, { type: "turn/start-failed", displayItems: [] }); + + expect(chatTurnBusy(next)).toBe(false); + expect(activeTurnId(next)).toBeNull(); + expect(pendingTurnStart(next)).toBeNull(); + }); + + it("ignores turn start acknowledgements after the turn has already gone idle", () => { + const state = createChatState(); + state.turnLifecycle = { kind: "idle" }; + + const next = chatReducer(state, { + type: "turn/start-acknowledged", + turnId: "completed-turn", + displayItems: [{ id: "local-user", kind: "message", role: "user", text: "hello", markdown: true, turnId: "completed-turn" }], + }); + + expect(next).toBe(state); + expect(chatTurnBusy(next)).toBe(false); + expect(activeTurnId(next)).toBeNull(); + }); + + it("ignores completed turns while a new turn is still starting", () => { + const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] }; + const state = createChatState(); + state.turnLifecycle = { kind: "starting", pendingTurnStart: pending }; + state.displayItems = [{ id: "local-user", kind: "message", role: "user", text: "hello", markdown: true }]; + + const next = chatReducer(state, { + type: "turn/completed", + turnId: "stale-turn", + status: "completed", + displayItems: [], + }); + + expect(next).toBe(state); + expect(chatTurnBusy(next)).toBe(true); + expect(pendingTurnStart(next)).toEqual(pending); + expect(next.displayItems).toEqual(state.displayItems); + }); + it("keeps toolbar panels mutually exclusive", () => { let state = createChatState(); diff --git a/tests/features/chat/ui/message-stream.test.tsx b/tests/features/chat/ui/message-stream.test.tsx index 9b8afbc5..37804e60 100644 --- a/tests/features/chat/ui/message-stream.test.tsx +++ b/tests/features/chat/ui/message-stream.test.tsx @@ -776,7 +776,7 @@ describe("message stream block identity and message actions", () => { } as const; const baseState = { activeThreadId: "thread", - busy: false, + turnLifecycle: { kind: "idle" as const }, composerDraft: "", requestedCollaborationMode: "plan" as const, displayItems: [firstPlan, { id: "a1", kind: "message", role: "assistant", text: "answer" } as const, secondPlan], @@ -785,7 +785,7 @@ describe("message stream block identity and message actions", () => { expect(implementPlanCandidateFromState(baseState)).toBe(secondPlan); expect(implementPlanCandidateFromState({ ...baseState, requestedCollaborationMode: "default" })).toBeNull(); expect(implementPlanCandidateFromState({ ...baseState, composerDraft: "edit first" })).toBeNull(); - expect(implementPlanCandidateFromState({ ...baseState, busy: true })).toBeNull(); + expect(implementPlanCandidateFromState({ ...baseState, turnLifecycle: { kind: "running", turnId: "turn-2" } })).toBeNull(); }); it("does not render copy actions for tool items", () => { diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx index c4415c49..961fd433 100644 --- a/tests/features/chat/ui/shell.test.tsx +++ b/tests/features/chat/ui/shell.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { act } from "react"; -import { createChatStateStore } from "../../../../src/features/chat/chat-state"; +import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/chat-state"; import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell"; import { installObsidianDomShims } from "./dom-test-helpers"; @@ -123,9 +123,9 @@ function shellRenderers(store: ReturnType) { }, composer: { render: vi.fn((composer: HTMLElement) => { - composer.textContent = store.getState().busy ? "busy" : "ready"; + composer.textContent = chatTurnBusy(store.getState()) ? "busy" : "ready"; }), - snapshot: () => store.getState().busy, + snapshot: () => chatTurnBusy(store.getState()), }, }; } diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 0a924d61..d404b1bb 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_SETTINGS } from "../../../src/settings/model"; import type { CodexChatHost } from "../../../src/features/chat/view"; import { createAppServerDiagnostics } from "../../../src/app-server/compatibility"; import { createChatState, type ChatState } from "../../../src/features/chat/chat-state"; +import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification"; import { notices } from "../../mocks/obsidian"; import { installObsidianDomShims } from "./ui/dom-test-helpers"; @@ -301,6 +302,33 @@ describe("CodexChatView connection lifecycle", () => { expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); }); + it("does not revive a completed turn when the startTurn response arrives late", async () => { + const startTurn = deferred<{ turn: { id: string } }>(); + const client = connectedClient({ + startTurn: vi.fn(() => startTurn.promise), + }); + connectionMock.state.client = client; + const view = await chatView(); + + await view.openThread("thread-1"); + view.setComposerText("hello"); + const submit = (view as unknown as { submitComposerAction: () => Promise }).submitComposerAction(); + await vi.waitFor(() => { + expect(client.startTurn).toHaveBeenCalled(); + }); + + const controller = (view as unknown as { controller: { handleNotification: (notification: ServerNotification) => void } }).controller; + controller.handleNotification(turnStartedNotification("thread-1", "turn-1")); + controller.handleNotification(turnCompletedNotification("thread-1", "turn-1")); + expect((view as unknown as { state: ChatState }).state.turnLifecycle).toEqual({ kind: "idle" }); + + startTurn.resolve({ turn: { id: "turn-1" } }); + await submit; + + expect((view as unknown as { state: ChatState }).state.turnLifecycle).toEqual({ kind: "idle" }); + expect(view.openPanelSnapshot()).toMatchObject({ busy: false, activeTurnId: null }); + }); + it("requests a workspace layout save after resuming a thread", async () => { const requestSaveLayout = vi.fn(); const client = connectedClient(); @@ -775,6 +803,47 @@ function turnWithUserMessage(text: string) { }; } +function turnStartedNotification(threadId: string, turnId: string): Extract { + return { + method: "turn/started", + params: { + threadId, + turn: { + id: turnId, + status: "inProgress", + error: null, + startedAt: 1, + completedAt: null, + durationMs: null, + itemsView: "full", + items: [], + }, + }, + }; +} + +function turnCompletedNotification(threadId: string, turnId: string): Extract { + return { + method: "turn/completed", + params: { + threadId, + turn: { + id: turnId, + status: "completed", + error: null, + startedAt: 1, + completedAt: 2, + durationMs: 1, + itemsView: "full", + items: [ + { type: "userMessage", id: "user-1", content: [{ type: "text", text: "hello", text_elements: [] }] }, + { type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null }, + ], + }, + }, + }; +} + function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void; const promise = new Promise((innerResolve) => {