From 35017ffa33e40807ad6922459287bf260e6dff95 Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 17 Aug 2026 21:10:18 +0800 Subject: [PATCH 1/3] fix: strip tagless harness reminders from replayed user messages --- CHANGELOG.md | 9 +++++ src/handlers/replay.ts | 24 ++++++++++--- tests/load-tail.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a554f9a..561608e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Replay now also strips harness tool-usage reminders that the backend stores + WITHOUT `` tags (TodoWrite/Read nudges followed by the + todo-list dump) — verified against live `session/messages` payloads, they + previously replayed verbatim and rendered as user input. Matching anchors on + the nudge's stable opening signature and closing sentence, so real user text + before or after the block survives. + ## [0.3.1] - 2026-08-17 ### Fixed diff --git a/src/handlers/replay.ts b/src/handlers/replay.ts index 6c3adb2..224252b 100644 --- a/src/handlers/replay.ts +++ b/src/handlers/replay.ts @@ -210,13 +210,27 @@ export async function fetchMessages( } /** - * Strip harness-injected reminder blocks from user text. The agent runtime - * appends `` blocks (TodoWrite nudges, - * context handoffs) to user turns as context plumbing — they are not user - * speech, and replaying them verbatim makes clients render them as user input. + * Strip harness-injected reminder plumbing from user text. The agent runtime + * appends TodoWrite/Read usage nudges (with an optional todo-list dump) and + * `` blocks to user turns — they are not user speech, and + * replaying them verbatim makes clients render them as user input. + * + * The nudges arrive in stored history WITHOUT tags (verified against live + * session/messages payloads), so they are matched by their stable shape: a + * fixed opening signature, a fixed closing sentence, and — when present — a + * bracket-wrapped todo dump. Real user text before or after the block + * survives; user messages that consist only of plumbing are dropped by the + * caller's empty-check. */ function stripSystemReminders(text: string): string { - return text.replace(/[\s\S]*?<\/system-reminder>/g, "").trim(); + return text + .replace(/[\s\S]*?<\/system-reminder>/g, "") + .replace( + /The (?:TodoWrite|Read) tool hasn't been used recently\.[\s\S]*?This is just a gentle reminder - ignore if not applicable\./g, + "", + ) + .replace(/Here (?:are|is)[^\n]*todo list:\s*\n\s*\n\[[\s\S]*?\](?=\n|$)/g, "") + .trim(); } /** diff --git a/tests/load-tail.test.ts b/tests/load-tail.test.ts index eee07b9..0e62b7c 100644 --- a/tests/load-tail.test.ts +++ b/tests/load-tail.test.ts @@ -187,6 +187,85 @@ describe("system-reminder stripping in replay", () => { }); }); +describe("tag-less tool reminder stripping in replay", () => { + // Exact shape captured from a live session/messages payload: the harness + // stores TodoWrite nudges as plain text WITHOUT tags. + const NUDGE = + "The TodoWrite tool hasn't been used recently. If you're working on " + + "tasks that would benefit from tracking progress, consider using the " + + "TodoWrite tool to track progress. Also consider cleaning up the todo " + + "list if it no longer matches what you are working on. Only use it if " + + "it's relevant to the current work. This is just a gentle reminder - " + + "ignore if not applicable."; + const DUMP = + "Here are the existing contents of your todo list:\n\n" + + "[1. [completed] probe\n2. [pending] client app refresh]"; + + it("drops a reminder-only message (nudge + todo dump)", async () => { + const history = hist(); + history[6] = { + info: { id: "u3", role: "user" }, + parts: [{ type: "text", text: `${NUDGE}\n\n${DUMP}` }], + }; + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams(), cx); + + const texts = chunks(updates); + expect(texts).toEqual(["sys", "one", "A1", "two", "A2a", "A2b", "A3"]); + expect(texts.join("\n")).not.toContain("TodoWrite"); + }); + + it("keeps user text that follows the reminder in the same message", async () => { + const history = hist(); + history[6] = { + info: { id: "u3", role: "user" }, + parts: [{ type: "text", text: `${NUDGE}\n\n${DUMP}\n\n这个问题还是存在` }], + }; + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams(), cx); + + expect(chunks(updates)).toContain("这个问题还是存在"); + }); + + it("keeps user text that precedes the nudge", async () => { + const history = hist(); + history[6] = { + info: { id: "u3", role: "user" }, + parts: [{ type: "text", text: `please fix this\n\n${NUDGE}` }], + }; + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams(), cx); + + const texts = chunks(updates); + expect(texts).toContain("please fix this"); + expect(texts.join("\n")).not.toContain("gentle reminder"); + }); + + it("leaves ordinary mentions of the tool name untouched", async () => { + const history = hist(); + history[6] = { + info: { id: "u3", role: "user" }, + parts: [{ type: "text", text: "帮我处理 TodoWrite 的问题" }], + }; + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams(), cx); + + expect(chunks(updates)).toContain("帮我处理 TodoWrite 的问题"); + }); +}); + describe("session/load_earlier", () => { async function attachTail(server: ZcodeAcpServer): Promise { const { cx } = collectCx(); From c5e8d72525e0a80e0d50d358808c5828de39961c Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 17 Aug 2026 21:14:21 +0800 Subject: [PATCH 2/3] fix: dedupe backend history by message id in replay --- CHANGELOG.md | 5 +++++ src/handlers/replay.ts | 29 ++++++++++++++++++++++++++++- tests/load-tail.test.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561608e..8e18739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 previously replayed verbatim and rendered as user input. Matching anchors on the nudge's stable opening signature and closing sentence, so real user text before or after the block survives. +- Replay now deduplicates history entries that share a message id — the + backend can return the same message at multiple non-adjacent positions + (observed in a live payload: 21 of 42 messages were duplicates), and every + copy was replayed, rendering identical paragraphs twice. Each id is kept + once, at its original position with its latest content. ## [0.3.1] - 2026-08-17 diff --git a/src/handlers/replay.ts b/src/handlers/replay.ts index 224252b..56b4c2f 100644 --- a/src/handlers/replay.ts +++ b/src/handlers/replay.ts @@ -187,6 +187,33 @@ export function readTailLimit(params: acp.LoadSessionRequest): number | null { return clampLimit(raw); } +/** + * Drop duplicate entries the backend can return for the same message id + * (observed in live session/messages payloads: the same id appears at + * multiple, non-adjacent positions with identical content). Replaying every + * copy makes clients render the same paragraph once per copy. Each id is + * kept at its first (original) position with its latest (most recent) + * content; id-less entries pass through untouched. + */ +function dedupeMessages(messages: ZcodeMessage[]): ZcodeMessage[] { + const firstIndex = new Map(); + const latest = new Map(); + messages.forEach((m, i) => { + const id = m.info?.id; + if (!id) return; + if (!firstIndex.has(id)) firstIndex.set(id, i); + latest.set(id, m); + }); + if (firstIndex.size === messages.length) return messages; + return messages + .map((m, i) => { + const id = m.info?.id; + if (!id) return m; + return firstIndex.get(id) === i ? (latest.get(id) ?? m) : null; + }) + .filter((m): m is ZcodeMessage => m !== null); +} + /** Fetch session/messages from zcode (the bridge's only history source). */ export async function fetchMessages( server: ZcodeAcpServer, @@ -206,7 +233,7 @@ export async function fetchMessages( return []; } const result = (resp.result ?? {}) as ZcodeMessagesResult; - return result.messages ?? []; + return dedupeMessages(result.messages ?? []); } /** diff --git a/tests/load-tail.test.ts b/tests/load-tail.test.ts index 0e62b7c..f4c25ea 100644 --- a/tests/load-tail.test.ts +++ b/tests/load-tail.test.ts @@ -266,6 +266,40 @@ describe("tag-less tool reminder stripping in replay", () => { }); }); +describe("message dedup in replay", () => { + it("replays a backend-duplicated message id only once", async () => { + const history = hist(); + // The same id at a NON-ADJACENT position, as observed in live payloads. + history.splice(5, 0, { + info: { id: "u1", role: "user" }, + parts: [{ type: "text", text: "one" }], + }); + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + const result = await loadSession(server, loadParams(), cx); + + expect(chunks(updates)).toEqual(["sys", "one", "A1", "two", "A2a", "A2b", "three", "A3"]); + expect((result as { replayMeta?: { totalMessages?: number } }).replayMeta).toMatchObject({ + totalMessages: 8, + }); + }); + + it("keeps distinct message ids even when their text is identical", async () => { + const history = hist(); + history[3] = { info: { id: "u2", role: "user" }, parts: [{ type: "text", text: "继续" }] }; + history[6] = { info: { id: "u3", role: "user" }, parts: [{ type: "text", text: "继续" }] }; + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(history); + const { cx, updates } = collectCx(); + + await loadSession(server, loadParams(), cx); + + expect(chunks(updates)).toEqual(["sys", "one", "A1", "继续", "A2a", "A2b", "继续", "A3"]); + }); +}); + describe("session/load_earlier", () => { async function attachTail(server: ZcodeAcpServer): Promise { const { cx } = collectCx(); From f6f8a640acfede21412a045c2fe629886282e67d Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 17 Aug 2026 21:15:22 +0800 Subject: [PATCH 3/3] chore: release 0.3.2 --- CHANGELOG.md | 2 ++ package.json | 2 +- registry/zcode-acp-server/agent.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e18739..82379e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.2] - 2026-08-17 + ### Fixed - Replay now also strips harness tool-usage reminders that the backend stores diff --git a/package.json b/package.json index 3fa5733..a0e7e22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.3.1", + "version": "0.3.2", "description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.", "type": "module", "license": "Apache-2.0", diff --git a/registry/zcode-acp-server/agent.json b/registry/zcode-acp-server/agent.json index 22e02f2..e3a4754 100644 --- a/registry/zcode-acp-server/agent.json +++ b/registry/zcode-acp-server/agent.json @@ -1,7 +1,7 @@ { "id": "zcode-acp-server", "name": "ZCode", - "version": "0.3.1", + "version": "0.3.2", "description": "Standalone ACP server bridging the headless ZCode app-server (GLM-5.2) to editors like Zed and JetBrains. Supports streaming, tool calls, session fork/resume, mode switching, and reads GLM credentials locally — no editor-side API key required.", "repository": "https://github.com/william0wang/zcode-acp", "website": "https://github.com/william0wang/zcode-acp",