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 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..879dc3e23ce 100644 --- a/cli/src/state/atoms/effects.ts +++ b/cli/src/state/atoms/effects.ts @@ -13,8 +13,9 @@ import { updateRouterModelsAtom, chatMessagesAtom, updateChatMessagesAtom, + taskResumedViaContinueOrSessionAtom, } from "./extension.js" -import { ciCompletionDetectedAtom } from "./ci.js" +import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "./ci.js" import { updateProfileDataAtom, updateBalanceDataAtom, @@ -611,11 +612,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..b3229d44292 --- /dev/null +++ b/cli/src/state/hooks/__tests__/useCIMode.test.tsx @@ -0,0 +1,126 @@ +/** + * 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]) + await new Promise((resolve) => setTimeout(resolve, 0)) + 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..2e01991f286 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" @@ -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() @@ -61,12 +62,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) @@ -77,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() @@ -164,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 } @@ -177,10 +182,21 @@ export const UI: React.FC = ({ options, onExit }) => { if (trimmedPrompt) { logs.debug("Executing initial prompt", "UI", { prompt: trimmedPrompt }) - // Determine if it's a command or regular message + // 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) + } + + // 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 @@ -212,9 +228,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) } @@ -224,14 +250,20 @@ export const UI: React.FC = ({ options, onExit }) => { }, [ options.prompt, options.attachments, + options.ci, taskResumedViaSession, hasActiveTask, configValidation.valid, executeCommand, sendUserMessage, sendMessage, + sendAskResponse, addMessage, onExit, + lastChatMessage, + setCiCompletionIgnoreBeforeTimestamp, + setTaskResumedViaSession, + setCiCompletionDetected, ]) // Simplified submit handler that delegates to appropriate hook 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) +}