Skip to content

Commit 6600bd2

Browse files
committed
fix(chat): share tool batching predicates
1 parent 26b3351 commit 6600bd2

4 files changed

Lines changed: 105 additions & 72 deletions

File tree

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useDebounceEffect } from "@src/utils/useDebounceEffect"
99
import { appendImages } from "@src/utils/imageUtils"
1010
import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting"
1111
import { batchNearby } from "@src/utils/batchNearby"
12+
import { isBoundary, isIgnorableBetweenTargets } from "@src/utils/chatBatchingPredicates"
1213

1314
import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types"
1415
import { getCompletionCheckpoint, getSuggestionMode, isRetiredProvider } from "@roo-code/types"
@@ -1279,37 +1280,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12791280
}
12801281
}
12811282

1282-
// Messages that can be safely skipped over when batching tool asks.
1283-
// These are low-information or invisible messages that don't affect semantics:
1284-
// - api_req_started (API request metadata row)
1285-
// - empty text rows (partial streaming with no visible content)
1286-
// - reasoning rows (hidden from user by default)
1287-
const isIgnorableBetweenTargets = (msg: ClineMessage): boolean => {
1288-
if (msg.type !== "say") return false
1289-
return msg.say === "api_req_started" || (msg.say === "text" && !msg.text?.trim()) || msg.say === "reasoning"
1290-
}
1291-
1292-
// Semantic boundaries that stop batching. When we hit one of these,
1293-
// any current batch is finalized and the boundary message is preserved as-is:
1294-
// - user feedback / new user messages
1295-
// - visible assistant text (the model spoke to the user)
1296-
// - completion result (turn ended)
1297-
// - checkpoint saved
1298-
// - errors
1299-
const isBoundary = (msg: ClineMessage): boolean => {
1300-
if (msg.type !== "say") return false
1301-
return (
1302-
msg.say === "user_feedback" ||
1303-
msg.say === "user_feedback_diff" ||
1304-
(msg.say === "text" && !!msg.text?.trim()) ||
1305-
msg.say === "completion_result" ||
1306-
msg.say === "checkpoint_saved" ||
1307-
msg.say === "error" ||
1308-
msg.say === "condense_context" ||
1309-
msg.say === "codebase_search_result"
1310-
)
1311-
}
1312-
13131283
// Consolidate tool asks into batches, allowing ignorable messages between targets.
13141284
// batchNearby skips over api_req_started, empty text rows, and reasoning rows that
13151285
// models like qwen insert between tool calls during streaming.

webview-ui/src/components/chat/__tests__/ChatView.spec.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const mockVirtuosoState = vi.hoisted(() => ({
1616
defaultItemHeight?: number
1717
increaseViewportBy?: number | { top?: number; bottom?: number }
1818
} | null,
19+
lastData: [] as ClineMessage[],
1920
}))
2021

2122
// Define minimal types needed for testing
@@ -111,6 +112,7 @@ vi.mock("react-virtuoso", () => ({
111112
defaultItemHeight,
112113
increaseViewportBy,
113114
}
115+
mockVirtuosoState.lastData = data
114116

115117
return (
116118
<div data-testid="virtuoso-item-list">
@@ -287,6 +289,22 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({
287289
</button>
288290
)
289291
},
292+
VSCodeCheckbox: function MockVSCodeCheckbox({
293+
children,
294+
checked,
295+
onChange,
296+
}: {
297+
children: React.ReactNode
298+
checked?: boolean
299+
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
300+
}) {
301+
return (
302+
<label>
303+
<input type="checkbox" checked={checked} onChange={onChange} />
304+
{children}
305+
</label>
306+
)
307+
},
290308
VSCodeTextField: function MockVSCodeTextField({
291309
value,
292310
onInput,
@@ -308,6 +326,9 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({
308326
VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) {
309327
return <a href={href}>{children}</a>
310328
},
329+
VSCodeProgressRing: function MockVSCodeProgressRing() {
330+
return <div data-testid="vscode-progress-ring" />
331+
},
311332
}))
312333

313334
// Mock window.postMessage to trigger state hydration
@@ -349,6 +370,55 @@ const renderChatView = (props: Partial<ChatViewProps> = {}) => {
349370
)
350371
}
351372

373+
describe("ChatView - Tool Batching Tests", () => {
374+
beforeEach(() => vi.clearAllMocks())
375+
376+
it("batches readFile asks separated by an API request row", async () => {
377+
renderChatView()
378+
379+
mockPostMessage({
380+
clineMessages: [
381+
{
382+
type: "say",
383+
say: "task",
384+
ts: 1,
385+
text: "Initial task",
386+
},
387+
{
388+
type: "ask",
389+
ask: "tool",
390+
ts: 2,
391+
text: JSON.stringify({ tool: "readFile", path: "a.ts" }),
392+
},
393+
{
394+
type: "say",
395+
say: "api_req_started",
396+
ts: 3,
397+
text: JSON.stringify({ apiProtocol: "anthropic" }),
398+
},
399+
{
400+
type: "ask",
401+
ask: "tool",
402+
ts: 4,
403+
text: JSON.stringify({ tool: "readFile", path: "b.ts" }),
404+
},
405+
],
406+
})
407+
408+
await waitFor(() => {
409+
const toolRows = mockVirtuosoState.lastData.filter(
410+
(message) => message.type === "ask" && message.ask === "tool",
411+
)
412+
const [toolRow] = toolRows
413+
414+
expect(toolRows).toHaveLength(1)
415+
expect(toolRow?.text).toContain('"batchFiles"')
416+
expect(toolRow?.text).toContain('"path":"a.ts"')
417+
expect(toolRow?.text).toContain('"path":"b.ts"')
418+
})
419+
})
420+
})
421+
352422
describe("ChatView - Sound Playing Tests", () => {
353423
beforeEach(() => vi.clearAllMocks())
354424

webview-ui/src/utils/__tests__/batchNearby.spec.ts

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { batchNearby } from "../batchNearby"
2+
import { isBoundary, isIgnorableBetweenTargets } from "../chatBatchingPredicates"
23

34
interface TestItem {
45
ts: number
@@ -18,32 +19,6 @@ const isMatch = (m: TestItem) => !!m.text?.startsWith("match")
1819
/** Predicate for realistic qwen tests: matches JSON tool calls. */
1920
const isToolCall = (m: TestItem) => !!m.text?.startsWith("{")
2021

21-
/** Ignorable: api_req_started/finished, empty text, reasoning. */
22-
const isIgnorableBetweenTargets = (m: TestItem): boolean => {
23-
if (m.type !== "say") return false
24-
return (
25-
m.say === "api_req_started" ||
26-
m.say === "api_req_finished" ||
27-
(m.say === "text" && !m.text?.trim()) ||
28-
m.say === "reasoning"
29-
)
30-
}
31-
32-
/** Boundary: user_feedback, visible text, completion_result, checkpoint_saved, error. */
33-
const isBoundary = (m: TestItem): boolean => {
34-
if (m.type !== "say") return false
35-
return (
36-
m.say === "user_feedback" ||
37-
m.say === "user_feedback_diff" ||
38-
(m.say === "text" && !!m.text?.trim()) ||
39-
m.say === "completion_result" ||
40-
m.say === "checkpoint_saved" ||
41-
m.say === "error" ||
42-
m.say === "condense_context" ||
43-
m.say === "codebase_search_result"
44-
)
45-
}
46-
4722
/** Synthesize: merges a batch into a single item with a "BATCH:" marker. */
4823
const synthesizeBatch = (batch: TestItem[]): TestItem => ({
4924
...batch[0],
@@ -346,25 +321,11 @@ describe("batchNearby", () => {
346321
expect(result[1].text).toBe("BATCH:match-1,match-2")
347322
})
348323

349-
test("batch at the beginning of the array", () => {
350-
const items = [msg("match-1", "ask"), msg("", "say", "api_req_finished"), msg("match-2", "ask"), msg("other")]
351-
const result = batchNearby(items, {
352-
isTarget: isMatch,
353-
isIgnorableBetweenTargets,
354-
isBoundary,
355-
synthesize: synthesizeBatch,
356-
})
357-
expect(result).toHaveLength(2)
358-
expect(result[0].text).toBe("BATCH:match-1,match-2")
359-
expect(result[1].text).toBe("other")
360-
})
361-
362324
test("multiple ignorable messages between targets", () => {
363325
const items = [
364326
msg("match-1", "ask"),
365327
msg("", "say", "api_req_started"),
366328
msg("", "say", "reasoning"),
367-
msg("", "say", "api_req_finished"),
368329
msg("match-2", "ask"),
369330
]
370331
const result = batchNearby(items, {
@@ -383,7 +344,7 @@ describe("batchNearby", () => {
383344
msg("", "say", "api_req_started"),
384345
msg("", "say", "text"), // empty streaming row
385346
msg('{"tool":"readFile","path":"b.ts"}', "ask"),
386-
msg("", "say", "api_req_finished"),
347+
msg("", "say", "reasoning"),
387348
msg('{"tool":"editFile","path":"c.ts"}', "ask"),
388349
]
389350
const result = batchNearby(messages, {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
interface BatchableMessage {
2+
type?: string
3+
say?: string
4+
text?: string
5+
}
6+
7+
/**
8+
* Messages that can be safely skipped over when batching tool asks.
9+
* These are low-information or invisible messages that don't affect semantics.
10+
*/
11+
export const isIgnorableBetweenTargets = (msg: BatchableMessage): boolean => {
12+
if (msg.type !== "say") return false
13+
return msg.say === "api_req_started" || (msg.say === "text" && !msg.text?.trim()) || msg.say === "reasoning"
14+
}
15+
16+
/**
17+
* Semantic boundaries that stop batching. When batching hits one of these,
18+
* the current batch is finalized and the boundary message is preserved as-is.
19+
*/
20+
export const isBoundary = (msg: BatchableMessage): boolean => {
21+
if (msg.type !== "say") return false
22+
return (
23+
msg.say === "user_feedback" ||
24+
msg.say === "user_feedback_diff" ||
25+
(msg.say === "text" && !!msg.text?.trim()) ||
26+
msg.say === "completion_result" ||
27+
msg.say === "checkpoint_saved" ||
28+
msg.say === "error" ||
29+
msg.say === "condense_context" ||
30+
msg.say === "codebase_search_result"
31+
)
32+
}

0 commit comments

Comments
 (0)