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:
co-authored by
Claude Opus 5
parent
1a117652fd
commit
b19db9537c
@@ -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<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";
|
||||
}
|
||||
|
||||
// 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) => ({
|
||||
|
||||
Reference in New Issue
Block a user