Просмотр исходного кода

fix(cli): streaming deltas, task ID propagation, cancel recovery, and misc fixes (#11736)

* fix(cli): streaming deltas, task ID propagation, cancel recovery, and misc fixes

- Stream tool_use ask messages (command, tool, mcp) as structured deltas
  instead of full snapshots in json-event-emitter
- Generate task ID upfront and propagate through runTask/createTask so
  currentTaskId is available in extension state immediately
- Wait for resumable state after cancel before processing follow-up
  messages to prevent race conditions in stdin-stream
- Add ROO_CODE_DISABLE_TELEMETRY=1 env var to disable cloud telemetry
- Provide valid empty JSON Schema for custom tools without parameters
  to fix strict-mode API validation
- Skip paths outside cwd in RooProtectedController to avoid RangeError
- Silently handle abort during exponential backoff retry countdown
- Enable customTools experiment in extension host

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add start() to TaskStub in single-open-invariant test

The ClineProvider.createTask change to call task.start() after
addClineToStack requires the test's TaskStub mock to have this method.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Chris Estreich 1 месяц назад
Родитель
Сommit
70cbc716e8

+ 218 - 0
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<string, unknown>[] } {
+	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<string, unknown>)
+
+	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>): 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" })
+	})
+})

+ 6 - 3
apps/cli/src/agent/extension-host.ts

@@ -107,7 +107,7 @@ interface WebviewViewProvider {
 export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
 	client: ExtensionClient
 	activate(): Promise<void>
-	runTask(prompt: string): Promise<void>
+	runTask(prompt: string, taskId?: string): Promise<void>
 	sendToExtension(message: WebviewMessage): void
 	dispose(): Promise<void>
 }
@@ -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<void> {
-		this.sendToExtension({ type: "newTask", text: prompt })
+	public async runTask(prompt: string, taskId?: string): Promise<void> {
+		this.sendToExtension({ type: "newTask", text: prompt, taskId })
 
 		return new Promise((resolve, reject) => {
 			const completeHandler = () => {

+ 174 - 30
apps/cli/src/agent/json-event-emitter.ts

@@ -104,6 +104,8 @@ export class JsonEventEmitter {
 	private seenMessageIds = new Set<number>()
 	// Track previous content for delta computation
 	private previousContent = new Map<number, string>()
+	// Track previous tool-use content for structured (non-append-only) delta computation.
+	private previousToolUseContent = new Map<number, string>()
 	// 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

+ 45 - 6
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<void> {
+	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,

+ 8 - 0
packages/cloud/src/TelemetryClient.ts

@@ -194,6 +194,10 @@ export class CloudTelemetryClient extends BaseTelemetryClient {
 	}
 
 	public async backfillMessages(messages: ClineMessage[], taskId: string): Promise<void> {
+		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
 	}
 

+ 2 - 1
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,
 			},
 		})

+ 4 - 0
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 } }

+ 1 - 0
packages/types/src/task.ts

@@ -89,6 +89,7 @@ export type TaskProviderEvents = {
  */
 
 export interface CreateTaskOptions {
+	taskId?: string
 	enableCheckpoints?: boolean
 	consecutiveMistakeLimit?: number
 	experiments?: Record<string, boolean>

+ 2 - 0
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

+ 1 - 0
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() {}

+ 6 - 1
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
 		}

+ 5 - 0
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", () => {

+ 8 - 1
src/core/task/Task.ts

@@ -423,6 +423,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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)
 		}
 	}

+ 10 - 6
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`,

+ 3 - 1
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) {