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 new file mode 100644 index 000000000..1c4bb0fd3 --- /dev/null +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -0,0 +1,153 @@ +import type { AgentSideConnection } from "@agentclientprotocol/sdk"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockQuery, type MockQuery } from "../../test/mocks/claude-sdk"; +import { Pushable } from "../../utils/streams"; + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: vi.fn(), +})); + +vi.mock("./mcp/tool-metadata", () => ({ + fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined), + getConnectedMcpServerNames: vi.fn().mockReturnValue([]), + setMcpToolApprovalStates: vi.fn(), + isMcpToolReadOnly: vi.fn().mockReturnValue(false), + getMcpToolMetadata: vi.fn().mockReturnValue(undefined), + getMcpToolApprovalState: vi.fn().mockReturnValue(undefined), +})); + +const { ClaudeAcpAgent } = await import("./claude-agent"); +type Agent = InstanceType; + +interface ClientMocks { + sessionUpdate: ReturnType; + extNotification: ReturnType; +} + +function makeAgent(): { agent: Agent; client: ClientMocks } { + const client: ClientMocks = { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + extNotification: vi.fn().mockResolvedValue(undefined), + }; + const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection); + return { agent, client }; +} + +function installFakeSession(agent: Agent, sessionId: string): MockQuery { + const query = createMockQuery(); + const input = new Pushable(); + const abortController = new AbortController(); + + const session = { + query, + queryOptions: { sessionId, cwd: "/tmp/repo", abortController }, + input, + cancelled: false, + interruptReason: undefined, + settingsManager: { dispose: vi.fn(), getRepoRoot: () => "/tmp/repo" }, + permissionMode: "default" as const, + abortController, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + cwd: "/tmp/repo", + notificationHistory: [] as unknown[], + taskRunId: "run-1", + lastContextWindowSize: 200_000, + modelId: "claude-sonnet-4-6", + }; + + (agent as unknown as { session: typeof session }).session = session; + (agent as unknown as { sessionId: string }).sessionId = sessionId; + + return query; +} + +function findUnsupportedChunkText( + calls: ClientMocks["sessionUpdate"]["mock"]["calls"], +): string | undefined { + const match = calls.find(([call]) => { + const update = ( + call as { + update?: { sessionUpdate?: string; content?: { text?: string } }; + } + ).update; + return ( + update?.sessionUpdate === "agent_message_chunk" && + update?.content?.text?.toLowerCase().includes("unsupported") + ); + }); + return (match?.[0] as { update: { content: { text: string } } } | undefined) + ?.update.content.text; +} + +describe("ClaudeAcpAgent.prompt — early idle handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const cases = [ + { + label: "unsupported slash command surfaces error and ends turn", + sessionId: "s-slash", + prompt: "/plugin install slack", + expectsUnsupportedChunk: true, + commandInMessage: "/plugin", + }, + { + label: "non-slash prompt with early idle is silently skipped", + sessionId: "s-regular", + prompt: "hello", + expectsUnsupportedChunk: false, + commandInMessage: null, + }, + ] as const; + + it.each(cases)("$label", async (tc) => { + const { agent, client } = makeAgent(); + const query = installFakeSession(agent, tc.sessionId); + + const promptPromise = agent.prompt({ + sessionId: tc.sessionId, + prompt: [{ type: "text", text: tc.prompt }], + }); + + // Let the prompt loop start awaiting the first SDK message. + await new Promise((resolve) => setImmediate(resolve)); + + query._mockHelpers.sendMessage({ + type: "system", + subtype: "session_state_changed", + state: "idle", + } as unknown as SDKMessage); + query._mockHelpers.complete(); + + if (tc.expectsUnsupportedChunk) { + const result = await promptPromise; + expect(result.stopReason).toBe("end_turn"); + + const text = findUnsupportedChunkText(client.sessionUpdate.mock.calls); + expect(text).toBeDefined(); + if (tc.commandInMessage) { + expect(text).toContain(tc.commandInMessage); + } + } else { + // No unsupported chunk; loop falls through to the existing + // "Session did not end in result" failure path. + await expect(promptPromise).rejects.toThrow( + /Session did not end in result/, + ); + expect( + findUnsupportedChunkText(client.sessionUpdate.mock.calls), + ).toBeUndefined(); + } + }); +}); diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index def341f56..1ff657452 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -496,6 +496,28 @@ export class ClaudeAcpAgent extends BaseAcpAgent { (message as Record).state === "idle" ) { if (!promptReplayed) { + // The SDK consumed a slash command we do not handle locally + // 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) { + const cmd = commandMatch[1]; + this.logger.warn( + "Slash command produced no output; treating as unsupported", + { sessionId: params.sessionId, command: cmd }, + ); + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `Unsupported slash command: \`${cmd}\`. PostHog Code does not implement this command.`, + }, + }, + }); + return { stopReason: "end_turn" }; + } this.logger.debug("Skipping idle state before prompt replay", { sessionId: params.sessionId, });