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
1 change: 1 addition & 0 deletions packages/agent/src/adapters/claude/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
): MockQuery {
const query = createMockQuery();
const input = new Pushable();
const abortController = new AbortController();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -99,21 +104,36 @@ 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",
},
{
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,
},
] as const;

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<string> | undefined,
);

const promptPromise = agent.prompt({
sessionId: tc.sessionId,
Expand Down
32 changes: 31 additions & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -143,6 +144,17 @@ function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined {
}
}

function collectKnownSlashCommands(
commands: SlashCommand[] | undefined,
): Set<string> {
const names = new Set<string>();
if (!commands) return names;
for (const cmd of commands) {
if (cmd.name) names.add(cmd.name);
}
return names;
Comment thread
charlesvien marked this conversation as resolved.
}

function sanitizeTitle(text: string): string {
const sanitized = text
.replace(/[\r\n]+/g, " ")
Expand Down Expand Up @@ -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",
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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", {
Expand Down
7 changes: 7 additions & 0 deletions packages/agent/src/adapters/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
};

export type ToolUseCache = {
Expand Down
Loading