Skip to content
Open
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
163 changes: 163 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts
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();
});
Comment on lines +79 to +162
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-parameterized tests — the two cases share identical setup (make agent, install session, send idle, complete) and differ only in the prompt text and expected outcome. The project convention is to always prefer parameterised tests. Both scenarios could be driven by a single it.each table 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
This is a comment left during a code review.
Path: packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts
Line: 79-162

Comment:
**Non-parameterized tests** — the two cases share identical setup (make agent, install session, send idle, complete) and differ only in the prompt text and expected outcome. The project convention is to always prefer parameterised tests. Both scenarios could be driven by a single `it.each` table 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](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

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!

});
22 changes: 22 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
if (commandMatch && !isLocalOnlyCommand) {
if (commandMatch) {
Prompt To Fix With AI
This 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,
});
Expand Down