mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
6f65a26694
commit
70cbc716e8
15 changed files with 494 additions and 50 deletions
|
|
@ -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" })
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export type TaskProviderEvents = {
|
|||
*/
|
||||
|
||||
export interface CreateTaskOptions {
|
||||
taskId?: string
|
||||
enableCheckpoints?: boolean
|
||||
consecutiveMistakeLimit?: number
|
||||
experiments?: Record<string, boolean>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ vi.mock("../core/task/Task", () => {
|
|||
this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" }
|
||||
opts.onCreated?.(this)
|
||||
}
|
||||
start() {}
|
||||
on() {}
|
||||
off() {}
|
||||
emit() {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue