Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion extensions/ce-core/tools/isolated-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,44 @@ export function assistantProgressPreview(line: string, maxChars = 120): string |
return flat.length <= maxChars ? flat : flat.slice(0, maxChars - 1) + "…"
}

/**
* Single-line preview of a tool_execution event from the reviewer's JSON stream.
* Without this, long tool-only stretches (reading diff, rg, reading rules) emit
* zero progress and the host UI appears frozen.
*/
export function toolExecutionPreview(line: string, maxChars = 120): string | null {
let event: { type?: string; toolName?: string; args?: unknown; isError?: boolean }
try {
event = JSON.parse(line)
} catch {
return null
}
if (!event || typeof event.toolName !== "string") return null
if (event.type === "tool_execution_start") {
return `tool ${event.toolName}: ${summarizeToolArgs(event.args, maxChars)}`
}
if (event.type === "tool_execution_end" && event.isError === true) {
return `tool ${event.toolName} failed`
}
return null
}

const TOOL_ARG_KEYS = ["file_path", "path", "command", "query", "pattern", "url", "skill"] as const

function summarizeToolArgs(args: unknown, maxChars: number): string {
if (args && typeof args === "object") {
const record = args as Record<string, unknown>
const key = TOOL_ARG_KEYS.find((k) => typeof record[k] === "string" && (record[k] as string).length > 0)
if (key) return truncateProgressText(record[key] as string, maxChars)
}
return truncateProgressText(JSON.stringify(args ?? {}), maxChars)
}

function truncateProgressText(text: string, maxChars: number): string {
const flat = text.replace(/\s+/g, " ").trim()
return flat.length <= maxChars ? flat : flat.slice(0, maxChars - 1) + "…"
}

async function runOnce(
input: IsolatedReviewInput,
deps: IsolatedReviewDeps,
Expand All @@ -262,7 +300,7 @@ async function runOnce(
const stderr = collectStderrTail(child)
collectOutput(child, (line) => {
lines.push(line)
const preview = assistantProgressPreview(line)
const preview = assistantProgressPreview(line) ?? toolExecutionPreview(line)
if (preview) onProgress?.(`[isolated_review] ${preview}`)
})
const onAbort = () => child.kill()
Expand Down
64 changes: 64 additions & 0 deletions tests/isolated-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
extractAssistantText,
loadPromptTemplate,
runIsolatedReview,
toolExecutionPreview,
type ReviewChildProcess,
type SpawnFn,
type VersionProbe,
Expand Down Expand Up @@ -82,6 +83,40 @@ describe("extractAssistantText", () => {
})
})

describe("toolExecutionPreview", () => {
test("previews tool_execution_start with the primary arg value", () => {
const line = JSON.stringify({ type: "tool_execution_start", toolCallId: "t1", toolName: "read", args: { file_path: "/repo/diff.patch" } })
expect(toolExecutionPreview(line)).toBe("tool read: /repo/diff.patch")
})

test("flattens and truncates long args like bash commands", () => {
const line = JSON.stringify({ type: "tool_execution_start", toolName: "bash", args: { command: `rg pattern\n${"x".repeat(200)}` } })
const preview = toolExecutionPreview(line)
expect(preview).toMatch(/^tool bash: rg pattern x+/)
// maxChars caps the args portion; the "tool bash: " prefix adds 11 chars
expect(preview!.length).toBeLessThanOrEqual("tool bash: ".length + 120)
expect(preview!.endsWith("…"))
})

test("previews tool_execution_end only when it errored", () => {
const failed = JSON.stringify({ type: "tool_execution_end", toolCallId: "t1", toolName: "bash", isError: true, result: "boom" })
expect(toolExecutionPreview(failed)).toBe("tool bash failed")
const ok = JSON.stringify({ type: "tool_execution_end", toolCallId: "t1", toolName: "bash", isError: false, result: { big: "payload" } })
expect(toolExecutionPreview(ok)).toBeNull()
})

test("falls back to stringified args when no known key matches", () => {
const line = JSON.stringify({ type: "tool_execution_start", toolName: "custom", args: { nested: { a: 1 } } })
expect(toolExecutionPreview(line)).toBe('tool custom: {"nested":{"a":1}}')
})

test("returns null for non-tool or malformed lines", () => {
expect(toolExecutionPreview("not json")).toBeNull()
expect(toolExecutionPreview('{"type":"message_end","message":{"role":"assistant","content":[]}}')).toBeNull()
expect(toolExecutionPreview('{"type":"agent_start"}')).toBeNull()
})
})

describe("runIsolatedReview", () => {
test("returns completed with reviewer output when child succeeds and writes findings", async () => {
const findingsPath = tmpFindingsPath()
Expand Down Expand Up @@ -345,6 +380,35 @@ describe("progress reporting (onProgress)", () => {
cleanup(path.dirname(findingsPath))
})

test("reports tool_execution events so tool-only stretches keep the UI moving", async () => {
const findingsPath = tmpFindingsPath()
const child = makeFakeChild()
const spawnFn = makeSpawnFn([child], [])
const progress: string[] = []

const pending = runIsolatedReview(
{ repoRoot: "/repo", diffBase: "main", findingsPath, promptTemplate: "P" },
{ spawnFn, probeCliVersion: okVersionProbe },
undefined,
(text) => progress.push(text),
)

queueMicrotask(() => {
child.emitStdout(JSON.stringify({ type: "tool_execution_start", toolCallId: "t1", toolName: "read", args: { file_path: "/repo/rules/review.md" } }) + "\n")
child.emitStdout(JSON.stringify({ type: "tool_execution_end", toolCallId: "t1", toolName: "read", isError: false, result: "ok" }) + "\n")
child.emitStdout(JSON.stringify({ type: "tool_execution_start", toolCallId: "t2", toolName: "bash", args: { command: "rg TODO" } }) + "\n")
child.emitExit(0)
})

const result = await pending
expect(result.status).toBe("completed")
expect(progress.some((t) => t.includes("tool read: /repo/rules/review.md"))).toBe(true)
// non-error tool_execution_end stays silent
expect(progress.some((t) => t.includes("tool read failed"))).toBe(false)
expect(progress.some((t) => t.includes("tool bash: rg TODO"))).toBe(true)
cleanup(path.dirname(findingsPath))
})

test("aborted run writes evidence findings (isolation marker, elapsed, stderr tail)", async () => {
const findingsPath = tmpFindingsPath()
const child = makeFakeChild()
Expand Down
Loading