From 0286e6a7e2e776468a115d7195398053f4236362 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 28 May 2026 15:15:03 -0700 Subject: [PATCH] Gate unsupported-slash-command on SDK command list --- .../agent/src/adapters/claude/UPSTREAM.md | 1 + .../claude/claude-agent.slash-command.test.ts | 24 ++++++++++++-- .../agent/src/adapters/claude/claude-agent.ts | 32 ++++++++++++++++++- packages/agent/src/adapters/claude/types.ts | 7 ++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index d8c410832..e4337fab1 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -53,6 +53,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth | Auth methods | `claude-ai-login` + `console-login` | Returns empty `authMethods` | Auth handled externally | | Session fingerprinting | Implicit teardown on cwd/mcp change | Explicit `refreshSession()` | Caller-initiated is more predictable | | Shutdown on ACP close | Process exits | No standalone process | Agent is embedded in server | +| Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. | ## Changes Ported in v0.30.0 Sync diff --git a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 1c4bb0fd3..2ab70025c 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -34,7 +34,11 @@ function makeAgent(): { agent: Agent; client: ClientMocks } { return { agent, client }; } -function installFakeSession(agent: Agent, sessionId: string): MockQuery { +function installFakeSession( + agent: Agent, + sessionId: string, + knownSlashCommands?: Set, +): MockQuery { const query = createMockQuery(); const input = new Pushable(); const abortController = new AbortController(); @@ -63,6 +67,7 @@ function installFakeSession(agent: Agent, sessionId: string): MockQuery { taskRunId: "run-1", lastContextWindowSize: 200_000, modelId: "claude-sonnet-4-6", + knownSlashCommands, }; (agent as unknown as { session: typeof session }).session = session; @@ -99,6 +104,7 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { label: "unsupported slash command surfaces error and ends turn", sessionId: "s-slash", prompt: "/plugin install slack", + knownCommands: undefined, expectsUnsupportedChunk: true, commandInMessage: "/plugin", }, @@ -106,6 +112,16 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { label: "non-slash prompt with early idle is silently skipped", sessionId: "s-regular", prompt: "hello", + knownCommands: undefined, + expectsUnsupportedChunk: false, + commandInMessage: null, + }, + { + label: + "known plugin/skill command with early idle is not flagged as unsupported", + sessionId: "s-skill", + prompt: "/skills-store use my address pr review skill", + knownCommands: new Set(["skills-store"]), expectsUnsupportedChunk: false, commandInMessage: null, }, @@ -113,7 +129,11 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { it.each(cases)("$label", async (tc) => { const { agent, client } = makeAgent(); - const query = installFakeSession(agent, tc.sessionId); + const query = installFakeSession( + agent, + tc.sessionId, + tc.knownCommands as Set | undefined, + ); const promptPromise = agent.prompt({ sessionId: tc.sessionId, diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 1ff657452..ccccd2f10 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -43,6 +43,7 @@ import { type Query, query, type SDKUserMessage, + type SlashCommand, } from "@anthropic-ai/claude-agent-sdk"; import { v7 as uuidv7 } from "uuid"; import packageJson from "../../../package.json" with { type: "json" }; @@ -143,6 +144,17 @@ function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined { } } +function collectKnownSlashCommands( + commands: SlashCommand[] | undefined, +): Set { + const names = new Set(); + if (!commands) return names; + for (const cmd of commands) { + if (cmd.name) names.add(cmd.name); + } + return names; +} + function sanitizeTitle(text: string): string { const sanitized = text .replace(/[\r\n]+/g, " ") @@ -500,7 +512,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // and produced no output (e.g. /plugin in a non-interactive // context). Without this branch we would loop forever waiting // for an echo that never comes; surface a clear error instead. - if (commandMatch) { + // + // Only fire for commands the SDK does NOT recognize. Plugin + // and skill commands (e.g. /skills-store) produce a fresh + // user-message echo with a new uuid that our replay check + // can't match, so an early idle here is a race, not a real + // "unsupported" — fall through and let the loop continue. + const cmdName = commandMatch?.[1].slice(1); + const known = + cmdName !== undefined && + this.session.knownSlashCommands?.has(cmdName) === true; + if (commandMatch && !known) { const cmd = commandMatch[1]; this.logger.warn( "Slash command produced no output; treating as unsupported", @@ -520,6 +542,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } this.logger.debug("Skipping idle state before prompt replay", { sessionId: params.sessionId, + command: commandMatch?.[1], + known, }); break; } @@ -1305,6 +1329,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { `Session ${forkSession ? "fork" : "resumption"} timed out for sessionId=${sessionId}`, ); } + session.knownSlashCommands = collectKnownSlashCommands( + result.value.commands, + ); } catch (err) { settingsManager.dispose(); if ( @@ -1356,6 +1383,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { `Session initialization timed out for sessionId=${sessionId}`, ); } + session.knownSlashCommands = collectKnownSlashCommands( + initResult.value.commands, + ); } catch (err) { settingsManager.dispose(); this.logger.error("Session initialization failed", { diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index af07ea222..da1f29ad5 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -68,6 +68,13 @@ export type Session = BaseSession & { emitRawSDKMessages: boolean | SDKMessageFilter[]; /** Refreshed at session init and on MCP/skill changes. */ contextBreakdownBaseline?: ContextBreakdownBaseline; + /** + * Slash command names (without leading slash) the SDK recognizes for this + * session — built-ins plus plugin/skill commands. Captured from the SDK's + * init response. Used to distinguish "command produced no output" from + * "command is genuinely unknown" when the session goes idle without an echo. + */ + knownSlashCommands?: Set; }; export type ToolUseCache = {