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:
Emre K.
2026-09-23 12:37:38 +03:00
committed by GitHub
parent 388a0f5191
commit 97394d93b6
2 changed files with 78 additions and 4 deletions

View File

@@ -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);

View File

@@ -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(