diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index ad6bdcedb2..5da464080b 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -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() + 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() diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index ebe1aa6b3a..0966a5870b 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -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, @@ -1043,7 +1047,6 @@ export function streamFlushDelayMs(liveRunChars: number): number { } type ConnectionsMap = Map -const MAX_LIVE_TOOL_RAW_OUTPUT_CHARS = 200_000 const MAX_BUFFERED_UNMAPPED_EVENTS_PER_CONNECTION = 64 const MAX_BUFFERED_UNMAPPED_CONNECTIONS = 128 /** @@ -1846,40 +1849,10 @@ 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, @@ -1887,10 +1860,9 @@ function mergeToolCallUpdate( 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, } } @@ -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 ?? [], @@ -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). @@ -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 ?? [], @@ -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, { @@ -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 ?? [], diff --git a/src/lib/live-tool-output.test.ts b/src/lib/live-tool-output.test.ts new file mode 100644 index 0000000000..ef7b3747b1 --- /dev/null +++ b/src/lib/live-tool-output.test.ts @@ -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) + }) +}) diff --git a/src/lib/live-tool-output.ts b/src/lib/live-tool-output.ts new file mode 100644 index 0000000000..e9a94d013c --- /dev/null +++ b/src/lib/live-tool-output.ts @@ -0,0 +1,101 @@ +/** + * Retained plain-text output per live tool call, measured in UTF-16 code units + * (the same unit as String.length). Structured tool results are consumed by + * cards as JSON and must not be cut into an invalid suffix. + */ +export const MAX_LIVE_TOOL_RAW_OUTPUT_CHARS = 200_000 +const MAX_OUTPUT_CHUNKS = 128 + +export interface LiveToolRawOutput { + raw_output_chunks: string[] + raw_output_total_bytes: number +} + +function mayBeStructuredOutput(chunks: readonly string[]): boolean { + for (const chunk of chunks) { + const first = chunk.trimStart()[0] + if (first) return first === "{" || first === "[" + } + return false +} + +function copyTail(value: string, budget: number): string { + let start = value.length - budget + // A tail must not start halfway through a surrogate pair. + const unit = value.charCodeAt(start) + if ( + start > 0 && + unit >= 0xdc00 && + unit <= 0xdfff && + value.charCodeAt(start - 1) >= 0xd800 && + value.charCodeAt(start - 1) <= 0xdbff + ) { + start += 1 + } + const tail = value.slice(start) + // Copy only the bounded tail: substring implementations can share the + // backing storage of a much larger input. The temporary array is bounded + // by the preview budget as well. + return tail.length === 1 + ? String.fromCharCode(tail.charCodeAt(0)) + : tail.split("").join("") +} + +function boundChunks(chunks: string[], total: number): LiveToolRawOutput { + // A JSON object/array can arrive across append frames. Preserve candidates + // even while incomplete; testing JSON.parse on each partial frame would + // truncate a large result before its closing bracket arrives. + if ( + total > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS && + !mayBeStructuredOutput(chunks) + ) { + const kept: string[] = [] + let room = MAX_LIVE_TOOL_RAW_OUTPUT_CHARS + for (let i = chunks.length - 1; i >= 0 && room > 0; i -= 1) { + const chunk = chunks[i] + if (!chunk) continue + const tail = chunk.length > room ? copyTail(chunk, room) : chunk + kept.push(tail) + room -= tail.length + if (chunk.length > tail.length) break + } + chunks = kept.reverse() + total = MAX_LIVE_TOOL_RAW_OUTPUT_CHARS - room + } + // Byte/character limits alone do not bound the chunk array: tiny or empty + // append frames can otherwise accumulate for an entire long-running call. + if (chunks.length > MAX_OUTPUT_CHUNKS) chunks = [chunks.join("")] + return { + raw_output_chunks: chunks, + raw_output_total_bytes: total, + } +} + +/** Opening frames, replacements and snapshot hydration share one policy. */ +export function replaceLiveToolRawOutput( + output: string | null +): LiveToolRawOutput { + return output === null + ? { raw_output_chunks: [], raw_output_total_bytes: 0 } + : boundChunks([output], output.length) +} + +/** A null update is absent; an empty update still establishes output precedence. */ +export function appendLiveToolRawOutput( + previous: LiveToolRawOutput, + output: string | null +): LiveToolRawOutput { + if ( + output === null || + (output === "" && previous.raw_output_chunks.length > 0) + ) { + return { + raw_output_chunks: previous.raw_output_chunks, + raw_output_total_bytes: previous.raw_output_total_bytes, + } + } + return boundChunks( + [...previous.raw_output_chunks, output], + previous.raw_output_total_bytes + output.length + ) +} diff --git a/src/lib/snapshot-denormalize.test.ts b/src/lib/snapshot-denormalize.test.ts index 7ae5d6983e..983c8dddb5 100644 --- a/src/lib/snapshot-denormalize.test.ts +++ b/src/lib/snapshot-denormalize.test.ts @@ -214,6 +214,24 @@ describe("denormalizeSnapshot — tool call raw output", () => { expect(info.raw_output_total_bytes).toBe("file contents".length) }) + it("bounds oversized plain text when a reconnect hydrates the call", () => { + const info = hydratedToolInfo({ + kind: "text", + content: "x".repeat(300_000) + "\nlatest", + }) + expect(info.raw_output_total_bytes).toBe(200_000) + expect(info.raw_output_chunks.join("")).toHaveLength(200_000) + expect(info.raw_output_chunks.join("")).toMatch(/\nlatest$/) + }) + + it("keeps oversized structured output parseable after hydration", () => { + const payload = { + tasks: [{ task_id: "task-1", message: "x".repeat(300_000) }], + } + const info = hydratedToolInfo({ kind: "json", value: payload }) + expect(JSON.parse(info.raw_output_chunks.join(""))).toEqual(payload) + }) + // The backend promotes `{"error": "…"}` raw output to this variant, so the // inverse is that same object — readers find the error where they look. it("hydrates an error output as the {error} object it was promoted from", () => { diff --git a/src/lib/snapshot-denormalize.ts b/src/lib/snapshot-denormalize.ts index 3853cb2714..3cd38d276e 100644 --- a/src/lib/snapshot-denormalize.ts +++ b/src/lib/snapshot-denormalize.ts @@ -20,6 +20,7 @@ import type { } from "@/lib/types" import { routeAcpError, type AcpErrorLevel } from "@/lib/acp-error-presentation" +import { replaceLiveToolRawOutput } from "@/lib/live-tool-output" import type { LiveContentBlock as LocalLiveContentBlock, LiveMessage as LocalLiveMessage, @@ -306,13 +307,8 @@ function toolStateToInfo(tc: ToolCallState): ToolCallInfo { // Backend's structured output is collapsed into a single raw chunk for // hydration. Chunk history isn't recoverable from the snapshot — the // frontend's per-chunk delta tracking will resume from subsequent events. - const outputChunks: string[] = [] - let outputBytes = 0 - const rawOutput = toolOutputRawText(tc.output) - if (rawOutput !== null) { - outputChunks.push(rawOutput) - outputBytes = rawOutput.length - } + const output = replaceLiveToolRawOutput(toolOutputRawText(tc.output)) + return { tool_call_id: tc.id, title: tc.label, @@ -320,8 +316,7 @@ function toolStateToInfo(tc: ToolCallState): ToolCallInfo { status: tc.status, content: tc.content, raw_input: tc.input == null ? null : JSON.stringify(tc.input), - raw_output_chunks: outputChunks, - raw_output_total_bytes: outputBytes, + ...output, locations: tc.locations ?? null, meta: tc.meta ?? null, images: tc.images ?? [],