Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ 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
WITHOUT `<system-reminder>` 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.
- 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

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion registry/zcode-acp-server/agent.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
53 changes: 47 additions & 6 deletions src/handlers/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
const latest = new Map<string, ZcodeMessage>();
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,
Expand All @@ -206,17 +233,31 @@ export async function fetchMessages(
return [];
}
const result = (resp.result ?? {}) as ZcodeMessagesResult;
return result.messages ?? [];
return dedupeMessages(result.messages ?? []);
}

/**
* Strip harness-injected reminder blocks from user text. The agent runtime
* appends `<system-reminder>…</system-reminder>` 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
* `<system-reminder>` 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(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
return text
.replace(/<system-reminder>[\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();
}

/**
Expand Down
113 changes: 113 additions & 0 deletions tests/load-tail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,119 @@ 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 <system-reminder> 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("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<string> {
const { cx } = collectCx();
Expand Down
Loading