From 202cde13b870fa11192c545558f57da2f34e03e3 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 5 Aug 2026 21:49:11 +0800 Subject: [PATCH 1/2] fix: skip tool messages in renderMessage to prevent JSON corruption renderMessage() prepended ACP tags (REF) to ALL messages including tool-call arguments stored in CoreMessage.text. In proxy adapters that store wire-format JSON in .text (e.g. OpenAI function.arguments), this corrupted the JSON and caused downstream SchemaError (Missing key at ["command"] etc). Tool messages don't need ACP tags: they are structured metadata, not displayable text. assignRefs still allocates refs for them (compression ranges can still target them), and adjustBoundariesForToolPairs auto- expands ranges to include tool_call+tool_result pairs. The model never needs to see a tool message's ref to compress it. Plugin (opencode-acp) is unaffected: tool args live in Part.input (a separate JSON field), not CoreMessage.text, so tags on .text were always cosmetic metadata, never load-bearing. --- src/render-refs.ts | 7 +++++++ tests/pipeline.test.ts | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/render-refs.ts b/src/render-refs.ts index 597f511..102897d 100644 --- a/src/render-refs.ts +++ b/src/render-refs.ts @@ -40,6 +40,13 @@ function renderMessage( const ref = refForRaw(map, message.id); if (!ref || ref === BLOCKED_REF) return message; + if ( + message.contentType === "tool-call" || + message.contentType === "tool-result" + ) { + return message; + } + // Strip own stale tag BEFORE computing tokens (idempotency). // Match the message's own ref only — foreign tags survive (content-corruption fix). const ownTagRe = new RegExp( diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index abf7a1a..c25bd4b 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -141,3 +141,24 @@ test("processTurn tags every mapped message with a derived ref (end-to-end)", () assert.match(result.messages[0]!.text!, /^m00001<\/acp>\nalpha$/); assert.match(result.messages[1]!.text!, /^m00002<\/acp>\nbeta$/); }); + +test("renderVisibleRefs skips tool-call and tool-result messages", () => { + const state = createInitialState(); + const messages: CoreMessage[] = [ + { id: "u1", role: "user", contentType: "text", text: "run echo" }, + { id: "a1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tc1", text: '{"command":"echo hello"}' }, + { id: "t1", role: "tool", contentType: "tool-result", toolName: "bash", toolCallId: "tc1", text: "hello" }, + { id: "a2", role: "assistant", contentType: "text", text: "Done." }, + ]; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; + + const rendered = renderVisibleRefs(messages, state); + + assert.match(rendered[0]!.text!, /m00001<\/acp>/, "user message gets tag"); + assert.equal(rendered[1]!.text, '{"command":"echo hello"}', "tool-call args unmodified"); + assert.equal(rendered[2]!.text, "hello", "tool-result content unmodified"); + assert.match(rendered[3]!.text!, /m00004<\/acp>/, "assistant text gets tag"); +}); From 6b7ed4c167a0c82fc8bb45d1a600c00da16ff4f7 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 5 Aug 2026 22:17:24 +0800 Subject: [PATCH 2/2] fix: backward-compatible renderMessage option for tool messages Redesign based on review feedback: instead of unconditionally skipping tool messages (breaking change for existing callers), extract rendering into composable building blocks with opt-in behavior. Changes: - renderMessage() and renderVisibleRefs() accept optional RenderOptions parameter ({ skipToolMessages?: boolean }). Default behavior unchanged. - Config gains optional render?: RenderConfig ({ skipToolMessageTags?: boolean }). - renderRefsNode reads ctx.config.render?.skipToolMessageTags via optionsFromConfig() helper. Pipeline callers with no config change get the original behavior. - renderMessage() is now exported (was private) so adapters can reuse it. Plugin (opencode-acp): no config change needed, identical behavior. Proxy (acp-proxy): sets config.render.skipToolMessageTags = true. --- ...fc0fc-f454-74d3-8d19-f9af6f837aad.acp.json | 1 + src/render-refs.ts | 41 ++++++++++++---- src/types.ts | 5 ++ tests/pipeline.test.ts | 49 ++++++++++++++++++- 4 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 .acp-019fc0fc-f454-74d3-8d19-f9af6f837aad.acp.json diff --git a/.acp-019fc0fc-f454-74d3-8d19-f9af6f837aad.acp.json b/.acp-019fc0fc-f454-74d3-8d19-f9af6f837aad.acp.json new file mode 100644 index 0000000..f13296e --- /dev/null +++ b/.acp-019fc0fc-f454-74d3-8d19-f9af6f837aad.acp.json @@ -0,0 +1 @@ +{"blocks":[],"messageRefs":{"byRaw":{"6e473b99":"m00001","b9495b28":"m00002","773e2a86":"m00003"},"byRef":{"m00001":"6e473b99","m00002":"b9495b28","m00003":"773e2a86"}},"nudge":{"lastPerMessageNudgeTokens":30,"lastNudgeShownTokens":0,"baselineTokens":0,"anchors":{},"lastShownByTier":{}},"stats":{"tokensCompressed":0,"compressionCount":0},"nextBlockId":1,"nextRunId":1} \ No newline at end of file diff --git a/src/render-refs.ts b/src/render-refs.ts index 102897d..061affa 100644 --- a/src/render-refs.ts +++ b/src/render-refs.ts @@ -1,8 +1,16 @@ -import type { CoreMessage, CompressionState, MessageRefMap } from "./types.js"; +import type { + CoreMessage, + CompressionState, + MessageRefMap, + RenderConfig, +} from "./types.js"; import { refForRaw, BLOCKED_REF } from "./refs.js"; import type { PipelineNode, PipelineContext, NodeIO } from "./pipeline.js"; -/** Format token count: <1K raw, <10K "X.YK", >=10K "XK". */ +export interface RenderOptions { + skipToolMessages?: boolean; +} + function formatTokens(tokens: number): string { if (tokens < 1000) return String(tokens); if (tokens < 10000) return (tokens / 1000).toFixed(1) + "K"; @@ -32,23 +40,23 @@ function acpTag(ref: string, tokens: number, type: string): string { return TAG_OPEN + 'tokens="' + formatTokens(tokens) + '" type="' + type + '"' + GT + ref + TAG_CLOSE; } -function renderMessage( +export function renderMessage( message: CoreMessage, map: MessageRefMap, countTokens: (text: string) => number, + options?: RenderOptions, ): CoreMessage { const ref = refForRaw(map, message.id); if (!ref || ref === BLOCKED_REF) return message; if ( - message.contentType === "tool-call" || - message.contentType === "tool-result" + options?.skipToolMessages && + (message.contentType === "tool-call" || + message.contentType === "tool-result") ) { return message; } - // Strip own stale tag BEFORE computing tokens (idempotency). - // Match the message's own ref only — foreign tags survive (content-corruption fix). const ownTagRe = new RegExp( "^" + escapeRegex(TAG_OPEN) + "[^>]*" + GT + escapeRegex(ref) + escapeRegex(TAG_CLOSE) + "\\n?", ); @@ -67,19 +75,34 @@ export function renderVisibleRefs( state: CompressionState, countTokens: (text: string) => number = (text) => Math.ceil(text.length / 4), + options?: RenderOptions, ): CoreMessage[] { const map = state.messageRefs; return messages.map((message) => - renderMessage(message, map, countTokens), + renderMessage(message, map, countTokens, options), ); } +export function optionsFromConfig( + config?: { render?: RenderConfig }, +): RenderOptions | undefined { + if (config?.render?.skipToolMessageTags) { + return { skipToolMessages: true }; + } + return undefined; +} + export const renderRefsNode: PipelineNode = { name: "render-refs", run(io: NodeIO, ctx: PipelineContext): NodeIO { return { ...io, - messages: renderVisibleRefs(io.messages, io.state, ctx.countTokens), + messages: renderVisibleRefs( + io.messages, + io.state, + ctx.countTokens, + optionsFromConfig(ctx.config), + ), }; }, }; diff --git a/src/types.ts b/src/types.ts index b99479c..f47c28e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,10 @@ export interface CompressValidationConfig { minSummaryLength: number; } +export interface RenderConfig { + skipToolMessageTags?: boolean; +} + export interface Config { tiers: TierConfig; nudge: NudgeConfig; @@ -123,6 +127,7 @@ export interface Config { preserveRecentTokens: number; modelContextLimit: number; messageFilters?: import("./filter/types.js").MessageFiltersConfig; + render?: RenderConfig; } export type CompressMode = "range" | "message"; diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index c25bd4b..9e8e666 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -142,7 +142,7 @@ test("processTurn tags every mapped message with a derived ref (end-to-end)", () assert.match(result.messages[1]!.text!, /^m00002<\/acp>\nbeta$/); }); -test("renderVisibleRefs skips tool-call and tool-result messages", () => { +test("renderVisibleRefs tags tool messages by default (backward compat)", () => { const state = createInitialState(); const messages: CoreMessage[] = [ { id: "u1", role: "user", contentType: "text", text: "run echo" }, @@ -157,8 +157,55 @@ test("renderVisibleRefs skips tool-call and tool-result messages", () => { const rendered = renderVisibleRefs(messages, state); + assert.match(rendered[0]!.text!, /m00001<\/acp>/, "user message gets tag"); + assert.match(rendered[1]!.text!, /m00002<\/acp>/, "tool-call gets tag (default)"); + assert.match(rendered[2]!.text!, /m00003<\/acp>/, "tool-result gets tag (default)"); + assert.match(rendered[3]!.text!, /m00004<\/acp>/, "assistant text gets tag"); +}); + +test("renderVisibleRefs skips tool messages when options.skipToolMessages is set", () => { + const state = createInitialState(); + const messages: CoreMessage[] = [ + { id: "u1", role: "user", contentType: "text", text: "run echo" }, + { id: "a1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tc1", text: '{"command":"echo hello"}' }, + { id: "t1", role: "tool", contentType: "tool-result", toolName: "bash", toolCallId: "tc1", text: "hello" }, + { id: "a2", role: "assistant", contentType: "text", text: "Done." }, + ]; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; + + const rendered = renderVisibleRefs(messages, state, undefined, { + skipToolMessages: true, + }); + assert.match(rendered[0]!.text!, /m00001<\/acp>/, "user message gets tag"); assert.equal(rendered[1]!.text, '{"command":"echo hello"}', "tool-call args unmodified"); assert.equal(rendered[2]!.text, "hello", "tool-result content unmodified"); assert.match(rendered[3]!.text!, /m00004<\/acp>/, "assistant text gets tag"); }); + +test("renderRefsNode respects config.render.skipToolMessageTags", () => { + const messages: CoreMessage[] = [ + { id: "u1", role: "user", contentType: "text", text: "run echo" }, + { id: "a1", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tc1", text: '{"command":"echo hello"}' }, + ]; + const state = createInitialState(); + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; + + const configWithSkip = { ...defaultConfig(100000), render: { skipToolMessageTags: true } }; + const io = makeIO(messages, state); + const ctx = { + config: configWithSkip, + tokenCount: 100, + countTokens: (t: string) => Math.ceil(t.length / 4), + }; + const result = renderRefsNode.run(io, ctx); + + assert.match(result.messages[0]!.text!, /m00001<\/acp>/, "user text gets tag"); + assert.equal(result.messages[1]!.text, '{"command":"echo hello"}', "tool-call args unmodified with skip flag"); +});