fix(server): summarise Claude AskUserQuestion permission notifications (#2925)

* fix(server): summarise Claude AskUserQuestion permission notifications

Claude's handlePermissionRequest never set title or description on the
AgentPermissionRequest it emits, so buildPermissionDetails fell through to
serialising request.input. For AskUserQuestion that input is the whole question
object, and the push and in-app notifications both showed truncated JSON.

Summarise the first question the way the OMP and Pi providers already do for
their ask_user permissions: title is the question text, description is the
option labels. request.input is untouched, so the permission UI keeps rendering
the full payload.

Closes #2612

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ

* test(server): assert question permissions through the emitted request

Greptile flagged that the suite imported a production helper that was only
exported for the test, which leaves an unnecessary public export behind and
couples the tests to implementation structure.

The helper is module-private again, and every case now asserts on the
permission request the session emits and the notification body built from it.
Added a case pinning request.input so the permission UI payload stays covered,
and one for a non-question tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWY2Xdowxr562GXFdxPhjZ

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sihyeon Jeon
2026-08-08 21:28:21 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 1a117652fd
commit b19db9537c
2 changed files with 146 additions and 0 deletions
@@ -6,6 +6,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../../test-utils/test-logger.js";
import * as executableUtils from "../../../../executable-resolution/executable-resolution.js"; import * as executableUtils from "../../../../executable-resolution/executable-resolution.js";
import { buildAgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
import { import {
ClaudeAgentClient, ClaudeAgentClient,
convertClaudeHistoryEntry, convertClaudeHistoryEntry,
@@ -2650,3 +2651,116 @@ describe("toClaudeSdkMcpConfig", () => {
expect(result.alwaysLoad).toBeUndefined(); 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<string, unknown>,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>["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<string, unknown>,
options: Record<string, unknown>,
) => Promise<unknown>;
};
void internal.handlePermissionRequest(toolName, input, {}).catch(() => undefined);
const requested = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
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();
});
});
@@ -1024,6 +1024,37 @@ function resolvePermissionKind(
return "tool"; 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 { function getClaudeModeLabel(modeId: PermissionMode): string {
return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId; return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId;
} }
@@ -4351,6 +4382,7 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude", provider: "claude",
name: toolName, name: toolName,
kind, kind,
...buildClaudeQuestionPermissionSummary(toolName, input),
input: requestInput, input: requestInput,
detail: toolDetail, detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ suggestions: options.suggestions?.map((suggestion) => ({