Skip to content

Commit 70cbc71

Browse files
cteclaude
andauthored
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>
1 parent 6f65a26 commit 70cbc71

File tree

15 files changed

+493
-49
lines changed

15 files changed

+493
-49
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import type { ClineMessage } from "@roo-code/types"
2+
import { Writable } from "stream"
3+
4+
import { JsonEventEmitter } from "../json-event-emitter.js"
5+
6+
function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record<string, unknown>[] } {
7+
const chunks: string[] = []
8+
9+
const writable = new Writable({
10+
write(chunk, _encoding, callback) {
11+
chunks.push(chunk.toString())
12+
callback()
13+
},
14+
}) as unknown as NodeJS.WriteStream
15+
16+
const lines = () =>
17+
chunks
18+
.join("")
19+
.split("\n")
20+
.filter((line) => line.length > 0)
21+
.map((line) => JSON.parse(line) as Record<string, unknown>)
22+
23+
return { stdout: writable, lines }
24+
}
25+
26+
function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void {
27+
;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage(
28+
message,
29+
false,
30+
)
31+
}
32+
33+
function createAskMessage(overrides: Partial<ClineMessage>): ClineMessage {
34+
return {
35+
ts: 1,
36+
type: "ask",
37+
ask: "tool",
38+
partial: true,
39+
text: "",
40+
...overrides,
41+
} as ClineMessage
42+
}
43+
44+
describe("JsonEventEmitter streaming deltas", () => {
45+
it("streams ask:command partial updates as deltas and emits full final snapshot", () => {
46+
const { stdout, lines } = createMockStdout()
47+
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
48+
const id = 101
49+
50+
emitMessage(
51+
emitter,
52+
createAskMessage({
53+
ts: id,
54+
ask: "command",
55+
partial: true,
56+
text: "g",
57+
}),
58+
)
59+
emitMessage(
60+
emitter,
61+
createAskMessage({
62+
ts: id,
63+
ask: "command",
64+
partial: true,
65+
text: "gh",
66+
}),
67+
)
68+
emitMessage(
69+
emitter,
70+
createAskMessage({
71+
ts: id,
72+
ask: "command",
73+
partial: true,
74+
text: "gh pr",
75+
}),
76+
)
77+
emitMessage(
78+
emitter,
79+
createAskMessage({
80+
ts: id,
81+
ask: "command",
82+
partial: false,
83+
text: "gh pr",
84+
}),
85+
)
86+
87+
const output = lines()
88+
expect(output).toHaveLength(4)
89+
expect(output[0]).toMatchObject({
90+
type: "tool_use",
91+
id,
92+
subtype: "command",
93+
content: "g",
94+
tool_use: { name: "execute_command", input: { command: "g" } },
95+
})
96+
expect(output[1]).toMatchObject({
97+
type: "tool_use",
98+
id,
99+
subtype: "command",
100+
content: "h",
101+
tool_use: { name: "execute_command", input: { command: "h" } },
102+
})
103+
expect(output[2]).toMatchObject({
104+
type: "tool_use",
105+
id,
106+
subtype: "command",
107+
content: " pr",
108+
tool_use: { name: "execute_command", input: { command: " pr" } },
109+
})
110+
expect(output[3]).toMatchObject({
111+
type: "tool_use",
112+
id,
113+
subtype: "command",
114+
tool_use: { name: "execute_command", input: { command: "gh pr" } },
115+
done: true,
116+
})
117+
expect(output[3]).not.toHaveProperty("content")
118+
})
119+
120+
it("streams ask:tool snapshots as structured deltas and preserves full final payload", () => {
121+
const { stdout, lines } = createMockStdout()
122+
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
123+
const id = 202
124+
const first = JSON.stringify({ tool: "readFile", path: "a" })
125+
const second = JSON.stringify({ tool: "readFile", path: "ab" })
126+
127+
emitMessage(
128+
emitter,
129+
createAskMessage({
130+
ts: id,
131+
ask: "tool",
132+
partial: true,
133+
text: first,
134+
}),
135+
)
136+
emitMessage(
137+
emitter,
138+
createAskMessage({
139+
ts: id,
140+
ask: "tool",
141+
partial: true,
142+
text: second,
143+
}),
144+
)
145+
emitMessage(
146+
emitter,
147+
createAskMessage({
148+
ts: id,
149+
ask: "tool",
150+
partial: false,
151+
text: second,
152+
}),
153+
)
154+
155+
const output = lines()
156+
expect(output).toHaveLength(3)
157+
expect(output[0]).toMatchObject({
158+
type: "tool_use",
159+
id,
160+
subtype: "tool",
161+
content: first,
162+
tool_use: { name: "readFile" },
163+
})
164+
expect(output[1]).toMatchObject({
165+
type: "tool_use",
166+
id,
167+
subtype: "tool",
168+
content: "b",
169+
tool_use: { name: "readFile" },
170+
})
171+
expect(output[2]).toMatchObject({
172+
type: "tool_use",
173+
id,
174+
subtype: "tool",
175+
tool_use: { name: "readFile", input: { tool: "readFile", path: "ab" } },
176+
done: true,
177+
})
178+
})
179+
180+
it("suppresses duplicate partial tool snapshots with no delta", () => {
181+
const { stdout, lines } = createMockStdout()
182+
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
183+
const id = 303
184+
185+
emitMessage(
186+
emitter,
187+
createAskMessage({
188+
ts: id,
189+
ask: "command",
190+
partial: true,
191+
text: "gh",
192+
}),
193+
)
194+
emitMessage(
195+
emitter,
196+
createAskMessage({
197+
ts: id,
198+
ask: "command",
199+
partial: true,
200+
text: "gh",
201+
}),
202+
)
203+
emitMessage(
204+
emitter,
205+
createAskMessage({
206+
ts: id,
207+
ask: "command",
208+
partial: true,
209+
text: "gh pr",
210+
}),
211+
)
212+
213+
const output = lines()
214+
expect(output).toHaveLength(2)
215+
expect(output[0]).toMatchObject({ content: "gh" })
216+
expect(output[1]).toMatchObject({ content: " pr" })
217+
})
218+
})

apps/cli/src/agent/extension-host.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ interface WebviewViewProvider {
107107
export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
108108
client: ExtensionClient
109109
activate(): Promise<void>
110-
runTask(prompt: string): Promise<void>
110+
runTask(prompt: string, taskId?: string): Promise<void>
111111
sendToExtension(message: WebviewMessage): void
112112
dispose(): Promise<void>
113113
}
@@ -215,6 +215,9 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
215215
mode: this.options.mode,
216216
commandExecutionTimeout: 30,
217217
enableCheckpoints: false,
218+
experiments: {
219+
customTools: true,
220+
},
218221
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
219222
}
220223

@@ -458,8 +461,8 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
458461
// Task Management
459462
// ==========================================================================
460463

461-
public async runTask(prompt: string): Promise<void> {
462-
this.sendToExtension({ type: "newTask", text: prompt })
464+
public async runTask(prompt: string, taskId?: string): Promise<void> {
465+
this.sendToExtension({ type: "newTask", text: prompt, taskId })
463466

464467
return new Promise((resolve, reject) => {
465468
const completeHandler = () => {

0 commit comments

Comments
 (0)