diff --git a/apps/cli/src/__tests__/store.test.ts b/apps/cli/src/__tests__/store.test.ts new file mode 100644 index 0000000000..2cd628df2e --- /dev/null +++ b/apps/cli/src/__tests__/store.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { useCLIStore } from "../ui/store.js" + +describe("useCLIStore", () => { + beforeEach(() => { + // Reset store to initial state before each test + useCLIStore.getState().reset() + }) + + describe("initialState", () => { + it("should have isResumingTask set to false initially", () => { + const state = useCLIStore.getState() + expect(state.isResumingTask).toBe(false) + }) + + it("should have empty messages array initially", () => { + const state = useCLIStore.getState() + expect(state.messages).toEqual([]) + }) + + it("should have empty taskHistory initially", () => { + const state = useCLIStore.getState() + expect(state.taskHistory).toEqual([]) + }) + }) + + describe("setIsResumingTask", () => { + it("should set isResumingTask to true", () => { + useCLIStore.getState().setIsResumingTask(true) + expect(useCLIStore.getState().isResumingTask).toBe(true) + }) + + it("should set isResumingTask to false", () => { + useCLIStore.getState().setIsResumingTask(true) + useCLIStore.getState().setIsResumingTask(false) + expect(useCLIStore.getState().isResumingTask).toBe(false) + }) + }) + + describe("reset", () => { + it("should reset all state to initial values", () => { + // Set some state first + const store = useCLIStore.getState() + store.addMessage({ id: "1", role: "user", content: "test" }) + store.setTaskHistory([{ id: "task1", task: "test", workspace: "/test", ts: Date.now() }]) + store.setAvailableModes([{ slug: "code", name: "Code" }]) + store.setAllSlashCommands([{ name: "test", source: "global" as const }]) + store.setIsResumingTask(true) + store.setLoading(true) + store.setHasStartedTask(true) + + // Reset + useCLIStore.getState().reset() + + // Verify all state is reset + const resetState = useCLIStore.getState() + expect(resetState.messages).toEqual([]) + expect(resetState.taskHistory).toEqual([]) + expect(resetState.availableModes).toEqual([]) + expect(resetState.allSlashCommands).toEqual([]) + expect(resetState.isResumingTask).toBe(false) + expect(resetState.isLoading).toBe(false) + expect(resetState.hasStartedTask).toBe(false) + }) + }) + + describe("resetForTaskSwitch", () => { + it("should clear task-specific state", () => { + // Set up task-specific state + const store = useCLIStore.getState() + store.addMessage({ id: "1", role: "user", content: "test" }) + store.setLoading(true) + store.setComplete(true) + store.setHasStartedTask(true) + store.setError("some error") + store.setIsResumingTask(true) + store.setTokenUsage({ + totalTokensIn: 100, + totalTokensOut: 50, + totalCost: 0.01, + contextTokens: 0, + totalCacheReads: 0, + totalCacheWrites: 0, + }) + store.setTodos([{ id: "1", content: "test todo", status: "pending" }]) + + // Reset for task switch + useCLIStore.getState().resetForTaskSwitch() + + // Verify task-specific state is cleared + const resetState = useCLIStore.getState() + expect(resetState.messages).toEqual([]) + expect(resetState.pendingAsk).toBeNull() + expect(resetState.isLoading).toBe(false) + expect(resetState.isComplete).toBe(false) + expect(resetState.hasStartedTask).toBe(false) + expect(resetState.error).toBeNull() + expect(resetState.isResumingTask).toBe(false) + expect(resetState.tokenUsage).toBeNull() + expect(resetState.currentTodos).toEqual([]) + expect(resetState.previousTodos).toEqual([]) + }) + + it("should PRESERVE taskHistory", () => { + const taskHistory = [ + { id: "task1", task: "test task 1", workspace: "/test", ts: Date.now() }, + { id: "task2", task: "test task 2", workspace: "/test", ts: Date.now() }, + ] + useCLIStore.getState().setTaskHistory(taskHistory) + + useCLIStore.getState().resetForTaskSwitch() + + expect(useCLIStore.getState().taskHistory).toEqual(taskHistory) + }) + + it("should PRESERVE availableModes", () => { + const modes = [ + { slug: "code", name: "Code", description: "Code mode" }, + { slug: "architect", name: "Architect", description: "Architect mode" }, + ] + useCLIStore.getState().setAvailableModes(modes) + + useCLIStore.getState().resetForTaskSwitch() + + expect(useCLIStore.getState().availableModes).toEqual(modes) + }) + + it("should PRESERVE allSlashCommands", () => { + const commands = [ + { name: "new", description: "New task", source: "global" as const }, + { name: "help", description: "Get help", source: "built-in" as const }, + ] + useCLIStore.getState().setAllSlashCommands(commands) + + useCLIStore.getState().resetForTaskSwitch() + + expect(useCLIStore.getState().allSlashCommands).toEqual(commands) + }) + + it("should PRESERVE fileSearchResults", () => { + const results = [ + { path: "file1.ts", type: "file" as const }, + { path: "file2.ts", type: "file" as const }, + ] + useCLIStore.getState().setFileSearchResults(results) + + useCLIStore.getState().resetForTaskSwitch() + + expect(useCLIStore.getState().fileSearchResults).toEqual(results) + }) + + it("should PRESERVE currentMode", () => { + useCLIStore.getState().setCurrentMode("architect") + + useCLIStore.getState().resetForTaskSwitch() + + expect(useCLIStore.getState().currentMode).toBe("architect") + }) + + it("should PRESERVE routerModels", () => { + const models = { openai: { "gpt-4": { contextWindow: 128000 } } } + useCLIStore.getState().setRouterModels(models) + + useCLIStore.getState().resetForTaskSwitch() + + expect(useCLIStore.getState().routerModels).toEqual(models) + }) + + it("should PRESERVE apiConfiguration", () => { + const config = { apiProvider: "openai", apiModelId: "gpt-4" } + useCLIStore + .getState() + .setApiConfiguration(config as ReturnType["apiConfiguration"]) + + useCLIStore.getState().resetForTaskSwitch() + + expect(useCLIStore.getState().apiConfiguration).toEqual(config) + }) + }) + + describe("task resumption flow", () => { + it("should support the full task resumption workflow", () => { + const store = useCLIStore.getState + + // Step 1: Initial state with task history and modes from webviewDidLaunch + store().setTaskHistory([{ id: "task1", task: "Previous task", workspace: "/test", ts: Date.now() }]) + store().setAvailableModes([{ slug: "code", name: "Code" }]) + store().setAllSlashCommands([{ name: "new", source: "global" as const }]) + + // Step 2: User starts a new task + store().setHasStartedTask(true) + store().addMessage({ id: "1", role: "user", content: "New task" }) + store().addMessage({ id: "2", role: "assistant", content: "Working on it..." }) + store().setLoading(true) + + // Verify current state + expect(store().messages.length).toBe(2) + expect(store().hasStartedTask).toBe(true) + + // Step 3: User selects a task from history to resume + // This triggers resetForTaskSwitch + setIsResumingTask(true) + store().resetForTaskSwitch() + store().setIsResumingTask(true) + + // Verify task-specific state is cleared but global state preserved + expect(store().messages).toEqual([]) + expect(store().isLoading).toBe(false) + expect(store().hasStartedTask).toBe(false) + expect(store().isResumingTask).toBe(true) // Flag is set + expect(store().taskHistory.length).toBe(1) // Preserved + expect(store().availableModes.length).toBe(1) // Preserved + expect(store().allSlashCommands.length).toBe(1) // Preserved + + // Step 4: Extension sends state message with clineMessages + // (simulated by adding messages) + store().addMessage({ id: "old1", role: "user", content: "Previous task prompt" }) + store().addMessage({ id: "old2", role: "assistant", content: "Previous response" }) + + // Step 5: After processing state, isResumingTask should be cleared + store().setIsResumingTask(false) + + // Final verification + expect(store().isResumingTask).toBe(false) + expect(store().messages.length).toBe(2) + expect(store().taskHistory.length).toBe(1) // Still preserved + }) + + it("should allow reading isResumingTask synchronously during message processing", () => { + const store = useCLIStore.getState + + // Set the flag + store().setIsResumingTask(true) + + // Simulate synchronous read during message processing + const isResuming = store().isResumingTask + expect(isResuming).toBe(true) + + // The handler can use this to decide whether to skip messages + if (!isResuming) { + // Would skip first text message for new tasks + } else { + // Would NOT skip first text message for resumed tasks + } + + // After processing, clear the flag + store().setIsResumingTask(false) + expect(store().isResumingTask).toBe(false) + }) + }) + + describe("difference between reset and resetForTaskSwitch", () => { + it("should show that reset clears everything while resetForTaskSwitch preserves global state", () => { + const store = useCLIStore.getState + + // Set up both task-specific and global state + store().addMessage({ id: "1", role: "user", content: "test" }) + store().setTaskHistory([{ id: "t1", task: "task", workspace: "/", ts: Date.now() }]) + store().setAvailableModes([{ slug: "code", name: "Code" }]) + + // Use resetForTaskSwitch + store().resetForTaskSwitch() + + // Task-specific cleared, global preserved + expect(store().messages).toEqual([]) + expect(store().taskHistory.length).toBe(1) + expect(store().availableModes.length).toBe(1) + + // Now use reset() + store().reset() + + // Everything cleared + expect(store().messages).toEqual([]) + expect(store().taskHistory).toEqual([]) + expect(store().availableModes).toEqual([]) + }) + }) +}) diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index da4c979616..38fc6b23a1 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -193,6 +193,8 @@ function AppInner({ setAllSlashCommands, setAvailableModes, setTaskHistory, + currentTaskId, + setCurrentTaskId, currentMode, setCurrentMode, tokenUsage, @@ -567,6 +569,7 @@ function AppInner({ const handleSayMessage = useCallback( (ts: number, say: SayType, text: string, partial: boolean) => { const messageId = ts.toString() + const isResuming = useCLIStore.getState().isResumingTask if (say === "checkpoint_saved") { return @@ -580,7 +583,9 @@ function AppInner({ return } - if (say === "text" && !firstTextMessageSkipped.current) { + // Skip first text message ONLY for new tasks, not resumed tasks + // When resuming, we want to show all historical messages including the first one + if (say === "text" && !firstTextMessageSkipped.current && !isResuming) { firstTextMessageSkipped.current = true seenMessageIds.current.add(messageId) return @@ -691,6 +696,9 @@ function AppInner({ // Mark that a task has been started so subsequent messages continue the task // (instead of starting a brand new task via runTask) setHasStartedTask(true) + // Clear the resuming flag since we're now ready for interaction + // Historical messages should already be displayed from state processing + useCLIStore.getState().setIsResumingTask(false) // Do not set pendingAsk - let the normal text input appear return } @@ -839,6 +847,12 @@ function AppInner({ setTokenUsage(metrics) } } + + // After processing state, clear the resuming flag if it was set + // This ensures the flag is cleared even if no resume_task ask message is received + if (useCLIStore.getState().isResumingTask) { + useCLIStore.getState().setIsResumingTask(false) + } } else if (msg.type === "messageUpdated") { const clineMessage = msg.clineMessage as Record if (!clineMessage) return @@ -1171,20 +1185,37 @@ function AppInner({ return } + // If selecting the same task that's already loaded, just close the picker + if (historyItem.id === currentTaskId) { + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + return + } + // Send showTaskWithId message to extension to resume the task if (hostRef.current) { - // Reset CLI state before resuming task - useCLIStore.getState().reset() + // Use selective reset that preserves global state (taskHistory, modes, commands) + useCLIStore.getState().resetForTaskSwitch() + // Set the resuming flag so message handlers know we're resuming + // This prevents skipping the first text message (which is historical) + useCLIStore.getState().setIsResumingTask(true) + // Track which task we're switching to + setCurrentTaskId(historyItem.id) + // Reset refs to avoid stale state across task switches seenMessageIds.current.clear() firstTextMessageSkipped.current = false // Send message to resume the selected task + // This triggers createTaskWithHistoryItem -> postStateToWebview + // which includes clineMessages and handles mode restoration hostRef.current.sendToExtension({ type: "showTaskWithId", text: historyItem.id }) - // Re-request state, commands and modes since reset() cleared them - hostRef.current.sendToExtension({ type: "webviewDidLaunch" }) - hostRef.current.sendToExtension({ type: "requestCommands" }) - hostRef.current.sendToExtension({ type: "requestModes" }) + // DON'T send these redundant requests - they cause race conditions: + // - showTaskWithId already triggers postStateToWebview which includes everything + // - resetForTaskSwitch preserves taskHistory, modes, and commands + // hostRef.current.sendToExtension({ type: "webviewDidLaunch" }) + // hostRef.current.sendToExtension({ type: "requestCommands" }) + // hostRef.current.sendToExtension({ type: "requestModes" }) } // Close the picker @@ -1196,7 +1227,7 @@ function AppInner({ followupAutocompleteRef.current?.handleItemSelect(item) } }, - [pickerState.activeTrigger, isLoading, showInfo], + [pickerState.activeTrigger, isLoading, showInfo, currentTaskId, setCurrentTaskId], ) // Handle picker close from external PickerSelect diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index f0675d9a58..f79d5c34cb 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -35,6 +35,10 @@ interface CLIState { hasStartedTask: boolean error: string | null + // Task resumption flag - true when resuming a task from history + // Used to modify message processing behavior (e.g., don't skip first text message) + isResumingTask: boolean + // Autocomplete data (from API/extension) fileSearchResults: FileSearchResult[] allSlashCommands: SlashCommandResult[] @@ -43,6 +47,9 @@ interface CLIState { // Task history (for resuming previous tasks) taskHistory: TaskHistoryItem[] + // Current task ID (for detecting same-task reselection) + currentTaskId: string | null + // Current mode (updated reactively when mode changes) currentMode: string | null @@ -70,6 +77,10 @@ interface CLIActions { setHasStartedTask: (started: boolean) => void setError: (error: string | null) => void reset: () => void + /** Reset for task switching - preserves global state (taskHistory, modes, commands) */ + resetForTaskSwitch: () => void + /** Set the isResumingTask flag - used when resuming a task from history */ + setIsResumingTask: (isResuming: boolean) => void // Autocomplete data actions setFileSearchResults: (results: FileSearchResult[]) => void @@ -79,6 +90,9 @@ interface CLIActions { // Task history action setTaskHistory: (history: TaskHistoryItem[]) => void + // Current task ID action + setCurrentTaskId: (taskId: string | null) => void + // Current mode action setCurrentMode: (mode: string | null) => void @@ -98,10 +112,12 @@ const initialState: CLIState = { isComplete: false, hasStartedTask: false, error: null, + isResumingTask: false, fileSearchResults: [], allSlashCommands: [], availableModes: [], taskHistory: [], + currentTaskId: null, currentMode: null, tokenUsage: null, routerModels: null, @@ -160,10 +176,36 @@ export const useCLIStore = create((set) => ({ setHasStartedTask: (started) => set({ hasStartedTask: started }), setError: (error) => set({ error }), reset: () => set(initialState), + resetForTaskSwitch: () => + set((state) => ({ + // Clear task-specific state + messages: [], + pendingAsk: null, + isLoading: false, + isComplete: false, + hasStartedTask: false, + error: null, + isResumingTask: false, + tokenUsage: null, + currentTodos: [], + previousTodos: [], + // currentTaskId is preserved - will be updated to new task ID by caller + currentTaskId: state.currentTaskId, + // PRESERVE global state - don't clear these + taskHistory: state.taskHistory, + availableModes: state.availableModes, + allSlashCommands: state.allSlashCommands, + fileSearchResults: state.fileSearchResults, + currentMode: state.currentMode, + routerModels: state.routerModels, + apiConfiguration: state.apiConfiguration, + })), + setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }), setFileSearchResults: (results) => set({ fileSearchResults: results }), setAllSlashCommands: (commands) => set({ allSlashCommands: commands }), setAvailableModes: (modes) => set({ availableModes: modes }), setTaskHistory: (history) => set({ taskHistory: history }), + setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }), setCurrentMode: (mode) => set({ currentMode: mode }), setTokenUsage: (usage) => set({ tokenUsage: usage }), setRouterModels: (models) => set({ routerModels: models }),