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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Estreich 2026-02-24 23:29:50 -08:00 committed by GitHub
parent 6f65a26694
commit 70cbc716e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 494 additions and 50 deletions

View file

@ -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" })
})
})

View file

@ -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 = () => {

View file

@ -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)
}
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.previousToolUseContent.delete(msg.ts)
}
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
}
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

View file

@ -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,

View file

@ -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
}

View file

@ -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,
},
})

View file

@ -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 } }

View file

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

View file

@ -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

View file

@ -19,6 +19,7 @@ vi.mock("../core/task/Task", () => {
this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" }
opts.onCreated?.(this)
}
start() {}
on() {}
off() {}
emit() {}

View file

@ -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
}

View file

@ -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", () => {

View file

@ -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)
}
}

View file

@ -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`,

View file

@ -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) {