From 3f7df00b0dc71c3612f40b9f1ab4a662d82e52d4 Mon Sep 17 00:00:00 2001 From: syn Date: Sun, 18 Jan 2026 14:33:15 -0600 Subject: [PATCH 1/5] Fix session restore race --- cli/src/cli.ts | 7 + .../__tests__/effects-ci-completion.test.ts | 71 ++++++++++ cli/src/state/atoms/ci.ts | 6 + cli/src/state/atoms/effects.ts | 19 ++- .../state/hooks/__tests__/useCIMode.test.tsx | 125 ++++++++++++++++++ cli/src/state/hooks/useCIMode.ts | 29 +++- cli/src/ui/UI.tsx | 19 ++- 7 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 cli/src/state/atoms/__tests__/effects-ci-completion.test.ts create mode 100644 cli/src/state/hooks/__tests__/useCIMode.test.tsx diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 0a1120df5a0..2ef0f1b4739 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -278,8 +278,15 @@ export class CLI { logs.debug("SessionManager workspace directory set", "CLI", { workspace }) if (this.options.session) { + // Set flag BEFORE restoring session to prevent race condition + // The session restoration triggers async state updates that may contain + // historical completion_result messages. Without this flag set first, + // the CI exit logic may trigger before the prompt can execute. + this.store.set(taskResumedViaContinueOrSessionAtom, true) await this.sessionService?.restoreSession(this.options.session) } else if (this.options.fork) { + // Set flag BEFORE forking session (same race condition as restore) + this.store.set(taskResumedViaContinueOrSessionAtom, true) logs.info("Forking session from share ID", "CLI", { shareId: this.options.fork }) await this.sessionService?.forkSession(this.options.fork) } diff --git a/cli/src/state/atoms/__tests__/effects-ci-completion.test.ts b/cli/src/state/atoms/__tests__/effects-ci-completion.test.ts new file mode 100644 index 00000000000..6b338ba6375 --- /dev/null +++ b/cli/src/state/atoms/__tests__/effects-ci-completion.test.ts @@ -0,0 +1,71 @@ +/** + * Tests for CI completion detection in effects.ts + */ + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { createStore } from "jotai" +import { messageHandlerEffectAtom } from "../effects.js" +import { extensionServiceAtom } from "../service.js" +import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "../ci.js" +import { taskResumedViaContinueOrSessionAtom } from "../extension.js" +import type { ExtensionMessage, ExtensionChatMessage } from "../../../types/messages.js" +import type { ExtensionService } from "../../../services/extension.js" + +describe("CI completion detection in effects", () => { + let store: ReturnType + + beforeEach(() => { + store = createStore() + + const mockService: Partial = { + initialize: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } + store.set(extensionServiceAtom, mockService as ExtensionService) + }) + + it("skips completion detection when session was resumed", () => { + const completionMessage: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "completion_result", + text: "Task completed", + } + + const stateMessage: ExtensionMessage = { + type: "state", + state: { + chatMessages: [completionMessage], + } as ExtensionMessage["state"], + } + + store.set(taskResumedViaContinueOrSessionAtom, true) + store.set(messageHandlerEffectAtom, stateMessage) + + expect(store.get(ciCompletionDetectedAtom)).toBe(false) + }) + + it("uses the ignore timestamp to skip historical completion_result", () => { + const historicalTs = Date.now() + const completionMessage: ExtensionChatMessage = { + ts: historicalTs, + type: "ask", + ask: "completion_result", + text: "Task completed", + } + + const stateMessage: ExtensionMessage = { + type: "state", + state: { + chatMessages: [completionMessage], + } as ExtensionMessage["state"], + } + + store.set(ciCompletionIgnoreBeforeTimestampAtom, historicalTs) + store.set(messageHandlerEffectAtom, stateMessage) + + expect(store.get(ciCompletionDetectedAtom)).toBe(false) + }) +}) diff --git a/cli/src/state/atoms/ci.ts b/cli/src/state/atoms/ci.ts index fcc882929b7..57346314f04 100644 --- a/cli/src/state/atoms/ci.ts +++ b/cli/src/state/atoms/ci.ts @@ -20,6 +20,11 @@ export const ciTimeoutAtom = atom(undefined) */ export const ciCompletionDetectedAtom = atom(false) +/** + * Ignore completion_result messages at or before this timestamp + */ +export const ciCompletionIgnoreBeforeTimestampAtom = atom(0) + /** * Atom to track if command/message execution has finished */ @@ -45,6 +50,7 @@ export const resetCIStateAtom = atom(null, (get, set) => { set(ciModeAtom, false) set(ciTimeoutAtom, undefined) set(ciCompletionDetectedAtom, false) + set(ciCompletionIgnoreBeforeTimestampAtom, 0) set(ciCommandFinishedAtom, false) set(ciExitReasonAtom, null) }) diff --git a/cli/src/state/atoms/effects.ts b/cli/src/state/atoms/effects.ts index 03e036a1a1d..72ad75287fe 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -14,7 +14,7 @@ import { chatMessagesAtom, updateChatMessagesAtom, } from "./extension.js" -import { ciCompletionDetectedAtom } from "./ci.js" +import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "./ci.js" import { updateProfileDataAtom, updateBalanceDataAtom, @@ -611,11 +611,22 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension if (message.state?.chatMessages) { const lastMessage = message.state.chatMessages[message.state.chatMessages.length - 1] if (lastMessage?.type === "ask" && lastMessage?.ask === "completion_result") { - logs.info("Completion result detected in state update", "effects") + const completionIgnoreBeforeTimestamp = get(ciCompletionIgnoreBeforeTimestampAtom) + const taskResumedViaSession = get(taskResumedViaContinueOrSessionAtom) + const isHistoricalCompletion = + lastMessage.ts !== undefined && lastMessage.ts <= completionIgnoreBeforeTimestamp + + // Skip completion detection if session was just restored via --session or --continue + // The historical completion_result from the previous task should not trigger CI exit + if (taskResumedViaSession || isHistoricalCompletion) { + logs.debug("Skipping completion_result detection - historical completion_result", "effects") + } else { + logs.info("Completion result detected in state update", "effects") - set(ciCompletionDetectedAtom, true) + set(ciCompletionDetectedAtom, true) - SessionManager.init()?.doSync(true) + SessionManager.init()?.doSync(true) + } } } } catch (error) { diff --git a/cli/src/state/hooks/__tests__/useCIMode.test.tsx b/cli/src/state/hooks/__tests__/useCIMode.test.tsx new file mode 100644 index 00000000000..1792fb331f0 --- /dev/null +++ b/cli/src/state/hooks/__tests__/useCIMode.test.tsx @@ -0,0 +1,125 @@ +/** + * Tests for useCIMode hook behavior + */ + +import React from "react" +import { describe, it, expect, beforeEach, vi } from "vitest" +import { createStore } from "jotai" +import { Provider } from "jotai" +import { render } from "ink-testing-library" +import { useCIMode } from "../useCIMode.js" +import { chatMessagesAtom, taskResumedViaContinueOrSessionAtom } from "../../atoms/extension.js" +import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom, ciExitReasonAtom } from "../../atoms/ci.js" +import type { ExtensionChatMessage } from "../../../types/messages.js" + +vi.mock("../../../services/logs.js", () => ({ + logs: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +const noop = () => {} + +const TestComponent = ({ enabled }: { enabled: boolean }) => { + useCIMode({ enabled, onExit: noop }) + return null +} + +describe("useCIMode", () => { + let store: ReturnType + + beforeEach(() => { + store = createStore() + }) + + it("skips historical completion_result after session resume", async () => { + const completionMessage: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "completion_result", + text: "Completed", + } + + store.set(taskResumedViaContinueOrSessionAtom, true) + store.set(ciCompletionIgnoreBeforeTimestampAtom, completionMessage.ts) + store.set(chatMessagesAtom, [completionMessage]) + + const { unmount } = render( + + + , + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(store.get(ciExitReasonAtom)).toBeNull() + + unmount() + }) + + it("exits on completion_result when not ignored", async () => { + const completionMessage: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "completion_result", + text: "Completed", + } + + store.set(taskResumedViaContinueOrSessionAtom, false) + store.set(ciCompletionIgnoreBeforeTimestampAtom, 0) + store.set(chatMessagesAtom, [completionMessage]) + + const { unmount } = render( + + + , + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(store.get(ciExitReasonAtom)).toBe("completion_result") + + unmount() + }) + + it("exits when a new completion_result arrives after the ignore timestamp", async () => { + const historicalTs = Date.now() + const historicalMessage: ExtensionChatMessage = { + ts: historicalTs, + type: "ask", + ask: "completion_result", + text: "Completed", + } + + store.set(taskResumedViaContinueOrSessionAtom, false) + store.set(ciCompletionIgnoreBeforeTimestampAtom, historicalTs) + store.set(chatMessagesAtom, [historicalMessage]) + + const { unmount } = render( + + + , + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.get(ciExitReasonAtom)).toBeNull() + + const newMessage: ExtensionChatMessage = { + ts: historicalTs + 1000, + type: "ask", + ask: "completion_result", + text: "Completed again", + } + + store.set(chatMessagesAtom, [historicalMessage, newMessage]) + store.set(ciCompletionDetectedAtom, true) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.get(ciExitReasonAtom)).toBe("completion_result") + + unmount() + }) +}) diff --git a/cli/src/state/hooks/useCIMode.ts b/cli/src/state/hooks/useCIMode.ts index bcea886f940..ff89e58c1c0 100644 --- a/cli/src/state/hooks/useCIMode.ts +++ b/cli/src/state/hooks/useCIMode.ts @@ -5,7 +5,13 @@ import { useAtomValue, useSetAtom } from "jotai" import { useEffect, useState, useCallback, useRef } from "react" -import { ciCompletionDetectedAtom, ciCommandFinishedAtom, ciExitReasonAtom } from "../atoms/ci.js" +import { + ciCompletionDetectedAtom, + ciCompletionIgnoreBeforeTimestampAtom, + ciCommandFinishedAtom, + ciExitReasonAtom, +} from "../atoms/ci.js" +import { taskResumedViaContinueOrSessionAtom } from "../atoms/extension.js" import { useExtensionMessage } from "./useExtensionMessage.js" import { logs } from "../../services/logs.js" @@ -67,8 +73,9 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn { const { enabled, timeout } = options const completionDetected = useAtomValue(ciCompletionDetectedAtom) + const completionIgnoreBeforeTimestamp = useAtomValue(ciCompletionIgnoreBeforeTimestampAtom) const commandFinished = useAtomValue(ciCommandFinishedAtom) - + const taskResumedViaSession = useAtomValue(taskResumedViaContinueOrSessionAtom) // Write atoms const setCommandFinished = useSetAtom(ciCommandFinishedAtom) const setExitReason = useSetAtom(ciExitReasonAtom) @@ -86,10 +93,18 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn { // Get extension messages to monitor for completion_result const { lastMessage } = useExtensionMessage() + const isHistoricalCompletion = lastMessage?.ts !== undefined && lastMessage.ts <= completionIgnoreBeforeTimestamp + // Monitor for completion_result messages useEffect(() => { if (!enabled || !lastMessage || exitTriggeredRef.current) return + // Skip if session was just restored - the completion_result is historical + if (taskResumedViaSession || isHistoricalCompletion) { + logs.debug("CI mode: Skipping completion_result check - historical message", "useCIMode") + return + } + // Check if this is a completion_result message if (lastMessage.type === "ask" && lastMessage.ask === "completion_result") { logs.info("CI mode: completion_result message received", "useCIMode") @@ -98,7 +113,7 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn { setShouldExit(true) exitTriggeredRef.current = true } - }, [enabled, lastMessage]) + }, [enabled, lastMessage, taskResumedViaSession, isHistoricalCompletion]) // Monitor for command finished useEffect(() => { @@ -115,12 +130,18 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn { useEffect(() => { if (!enabled || !completionDetected || exitTriggeredRef.current) return + // Skip if session was just restored - the completion_result is historical + if (taskResumedViaSession || isHistoricalCompletion) { + logs.debug("CI mode: Skipping completion detected atom - historical message", "useCIMode") + return + } + logs.info("CI mode: completion detected via atom", "useCIMode") setLocalExitReason("completion_result") setExitReason("completion_result") setShouldExit(true) exitTriggeredRef.current = true - }, [enabled, completionDetected]) + }, [enabled, completionDetected, taskResumedViaSession, isHistoricalCompletion]) // Setup timeout if provided useEffect(() => { diff --git a/cli/src/ui/UI.tsx b/cli/src/ui/UI.tsx index b80f8add3d6..600601f23d4 100644 --- a/cli/src/ui/UI.tsx +++ b/cli/src/ui/UI.tsx @@ -8,9 +8,9 @@ import { Box, Text } from "ink" import { useAtomValue, useSetAtom } from "jotai" import { isStreamingAtom, errorAtom, addMessageAtom, messageResetCounterAtom, yoloModeAtom } from "../state/atoms/ui.js" import { processImagePaths } from "../media/images.js" -import { setCIModeAtom } from "../state/atoms/ci.js" +import { setCIModeAtom, ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "../state/atoms/ci.js" import { configValidationAtom } from "../state/atoms/config.js" -import { taskResumedViaContinueOrSessionAtom } from "../state/atoms/extension.js" +import { lastChatMessageAtom, taskResumedViaContinueOrSessionAtom } from "../state/atoms/extension.js" import { useTaskState } from "../state/hooks/useTaskState.js" import { isParallelModeAtom } from "../state/atoms/index.js" import { addToHistoryAtom, resetHistoryNavigationAtom, exitHistoryModeAtom } from "../state/atoms/history.js" @@ -61,12 +61,16 @@ export const UI: React.FC = ({ options, onExit }) => { const setCIMode = useSetAtom(setCIModeAtom) const setYoloMode = useSetAtom(yoloModeAtom) const addMessage = useSetAtom(addMessageAtom) + const setTaskResumedViaSession = useSetAtom(taskResumedViaContinueOrSessionAtom) + const setCiCompletionDetected = useSetAtom(ciCompletionDetectedAtom) + const setCiCompletionIgnoreBeforeTimestamp = useSetAtom(ciCompletionIgnoreBeforeTimestampAtom) const addToHistory = useSetAtom(addToHistoryAtom) const resetHistoryNavigation = useSetAtom(resetHistoryNavigationAtom) const exitHistoryMode = useSetAtom(exitHistoryModeAtom) const setIsParallelMode = useSetAtom(isParallelModeAtom) const setWorkspacePath = useSetAtom(workspacePathAtom) const taskResumedViaSession = useAtomValue(taskResumedViaContinueOrSessionAtom) + const lastChatMessage = useAtomValue(lastChatMessageAtom) const { hasActiveTask } = useTaskState() const exitRequestCounter = useAtomValue(exitRequestCounterAtom) @@ -177,6 +181,14 @@ export const UI: React.FC = ({ options, onExit }) => { if (trimmedPrompt) { logs.debug("Executing initial prompt", "UI", { prompt: trimmedPrompt }) + // Clear the session restoration flag after prompt execution starts + if (taskResumedViaSession) { + logs.debug("Resetting session restoration flags after prompt execution", "UI") + setCiCompletionIgnoreBeforeTimestamp(lastChatMessage?.ts ?? Date.now()) + setTaskResumedViaSession(false) + setCiCompletionDetected(false) + } + // Determine if it's a command or regular message if (isCommandInput(trimmedPrompt)) { executeCommand(trimmedPrompt, onExit) @@ -224,6 +236,7 @@ export const UI: React.FC = ({ options, onExit }) => { }, [ options.prompt, options.attachments, + options.ci, taskResumedViaSession, hasActiveTask, configValidation.valid, @@ -232,6 +245,8 @@ export const UI: React.FC = ({ options, onExit }) => { sendMessage, addMessage, onExit, + setTaskResumedViaSession, + setCiCompletionDetected, ]) // Simplified submit handler that delegates to appropriate hook From 72f260ec179507c6d102761cbca27f8d5cdcecfa Mon Sep 17 00:00:00 2001 From: syn Date: Sun, 18 Jan 2026 14:34:39 -0600 Subject: [PATCH 2/5] fix session restore race that causes premature exit when continuing --- cli/src/state/atoms/effects.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/src/state/atoms/effects.ts b/cli/src/state/atoms/effects.ts index 72ad75287fe..879dc3e23ce 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -13,6 +13,7 @@ import { updateRouterModelsAtom, chatMessagesAtom, updateChatMessagesAtom, + taskResumedViaContinueOrSessionAtom, } from "./extension.js" import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "./ci.js" import { From 057a5e988bc7ddfa154e00bff673897840070965 Mon Sep 17 00:00:00 2001 From: syn Date: Sun, 18 Jan 2026 17:12:07 -0600 Subject: [PATCH 3/5] Fix hang --- .../state/hooks/__tests__/useCIMode.test.tsx | 1 + cli/src/ui/UI.tsx | 32 ++++++++--- .../ui/utils/__tests__/resumePrompt.test.ts | 53 +++++++++++++++++++ cli/src/ui/utils/resumePrompt.ts | 23 ++++++++ 4 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 cli/src/ui/utils/__tests__/resumePrompt.test.ts create mode 100644 cli/src/ui/utils/resumePrompt.ts diff --git a/cli/src/state/hooks/__tests__/useCIMode.test.tsx b/cli/src/state/hooks/__tests__/useCIMode.test.tsx index 1792fb331f0..b3229d44292 100644 --- a/cli/src/state/hooks/__tests__/useCIMode.test.tsx +++ b/cli/src/state/hooks/__tests__/useCIMode.test.tsx @@ -115,6 +115,7 @@ describe("useCIMode", () => { } store.set(chatMessagesAtom, [historicalMessage, newMessage]) + await new Promise((resolve) => setTimeout(resolve, 0)) store.set(ciCompletionDetectedAtom, true) await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/cli/src/ui/UI.tsx b/cli/src/ui/UI.tsx index 600601f23d4..fbc1c08c262 100644 --- a/cli/src/ui/UI.tsx +++ b/cli/src/ui/UI.tsx @@ -39,6 +39,7 @@ import { workspacePathAtom } from "../state/atoms/shell.js" import { useTerminal } from "../state/hooks/useTerminal.js" import { exitRequestCounterAtom } from "../state/atoms/keyboard.js" import { useWebviewMessage } from "../state/hooks/useWebviewMessage.js" +import { isResumeAskMessage, shouldWaitForResumeAsk } from "./utils/resumePrompt.js" // Initialize commands on module load initializeCommands() @@ -81,7 +82,7 @@ export const UI: React.FC = ({ options, onExit }) => { }) // Get sendMessage for sending initial prompt with attachments - const { sendMessage } = useWebviewMessage() + const { sendMessage, sendAskResponse } = useWebviewMessage() // Followup handler hook for automatic suggestion population useFollowupHandler() @@ -168,10 +169,10 @@ export const UI: React.FC = ({ options, onExit }) => { // Execute prompt automatically on mount if provided useEffect(() => { if (options.prompt && !promptExecutedRef.current && configValidation.valid) { - // If a session was restored, wait for the task messages to be loaded - // This prevents creating a new task instead of continuing the restored one - if (taskResumedViaSession && !hasActiveTask) { - logs.debug("Waiting for restored session messages to load", "UI") + // If a session was restored, wait for the resume ask to arrive + // This ensures the prompt answers the resume ask instead of sending too early. + if (shouldWaitForResumeAsk(taskResumedViaSession, hasActiveTask, lastChatMessage)) { + logs.debug("Waiting for resume ask before executing prompt", "UI") return } @@ -189,8 +190,10 @@ export const UI: React.FC = ({ options, onExit }) => { setCiCompletionDetected(false) } + const shouldAnswerResumeAsk = taskResumedViaSession && isResumeAskMessage(lastChatMessage) + // Determine if it's a command or regular message - if (isCommandInput(trimmedPrompt)) { + if (isCommandInput(trimmedPrompt) && !shouldAnswerResumeAsk) { executeCommand(trimmedPrompt, onExit) } else { // Check if there are CLI attachments to load @@ -224,9 +227,19 @@ export const UI: React.FC = ({ options, onExit }) => { textLength: trimmedPrompt.length, imageCount: result.images.length, }) - // Send message with loaded images directly using sendMessage - await sendMessage({ type: "newTask", text: trimmedPrompt, images: result.images }) + // Respond to resume ask if present, otherwise start a new task + if (shouldAnswerResumeAsk) { + await sendAskResponse({ + response: "messageResponse", + text: trimmedPrompt, + images: result.images, + }) + } else { + await sendMessage({ type: "newTask", text: trimmedPrompt, images: result.images }) + } })() + } else if (shouldAnswerResumeAsk) { + void sendAskResponse({ response: "messageResponse", text: trimmedPrompt }) } else { sendUserMessage(trimmedPrompt) } @@ -243,8 +256,11 @@ export const UI: React.FC = ({ options, onExit }) => { executeCommand, sendUserMessage, sendMessage, + sendAskResponse, addMessage, onExit, + lastChatMessage, + setCiCompletionIgnoreBeforeTimestamp, setTaskResumedViaSession, setCiCompletionDetected, ]) diff --git a/cli/src/ui/utils/__tests__/resumePrompt.test.ts b/cli/src/ui/utils/__tests__/resumePrompt.test.ts new file mode 100644 index 00000000000..b5d754ccda4 --- /dev/null +++ b/cli/src/ui/utils/__tests__/resumePrompt.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest" +import type { ExtensionChatMessage } from "../../../types/messages.js" +import { isResumeAskMessage, shouldWaitForResumeAsk } from "../resumePrompt.js" + +describe("resumePrompt helpers", () => { + it("detects resume ask messages", () => { + const message: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "resume_task", + text: "Resume?", + } + + expect(isResumeAskMessage(message)).toBe(true) + }) + + it("returns false for non-resume ask messages", () => { + const message: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "completion_result", + text: "Completed", + } + + expect(isResumeAskMessage(message)).toBe(false) + }) + + it("waits when session resumed but no active task yet", () => { + expect(shouldWaitForResumeAsk(true, false, null)).toBe(true) + }) + + it("waits when session resumed and last message is not resume ask", () => { + const message: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "completion_result", + text: "Completed", + } + + expect(shouldWaitForResumeAsk(true, true, message)).toBe(true) + }) + + it("does not wait when resume ask is present", () => { + const message: ExtensionChatMessage = { + ts: Date.now(), + type: "ask", + ask: "resume_completed_task", + text: "Resume completed?", + } + + expect(shouldWaitForResumeAsk(true, true, message)).toBe(false) + }) +}) diff --git a/cli/src/ui/utils/resumePrompt.ts b/cli/src/ui/utils/resumePrompt.ts new file mode 100644 index 00000000000..dce9a0bfc3a --- /dev/null +++ b/cli/src/ui/utils/resumePrompt.ts @@ -0,0 +1,23 @@ +import type { ExtensionChatMessage } from "../../types/messages.js" + +const resumeAskTypes = new Set(["resume_task", "resume_completed_task"]) + +export function isResumeAskMessage(message: ExtensionChatMessage | null): boolean { + return message?.type === "ask" && resumeAskTypes.has(message.ask ?? "") +} + +export function shouldWaitForResumeAsk( + taskResumedViaSession: boolean, + hasActiveTask: boolean, + lastChatMessage: ExtensionChatMessage | null, +): boolean { + if (!taskResumedViaSession) { + return false + } + + if (!hasActiveTask) { + return true + } + + return !isResumeAskMessage(lastChatMessage) +} From c710df7d14bc375c9203ea3285d4fb46ec3c28b2 Mon Sep 17 00:00:00 2001 From: syn Date: Sun, 18 Jan 2026 17:13:40 -0600 Subject: [PATCH 4/5] add changeset --- .changeset/cozy-shirts-try.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cozy-shirts-try.md diff --git a/.changeset/cozy-shirts-try.md b/.changeset/cozy-shirts-try.md new file mode 100644 index 00000000000..297d383876a --- /dev/null +++ b/.changeset/cozy-shirts-try.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +fix session restore race that triggered premature exit From f660a186e8e84065d24f4c07b83040a218b42e30 Mon Sep 17 00:00:00 2001 From: syn Date: Mon, 19 Jan 2026 04:38:56 -0600 Subject: [PATCH 5/5] fix /command broken --- cli/src/ui/UI.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/src/ui/UI.tsx b/cli/src/ui/UI.tsx index fbc1c08c262..2e01991f286 100644 --- a/cli/src/ui/UI.tsx +++ b/cli/src/ui/UI.tsx @@ -190,12 +190,13 @@ export const UI: React.FC = ({ options, onExit }) => { setCiCompletionDetected(false) } - const shouldAnswerResumeAsk = taskResumedViaSession && isResumeAskMessage(lastChatMessage) - - // Determine if it's a command or regular message - if (isCommandInput(trimmedPrompt) && !shouldAnswerResumeAsk) { + // Commands are always executed, regardless of resume ask state + // This ensures /exit, /clear, etc. work correctly even when resuming a session + if (isCommandInput(trimmedPrompt)) { executeCommand(trimmedPrompt, onExit) } else { + const shouldAnswerResumeAsk = taskResumedViaSession && isResumeAskMessage(lastChatMessage) + // Check if there are CLI attachments to load if (options.attachments && options.attachments.length > 0) { // Async IIFE to load attachments and send message