diff --git a/apps/cli/src/agent/__tests__/json-event-emitter-streaming.test.ts b/apps/cli/src/agent/__tests__/json-event-emitter-streaming.test.ts new file mode 100644 index 00000000000..329e06d24a0 --- /dev/null +++ b/apps/cli/src/agent/__tests__/json-event-emitter-streaming.test.ts @@ -0,0 +1,218 @@ +import type { ClineMessage } from "@roo-code/types" +import { Writable } from "stream" + +import { JsonEventEmitter } from "../json-event-emitter.js" + +function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record[] } { + const chunks: string[] = [] + + const writable = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()) + callback() + }, + }) as unknown as NodeJS.WriteStream + + const lines = () => + chunks + .join("") + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record) + + return { stdout: writable, lines } +} + +function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void { + ;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage( + message, + false, + ) +} + +function createAskMessage(overrides: Partial): ClineMessage { + return { + ts: 1, + type: "ask", + ask: "tool", + partial: true, + text: "", + ...overrides, + } as ClineMessage +} + +describe("JsonEventEmitter streaming deltas", () => { + it("streams ask:command partial updates as deltas and emits full final snapshot", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const id = 101 + + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "g", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh pr", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: false, + text: "gh pr", + }), + ) + + const output = lines() + expect(output).toHaveLength(4) + expect(output[0]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + content: "g", + tool_use: { name: "execute_command", input: { command: "g" } }, + }) + expect(output[1]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + content: "h", + tool_use: { name: "execute_command", input: { command: "h" } }, + }) + expect(output[2]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + content: " pr", + tool_use: { name: "execute_command", input: { command: " pr" } }, + }) + expect(output[3]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + tool_use: { name: "execute_command", input: { command: "gh pr" } }, + done: true, + }) + expect(output[3]).not.toHaveProperty("content") + }) + + it("streams ask:tool snapshots as structured deltas and preserves full final payload", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const id = 202 + const first = JSON.stringify({ tool: "readFile", path: "a" }) + const second = JSON.stringify({ tool: "readFile", path: "ab" }) + + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "tool", + partial: true, + text: first, + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "tool", + partial: true, + text: second, + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "tool", + partial: false, + text: second, + }), + ) + + const output = lines() + expect(output).toHaveLength(3) + expect(output[0]).toMatchObject({ + type: "tool_use", + id, + subtype: "tool", + content: first, + tool_use: { name: "readFile" }, + }) + expect(output[1]).toMatchObject({ + type: "tool_use", + id, + subtype: "tool", + content: "b", + tool_use: { name: "readFile" }, + }) + expect(output[2]).toMatchObject({ + type: "tool_use", + id, + subtype: "tool", + tool_use: { name: "readFile", input: { tool: "readFile", path: "ab" } }, + done: true, + }) + }) + + it("suppresses duplicate partial tool snapshots with no delta", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const id = 303 + + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh pr", + }), + ) + + const output = lines() + expect(output).toHaveLength(2) + expect(output[0]).toMatchObject({ content: "gh" }) + expect(output[1]).toMatchObject({ content: " pr" }) + }) +}) diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 4a0e941b4bb..ce9103df6f5 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -107,7 +107,7 @@ interface WebviewViewProvider { export interface ExtensionHostInterface extends IExtensionHost { client: ExtensionClient activate(): Promise - runTask(prompt: string): Promise + runTask(prompt: string, taskId?: string): Promise sendToExtension(message: WebviewMessage): void dispose(): Promise } @@ -215,6 +215,9 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac mode: this.options.mode, commandExecutionTimeout: 30, enableCheckpoints: false, + experiments: { + customTools: true, + }, ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), } @@ -458,8 +461,8 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Task Management // ========================================================================== - public async runTask(prompt: string): Promise { - this.sendToExtension({ type: "newTask", text: prompt }) + public async runTask(prompt: string, taskId?: string): Promise { + this.sendToExtension({ type: "newTask", text: prompt, taskId }) return new Promise((resolve, reject) => { const completeHandler = () => { diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index b772b13553c..a3962d0abf6 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -104,6 +104,8 @@ export class JsonEventEmitter { private seenMessageIds = new Set() // Track previous content for delta computation private previousContent = new Map() + // Track previous tool-use content for structured (non-append-only) delta computation. + private previousToolUseContent = new Map() // Track the completion result content private completionResultContent: string | undefined // Track the latest assistant text as a fallback for result.content. @@ -224,6 +226,60 @@ export class JsonEventEmitter { return fullContent.startsWith(previous) ? fullContent.slice(previous.length) : fullContent } + /** + * Compute a compact delta for structured strings (for tool_use snapshots). + * + * Unlike append-only text streams, tool-use payloads are often full snapshots + * where edits happen before a stable suffix (e.g., inside JSON strings). This + * extracts the inserted segment when possible; otherwise it falls back to the + * full snapshot so consumers can recover. + */ + private computeStructuredDelta(msgId: number, fullContent: string | undefined): string | null { + if (!fullContent) { + return null + } + + const previous = this.previousToolUseContent.get(msgId) || "" + + if (fullContent === previous) { + return null + } + + this.previousToolUseContent.set(msgId, fullContent) + + if (previous.length === 0) { + return fullContent + } + + if (fullContent.startsWith(previous)) { + return fullContent.slice(previous.length) + } + + let prefix = 0 + + while (prefix < previous.length && prefix < fullContent.length && previous[prefix] === fullContent[prefix]) { + prefix++ + } + + let suffix = 0 + + while ( + suffix < previous.length - prefix && + suffix < fullContent.length - prefix && + previous[previous.length - 1 - suffix] === fullContent[fullContent.length - 1 - suffix] + ) { + suffix++ + } + + const isPureInsertion = fullContent.length >= previous.length && prefix + suffix >= previous.length + + if (isPureInsertion) { + return fullContent.slice(prefix, fullContent.length - suffix) + } + + return fullContent + } + /** * Check if this is a streaming partial message with no new content. */ @@ -238,6 +294,7 @@ export class JsonEventEmitter { if (this.mode === "stream-json" && isPartial) { return this.computeDelta(msgId, text) } + return text ?? null } @@ -252,15 +309,19 @@ export class JsonEventEmitter { subtype?: string, ): JsonEvent { const event: JsonEvent = { type, id } + if (content !== null) { event.content = content } + if (subtype) { event.subtype = subtype } + if (isDone) { event.done = true } + return event } @@ -283,21 +344,22 @@ export class JsonEventEmitter { if (isDone) { this.seenMessageIds.add(msg.ts) this.previousContent.delete(msg.ts) + this.previousToolUseContent.delete(msg.ts) } - const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) + if (msg.type === "say" && msg.say) { + const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) - // Skip if no new content for streaming partial messages - if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { - return - } + // Skip if no new content for streaming partial messages + if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { + return + } - if (msg.type === "say" && msg.say) { this.handleSayMessage(msg, contentToSend, isDone) } if (msg.type === "ask" && msg.ask) { - this.handleAskMessage(msg, contentToSend, isDone) + this.handleAskMessage(msg, isDone) } } @@ -398,40 +460,31 @@ export class JsonEventEmitter { /** * Handle "ask" type messages. */ - private handleAskMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { + private handleAskMessage(msg: ClineMessage, isDone: boolean): void { switch (msg.ask) { - case "tool": { - const toolInfo = parseToolInfo(msg.text) - this.emitEvent({ - type: "tool_use", - id: msg.ts, - subtype: "tool", - tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } }, - }) + case "tool": + this.handleToolUseAsk(msg, "tool", isDone) break - } case "command": - this.emitEvent({ - type: "tool_use", - id: msg.ts, - subtype: "command", - tool_use: { name: "execute_command", input: { command: msg.text } }, - }) + this.handleToolUseAsk(msg, "command", isDone) break case "use_mcp_server": - this.emitEvent({ - type: "tool_use", - id: msg.ts, - subtype: "mcp", - tool_use: { name: "mcp_server", input: { raw: msg.text } }, - }) + this.handleToolUseAsk(msg, "mcp", isDone) break - case "followup": + case "followup": { + const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) + + // Skip if no new content for streaming partial messages + if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { + return + } + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, "followup")) break + } case "command_output": // Handled in say type @@ -445,12 +498,102 @@ export class JsonEventEmitter { default: if (msg.text) { + const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) + + // Skip if no new content for streaming partial messages + if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { + return + } + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.ask)) } break } } + private handleToolUseAsk(msg: ClineMessage, subtype: "tool" | "command" | "mcp", isDone: boolean): void { + const isStreamingPartial = this.mode === "stream-json" && msg.partial === true + const toolInfo = parseToolInfo(msg.text) + + if (subtype === "command") { + if (isStreamingPartial) { + const commandDelta = this.computeStructuredDelta(msg.ts, msg.text) + if (commandDelta === null) { + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "command", + content: commandDelta, + tool_use: { name: "execute_command", input: { command: commandDelta } }, + }) + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "command", + tool_use: { name: "execute_command", input: { command: msg.text } }, + ...(isDone ? { done: true } : {}), + }) + return + } + + if (subtype === "mcp") { + if (isStreamingPartial) { + const mcpDelta = this.computeStructuredDelta(msg.ts, msg.text) + if (mcpDelta === null) { + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "mcp", + content: mcpDelta, + tool_use: { name: "mcp_server" }, + }) + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "mcp", + tool_use: { name: "mcp_server", input: { raw: msg.text } }, + ...(isDone ? { done: true } : {}), + }) + return + } + + if (isStreamingPartial) { + const toolDelta = this.computeStructuredDelta(msg.ts, msg.text) + if (toolDelta === null) { + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "tool", + content: toolDelta, + tool_use: { name: toolInfo?.name ?? "unknown_tool" }, + }) + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "tool", + tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } }, + ...(isDone ? { done: true } : {}), + }) + } + /** * Handle task completion and emit result event. */ @@ -537,6 +680,7 @@ export class JsonEventEmitter { this.lastCost = undefined this.seenMessageIds.clear() this.previousContent.clear() + this.previousToolUseContent.clear() this.completionResultContent = undefined this.lastAssistantText = undefined this.expectPromptEchoAsUser = true diff --git a/apps/cli/src/commands/cli/stdin-stream.ts b/apps/cli/src/commands/cli/stdin-stream.ts index dceca2e84d7..3d74dd126e0 100644 --- a/apps/cli/src/commands/cli/stdin-stream.ts +++ b/apps/cli/src/commands/cli/stdin-stream.ts @@ -1,4 +1,5 @@ import { createInterface } from "readline" +import { randomUUID } from "crypto" import { isRecord } from "@/lib/utils/guards.js" @@ -182,6 +183,31 @@ function isCancellationLikeError(error: unknown): boolean { return normalized.includes("aborted") || normalized.includes("cancelled") || normalized.includes("canceled") } +const RESUME_ASKS = new Set(["resume_task", "resume_completed_task"]) +const CANCEL_RECOVERY_WAIT_TIMEOUT_MS = 8_000 +const CANCEL_RECOVERY_POLL_INTERVAL_MS = 100 + +function isResumableState(host: ExtensionHost): boolean { + const agentState = host.client.getAgentState() + return ( + agentState.isWaitingForInput && + typeof agentState.currentAsk === "string" && + RESUME_ASKS.has(agentState.currentAsk) + ) +} + +async function waitForPostCancelRecovery(host: ExtensionHost): Promise { + const deadline = Date.now() + CANCEL_RECOVERY_WAIT_TIMEOUT_MS + + while (Date.now() < deadline) { + if (isResumableState(host)) { + return + } + + await new Promise((resolve) => setTimeout(resolve, CANCEL_RECOVERY_POLL_INTERVAL_MS)) + } +} + export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }: StdinStreamModeOptions) { let hasReceivedStdinCommand = false let shouldShutdown = false @@ -191,6 +217,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId let activeTaskCommand: "start" | undefined let latestTaskId: string | undefined let cancelRequestedForActiveTask = false + let awaitingPostCancelRecovery = false let hasSeenQueueState = false let lastQueueDepth = 0 let lastQueueMessageIds: string[] = [] @@ -242,6 +269,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId const onExtensionMessage = (message: { type?: string state?: { + currentTaskId?: unknown currentTaskItem?: { id?: unknown } messageQueue?: unknown } @@ -250,7 +278,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId return } - const currentTaskId = message.state?.currentTaskItem?.id + const currentTaskId = message.state?.currentTaskId ?? message.state?.currentTaskItem?.id if (typeof currentTaskId === "string" && currentTaskId.trim().length > 0) { latestTaskId = currentTaskId } @@ -378,8 +406,9 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId activeRequestId = stdinCommand.requestId activeTaskCommand = "start" setStreamRequestId(stdinCommand.requestId) - latestTaskId = undefined + latestTaskId = randomUUID() cancelRequestedForActiveTask = false + awaitingPostCancelRecovery = false jsonEmitter.emitControl({ subtype: "ack", @@ -392,7 +421,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }) activeTaskPromise = host - .runTask(stdinCommand.prompt) + .runTask(stdinCommand.prompt, latestTaskId) .catch((error) => { const message = error instanceof Error ? error.message : String(error) @@ -434,7 +463,14 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }) break - case "message": + case "message": { + // If cancel was requested, wait briefly for the task to be rehydrated + // so message prompts don't race into the pre-cancel task instance. + if (awaitingPostCancelRecovery) { + await waitForPostCancelRecovery(host) + } + const wasResumable = isResumableState(host) + if (!host.client.hasActiveTask()) { jsonEmitter.emitControl({ subtype: "error", @@ -464,11 +500,13 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId requestId: stdinCommand.requestId, command: "message", taskId: latestTaskId, - content: "message queued", - code: "queued", + content: wasResumable ? "resume message queued" : "message queued", + code: wasResumable ? "resumed" : "queued", success: true, }) + awaitingPostCancelRecovery = false break + } case "cancel": { setStreamRequestId(stdinCommand.requestId) @@ -500,6 +538,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId } cancelRequestedForActiveTask = true + awaitingPostCancelRecovery = true jsonEmitter.emitControl({ subtype: "ack", requestId: stdinCommand.requestId, diff --git a/packages/cloud/src/TelemetryClient.ts b/packages/cloud/src/TelemetryClient.ts index f8e8640323a..252cc8ad42b 100644 --- a/packages/cloud/src/TelemetryClient.ts +++ b/packages/cloud/src/TelemetryClient.ts @@ -194,6 +194,10 @@ export class CloudTelemetryClient extends BaseTelemetryClient { } public async backfillMessages(messages: ClineMessage[], taskId: string): Promise { + if (!this.isTelemetryEnabled()) { + return + } + if (!this.authService.isAuthenticated()) { if (this.debug) { console.info(`[TelemetryClient#backfillMessages] Skipping: Not authenticated`) @@ -260,6 +264,10 @@ export class CloudTelemetryClient extends BaseTelemetryClient { public override updateTelemetryState(_didUserOptIn: boolean) {} public override isTelemetryEnabled(): boolean { + if (process.env.ROO_CODE_DISABLE_TELEMETRY === "1") { + return false + } + return true } diff --git a/packages/core/src/custom-tools/__tests__/format-native.spec.ts b/packages/core/src/custom-tools/__tests__/format-native.spec.ts index 33b909623e3..c53b220a65e 100644 --- a/packages/core/src/custom-tools/__tests__/format-native.spec.ts +++ b/packages/core/src/custom-tools/__tests__/format-native.spec.ts @@ -38,7 +38,8 @@ describe("formatNative", () => { function: { name: "simple_tool", description: "A simple tool", - parameters: undefined, + parameters: { type: "object", properties: {}, required: [], additionalProperties: false }, + source: undefined, strict: true, }, }) diff --git a/packages/core/src/custom-tools/format-native.ts b/packages/core/src/custom-tools/format-native.ts index c1c0018d62d..e7e811172cc 100644 --- a/packages/core/src/custom-tools/format-native.ts +++ b/packages/core/src/custom-tools/format-native.ts @@ -17,6 +17,10 @@ export function formatNative(tool: SerializedCustomToolDefinition): OpenAI.Chat. if (!parameters.required) { parameters.required = [] } + } else { + // Tools without parameters still need a valid JSON Schema object. + // APIs (e.g. Anthropic, OpenAI with strict mode) require inputSchema.type to be "object". + parameters = { type: "object", properties: {}, required: [], additionalProperties: false } } return { type: "function", function: { ...tool, strict: true, parameters } } diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 55b442cca05..56a75728980 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -89,6 +89,7 @@ export type TaskProviderEvents = { */ export interface CreateTaskOptions { + taskId?: string enableCheckpoints?: boolean consecutiveMistakeLimit?: number experiments?: Record diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 15edd13db45..253ce3e55d5 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -309,6 +309,7 @@ export type ExtensionState = Pick< lockApiConfigAcrossModes?: boolean version: string clineMessages: ClineMessage[] + currentTaskId?: string currentTaskItem?: HistoryItem currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings @@ -580,6 +581,7 @@ export interface WebviewMessage { | "updateSkillModes" | "openSkillFile" text?: string + taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index b7e1b99d7c4..2dd466a992a 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -19,6 +19,7 @@ vi.mock("../core/task/Task", () => { this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" } opts.onCreated?.(this) } + start() {} on() {} off() {} emit() {} diff --git a/src/core/protect/RooProtectedController.ts b/src/core/protect/RooProtectedController.ts index d498457ffd0..65676b2d112 100644 --- a/src/core/protect/RooProtectedController.ts +++ b/src/core/protect/RooProtectedController.ts @@ -43,11 +43,16 @@ export class RooProtectedController { const absolutePath = path.resolve(this.cwd, filePath) const relativePath = path.relative(this.cwd, absolutePath).toPosix() + // Paths outside the cwd start with ".." and can't match any protected pattern. + // The ignore library throws RangeError for such paths, so skip them early. + if (relativePath.startsWith("..")) { + return false + } + // Use ignore library to check if file matches any protected pattern return this.ignoreInstance.ignores(relativePath) } catch (error) { // If there's an error processing the path, err on the side of caution - // Ignore is designed to work with relative file paths, so will throw error for paths outside cwd console.error(`Error checking protection for ${filePath}:`, error) return false } diff --git a/src/core/protect/__tests__/RooProtectedController.spec.ts b/src/core/protect/__tests__/RooProtectedController.spec.ts index 974ad028390..0d1de58422d 100644 --- a/src/core/protect/__tests__/RooProtectedController.spec.ts +++ b/src/core/protect/__tests__/RooProtectedController.spec.ts @@ -91,6 +91,11 @@ describe("RooProtectedController", () => { expect(controller.isWriteProtected(".roo\\config.json")).toBe(true) expect(controller.isWriteProtected(".roo/config.json")).toBe(true) }) + + it("should not throw for absolute paths outside cwd", () => { + expect(controller.isWriteProtected("/tmp/comment-2-pr63.json")).toBe(false) + expect(controller.isWriteProtected("/etc/passwd")).toBe(false) + }) }) describe("getProtectedFiles", () => { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9d19248057d..1d4320493a0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -423,6 +423,7 @@ export class Task extends EventEmitter implements TaskLike { enableCheckpoints = true, checkpointTimeout = DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, + taskId, task, images, historyItem, @@ -456,7 +457,7 @@ export class Task extends EventEmitter implements TaskLike { ) } - this.taskId = historyItem ? historyItem.id : uuidv7() + this.taskId = historyItem ? historyItem.id : (taskId ?? uuidv7()) this.rootTaskId = historyItem ? historyItem.rootTaskId : rootTask?.taskId this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId this.childTaskId = undefined @@ -4431,6 +4432,12 @@ export class Task extends EventEmitter implements TaskLike { await this.say("api_req_retry_delayed", headerText, undefined, false) } catch (err) { + const message = err instanceof Error ? err.message : String(err) + + if (this.abort && message.includes("Aborted during retry countdown")) { + return + } + console.error("Exponential backoff failed:", err) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b9da4b4c607..e4a51d8645b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2226,6 +2226,7 @@ export class ClineProvider const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands) const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd + const currentTask = this.getCurrentTask() return { version: this.context.extension?.packageJSON?.version ?? "", @@ -2245,12 +2246,11 @@ export class ClineProvider autoCondenseContext: autoCondenseContext ?? true, autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, - currentTaskItem: this.getCurrentTask()?.taskId - ? this.taskHistoryStore.get(this.getCurrentTask()!.taskId) - : undefined, - clineMessages: this.getCurrentTask()?.clineMessages || [], - currentTaskTodos: this.getCurrentTask()?.todoList || [], - messageQueue: this.getCurrentTask()?.messageQueueService?.messages, + currentTaskId: currentTask?.taskId, + currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, + clineMessages: currentTask?.clineMessages || [], + currentTaskTodos: currentTask?.todoList || [], + messageQueue: currentTask?.messageQueueService?.messages, taskHistory: this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task), soundEnabled: soundEnabled ?? false, ttsEnabled: ttsEnabled ?? false, @@ -2943,10 +2943,14 @@ export class ClineProvider taskNumber: this.clineStack.length + 1, onCreated: this.taskCreationCallback, initialTodos: options.initialTodos, + // Ensure this task is present in clineStack before startTask() emits + // its initial state update, so state.currentTaskId is available ASAP. + startTask: false, ...options, }) await this.addClineToStack(task) + task.start() this.log( `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5194b16df9d..4ec715cf104 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -554,7 +554,9 @@ export const webviewMessageHandler = async ( // task. This essentially creates a fresh slate for the new task. try { const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) - await provider.createTask(resolved.text, resolved.images) + await provider.createTask(resolved.text, resolved.images, undefined, { + taskId: message.taskId, + }) // Task created successfully - notify the UI to reset await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" }) } catch (error) {