diff --git a/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts index 15d1e2c625..c15ef90acb 100644 --- a/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts @@ -21,7 +21,7 @@ describe("SdkTaskStartCoordinator", () => { const sessionId = await coordinator.initTask("hello @file", ["image.png"], ["a.ts"]) - expect(sessionId).toBe("session-123") + expect(sessionId).toEqual(expect.any(String)) expect(options.clearTask).toHaveBeenCalledOnce() expect(options.sessionConfigBuilder.build).toHaveBeenCalledWith({ prompt: "hello @file", @@ -32,19 +32,32 @@ describe("SdkTaskStartCoordinator", () => { cwd: "/workspace", mode: "act", }) - expect(state.task?.taskId).toBe("session-123") + expect(options.buildStartSessionInput).toHaveBeenCalledWith( + expect.objectContaining({ providerId: "anthropic", modelId: "model", sessionId }), + expect.objectContaining({ + prompt: "hello @file", + images: ["image.png"], + files: ["a.ts"], + cwd: "/workspace", + mode: "act", + }), + ) + expect(state.task?.taskId).toBe(sessionId) expect(options.taskHistory.updateTaskHistoryItem).toHaveBeenCalledWith( - expect.objectContaining({ id: "session-123", task: "hello @file", modelId: "model" }), + expect.objectContaining({ id: sessionId, task: "hello @file", modelId: "model" }), ) expect(options.messages.appendAndEmit).toHaveBeenCalledWith( [expect.objectContaining({ type: "say", say: "task", text: "hello @file" })], - { type: "status", payload: { sessionId: "session-123", status: "running" } }, + { type: "status", payload: { sessionId, status: "running" } }, ) expect(options.postStateToWebview).toHaveBeenCalledOnce() + expect(options.messages.appendAndEmit.mock.invocationCallOrder[0]).toBeLessThan( + options.sessions.startNewSession.mock.invocationCallOrder[0], + ) expect(options.resolveContextMentions).toHaveBeenCalledWith("hello @file") expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith( expect.objectContaining({ send: expect.any(Function) }), - "session-123", + sessionId, "resolved: hello @file", ["image.png"], ["a.ts"], @@ -72,14 +85,15 @@ describe("SdkTaskStartCoordinator", () => { }) it("emits a plain chat error when session start fails (e.g. provider misconfigured)", async () => { - const { coordinator, options } = makeCoordinator() + const { coordinator, options, state } = makeCoordinator() options.sessions.startNewSession.mockRejectedValue(new Error("No model configured for provider openai")) const sessionId = await coordinator.initTask("do something") expect(sessionId).toBeUndefined() expect(options.emitClineAuthError).not.toHaveBeenCalled() - expect(options.messages.emitSessionEvents).toHaveBeenCalledWith( + expect(state.task?.taskId).toEqual(expect.any(String)) + expect(options.messages.appendAndEmit).toHaveBeenCalledWith( [ expect.objectContaining({ type: "say", @@ -87,8 +101,9 @@ describe("SdkTaskStartCoordinator", () => { text: expect.stringContaining("No model configured for provider openai"), }), ], - { type: "status", payload: { sessionId: "", status: "error" } }, + { type: "status", payload: { sessionId: state.task?.taskId, status: "error" } }, ) + expect(options.postStateToWebview).toHaveBeenCalledOnce() }) it.each([true, false])("forwards task useAutoCondense=%s into SDK session config inputs", async (useAutoCondense) => { @@ -182,10 +197,10 @@ function makeCoordinator(input: Partial = {}) { getGlobalSettingsKey: vi.fn(() => input.mode ?? "act"), } as unknown as StateManager, sessions: { - startNewSession: vi.fn().mockResolvedValue({ - startResult: { sessionId: "session-123" }, + startNewSession: vi.fn((startInput?: { config?: { sessionId?: string } }) => ({ + startResult: { sessionId: startInput?.config?.sessionId ?? "session-123" }, sdkHost, - }), + })), fireAndForgetSend: vi.fn(), }, messages: { diff --git a/apps/vscode/src/sdk/sdk-task-start-coordinator.ts b/apps/vscode/src/sdk/sdk-task-start-coordinator.ts index f23317f47e..8a19847f92 100644 --- a/apps/vscode/src/sdk/sdk-task-start-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-task-start-coordinator.ts @@ -1,4 +1,5 @@ import { getProviderAuthStorageId } from "@cline/core" +import { createSessionId } from "@cline/shared" import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount" import type { ClineMessage } from "@shared/ExtensionMessage" import type { HistoryItem } from "@shared/HistoryItem" @@ -65,6 +66,7 @@ export class SdkTaskStartCoordinator { taskSettings?: Partial, ): Promise { Logger.log(`[SdkController] initTask called: "${prompt?.substring(0, 50)}"`) + let taskSessionId: string | undefined try { await this.options.clearTask() @@ -93,7 +95,13 @@ export class SdkTaskStartCoordinator { return undefined } - const startInput = this.options.buildStartSessionInput(config, { + taskSessionId = config.sessionId?.trim() || createSessionId() + const configWithSessionId = { + ...config, + sessionId: taskSessionId, + } + + const startInput = this.options.buildStartSessionInput(configWithSessionId, { prompt: prompt, images, files, @@ -103,30 +111,40 @@ export class SdkTaskStartCoordinator { mode, }) + const task = this.createAndSetTask(taskSessionId) + this.emitInitialTaskMessage(taskSessionId, prompt ?? "") + const { startResult, sdkHost } = await this.options.sessions.startNewSession(startInput) - this.createAndSetTask(startResult.sessionId) + if (startResult.sessionId !== taskSessionId) { + Logger.warn( + `[SdkController] SDK returned session id ${startResult.sessionId} after requested id ${taskSessionId}`, + ) + task.taskId = startResult.sessionId + taskSessionId = startResult.sessionId + } const newHistoryItem = this.options.createHistoryItemFromSession( - startResult.sessionId, + taskSessionId, prompt ?? "", - config.modelId, + configWithSessionId.modelId, cwd, ) await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem) - - this.emitInitialTaskMessage(startResult.sessionId, prompt ?? "") await this.options.postStateToWebview() if (prompt?.trim()) { - Logger.log(`[SdkController] Sending prompt to session: ${startResult.sessionId}`) + Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`) const resolvedTask = await this.options.resolveContextMentions(prompt) - this.options.sessions.fireAndForgetSend(sdkHost, startResult.sessionId, resolvedTask, images, files) + this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files) } - Logger.log(`[SdkController] Task initialized: ${startResult.sessionId}`) - return startResult.sessionId + Logger.log(`[SdkController] Task initialized: ${taskSessionId}`) + return taskSessionId } catch (error) { - this.handleInitError(error) + this.handleInitError(error, taskSessionId) + await this.options.postStateToWebview().catch((postError) => { + Logger.error("[SdkController] Failed to post state after init error:", postError) + }) return undefined } } @@ -172,14 +190,14 @@ export class SdkTaskStartCoordinator { return m === "plan" ? m : "act" } - private createAndSetTask(sessionId: string): void { - this.options.setTask( - createTaskProxy( - sessionId, - (text?: string, images?: string[], files?: string[]) => this.options.onAskResponse(text, images, files), - () => this.options.onCancelTask(), - ), + private createAndSetTask(sessionId: string): TaskProxy { + const task = createTaskProxy( + sessionId, + (text?: string, images?: string[], files?: string[]) => this.options.onAskResponse(text, images, files), + () => this.options.onCancelTask(), ) + this.options.setTask(task) + return task } private emitInitialTaskMessage(sessionId: string, task: string): void { @@ -196,13 +214,13 @@ export class SdkTaskStartCoordinator { }) } - private handleInitError(error: unknown): void { + private handleInitError(error: unknown, sessionId?: string): void { const errorDetails = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack?.substring(0, 500)}` : String(error) Logger.error(`[SdkController] Failed to init task: ${errorDetails}`) ;(globalThis as Record).__cline_last_init_error = errorDetails ;(globalThis as Record).__cline_last_init_error_raw = error - this.options.messages.emitSessionEvents( + this.options.messages.appendAndEmit( [ { ts: Date.now(), @@ -212,7 +230,7 @@ export class SdkTaskStartCoordinator { partial: false, }, ], - { type: "status", payload: { sessionId: "", status: "error" } }, + { type: "status", payload: { sessionId: sessionId ?? "", status: "error" } }, ) } diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.test.tsx b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.test.tsx index 848575c8fc..735d73e358 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.test.tsx +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.test.tsx @@ -43,9 +43,9 @@ import type { ChatState } from "../types/chatTypes" import { useMessageHandlers } from "./useMessageHandlers" // Minimal ChatState stub. clineAsk/lastMessage are the only derived values the send path reads. -function makeChatState(messages: ClineMessage[]): ChatState { +function makeChatState(messages: ClineMessage[], overrides: Partial = {}): ChatState { const last = messages.at(-1) - return { + const state = { inputValue: "", setInputValue: vi.fn(), activeQuote: null, @@ -75,6 +75,7 @@ function makeChatState(messages: ClineMessage[]): ChatState { clearExpandedRows: vi.fn(), resetState: vi.fn(), } as unknown as ChatState + return { ...state, ...overrides } as ChatState } const completedConversation: ClineMessage[] = [ @@ -84,9 +85,12 @@ const completedConversation: ClineMessage[] = [ describe("useMessageHandlers — send routing", () => { beforeEach(() => { - newTask.mockClear() - askResponse.mockClear() - condense.mockClear() + newTask.mockReset() + newTask.mockResolvedValue(undefined) + askResponse.mockReset() + askResponse.mockResolvedValue(undefined) + condense.mockReset() + condense.mockResolvedValue(undefined) mockTurnState = undefined }) @@ -168,6 +172,60 @@ describe("useMessageHandlers — send routing", () => { expect(askResponse).not.toHaveBeenCalled() }) + it("restores pending new-task UI state when the RPC fails", async () => { + mockTurnState = { phase: "idle", seq: 1 } + const error = new Error("transport down") + const setInputValue = vi.fn() + const setActiveQuote = vi.fn() + const setSendingDisabled = vi.fn() + const setSelectedImages = vi.fn() + const setSelectedFiles = vi.fn() + const setEnableButtons = vi.fn() + const chatState = makeChatState([], { + activeQuote: "selected context", + sendingDisabled: false, + enableButtons: true, + setInputValue, + setActiveQuote, + setSendingDisabled, + setSelectedImages, + setSelectedFiles, + setEnableButtons, + }) + const { result } = renderHook(() => useMessageHandlers([], chatState)) + newTask.mockRejectedValueOnce(error) + + let caught: unknown + await act(async () => { + try { + await result.current.handleSendMessage("brand new task", ["image.png"], ["a.ts"]) + } catch (err) { + caught = err + } + }) + + expect(caught).toBe(error) + expect(newTask).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("selected context"), + images: ["image.png"], + files: ["a.ts"], + }), + ) + expect(setInputValue).toHaveBeenNthCalledWith(1, "") + expect(setInputValue).toHaveBeenLastCalledWith("brand new task") + expect(setActiveQuote).toHaveBeenNthCalledWith(1, null) + expect(setActiveQuote).toHaveBeenLastCalledWith("selected context") + expect(setSendingDisabled).toHaveBeenNthCalledWith(1, true) + expect(setSendingDisabled).toHaveBeenLastCalledWith(false) + expect(setSelectedImages).toHaveBeenNthCalledWith(1, []) + expect(setSelectedImages).toHaveBeenLastCalledWith(["image.png"]) + expect(setSelectedFiles).toHaveBeenNthCalledWith(1, []) + expect(setSelectedFiles).toHaveBeenLastCalledWith(["a.ts"]) + expect(setEnableButtons).toHaveBeenNthCalledWith(1, false) + expect(setEnableButtons).toHaveBeenLastCalledWith(true) + }) + // The webview does not gate sends on provider usability: submission always // reaches the extension, which surfaces auth/config problems as chat errors // (emitClineAuthError for the Cline provider, say:"error" otherwise). diff --git a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts index 237e7ac102..78362afa8d 100644 --- a/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts +++ b/apps/vscode/webview-ui/src/components/chat/chat-view/hooks/useMessageHandlers.ts @@ -19,7 +19,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat setActiveQuote, setSelectedImages, setSelectedFiles, + sendingDisabled, setSendingDisabled, + enableButtons, setEnableButtons, clineAsk, lastMessage, @@ -61,15 +63,36 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat if (hasContent) { console.log("[ChatView] handleSendMessage - Sending message:", messageToSend) let messageSent = false + const clearSentMessageState = () => { + setInputValue("") + setActiveQuote(null) + setSendingDisabled(true) + setSelectedImages([]) + setSelectedFiles([]) + setEnableButtons(false) + } + const restorePendingMessageState = () => { + setInputValue(text) + setActiveQuote(activeQuote) + setSendingDisabled(sendingDisabled) + setSelectedImages(images) + setSelectedFiles(files) + setEnableButtons(enableButtons) + } if (messages.length === 0) { - await TaskServiceClient.newTask( - NewTaskRequest.create({ - text: messageToSend, - images, - files, - }), - ) + const request = NewTaskRequest.create({ + text: messageToSend, + images, + files, + }) + clearSentMessageState() + try { + await TaskServiceClient.newTask(request) + } catch (error) { + restorePendingMessageState() + throw error + } messageSent = true } else if (clineAsk) { // For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior @@ -147,14 +170,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat } } - // Only clear input and disable UI if message was actually sent + // New tasks clear optimistically before the RPC; the repeated success cleanup is idempotent. if (messageSent) { - setInputValue("") - setActiveQuote(null) - setSendingDisabled(true) - setSelectedImages([]) - setSelectedFiles([]) - setEnableButtons(false) + clearSentMessageState() // Reset auto-scroll if ("disableAutoScrollRef" in chatState) { @@ -170,9 +188,11 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat activeQuote, setInputValue, setActiveQuote, + sendingDisabled, setSendingDisabled, setSelectedImages, setSelectedFiles, + enableButtons, setEnableButtons, chatState, ],