diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 94e108f3a..2abba3a6e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -6,6 +6,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import * as executableUtils from "../../../../executable-resolution/executable-resolution.js"; +import { buildAgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { ClaudeAgentClient, convertClaudeHistoryEntry, @@ -2650,3 +2651,116 @@ describe("toClaudeSdkMcpConfig", () => { expect(result.alwaysLoad).toBeUndefined(); }); }); + +describe("Claude question permission notifications", () => { + // Regression for #2612: the attention notification serialized the raw + // AskUserQuestion input, so both the iOS push and the desktop app showed + // JSON instead of the question. + async function requestPermission( + toolName: string, + input: Record, + ): Promise["request"]> { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + try { + const internal = session as unknown as { + handlePermissionRequest: ( + toolName: string, + input: Record, + options: Record, + ) => Promise; + }; + void internal.handlePermissionRequest(toolName, input, {}).catch(() => undefined); + + const requested = events.find( + (event): event is Extract => + event.type === "permission_requested", + ); + if (!requested) { + throw new Error(`no permission was requested for ${toolName}`); + } + return requested.request; + } finally { + await session.close(); + } + } + + test("renders the notification as the question and its options", async () => { + const request = await requestPermission("AskUserQuestion", { + questions: [ + { + question: "Which library should we use?", + header: "Library", + options: [{ label: "date-fns" }, { label: "Luxon" }], + multiSelect: false, + }, + ], + }); + + const payload = buildAgentAttentionNotificationPayload({ + reason: "permission", + serverId: "srv-2612", + workspaceId: "workspace-2612", + agentId: "agent-2612", + permissionRequest: request, + }); + + expect(payload.body).toBe("Which library should we use? - date-fns / Luxon"); + expect(payload.body).not.toContain('"questions"'); + }); + + test("keeps the full question payload for the permission UI", async () => { + const request = await requestPermission("AskUserQuestion", { + questions: [ + { + question: "Which library should we use?", + header: "Library", + options: [{ label: "date-fns" }, { label: "Luxon" }], + multiSelect: false, + }, + ], + }); + + expect(request.input).toEqual( + normalizeClaudeAskUserQuestionRequestInput("AskUserQuestion", { + questions: [ + { + question: "Which library should we use?", + header: "Library", + options: [{ label: "date-fns" }, { label: "Luxon" }], + multiSelect: false, + }, + ], + }), + ); + }); + + test("falls back to the question alone when it has no options", async () => { + const request = await requestPermission("AskUserQuestion", { + questions: [{ question: "Ready to deploy?", header: "Deploy", options: [] }], + }); + + const payload = buildAgentAttentionNotificationPayload({ + reason: "permission", + serverId: "srv-2612", + workspaceId: "workspace-2612", + agentId: "agent-2612", + permissionRequest: request, + }); + + expect(payload.body).toBe("Ready to deploy?"); + }); + + test("leaves other tools unsummarised", async () => { + const request = await requestPermission("Bash", { command: "ls" }); + + expect(request.title).toBeUndefined(); + expect(request.description).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 2a5257ee0..96fa62aa8 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1024,6 +1024,37 @@ function resolvePermissionKind( return "tool"; } +// Notification previews fall back to serializing the raw request input when a +// permission request carries no title. For AskUserQuestion that fallback is the +// whole question object, so the notification reads as JSON. Summarize the first +// question the same way the OMP and Pi providers do for their ask_user +// permissions. +function buildClaudeQuestionPermissionSummary( + toolName: string, + input: AgentMetadata, +): { title?: string; description?: string } { + if (toolName !== "AskUserQuestion" || !Array.isArray(input.questions)) { + return {}; + } + + const question = input.questions.find(isMetadata); + const title = typeof question?.question === "string" ? question.question.trim() : ""; + if (!title) { + return {}; + } + + const labels = Array.isArray(question?.options) + ? question.options + .map((option) => { + if (typeof option === "string") return option.trim(); + return isMetadata(option) && typeof option.label === "string" ? option.label.trim() : ""; + }) + .filter((label) => label.length > 0) + : []; + + return labels.length > 0 ? { title, description: labels.join(" / ") } : { title }; +} + function getClaudeModeLabel(modeId: PermissionMode): string { return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId; } @@ -4351,6 +4382,7 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", name: toolName, kind, + ...buildClaudeQuestionPermissionSummary(toolName, input), input: requestInput, detail: toolDetail, suggestions: options.suggestions?.map((suggestion) => ({