Compare commits

...

1 Commits

Author SHA1 Message Date
mark.tkachenko
0dd9139965 fix: run built-in slash commands when the client attaches context 2026-10-30 13:50:59 +01:00
3 changed files with 90 additions and 9 deletions

View File

@@ -2190,6 +2190,31 @@ 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"]);
// Built-in commands that never use attached context. Clients like Air attach
// editor context (resources, extra text chunks) to every prompt, but Claude
// Code runs a slash command only when the user message is a single text block.
const CONTEXT_FREE_COMMANDS = new Set([
...LOCAL_ONLY_COMMANDS,
"/compact",
"/usage",
"/cost",
"/stats",
"/status",
"/model",
"/mcp",
"/remote-control",
]);
/** Drops the chunks after a context-free built-in command (see
* `CONTEXT_FREE_COMMANDS`), keeping the command and its arguments. Returns
* `prompt` itself when it isn't such a command. */
export function withoutCommandContext(prompt: PromptRequest["prompt"]): PromptRequest["prompt"] {
const first = prompt[0];
if (prompt.length < 2 || first?.type !== "text") return prompt;
const command = first.text.trimStart().split(/\s/, 1)[0];
return CONTEXT_FREE_COMMANDS.has(command) ? [first] : prompt;
}
// 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>`.
@@ -3901,6 +3926,9 @@ export class ClaudeAcpAgent {
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
}
const prompt = withoutCommandContext(params.prompt);
if (prompt !== params.prompt) params = { ...params, prompt };
const remoteControlCommand = parseRemoteControlCommand(params.prompt);
if (
remoteControlCommand &&

View File

@@ -36,6 +36,7 @@ import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import {
toAcpNotifications,
promptToClaude,
withoutCommandContext,
isLocalCommandMetadata,
isReplayHiddenMetaMessage,
isSyntheticLoginMessage,
@@ -4716,6 +4717,50 @@ describe("escape markdown", () => {
});
});
describe("withoutCommandContext", () => {
const link = { type: "resource_link", uri: "file:///a.ts", name: "a.ts" } as const;
const resource = {
type: "resource",
resource: { uri: "file:///a.ts", text: "const a = 1;" },
} as const;
/** `/compact` with attached editor context keeps only the text chunk. */
it("drops context after /compact", () => {
const text = { type: "text", text: "/compact" } as const;
expect(withoutCommandContext([text, link, resource])).toEqual([text]);
});
/** Arguments in the command chunk survive; the attached resource goes. */
it("keeps command arguments and drops context", () => {
const text = { type: "text", text: "/compact focus on tests" } as const;
expect(withoutCommandContext([text, resource])).toEqual([text]);
});
/** An extra text chunk after `/usage` is dropped too. */
it("drops trailing text chunks after /usage", () => {
const text = { type: "text", text: "/usage" } as const;
expect(withoutCommandContext([text, { type: "text", text: "editor context" }])).toEqual([text]);
});
/** Skills and custom commands use attached context, so the prompt is untouched. */
it("leaves custom commands unchanged", () => {
const prompt = [{ type: "text", text: "/code-review" } as const, resource];
expect(withoutCommandContext(prompt)).toBe(prompt);
});
/** A plain text prompt with a resource is untouched. */
it("leaves plain prompts unchanged", () => {
const prompt = [{ type: "text", text: "explain this" } as const, resource];
expect(withoutCommandContext(prompt)).toBe(prompt);
});
/** `/mcp:server:cmd` is an MCP prompt, not the `/mcp` built-in. */
it("leaves MCP prompt commands unchanged", () => {
const prompt = [{ type: "text", text: "/mcp:server:cmd" } as const, resource];
expect(withoutCommandContext(prompt)).toBe(prompt);
});
});
describe("prompt conversion", () => {
it("should not change built-in slash commands", () => {
const message = promptToClaude({

View File

@@ -635,25 +635,33 @@ describe("/mcp", () => {
expect(text()).toBe(formatMcpStatus(SERVERS));
});
it("leaves the text of other /mcp prompts unchanged", async () => {
const { agent, sdkQuery, forwarded, text, prompt } = setup();
/** A client like Air attaches editor context to `/mcp`; the adapter drops it and still shows the status. */
it("shows the status for /mcp with attached context", async () => {
const { agent, sdkQuery, forwarded, text } = setup();
await prompt("/mcp:github:prompt");
await prompt("/mcp help");
await agent.prompt({
sessionId: "test-session",
prompt: [
{ type: "text", text: "/mcp" },
{ type: "text", text: "more" },
{ type: "resource", resource: { uri: "file:///a.ts", text: "const a = 1;" } },
],
});
expect(forwarded).toEqual(["/mcp"]);
expect(text()).toBe(formatMcpStatus(SERVERS));
expect(sdkQuery.mcpServerStatus).toHaveBeenCalled();
});
it("leaves the text of other /mcp prompts unchanged", async () => {
const { sdkQuery, forwarded, text, prompt } = setup();
await prompt("/mcp:github:prompt");
await prompt("/mcp help");
// The adapter rewrites an MCP prompt command into the Claude Code form.
expect(forwarded).toEqual(["/github:prompt (MCP)", "/mcp help", "/mcp more"]);
expect(forwarded).toEqual(["/github:prompt (MCP)", "/mcp help"]);
expect(text()).toBe(
["/github:prompt (MCP)", "/mcp help", "/mcp more"]
.map((command) => cliText(command, SERVERS))
.join(""),
["/github:prompt (MCP)", "/mcp help"].map((command) => cliText(command, SERVERS)).join(""),
);
expect(sdkQuery.mcpServerStatus).not.toHaveBeenCalled();
});