mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
fix: replay marker-only slash skill prompts (#1035)
## Summary - reconstructs marker-only model-bound slash skill prompts during history replay - keeps client/local command metadata hidden when local stdout/stderr is present - adds regression coverage for both string and content-array transcript shapes Closes #998. ## Verification - `npm run test:run -- src/tests/acp-agent.test.ts -t 'stripLocalCommandMetadata'` - `npm run test:run -- src/tests/acp-agent.test.ts` - `npm run build` - `npm run check` - `git diff --check` I also ran `npm run test:run`. It completed with 965 passing tests, 27 skipped, and the same two root-environment `resolve-permission-mode` failures seen when running as root: `bypassPermissions` is intentionally downgraded to `default` in that environment. The changed replay/metadata tests passed. Signed-off-by: Emre Koca <1328630750+kocaemre@users.noreply.github.com> Co-authored-by: Emre Koca <1328630750+kocaemre@users.noreply.github.com>
This commit is contained in:
@@ -1574,6 +1574,17 @@ const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start
|
||||
// message and without invoking the model.
|
||||
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
// Commands whose marker-only transcript records are ACP/client-local UI noise,
|
||||
// not model-visible user prompts. Marker-only custom slash skills are not in
|
||||
// this list, so replay can reconstruct them from `<command-name>`/`<command-args>`.
|
||||
const REPLAY_HIDDEN_COMMANDS = new Set([
|
||||
...LOCAL_ONLY_COMMANDS,
|
||||
"/compact",
|
||||
"/model",
|
||||
"/status",
|
||||
"/usage",
|
||||
]);
|
||||
|
||||
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
|
||||
// their output as user messages in the session transcript, wrapping the
|
||||
// payload in these XML-like markers that the CLI uses for its own display.
|
||||
@@ -1630,6 +1641,38 @@ function stripMarkerTags(text: string): string {
|
||||
return result + text.slice(copiedUpTo);
|
||||
}
|
||||
|
||||
function markerTagText(text: string, tag: string): string | undefined {
|
||||
const open = `<${tag}>`;
|
||||
const close = `</${tag}>`;
|
||||
const start = text.indexOf(open);
|
||||
if (start === -1) return undefined;
|
||||
const end = text.indexOf(close, start + open.length);
|
||||
if (end === -1) return undefined;
|
||||
return text.slice(start + open.length, end);
|
||||
}
|
||||
|
||||
function hasMarkerTag(text: string, tag: string): boolean {
|
||||
return markerTagText(text, tag) !== undefined;
|
||||
}
|
||||
|
||||
function commandInvocationFromMarkerOnlyText(text: string): string | null {
|
||||
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
|
||||
return null;
|
||||
}
|
||||
const commandName = markerTagText(text, "command-name")?.trim();
|
||||
if (!commandName?.startsWith("/")) return null;
|
||||
if (REPLAY_HIDDEN_COMMANDS.has(commandName.split(" ", 1)[0])) return null;
|
||||
|
||||
const commandArgs = markerTagText(text, "command-args")?.trim();
|
||||
return commandArgs ? `${commandName} ${commandArgs}` : commandName;
|
||||
}
|
||||
|
||||
function stripLocalCommandMetadataText(text: string): string | null {
|
||||
const stripped = stripMarkerTags(text);
|
||||
if (stripped.trim() !== "") return stripped;
|
||||
return commandInvocationFromMarkerOnlyText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return user-message content with local-command and injected-context marker
|
||||
* tags removed, or `null` if nothing meaningful remains (caller should skip
|
||||
@@ -1639,8 +1682,7 @@ function stripMarkerTags(text: string): string {
|
||||
*/
|
||||
export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
if (typeof content === "string") {
|
||||
const stripped = stripMarkerTags(content);
|
||||
return stripped.trim() === "" ? null : stripped;
|
||||
return stripLocalCommandMetadataText(content);
|
||||
}
|
||||
if (!Array.isArray(content)) return content;
|
||||
|
||||
@@ -1654,8 +1696,8 @@ export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
"text" in block &&
|
||||
typeof (block as { text: unknown }).text === "string"
|
||||
) {
|
||||
const stripped = stripMarkerTags((block as { text: string }).text);
|
||||
if (stripped.trim() === "") continue;
|
||||
const stripped = stripLocalCommandMetadataText((block as { text: string }).text);
|
||||
if (stripped === null) continue;
|
||||
kept.push({ ...(block as object), text: stripped });
|
||||
} else {
|
||||
kept.push(block);
|
||||
|
||||
@@ -1863,6 +1863,38 @@ describe("stripLocalCommandMetadata", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reconstructs marker-only model-bound slash skill prompts", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata(
|
||||
"<command-message>example-skill</command-message>\n" +
|
||||
"<command-name>/example-skill</command-name>\n" +
|
||||
"<command-args>explain the startup decision</command-args>",
|
||||
),
|
||||
).toBe("/example-skill explain the startup decision");
|
||||
|
||||
expect(
|
||||
stripLocalCommandMetadata([
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"<command-message>example-skill</command-message>\n" +
|
||||
"<command-name>/example-skill</command-name>\n" +
|
||||
"<command-args>explain the startup decision</command-args>",
|
||||
},
|
||||
]),
|
||||
).toEqual([{ type: "text", text: "/example-skill explain the startup decision" }]);
|
||||
});
|
||||
|
||||
it("keeps local-command stdout marker-only payloads hidden", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata(
|
||||
"<command-name>/example-skill</command-name>" +
|
||||
"<command-args>explain</command-args>" +
|
||||
"<local-command-stdout>handled locally</local-command-stdout>",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the string unchanged for real content", () => {
|
||||
expect(stripLocalCommandMetadata("hi")).toBe("hi");
|
||||
expect(stripLocalCommandMetadata("please run /model with args")).toBe(
|
||||
|
||||
Reference in New Issue
Block a user