import { WebviewFocusTracker } from "../../webview/WebviewFocusTracker" // npx vitest core/task/__tests__/Task.spec.ts import { DispatchState } from "../dispatchState" import * as fsReal from "fs" import * as os from "os" import * as path from "path" import * as vscode from "vscode" import { Anthropic } from "@anthropic-ai/sdk" import type { Mock } from "vitest" import { providerIdentifiers, RooCodeEventName, type ClineMessage, type GlobalState, type HistoryItem, type ModeConfig, type ProviderSettings, type ModelInfo, type TaskLike, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { MODEL_FETCH_TIMEOUT_MS, Task } from "../Task" import { SYSTEM_PROMPT } from "../../prompts/system" import { createRateLimitClock } from "../RateLimitClock" import { summarizeConversation } from "../../condense" import { getEnvironmentDetails } from "../../environment/getEnvironmentDetails" import { ClineProvider } from "../../webview/ClineProvider" import { ApiStreamChunk } from "../../../api/transform/stream" import { OutputTokenLimitError } from "../../../api/providers/utils/output-token-limit-error" import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" import type { ApiMessage } from "../../task-persistence" import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy" import { asyncStreamFrom } from "../../../test-utils/stream" import { McpHub } from "../../../services/mcp/McpHub" import { McpServerManager } from "../../../services/mcp/McpServerManager" import { readFileTool } from "../../tools/ReadFileTool" import { writeToFileTool } from "../../tools/WriteToFileTool" type TaskTestAccess = { getSystemPrompt: (requestState: ProviderState | undefined, requestModelInfo?: ModelInfo) => Promise handleContextWindowExceededError: (requestModelInfo: ModelInfo) => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise startTask: (task?: string, images?: string[]) => Promise resumeTaskFromHistory: () => Promise presentAssistantMessageSafe: () => void hasCompleteToolResultsForCurrentTurn: () => boolean addToClineMessages: (message: import("@roo-code/types").ClineMessage) => Promise updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise saveClineMessages: () => Promise safeEnsureModelFetched: () => Promise backoffAndAnnounce: (retryAttempt: number, error: unknown) => Promise requestPolicySnapshot?: RequestPolicySnapshot getFilesReadByRooSafely: (context: string) => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise resetAssistantMessagePersistence: () => void buildCleanConversationHistory: ( messages: ApiMessage[], requestModelInfo: ModelInfo, ) => Array<{ role: string; content: unknown } | { type: "reasoning"; encrypted_content: string }> attemptApiRequestWithActiveApi: () => AsyncGenerator condenseContextWithActiveApi: () => Promise retiredApis: Set } type TaskAskResult = Awaited> type ProviderState = Awaited> type MockedClineProvider = ClineProvider & { getState: Mock & { mockResolvedValue: (value: Partial) => void } } type RateLimitedProviderSettings = ProviderSettings & { rateLimitSeconds: number } function getTaskTestAccess(task: Task): TaskTestAccess { return task as unknown as TaskTestAccess } function requireDefined(value: T | null | undefined): T { if (value == null) { throw new Error("Expected test value to be defined") } return value } // Mock delay before any imports that might use it vi.mock("delay", () => ({ __esModule: true, default: vi.fn().mockResolvedValue(undefined), })) import delay from "delay" import pWaitFor from "p-wait-for" import { setAbortLatch } from "../../../test-utils/run-state" vi.mock("uuid", async (importOriginal) => { const actual = await importOriginal() return { ...actual, v7: vi.fn(() => "00000000-0000-7000-8000-000000000000"), } }) vi.mock("execa", () => ({ execa: vi.fn(), })) vi.mock("fs/promises", async (importOriginal) => { const actual = await importOriginal() const mockFunctions = { mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockImplementation((filePath) => { if (filePath.includes("ui_messages.json")) { return Promise.resolve(JSON.stringify(mockMessages)) } if (filePath.includes("api_conversation_history.json")) { return Promise.resolve( JSON.stringify([ { role: "user", content: [{ type: "text", text: "historical task" }], ts: Date.now(), }, { role: "assistant", content: [{ type: "text", text: "I'll help you with that task." }], ts: Date.now(), }, ]), ) } return Promise.resolve("[]") }), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), stat: vi.fn().mockRejectedValue({ code: "ENOENT" }), readdir: vi.fn().mockResolvedValue([]), } return { ...actual, ...mockFunctions, default: mockFunctions, } }) vi.mock("p-wait-for", () => ({ default: vi.fn().mockImplementation(async () => Promise.resolve()), })) // Task tests do not exercise indexing; keep workspace resolution and its cache out of this suite. vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ CodeIndexManagerRegistry: { getOrCreate: vi.fn().mockReturnValue(undefined), getAllInstances: vi.fn().mockReturnValue([]), disposeAll: vi.fn(), }, })) vi.mock("vscode", () => { const mockDisposable = { dispose: vi.fn() } const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } const mockTextEditor = { document: mockTextDocument } const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } const mockTabGroup = { tabs: [mockTab] } return { TabInputTextDiff: vi.fn(), CodeActionKind: { QuickFix: { value: "quickfix" }, RefactorRewrite: { value: "refactor.rewrite" }, }, window: { createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn(), }), visibleTextEditors: [mockTextEditor], tabGroups: { all: [mockTabGroup], close: vi.fn(), onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), }, showErrorMessage: vi.fn(), }, workspace: { workspaceFolders: [ { uri: { fsPath: "/mock/workspace/path" }, name: "mock-workspace", index: 0, }, ], createFileSystemWatcher: vi.fn(() => ({ onDidCreate: vi.fn(() => mockDisposable), onDidDelete: vi.fn(() => mockDisposable), onDidChange: vi.fn(() => mockDisposable), dispose: vi.fn(), })), fs: { stat: vi.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1 }, onDidSaveTextDocument: vi.fn(() => mockDisposable), getConfiguration: vi.fn(() => ({ get: (_key: string, defaultValue: unknown) => defaultValue })), }, env: { uriScheme: "vscode", language: "en", }, EventEmitter: vi.fn().mockImplementation(function () { return mockEventEmitter }), Disposable: { from: vi.fn(), }, TabInputText: vi.fn(), } }) vi.mock("../../mentions", () => ({ parseMentions: vi.fn().mockImplementation((text) => { return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) }), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), })) vi.mock("../../../integrations/misc/extract-text", () => ({ extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), })) vi.mock("../../environment/getEnvironmentDetails", () => ({ getEnvironmentDetails: vi.fn().mockResolvedValue(""), })) vi.mock("../../mentions/processUserContentMentions", async (importOriginal) => { const actual = await importOriginal() return { ...actual, processUserContentMentions: vi.fn().mockImplementation(actual.processUserContentMentions), } }) vi.mock("../../ignore/RooIgnoreController") vi.mock("../../../i18n", () => { return { t: (key: string, args?: Record) => { if (key === "tools:missingToolParameterWithPath") { return `${args?.toolName}|${args?.relPath}|${args?.paramName}` } if (key === "tools:missingToolParameter") { return `${args?.toolName}|${args?.paramName}` } return key }, } }) vi.mock("../../condense", async (importOriginal) => { const actual = await importOriginal() return { ...actual, summarizeConversation: vi.fn().mockResolvedValue({ messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }], summary: "summary", cost: 0, newContextTokens: 1, }), } }) vi.mock("../../prompts/system", async (importOriginal) => { const actual = await importOriginal() return { ...actual, SYSTEM_PROMPT: vi.fn(actual.SYSTEM_PROMPT), } }) // Mock storagePathManager to prevent dynamic import issues. vi.mock("../../../utils/storage", () => ({ getTaskDirectoryPath: vi .fn() .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), getSettingsDirectoryPath: vi .fn() .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), })) vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockImplementation((filePath) => { return filePath.includes("ui_messages.json") || filePath.includes("api_conversation_history.json") }), })) const mockMessages = [ { ts: Date.now(), type: "say", say: "text", text: "historical task", }, ] // Model-info stand-in for tests that stub safeEnsureModelFetched and only need // the settled snapshot to be a defined ModelInfo. const stubModelInfo: ModelInfo = { contextWindow: 200_000, maxTokens: 4096, supportsPromptCache: true, } describe("Cline", () => { let mockProvider: ClineProvider let mockApiConfig: ProviderSettings let mockOutputChannel: vscode.OutputChannel let mockExtensionContext: vscode.ExtensionContext // Builds provider-state doubles on top of the real getState() result // captured before any test stubs it, so required fields stay // compile-checked while each test states only its own overrides. // mcpEnabled defaults to false so doubles skip the MCP-hub path unless a // test opts in. let baseProviderState: ProviderState const providerStateWith = (overrides: Partial = {}): ProviderState => ({ ...baseProviderState, mcpEnabled: false, ...overrides, }) beforeEach(async () => { if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) } // Setup mock extension context const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage"), } mockExtensionContext = { globalState: { get: vi.fn().mockImplementation((key: keyof GlobalState) => { if (key === "taskHistory") { return [ { id: "123", number: 0, ts: Date.now(), task: "historical task", tokensIn: 100, tokensOut: 200, cacheWrites: 0, cacheReads: 0, totalCost: 0.001, }, ] } return undefined }), update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), keys: vi.fn().mockReturnValue([]), }, globalStorageUri: storageUri, workspaceState: { get: vi.fn().mockImplementation((_key) => undefined), update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), keys: vi.fn().mockReturnValue([]), }, secrets: { get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), delete: vi.fn().mockImplementation((_key) => Promise.resolve()), }, extensionUri: { fsPath: "/mock/extension/path", }, extension: { packageJSON: { version: "1.0.0", }, }, } as unknown as vscode.ExtensionContext // Setup mock output channel mockOutputChannel = { name: "test-output", appendLine: vi.fn(), append: vi.fn(), replace: vi.fn(), clear: vi.fn(), show: vi.fn(), hide: vi.fn(), dispose: vi.fn(), } // Setup mock provider with output channel mockProvider = new ClineProvider( mockExtensionContext, mockOutputChannel, "sidebar", new ContextProxy(mockExtensionContext), new WebviewFocusTracker(), ) // Setup mock API configuration mockApiConfig = { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", // Add API key to mock config } // Mock provider methods mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) mockProvider.flushPostStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({ historyItem: { id, ts: Date.now(), task: "historical task", tokensIn: 100, tokensOut: 200, cacheWrites: 0, cacheReads: 0, totalCost: 0.001, }, taskDirPath: "/mock/storage/path/tasks/123", apiConversationHistoryFilePath: "/mock/storage/path/tasks/123/api_conversation_history.json", uiMessagesFilePath: "/mock/storage/path/tasks/123/ui_messages.json", apiConversationHistory: [ { role: "user", content: [{ type: "text", text: "historical task" }], ts: Date.now(), }, { role: "assistant", content: [{ type: "text", text: "I'll help you with that task." }], ts: Date.now(), }, ], })) baseProviderState = await mockProvider.getState() }) function stream(chunks: ApiStreamChunk[]): AsyncGenerator { return (async function* () { yield* chunks })() } async function createTaskWithManualRetries() { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const state = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...state, apiConfiguration: mockApiConfig, autoApprovalEnabled: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) return task } describe("empty-response retries", () => { it("restores the user message before a confirmed empty-response retry", async () => { const task = await createTaskWithManualRetries() let retryHistory: ApiMessage[] | undefined let retryUserMessageCount: number | undefined vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) const attemptApiRequestSpy = vi .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => stream([])) .mockImplementationOnce(() => { retryHistory = structuredClone(task.apiConversationHistory) retryUserMessageCount = task.messageCounts.user return stream([{ type: "text", text: "retry succeeded" }]) }) .mockImplementation(() => { throw new Error("stop after retry response") }) await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) expect(retryHistory).toHaveLength(1) // The retry iteration must reach the request seam with its own incremented // retry count; passing the initial-attempt value instead would make retries // indistinguishable from the first attempt downstream. expect(attemptApiRequestSpy).toHaveBeenNthCalledWith( 2, 1, expect.objectContaining({ skipProviderRateLimit: true }), ) expect(retryHistory?.[0]).toMatchObject({ role: "user", content: expect.arrayContaining([expect.objectContaining({ text: "original user request" })]), }) expect(retryUserMessageCount).toBe(1) }) it("restores the user message and records the failure when retry is declined", async () => { const task = await createTaskWithManualRetries() vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" } satisfies TaskAskResult) vi.spyOn(task, "attemptApiRequest").mockImplementation(() => stream([])) const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) expect(result).toBe(false) expect(task.apiConversationHistory).toMatchObject([ { role: "user", content: [{ type: "text", text: "original user request" }] }, { role: "assistant", content: [{ type: "text", text: "Failure: I did not provide a response." }] }, ]) expect(task.messageCounts).toEqual({ user: 1, assistant: 1 }) }) }) describe("cache-write TTL split in telemetry", () => { it("bills a 1h cache write at the 1h rate when usage is captured after a cancelled stream", async () => { // glm P1: the telemetry cost recompute inside captureUsageData must receive // the per-TTL split. Before the fix it passed only the aggregate write count, // so a 1h write was billed at the 5m rate (0.375 instead of 0.6 for 100k // tokens at claude-3-5-sonnet's 3.0 input price). const task = await createTaskWithManualRetries() const captureLlmCompletion = vi.spyOn(TelemetryService.instance, "captureLlmCompletion") vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) // A stream that reports the split but never yields a final cost chunk, which is // the shape a cancelled request leaves behind. The second call throws so the // request loop terminates the way the neighbouring tests do. vi.spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => stream([ { type: "usage", inputTokens: 0, outputTokens: 0, cacheWriteTokens: 100_000, cacheWrite1hTokens: 100_000, cacheReadTokens: 0, }, { type: "text", text: "partial response" }, ]), ) .mockImplementation(() => { throw new Error("stop after usage capture") }) await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) // The drain is fire-and-forget and reaches `captureLlmCompletion` after several // awaits, some of which touch the filesystem. A fixed sleep races that work on a // loaded worker, so poll for the captured call instead. await vi.waitFor(() => expect( captureLlmCompletion.mock.calls.some(([, payload]) => payload?.cacheWriteTokens === 100_000), ).toBe(true), ) const call = captureLlmCompletion.mock.calls.find(([, payload]) => payload?.cacheWriteTokens === 100_000) expect(call).toBeDefined() // claude-3-5-sonnet-20241022: input 3.0, cacheWritesPrice 3.75, cacheWritesPrice1h 6.0. // 1h writes: (6.0 / 1_000_000) * 100_000 = 0.6 // The pre-fix lumped behaviour billed (3.75 / 1_000_000) * 100_000 = 0.375. expect(call![1].cost).toBeCloseTo(0.6, 9) expect(call![1].cost).not.toBeCloseTo(0.375, 9) }) }) describe("request continuation", () => { it("sends the no-tool-use reminder in a second request when the first response has no tool use", async () => { const task = await createTaskWithManualRetries() let secondRequestHistory: ApiMessage[] | undefined const attemptApiRequestSpy = vi .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => stream([{ type: "text", text: "no tool here" }])) .mockImplementationOnce(() => { secondRequestHistory = structuredClone(task.apiConversationHistory) throw new Error("stop after continuation request") }) await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(secondRequestHistory).toMatchObject([ { role: "user", content: expect.arrayContaining([expect.objectContaining({ text: "original user request" })]), }, { role: "assistant", content: [{ type: "text", text: "no tool here" }] }, { role: "user", content: expect.arrayContaining([ expect.objectContaining({ text: expect.stringContaining("You did not use a tool in your previous response"), }), ]), }, ]) }) }) describe("output token limit mid-stream", () => { async function* truncatedStream(): AsyncGenerator { yield { type: "text", text: "partial answer" } throw new OutputTokenLimitError() } async function createAutoApprovedTask() { vi.spyOn(mockProvider, "getState").mockResolvedValue( providerStateWith({ autoApprovalEnabled: true, requestDelaySeconds: 0 }), ) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) return task } it("asks once instead of auto-retrying, even with auto-approval", async () => { const task = await createAutoApprovedTask() const askSpy = vi .spyOn(task, "ask") .mockResolvedValue({ response: "noButtonClicked" } satisfies TaskAskResult) const backoffSpy = vi.spyOn(getTaskTestAccess(task), "backoffAndAnnounce") const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest").mockImplementation(() => truncatedStream()) await task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }]) expect(attemptApiRequestSpy).toHaveBeenCalledOnce() expect(backoffSpy).not.toHaveBeenCalled() expect(askSpy).toHaveBeenCalledWith("api_req_failed", expect.stringContaining("Output token limit reached")) }) it("retries from a fresh attempt count when the user confirms", async () => { const task = await createAutoApprovedTask() vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) const attemptApiRequestSpy = vi .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => truncatedStream()) .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "done" }])) .mockImplementation(() => { throw new Error("stop after retry response") }) await task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }]) expect(attemptApiRequestSpy.mock.calls.slice(0, 2).map(([retryAttempt]) => retryAttempt)).toEqual([0, 0]) }) }) describe("native tool-call request isolation", () => { it("derives readiness only from a closed, idle, fully paired tool turn", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "tool turn readiness test", startTask: false, }) const readiness = () => getTaskTestAccess(task).hasCompleteToolResultsForCurrentTurn() task.assistantMessageContent = [ { type: "tool_use", id: "call_ready", name: "read_file", params: {}, nativeArgs: { path: "README.md" }, partial: false, }, ] task.userMessageContent = [{ type: "tool_result", tool_use_id: "call_ready", content: "finished" }] task.currentStreamingContentIndex = task.assistantMessageContent.length expect(readiness()).toBe(false) task.didCompleteReadingStream = true expect(readiness()).toBe(true) task.dispatchState = DispatchState.SERIAL expect(readiness()).toBe(false) task.dispatchState = DispatchState.PARALLEL expect(readiness()).toBe(false) task.dispatchState = DispatchState.IDLE expect(readiness()).toBe(true) task.assistantMessageContent[0].partial = true expect(readiness()).toBe(false) task.assistantMessageContent[0].partial = false task.userMessageContent = [{ type: "tool_result", tool_use_id: "different_call", content: "finished" }] expect(readiness()).toBe(false) task.userMessageContent = [{ type: "tool_result", tool_use_id: "call_ready", content: "finished" }] expect(readiness()).toBe(true) }) it("requires a result for every tool call in the turn", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "multi tool readiness test", startTask: false, }) const readiness = () => getTaskTestAccess(task).hasCompleteToolResultsForCurrentTurn() task.didCompleteReadingStream = true task.assistantMessageContent = [ { type: "tool_use", id: "call_first", name: "read_file", params: {}, partial: false }, { type: "tool_use", id: "call_middle", name: "read_file", params: {}, partial: false }, { type: "tool_use", id: "call_last", name: "read_file", params: {}, partial: false }, ] const resultsFor = (...ids: string[]): Anthropic.ToolResultBlockParam[] => ids.map((id) => ({ type: "tool_result", tool_use_id: id, content: "finished" })) task.userMessageContent = resultsFor("call_first") expect(readiness()).toBe(false) task.userMessageContent = resultsFor("call_last") expect(readiness()).toBe(false) task.userMessageContent = resultsFor("call_first", "call_last") expect(readiness()).toBe(false) task.userMessageContent = resultsFor("call_first", "call_middle", "call_last") expect(readiness()).toBe(true) }) it("does not strand a paired tool turn after a presenter failure", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "presenter failure readiness test", startTask: false, }) task.didCompleteReadingStream = true task.dispatchState = DispatchState.IDLE task.assistantMessageContent = [ { type: "tool_use", id: "call_ready", name: "read_file", params: {}, partial: false, }, { type: "text", content: "presenter failed here", partial: false }, ] task.currentStreamingContentIndex = 1 task.userMessageContent = [{ type: "tool_result", tool_use_id: "call_ready", content: "finished" }] expect(getTaskTestAccess(task).hasCompleteToolResultsForCurrentTurn()).toBe(true) }) it("requires an identifiable tool call and matches sanitized result IDs", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "tool turn identity test", startTask: false, }) const readiness = () => getTaskTestAccess(task).hasCompleteToolResultsForCurrentTurn() task.didCompleteReadingStream = true task.assistantMessageContent = [{ type: "text", content: "finished", partial: false }] expect(readiness()).toBe(false) // An empty result ID must not satisfy a tool call that has no ID. task.userMessageContent = [{ type: "tool_result", tool_use_id: "", content: "finished" }] task.assistantMessageContent = [ { type: "tool_use", name: "read_file", params: {}, partial: false, }, ] expect(readiness()).toBe(false) task.assistantMessageContent = [ { type: "mcp_tool_use", id: "", name: "mcp_server_tool", serverName: "server", toolName: "tool", arguments: {}, partial: false, }, ] expect(readiness()).toBe(false) const mcpToolUse = task.assistantMessageContent[0] if (mcpToolUse.type !== "mcp_tool_use") { throw new Error("Expected MCP tool use fixture") } mcpToolUse.id = "functions.read_file:0" task.userMessageContent = [ { type: "tool_result", tool_use_id: "functions.read_file:0", content: "finished" }, ] expect(readiness()).toBe(false) task.userMessageContent = [ { type: "tool_result", tool_use_id: "functions_read_file_0", content: "finished" }, ] expect(readiness()).toBe(true) }) it("continues after a complete read_file result when the readiness flag update is lost", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "read file continuation test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => { const completedRead = task.assistantMessageContent.find( (block) => block.type === "tool_use" && block.name === "read_file" && !block.partial, ) if (!completedRead || completedRead.type !== "tool_use" || !completedRead.id) return task.pushToolResultToUserContent({ type: "tool_result", tool_use_id: completedRead.id, content: "File: README.md\nfinished", }) // Reproduce the persisted field symptom: the tool result exists and // presentation consumed the completed block, but the one-shot readiness // latch never flips, so the old loop waits forever before the next request. task.currentStreamingContentIndex = task.assistantMessageContent.length task.userMessageContentReady = false }) let continuationUserContent: Anthropic.Messages.ContentBlockParam[] | undefined const attemptApiRequestSpy = vi .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_read", name: "read_file" }, { type: "tool_call_partial", index: 0, arguments: '{"path":"README.md"}' }, ]), ) .mockImplementationOnce(() => { const continuationMessage = task.apiConversationHistory.at(-1) continuationUserContent = continuationMessage?.role === "user" && Array.isArray(continuationMessage.content) ? continuationMessage.content : undefined throw new Error("continuation request reached") }) vi.mocked(pWaitFor).mockImplementation(async (condition) => { if (!(await condition())) { throw new Error("read_file continuation would stall") } }) try { await task.recursivelyMakeClineRequests([{ type: "text", text: "read a file, then continue" }]) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(continuationUserContent?.filter((block) => block.type === "tool_result")).toEqual([ expect.objectContaining({ type: "tool_result", tool_use_id: "call_read", content: "File: README.md\nfinished", }), ]) } finally { vi.mocked(pWaitFor).mockImplementation(async () => {}) } }) it("continues after the real presenter leaves a complete tool result behind a non-abort failure", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "real presenter continuation test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) const readFileHandleSpy = vi .spyOn(readFileTool, "handle") .mockImplementation(async (_task, block, callbacks) => { if (!block.partial) callbacks.pushToolResult(`File: ${block.params.path}\nfinished`) }) const say = task.say.bind(task) const saySpy = vi.spyOn(task, "say").mockImplementation(async (...args) => { const [type, _text, _images, partial] = args if (type === "text" && partial === false) { throw new Error("presenter text failed") } return say(...args) }) const readiness = getTaskTestAccess(task).hasCompleteToolResultsForCurrentTurn.bind(task) let sawCompleteToolTurn = false const readinessSpy = vi .spyOn(getTaskTestAccess(task), "hasCompleteToolResultsForCurrentTurn") .mockImplementation(() => { const ready = readiness() if (ready) sawCompleteToolTurn = true return ready }) let continuationUserContent: Anthropic.Messages.ContentBlockParam[] | undefined const attemptApiRequestSpy = vi .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => { // The real generator publishes the request policy snapshot that // presentAssistantMessage requires; with the generator replaced // wholesale, the mock must publish one too or the presenter's // entry guard throws before any block is processed. getTaskTestAccess(task).requestPolicySnapshot = { disabledTools: [], customModes: [], modelInfo: stubModelInfo, } return asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_read_first", name: "read_file" }, { type: "tool_call_partial", index: 0, arguments: '{"path":"README.md"}' }, { type: "tool_call_partial", index: 1, id: "call_read_second", name: "read_file" }, { type: "tool_call_partial", index: 1, arguments: '{"path":"package.json"}' }, { type: "text", text: "present this after the tool" }, ]) }) .mockImplementationOnce(() => { const continuationMessage = task.apiConversationHistory.at(-1) continuationUserContent = continuationMessage?.role === "user" && Array.isArray(continuationMessage.content) ? continuationMessage.content : undefined throw new Error("continuation request reached") }) const { default: realPWaitFor } = await vi.importActual("p-wait-for") vi.mocked(pWaitFor).mockImplementation((condition) => realPWaitFor(condition, { interval: 1, timeout: 1_000 }), ) try { await task.recursivelyMakeClineRequests([{ type: "text", text: "read a file, then continue" }]) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(continuationUserContent?.filter((block) => block.type === "tool_result")).toEqual([ expect.objectContaining({ type: "tool_result", tool_use_id: "call_read_first", content: "File: README.md\nfinished", }), expect.objectContaining({ type: "tool_result", tool_use_id: "call_read_second", content: "File: package.json\nfinished", }), ]) expect(task.dispatchState).toBe(DispatchState.IDLE) expect(sawCompleteToolTurn).toBe(true) } finally { vi.mocked(pWaitFor).mockImplementation(async () => {}) readinessSpy.mockRestore() readFileHandleSpy.mockRestore() saySpy.mockRestore() } }) it("waits for a running tool handler even when its result is already pushed", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "running handler continuation test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) let releaseHandler: (() => void) | undefined const handlerGate = new Promise((resolve) => { releaseHandler = resolve }) let handlerPushedResult: (() => void) | undefined const resultPushed = new Promise((resolve) => { handlerPushedResult = resolve }) const readFileHandleSpy = vi .spyOn(readFileTool, "handle") .mockImplementation(async (_task, block, callbacks) => { if (block.partial) return callbacks.pushToolResult("File: README.md\nfinished") handlerPushedResult?.() // The handler keeps running after it pushes the result, for example while it waits for approval. await handlerGate }) const attemptApiRequestSpy = vi .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => { // The real generator publishes the request policy snapshot that // presentAssistantMessage requires; with the generator replaced // wholesale, the mock must publish one too or the presenter's // entry guard throws before any block is processed. getTaskTestAccess(task).requestPolicySnapshot = { disabledTools: [], customModes: [], modelInfo: stubModelInfo, } return asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_read", name: "read_file" }, { type: "tool_call_partial", index: 0, arguments: '{"path":"README.md"}' }, ]) }) .mockImplementationOnce(() => { throw new Error("continuation request reached") }) const { default: realPWaitFor } = await vi.importActual("p-wait-for") vi.mocked(pWaitFor).mockImplementation((condition) => realPWaitFor(condition, { interval: 1, timeout: 1_000 }), ) try { const run = task.recursivelyMakeClineRequests([{ type: "text", text: "read a file, then wait" }]) await resultPushed // Give the request loop several poll intervals to continue early. await new Promise((resolve) => setTimeout(resolve, 50)) expect(task.dispatchState).toBe(DispatchState.SERIAL) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(1) releaseHandler?.() await run expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(task.dispatchState).toBe(DispatchState.IDLE) } finally { releaseHandler?.() vi.mocked(pWaitFor).mockImplementation(async () => {}) readFileHandleSpy.mockRestore() } }) it("lets abort win when the real presenter leaves a complete tool result", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "real presenter abort test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) const readFileHandleSpy = vi .spyOn(readFileTool, "handle") .mockImplementation(async (_task, block, callbacks) => { if (!block.partial) callbacks.pushToolResult("File: README.md\nfinished") }) const say = task.say.bind(task) const saySpy = vi.spyOn(task, "say").mockImplementation(async (...args) => { const [type, _text, _images, partial] = args if (type === "text" && partial === false) { setAbortLatch(task, true) throw new Error("presenter text failed while aborting") } return say(...args) }) const readiness = getTaskTestAccess(task).hasCompleteToolResultsForCurrentTurn.bind(task) let sawReadyWhileAborted = false const readinessSpy = vi .spyOn(getTaskTestAccess(task), "hasCompleteToolResultsForCurrentTurn") .mockImplementation(() => { const ready = readiness() if (ready && task.abort) sawReadyWhileAborted = true return ready }) const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { // The real generator publishes the request policy snapshot that // presentAssistantMessage requires; with the generator replaced // wholesale, the mock must publish one too or the presenter's // entry guard throws before any block is processed. getTaskTestAccess(task).requestPolicySnapshot = { disabledTools: [], customModes: [], modelInfo: stubModelInfo, } return asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_read", name: "read_file" }, { type: "tool_call_partial", index: 0, arguments: '{"path":"README.md"}' }, { type: "text", text: "present this after the tool" }, ]) }) const { default: realPWaitFor } = await vi.importActual("p-wait-for") vi.mocked(pWaitFor).mockImplementation((condition) => realPWaitFor(condition, { interval: 1, timeout: 1_000 }), ) try { await task.recursivelyMakeClineRequests([{ type: "text", text: "read a file, then abort" }]) expect(attemptApiRequestSpy).toHaveBeenCalledOnce() expect(sawReadyWhileAborted).toBe(true) expect(task.dispatchState).toBe(DispatchState.IDLE) } finally { vi.mocked(pWaitFor).mockImplementation(async () => {}) readinessSpy.mockRestore() readFileHandleSpy.mockRestore() saySpy.mockRestore() } }) it("keeps overlapping Task parser state scoped to each request", async () => { const firstTask = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "first task", startTask: false, }) const secondTask = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "second task", startTask: false, }) let releaseFirstStream: (() => void) | undefined let markFirstStreamPaused: (() => void) | undefined const firstStreamRelease = new Promise((resolve) => { releaseFirstStream = resolve }) const firstStreamPaused = new Promise((resolve) => { markFirstStreamPaused = resolve }) const firstStream = async function* (): AsyncGenerator { yield { type: "tool_call_partial", index: 0, id: "call_first", name: "read_file", } yield { type: "tool_call_partial", index: 0, arguments: '{"path":"first' } yield { type: "usage", inputTokens: 0, outputTokens: 0 } markFirstStreamPaused?.() await firstStreamRelease yield { type: "tool_call_partial", index: 0, arguments: 'Task.ts"}' } } for (const task of [firstTask, secondTask]) { vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) } vi.spyOn(firstTask, "attemptApiRequest").mockImplementation(() => firstStream()) vi.spyOn(secondTask, "attemptApiRequest").mockImplementation(() => asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_second", name: "read_file", }, { type: "tool_call_partial", index: 0, arguments: '{"path":"secondTask.ts"}' }, ]), ) const firstRequest = firstTask.recursivelyMakeClineRequests([{ type: "text", text: "first request" }]) await firstStreamPaused await secondTask.recursivelyMakeClineRequests([{ type: "text", text: "second request" }]) releaseFirstStream?.() await firstRequest const firstAssistantMessage = firstTask.apiConversationHistory.find( (message) => message.role === "assistant", ) const secondAssistantMessage = secondTask.apiConversationHistory.find( (message) => message.role === "assistant", ) expect(firstAssistantMessage?.content).toEqual([ { type: "tool_use", id: "call_first", name: "read_file", input: { path: "firstTask.ts" }, }, ]) expect(secondAssistantMessage?.content).toEqual([ { type: "tool_use", id: "call_second", name: "read_file", input: { path: "secondTask.ts" }, }, ]) }) it("uses a fresh parser scope on retry so stale partial state does not leak", async () => { // First stream: starts a tool call, then throws mid-stream. // Second stream (retry): completes a different tool call cleanly. // If the scope were shared across retries, the old partial state for // "call_stale" would still be in the WeakMap when the retry runs, // and could corrupt finalization of "call_fresh". const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "retry scope test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) const firstStream = async function* (): AsyncGenerator { yield { type: "tool_call_partial", index: 0, id: "call_stale", name: "read_file" } yield { type: "tool_call_partial", index: 0, arguments: '{"path":"stale' } throw new Error("simulated mid-stream failure") } vi.spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => firstStream()) .mockImplementationOnce(() => asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_fresh", name: "write_file" }, { type: "tool_call_partial", index: 0, arguments: '{"path":"new.ts","content":"hello"}', }, ]), ) await task.recursivelyMakeClineRequests([{ type: "text", text: "retry scope test" }]) // The assistant turn from the successful retry must contain only the // fresh tool call. If scope leaked, "call_stale" partial would pollute // "call_fresh" finalization (wrong args or null result). const assistantMessages = task.apiConversationHistory.filter((m) => m.role === "assistant") const retryAssistant = assistantMessages[assistantMessages.length - 1] expect(retryAssistant?.content).toEqual([ { type: "tool_use", id: "call_fresh", name: "write_file", input: { path: "new.ts", content: "hello" }, }, ]) }) it("clears didFinishAbortingStream on retry so cancelTask waits for the new stream (#1801)", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "abort flag reset test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) const failingStream = async function* (): AsyncGenerator { yield { type: "text", text: "partial" } throw new Error("simulated mid-stream failure") } let releaseRetryStream: (() => void) | undefined let markRetryStreamPaused: (() => void) | undefined const retryStreamRelease = new Promise((resolve) => { releaseRetryStream = resolve }) const retryStreamPaused = new Promise((resolve) => { markRetryStreamPaused = resolve }) const pausedRetryStream = async function* (): AsyncGenerator { yield { type: "text", text: "retry" } markRetryStreamPaused?.() await retryStreamRelease } const attemptApiRequestSpy = vi .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => failingStream()) .mockImplementationOnce(() => pausedRetryStream()) .mockImplementation(() => { throw new Error("stop after retry response") }) const { default: realPWaitFor } = await vi.importActual("p-wait-for") vi.mocked(pWaitFor).mockImplementation(realPWaitFor) let request: Promise | undefined let cancel: Promise | undefined try { await mockProvider.addClineToStack(task) request = task.recursivelyMakeClineRequests([{ type: "text", text: "abort flag reset test" }]) await Promise.race([ retryStreamPaused, request.then(() => { throw new Error("request settled before the retry stream paused") }), ]) const firstApiReq = task.clineMessages.find((message) => message.say === "api_req_started") expect(JSON.parse(firstApiReq?.text ?? "{}")).toMatchObject({ cancelReason: "streaming_failed" }) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) let markCancelWaiting: (() => void) | undefined const cancelWaiting = new Promise((resolve) => { markCancelWaiting = resolve }) vi.mocked(pWaitFor).mockImplementation((condition, options) => realPWaitFor(async () => { const done = await condition() if (!done) markCancelWaiting?.() return done }, options), ) vi.spyOn(mockProvider, "createTaskWithHistoryItem").mockImplementation((historyItem) => ClineProvider.prototype.createTaskWithHistoryItem.call(mockProvider, historyItem, { startTask: false, }), ) let cancelSettled = false cancel = mockProvider.cancelTask().then(() => { cancelSettled = true }) await Promise.race([cancelWaiting, cancel]) expect(cancelSettled, "cancelTask must wait while the retry stream is live").toBe(false) releaseRetryStream?.() await cancel await expect(request).resolves.toBe(true) } finally { releaseRetryStream?.() await Promise.allSettled([request, cancel]) vi.mocked(pWaitFor).mockImplementation(async () => {}) } }) it("finalizes MCP tool call using the request-scoped parser state", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "mcp tool test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) vi.spyOn(task, "attemptApiRequest").mockImplementation(() => asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_mcp", name: "mcp--testServer--myTool", }, { type: "tool_call_partial", index: 0, arguments: '{"param":"value"}' }, ]), ) await task.recursivelyMakeClineRequests([{ type: "text", text: "test request" }]) const assistantMessage = task.apiConversationHistory.find((m) => m.role === "assistant") // Verifies that finalizeStreamingToolCall receives the request-scoped state. // If the scope argument is removed, finalization returns null and the block // stays as a partial tool_use with input: {} instead of the parsed arguments. expect(assistantMessage?.content).toEqual([ { type: "tool_use", id: "call_mcp", name: "mcp--testServer--myTool", input: { param: "value" }, }, ]) }) it("blocks a truncated write_to_file call instead of executing it (issue #1221)", async () => { // Regression test for #1221: if the model's stream is cut off mid-way // through a write_to_file tool call's `content` argument (e.g. it hits // max_tokens), finalizeStreamingToolCall() can't parse the incomplete // JSON and returns null. Task.ts must not let the truncated content // reach writeToFileTool's execution path - it must clear nativeArgs so // presentAssistantMessage's fail-closed guard emits a structured // tool_result error instead. // // Unlike the simulation-based tests in truncated-native-tool-args.spec.ts, // this drives the real streaming + presentAssistantMessage flow through // Task, and spies on the actual tool handler to prove it is never invoked. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "truncated tool call test", startTask: false, }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) // presentAssistantMessageSafe is intentionally left un-mocked here (unlike // the other tests in this block) - the whole point is to exercise the real // dispatch/guard logic, not just tool_use finalization. const writeToFileHandleSpy = vi.spyOn(writeToFileTool, "handle") // Spy directly on the guard's own push, rather than inspecting // userMessageContent/apiConversationHistory afterwards - the task // recurses into a follow-up request once the tool_result is ready // (see the second mocked stream below), which resets those arrays // for the new turn before this function returns. const pushToolResultSpy = vi.spyOn(task, "pushToolResultToUserContent") vi.spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => { // The real generator publishes the request policy snapshot that // presentAssistantMessage requires; with the generator replaced // wholesale, the mock must publish one too or the presenter's // entry guard throws before any block is processed. getTaskTestAccess(task).requestPolicySnapshot = { disabledTools: [], customModes: [], modelInfo: stubModelInfo, } return asyncStreamFrom([ { type: "tool_call_partial", index: 0, id: "call_truncated", name: "write_to_file", }, { type: "tool_call_partial", index: 0, // Cut off mid-string: no closing quote/brace, and the stream // ends here with no explicit tool_call_end - exactly what // happens when the model hits max_tokens mid-argument. // .md path deliberately used so the only thing that can block // execution is the nativeArgs guard under test - an arbitrary // extension could also get caught by unrelated mode-based file // restrictions (e.g. Architect mode's markdown-only rule), // which would produce a false pass/fail unrelated to this bug. arguments: '{"path":"docs/config.md","content":"sk-live-abc123', }, ]) }) // The task recurses once the error tool_result makes the turn // "ready" - this bounds that follow-up to a single harmless text // reply instead of an unmocked second call. .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "" }])) await task.recursivelyMakeClineRequests([{ type: "text", text: "truncated tool call test" }]) // handle() legitimately gets called with partial: true while the call is // still streaming (BaseTool.handle short-circuits to a no-op preview hook // in that case) - that's expected and safe. What must never happen is a // call with partial: false, which is what actually reaches execute() and // writes to disk. const nonPartialCalls = writeToFileHandleSpy.mock.calls.filter( ([, block]) => (block as { partial?: boolean }).partial === false, ) expect(nonPartialCalls).toHaveLength(0) // Neither nativeArgs nor params should leak the truncated content into // the recorded assistant turn - Task.ts builds that entry's `input` via // `toolUse.nativeArgs || toolUse.params`, so clearing nativeArgs alone // would just have shifted the leak to params instead of closing it. const assistantEntry = task.apiConversationHistory.find( (m) => m.role === "assistant" && Array.isArray(m.content) && m.content[0]?.type === "tool_use", ) expect(JSON.stringify(assistantEntry)).not.toContain("sk-live-abc123") // A structured, matching tool_result error must have been pushed for // the truncated call's ID instead of letting it execute. const truncatedCallResult = pushToolResultSpy.mock.calls.find( ([result]) => result.tool_use_id === "call_truncated", )?.[0] expect(truncatedCallResult).toMatchObject({ type: "tool_result", tool_use_id: "call_truncated", is_error: true, }) expect(JSON.stringify(truncatedCallResult)).toContain("missing nativeArgs") }) }) describe("observation registry is task-local (S3, epic #1375)", () => { it("gives each Task its own observation registry", () => { const firstTask = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "first observation task", startTask: false, }) const secondTask = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "second observation task", startTask: false, }) // The guarded-write contract assumes an observation in one task never validates // a write issued by another task, so the registries must not be shared state. expect(firstTask.observationRegistry).not.toBe(secondTask.observationRegistry) firstTask.observationRegistry.observe("/workspace/a.ts", "v-a") expect(firstTask.observationRegistry.get("/workspace/a.ts")?.version).toBe("v-a") expect(secondTask.observationRegistry.get("/workspace/a.ts")).toBeUndefined() }) }) describe("create", () => { it("rejects missing task inputs before constructing the task", () => { const onCreated = vi.fn() expect(() => Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, onCreated })).toThrow( "Either historyItem or task/images must be provided", ) expect(onCreated).not.toHaveBeenCalled() }) it("returns the cached run promise", async () => { const startTaskSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "startTask").mockResolvedValue(undefined) try { const [task, promise] = Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "x", }) expect(task.run()).toBe(promise) expect(startTaskSpy).toHaveBeenCalledExactlyOnceWith("x", undefined) await promise } finally { startTaskSpy.mockRestore() } }) }) describe("constructor", () => { it.each([{ apiConfigName: "parent-local-profile" }, { apiConfigName: undefined }])( "uses an explicit delegated-child context without shared state or startup persistence", async ({ apiConfigName }) => { const captureTaskCreated = vi.spyOn(TelemetryService.instance, "captureTaskCreated") const captureTaskRestarted = vi.spyOn(TelemetryService.instance, "captureTaskRestarted") const getState = vi.spyOn(mockProvider, "getState") const updateTaskHistory = vi.spyOn(mockProvider, "updateTaskHistory") const localConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", } const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "delegated child", startTask: false, handoffExecutionContext: { mode: "ask", apiConfigName, apiConfiguration: localConfiguration, }, }) await expect(task.getTaskMode()).resolves.toBe("ask") await expect(task.getTaskApiConfigName()).resolves.toBe(apiConfigName) expect(task.apiConfiguration).toEqual(localConfiguration) expect(getState).not.toHaveBeenCalled() expect(updateTaskHistory).not.toHaveBeenCalled() expect(captureTaskCreated).toHaveBeenCalledWith(task.taskId) expect(captureTaskRestarted).not.toHaveBeenCalled() }, ) it("keeps history-task initialization distinct from delegated-child initialization", async () => { const captureTaskRestarted = vi.spyOn(TelemetryService.instance, "captureTaskRestarted") const historyItem = { id: "history-task", number: 1, task: "history", ts: Date.now(), tokensIn: 0, tokensOut: 0, totalCost: 0, mode: "architect", apiConfigName: "history-profile", } satisfies HistoryItem const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, historyItem, startTask: false, }) await expect(task.getTaskMode()).resolves.toBe("architect") await expect(task.getTaskApiConfigName()).resolves.toBe("history-profile") expect(captureTaskRestarted).toHaveBeenCalledWith("history-task") }) it("should always have diff strategy defined", async () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Diff is always enabled - diffStrategy should be defined expect(cline.diffStrategy).toBeDefined() }) it("should use default consecutiveMistakeLimit when not provided", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) expect(cline.consecutiveMistakeLimit).toBe(3) }) it("should respect provided consecutiveMistakeLimit", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, consecutiveMistakeLimit: 5, task: "test task", startTask: false, }) expect(cline.consecutiveMistakeLimit).toBe(5) }) it("should keep consecutiveMistakeLimit of 0 as 0 for unlimited", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, consecutiveMistakeLimit: 0, task: "test task", startTask: false, }) expect(cline.consecutiveMistakeLimit).toBe(0) }) it("should pass 0 to ToolRepetitionDetector for unlimited mode", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, consecutiveMistakeLimit: 0, task: "test task", startTask: false, }) // The toolRepetitionDetector should be initialized with 0 for unlimited mode expect(cline.toolRepetitionDetector).toBeDefined() // Verify the limit remains as 0 expect(cline.consecutiveMistakeLimit).toBe(0) }) it("should pass consecutiveMistakeLimit to ToolRepetitionDetector", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, consecutiveMistakeLimit: 5, task: "test task", startTask: false, }) // The toolRepetitionDetector should be initialized with the same limit expect(cline.toolRepetitionDetector).toBeDefined() expect(cline.consecutiveMistakeLimit).toBe(5) }) it("should disable the tool repetition soft limit when consecutiveMistakeLimit is 0", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, consecutiveMistakeLimit: 0, toolRepetitionSoftLimit: 2, task: "test task", startTask: false, }) expect(cline.toolRepetitionSoftLimit).toBe(0) const identicalToolCall = { type: "tool_use" as const, name: "execute_command" as const, params: { command: "ls" }, partial: false, } for (let call = 0; call < 6; call++) { expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("allow") } }) it("should default tool repetition soft limit when not provided", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) expect(cline.toolRepetitionSoftLimit).toBe(2) }) it("should respect provided tool repetition soft limit", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, consecutiveMistakeLimit: 7, toolRepetitionSoftLimit: 3, task: "test task", startTask: false, }) expect(cline.toolRepetitionSoftLimit).toBe(3) // Verify the constructor actually wires the soft limit into the // detector (soft=3 differs from the default of 2). const identicalToolCall = { type: "tool_use" as const, name: "execute_command" as const, params: { command: "ls" }, partial: false, } const actions = Array.from( { length: 4 }, () => cline.toolRepetitionDetector.check(identicalToolCall).action, ) expect(actions).toEqual(["allow", "allow", "allow", "soft_block"]) }) it("should derive the tool repetition hard stop from consecutiveMistakeLimit", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, consecutiveMistakeLimit: 7, toolRepetitionSoftLimit: 2, task: "test task", startTask: false, }) expect(cline.consecutiveMistakeLimit).toBe(7) expect(cline.toolRepetitionSoftLimit).toBe(2) expect(cline.toolRepetitionDetector).toBeDefined() // The detector's counter increments from the second identical call // onward (the first call establishes the baseline at count 0), so the // count reaches the hard limit of 7 on the 8th identical call. const identicalToolCall = { type: "tool_use" as const, name: "execute_command" as const, params: { command: "ls" }, partial: false, } // Calls 1-7: count stays below the hard limit, so the hard stop must not fire. for (let call = 1; call <= 7; call++) { const result = cline.toolRepetitionDetector.check(identicalToolCall) expect(result.action).not.toBe("hard_block") } // Call 8: count reaches the hard limit of 7, so the hard stop fires. const hardBlockingCall = cline.toolRepetitionDetector.check(identicalToolCall) expect(hardBlockingCall.action).toBe("hard_block") }) it("should require either task or historyItem", () => { expect(() => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) }).toThrow("Either historyItem or task/images must be provided") }) }) describe("updateApiConfiguration", () => { it("refreshes the soft limit but preserves the task-level hard limit when the provider profile changes", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: { ...mockApiConfig, consecutiveMistakeLimit: 5, toolRepetitionSoftLimit: 2 }, consecutiveMistakeLimit: 5, toolRepetitionSoftLimit: 2, task: "test task", startTask: false, }) expect(cline.consecutiveMistakeLimit).toBe(5) expect(cline.toolRepetitionSoftLimit).toBe(2) // Switching provider profile mid-task applies the new soft limit to // the live detector, but keeps the hard limit captured at creation. cline.updateApiConfiguration({ ...mockApiConfig, consecutiveMistakeLimit: 2, toolRepetitionSoftLimit: 1, }) expect(cline.consecutiveMistakeLimit).toBe(5) expect(cline.toolRepetitionSoftLimit).toBe(1) const identicalToolCall = { type: "tool_use" as const, name: "execute_command" as const, params: { command: "ls" }, partial: false, } // Call 1 (count = 0) -> allow expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("allow") // Calls 2-5 (count = 1..4) -> soft_block (new soft limit 1, hard limit still 5) for (let call = 2; call <= 5; call++) { expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("soft_block") } // Call 6 (count = 5) -> hard_block (preserved hard limit 5) expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("hard_block") }) it("does not override an explicit task-level consecutiveMistakeLimit (e.g. extension API tasks)", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: { ...mockApiConfig, consecutiveMistakeLimit: 3 }, consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, task: "test task", startTask: false, }) cline.updateApiConfiguration({ ...mockApiConfig, consecutiveMistakeLimit: 3 }) expect(cline.consecutiveMistakeLimit).toBe(Number.MAX_SAFE_INTEGER) }) it("disables the soft tier for an unreachable hard limit, even after a profile switch", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: { ...mockApiConfig, toolRepetitionSoftLimit: 2 }, consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, toolRepetitionSoftLimit: 2, task: "test task", startTask: false, }) expect(cline.toolRepetitionSoftLimit).toBe(0) // A profile switch re-derives the soft limit from the new profile; // it must stay disabled because the hard stop is still unreachable. cline.updateApiConfiguration({ ...mockApiConfig, toolRepetitionSoftLimit: 2 }) expect(cline.toolRepetitionSoftLimit).toBe(0) const identicalToolCall = { type: "tool_use" as const, name: "execute_command" as const, params: { command: "ls" }, partial: false, } for (let call = 0; call < 10; call++) { expect(cline.toolRepetitionDetector.check(identicalToolCall).action).toBe("allow") } }) it("falls back to the default soft limit when the new profile omits it", () => { const cline = new Task({ provider: mockProvider, apiConfiguration: { ...mockApiConfig, consecutiveMistakeLimit: 7, toolRepetitionSoftLimit: 4 }, consecutiveMistakeLimit: 7, toolRepetitionSoftLimit: 4, task: "test task", startTask: false, }) cline.updateApiConfiguration({ ...mockApiConfig }) // The default soft limit is 2; the task-level hard limit is preserved. expect(cline.consecutiveMistakeLimit).toBe(7) expect(cline.toolRepetitionSoftLimit).toBe(2) }) }) describe("API handler disposal", () => { const disposableApi = (task: Task) => { const dispose = vi.fn() task.api = Object.assign(Object.create(task.api), { dispose }) return dispose } it("clears the observation registry when the task is disposed", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false, }) task.observationRegistry.observe("/tmp/observed.txt", "v1") expect(task.observationRegistry.get("/tmp/observed.txt")).toBeDefined() await task.dispose() // The registry is task-owned: its per-path entries must not outlive disposal. expect(task.observationRegistry.get("/tmp/observed.txt")).toBeUndefined() }) it("disposes the replaced handler when the configuration changes while idle", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false, }) const dispose = disposableApi(task) task.updateApiConfiguration({ ...mockApiConfig }) expect(dispose).toHaveBeenCalledOnce() await task.dispose() expect(dispose).toHaveBeenCalledOnce() }) /** * Replaces the request body behind `attemptApiRequest` with a stream the test * drives, so the lifecycle wrapper runs for real without prompt/tool setup. * `gate` holds the request in setup (before its first chunk) until opened. */ const controllableRequest = (task: Task) => { let openGate!: () => void const gate = new Promise((resolve) => (openGate = resolve)) let finish!: () => void const finished = new Promise((resolve) => (finish = resolve)) vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation(async function* () { await gate yield { type: "text", text: "a" } await finished yield { type: "text", text: "b" } }) return { openGate, finish } } const newTask = () => new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false }) it("keeps a handler replaced during request setup alive until that request settles", async () => { const task = newTask() const { openGate, finish } = controllableRequest(task) const oldDispose = disposableApi(task) const request = task.attemptApiRequest() const first = request.next() // enters setup and blocks on the gate, isStreaming still false await Promise.resolve() expect(task.isStreaming).toBe(false) task.updateApiConfiguration({ ...mockApiConfig }) expect(oldDispose).not.toHaveBeenCalled() openGate() await first expect(oldDispose).not.toHaveBeenCalled() finish() for await (const _ of request) { // drain } expect(oldDispose).toHaveBeenCalledOnce() await task.dispose() expect(oldDispose).toHaveBeenCalledOnce() }) it("releases a handler replaced mid-stream when the stream is closed early, not at task disposal", async () => { const task = newTask() const { openGate } = controllableRequest(task) const oldDispose = disposableApi(task) openGate() const request = task.attemptApiRequest() await request.next() task.updateApiConfiguration({ ...mockApiConfig }) expect(oldDispose).not.toHaveBeenCalled() await request.return(undefined) expect(oldDispose).toHaveBeenCalledOnce() }) it("does not accumulate handlers across repeated mid-stream profile switches", async () => { const task = newTask() const disposals: Array> = [] for (let i = 0; i < 3; i++) { const { openGate, finish } = controllableRequest(task) disposals.push(disposableApi(task)) openGate() const request = task.attemptApiRequest() await request.next() task.updateApiConfiguration({ ...mockApiConfig }) finish() for await (const _ of request) { // drain } expect(disposals[i]).toHaveBeenCalledOnce() } expect(getTaskTestAccess(task).retiredApis.size).toBe(0) }) it("waits for every overlapping unit of work before disposing a replaced handler", async () => { const task = newTask() const first = controllableRequest(task) const oldDispose = disposableApi(task) first.openGate() const requestA = task.attemptApiRequest() await requestA.next() const second = controllableRequest(task) second.openGate() const requestB = task.attemptApiRequest() await requestB.next() task.updateApiConfiguration({ ...mockApiConfig }) await requestA.return(undefined) expect(oldDispose).not.toHaveBeenCalled() await requestB.return(undefined) expect(oldDispose).toHaveBeenCalledOnce() }) it("disposes a handler retired by work that never settles when the task is disposed", async () => { const task = newTask() const { openGate } = controllableRequest(task) const oldDispose = disposableApi(task) openGate() const request = task.attemptApiRequest() await request.next() task.updateApiConfiguration({ ...mockApiConfig }) const newDispose = disposableApi(task) await task.dispose() expect(oldDispose).toHaveBeenCalledOnce() expect(newDispose).toHaveBeenCalledOnce() // The request settling later must not dispose anything a second time. await request.return(undefined) expect(oldDispose).toHaveBeenCalledOnce() }) it("keeps the handler alive for a manual condense started before the switch", async () => { const task = newTask() const oldDispose = disposableApi(task) let release!: () => void vi.spyOn(getTaskTestAccess(task), "condenseContextWithActiveApi").mockImplementation( () => new Promise((resolve) => (release = resolve)), ) const condensing = task.condenseContext() task.updateApiConfiguration({ ...mockApiConfig }) expect(oldDispose).not.toHaveBeenCalled() release() await condensing expect(oldDispose).toHaveBeenCalledOnce() }) it("logs and continues when a replaced handler's dispose throws", async () => { const task = newTask() const error = new Error("dispose failed") task.api = Object.assign(Object.create(task.api), { dispose: vi.fn(() => { throw error }), }) const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) expect(() => task.updateApiConfiguration({ ...mockApiConfig })).not.toThrow() expect(consoleError).toHaveBeenCalledWith("Error disposing API handler:", error) consoleError.mockRestore() }) describe("through the real request lease", () => { /** Lets the first `count` requests run the real lifecycle wrapper, then stops the loop. */ const realRequestsThenStop = (task: Task, count = 1) => { const real = task.attemptApiRequest.bind(task) let calls = 0 return vi.spyOn(task, "attemptApiRequest").mockImplementation((...args) => { if (++calls <= count) return real(...args) throw new Error("stop after the request under test") }) } it("closes a stream whose read failed, releasing the replaced handler (stream.return())", async () => { const task = await createTaskWithManualRetries() const oldDispose = disposableApi(task) let releaseAfterAbort!: () => void const afterAbort = new Promise((resolve) => (releaseAfterAbort = resolve)) let firstChunkSent!: () => void const firstChunk = new Promise((resolve) => (firstChunkSent = resolve)) vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation( async function* () { task.currentRequestAbortController = new AbortController() yield { type: "text", text: "a" } firstChunkSent() // A host that ignores cancellation and keeps producing after the // consumer gave up: only `return()` can end this generator. await afterAbort yield { type: "text", text: "late" } yield { type: "text", text: "later" } }, ) realRequestsThenStop(task) vi.spyOn(console, "error").mockImplementation(() => {}) const loop = task.recursivelyMakeClineRequests([{ type: "text", text: "go" }]) await firstChunk task.updateApiConfiguration({ ...mockApiConfig }) expect(oldDispose).not.toHaveBeenCalled() // The read in flight is abandoned by the consumer (not failed by the stream). task.currentRequestAbortController?.abort() releaseAfterAbort() await loop await vi.waitFor(() => expect(oldDispose).toHaveBeenCalledOnce()) expect(getTaskTestAccess(task).retiredApis.size).toBe(0) }) it("keeps a replaced handler alive through a retry until the retry's stream settles", async () => { const task = newTask() const oldDispose = disposableApi(task) let startRetry!: () => void const retryGate = new Promise((resolve) => (startRetry = resolve)) let finishRetry!: () => void const retryDone = new Promise((resolve) => (finishRetry = resolve)) let attempt = 0 vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation( async function* () { if (++attempt === 1) { // The first attempt fails and retries through the public method, // as the real first-chunk and context-window paths do. await retryGate yield* task.attemptApiRequest(1) return } yield { type: "text", text: "retried" } await retryDone }, ) const request = task.attemptApiRequest() const first = request.next() await Promise.resolve() task.updateApiConfiguration({ ...mockApiConfig }) expect(oldDispose).not.toHaveBeenCalled() startRetry() await first expect(attempt).toBe(2) expect(oldDispose).not.toHaveBeenCalled() finishRetry() for await (const _ of request) { // drain } expect(oldDispose).toHaveBeenCalledOnce() }) it("keeps a replaced handler alive until the background usage drain finishes", async () => { const task = await createTaskWithManualRetries() const oldDispose = disposableApi(task) let finishDrain!: () => void const drainGate = new Promise((resolve) => (finishDrain = resolve)) let drainStarted!: () => void const draining = new Promise((resolve) => (drainStarted = resolve)) vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation( async function* () { yield { type: "text", text: "a" } // The main loop stops reading here and hands the rest of the stream to // the background usage drain. task.didRejectTool = true yield { type: "text", text: "b" } drainStarted() await drainGate yield { type: "usage", inputTokens: 1, outputTokens: 1 } }, ) realRequestsThenStop(task) vi.spyOn(console, "error").mockImplementation(() => {}) const loop = task.recursivelyMakeClineRequests([{ type: "text", text: "go" }]) // Reached only when the background drain reads past the main loop's last chunk. await draining task.updateApiConfiguration({ ...mockApiConfig }) // Give any premature release a chance to run before checking it did not. await new Promise((resolve) => setTimeout(resolve, 0)) expect(oldDispose).not.toHaveBeenCalled() finishDrain() await loop await vi.waitFor(() => expect(oldDispose).toHaveBeenCalledOnce()) }) }) it("disposes the current handler exactly once even when dispose is called twice", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "t", startTask: false, }) const dispose = disposableApi(task) await Promise.all([task.dispose(), task.dispose()]) expect(dispose).toHaveBeenCalledOnce() }) }) describe("task-local configuration isolation", () => { it("uses the task mode and API configuration when focused provider state differs", async () => { const taskApiConfiguration: ProviderSettings = { ...mockApiConfig, todoListEnabled: true, } vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, apiConfiguration: taskApiConfiguration, task: "test task", startTask: false, }) await task.getTaskMode() // The focused provider's state diverges from the task's own // configuration; threading it must not change what the prompt resolves. const focusedProviderState = providerStateWith({ apiConfiguration: { ...mockApiConfig, todoListEnabled: false }, }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await getTaskTestAccess(task).getSystemPrompt(focusedProviderState) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) const [, , , , , mode, , , , , , , settings] = systemPromptCall expect(mode).toBe("architect") expect(settings).toMatchObject({ todoListEnabled: true }) }) it("passes undefined disabledTools when the threaded snapshot carries none", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() // The threaded snapshot's mcpEnabled:false skips the MCP-hub path; its // disabledTools is undefined, so the prompt call receives undefined for // that argument. vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await expect(getTaskTestAccess(task).getSystemPrompt(providerStateWith())).resolves.toBe( "mock system prompt", ) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`. expect(systemPromptCall[16]).toBeUndefined() }) it("passes undefined disabledTools to the system prompt when the threaded snapshot is undefined", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() // A caller whose own read came back empty threads undefined; the prompt // path must still deliver undefined disabledTools rather than fail, and // it must not read provider state to fill the gap. providerRef stays // alive, so the MCP-hub branch below can run. const getStateSpy = vi.spyOn(mockProvider, "getState") // An unavailable snapshot leaves the mcpEnabled gate open, so the hub branch // runs; the spy also witnesses that the branch really was taken. The // awaited connect wait is a no-op under this file's p-wait-for mock, so // the hub double needs no members. const hubSpy = vi.spyOn(McpServerManager, "getInstance") hubSpy.mockResolvedValue(Object.create(McpHub.prototype)) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await expect(getTaskTestAccess(task).getSystemPrompt(undefined)).resolves.toBe("mock system prompt") expect(getStateSpy).not.toHaveBeenCalled() expect(hubSpy).toHaveBeenCalledTimes(1) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`. expect(systemPromptCall[16]).toBeUndefined() hubSpy.mockRestore() }) it("builds the prompt from the threaded snapshot without reading provider state", async () => { // A live provider whose state read returns a divergent snapshot must // not be able to influence the prompt: the prompt path performs no // provider-state read at all, so a re-read here would pick up the // divergent disabledTools instead of the threaded ones. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() const getStateSpy = vi .spyOn(mockProvider, "getState") .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") expect(getStateSpy).not.toHaveBeenCalled() const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 16 is disabledTools: the threaded snapshot's value, // not the value a provider-state re-read would have produced. expect(systemPromptCall[16]).toEqual(["execute_command"]) }) it("forwards non-empty disabledTools and modelInfo to the system prompt call", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() // The threaded snapshot feeds `requestState?.disabledTools`; mcpEnabled // stays false so the MCP-hub path is skipped. const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) const modelInfo: ModelInfo = { contextWindow: 128_000, supportsPromptCache: false, maxTokens: 1234, } vi.spyOn(task.api, "getModel").mockReturnValue({ id: "distinctive-model-id", info: modelInfo }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`; // index 17 is the modelInfo from `this.api.getModel().info`. expect(systemPromptCall[16]).toEqual(["execute_command"]) expect(systemPromptCall[17]).toBe(modelInfo) }) it("fetches dynamic model metadata before reading model info for the prompt", async () => { // Router providers discover model metadata (including included/excluded // tools) lazily. getSystemPrompt must await ensureModelFetched() before // reading getModel().info, otherwise the prompt is built from fallback // metadata with different tool guidance than the runtime path. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() const snapshot = providerStateWith() const fallbackInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false, } const fetchedInfo: ModelInfo = { contextWindow: 128_000, supportsPromptCache: true, excludedTools: ["execute_command"], } let currentInfo = fallbackInfo const ensureModelFetched = vi.fn(async () => { currentInfo = fetchedInfo }) Object.assign(task.api, { ensureModelFetched }) vi.spyOn(task.api, "getModel").mockImplementation(() => ({ id: "router-model", info: currentInfo })) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") expect(ensureModelFetched).toHaveBeenCalledTimes(1) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 17 is modelInfo: it must be the post-fetch metadata. expect(systemPromptCall[17]).toBe(fetchedInfo) }) it("uses the threaded model-info snapshot and skips the fetch guard when one is provided", async () => { // A threaded snapshot replaces the per-call guard entirely: the prompt // must be built from the caller's snapshot without touching // ensureModelFetched or the handler's current model info. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() const handlerInfo: ModelInfo = { contextWindow: 100_000, supportsPromptCache: false } const threadedInfo: ModelInfo = { contextWindow: 64_000, supportsPromptCache: true, excludedTools: [] } const ensureModelFetched = vi.fn(async () => {}) Object.assign(task.api, { ensureModelFetched }) vi.spyOn(task.api, "getModel").mockReturnValue({ id: "threaded-model", info: handlerInfo }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") await expect(getTaskTestAccess(task).getSystemPrompt(providerStateWith(), threadedInfo)).resolves.toBe( "mock system prompt", ) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 17 is modelInfo: the threaded snapshot, not the handler's. expect(systemPromptCall[17]).toBe(threadedInfo) expect(ensureModelFetched).not.toHaveBeenCalled() }) it("threads the request state snapshot into the system prompt when provider state changes mid-request", async () => { // attemptApiRequest captures provider state, then getSystemPrompt waits // on MCP initialization. A settings change during that window must NOT // leak into the prompt: the prompt and the runtime tool array (both fed // from the request snapshot) have to stay aligned. The prompt must be // built from that snapshot: if the prompt path re-read provider state, // it would pick up the divergent disabledTools stubbed for later calls. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() const realState = await mockProvider.getState() // First call: the snapshot captured by attemptApiRequest. vi.spyOn(mockProvider, "getState") .mockResolvedValueOnce({ ...realState, mcpEnabled: false, autoApprovalEnabled: false, disabledTools: ["execute_command"], }) // Any later getState() read returns different disabledTools, so a // re-read along the prompt path would change the observed behavior. .mockResolvedValue({ ...realState, mcpEnabled: false, autoApprovalEnabled: false, disabledTools: ["read_file"], }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 0, }) vi.spyOn(task.api, "createMessage").mockReturnValue({ async *[Symbol.asyncIterator]() { yield { type: "text", text: "ok" } }, async next() { return { done: true, value: undefined } }, async return() { return { done: true, value: undefined } }, async throw(error: unknown) { throw error }, async [Symbol.asyncDispose]() {}, } as AsyncGenerator) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") const iterator = task.attemptApiRequest(0) await iterator.next() const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 16 is disabledTools: the snapshot value from the first // getState call, not the changed value from later reads. expect(systemPromptCall[16]).toEqual(["execute_command"]) }) it("threads the captured state snapshot into the system prompt when manually condensing", async () => { // condenseContext captures provider state once and threads it into // getSystemPrompt so the prompt and the condensing tool array resolve // from one snapshot. Without threading, getSystemPrompt would re-read // provider state here and pick up the divergent second state below. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) vi.spyOn(mockProvider, "getState") // First call: the snapshot captured by condenseContext. .mockResolvedValueOnce(snapshot) // Any later getState() read returns different disabledTools, so a // re-read along the prompt path would change the observed behavior. .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) const getSystemPromptSpy = vi .spyOn(getTaskTestAccess(task), "getSystemPrompt") .mockResolvedValue("mock system prompt") await task.condenseContext() // Reference equality: without threading, the arguments would be // undefined and getSystemPrompt would re-read the divergent state and // re-guard the model metadata. The second argument must be the handler's // own settled snapshot, not just any object. expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot, task.api.getModel().info) }) it("threads the captured state snapshot into the system prompt when the context window is exceeded", async () => { // handleContextWindowExceededError captures provider state up front // and threads it into the getSystemPrompt call feeding manageContext; // a settings change mid-handler must not leak into the condensing prompt. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) vi.spyOn(mockProvider, "getState") // First call: the snapshot captured at the top of // handleContextWindowExceededError. .mockResolvedValueOnce(snapshot) // Any later getState() read returns different disabledTools, so a // re-read along the prompt path would change the observed behavior. .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) // Overflow the 50k window so manageContext takes the condense branch // (the module-mocked summarizeConversation returns a summary). vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 100_000, }) const ctxModelInfo: ModelInfo = { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } vi.spyOn(task.api, "getModel").mockReturnValue({ id: "ctx-model", info: ctxModelInfo, }) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) task.apiConversationHistory = [{ role: "user", content: [{ type: "text", text: "x" }], ts: Date.now() }] const getSystemPromptSpy = vi .spyOn(getTaskTestAccess(task), "getSystemPrompt") .mockResolvedValue("mock system prompt") await getTaskTestAccess(task).handleContextWindowExceededError(ctxModelInfo) // Reference equality: without threading, the arguments would be // undefined and getSystemPrompt would re-read the divergent state and // the model info; the second argument must be the snapshot threaded // into the handler, not a fresh re-read. expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot, ctxModelInfo) }) describe("emergency condense reporting (#1769)", () => { const ctxModelInfo: ModelInfo = { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } // Overflow the 50k window so the real handler reaches manageContext's condense branch. const createOverflowingTask = async (state: ProviderState) => { vi.spyOn(mockProvider, "getState").mockResolvedValue(state) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 100_000, }) vi.spyOn(task.api, "getModel").mockReturnValue({ id: "ctx-model", info: ctxModelInfo }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) task.apiConversationHistory = [{ role: "user", content: [{ type: "text", text: "x" }], ts: Date.now() }] const say = vi.spyOn(task, "say").mockResolvedValue(undefined) return { task, say } } it("emits the summary's condenseId and summarizes with the task's condense settings", async () => { const { task, say } = await createOverflowingTask( providerStateWith({ customSupportPrompts: { CONDENSE: "custom condense prompt" } }), ) vi.mocked(summarizeConversation).mockResolvedValueOnce({ messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: Date.now() }], summary: "summary", cost: 0, newContextTokens: 1, condenseId: "emergency-condense-id", }) await getTaskTestAccess(task).handleContextWindowExceededError(ctxModelInfo) // Rewind cleanup can only remove the summary if the event carries its condenseId. const condenseCall = say.mock.calls.find(([type]) => type === "condense_context") expect(condenseCall?.[7]).toMatchObject({ condenseId: "emergency-condense-id" }) const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) expect(options).toMatchObject({ customCondensingPrompt: "custom condense prompt", cwd: task.cwd }) }) it("surfaces a failed emergency condense as condense_context_error", async () => { const { task, say } = await createOverflowingTask(providerStateWith()) // Enough visible messages for the 50% fallback to hide a pair (floor(4 * 0.5) = 2). task.apiConversationHistory = ["a", "b", "c", "d", "e"].map((text, index) => ({ role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), content: [{ type: "text" as const, text }], ts: index + 1, })) const overwrite = vi.mocked(task.overwriteApiConversationHistory) vi.mocked(summarizeConversation).mockResolvedValueOnce({ messages: task.apiConversationHistory, summary: "", cost: 0, error: "Condensing failed: rate limited", }) await getTaskTestAccess(task).handleContextWindowExceededError(ctxModelInfo) expect(say).toHaveBeenCalledWith("condense_context_error", "Condensing failed: rate limited") // The sliding-window fallback still reports its truncation after the error row. expect(say.mock.calls.map(([type]) => type)).toEqual([ "condense_context_error", "sliding_window_truncation", ]) const truncatedHistory = requireDefined(overwrite.mock.calls.at(-1))[0] const marker = requireDefined(truncatedHistory.find((message) => message.isTruncationMarker)) const truncationCall = requireDefined( say.mock.calls.find(([type]) => type === "sliding_window_truncation"), ) const reported = requireDefined(truncationCall[8]) expect(reported.truncationId).toBe(marker.truncationId) expect(reported.messagesRemoved).toBe(2) expect( truncatedHistory.filter((message) => message.truncationParent === reported.truncationId), ).toHaveLength(2) expect(reported.prevContextTokens).toBeGreaterThanOrEqual(100_000) expect(reported.newContextTokens).toBeGreaterThan(0) expect(reported.newContextTokens).toBeLessThan(reported.prevContextTokens) }) }) it("uses the task mode when manually condensing after focused state changes", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "code" })) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) expect(options.metadata?.mode).toBe("architect") }) it("uses the task mode in request metadata when focused provider state differs", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue( providerStateWith({ mode: "ask", autoApprovalEnabled: true, requestDelaySeconds: 0 }), ) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(mockProvider, "getState").mockResolvedValue( providerStateWith({ mode: "code", autoApprovalEnabled: true, requestDelaySeconds: 0 }), ) const stream = (async function* () { yield { type: "text", text: "response" } as ApiStreamChunk })() const createMessage = vi.spyOn(task.api, "createMessage").mockReturnValue(stream) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] await task.attemptApiRequest().next() const metadata = requireDefined(createMessage.mock.calls[0])[2] expect(metadata?.mode).toBe("ask") }) it("condenses with an undefined state snapshot when the provider is gone", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() // Condensing must tolerate a collected provider: the snapshot read resolves to undefined and completes. Object.defineProperty(task, "providerRef", { value: { deref: () => undefined }, writable: false, configurable: true, }) const getSystemPromptSpy = vi .spyOn(getTaskTestAccess(task), "getSystemPrompt") .mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 0, }) vi.mocked(summarizeConversation).mockResolvedValueOnce({ messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: Date.now() }], summary: "summary", cost: 0, newContextTokens: 1, condenseId: "condense-id", }) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) await expect(task.condenseContext()).resolves.toBeUndefined() // The state snapshot stays undefined for a gone provider; the model-info // snapshot is still captured from the task's own api handler. expect(getSystemPromptSpy).toHaveBeenCalledWith(undefined, task.api.getModel().info) expect(overwriteSpy).toHaveBeenCalledTimes(1) }) it("rejects with the view-transition error when the provider ref is lost", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() // A collected provider must reject with the view-transition error, not with a state-read TypeError. Object.defineProperty(task, "providerRef", { value: { deref: () => undefined }, writable: false, configurable: true, }) // The undefined snapshot keeps the mcpEnabled gate open, so the request // reaches the provider guard and rejects there. await expect(getTaskTestAccess(task).getSystemPrompt(undefined)).rejects.toThrow( "Provider reference lost during view transition", ) }) it("rejects with the provider-unavailable error when the provider dies between state reads", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() // The caller's own snapshot read performs the only deref that finds the // provider alive - it flips the mock's liveness flag - so the provider // guard at the top of the prompt-building closure is what answers for the // ref that died before the prompt was built. let providerAlive = true const providerRef = { deref: () => { if (providerAlive) { providerAlive = false return mockProvider } return undefined }, } Object.defineProperty(task, "providerRef", { value: providerRef, writable: false, configurable: true, }) vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) const snapshot = await providerRef.deref()?.getState() await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).rejects.toThrow("Provider not available") }) }) describe("request policy snapshot publication", () => { // The wrapper is fire-and-forget; this is how the wrapper's own specs // settle its catch microtask before asserting. const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)) // Custom mode only present on the request-time read: the live double // below disagrees on every policy field, so a snapshot re-derived from // live state fails these tests instead of passing vacuously. const requestTimeCustomMode: ModeConfig = { slug: "snapshot-mode", name: "Snapshot Mode", roleDefinition: "Frozen at request build time.", groups: ["read"], } // Common seams so attemptApiRequest runs its capture and streams a first // chunk without touching prompt building, context management, or UX. function armRequestSeams(task: Task) { vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 0, }) vi.spyOn(task, "say").mockResolvedValue(undefined) } afterEach(() => { vi.restoreAllMocks() }) it("delivers the exact request-time snapshot to the presenter, immune to later provider-state changes", async () => { const requestTimeState = providerStateWith({ autoApprovalEnabled: false, disabledTools: ["read_file"], experiments: { customTools: true }, customModes: [requestTimeCustomMode], }) const laterState = providerStateWith({ autoApprovalEnabled: false, disabledTools: [], experiments: { customTools: false }, customModes: [], }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState") // First read: the one attemptApiRequest captures at request build. .mockResolvedValueOnce(requestTimeState) // Every later read returns divergent settings: any surviving // live re-read (in publication or consumption) would pick these // up and contradict the request's prompt. .mockResolvedValue(laterState) armRequestSeams(task) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) // The published snapshot disables read_file, so validation must // reject the tool call before any approval ask is reached. const askSpy = vi.spyOn(task, "ask").mockRejectedValue(new Error("approval path must not be reached")) vi.spyOn(task.api, "createMessage").mockReturnValue( asyncStreamFrom([{ type: "text", text: "ok" }]), ) // A completed non-partial native tool block: when the wrapper fires, // the real presenter validates it against the published snapshot. task.assistantMessageContent = [ { type: "tool_use", id: "tool_call_snapshot_1", name: "read_file", params: { path: "test.txt" }, nativeArgs: { path: "test.txt" }, partial: false, }, ] await expect(task.attemptApiRequest(0).next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" }, }) // Spied through to the original: this records the exact second // argument while the real presenter's consumption below still runs. const assistantMessageModule = await import("../../assistant-message") const presentSpy = vi.spyOn(assistantMessageModule, "presentAssistantMessage") getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) const delivered = requireDefined(presentSpy.mock.calls[0]?.[1]) // Exact four-field content — nothing more, nothing less — and each // field is the very reference read at request time, not the // divergent live values. expect(delivered).toEqual({ disabledTools: ["read_file"], experiments: { customTools: true }, customModes: [requestTimeCustomMode], modelInfo: stubModelInfo, }) expect(delivered.disabledTools).toBe(requestTimeState.disabledTools) expect(delivered.experiments).toBe(requestTimeState.experiments) expect(delivered.customModes).toBe(requestTimeState.customModes) expect(delivered.modelInfo).toBe(stubModelInfo) // End-to-end consumption: the rejection can only originate from the // snapshot's disabledTools; live state no longer disables read_file. const errorResults = task.userMessageContent.filter( (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result" && block.is_error === true, ) expect(errorResults).toHaveLength(1) // The wrapped validation error is exactly the snapshot's restriction // message; the divergent live list (no disabled tools) could not // produce it. expect(errorResults[0]?.content).toEqual(expect.stringContaining("not allowed")) expect(askSpy).not.toHaveBeenCalled() }) it("re-captures policy state for the retry attempt while the first attempt's snapshot stays frozen", async () => { const firstAttemptState = providerStateWith({ autoApprovalEnabled: false, disabledTools: ["read_file"], experiments: { customTools: true }, customModes: [requestTimeCustomMode], }) const retryAttemptState = providerStateWith({ autoApprovalEnabled: false, disabledTools: ["execute_command"], experiments: { customTools: false }, customModes: [], }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState") .mockResolvedValueOnce(firstAttemptState) // Settings change lands after attempt 1 was built: attempt 2's // generator re-entry must re-read, attempt 1 must not be rewritten. .mockResolvedValue(retryAttemptState) armRequestSeams(task) const safeEnsureModelFetchedSpy = vi .spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") .mockResolvedValue(stubModelInfo) // Seam between attempt 1's first-chunk failure and the retry // recursion: whatever the field holds here is exactly what attempt // 1's in-flight validation was bound to. let firstAttemptSnapshot: RequestPolicySnapshot | undefined vi.spyOn(getTaskTestAccess(task), "handleContextWindowExceededError").mockImplementation(async () => { firstAttemptSnapshot = getTaskTestAccess(task).requestPolicySnapshot }) const contextWindowFailure: AsyncGenerator = (async function* () { yield* [] throw { status: 400, message: "context length exceeded" } })() vi.spyOn(task.api, "createMessage") .mockImplementationOnce(() => contextWindowFailure) .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "retry response" }]), ) await expect(task.attemptApiRequest(0).next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "retry response" }, }) // What attempt 1's validation saw in flight: the request-time list. const attemptOne = requireDefined(firstAttemptSnapshot) expect(attemptOne.disabledTools).toEqual(["read_file"]) const retrySnapshot = requireDefined(getTaskTestAccess(task).requestPolicySnapshot) // The retry re-executed the generator-body capture and picked up the // settings that landed between attempts. expect(retrySnapshot.disabledTools).toEqual(["execute_command"]) expect(retrySnapshot.experiments).toEqual(retryAttemptState.experiments) expect(retrySnapshot.customModes).toEqual(retryAttemptState.customModes) // Re-capture replaces the snapshot object; it never mutates the one // the first attempt's in-flight validation already consumed. expect(retrySnapshot).not.toBe(attemptOne) expect(attemptOne.disabledTools).toEqual(["read_file"]) expect(attemptOne.experiments).toEqual(firstAttemptState.experiments) expect(attemptOne.customModes).toEqual(firstAttemptState.customModes) // modelInfo carries the request-scoped resolution on every attempt: // retries forward the once-resolved metadata instead of re-resolving, // so model-specific tool policy cannot move mid-request. expect(retrySnapshot.modelInfo).toBe(stubModelInfo) expect(safeEnsureModelFetchedSpy).toHaveBeenCalledTimes(1) // The retry request's presenter receives the re-captured snapshot. const assistantMessageModule = await import("../../assistant-message") const presentSpy = vi.spyOn(assistantMessageModule, "presentAssistantMessage").mockResolvedValue(undefined) getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) expect(presentSpy.mock.calls[0]?.[1].disabledTools).toEqual(["execute_command"]) }) it("surfaces the presenter's missing-snapshot throw as a real failure without aborting the run", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Drain constructor noise so only the wrapper's own log is asserted. await flushMicrotasks() consoleErrorSpy.mockClear() // No attemptApiRequest ran, so nothing published a snapshot: the // wrapper passes undefined and the real presenter's guard throws. getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() const presentErrors = consoleErrorSpy.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"), ) // The guard's message deliberately lacks the "aborted" suffix, so the // wrapper must log it as a real failure rather than treat the run as // cancelled. expect(presentErrors).toHaveLength(1) const loggedError = presentErrors[0]?.[1] if (loggedError instanceof Error) { expect(loggedError.message).toMatch(/missing request policy snapshot/) } else { throw new Error("Expected the missing-snapshot throw to be logged as a real Error") } expect(task.abort).toBeFalsy() }) }) describe("sayAndCreateMissingParamError", () => { it("surfaces a localized error notice and returns the missing-parameter tool error for both relPath branches", async () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const saySpy = vi.spyOn(cline, "say").mockResolvedValue(undefined) // relPath provided -> the "...WithPath" message branch. const withPath = await cline.sayAndCreateMissingParamError("read_file", "path", "src/foo.ts") // relPath omitted -> the plain message branch. const withoutPath = await cline.sayAndCreateMissingParamError("execute_command", "command") // Both branches emit an "error" say whose resolved text names the tool and the // missing parameter (guards against a silent i18n regression where t() would // otherwise return the raw key or an empty string and still type-check as a String). expect(saySpy).toHaveBeenCalledTimes(2) const [withPathChannel, withPathNotice] = saySpy.mock.calls[0] const [withoutPathChannel, withoutPathNotice] = saySpy.mock.calls[1] expect(withPathChannel).toBe("error") expect(withoutPathChannel).toBe("error") expect(withPathNotice).toEqual(expect.stringContaining("read_file")) expect(withPathNotice).toEqual(expect.stringContaining("path")) expect(withPathNotice).toEqual(expect.stringContaining("src/foo.ts")) expect(withoutPathNotice).toEqual(expect.stringContaining("execute_command")) expect(withoutPathNotice).toEqual(expect.stringContaining("command")) // The returned tool error names the missing parameter. expect(withPath).toContain("path") expect(withoutPath).toContain("command") }) }) describe("getEnvironmentDetails", () => { describe("API conversation handling", () => { it("should strip non-protocol fields from API conversation history before sending to the API", async () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(error: unknown) { throw error }, async [Symbol.asyncDispose]() { // Cleanup }, } as AsyncGenerator const createMessageSpy = vi.spyOn(cline.api, "createMessage").mockReturnValue(mockStream) cline.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), extraProp: "should be removed", }, ] as Array const iterator = cline.attemptApiRequest(0) await iterator.next() const [, cleanConversationHistory] = createMessageSpy.mock.calls[0]! expect(cleanConversationHistory).toEqual([ { role: "user", content: [{ type: "text", text: "test message" }], }, ]) expect(Object.keys(cleanConversationHistory[0]!)).toEqual(["role", "content"]) }) it("should shape image blocks for API compatibility before request construction", async () => { const conversationHistory = [ { role: "user" as const, content: [ { type: "text" as const, text: "Here is an image" }, { type: "image" as const, source: { type: "base64" as const, media_type: "image/jpeg", data: "base64data", }, }, ], }, ] const withImages = new Task({ provider: mockProvider, apiConfiguration: { ...mockApiConfig, apiModelId: "claude-3-sonnet", }, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(withImages), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(withImages.api, "getModel").mockReturnValue({ id: "claude-3-sonnet", info: { supportsImages: true, supportsPromptCache: true, contextWindow: 200000, maxTokens: 4096, inputPrice: 0.25, outputPrice: 0.75, } as ModelInfo, }) const withoutImages = new Task({ provider: mockProvider, apiConfiguration: { ...mockApiConfig, apiModelId: "gpt-3.5-turbo", }, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(withoutImages), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(withoutImages.api, "getModel").mockReturnValue({ id: "gpt-3.5-turbo", info: { supportsImages: false, supportsPromptCache: false, contextWindow: 16000, maxTokens: 2048, inputPrice: 0.1, outputPrice: 0.2, } as ModelInfo, }) const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(error: unknown) { throw error }, async [Symbol.asyncDispose]() { // Cleanup }, } as AsyncGenerator const withImagesSpy = vi.spyOn(withImages.api, "createMessage").mockReturnValue(mockStream) const withoutImagesSpy = vi.spyOn(withoutImages.api, "createMessage").mockReturnValue(mockStream) withImages.apiConversationHistory = conversationHistory as ApiMessage[] withoutImages.apiConversationHistory = conversationHistory as ApiMessage[] const withImagesIterator = withImages.attemptApiRequest(0) await withImagesIterator.next() const withoutImagesIterator = withoutImages.attemptApiRequest(0) await withoutImagesIterator.next() const [, historyWithImages] = withImagesSpy.mock.calls[0]! const [, historyWithoutImages] = withoutImagesSpy.mock.calls[0]! expect(historyWithImages).toEqual([ { role: "user", content: [ { type: "text", text: "Here is an image" }, { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data", }, }, ], }, ]) expect(historyWithoutImages).toEqual([ { role: "user", content: [ { type: "text", text: "Here is an image" }, { type: "text", text: "[Referenced image in conversation]" }, ], }, ]) }) it("should handle API retry with countdown", async () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock delay to track countdown timing const mockDelay = vi.fn().mockResolvedValue(undefined) vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay) // Mock say to track messages const saySpy = vi.spyOn(cline, "say") // Create a stream that fails on first chunk const mockError = new Error("API Error") const mockFailedStream = { // eslint-disable-next-line require-yield async *[Symbol.asyncIterator]() { throw mockError }, async next() { throw mockError }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, async [Symbol.asyncDispose]() { // Cleanup }, } as AsyncGenerator // Create a successful stream for retry const mockSuccessStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "Success" } }, async next() { return { done: true, value: { type: "text", text: "Success" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, async [Symbol.asyncDispose]() { // Cleanup }, } as AsyncGenerator // Mock createMessage to fail first then succeed let firstAttempt = true vi.spyOn(cline.api, "createMessage").mockImplementation(() => { if (firstAttempt) { firstAttempt = false return mockFailedStream } return mockSuccessStream }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 3, }) // Trigger API request const iterator = cline.attemptApiRequest(0) await iterator.next() const retryMessages = saySpy.mock.calls.filter((call) => call[0] === "api_req_retry_delayed") expect(retryMessages).toEqual([ ["api_req_retry_delayed", "API Error\n3", undefined, true], ["api_req_retry_delayed", "API Error\n2", undefined, true], ["api_req_retry_delayed", "API Error\n1", undefined, true], ["api_req_retry_delayed", "API Error\n", undefined, false], ]) expect(mockDelay).toHaveBeenCalledTimes(3) expect(mockDelay).toHaveBeenCalledWith(1000) }) it("uses the task rate limit in retry backoff when focused provider state differs", async () => { const clock = createRateLimitClock() const rateLimitConfig = { ...mockApiConfig, rateLimitSeconds: 10, } const cline = new Task({ provider: mockProvider, apiConfiguration: rateLimitConfig, task: "test task", startTask: false, rateLimitClock: clock, }) vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") const mockDelay = vi.fn().mockResolvedValue(undefined) vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay) const saySpy = vi.spyOn(cline, "say") const mockError = new Error("API Error") const mockFailedStream = { // eslint-disable-next-line require-yield async *[Symbol.asyncIterator]() { throw mockError }, async next() { throw mockError }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, async [Symbol.asyncDispose]() {}, } as AsyncGenerator const mockSuccessStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "Success" } }, async next() { return { done: true, value: { type: "text", text: "Success" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, async [Symbol.asyncDispose]() {}, } as AsyncGenerator let firstAttempt = true vi.spyOn(cline.api, "createMessage").mockImplementation(() => { if (firstAttempt) { firstAttempt = false return mockFailedStream } return mockSuccessStream }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: { ...mockApiConfig, rateLimitSeconds: 1, }, autoApprovalEnabled: true, requestDelaySeconds: 3, }) const iterator = cline.attemptApiRequest(0) await iterator.next() // The task rateLimitSeconds=10 (rather than the focused provider's 1) // exceeds exponentialDelay=ceil(3*2^0)=3, so // finalDelay=10 and the countdown loop fires delay(1000) ten times. expect(mockDelay).toHaveBeenCalledWith(1000) expect(mockDelay).toHaveBeenCalledTimes(10) const countdownMessages = saySpy.mock.calls.filter( ([type, text, , partial]) => type === "api_req_retry_delayed" && partial && typeof text === "string", ) expect(countdownMessages.map(([, text]) => text)).toEqual( Array.from({ length: 10 }, (_, index) => `API Error\n${10 - index}`), ) expect(clock.getLastRequestTime()).toBeDefined() }) it("should not apply retry delay twice", async () => { const cline = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(cline), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock delay to track countdown timing const mockDelay = vi.fn().mockResolvedValue(undefined) vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay) // Mock say to track messages const saySpy = vi.spyOn(cline, "say") // Create a stream that fails on first chunk const mockError = new Error("API Error") const mockFailedStream = { // eslint-disable-next-line require-yield async *[Symbol.asyncIterator]() { throw mockError }, async next() { throw mockError }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, async [Symbol.asyncDispose]() { // Cleanup }, } as AsyncGenerator // Create a successful stream for retry const mockSuccessStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "Success" } }, async next() { return { done: true, value: { type: "text", text: "Success" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, async [Symbol.asyncDispose]() { // Cleanup }, } as AsyncGenerator // Mock createMessage to fail first then succeed let firstAttempt = true vi.spyOn(cline.api, "createMessage").mockImplementation(() => { if (firstAttempt) { firstAttempt = false return mockFailedStream } return mockSuccessStream }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 3, }) // Trigger API request const iterator = cline.attemptApiRequest(0) await iterator.next() expect(mockDelay).toHaveBeenCalledTimes(3) expect(mockDelay).toHaveBeenCalledWith(1000) // Each delay should be 1 second // Verify countdown messages were only shown once const retryMessages = saySpy.mock.calls.filter( (call) => call[0] === "api_req_retry_delayed" && call[3] === true, ) expect(retryMessages).toEqual([ ["api_req_retry_delayed", "API Error\n3", undefined, true], ["api_req_retry_delayed", "API Error\n2", undefined, true], ["api_req_retry_delayed", "API Error\n1", undefined, true], ]) }) describe("processUserContentMentions", () => { it("should process mentions in user_message tags", async () => { const [cline, task] = Task.create({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", }) const userContent = [ { type: "text", text: "Regular text with 'some/path' (see below for file content)", } as const, { type: "text", text: "Text with 'some/path' (see below for file content) in user_message tags", } as const, { type: "tool_result", tool_use_id: "test-id", content: [ { type: "text", text: "Check 'some/path' (see below for file content)", }, ], } as Anthropic.ToolResultBlockParam, { type: "tool_result", tool_use_id: "test-id-2", content: [ { type: "text", text: "Regular tool result with 'path' (see below for file content)", }, ], } as Anthropic.ToolResultBlockParam, ] const { content: processedContent } = await processUserContentMentions({ userContent, cwd: cline.cwd, fileContextTracker: cline.fileContextTracker, }) // Regular text should not be processed expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe( "Regular text with 'some/path' (see below for file content)", ) // Text within user_message tags should be processed expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:") expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain( "Text with 'some/path' (see below for file content) in user_message tags", ) // user_message tag content should be processed const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content expect((content1 as Anthropic.TextBlockParam).text).toContain("processed:") expect((content1 as Anthropic.TextBlockParam).text).toContain( "Check 'some/path' (see below for file content)", ) // Regular tool result should not be processed const toolResult2 = processedContent[3] as Anthropic.ToolResultBlockParam const content2 = Array.isArray(toolResult2.content) ? toolResult2.content[0] : toolResult2.content expect((content2 as Anthropic.TextBlockParam).text).toBe( "Regular tool result with 'path' (see below for file content)", ) await cline.abortTask(true) await task.catch(() => {}) }) }) }) describe("slash command mode switch", () => { it("passes null as targetTask so handleModeSwitch does not overwrite the orchestrator task", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const ensureModelFetched = vi.fn().mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched }) vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200_000, maxTokens: 4096, } as ModelInfo, }) vi.mocked(processUserContentMentions).mockResolvedValueOnce({ content: [{ type: "text", text: "run /commit" }], mode: "code", }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined as never) vi.spyOn(getTaskTestAccess(task), "addToApiConversationHistory").mockResolvedValue(undefined) task.clineMessages = [{ ts: Date.now(), type: "say", say: "api_req_started", text: "{}" }] vi.spyOn(task, "say").mockImplementation(async (type) => { if (type === "api_req_started") { task.clineMessages.push({ ts: Date.now(), type: "say", say: "api_req_started", text: "{}" }) } return undefined as never }) vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { throw new Error("stop after mode switch") }) const handleModeSwitchSpy = vi.spyOn(mockProvider, "handleModeSwitch").mockResolvedValue(undefined) await task.recursivelyMakeClineRequests([{ type: "text", text: "run /commit" }]).catch(() => {}) expect(handleModeSwitchSpy).toHaveBeenCalledWith("code", null) }) }) describe("Subtask Rate Limiting", () => { let mockProvider: MockedClineProvider let mockApiConfig: RateLimitedProviderSettings let mockDelay: ReturnType beforeEach(() => { vi.clearAllMocks() mockApiConfig = { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", rateLimitSeconds: 5, } mockProvider = { context: { globalStorageUri: { fsPath: "/test/storage" }, globalState: { get: vi.fn().mockImplementation(() => undefined), update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, }, getState: vi.fn().mockResolvedValue({ apiConfiguration: mockApiConfig, mcpEnabled: false, }), getMcpHub: vi.fn().mockReturnValue(undefined), getSkillsManager: vi.fn().mockReturnValue(undefined), say: vi.fn(), postStateToWebview: vi.fn().mockResolvedValue(undefined), postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), // Task receives a full ClineProvider at runtime; this focused unit test only exercises these methods. } as unknown as MockedClineProvider // Get the mocked delay function mockDelay = delay as ReturnType mockDelay.mockClear() }) it("should enforce rate limiting across parent and subtask", async () => { // Shared clock so parent and child see each other's timestamps const sharedClock = createRateLimitClock() // Create parent task const parent = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "parent task", startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "parent response" } }, async next() { return { done: true, value: { type: "text", text: "parent response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the parent task const parentIterator = parent.attemptApiRequest(0) await parentIterator.next() // Verify no delay was applied for the first request expect(mockDelay).not.toHaveBeenCalled() // Create a subtask immediately after, sharing the same clock const child = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "child task", parentTask: parent, rootTask: parent, startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") // Spy on child.say to verify the emitted message type const saySpy = vi.spyOn(child, "say") // Mock the child's API stream const childMockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "child response" } }, async next() { return { done: true, value: { type: "text", text: "child response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator vi.spyOn(child.api, "createMessage").mockReturnValue(childMockStream) // Make an API request with the child task const childIterator = child.attemptApiRequest(0) await childIterator.next() // Verify rate limiting was applied expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds) expect(mockDelay).toHaveBeenCalledWith(1000) // Verify we used the non-error rate-limit wait message type (JSON format) expect(saySpy).toHaveBeenCalledWith( "api_req_rate_limit_wait", expect.stringMatching(/\{"seconds":\d+\}/), undefined, true, ) // Verify the wait message was finalized expect(saySpy).toHaveBeenCalledWith("api_req_rate_limit_wait", undefined, undefined, false) }, 10000) // Increase timeout to 10 seconds it("should not apply rate limiting if enough time has passed", async () => { const sharedClock = createRateLimitClock() // Create parent task const parent = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "parent task", startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the parent task const parentIterator = parent.attemptApiRequest(0) await parentIterator.next() // Simulate time passing (more than rate limit) const originalPerformanceNow = performance.now const mockTime = performance.now() + (mockApiConfig.rateLimitSeconds + 1) * 1000 performance.now = vi.fn(() => mockTime) // Create a subtask after time has passed const child = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "child task", parentTask: parent, rootTask: parent, startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the child task const childIterator = child.attemptApiRequest(0) await childIterator.next() // Verify no rate limiting was applied expect(mockDelay).not.toHaveBeenCalled() // Restore performance.now performance.now = originalPerformanceNow }) it("should share rate limiting across multiple subtasks", async () => { const sharedClock = createRateLimitClock() // Create parent task const parent = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "parent task", startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the parent task const parentIterator = parent.attemptApiRequest(0) await parentIterator.next() // Create first subtask const child1 = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "child task 1", parentTask: parent, rootTask: parent, startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(child1), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child1.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the first child task const child1Iterator = child1.attemptApiRequest(0) await child1Iterator.next() // Verify rate limiting was applied const firstDelayCount = mockDelay.mock.calls.length expect(firstDelayCount).toBe(mockApiConfig.rateLimitSeconds) // Clear the mock to count new delays mockDelay.mockClear() // Create second subtask immediately after const child2 = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "child task 2", parentTask: parent, rootTask: parent, startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(child2), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child2.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the second child task const child2Iterator = child2.attemptApiRequest(0) await child2Iterator.next() // Verify rate limiting was applied again expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds) }, 15000) // Increase timeout to 15 seconds it("should handle rate limiting with zero rate limit", async () => { // Update config to have zero rate limit mockApiConfig.rateLimitSeconds = 0 mockProvider.getState.mockResolvedValue({ apiConfiguration: mockApiConfig, mcpEnabled: false, }) const sharedClock = createRateLimitClock() // Create parent task const parent = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "parent task", startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(parent), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the parent task const parentIterator = parent.attemptApiRequest(0) await parentIterator.next() // Create a subtask const child = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "child task", parentTask: parent, rootTask: parent, startTask: false, rateLimitClock: sharedClock, }) vi.spyOn(getTaskTestAccess(child), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream) // Make an API request with the child task const childIterator = child.attemptApiRequest(0) await childIterator.next() // Verify no delay was applied expect(mockDelay).not.toHaveBeenCalled() }) it("should update clock timestamp even when no rate limiting is needed", async () => { const clock = createRateLimitClock() // Create task const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, rateLimitClock: clock, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") // Mock the API stream response const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) // Make an API request const iterator = task.attemptApiRequest(0) await iterator.next() const lastTime = clock.getLastRequestTime() expect(lastTime).toBeDefined() expect(lastTime).toBeGreaterThan(0) }) }) describe("Dynamic Strategy Selection", () => { let mockProvider: MockedClineProvider let mockApiConfig: ProviderSettings beforeEach(() => { vi.clearAllMocks() mockApiConfig = { apiProvider: providerIdentifiers.anthropic, apiKey: "test-key", } mockProvider = { context: { globalStorageUri: { fsPath: "/test/storage" }, }, getState: vi.fn(), } as MockedClineProvider }) it("should use MultiSearchReplaceDiffStrategy by default", async () => { mockProvider.getState.mockResolvedValue({}) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Should be MultiSearchReplaceDiffStrategy expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") }) it("should keep MultiSearchReplaceDiffStrategy when experiments are undefined", async () => { mockProvider.getState.mockResolvedValue({}) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Initially should be MultiSearchReplaceDiffStrategy expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) // Wait for async strategy update await new Promise((resolve) => setTimeout(resolve, 10)) // Should still be MultiSearchReplaceDiffStrategy expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy) expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace") }) }) describe("getApiProtocol", () => { it("should determine API protocol based on provider and model", async () => { // Test with Anthropic provider const anthropicConfig = { ...mockApiConfig, apiProvider: providerIdentifiers.anthropic, apiModelId: "gpt-4", } const anthropicTask = new Task({ provider: mockProvider, apiConfiguration: anthropicConfig, task: "test task", startTask: false, }) // Should use anthropic protocol even with non-claude model expect(anthropicTask.apiConfiguration.apiProvider).toBe("anthropic") // Test with OpenRouter provider and Claude model const openrouterClaudeConfig = { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "anthropic/claude-3-opus", } const openrouterClaudeTask = new Task({ provider: mockProvider, apiConfiguration: openrouterClaudeConfig, task: "test task", startTask: false, }) expect(openrouterClaudeTask.apiConfiguration.apiProvider).toBe("openrouter") // Test with OpenRouter provider and non-Claude model const openrouterGptConfig = { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "openai/gpt-4", } const openrouterGptTask = new Task({ provider: mockProvider, apiConfiguration: openrouterGptConfig, task: "test task", startTask: false, }) expect(openrouterGptTask.apiConfiguration.apiProvider).toBe("openrouter") // Test with various Claude model formats const claudeModelFormats = [ "claude-3-opus", "Claude-3-Sonnet", "CLAUDE-instant", "anthropic/claude-3-haiku", "some-provider/claude-model", ] for (const modelId of claudeModelFormats) { const config = { apiProvider: providerIdentifiers.openai, openAiModelId: modelId, } new Task({ provider: mockProvider, apiConfiguration: config, task: "test task", startTask: false, }) // Verify the model ID contains claude (case-insensitive) expect(modelId.toLowerCase()).toContain("claude") } }) it("should handle edge cases for API protocol detection", async () => { // Test with undefined provider const undefinedProviderConfig = { apiModelId: "claude-3-opus", } const undefinedProviderTask = new Task({ provider: mockProvider, apiConfiguration: undefinedProviderConfig, task: "test task", startTask: false, }) expect(undefinedProviderTask.apiConfiguration.apiProvider).toBeUndefined() // Test with no model ID const noModelConfig = { apiProvider: providerIdentifiers.openai, } const noModelTask = new Task({ provider: mockProvider, apiConfiguration: noModelConfig, task: "test task", startTask: false, }) expect(noModelTask.apiConfiguration.apiProvider).toBe("openai") }) }) describe("submitUserMessage", () => { it("should call handleWebviewAskResponse directly", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "initial task", startTask: false, }) // Spy on handleWebviewAskResponse const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse") // Set up some existing messages to simulate an ongoing conversation task.clineMessages = [ { ts: Date.now(), type: "say", say: "text", text: "Initial message", }, ] // Call submitUserMessage await task.submitUserMessage("test message", ["image1.png"]) // Verify handleWebviewAskResponse was called directly (not webview) expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "test message", ["image1.png"]) // Should NOT route through webview anymore expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) it("uses a mode selected through submitUserMessage in the next API request", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "ask" })) vi.spyOn(mockProvider, "setMode").mockResolvedValue(undefined) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "initial task", startTask: false, }) vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) await task.submitUserMessage("switch modes", undefined, "code") vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") const stream = (async function* () { yield { type: "text", text: "response" } as ApiStreamChunk })() const createMessage = vi.spyOn(task.api, "createMessage").mockReturnValue(stream) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] await task.attemptApiRequest().next() expect(mockProvider.setMode).toHaveBeenCalledWith("code") expect(requireDefined(createMessage.mock.calls[0])[2]?.mode).toBe("code") }) it("stores a provider profile selected through submitUserMessage", async () => { const selectedConfiguration: ProviderSettings = { ...mockApiConfig, apiModelId: "selected-model", } const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "initial task", startTask: false, }) task.setTaskApiConfigName("previous-profile") vi.spyOn(mockProvider, "setProviderProfile").mockResolvedValue(undefined) vi.spyOn(mockProvider, "getState").mockResolvedValue( providerStateWith({ currentApiConfigName: "selected-profile", apiConfiguration: selectedConfiguration, }), ) vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) await task.submitUserMessage("switch profiles", undefined, undefined, "selected-profile") expect(task.taskApiConfigName).toBe("selected-profile") }) it("should handle empty messages gracefully", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "initial task", startTask: false, }) // Spy on handleWebviewAskResponse const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse") // Call with empty text and no images await task.submitUserMessage("", []) // Should not call handleWebviewAskResponse for empty messages expect(handleResponseSpy).not.toHaveBeenCalled() // Call with whitespace only await task.submitUserMessage(" ", []) expect(handleResponseSpy).not.toHaveBeenCalled() }) it("should call handleWebviewAskResponse for both new and existing task states", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "initial task", startTask: false, }) // Spy on handleWebviewAskResponse const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse") // Test with no messages (new task scenario) task.clineMessages = [] await task.submitUserMessage("new task", ["image1.png"]) expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "new task", ["image1.png"]) // Clear mock handleResponseSpy.mockClear() // Test with existing messages (ongoing task scenario) task.clineMessages = [ { ts: Date.now(), type: "say", say: "text", text: "Initial message", }, ] await task.submitUserMessage("follow-up message", ["image2.png"]) expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "follow-up message", ["image2.png"]) }) it("should handle undefined provider gracefully", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "initial task", startTask: false, }) // Spy on handleWebviewAskResponse const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse") // Simulate weakref returning undefined Object.defineProperty(task, "providerRef", { value: { deref: () => undefined }, writable: false, configurable: true, }) // Spy on console.error to verify error is logged const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Should log error but not throw await task.submitUserMessage("test message") expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#submitUserMessage] Provider reference lost") expect(handleResponseSpy).not.toHaveBeenCalled() // Restore console.error consoleErrorSpy.mockRestore() }) }) }) describe("webview state throttling", () => { it("schedules a complete new message without forcing an immediate state push", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) const message = { ts: Date.now(), type: "say" as const, say: "text" as const, text: "message", } await getTaskTestAccess(task).addToClineMessages(message) expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled() expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) it("waits for an unanswered ask flush before emitting the message", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const taskAccess = getTaskTestAccess(task) vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) let releaseFlush!: () => void const pendingFlush = new Promise((resolve) => { releaseFlush = resolve }) const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush) const messageListener = vi.fn() task.on(RooCodeEventName.Message, messageListener) const message = { ts: 1, type: "ask" as const, ask: "resume_task" as const, } const addPromise = taskAccess.addToClineMessages(message) await Promise.resolve() expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() expect(flushSpy).toHaveBeenCalledOnce() expect(flushSpy).toHaveBeenCalledWith() expect(messageListener).not.toHaveBeenCalled() releaseFlush() await addPromise expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) }) it("continues the message lifecycle when throttled state scheduling and flushing fail", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const taskAccess = getTaskTestAccess(task) const postError = new Error("state schedule failed") const flushError = new Error("state flush failed") const postSpy = vi.mocked(mockProvider.postStateToWebviewThrottled).mockRejectedValueOnce(postError) const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(flushError) const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) const messageListener = vi.fn() const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) task.on(RooCodeEventName.Message, messageListener) const message = { ts: 1, type: "ask" as const, ask: "resume_task" as const, } await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#addToClineMessages] postStateToWebviewThrottled failed:", postError, ) expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", flushError, ) expect(postSpy).toHaveBeenCalledOnce() expect(flushSpy).toHaveBeenCalledOnce() expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) expect(saveSpy).toHaveBeenCalledOnce() expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(flushSpy.mock.invocationCallOrder[0]) expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) expect(messageListener.mock.invocationCallOrder[0]).toBeLessThan(saveSpy.mock.invocationCallOrder[0]) consoleErrorSpy.mockRestore() }) it("keeps an already answered ask on the throttled path", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) await getTaskTestAccess(task).addToClineMessages({ ts: 1, type: "ask", ask: "tool", isAnswered: true, }) expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled() }) it("keeps a new partial message on the throttled path without flushing", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const taskAccess = getTaskTestAccess(task) vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) const updatePostSpy = vi.mocked(mockProvider.postMessageToWebview) const partialMessage = { ts: 1, type: "say" as const, say: "text" as const, text: "partial message", partial: true, } await taskAccess.addToClineMessages(partialMessage) await taskAccess.updateClineMessage({ ...partialMessage, text: "updated partial" }) // Flushing here would cancel the debounce's trailing timer and send the *next* // message straight through the leading edge, collapsing the throttle back to one // full-state post per message. expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled() expect(updatePostSpy).toHaveBeenCalledWith({ type: "messageUpdated", clineMessage: { ...partialMessage, text: "updated partial", }, }) }) }) describe("partial message update throttling", () => { function createStreamingTask() { // Task mutates one ClineMessage across deltas and finalization, so snapshot the // fields of each messageUpdated post when it happens instead of keeping the reference. const messageUpdates: Array> = [] vi.mocked(mockProvider.postMessageToWebview).mockImplementation(async (message) => { if (message.type === "messageUpdated" && message.clineMessage) { const { ts, text, partial } = message.clineMessage messageUpdates.push({ ts, text, partial }) } }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) return { task, messageUpdates } } beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it("coalesces a burst of partial updates and delivers the latest text last", async () => { const { task, messageUpdates } = createStreamingTask() await task.say("text", "chunk 0", undefined, true) for (let i = 1; i <= 50; i++) { await task.say("text", `chunk ${i}`, undefined, true) vi.advanceTimersByTime(5) } await vi.advanceTimersByTimeAsync(500) // 50 deltas over ~250ms: far fewer than one post per delta. expect(messageUpdates.length).toBeGreaterThan(0) expect(messageUpdates.length).toBeLessThan(10) expect(messageUpdates.at(-1)).toMatchObject({ text: "chunk 50", partial: true }) }) it("lets the finalized message through and drops the queued partial post", async () => { const { task, messageUpdates } = createStreamingTask() await task.say("text", "first", undefined, true) await task.say("text", "second", undefined, true) await task.say("text", "third", undefined, true) // "third" is queued behind the throttle; finalizing must cancel that queued post. expect(vi.getTimerCount()).toBe(1) await task.say("text", "final text", undefined, false) expect(vi.getTimerCount()).toBe(0) await vi.advanceTimersByTimeAsync(500) expect(messageUpdates.map(({ text, partial }) => ({ text, partial }))).toEqual([ { text: "second", partial: true }, { text: "final text", partial: false }, ]) }) it("drops a queued partial ask update when the ask is finalized", async () => { const { task, messageUpdates } = createStreamingTask() // Partial asks always reject with AskIgnoredError. await task.ask("tool", "first", true).catch(() => {}) await task.ask("tool", "second", true).catch(() => {}) await task.ask("tool", "third", true).catch(() => {}) expect(task["pendingPartialMessageTimer"]).toBeDefined() // A complete ask resolves only after a response, so leave it pending. Step 1ms per check // so the final post lands well before the queued timer would fire on its own. void task.ask("tool", "final", false).catch(() => {}) await vi.waitFor(() => expect(messageUpdates.at(-1)?.partial).toBe(false), { interval: 1 }) expect(task["pendingPartialMessageTimer"]).toBeUndefined() await vi.advanceTimersByTimeAsync(500) expect(messageUpdates.map(({ text, partial }) => ({ text, partial }))).toEqual([ { text: "second", partial: true }, { text: "final", partial: false }, ]) }) it("drops a queued partial update when the task is disposed", async () => { const { task, messageUpdates } = createStreamingTask() await task.say("text", "first", undefined, true) await task.say("text", "second", undefined, true) await task.say("text", "third", undefined, true) expect(task["pendingPartialMessageTimer"]).toBeDefined() await task.dispose() expect(task["pendingPartialMessageTimer"]).toBeUndefined() await vi.advanceTimersByTimeAsync(500) expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) }) it("holds a queued update until the 120ms interval elapses", async () => { const { task, messageUpdates } = createStreamingTask() await task.say("text", "first", undefined, true) await task.say("text", "second", undefined, true) await task.say("text", "third", undefined, true) expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) await vi.advanceTimersByTimeAsync(119) expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) await vi.advanceTimersByTimeAsync(1) expect(messageUpdates.map(({ text }) => text)).toEqual(["second", "third"]) }) it("delivers a queued update for one message before updating a different message", async () => { const { task, messageUpdates } = createStreamingTask() await task.say("reasoning", "r1", undefined, true) await task.say("reasoning", "r2", undefined, true) await task.say("reasoning", "r3", undefined, true) expect(messageUpdates.map(({ text }) => text)).toEqual(["r2"]) // A new message starts, then streams: its update must not replace the queued "r3". // Message timestamps are Date.now(), which fake timers freeze, so step the clock. await vi.advanceTimersByTimeAsync(1) await task.say("text", "t1", undefined, true) await task.say("text", "t2", undefined, true) expect(messageUpdates.map(({ text }) => text)).toEqual(["r2", "r3"]) await vi.advanceTimersByTimeAsync(500) expect(messageUpdates.map(({ text }) => text)).toEqual(["r2", "r3", "t2"]) // The queued "r3" post keeps the reasoning message's ts; "t2" targets the text message. expect(messageUpdates[1].ts).toBe(messageUpdates[0].ts) expect(messageUpdates[2].ts).not.toBe(messageUpdates[0].ts) }) it("does not delay the trailing post beyond the interval when the clock steps backward", async () => { const { task, messageUpdates } = createStreamingTask() await task.say("text", "first", undefined, true) await task.say("text", "second", undefined, true) expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) vi.setSystemTime(Date.now() - 10_000) await task.say("text", "third", undefined, true) expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) await vi.advanceTimersByTimeAsync(120) expect(messageUpdates.map(({ text }) => text)).toEqual(["second", "third"]) }) it("posts updates that are far enough apart immediately", async () => { const { task, messageUpdates } = createStreamingTask() await task.say("text", "one", undefined, true) await task.say("text", "two", undefined, true) await vi.advanceTimersByTimeAsync(1000) await task.say("text", "three", undefined, true) // "one" creates the message; "two" and "three" are each the first update in their window. expect(messageUpdates.map(({ text }) => text)).toEqual(["two", "three"]) }) }) describe("abortTask", () => { it("should set abort flag and emit TaskAborted event", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Spy on emit method const emitSpy = vi.spyOn(task, "emit") const persistenceWait = task.waitForCurrentAssistantMessagePersistence() // Mock the dispose method to avoid actual cleanup vi.spyOn(task, "dispose").mockResolvedValue(undefined) // Call abortTask await task.abortTask() // Verify abort flag is set expect(task.abort).toBe(true) expect(task.abandoned).toBe(false) // Verify TaskAborted event was emitted expect(emitSpy).toHaveBeenCalledWith("taskAborted") await expect(persistenceWait).resolves.toBe(false) }) it("should be equivalent to clicking Cancel button functionality", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Mock the dispose method to track cleanup const disposeSpy = vi.spyOn(task, "dispose").mockResolvedValue(undefined) // Call abortTask await task.abortTask() // Verify the same behavior as Cancel button expect(task.abort).toBe(true) expect(disposeSpy).toHaveBeenCalled() }) it("does not wait for ancillary disposal cleanup before abort resolves", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) let resolveDisposal: () => void const disposal = new Promise((resolve) => { resolveDisposal = resolve }) const disposeSpy = vi.spyOn(task, "dispose").mockReturnValue(disposal) const abort = task.abortTask() await vi.waitFor(() => expect(disposeSpy).toHaveBeenCalledOnce()) await abort expect(disposeSpy).toHaveBeenCalledOnce() resolveDisposal!() await disposal }) it("memoizes concurrent aborts while preserving abandoned state", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const emitSpy = vi.spyOn(task, "emit") vi.spyOn(task, "dispose").mockResolvedValue(undefined) const firstAbort = task.abortTask() const secondAbort = task.abortTask(true) expect(secondAbort).toBe(firstAbort) expect(task.abandoned).toBe(true) await firstAbort expect( (emitSpy.mock.calls as unknown[][]).filter(([event]) => event === RooCodeEventName.TaskAborted), ).toHaveLength(1) }) it("advances the cancellation generation on each cancellation and keeps it advanced across a resume", async () => { // The S4a write guard compares the cancellation generation a queued write // captured against the generation at publish time. The abort FLAG cannot do // that on its own: it is set on cancellation and no resume path clears it, // so on a resumed task it says only that some cancellation happened, not // which run a queued write belongs to. Only the generation keeps the two // runs apart, so it must never go backwards. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(task, "dispose").mockResolvedValue(undefined) expect(task.cancellationGeneration).toBe(0) await task.abortTask() expect(task.abort).toBe(true) expect(task.cancellationGeneration).toBe(1) await task.resumeAfterDelegation() // The run-state model (RSK-06) no longer clears the legacy `abort` flag // on resume: that is exactly why the generation exists. Only the // generation keeps the cancelled run and the resumed run apart. expect(task.cancellationGeneration).toBe(1) await task.abortTask() expect(task.abort).toBe(true) expect(task.cancellationGeneration).toBe(2) }) it("advances the cancellation generation on disposal, without any explicit cancel", async () => { // A task torn down by the host never sees abortTask(). Disposal has to raise the // same generation, or a write still parked on its path chain publishes after the // task is gone. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) expect(task.cancellationGeneration).toBe(0) await task.dispose() expect(task.abort).toBe(true) expect(task.cancellationGeneration).toBe(1) }) it("flushes pending state before TaskAborted and disposal while queue state is intact", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) let queuedMessagesAtFlush = -1 const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockImplementation(async () => { queuedMessagesAtFlush = task.messageQueueService.messages.length }) const emitSpy = vi.spyOn(task, "emit") const disposeSpy = vi.spyOn(task, "dispose").mockResolvedValue(undefined) task.messageQueueService.addMessage("queued text") await task.abortTask() const taskAbortedCallIndex = (emitSpy.mock.calls as unknown[][]).findIndex( ([event]) => event === RooCodeEventName.TaskAborted, ) expect(taskAbortedCallIndex).toBeGreaterThanOrEqual(0) expect(queuedMessagesAtFlush).toBe(1) expect(flushSpy).toHaveBeenCalledWith() expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan( emitSpy.mock.invocationCallOrder[taskAbortedCallIndex], ) expect(emitSpy.mock.invocationCallOrder[taskAbortedCallIndex]).toBeLessThan( disposeSpy.mock.invocationCallOrder[0], ) }) it("continues abort cleanup when flushing pending state fails", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const error = new Error("state flush failed") const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(error) const taskAbortedListener = vi.fn() const disposeSpy = vi.spyOn(task, "dispose").mockResolvedValue(undefined) const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) task.on(RooCodeEventName.TaskAborted, taskAbortedListener) await expect(task.abortTask()).resolves.toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalledWith( `[Task#abortTask] flushPostStateToWebviewThrottled failed for ${task.taskId}.${task.instanceId}:`, error, ) expect(task.abort).toBe(true) expect(flushSpy).toHaveBeenCalledOnce() expect(taskAbortedListener).toHaveBeenCalledOnce() expect(disposeSpy).toHaveBeenCalledOnce() expect(saveSpy).toHaveBeenCalledOnce() expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(taskAbortedListener.mock.invocationCallOrder[0]) expect(taskAbortedListener.mock.invocationCallOrder[0]).toBeLessThan(disposeSpy.mock.invocationCallOrder[0]) consoleErrorSpy.mockRestore() }) it("should work with TaskLike interface", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Cast to TaskLike to ensure interface compliance const taskLike: TaskLike = task // Verify abortTask method exists and is callable expect(typeof taskLike.abortTask).toBe("function") // Mock the dispose method to avoid actual cleanup vi.spyOn(task, "dispose").mockResolvedValue(undefined) // Call abortTask through interface await taskLike.abortTask() // Verify it works expect(task.abort).toBe(true) }) it("should handle errors during disposal gracefully", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Mock dispose to throw an error const mockError = new Error("Disposal failed") vi.spyOn(task, "dispose").mockImplementation(() => { throw mockError }) // Spy on console.error to verify error is logged const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // abortTask should not throw even if dispose fails await expect(task.abortTask()).resolves.not.toThrow() // Verify error was logged expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Error during task"), mockError) // Verify abort flag is still set expect(task.abort).toBe(true) // Restore console.error consoleErrorSpy.mockRestore() }) it("should handle asynchronous disposal errors gracefully", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const disposalError = new Error("Disposal failed asynchronously") vi.spyOn(task, "dispose").mockRejectedValue(disposalError) const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) await expect(task.abortTask()).resolves.toBeUndefined() await vi.waitFor(() => expect(consoleErrorSpy).toHaveBeenCalledWith( `Error during task ${task.taskId}.${task.instanceId} disposal:`, disposalError, ), ) consoleErrorSpy.mockRestore() }) describe("Stream Failure Retry", () => { it("should not abort task on stream failure, only on user cancellation", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Spy on console.error to verify error logging const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Spy on abortTask to verify it's NOT called for stream failures const abortTaskSpy = vi.spyOn(task, "abortTask").mockResolvedValue(undefined) // Test Case 1: Stream failure should NOT abort task setAbortLatch(task, false) task.abandoned = false // Simulate the catch block behavior for stream failure const streamFailureError = new Error("Stream failed mid-execution") // The key assertion: verify that when abort=false, abortTask is NOT called // This would normally happen in the catch block around line 2184 const shouldAbort = task.abort expect(shouldAbort).toBe(false) // Verify error would be logged (this is what the new code does) console.error( `[Task#${task.taskId}.${task.instanceId}] Stream failed, will retry: ${streamFailureError.message}`, ) expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Stream failed, will retry")) // Verify abortTask was NOT called expect(abortTaskSpy).not.toHaveBeenCalled() // Test Case 2: User cancellation SHOULD abort task setAbortLatch(task, true) // For user cancellation, abortTask SHOULD be called if (task.abort) { await task.abortTask() } expect(abortTaskSpy).toHaveBeenCalled() // Restore mocks consoleErrorSpy.mockRestore() }) }) describe("cancelCurrentRequest", () => { it("should cancel the current HTTP request via AbortController", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Create a real AbortController and spy on its abort method const mockAbortController = new AbortController() const abortSpy = vi.spyOn(mockAbortController, "abort") task.currentRequestAbortController = mockAbortController // Spy on console.log const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) // Call cancelCurrentRequest task.cancelCurrentRequest() // Verify abort was called on the controller expect(abortSpy).toHaveBeenCalled() // Verify the controller was cleared expect(task.currentRequestAbortController).toBeUndefined() // Verify logging expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Aborting current HTTP request")) // Restore console.log consoleLogSpy.mockRestore() }) it("should handle missing AbortController gracefully", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Ensure no controller exists task.currentRequestAbortController = undefined // Should not throw when called with no controller expect(() => task.cancelCurrentRequest()).not.toThrow() }) it("should be called during dispose", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Spy on cancelCurrentRequest const cancelSpy = vi.spyOn(task, "cancelCurrentRequest") // Mock other dispose operations vi.spyOn(task.messageQueueService, "removeListener").mockImplementation(() => task.messageQueueService) vi.spyOn(task.messageQueueService, "dispose").mockImplementation(() => {}) vi.spyOn(task, "removeAllListeners").mockImplementation(() => task) // Call dispose void task.dispose() // Verify cancelCurrentRequest was called expect(cancelSpy).toHaveBeenCalled() }) describe("abortSignal", () => { it("should pass AbortController signal to condenseContext metadata when a current request exists", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) task.currentRequestAbortController = new AbortController() vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() expect(summarizeConversation).toHaveBeenCalled() const [options] = vi.mocked(summarizeConversation).mock.calls.at(-1)! expect(options.metadata?.abortSignal).toBe(task.currentRequestAbortController!.signal) }) it("should omit abortSignal from condenseContext metadata when no current request exists", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() expect(summarizeConversation).toHaveBeenCalled() const [options] = vi.mocked(summarizeConversation).mock.calls.at(-1)! expect(options.metadata).toBeDefined() expect("abortSignal" in (options.metadata ?? {})).toBe(false) }) it("should pass AbortController signal to createMessage metadata", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Mock required methods for attemptApiRequest to work without hanging vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200000, maxTokens: 4096, inputPrice: 0.3, outputPrice: 1.5, } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 0, }) // Mock the API stream response const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] const iterator = task.attemptApiRequest(0) await iterator.next() // Verify createMessage was called with metadata containing abortSignal expect(createMessageSpy).toHaveBeenCalled() const [, , metadata] = createMessageSpy.mock.calls[0]! expect(metadata).toBeDefined() expect(metadata!.abortSignal).toBe(task.currentRequestAbortController!.signal) }) it("configures tool restrictions for Gemini requests", async () => { const apiConfiguration = { ...mockApiConfig, apiProvider: providerIdentifiers.gemini, } as ProviderSettings const task = new Task({ provider: mockProvider, apiConfiguration, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: requireDefined(mockApiConfig.apiModelId), info: { contextWindow: 200000, maxTokens: 4096 } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration, autoApprovalEnabled: true, requestDelaySeconds: 0, }) const mockStream = (async function* () { yield { type: "text", text: "response" } as ApiStreamChunk })() const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] await task.attemptApiRequest(0).next() const [, , metadata] = requireDefined(createMessageSpy.mock.calls[0]) const tools = requireDefined(metadata?.tools) const allowedFunctionNames = requireDefined(metadata?.allowedFunctionNames) const toolNames = tools.map((tool) => { if (tool.type !== "function") { throw new Error(`Unexpected tool type: ${tool.type}`) } return tool.function.name }) expect(tools.length).toBeGreaterThan(0) expect(allowedFunctionNames.length).toBeGreaterThan(0) expect(allowedFunctionNames.every((name) => toolNames.includes(name))).toBe(true) }) it("should invoke abort on currentRequestAbortController during first-chunk wait", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const abortSpy = vi.fn() task.currentRequestAbortController = { abort: abortSpy, signal: new AbortController().signal, } as AbortController task.cancelCurrentRequest() expect(abortSpy).toHaveBeenCalledTimes(1) expect(task.currentRequestAbortController).toBeUndefined() }) it("should reject streaming consumption when aborted between chunks", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200000, maxTokens: 4096, inputPrice: 0.3, outputPrice: 1.5, } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 0, }) const createMessageSpy = vi.fn((_systemPrompt, _messages, metadata) => { let callCount = 0 return { [Symbol.asyncIterator]() { return this }, next: () => { callCount++ if (callCount === 1) { return Promise.resolve({ done: false, value: { type: "text", text: "first chunk" }, }) } return new Promise>((_resolve, reject) => { if (metadata?.abortSignal?.aborted) { return reject(new Error("Request cancelled by user")) } metadata?.abortSignal?.addEventListener("abort", () => { reject(new Error("Request cancelled by user")) }) }) }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator }) vi.spyOn(task.api, "createMessage").mockImplementation(createMessageSpy) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] const streamIterator = task.attemptApiRequest(0) await expect(streamIterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "first chunk" }, }) task.cancelCurrentRequest() await expect(streamIterator.next()).rejects.toThrow("Request cancelled by user") expect(createMessageSpy).toHaveBeenCalledTimes(1) }) it("should use the same AbortController signal as currentRequestAbortController", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Mock required methods for attemptApiRequest to work without hanging vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200000, maxTokens: 4096, inputPrice: 0.3, outputPrice: 1.5, } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 0, }) // Mock the API stream response const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] const iterator = task.attemptApiRequest(0) await iterator.next() // Get the signal from metadata const [, , metadata] = createMessageSpy.mock.calls[0]! const metadataSignal = metadata!.abortSignal // The signal in metadata should be the same as the one from currentRequestAbortController expect(metadataSignal).toBe(task.currentRequestAbortController!.signal) }) it("should omit createMessage abortSignal metadata when no current request exists before condense metadata checks", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200000, maxTokens: 4096, inputPrice: 0.3, outputPrice: 1.5, } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 0, }) const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: true, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] expect(task.currentRequestAbortController).toBeUndefined() const iterator = task.attemptApiRequest(0) await iterator.next() const [, , metadata] = createMessageSpy.mock.calls[0]! expect(metadata).toBeDefined() expect("abortSignal" in metadata!).toBe(true) expect(metadata!.abortSignal).toBe(task.currentRequestAbortController!.signal) }) it("should keep createMessage abortSignal metadata unaborted before cancellation", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200000, maxTokens: 4096, inputPrice: 0.3, outputPrice: 1.5, } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 0, }) const mockStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "response" } }, async next() { return { done: false, value: { type: "text", text: "response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] const iterator = task.attemptApiRequest(0) await iterator.next() const [, , metadata] = createMessageSpy.mock.calls[0]! expect(metadata?.abortSignal).toBe(task.currentRequestAbortController!.signal) expect(metadata?.abortSignal?.aborted).toBe(false) }) }) it("should create a fresh AbortController for each sequential request", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200000, maxTokens: 4096, inputPrice: 0.3, outputPrice: 1.5, } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, autoApprovalEnabled: true, requestDelaySeconds: 0, }) let callCount = 0 const mockStreamFactory = async function* (): AsyncGenerator { yield { type: "text", text: `response ${callCount}` } callCount++ } const createMessageSpy = vi .spyOn(task.api, "createMessage") .mockImplementation(() => mockStreamFactory()) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] // First request const iterator1 = task.attemptApiRequest(0) await iterator1.next() expect(createMessageSpy).toHaveBeenCalledTimes(1) const [, , metadata1] = createMessageSpy.mock.calls[0]! const signal1 = metadata1!.abortSignal expect(signal1).toBeDefined() expect(signal1!.aborted).toBe(false) // Simulate request completion and cancellation to clear the controller task.cancelCurrentRequest() // Second request should create a fresh AbortController with a new signal callCount = 0 const iterator2 = task.attemptApiRequest(0) await iterator2.next() expect(createMessageSpy).toHaveBeenCalledTimes(2) const [, , metadata2] = createMessageSpy.mock.calls[1]! const signal2 = metadata2!.abortSignal // Signals should be different instances (fresh controller per request) expect(signal2).not.toBe(signal1) expect(signal2).toBe(task.currentRequestAbortController!.signal) expect(signal2!.aborted).toBe(false) }) it("should propagate AbortController signal through attemptApiRequest context-window retry path", async () => { vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 120000, }) vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 1000, maxTokens: 4096, inputPrice: 0.3, outputPrice: 1.5, } as ModelInfo, }) const providerState = await mockProvider.getState() vi.spyOn(mockProvider, "getState").mockResolvedValue({ ...providerState, apiConfiguration: mockApiConfig, mode: "code", autoCondenseContext: true, autoCondenseContextPercent: 80, requestDelaySeconds: 0, customModes: [], experiments: {}, disabledTools: [], customSupportPrompts: {}, autoApprovalEnabled: true, profileThresholds: {}, currentApiConfigName: "default", }) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] let firstCall = true const retryStream = { async *[Symbol.asyncIterator]() { yield { type: "text", text: "retried response" } }, async next() { return { done: false, value: { type: "text", text: "retried response" } } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator const contextWindowErrorStream = { [Symbol.asyncIterator]() { return this }, async next() { throw { status: 400, message: "context length exceeded" } }, async return() { return { done: true, value: undefined } }, async throw(e: unknown) { throw e }, [Symbol.asyncDispose]: async () => {}, } as AsyncGenerator vi.spyOn(task.api, "createMessage").mockImplementation(() => { if (firstCall) { firstCall = false return contextWindowErrorStream } return retryStream }) const iterator = task.attemptApiRequest(0) await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "retried response" }, }) expect(summarizeConversation).toHaveBeenCalled() const [options] = vi.mocked(summarizeConversation).mock.calls.at(-1)! expect(options.metadata?.taskId).toBe(task.taskId) expect(options.metadata?.mode).toBe("architect") expect(options.metadata?.abortSignal).toBeInstanceOf(AbortSignal) expect(options.metadata?.abortSignal?.aborted).toBe(false) }) // Shared harness for the retry-options-forwarding tests: the first // createMessage fails on the first chunk, the retry attempt streams a // success chunk, and the attemptApiRequest spy exposes the arguments // each retry site's recursion passes downstream. A same-reference // assertion on options is the point: a rebuilt object would silently // refetch model metadata and restart the rate-limit wait on retries. async function createRetryForwardingTask(stateOverrides: Partial = {}) { vi.spyOn(mockProvider, "getState").mockResolvedValue( providerStateWith({ autoApprovalEnabled: false, requestDelaySeconds: 0, ...stateOverrides, }), ) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 0, }) vi.spyOn(task, "say").mockResolvedValue(undefined) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] return task } const failingStream = (error: unknown): AsyncGenerator => (async function* () { // Yield nothing, then fail the first next() like a first-chunk // stream error would. yield* [] throw error })() const retryForwardingOptions = (): { skipProviderRateLimit: boolean; requestModelInfo: ModelInfo } => ({ skipProviderRateLimit: true, requestModelInfo: { contextWindow: 200_000, maxTokens: 4096, supportsPromptCache: true }, }) it("forwards the caller's options to the context-window retry recursion", async () => { const task = await createRetryForwardingTask() vi.spyOn(getTaskTestAccess(task), "handleContextWindowExceededError").mockResolvedValue(undefined) vi.spyOn(task.api, "createMessage") .mockImplementationOnce(() => failingStream({ status: 400, message: "context length exceeded" })) .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "retry response" }]), ) const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") const options = retryForwardingOptions() const iterator = task.attemptApiRequest(0, options) await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "retry response" }, }) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(1) expect(attemptApiRequestSpy.mock.calls[1]?.[1]).toBe(options) }) it("forwards the caller's options to the auto-approval backoff retry recursion", async () => { const task = await createRetryForwardingTask({ autoApprovalEnabled: true }) // An ask landing here means the auto-approval branch was not taken, // so the rejection names the wrong-site failure explicitly. vi.spyOn(task, "ask").mockRejectedValue(new Error("auto-approval retry must not prompt the user")) vi.spyOn(task.api, "createMessage") .mockImplementationOnce(() => failingStream({ status: 500, message: "server error" })) .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "retry response" }]), ) const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") const options = retryForwardingOptions() const iterator = task.attemptApiRequest(0, options) await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "retry response" }, }) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(1) expect(attemptApiRequestSpy.mock.calls[1]?.[1]).toBe(options) }) it("forwards the caller's options and resets the counter on the user-clicked retry recursion", async () => { const task = await createRetryForwardingTask() vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) vi.spyOn(task.api, "createMessage") .mockImplementationOnce(() => failingStream({ status: 500, message: "server error" })) .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "retry response" }]), ) const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") const options = retryForwardingOptions() const iterator = task.attemptApiRequest(0, options) await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "retry response" }, }) // The user-confirmed retry restarts the retry counter at 0 (its own // pacing is the user's click), unlike the automatic backoff retries. expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(0) expect(attemptApiRequestSpy.mock.calls[1]?.[1]).toBe(options) }) it("carries the derived model snapshot into the retry recursion when the caller omitted one", async () => { const task = await createRetryForwardingTask() vi.spyOn(getTaskTestAccess(task), "handleContextWindowExceededError").mockResolvedValue(undefined) const safeEnsureModelFetchedSpy = vi .spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") .mockResolvedValue(stubModelInfo) vi.spyOn(task.api, "createMessage") .mockImplementationOnce(() => failingStream({ status: 400, message: "context length exceeded" })) .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "retry response" }]), ) const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") // No caller-supplied snapshot: the first hop derives one locally. const iterator = task.attemptApiRequest(0) await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "retry response" }, }) expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(1) // The retry hop carries the snapshot derived at the first hop, so a // metadata update landing between attempts cannot move model-specific // tool policy mid-request. expect(attemptApiRequestSpy.mock.calls[1]?.[1]?.requestModelInfo).toBe(stubModelInfo) // Derivation ran once per logical request, not once per hop. expect(safeEnsureModelFetchedSpy).toHaveBeenCalledTimes(1) }) it("recovers from a context-window overflow against the pinned request snapshot when metadata changes in between", async () => { // The pinned-snapshot invariant covers the recovery half too: // truncation permanently rewrites apiConversationHistory for the // retry hop to consume, so recovery must size against the same // snapshot the retry uses — never a fresh metadata read that // landed between the failed attempt and recovery. const task = await createRetryForwardingTask() // Distinct object, same window as the stub: identity is what the // retry hop must carry forward. const pinnedInfo: ModelInfo = { ...stubModelInfo } // A narrower window arriving after the first failure would drive // harsher truncation math than the retry hop actually needs. const freshInfo: ModelInfo = { ...stubModelInfo, contextWindow: 32_000 } const safeEnsureModelFetchedSpy = vi .spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") .mockResolvedValueOnce(pinnedInfo) .mockResolvedValue(freshInfo) const getSystemPromptSpy = vi .spyOn(getTaskTestAccess(task), "getSystemPrompt") .mockResolvedValue("mock system prompt") vi.spyOn(task.api, "createMessage") .mockImplementationOnce(() => failingStream({ status: 400, message: "context length exceeded" })) .mockImplementationOnce(() => asyncStreamFrom([{ type: "text", text: "retry response" }]), ) const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") const iterator = task.attemptApiRequest(0) await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "retry response" }, }) // hop 1 prompt, the recovery handler's condensing prompt, hop 2 prompt. expect(getSystemPromptSpy).toHaveBeenCalledTimes(3) // Without threading, the handler re-fetches and this second call // carries freshInfo, so truncation is sized against a window the // retry never uses. expect(getSystemPromptSpy.mock.calls[1]?.[1]).toBe(pinnedInfo) // Recovery and the retry hop share one snapshot object. expect(attemptApiRequestSpy.mock.calls[1]?.[1]?.requestModelInfo).toBe(pinnedInfo) // The handler performs no metadata fetch of its own. expect(safeEnsureModelFetchedSpy).toHaveBeenCalledTimes(1) }) }) }) describe("safeEnsureModelFetched", () => { afterEach(() => { vi.restoreAllMocks() }) it("loads model metadata before getModel is used", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const ensureModelFetched = vi.fn().mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched }) await getTaskTestAccess(task).safeEnsureModelFetched() expect(ensureModelFetched).toHaveBeenCalledTimes(1) }) it("swallows fetch failures so callers can fall back to defaults", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const ensureModelFetched = vi.fn().mockRejectedValue(new Error("network down")) Object.assign(task.api, { ensureModelFetched }) const expectedInfo = task.api.getModel().info const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // A swallowed failure still returns the handler's settled fallback info. await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to fetch model metadata"), "network down", ) }) it("is a no-op when the api handler does not implement ensureModelFetched", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const expectedInfo = task.api.getModel().info await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) }) it("settles at the bound when ensureModelFetched never resolves", async () => { // A hung metadata endpoint (some fetchers issue unbounded GETs) must // not stall the task: the race resolves at MODEL_FETCH_TIMEOUT_MS and // callers proceed with the handler's fallback metadata. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) const expectedInfo = task.api.getModel().info const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const settled = getTaskTestAccess(task).safeEnsureModelFetched() await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) // The timeout path returns the handler's settled fallback snapshot. await expect(settled).resolves.toBe(expectedInfo) } finally { vi.useRealTimers() } expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) }) it("aborts the fetch signal when the bounded wait expires", async () => { // The bound must detach this task's waiter, not just stop waiting on // it: a handler that observes its signal stops serving the abandoned // fetch, and one that ignores it at least sees the task move on. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) let capturedSignal: AbortSignal | undefined Object.assign(task.api, { ensureModelFetched: (signal?: AbortSignal) => { capturedSignal = signal return new Promise(() => {}) }, }) vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const settled = getTaskTestAccess(task).safeEnsureModelFetched() await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await settled } finally { vi.useRealTimers() } expect(capturedSignal?.aborted).toBe(true) }) it("aborts an in-flight metadata wait when the current request is cancelled", async () => { // Cancel/dispose must reach the metadata waiter, not only the stream: // cancelCurrentRequest aborts the controller feeding the handler's // signal, so a signal-observing handler settles the waiter immediately // and the call degrades to fallback info instead of hanging until the // bound. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) let capturedSignal: AbortSignal | undefined Object.assign(task.api, { ensureModelFetched: (signal?: AbortSignal) => new Promise((_resolve, reject) => { capturedSignal = signal signal?.addEventListener("abort", () => reject(signal.reason), { once: true }) }), }) const expectedInfo = task.api.getModel().info const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) const settled = getTaskTestAccess(task).safeEnsureModelFetched() // Let the waiter attach to the handler before cancelling. await Promise.resolve() task.cancelCurrentRequest() await expect(settled).resolves.toBe(expectedInfo) expect(capturedSignal?.aborted).toBe(true) expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to fetch model metadata"), expect.anything(), ) }) it("releases the metadata abort controller once the wait completes", async () => { // The ownership guard in safeEnsureModelFetched's finally block must // clear the field for the call that still owns it: a never-cleared // controller would let cancelCurrentRequest abort a long-dead signal. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) await getTaskTestAccess(task).safeEnsureModelFetched() expect(task.metadataFetchAbortController).toBeUndefined() }) it("does not clear a controller owned by a newer metadata wait", async () => { // Overlap guard: if a newer call replaced this call's controller while // the bounded wait was pending, the finished call's finally block must // leave the foreign controller in place — clearing unconditionally // would silently orphan the newer wait from cancel/dispose. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) let settleFetch!: () => void Object.assign(task.api, { ensureModelFetched: () => new Promise((resolve) => { settleFetch = resolve }), }) const settled = getTaskTestAccess(task).safeEnsureModelFetched() // The per-call controller is installed synchronously before the race. expect(task.metadataFetchAbortController).toBeInstanceOf(AbortController) const foreignController = new AbortController() task.metadataFetchAbortController = foreignController settleFetch() await settled expect(task.metadataFetchAbortController).toBe(foreignController) }) it("does not block getSystemPrompt when ensureModelFetched never settles", async () => { // The prompt/condense guard site must proceed with fallback model // info once the bounded wait expires instead of hanging the request. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") const callsBefore = vi.mocked(SYSTEM_PROMPT).mock.calls.length vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const promptPromise = getTaskTestAccess(task).getSystemPrompt(providerStateWith()) await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await expect(promptPromise).resolves.toBe("mock system prompt") } finally { vi.useRealTimers() } expect(vi.mocked(SYSTEM_PROMPT).mock.calls.length).toBe(callsBefore + 1) }) it("refuses to send a request when the task is cancelled during the bounded metadata wait", async () => { // Cancellation must be honored before any provider-visible work of // the request: an abort landing while the metadata wait is pending // rejects the generator instead of quietly sending a request the // user already cancelled. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) // The wait never settles on its own; only the bound expires it. Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) const createMessageSpy = vi .spyOn(task.api, "createMessage") .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 0, }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const first = task.attemptApiRequest(0).next() // Observe the rejection the moment it can land: the generator // rejects during the timer advance below, before the assertion // line runs, and an unobserved rejection would surface as an // unhandled rejection independent of the awaited assertion. void first.catch(() => {}) await vi.advanceTimersByTimeAsync(0) // The user cancels while the bounded metadata wait is still pending. const cancelling = task.abortTask() // The wait itself still expires at the bound, as it normally would. await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await expect(first).rejects.toThrow(/aborted during request construction/) expect(createMessageSpy).not.toHaveBeenCalled() // The per-request controller is only created once the request is // committed, so a cancelled construction never reaches it. expect(task.currentRequestAbortController).toBeUndefined() await cancelling } finally { vi.useRealTimers() } expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) }) it("refuses to send a request when the task is disposed during the bounded metadata wait", async () => { // Disposal alone — no cancel button, no abortTask — must make the // task observe cancellation: disposeOnce sets the abort state // synchronously in its call, before its aborts land, so once the // metadata wait settles at its bound the request-construction // guard refuses to build tools or call createMessage for a task // nobody owns anymore. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) // The wait never settles on its own; only the bound expires it. Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) const createMessageSpy = vi .spyOn(task.api, "createMessage") .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 0, }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const first = task.attemptApiRequest(0).next() // Observe the rejection the moment it can land: the generator // rejects during the timer advance below, before the assertion // line runs, and an unobserved rejection would surface as an // unhandled rejection independent of the awaited assertion. void first.catch(() => {}) await vi.advanceTimersByTimeAsync(0) // The task is disposed while the bounded metadata wait is still // pending; the abort state is set synchronously in this call. const disposal = task.dispose() expect(task.abort).toBe(true) // The wait itself still expires at the bound, as it normally would. await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await expect(first).rejects.toThrow(/aborted during request construction/) expect(createMessageSpy).not.toHaveBeenCalled() // The per-request controller is only created once the request is // committed, so a disposed construction never reaches it. expect(task.currentRequestAbortController).toBeUndefined() await disposal } finally { vi.useRealTimers() } expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) }) it("stops manual condensation when the task is aborted during the metadata wait", async () => { // Cancellation must be honored before any provider-visible work of // the condense: an abort landing while the metadata wait is pending // ends condenseContext instead of quietly issuing a summarization // request the user already cancelled. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) // The wait never settles on its own; only the bound expires it. Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] // Spying on the prompt build pins the cancellation to the entry // checkpoint: skipping summarization alone is also achieved by the // check placed after the prompt await, so only an unstarted // prompt build proves the entry check did its work. const promptSpy = vi .spyOn(getTaskTestAccess(task), "getSystemPrompt") .mockResolvedValue("mock system prompt") // The summarizeConversation module mock is never cleared, so pin the // call count this condense starts from. const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const condensing = task.condenseContext() await vi.advanceTimersByTimeAsync(0) // The user cancels while the bounded metadata wait is still pending. const cancelling = task.abortTask() // The wait itself still expires at the bound, as it normally would. await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await condensing await cancelling } finally { vi.useRealTimers() } expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) expect(promptSpy).not.toHaveBeenCalled() expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) }) it("stops manual condensation on an abandoned task", async () => { // Abandonment is the other cancellation flavor: the metadata wait // settles normally, yet condenseContext must still end before the // prompt build and the summarization request. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.abandoned = true // Only an unstarted prompt build attributes the skip to the entry // checkpoint rather than one of the later cancellation checks. const promptSpy = vi .spyOn(getTaskTestAccess(task), "getSystemPrompt") .mockResolvedValue("mock system prompt") const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length await task.condenseContext() expect(promptSpy).not.toHaveBeenCalled() expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) }) it("stops manual condensation when the task is aborted while the system prompt is pending", async () => { // A cancellation landing inside the prompt build (whose bounded MCP // wait is cancellation-blind) must stop condenseContext before it // issues the summarization request. The prompt gate is released only // after abortTask has synchronously set its flag, so whenever the // prompt await resumes the cancellation is observed. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] let resolvePrompt!: (value: string) => void const promptGate = new Promise((resolve) => { resolvePrompt = resolve }) const promptSpy = vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockReturnValue(promptGate) // The early return this guards never reaches say; the spy only keeps // the aborted task's post-overwrite say from throwing before the // summarize/overwrite assertions can report a regression. vi.spyOn(task, "say").mockResolvedValue(undefined) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) // The summarizeConversation module mock is never cleared, so pin the // call count this condense starts from. const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length // Attribution pin: the summarize-count assertion alone is also // satisfied by the later input-gathering checkpoint, so the collectors // must additionally stay unstarted to prove THIS prompt-boundary check // (not a downstream one) stopped the condense. const envCallsBefore = vi.mocked(getEnvironmentDetails).mock.calls.length const filesReadSpy = vi.spyOn(getTaskTestAccess(task), "getFilesReadByRooSafely") const condensing = task.condenseContext() // Suspend inside the prompt build, past the entry guard, so this // exercises the prompt-boundary check rather than the entry one. await vi.waitFor(() => expect(promptSpy).toHaveBeenCalled()) const cancelling = task.abortTask() resolvePrompt("mock system prompt") await condensing await cancelling expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) expect(overwriteSpy).not.toHaveBeenCalled() expect(vi.mocked(getEnvironmentDetails).mock.calls.length).toBe(envCallsBefore) expect(filesReadSpy).not.toHaveBeenCalled() }) it("stops manual condensation from overwriting history when the task is aborted during summarization", async () => { // The summarization round-trip is the widest cancellation window on // the condense path: a cancellation landing while it is pending must // stop condenseContext before it replaces and persists the history. // The gate is released only after abortTask has synchronously set // its flag, so whenever the summarize await resumes the cancellation // is observed. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") // The early return this guards never reaches say; the spy only keeps // the aborted task's post-overwrite say from throwing before the // overwrite assertion can report the regression. const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) // The summarizeConversation module mock is never cleared, so pin the // call count this condense starts from. const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length type SummarizeResult = Awaited> let releaseSummarize!: (value: SummarizeResult) => void const summarizeGate = new Promise((resolve) => { releaseSummarize = resolve }) vi.mocked(summarizeConversation).mockImplementationOnce(() => summarizeGate) const condensing = task.condenseContext() await vi.waitFor(() => expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore + 1), ) const cancelling = task.abortTask() releaseSummarize({ messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: Date.now() }], summary: "summary", cost: 0, newContextTokens: 1, }) await condensing await cancelling expect(overwriteSpy).not.toHaveBeenCalled() expect(saySpy).not.toHaveBeenCalled() }) it("stops manual condensation when the task is aborted while environment details are pending", async () => { // Gathering the summary inputs suspends twice before the // summarization request: a cancellation landing while the // environment-details collector is pending must stop condenseContext // before any summarization request or history rewrite is issued. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "say").mockResolvedValue(undefined) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) // Suspend inside the collector so the abort lands while the // summarization request cannot have started yet. let releaseEnvDetails!: (value: string) => void const envDetailsGate = new Promise((resolve) => { releaseEnvDetails = resolve }) vi.mocked(getEnvironmentDetails).mockReturnValueOnce(envDetailsGate) // The summarizeConversation module mock is never cleared, so pin the // call count this condense starts from. const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length // Same for the environment-details module mock: waiting on a count // delta proves THIS condense reached the collector before the abort. const envCallsBefore = vi.mocked(getEnvironmentDetails).mock.calls.length const condensing = task.condenseContext() await vi.waitFor(() => expect(vi.mocked(getEnvironmentDetails).mock.calls.length).toBe(envCallsBefore + 1)) const cancelling = task.abortTask() releaseEnvDetails("") await condensing await cancelling expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) expect(overwriteSpy).not.toHaveBeenCalled() }) it("stops manual condensation when the task is aborted while the files-read collector is pending", async () => { // The second input-gathering suspension sits between the // environment-details collector and the summarization request: a // cancellation landing while the files-read collector is pending must // likewise stop condenseContext before either is issued. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) vi.spyOn(task, "dispose").mockResolvedValue(undefined) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "say").mockResolvedValue(undefined) const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) let releaseFilesRead!: (value: string[] | undefined) => void const filesReadGate = new Promise((resolve) => { releaseFilesRead = resolve }) const filesReadSpy = vi .spyOn(getTaskTestAccess(task), "getFilesReadByRooSafely") .mockReturnValue(filesReadGate) const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length const condensing = task.condenseContext() await vi.waitFor(() => expect(filesReadSpy).toHaveBeenCalled()) const cancelling = task.abortTask() releaseFilesRead(undefined) await condensing await cancelling expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) expect(overwriteSpy).not.toHaveBeenCalled() }) it("calls safeEnsureModelFetched from attemptApiRequest when context tokens are present", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 50_000, }) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200_000, maxTokens: 4096, } as ModelInfo, }) vi.spyOn(task.api, "createMessage").mockReturnValue({ async *[Symbol.asyncIterator]() { yield { type: "text", text: "ok" } }, async next() { return { done: true, value: undefined } }, async return() { return { done: true, value: undefined } }, async throw(error: unknown) { throw error }, async [Symbol.asyncDispose]() {}, } as AsyncGenerator) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] const iterator = task.attemptApiRequest(0) await iterator.next() expect(safeSpy).toHaveBeenCalled() }) it("continues attemptApiRequest when model metadata fetch fails", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, contextTokens: 50_000, }) const ensureModelFetched = vi.fn().mockRejectedValue(new Error("fetch failed")) Object.assign(task.api, { ensureModelFetched }) vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200_000, maxTokens: 4096, } as ModelInfo, }) vi.spyOn(task.api, "createMessage").mockReturnValue({ async *[Symbol.asyncIterator]() { yield { type: "text", text: "ok" } }, async next() { return { done: false, value: { type: "text", text: "ok" } } }, async return() { return { done: true, value: undefined } }, async throw(error: unknown) { throw error }, async [Symbol.asyncDispose]() {}, } as AsyncGenerator) const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) task.apiConversationHistory = [ { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), }, ] const iterator = task.attemptApiRequest(0) await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" }, }) expect(errorSpy).toHaveBeenCalled() }) it("fetches model metadata before caching the streaming model", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const ensureModelFetched = vi.fn().mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched }) vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { supportsImages: false, supportsPromptCache: true, contextWindow: 200_000, maxTokens: 4096, } as ModelInfo, }) vi.mocked(processUserContentMentions).mockResolvedValueOnce({ content: [{ type: "text", text: "hello" }], mode: undefined, }) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") const resetPersistenceSpy = vi.spyOn(getTaskTestAccess(task), "resetAssistantMessagePersistence") const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { throw new Error("stop after model metadata fetch") }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined as never) vi.spyOn(getTaskTestAccess(task), "addToApiConversationHistory").mockResolvedValue(undefined) task.clineMessages = [ { ts: Date.now(), type: "say", say: "api_req_started", text: "{}", }, ] vi.spyOn(task, "say").mockImplementation(async (type) => { if (type === "api_req_started") { task.clineMessages.push({ ts: Date.now(), type: "say", say: "api_req_started", text: "{}", }) } return undefined as never }) const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }], false) expect(result).toBe(true) expect(safeSpy).toHaveBeenCalled() expect(resetPersistenceSpy).toHaveBeenCalledTimes(1) expect(ensureModelFetched).toHaveBeenCalled() // Exact-object match on purpose: a partial matcher would stop pinning the // options literal the streaming loop passes to attemptApiRequest. expect(attemptApiRequestSpy).toHaveBeenCalledWith(0, { skipProviderRateLimit: true, requestModelInfo: task.cachedStreamingModel?.info, }) expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) }) it("stays silent when the api handler lacks ensureModelFetched", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const expectedInfo = task.api.getModel().info const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // A missing optional fetcher is the normal case for static providers, // so the call must resolve quietly instead of surfacing a caught TypeError. await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) expect(errorSpy.mock.calls.flat().join(" ")).not.toContain("Failed to fetch model metadata") }) it("does not warn when the fetch resolves within the bound", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { await getTaskTestAccess(task).safeEnsureModelFetched() await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) } finally { vi.useRealTimers() } expect(warnSpy).not.toHaveBeenCalled() }) it("warns only once the bound elapses", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) const expectedInfo = task.api.getModel().info const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const settled = getTaskTestAccess(task).safeEnsureModelFetched() // The 5000 ms bound is asserted in absolute milliseconds so any // change to it changes observed behavior, not just the schedule. await vi.advanceTimersByTimeAsync(4_999) expect(warnSpy).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(warnSpy).toHaveBeenCalledTimes(1) await expect(settled).resolves.toBe(expectedInfo) } finally { vi.useRealTimers() } }) it("clears the race timer after the fetch wins", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) const clearSpy = vi.spyOn(globalThis, "clearTimeout") await getTaskTestAccess(task).safeEnsureModelFetched() // The armed handle must be handed to clearTimeout on the winning // path; a never-armed or never-cleared timer leaks a pending handle. expect(clearSpy).toHaveBeenCalledWith(expect.any(Object)) }) it("only clears a timer handle that was actually armed", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Hand production a falsy handle while still arming the real timer, // so skipping clearTimeout for an unarmed handle is observable. const realSetTimeout = globalThis.setTimeout let armedHandle: ReturnType | undefined vi.stubGlobal("setTimeout", (callback: () => void, delay?: number) => { armedHandle = realSetTimeout(callback, delay) return 0 }) const clearSpy = vi.spyOn(globalThis, "clearTimeout") Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) try { await getTaskTestAccess(task).safeEnsureModelFetched() expect(clearSpy).not.toHaveBeenCalled() } finally { if (armedHandle !== undefined) { clearTimeout(armedHandle) } vi.unstubAllGlobals() } }) it("keeps prompt and request tools on one snapshot when the fetch stalls past the bound and resolves late", async () => { // Stalled-then-late fetch: the entry snapshot times out at // MODEL_FETCH_TIMEOUT_MS and captures fallback metadata; the fetch // then resolves while the request is still being built. The prompt // and every tool array of this request must both come from the same // (fallback) snapshot, even though the handler's model info has // already flipped to the loaded metadata with different exclusions. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) const fallbackInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } const loadedInfo: ModelInfo = { contextWindow: 128_000, supportsPromptCache: true, // Divergent tool policy: only the loaded metadata excludes it. excludedTools: ["read_file"], } const fetchState = { resolved: false } let resolveFetch!: () => void const metadataFetch = new Promise((resolve) => { resolveFetch = () => { fetchState.resolved = true resolve() } }) Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) vi.spyOn(task.api, "getModel").mockImplementation(() => ({ id: "lazy-router-model", info: fetchState.resolved ? loadedInfo : fallbackInfo, })) vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, // Far above allowedTokens under either snapshot (32k or 128k window), // so the request-scoped condense sub-call always runs and its metadata // tools are observable on the mocked summarizeConversation. contextTokens: 500_000, }) vi.spyOn(task.api, "countTokens").mockResolvedValue(1_000) const createMessageSpy = vi .spyOn(task.api, "createMessage") .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) // Hold the prompt build open so the late flip lands after the request // snapshot was captured but before any tool array is built: a flip // between those points must not move either consumer. let releasePrompt!: () => void const promptGate = new Promise((resolve) => { releasePrompt = () => resolve("mock system prompt") }) vi.mocked(SYSTEM_PROMPT).mockImplementationOnce(() => promptGate) const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] // The summarizeConversation module mock is never cleared, so pin the // call count this request starts from. const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length vi.useFakeTimers() try { const first = task.attemptApiRequest(0).next() // The entry snapshot times out and resolves with fallback info; // the prompt is then built from it and suspends on the gate. await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) // Late success flips the handler's metadata mid-request. resolveFetch() releasePrompt() await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await expect(first).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" } }) } finally { vi.useRealTimers() } const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 17 is modelInfo: the prompt used the captured snapshot. expect(systemPromptCall[17]).toBe(fallbackInfo) const [, , metadata] = requireDefined(createMessageSpy.mock.calls[0]) // Indexed-access type keeps the helper import-free (the spec does not // import the OpenAI types) and metadata itself stays possibly-undefined. type MetadataTools = NonNullable["tools"] const toolNames = (tools: MetadataTools): string[] => requireDefined(tools).map((tool) => { if (tool.type !== "function") { throw new Error(`Unexpected tool type: ${tool.type}`) } return tool.function.name }) // The request's tools were built from the same fallback snapshot, so // the tool the loaded metadata excludes is still declared. expect(toolNames(metadata?.tools)).toContain("read_file") // The condense sub-call of this same request carries the same guarantee: // manageContext forwards its metadata verbatim into summarizeConversation, // so that array is the one built at the context-management tool site. expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) const [condenseOptions] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) expect(condenseOptions.isAutomaticTrigger).toBe(true) // Reverting that build to a fresh guarded re-read (like the per-site // guard at the top of this block) would pick up the flipped (loaded) // metadata and drop read_file from this array. expect(toolNames(condenseOptions.metadata?.tools)).toContain("read_file") }) it("keeps manual condense prompt and tools on one snapshot when the fetch resolves late", async () => { // condenseContext twin of the stalled-fetch regression: the prompt and // the condensing metadata's tool array must resolve from the single // snapshot captured before prompt generation. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) const fallbackInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } const loadedInfo: ModelInfo = { contextWindow: 128_000, supportsPromptCache: true, excludedTools: ["read_file"], } const fetchState = { resolved: false } let resolveFetch!: () => void const metadataFetch = new Promise((resolve) => { resolveFetch = () => { fetchState.resolved = true resolve() } }) Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) vi.spyOn(task.api, "getModel").mockImplementation(() => ({ id: "lazy-router-model", info: fetchState.resolved ? loadedInfo : fallbackInfo, })) // Hold the prompt build open so the test can flip the handler's // metadata after the snapshot is captured but before the tools are // built: a flip between those points must not move either consumer. let releasePrompt!: () => void const promptGate = new Promise((resolve) => { releasePrompt = () => resolve("mock system prompt") }) vi.mocked(SYSTEM_PROMPT).mockReturnValueOnce(promptGate) vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) vi.useFakeTimers() try { const condensing = task.condenseContext() // The entry snapshot times out with fallback info and the prompt // build suspends on the gate. await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) // Late success lands after the snapshot but before the tools. resolveFetch() releasePrompt() await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await condensing } finally { vi.useRealTimers() } const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 17 is modelInfo: the prompt used the captured snapshot. expect(systemPromptCall[17]).toBe(fallbackInfo) const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) const toolNames = requireDefined(options.metadata?.tools).map((tool) => { if (tool.type !== "function") { throw new Error(`Unexpected tool type: ${tool.type}`) } return tool.function.name }) // Same fallback snapshot: the loaded metadata's exclusion never applied. expect(toolNames).toContain("read_file") }) it("uses the caller's model-info snapshot for both the prompt and context sizing", async () => { // A streaming turn captures its model-info snapshot before opening // the request; attemptApiRequest must reuse it for the prompt, the // context-window sizing, and every tool array, so a metadata fetch // that lands mid-request cannot re-decide whether condensing runs. const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await task.getTaskMode() vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) const threadedInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } const lateInfo: ModelInfo = { contextWindow: 128_000, supportsPromptCache: true, // Divergent policy: only the late metadata excludes it. excludedTools: ["read_file"], } const fetchState = { resolved: false } let resolveFetch!: () => void const metadataFetch = new Promise((resolve) => { resolveFetch = () => { fetchState.resolved = true resolve() } }) Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) vi.spyOn(task.api, "getModel").mockImplementation(() => ({ id: "lazy-router-model", info: fetchState.resolved ? lateInfo : threadedInfo, })) vi.spyOn(task, "getTokenUsage").mockReturnValue({ totalCost: 0, totalTokensIn: 0, totalTokensOut: 0, // Above the hard limit for the 32k threaded window (~24.7k tokens) // but well below the limit for a 128k re-read (~111k), so whether // condensing runs exposes which snapshot the sizing resolved from. contextTokens: 50_000, }) vi.spyOn(task.api, "countTokens").mockResolvedValue(1_000) vi.spyOn(task.api, "createMessage").mockReturnValue( asyncStreamFrom([{ type: "text", text: "ok" }]), ) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") const cleanHistorySpy = vi.spyOn(getTaskTestAccess(task), "buildCleanConversationHistory") // Hold the prompt build open so the metadata fetch can land after the // snapshot was captured but before context sizing runs. let releasePrompt!: () => void const promptGate = new Promise((resolve) => { releasePrompt = () => resolve("mock system prompt") }) vi.mocked(SYSTEM_PROMPT).mockImplementationOnce(() => promptGate) task.apiConversationHistory = [ { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, ] // The summarizeConversation module mock is never cleared, so pin the // call count this request starts from. const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length vi.useFakeTimers() try { const first = task.attemptApiRequest(0, { requestModelInfo: threadedInfo }).next() await vi.advanceTimersByTimeAsync(0) // Late metadata arrives while the request is still being built; a // fresh re-read here would return the wider 128k window instead. resolveFetch() releasePrompt() await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) await expect(first).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" } }) } finally { vi.useRealTimers() } const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) // Argument index 17 is modelInfo: the prompt used the caller's snapshot. expect(systemPromptCall[17]).toBe(threadedInfo) // The threaded snapshot replaces the per-request guard entirely. expect(safeSpy).not.toHaveBeenCalled() // The cleaned request history resolves its model-dependent flags from // the same threaded snapshot, not from a fresh handler re-read. expect(cleanHistorySpy).toHaveBeenCalledWith(expect.any(Array), threadedInfo) // Condensing ran because sizing resolved from the 32k threaded // window; a 128k re-read would have cleared the threshold instead. expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) }) }) describe("buildCleanConversationHistory", () => { // Assistant message carrying a plain-text (unencrypted) reasoning block: // whether the block survives into the sent history depends solely on the // model snapshot's preserveReasoning flag. const reasoningMessage: ApiMessage = { role: "assistant", content: [ { type: "reasoning", text: "hidden chain of thought", summary: [], } as unknown as Anthropic.Messages.ContentBlockParam, { type: "text", text: "answer" }, ], ts: 1, } function historyFor(preserveReasoning: boolean) { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // The handler re-read deliberately disagrees with the threaded // snapshot: any output that follows the re-read instead of the // parameter flips the assertions below. vi.spyOn(task.api, "getModel").mockReturnValue({ id: "lazy-router-model", info: { contextWindow: 1_000, supportsPromptCache: false, preserveReasoning: !preserveReasoning }, }) const requestModelInfo: ModelInfo = { contextWindow: 1_000, supportsPromptCache: false, preserveReasoning, } return getTaskTestAccess(task).buildCleanConversationHistory([reasoningMessage], requestModelInfo) } it("keeps plain-text reasoning when the threaded snapshot sets preserveReasoning", () => { const history = historyFor(true) expect(history).toEqual([{ role: "assistant", content: reasoningMessage.content }]) }) it("strips plain-text reasoning when the threaded snapshot omits preserveReasoning", () => { const history = historyFor(false) expect(history).toEqual([{ role: "assistant", content: "answer" }]) }) }) describe("startTask", () => { it("posts a clean state immediately before adding the first task message", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "new task", startTask: false, }) const taskAccess = getTaskTestAccess(task) task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "stale message" }] let resolvePostState: (() => void) | undefined const pendingPostState = new Promise((resolve) => { resolvePostState = resolve }) const postStateSpy = vi .mocked(mockProvider.postStateToWebviewWithoutTaskHistory) .mockImplementationOnce(async () => { expect(task.clineMessages).toEqual([]) await pendingPostState }) const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ enabledToolCount: 0, enabledServerCount: 0, }) const initiateTaskLoopSpy = vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) const startPromise = taskAccess.startTask("new task") expect(postStateSpy).toHaveBeenCalledTimes(1) expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() expect(saySpy).not.toHaveBeenCalled() resolvePostState?.() await startPromise expect(saySpy).toHaveBeenCalledOnce() expect(saySpy).toHaveBeenCalledWith("text", "new task", undefined) expect(initiateTaskLoopSpy).toHaveBeenCalledOnce() }) }) describe("start()", () => { it("should be a no-op if the task was already started in the constructor", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Manually trigger start const startTaskSpy = vi.spyOn(getTaskTestAccess(task), "startTask").mockImplementation(async () => {}) task.start() expect(startTaskSpy).toHaveBeenCalledTimes(1) // Calling start() again should be a no-op task.start() expect(startTaskSpy).toHaveBeenCalledTimes(1) }) it("should not call startTask if already started via constructor", () => { // Create a task that starts immediately (startTask defaults to true) // but mock startTask to prevent actual execution const startTaskSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "startTask") .mockImplementation(async () => {}) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: true, }) // startTask was called by the constructor expect(startTaskSpy).toHaveBeenCalledTimes(1) // Calling start() should be a no-op since _started is already true task.start() expect(startTaskSpy).toHaveBeenCalledTimes(1) startTaskSpy.mockRestore() }) }) describe("unhandled-rejection guards on void async calls", () => { // PR #253 wired `.catch(...)` onto every fire-and-forget async call that // Copilot flagged as a potential unhandled-rejection source. These specs // pin that behavior so a future refactor cannot silently drop the // handler and reintroduce the crash risk on the extension host. const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)) let consoleErrorSpy: ReturnType beforeEach(() => { consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) }) afterEach(() => { consoleErrorSpy.mockRestore() vi.restoreAllMocks() }) it("logs (instead of crashing) when startTask rejects from the constructor", async () => { const boom = new Error("startTask boom") const startTaskSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "startTask") .mockImplementation(async () => { throw boom }) new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: true, }) expect(startTaskSpy).toHaveBeenCalledTimes(1) await flushMicrotasks() expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#constructor] startTask failed:", boom) startTaskSpy.mockRestore() }) it("logs (instead of crashing) when resumeTaskFromHistory rejects from the constructor", async () => { const boom = new Error("resume boom") const resumeSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "resumeTaskFromHistory") .mockImplementation(async () => { throw boom }) new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, historyItem: { id: "123", number: 0, ts: Date.now(), task: "historical task", tokensIn: 100, tokensOut: 200, cacheWrites: 0, cacheReads: 0, totalCost: 0.001, }, startTask: true, }) expect(resumeSpy).toHaveBeenCalledTimes(1) await flushMicrotasks() expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#constructor] resumeTaskFromHistory failed:", boom) resumeSpy.mockRestore() }) it("logs (instead of crashing) when postStateToWebviewThrottled rejects from the queue handler", async () => { const boom = new Error("postState boom") mockProvider.postStateToWebviewThrottled = vi.fn().mockRejectedValue(boom) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Triggers messageQueueStateChangedHandler -> void postStateToWebviewThrottled() task.messageQueueService.addMessage("queued text") await flushMicrotasks() expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#messageQueueStateChangedHandler] postStateToWebviewThrottled failed:", boom, ) }) it("logs (instead of crashing) when startTask rejects from start()", async () => { const boom = new Error("start() boom") const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) vi.spyOn(getTaskTestAccess(task), "startTask").mockImplementation(async () => { throw boom }) task.start() await flushMicrotasks() expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#start] startTask failed:", boom) }) it("swallows the expected abort rejection from presentAssistantMessageSafe", async () => { const assistantMessageModule = await import("../../assistant-message") const presentSpy = vi .spyOn(assistantMessageModule, "presentAssistantMessage") .mockRejectedValue(new Error("[Task#presentAssistantMessage] task t.i aborted")) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Drain any unrelated console.error noise emitted by async constructor side effects // (CloudService/getState complaints in the test harness) so we only assert on the // abort-path behavior under test. await flushMicrotasks() consoleErrorSpy.mockClear() setAbortLatch(task, true) getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) const presentErrors = consoleErrorSpy.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"), ) expect(presentErrors).toHaveLength(0) }) it("logs non-abort rejections from presentAssistantMessageSafe", async () => { const assistantMessageModule = await import("../../assistant-message") const boom = new Error("present boom") const presentSpy = vi.spyOn(assistantMessageModule, "presentAssistantMessage").mockRejectedValue(boom) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) expect(task.abort).toBeFalsy() getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining("[Task#presentAssistantMessage] task"), boom, ) }) it("logs a non-abort error even when this.abort flips true after the throw", async () => { // Pins that the message-based discriminator is load-bearing, not the // state check. Under the previous `if (this.abort) return` guard this // case (a genuine downstream failure racing with an abort flip between // the throw and the catch microtask) would silently swallow the error. const assistantMessageModule = await import("../../assistant-message") const realError = new Error("genuine downstream failure") const presentSpy = vi.spyOn(assistantMessageModule, "presentAssistantMessage").mockRejectedValue(realError) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await flushMicrotasks() consoleErrorSpy.mockClear() // Simulate the TOCTOU race: abort flips between throw and catch. setAbortLatch(task, true) getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining("[Task#presentAssistantMessage] task"), realError, ) }) it("suppresses an abort-pattern error by message match even when this.abort is false", async () => { // Pins the inverse: message wins over state. A stale abort rejection // arriving before `this.abort` has been observed as true must still be // suppressed, so the catch handler never logs the expected // cancellation rejection as a real failure. const assistantMessageModule = await import("../../assistant-message") const abortError = new Error("[Task#presentAssistantMessage] task t.i aborted") const presentSpy = vi.spyOn(assistantMessageModule, "presentAssistantMessage").mockRejectedValue(abortError) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) await flushMicrotasks() consoleErrorSpy.mockClear() expect(task.abort).toBeFalsy() getTaskTestAccess(task).presentAssistantMessageSafe() await flushMicrotasks() expect(presentSpy).toHaveBeenCalledTimes(1) const presentErrors = consoleErrorSpy.mock.calls.filter( (call: unknown[]) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"), ) expect(presentErrors).toHaveLength(0) }) it("logs (instead of crashing) when updateClineMessage rejects from the say() partial-update path", async () => { // Pins the symmetric .catch arm on the fire-and-forget // updateClineMessage call in say(). The callee's webview post is // internally guarded, but its synchronous emit can throw via a // consumer-attached listener — that path must surface as a log, // not an unhandled rejection. const boom = new Error("updateClineMessage boom") const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockImplementation(async () => { throw boom }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Seed a prior partial "say" so the partial-update branch fires. task.clineMessages.push({ ts: Date.now() - 1, type: "say", say: "text", text: "partial", partial: true, }) await task.say("text", "updated partial", undefined, true) await flushMicrotasks() expect(updateSpy).toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#say] updateClineMessage failed:", boom) updateSpy.mockRestore() }) it("logs (instead of crashing) when updateClineMessage rejects from the ask() complete-partial path", async () => { // Pins the symmetric .catch arm on the fire-and-forget // updateClineMessage call in ask() when finalizing a partial. const boom = new Error("updateClineMessage boom") const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockImplementation(async () => { throw boom }) const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Seed a prior partial "ask" of type "tool" so the complete-partial // branch fires when ask("tool", ..., false) is called. task.clineMessages.push({ ts: Date.now() - 1, type: "ask", ask: "tool", text: "partial", partial: true, }) // ask() resolves only after a response — fire-and-forget so the // promise the suite awaits stays bounded. The .catch on the // pending ask handles the never-resolved promise. void task.ask("tool", "complete", false).catch(() => {}) await flushMicrotasks() expect(updateSpy).toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#ask] updateClineMessage failed:", boom) updateSpy.mockRestore() saveSpy.mockRestore() }) it("finalizePartialToolAsk persists and updates a non-last partial tool ask", async () => { let updateSnapshot: Record | undefined const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockImplementation(async (message) => { updateSnapshot = { ...message } }) // Capture the message as it is when the save runs: this is the payload that // reaches disk, so the flags must already be set before the save. let savedSnapshot: Record | undefined const saveSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages") .mockImplementation(async () => { savedSnapshot = structuredClone(task.clineMessages[0]) return true }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const partialToolAsk = { ts: Date.now() - 2, type: "ask" as const, ask: "tool" as const, text: "partial tool message", partial: true, progressStatus: { text: "Generating…", icon: "sync" }, } task.clineMessages.push(partialToolAsk) task.clineMessages.push({ ts: Date.now() - 1, type: "say", say: "error", text: "intervening async message", }) await task.finalizePartialToolAsk("partial tool message") await flushMicrotasks() expect(partialToolAsk.partial).toBe(false) expect(partialToolAsk.progressStatus).toBeUndefined() // The ask is resolved by the system, not the user: stamp isAnswered so ChatView // does not keep Save/Reject armed for a write that already failed. expect(task.clineMessages[0].isAnswered).toBe(true) expect(saveSpy).toHaveBeenCalledTimes(1) expect(savedSnapshot?.partial).toBe(false) expect(savedSnapshot?.progressStatus).toBeUndefined() expect(savedSnapshot?.isAnswered).toBe(true) expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) expect(updateSnapshot?.partial).toBe(false) expect(updateSnapshot?.progressStatus).toBeUndefined() expect(updateSnapshot?.isAnswered).toBe(true) updateSpy.mockRestore() saveSpy.mockRestore() }) it("finalizePartialToolAsk ignores non-matching partial tool asks when text is provided", async () => { const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockResolvedValue(undefined) const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) task.clineMessages.push({ ts: Date.now() - 1, type: "ask", ask: "tool", text: "other partial tool message", partial: true, }) await task.finalizePartialToolAsk("target partial tool message") await flushMicrotasks() expect(task.clineMessages[0].partial).toBe(true) expect(task.clineMessages[0].isAnswered).toBeUndefined() expect(saveSpy).not.toHaveBeenCalled() expect(updateSpy).not.toHaveBeenCalled() updateSpy.mockRestore() saveSpy.mockRestore() }) it("finalizePartialToolAsk updates the latest partial tool ask when no text is provided", async () => { const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockResolvedValue(undefined) const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const olderPartialToolAsk = { ts: Date.now() - 2, type: "ask" as const, ask: "tool" as const, text: "older partial tool message", partial: true, } const latestPartialToolAsk = { ts: Date.now() - 1, type: "ask" as const, ask: "tool" as const, text: "latest partial tool message", partial: true, } task.clineMessages.push(olderPartialToolAsk) task.clineMessages.push(latestPartialToolAsk) await task.finalizePartialToolAsk() await flushMicrotasks() expect(olderPartialToolAsk.partial).toBe(true) expect(task.clineMessages[0].isAnswered).toBeUndefined() expect(latestPartialToolAsk.partial).toBe(false) // Only the finalized ask is stamped answered; the untouched one is not. expect(task.clineMessages[1].isAnswered).toBe(true) expect(saveSpy).toHaveBeenCalled() expect(updateSpy).toHaveBeenCalledWith(latestPartialToolAsk) updateSpy.mockRestore() saveSpy.mockRestore() }) it("finalizePartialToolAsk logs (instead of rejecting) when updateClineMessage rejects", async () => { // Pins the .catch arm on updateClineMessage in finalizePartialToolAsk: the // partial flag must already be persisted (saveClineMessages ran first), the // failure must only be logged, and finalize must still resolve so callers' // error-path cleanup (diff-view reset, resetTaskPartialState) always completes. const boom = new Error("updateClineMessage boom") const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockImplementation(async () => { throw boom }) const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const partialToolAsk = { ts: Date.now() - 1, type: "ask" as const, ask: "tool" as const, text: "partial tool message", partial: true, } task.clineMessages.push(partialToolAsk) await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() await flushMicrotasks() expect(partialToolAsk.partial).toBe(false) expect(task.clineMessages[0].isAnswered).toBe(true) expect(saveSpy).toHaveBeenCalled() expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#finalizePartialToolAsk] updateClineMessage failed:", boom, ) updateSpy.mockRestore() saveSpy.mockRestore() }) it("finalizePartialToolAsk logs and skips the webview update when persistence fails", async () => { // Pins the saveClineMessages-failure guard in finalizePartialToolAsk: while the // on-disk record still carries partial: true, a webview-only update would diverge // the two views (a later state resync or restart reload would flip the spinner // back on). finalize must log, skip updateClineMessage, and still resolve so // callers' error-path cleanup completes. const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(false) const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockResolvedValue(undefined) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const partialToolAsk = { ts: Date.now() - 1, type: "ask" as const, ask: "tool" as const, text: "partial tool message", partial: true, } task.clineMessages.push(partialToolAsk) await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() await flushMicrotasks() // The in-memory message is still finalized so the ask is resolved by the system... expect(partialToolAsk.partial).toBe(false) expect(task.clineMessages[0].isAnswered).toBe(true) // ...and the persistence failure is observed instead of silently swallowed. expect(saveSpy).toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update", ) // ...but the webview update is skipped so disk (still partial: true) and // webview do not diverge until the next save repairs the record. expect(updateSpy).not.toHaveBeenCalled() updateSpy.mockRestore() saveSpy.mockRestore() }) it("finalizePartialToolAsk still updates the webview when a later save stage fails", async () => { // saveClineMessages() reports the message write separately from the metadata / // task-history stages: the message array persisted while a later stage failed // must still count as a successful save, so the finalized ask reaches the // webview. A stale metadata entry is recomputed and re-emitted by the next // saveClineMessages() call. // saveTaskMessages() persists through safeWriteJson, which only mocks the // fs/promises write helpers: its real fs.access gate and lockfile need the // task directory to exist (uuid v7 is mocked to the fixed id below). const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") fsReal.mkdirSync(taskDir, { recursive: true }) // Earlier tests in this file can leave a ui_messages.json for the fixed task id; // merge=true would read it here and fail the first save stage for an unrelated reason. fsReal.rmSync(path.join(taskDir, "ui_messages.json"), { force: true }) // Declared outside the try so the finally block can restore them. const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockResolvedValue(undefined) const historySpy = vi.spyOn(mockProvider, "updateTaskHistory") try { historySpy.mockRejectedValueOnce(new Error("task history stage failed")) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const partialToolAsk = { ts: Date.now() - 1, type: "ask" as const, ask: "tool" as const, text: "partial tool message", partial: true, } task.clineMessages.push(partialToolAsk) await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() await flushMicrotasks() expect(partialToolAsk.partial).toBe(false) expect(task.clineMessages[0].isAnswered).toBe(true) // The message array persisted, so the webview update must run even though a // later save stage failed... expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) // ...and the later-stage failure is observed instead of silently swallowed. expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save task metadata:", expect.any(Error)) } finally { // The task uuid is mocked to a fixed value, so the record this save writes outlives // the test. Later saves run with merge = true and would fold a stale // ui_messages.json into their own message arrays, so drop it here instead of // leaving it for whichever test cleans up next. fsReal.rmSync(path.join(taskDir, "ui_messages.json"), { force: true }) updateSpy.mockRestore() historySpy.mockRestore() } }) it("finalizePartialToolAsk skips the webview update when the message write itself fails", async () => { // Complements the later-stage-failure test above by failing the first save // stage: with the real task directory removed, safeWriteJson's fs.access // throws before anything is persisted, saveClineMessages() reports the // failed message write, and the skip guard keeps the webview update off. const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockResolvedValue(undefined) try { fsReal.rmSync(taskDir, { recursive: true, force: true }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const partialToolAsk = { ts: Date.now() - 1, type: "ask" as const, ask: "tool" as const, text: "partial tool message", partial: true, } task.clineMessages.push(partialToolAsk) await expect(task.finalizePartialToolAsk("partial tool message")).resolves.toBeUndefined() await flushMicrotasks() // The in-memory ask is still finalized... (the flags are set before saving) expect(partialToolAsk.partial).toBe(false) expect(task.clineMessages[0].isAnswered).toBe(true) // ...but the failed message write skips the webview update and surfaces // both failure logs instead of updating on an unpersisted save. expect(updateSpy).not.toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save Roo messages:", expect.any(Error)) expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update", ) } finally { // Restore the shared directory for sibling tests that persist through the // real fs, even when the operation rejects or an assertion fails. fsReal.mkdirSync(taskDir, { recursive: true }) updateSpy.mockRestore() } }) it("finalizePartialToolAsk ignores partial asks that match only some predicate clauses", async () => { // Each distractor below satisfies a strict subset of the findLast predicate // clauses, so no single clause (or a wrong combination of clauses) may select // it: partial, type, ask kind, and text must all hold together. const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockResolvedValue(undefined) const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const partialToolAsk: ClineMessage = { ts: Date.now() - 4, type: "ask" as const, ask: "tool" as const, text: "partial tool message", partial: true, } // Completed (non-partial) tool ask with the same text. const completedToolAsk: ClineMessage = { ts: Date.now() - 3, type: "ask" as const, ask: "tool" as const, text: "partial tool message", partial: false, } // Partial ask of a different kind. const nonToolAsk: ClineMessage = { ts: Date.now() - 2, type: "ask" as const, ask: "completion_result" as const, text: "partial tool message", partial: true, } // Deliberately malformed: a "say" message that still carries the tool ask // fields. The ClineMessage schema allows both fields, and the predicate under // test reads message.ask on any message, so this is exactly the distractor // the type clause exists to filter out. const sayWithToolAsk: ClineMessage = { ts: Date.now() - 1, type: "say" as const, say: "error" as const, text: "partial tool message", partial: true, ask: "tool" as const, } task.clineMessages.push(partialToolAsk) task.clineMessages.push(completedToolAsk) task.clineMessages.push(nonToolAsk) task.clineMessages.push(sayWithToolAsk) await task.finalizePartialToolAsk("partial tool message") await flushMicrotasks() expect(partialToolAsk.partial).toBe(false) expect(partialToolAsk.isAnswered).toBe(true) // Every distractor stays untouched: only the genuine partial tool ask is // finalized. expect(completedToolAsk.partial).toBe(false) expect(completedToolAsk.isAnswered).toBeUndefined() expect(nonToolAsk.partial).toBe(true) expect(nonToolAsk.isAnswered).toBeUndefined() expect(sayWithToolAsk.partial).toBe(true) expect(saveSpy).toHaveBeenCalledTimes(1) expect(updateSpy).toHaveBeenCalledTimes(1) expect(updateSpy).toHaveBeenCalledWith(partialToolAsk) updateSpy.mockRestore() saveSpy.mockRestore() }) it("logs (instead of crashing) when updateClineMessage rejects from the ask() ignore-partial path", async () => { // Pins the .catch arm on the fire-and-forget updateClineMessage call // in ask() when a new partial ask arrives while the previous partial // is still pending (AskIgnoredError path). const boom = new Error("updateClineMessage boom") const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockImplementation(async () => { throw boom }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Seed a prior partial ask so the isUpdatingPreviousPartial branch fires. task.clineMessages.push({ ts: Date.now() - 1, type: "ask", ask: "tool", text: "partial", partial: true, }) // Sending a new partial of the same type triggers updateClineMessage // then throws AskIgnoredError — catch it so the test doesn't fail. await task.ask("tool", "updated partial", true).catch(() => {}) await flushMicrotasks() expect(updateSpy).toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#ask] updateClineMessage failed:", boom) }) it("logs (instead of crashing) when updateClineMessage rejects from handleWebviewAskResponse", async () => { // Pins the .catch arm on the fire-and-forget updateClineMessage call // in handleWebviewAskResponse when marking a tool ask as answered. const boom = new Error("updateClineMessage boom") const updateSpy = vi .spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage") .mockImplementation(async () => { throw boom }) vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(false) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Seed an unanswered tool ask so the lastToolAskIndex branch fires. task.clineMessages.push({ ts: Date.now() - 1, type: "ask", ask: "tool", text: "tool call", partial: false, }) task.handleWebviewAskResponse("yesButtonClicked") await flushMicrotasks() expect(updateSpy).toHaveBeenCalled() expect(consoleErrorSpy).toHaveBeenCalledWith( "[Task#handleWebviewAskResponse] updateClineMessage failed:", boom, ) }) }) }) describe("Queued message processing after condense", () => { function createProvider(): ClineProvider { const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } const ctx = { globalState: { get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, globalStorageUri: storageUri, workspaceState: { get: vi.fn().mockImplementation((_key) => undefined), update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, secrets: { get: vi.fn().mockResolvedValue(undefined), store: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), }, extensionUri: { fsPath: "/mock/extension/path" }, extension: { packageJSON: { version: "1.0.0" } }, } as unknown as vscode.ExtensionContext const output = { appendLine: vi.fn(), append: vi.fn(), clear: vi.fn(), show: vi.fn(), hide: vi.fn(), dispose: vi.fn(), } const provider = new ClineProvider( ctx, output as unknown as vscode.OutputChannel, "sidebar", new ContextProxy(ctx), new WebviewFocusTracker(), ) provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) provider.getState = vi.fn().mockResolvedValue({}) return provider } const apiConfig: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } it("processes queued message after condense completes", async () => { const provider = createProvider() const task = new Task({ provider, apiConfiguration: apiConfig, task: "initial task", startTask: false, }) // Make condense fast + deterministic vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("system") const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) // Queue a message during condensing task.messageQueueService.addMessage("queued text", ["img1.png"]) // Use fake timers to capture setTimeout(0) in processQueuedMessages vi.useFakeTimers() await task.condenseContext() // Flush the microtask that submits the queued message vi.runAllTimers() vi.useRealTimers() expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"]) expect(task.messageQueueService.isEmpty()).toBe(true) }) it("does not cross-drain queues between separate tasks", async () => { const providerA = createProvider() const providerB = createProvider() const taskA = new Task({ provider: providerA, apiConfiguration: apiConfig, task: "task A", startTask: false, }) const taskB = new Task({ provider: providerB, apiConfiguration: apiConfig, task: "task B", startTask: false, }) vi.spyOn(getTaskTestAccess(taskA), "getSystemPrompt").mockResolvedValue("system") vi.spyOn(getTaskTestAccess(taskB), "getSystemPrompt").mockResolvedValue("system") const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(undefined) const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(undefined) taskA.messageQueueService.addMessage("A message") taskB.messageQueueService.addMessage("B message") // Condense in task A should only drain A's queue vi.useFakeTimers() await taskA.condenseContext() vi.runAllTimers() vi.useRealTimers() expect(spyA).toHaveBeenCalledWith("A message", undefined) expect(spyB).not.toHaveBeenCalled() expect(taskB.messageQueueService.isEmpty()).toBe(false) // Now condense in task B should drain B's queue vi.useFakeTimers() await taskB.condenseContext() vi.runAllTimers() vi.useRealTimers() expect(spyB).toHaveBeenCalledWith("B message", undefined) expect(taskB.messageQueueService.isEmpty()).toBe(true) }) }) describe("Telemetry installments (idle/shutdown flush)", () => { let mockProvider: ClineProvider let mockApiConfig: ProviderSettings let mockExtensionContext: vscode.ExtensionContext let captureTaskCompletedSpy: ReturnType beforeEach(() => { if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) } captureTaskCompletedSpy = vi.spyOn(TelemetryService.instance, "captureTaskCompleted") const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } mockExtensionContext = { globalState: { get: vi.fn().mockReturnValue(undefined), update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, globalStorageUri: storageUri, workspaceState: { get: vi.fn().mockReturnValue(undefined), update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, secrets: { get: vi.fn().mockResolvedValue(undefined), store: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), }, extensionUri: { fsPath: "/mock/extension/path" }, extension: { packageJSON: { version: "1.0.0" } }, } as unknown as vscode.ExtensionContext mockProvider = new ClineProvider( mockExtensionContext, { appendLine: vi.fn(), append: vi.fn(), clear: vi.fn(), show: vi.fn(), hide: vi.fn(), dispose: vi.fn(), } as unknown as vscode.OutputChannel, "sidebar", new ContextProxy(mockExtensionContext), new WebviewFocusTracker(), ) mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) mockApiConfig = { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } }) const createdTasks: Task[] = [] afterEach(async () => { for (const task of createdTasks) { await task.dispose() } createdTasks.length = 0 vi.useRealTimers() captureTaskCompletedSpy.mockRestore() }) function createTask() { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) task.startIdleTelemetryCheck() createdTasks.push(task) return task } describe("flushTelemetryInstallment", () => { it("reports nothing and does not call captureTaskCompleted when there is no new activity", () => { const task = createTask() task.flushTelemetryInstallment("idle") expect(captureTaskCompletedSpy).not.toHaveBeenCalled() }) it("reports the current toolUsage/messageCounts as the delta on the first flush", () => { const task = createTask() task.recordToolUsage("read_file") task.recordToolUsage("read_file") task.messageCounts = { user: 2, assistant: 3 } task.flushTelemetryInstallment("idle") expect(captureTaskCompletedSpy).toHaveBeenCalledWith( task.taskId, { read_file: { attempts: 2, failures: 0 } }, { user: 2, assistant: 3 }, "idle", ) }) it("does not mutate task.toolUsage/messageCounts (they stay running totals for the public API/UI)", () => { const task = createTask() task.recordToolUsage("read_file") task.messageCounts = { user: 1, assistant: 1 } task.flushTelemetryInstallment("idle") expect(task.toolUsage).toEqual({ read_file: { attempts: 1, failures: 0 } }) expect(task.messageCounts).toEqual({ user: 1, assistant: 1 }) }) it("reports only the delta since the previous installment on a second flush", () => { const task = createTask() task.recordToolUsage("read_file") task.messageCounts = { user: 1, assistant: 1 } task.flushTelemetryInstallment("idle") captureTaskCompletedSpy.mockClear() task.recordToolUsage("read_file") task.recordToolUsage("write_to_file") task.messageCounts = { user: 3, assistant: 2 } task.flushTelemetryInstallment("shutdown") expect(captureTaskCompletedSpy).toHaveBeenCalledWith( task.taskId, { read_file: { attempts: 1, failures: 0 }, write_to_file: { attempts: 1, failures: 0 } }, { user: 2, assistant: 1 }, "shutdown", ) }) it("does not emit an empty second installment when nothing changed since the first flush", () => { const task = createTask() task.recordToolUsage("read_file") task.flushTelemetryInstallment("idle") captureTaskCompletedSpy.mockClear() task.flushTelemetryInstallment("shutdown") expect(captureTaskCompletedSpy).not.toHaveBeenCalled() }) it("includes failure deltas alongside attempt deltas", () => { const task = createTask() task.recordToolUsage("read_file") task.flushTelemetryInstallment("idle") captureTaskCompletedSpy.mockClear() task.recordToolError("read_file") task.flushTelemetryInstallment("shutdown") expect(captureTaskCompletedSpy).toHaveBeenCalledWith( task.taskId, { read_file: { attempts: 0, failures: 1 } }, { user: 0, assistant: 0 }, "shutdown", ) }) }) describe("idle flush timer", () => { it("flushes once activity has been quiet for the idle threshold", () => { vi.useFakeTimers() const task = createTask() task.recordToolUsage("read_file") vi.advanceTimersByTime(31 * 60 * 1000) expect(captureTaskCompletedSpy).toHaveBeenCalledWith( task.taskId, { read_file: { attempts: 1, failures: 0 } }, { user: 0, assistant: 0 }, "idle", ) }) it("does not flush before the idle threshold has elapsed", () => { vi.useFakeTimers() const task = createTask() task.recordToolUsage("read_file") vi.advanceTimersByTime(10 * 60 * 1000) expect(captureTaskCompletedSpy).not.toHaveBeenCalled() }) it("does not call the idle flush after a prior idle installment without new activity", () => { vi.useFakeTimers() const task = createTask() const flushTelemetryInstallmentSpy = vi.spyOn(task, "flushTelemetryInstallment") task.recordToolUsage("read_file") vi.advanceTimersByTime(31 * 60 * 1000) expect(captureTaskCompletedSpy).toHaveBeenCalledTimes(1) flushTelemetryInstallmentSpy.mockClear() captureTaskCompletedSpy.mockClear() vi.advanceTimersByTime(5 * 60 * 1000) expect(flushTelemetryInstallmentSpy).not.toHaveBeenCalled() expect(captureTaskCompletedSpy).not.toHaveBeenCalled() }) }) describe("dispose", () => { it("flushes unreported activity as a shutdown installment", () => { const task = createTask() task.recordToolUsage("read_file") task.messageCounts = { user: 1, assistant: 1 } void task.dispose() expect(captureTaskCompletedSpy).toHaveBeenCalledWith( task.taskId, { read_file: { attempts: 1, failures: 0 } }, { user: 1, assistant: 1 }, "shutdown", ) }) it("does not flush again if everything was already reported before dispose", () => { const task = createTask() task.recordToolUsage("read_file") task.flushTelemetryInstallment("attempt_completion") captureTaskCompletedSpy.mockClear() void task.dispose() expect(captureTaskCompletedSpy).not.toHaveBeenCalled() }) it("stops the idle timer so a disposed task never flushes again", () => { vi.useFakeTimers() const task = createTask() task.recordToolUsage("read_file") void task.dispose() captureTaskCompletedSpy.mockClear() vi.advanceTimersByTime(60 * 60 * 1000) expect(captureTaskCompletedSpy).not.toHaveBeenCalled() }) }) }) describe("pushToolResultToUserContent", () => { let mockProvider: ClineProvider let mockApiConfig: ProviderSettings beforeEach(() => { mockApiConfig = { apiProvider: providerIdentifiers.anthropic, apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-api-key", } const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } const mockExtensionContext = { globalState: { get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, globalStorageUri: storageUri, workspaceState: { get: vi.fn().mockImplementation((_key) => undefined), update: vi.fn().mockResolvedValue(undefined), keys: vi.fn().mockReturnValue([]), }, secrets: { get: vi.fn().mockResolvedValue(undefined), store: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), }, extensionUri: { fsPath: "/mock/extension/path" }, extension: { packageJSON: { version: "1.0.0" } }, } as unknown as vscode.ExtensionContext const mockOutputChannel = { name: "test-output", appendLine: vi.fn(), append: vi.fn(), replace: vi.fn(), clear: vi.fn(), show: vi.fn(), hide: vi.fn(), dispose: vi.fn(), } mockProvider = new ClineProvider( mockExtensionContext, mockOutputChannel, "sidebar", new ContextProxy(mockExtensionContext), new WebviewFocusTracker(), ) mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) }) it("should add tool_result when not a duplicate", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const toolResult: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "test-id-1", content: "Test result", } const added = task.pushToolResultToUserContent(toolResult) expect(added).toBe(true) expect(task.userMessageContent).toHaveLength(1) expect(task.userMessageContent[0]).toEqual(toolResult) }) it("should prevent duplicate tool_result with same tool_use_id", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const toolResult1: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "duplicate-id", content: "First result", } const toolResult2: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "duplicate-id", content: "Second result (should be skipped)", } // Spy on console.warn to verify warning is logged const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) // Add first result - should succeed const added1 = task.pushToolResultToUserContent(toolResult1) expect(added1).toBe(true) expect(task.userMessageContent).toHaveLength(1) // Add second result with same ID - should be skipped const added2 = task.pushToolResultToUserContent(toolResult2) expect(added2).toBe(false) expect(task.userMessageContent).toHaveLength(1) // Verify only the first result is in the array expect(task.userMessageContent[0]).toEqual(toolResult1) // Verify warning was logged expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"), ) warnSpy.mockRestore() }) it("should allow different tool_use_ids to be added", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const toolResult1: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "id-1", content: "Result 1", } const toolResult2: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "id-2", content: "Result 2", } const added1 = task.pushToolResultToUserContent(toolResult1) const added2 = task.pushToolResultToUserContent(toolResult2) expect(added1).toBe(true) expect(added2).toBe(true) expect(task.userMessageContent).toHaveLength(2) expect(task.userMessageContent[0]).toEqual(toolResult1) expect(task.userMessageContent[1]).toEqual(toolResult2) }) it("should handle tool_result with is_error flag", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const errorResult: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "error-id", content: "Error message", is_error: true, } const added = task.pushToolResultToUserContent(errorResult) expect(added).toBe(true) expect(task.userMessageContent).toHaveLength(1) expect(task.userMessageContent[0]).toEqual(errorResult) }) it("should preserve other content types while keeping tool_results contiguous", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) // Add text and image blocks manually const image: Anthropic.ImageBlockParam = { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" }, } task.userMessageContent.push({ type: "text", text: "Some text" }, image) const toolResult: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "test-id", content: "Result", } const added = task.pushToolResultToUserContent(toolResult) expect(added).toBe(true) expect(task.userMessageContent).toHaveLength(3) // Results must lead the message (Anthropic rejects other content ahead // of or between tool_results), so the pre-existing text and image move // after the result; blocks keep their relative order within each group. expect(task.userMessageContent[0]).toEqual(toolResult) expect(task.userMessageContent[1].type).toBe("text") expect(task.userMessageContent[2]).toEqual(image) }) it("keeps tool_result blocks contiguous when a later result has no images (GitHub #1307)", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const toolResult1: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "id-1", content: "Result 1", } const toolResult2: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "id-2", content: "Result 2", } const image1: Anthropic.ImageBlockParam = { type: "image", source: { type: "base64", media_type: "image/png", data: "img1" }, } const image2: Anthropic.ImageBlockParam = { type: "image", source: { type: "base64", media_type: "image/png", data: "img2" }, } // First tool result arrives with its images appended right after it (as // presentAssistantMessage does); a second result without images follows. task.pushToolResultToUserContent(toolResult1) task.userMessageContent.push(image1, image2) task.pushToolResultToUserContent(toolResult2) expect(task.userMessageContent.map((block) => block.type)).toEqual([ "tool_result", "tool_result", "image", "image", ]) expect(task.userMessageContent[0]).toEqual(toolResult1) expect(task.userMessageContent[1]).toEqual(toolResult2) expect(task.userMessageContent[2]).toEqual(image1) expect(task.userMessageContent[3]).toEqual(image2) }) it("moves text as well as images behind the tool_results, preserving order within each group", () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) const toolResult1: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "id-1", content: "Result 1", } const toolResult2: Anthropic.ToolResultBlockParam = { type: "tool_result", tool_use_id: "id-2", content: "Result 2", } const image: Anthropic.ImageBlockParam = { type: "image", source: { type: "base64", media_type: "image/png", data: "img1" }, } const text1: Anthropic.TextBlockParam = { type: "text", text: "text one" } const text2: Anthropic.TextBlockParam = { type: "text", text: "text two" } // Interleaved arrival: image, text, r1, more text, r2. Grouping only // images would leave text1 ahead of r1, which Anthropic rejects; every // non-tool_result block must move behind the contiguous result run. task.userMessageContent.push(image, text1) task.pushToolResultToUserContent(toolResult1) task.userMessageContent.push(text2) task.pushToolResultToUserContent(toolResult2) expect(task.userMessageContent).toEqual([toolResult1, toolResult2, image, text1, text2]) }) })