-
Notifications
You must be signed in to change notification settings - Fork 30
fix(agent): surface error for unsupported slash commands instead of hanging #2345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| 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<typeof ClaudeAcpAgent>; | ||
|
|
||
| interface ClientMocks { | ||
| sessionUpdate: ReturnType<typeof vi.fn>; | ||
| extNotification: ReturnType<typeof vi.fn>; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| describe("ClaudeAcpAgent.prompt — unsupported slash command", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("emits a clear error and ends the turn when SDK silently consumes an unsupported slash command", async () => { | ||
| const { agent, client } = makeAgent(); | ||
| const query = installFakeSession(agent, "s-slash"); | ||
|
|
||
| const promptPromise = agent.prompt({ | ||
| sessionId: "s-slash", | ||
| prompt: [{ type: "text", text: "/plugin install slack" }], | ||
| }); | ||
|
|
||
| // Let the prompt loop start awaiting the first SDK message. | ||
| await new Promise((resolve) => setImmediate(resolve)); | ||
|
|
||
| // Simulate the SDK going idle without echoing the user message back | ||
| // (the failure mode reported in #2158). | ||
| const idleMessage: SDKMessage = { | ||
| type: "system", | ||
| subtype: "session_state_changed", | ||
| state: "idle", | ||
| } as unknown as SDKMessage; | ||
| query._mockHelpers.sendMessage(idleMessage); | ||
| query._mockHelpers.complete(); | ||
|
|
||
| const result = await promptPromise; | ||
|
|
||
| expect(result.stopReason).toBe("end_turn"); | ||
|
|
||
| const errorChunk = client.sessionUpdate.mock.calls.find( | ||
| ([call]) => | ||
| (call as { update?: { sessionUpdate?: string } }).update | ||
| ?.sessionUpdate === "agent_message_chunk", | ||
| ); | ||
| expect(errorChunk).toBeDefined(); | ||
| const errorText = | ||
| ( | ||
| errorChunk?.[0] as { | ||
| update: { content: { text: string } }; | ||
| } | ||
| ).update.content.text ?? ""; | ||
| expect(errorText).toContain("/plugin"); | ||
| expect(errorText.toLowerCase()).toContain("unsupported"); | ||
| }); | ||
|
|
||
| it("still skips a pre-prompt idle for non-slash-command prompts", async () => { | ||
| const { agent, client } = makeAgent(); | ||
| const query = installFakeSession(agent, "s-regular"); | ||
|
|
||
| const promptPromise = agent.prompt({ | ||
| sessionId: "s-regular", | ||
| prompt: [{ type: "text", text: "hello" }], | ||
| }); | ||
|
|
||
| await new Promise((resolve) => setImmediate(resolve)); | ||
|
|
||
| // First an unrelated idle (e.g. from a background task) before the prompt | ||
| // is replayed — should be skipped, not surfaced as an error. | ||
| query._mockHelpers.sendMessage({ | ||
| type: "system", | ||
| subtype: "session_state_changed", | ||
| state: "idle", | ||
| } as unknown as SDKMessage); | ||
|
|
||
| // Then the SDK is done with no further output. The existing loop exits via | ||
| // the "Session did not end in result" path. | ||
| query._mockHelpers.complete(); | ||
|
|
||
| await expect(promptPromise).rejects.toThrow( | ||
| /Session did not end in result/, | ||
| ); | ||
|
|
||
| // Most importantly: no agent_message_chunk with an "unsupported" error | ||
| // was emitted for the regular prompt. | ||
| const errorChunk = client.sessionUpdate.mock.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") | ||
| ); | ||
| }); | ||
| expect(errorChunk).toBeUndefined(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -471,6 +471,28 @@ export class ClaudeAcpAgent extends BaseAcpAgent { | |||||
| (message as Record<string, unknown>).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 && !isLocalOnlyCommand) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/agent/src/adapters/claude/claude-agent.ts
Line: 478
Comment:
The `!isLocalOnlyCommand` guard is always `true` inside this branch. When `isLocalOnlyCommand` is `true`, `promptReplayed` is also set to `true` at line 353, so the enclosing `if (!promptReplayed)` block is never entered with a local-only command. The check is superfluous (simplicity rule: no superfluous parts), and a reader has to trace back to line 353 to convince themselves it's harmless.
```suggestion
if (commandMatch) {
```
How can I resolve this? If you propose a fix, please make it concise. |
||||||
| 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, | ||||||
| }); | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it.eachtable that supplies{ prompt, expectedStopReason, expectsErrorChunk }(or similar), which would also make it trivial to add future slash-command variants without duplicating the fixture plumbing.Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!