From 6bac0a20c1037ed83942ee34782bdf15677a8234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 29 May 2026 14:35:52 -0400 Subject: [PATCH 1/2] fix: strip local command metadata without regex --- src/acp-agent.ts | 111 +++++++++++++++++++++++++++++++++++- src/tests/acp-agent.test.ts | 33 +++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index a6a01f05..63074727 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -347,11 +347,116 @@ const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); // payload in these XML-like markers that the CLI uses for its own display. // The live prompt loop drops them; replay must strip them too or they leak // into the UI on session/load. -const LOCAL_COMMAND_TAG_PATTERN = - /<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g; +const LOCAL_COMMAND_TAGS = [ + "command-name", + "command-message", + "command-args", + "local-command-stdout", + "local-command-stderr", +] as const; + +type LocalCommandTag = (typeof LOCAL_COMMAND_TAGS)[number]; +type LocalCommandTagToken = { + marker: string; + tag: LocalCommandTag; + closing: boolean; +}; +type LocalCommandTagPositions = { + openings: number[]; + openingHead: number; + closings: number[]; + closingHead: number; +}; + +const LOCAL_COMMAND_TAG_TOKENS: LocalCommandTagToken[] = LOCAL_COMMAND_TAGS.flatMap((tag) => [ + { marker: `<${tag}>`, tag, closing: false }, + { marker: ``, tag, closing: true }, +]); + +function readLocalCommandTagToken(text: string, index: number): LocalCommandTagToken | null { + for (const token of LOCAL_COMMAND_TAG_TOKENS) { + if (text.startsWith(token.marker, index)) return token; + } + return null; +} function stripMarkerTags(text: string): string { - return text.replace(LOCAL_COMMAND_TAG_PATTERN, ""); + const tagPositions = new Map(); + let index = 0; + + while (index < text.length) { + const markerStart = text.indexOf("<", index); + if (markerStart === -1) break; + + const token = readLocalCommandTagToken(text, markerStart); + if (token) { + let positions = tagPositions.get(token.tag); + if (!positions) { + positions = { openings: [], openingHead: 0, closings: [], closingHead: 0 }; + tagPositions.set(token.tag, positions); + } + + if (token.closing) { + positions.closings.push(markerStart); + } else { + positions.openings.push(markerStart); + } + } + + index = markerStart + (token?.marker.length ?? 1); + } + + let stripped = ""; + let keptStart = 0; + index = 0; + + while (index < text.length) { + const markerStart = text.indexOf("<", index); + if (markerStart === -1) break; + + const token = readLocalCommandTagToken(text, markerStart); + if (!token || token.closing) { + index = markerStart + 1; + continue; + } + + const positions = tagPositions.get(token.tag); + if (!positions) { + index = markerStart + token.marker.length; + continue; + } + + while ( + positions.openingHead < positions.openings.length && + positions.openings[positions.openingHead] <= markerStart + ) { + positions.openingHead++; + } + while ( + positions.closingHead < positions.closings.length && + positions.closings[positions.closingHead] < markerStart + token.marker.length + ) { + positions.closingHead++; + } + + const closingStart = positions.closings[positions.closingHead]; + const nextOpeningStart = positions.openings[positions.openingHead]; + if ( + closingStart === undefined || + (nextOpeningStart !== undefined && nextOpeningStart < closingStart) + ) { + index = markerStart + token.marker.length; + continue; + } + + positions.closingHead++; + stripped += text.slice(keptStart, markerStart); + index = closingStart + ``.length; + keptStart = index; + } + + if (keptStart === 0) return text; + return stripped + text.slice(keptStart); } /** diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 2b38c5e8..f5bf5dc6 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1156,6 +1156,39 @@ describe("stripLocalCommandMetadata", () => { ); }); + it("preserves unknown and incomplete marker-like tags", () => { + expect(stripLocalCommandMetadata("/model")).toBe("/model"); + expect(stripLocalCommandMetadata("/model")).toBe( + "/model", + ); + expect(stripLocalCommandMetadata("hi")).toBe( + "hi", + ); + }); + + it("handles long adversarial marker-like input without dropping text", () => { + const adversarial = Array.from( + { length: 5_000 }, + (_, index) => `${index}`, + ).join(""); + + expect(stripLocalCommandMetadata(adversarial)).toBe(adversarial); + }); + + it("preserves prose around crossed marker-like tags", () => { + expect( + stripLocalCommandMetadata( + "hi", + ), + ).toBe("hi"); + }); + + it("does not pair an incomplete opening tag with a later valid block", () => { + expect( + stripLocalCommandMetadata("/model hi /x tail"), + ).toBe("/model hi tail"); + }); + // Regression: in the original bug report the entire /model preamble and // the user's real "hi" prompt were concatenated into a single message. // We want to strip the marker tags and preserve the real prose, not drop From e3a7c88be3efd0e61aabb21d71dcdb0ec6ff5c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 29 May 2026 14:36:49 -0400 Subject: [PATCH 2/2] test: cover unterminated local command fragments --- src/tests/acp-agent.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index f5bf5dc6..8c2c0d9e 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1175,6 +1175,12 @@ describe("stripLocalCommandMetadata", () => { expect(stripLocalCommandMetadata(adversarial)).toBe(adversarial); }); + it("preserves many unterminated marker fragments", () => { + const markerLikeNoise = " { expect( stripLocalCommandMetadata(