Skip to content
Open
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
97 changes: 97 additions & 0 deletions src/contexts/acp-connections-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2252,6 +2252,103 @@ describe("AcpConnectionsProvider abandoned connect tears down only what it creat
})
})

describe("AcpConnectionsProvider retained tool output", () => {
it.each([
"opening",
"repeated opening",
"replacement",
"append",
"unannounced update",
"out-of-turn opening",
])("bounds a large plain-text %s frame", async (route) => {
const originalRaf = globalThis.requestAnimationFrame
const originalCancelRaf = globalThis.cancelAnimationFrame
const frames = new Map<number, FrameRequestCallback>()
let nextFrame = 0
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frames.set(++nextFrame, callback)
return nextFrame
})
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id))
try {
await mountProvider()
await act(async () => {
await h.actions!.connect(TAB, "claude_code", "/tmp/x", "sess-1")
})
const handlers = latestAttachHandlers()
let seq = 0
const large = "old output\n".repeat(30_000) + "LATEST"
const opening = (rawOutput: string | null) =>
emitAcpEvent(handlers, {
seq: ++seq,
connection_id: "spawned-conn",
type: "tool_call",
tool_call_id: "large-call",
title: "Bash",
kind: "execute",
status: "in_progress",
content: null,
raw_input: '{"command":"build"}',
raw_output: rawOutput,
})
if (route !== "out-of-turn opening") {
emitAcpEvent(handlers, {
seq: ++seq,
connection_id: "spawned-conn",
type: "status_changed",
status: "prompting",
})
}
if (
route === "opening" ||
route === "out-of-turn opening" ||
route === "repeated opening"
) {
if (route === "repeated opening") opening("small")
opening(large)
} else {
if (route !== "unannounced update") opening("small")
emitAcpEvent(handlers, {
seq: ++seq,
connection_id: "spawned-conn",
type: "tool_call_update",
tool_call_id: "large-call",
title: "Bash",
status: "in_progress",
content: null,
raw_input: null,
raw_output: large,
raw_output_append: route === "append",
})
act(() => {
const pending = [...frames.values()]
frames.clear()
for (const frame of pending) frame(0)
})
}
const connection = h.store!.getConnection(TAB)!
const block = connection.liveMessage?.content.find(
(item) =>
item.type === "tool_call" && item.info.tool_call_id === "large-call"
)
const info =
route === "out-of-turn opening"
? connection.outOfTurnToolCalls?.get("large-call")
: block?.type === "tool_call"
? block.info
: null
expect(info).toBeTruthy()
expect(info?.raw_output_total_bytes).toBe(200_000)
expect(info?.raw_output_chunks.join("")).toBe(large.slice(-200_000))
expect(info?.title).toBe("Bash")
expect(info?.status).toBe("in_progress")
} finally {
vi.stubGlobal("requestAnimationFrame", originalRaf)
vi.stubGlobal("cancelAnimationFrame", originalCancelRaf)
}
})
})

describe("AcpConnectionsProvider permission request details", () => {
it("hydrates a permission request from an existing live tool call input", async () => {
await mountProvider()
Expand Down
76 changes: 19 additions & 57 deletions src/contexts/acp-connections-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ import {
} from "@/lib/acp-error-presentation"
import { dismissNotification, notify, type NotifyAction } from "@/lib/notify"
import type { SnapshotPatch } from "@/lib/snapshot-denormalize"
import {
appendLiveToolRawOutput,
replaceLiveToolRawOutput,
} from "@/lib/live-tool-output"
import { getAgentLabel } from "@/lib/custom-agents"
import {
localizeConfigOptionLabel,
Expand Down Expand Up @@ -1043,7 +1047,6 @@ export function streamFlushDelayMs(liveRunChars: number): number {
}

type ConnectionsMap = Map<string, ConnectionState>
const MAX_LIVE_TOOL_RAW_OUTPUT_CHARS = 200_000
const MAX_BUFFERED_UNMAPPED_EVENTS_PER_CONNECTION = 64
const MAX_BUFFERED_UNMAPPED_CONNECTIONS = 128
/**
Expand Down Expand Up @@ -1846,51 +1849,20 @@ function mergeToolCallUpdate(
info: ToolCallInfo,
update: ToolCallUpdateAction
): ToolCallInfo {
let newChunks: string[]
let newTotalBytes: number

if (update.raw_output === null) {
newChunks = info.raw_output_chunks
newTotalBytes = info.raw_output_total_bytes
} else if (update.raw_output_append) {
newChunks = [...info.raw_output_chunks, update.raw_output]
newTotalBytes = info.raw_output_total_bytes + update.raw_output.length

// 超限时从头部批量移除 chunks(单次 slice 替代循环 shift)
if (
newTotalBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS &&
newChunks.length > 1
) {
let evictCount = 0
let evictedBytes = 0
while (
evictCount < newChunks.length - 1 &&
newTotalBytes - evictedBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS
) {
evictedBytes += newChunks[evictCount].length
evictCount++
}
if (evictCount > 0) {
newChunks = newChunks.slice(evictCount)
newTotalBytes -= evictedBytes
}
}
} else {
// 非 append 模式(替换)
newChunks = [update.raw_output]
newTotalBytes = update.raw_output.length
}
const output =
update.raw_output === null || update.raw_output_append
? appendLiveToolRawOutput(info, update.raw_output)
: replaceLiveToolRawOutput(update.raw_output)

return {
...info,
title: update.title ?? info.title,
status: update.status ?? info.status,
content: update.content ?? info.content,
raw_input: update.raw_input ?? info.raw_input,
raw_output_chunks: newChunks,
...output,
locations: update.locations ?? info.locations,
meta: update.meta ?? info.meta,
raw_output_total_bytes: newTotalBytes,
images: update.images !== null ? update.images : info.images,
}
}
Expand Down Expand Up @@ -2453,9 +2425,7 @@ function connectionsReducer(
status: action.status,
content: action.content,
raw_input: action.raw_input,
raw_output_chunks:
action.raw_output !== null ? [action.raw_output] : [],
raw_output_total_bytes: action.raw_output?.length ?? 0,
...replaceLiveToolRawOutput(action.raw_output),
locations: action.locations,
meta: action.meta,
images: action.images ?? [],
Expand Down Expand Up @@ -2496,14 +2466,9 @@ function connectionsReducer(
status: action.status ?? block.info.status,
content: action.content ?? block.info.content,
raw_input: action.raw_input ?? block.info.raw_input,
raw_output_chunks:
action.raw_output !== null
? [action.raw_output]
: block.info.raw_output_chunks,
raw_output_total_bytes:
action.raw_output !== null
? action.raw_output.length
: block.info.raw_output_total_bytes,
...(action.raw_output !== null
? replaceLiveToolRawOutput(action.raw_output)
: appendLiveToolRawOutput(block.info, null)),
// The block may be the placeholder a permission request drew
// before this frame was applied (the agent's announcement and
// its request reach codeg on separate paths, either first).
Expand Down Expand Up @@ -2532,9 +2497,7 @@ function connectionsReducer(
status: action.status,
content: action.content,
raw_input: action.raw_input,
raw_output_chunks:
action.raw_output !== null ? [action.raw_output] : [],
raw_output_total_bytes: action.raw_output?.length ?? 0,
...replaceLiveToolRawOutput(action.raw_output),
locations: action.locations ?? null,
meta: action.meta ?? null,
images: action.images ?? [],
Expand Down Expand Up @@ -2616,9 +2579,7 @@ function connectionsReducer(
let newContent: LiveContentBlock[]

if (existingIndex === -1) {
const initialChunks =
action.raw_output !== null ? [action.raw_output] : []
const initialBytes = action.raw_output?.length ?? 0
const output = replaceLiveToolRawOutput(action.raw_output)
newContent = [
...prev.content,
{
Expand All @@ -2629,11 +2590,12 @@ function connectionsReducer(
kind: action.fallback_kind,
status:
action.status ??
(initialChunks.length > 0 ? "in_progress" : "pending"),
(output.raw_output_chunks.length > 0
? "in_progress"
: "pending"),
content: action.content,
raw_input: action.raw_input,
raw_output_chunks: initialChunks,
raw_output_total_bytes: initialBytes,
...output,
locations: action.locations ?? null,
meta: action.meta ?? null,
images: action.images ?? [],
Expand Down
109 changes: 109 additions & 0 deletions src/lib/live-tool-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest"
import {
appendLiveToolRawOutput,
MAX_LIVE_TOOL_RAW_OUTPUT_CHARS as LIMIT,
replaceLiveToolRawOutput,
} from "./live-tool-output"

describe("live tool raw output retention", () => {
it("keeps the newest text of an oversized opening/replacement frame", () => {
const output = replaceLiveToolRawOutput("old".repeat(LIMIT) + "\nDONE")
expect(output.raw_output_chunks.join("")).toHaveLength(LIMIT)
expect(output.raw_output_chunks.join("")).toMatch(/\nDONE$/)
expect(output.raw_output_total_bytes).toBe(LIMIT)
})

it("bounds a single oversized append, without needing another chunk to evict", () => {
const output = appendLiveToolRawOutput(
replaceLiveToolRawOutput(null),
"x".repeat(LIMIT + 500)
)
expect(output.raw_output_total_bytes).toBe(LIMIT)
expect(output.raw_output_chunks.join("")).toBe("x".repeat(LIMIT))
})

it("uses the remaining tail budget across chunk boundaries", () => {
const previous = replaceLiveToolRawOutput("a".repeat(LIMIT - 3))
const output = appendLiveToolRawOutput(previous, "0123456789")
expect(output.raw_output_total_bytes).toBe(LIMIT)
expect(output.raw_output_chunks.join("")).toBe(
"a".repeat(LIMIT - 10) + "0123456789"
)
expect(previous.raw_output_chunks).toEqual(["a".repeat(LIMIT - 3)])
})

it("retains an exact-budget result unchanged", () => {
const text = "x".repeat(LIMIT)
expect(replaceLiveToolRawOutput(text).raw_output_chunks).toEqual([text])
})

it("does not start the retained text in the middle of an emoji", () => {
const output = replaceLiveToolRawOutput(
"discard" + "😀" + "x".repeat(LIMIT - 1)
)
expect(output.raw_output_chunks.join("")).toBe("x".repeat(LIMIT - 1))
expect(output.raw_output_total_bytes).toBe(LIMIT - 1)
})

it("preserves null versus empty output precedence", () => {
const absent = replaceLiveToolRawOutput(null)
expect(absent.raw_output_chunks).toEqual([])
const empty = appendLiveToolRawOutput(absent, "")
expect(empty.raw_output_chunks).toEqual([""])
expect(appendLiveToolRawOutput(empty, "").raw_output_chunks).toBe(
empty.raw_output_chunks
)
const populated = replaceLiveToolRawOutput("result")
expect(appendLiveToolRawOutput(populated, null).raw_output_chunks).toBe(
populated.raw_output_chunks
)
})

it("does not accumulate thousands of empty append chunks", () => {
let output = replaceLiveToolRawOutput("")
for (let i = 0; i < 2_000; i += 1) {
output = appendLiveToolRawOutput(output, "")
}
expect(output.raw_output_chunks).toEqual([""])
expect(output.raw_output_total_bytes).toBe(0)
})

it("compacts many tiny chunks while preserving their exact content", () => {
let output = replaceLiveToolRawOutput(null)
for (let i = 0; i < 1_000; i += 1) {
output = appendLiveToolRawOutput(output, String(i % 10))
}
expect(output.raw_output_chunks.length).toBeLessThanOrEqual(128)
expect(output.raw_output_chunks.join("")).toBe("0123456789".repeat(100))
expect(output.raw_output_total_bytes).toBe(1_000)
})

it.each(["{", "["])(
"preserves a structured result beginning with %s",
(open) => {
const value =
open === "{"
? { text: "x".repeat(LIMIT + 100), status: "completed" }
: [{ text: "x".repeat(LIMIT + 100), status: "completed" }]
const raw = " \n" + JSON.stringify(value)
const output = replaceLiveToolRawOutput(raw)
expect(output.raw_output_chunks.join("")).toBe(raw)
expect(JSON.parse(output.raw_output_chunks.join(""))).toEqual(value)
}
)

it("preserves a structured result arriving across partial append frames", () => {
let output = replaceLiveToolRawOutput(' \n{"tasks":[{"message":"')
for (let i = 0; i < 150; i += 1) {
output = appendLiveToolRawOutput(output, "x".repeat(2_000))
}
output = appendLiveToolRawOutput(output, '","status":"completed"}]}')
const parsed = JSON.parse(output.raw_output_chunks.join(""))
expect(parsed.tasks[0].message).toHaveLength(300_000)
expect(parsed.tasks[0].status).toBe("completed")
expect(output.raw_output_total_bytes).toBe(
output.raw_output_chunks.join("").length
)
expect(output.raw_output_chunks.length).toBeLessThanOrEqual(128)
})
})
Loading
Loading