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 6368e0398..333899b4f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -9,6 +9,7 @@ import * as executableUtils from "../../../../utils/executable.js"; import { ClaudeAgentClient, convertClaudeHistoryEntry, + normalizeClaudeAskUserQuestionRequestInput, normalizeClaudeAskUserQuestionUpdatedInput, toClaudeSdkMcpConfig, } from "./agent.js"; @@ -614,6 +615,37 @@ describe("ClaudeAgentSession features", () => { }); describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { + test("marks Claude AskUserQuestion options as allowing other answers", () => { + expect( + normalizeClaudeAskUserQuestionRequestInput("AskUserQuestion", { + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [ + { label: "Claude", description: "Use Claude Code" }, + { label: "Codex", description: "Use Codex" }, + ], + multiSelect: false, + }, + ], + }), + ).toEqual({ + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [ + { label: "Claude", description: "Use Claude Code" }, + { label: "Codex", description: "Use Codex" }, + ], + multiSelect: false, + allowOther: true, + }, + ], + }); + }); + test("maps frontend header-keyed answers to Claude question text keys", () => { expect( normalizeClaudeAskUserQuestionUpdatedInput( @@ -746,6 +778,86 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { await session.close(); } }); + + test("respondToPermission maps other answer text back to Claude question keys", async () => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const request = { + id: "permission-question-2", + provider: "claude", + name: "AskUserQuestion", + kind: "question", + input: normalizeClaudeAskUserQuestionRequestInput("AskUserQuestion", { + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [ + { label: "Claude", description: "Use Claude Code" }, + { label: "Codex", description: "Use Codex" }, + ], + multiSelect: false, + }, + ], + }), + }; + + const resultPromise = new Promise((resolve, reject) => { + ( + session as unknown as { + pendingPermissions: Map< + string, + { + request: typeof request; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + } + >; + } + ).pendingPermissions.set(request.id, { + request, + resolve, + reject, + }); + }); + + try { + await session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { + answers: { Provider: "Use both" }, + }, + }); + + await expect(resultPromise).resolves.toEqual({ + behavior: "allow", + updatedInput: { + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [ + { label: "Claude", description: "Use Claude Code" }, + { label: "Codex", description: "Use Codex" }, + ], + multiSelect: false, + }, + ], + answers: { "Which provider should I use?": "Use both" }, + }, + updatedPermissions: undefined, + }); + } finally { + await session.close(); + } + }); }); describe("ClaudeAgentSession context window usage", () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 2af3d6893..2b9203948 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -104,6 +104,49 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +export function normalizeClaudeAskUserQuestionRequestInput( + toolName: string, + input: AgentMetadata, +): AgentMetadata { + if (toolName !== "AskUserQuestion" || !Array.isArray(input.questions)) { + return input; + } + + // Claude Code's AskUserQuestion schema says "Other" is host-provided, not a + // model-supplied option. Paseo's shared question UI uses allowOther for that + // freeform answer path. + return { + ...input, + questions: input.questions.map((item) => { + if (!isMetadata(item)) { + return item; + } + return { + ...item, + allowOther: true, + }; + }), + }; +} + +function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetadata { + if (!Array.isArray(input.questions)) { + return input; + } + + return { + ...input, + questions: input.questions.map((item) => { + if (!isMetadata(item) || !("allowOther" in item)) { + return item; + } + const itemForClaude: AgentMetadata = { ...item }; + delete itemForClaude.allowOther; + return itemForClaude; + }), + }; +} + export function normalizeClaudeAskUserQuestionUpdatedInput( updatedInput: AgentMetadata | undefined, fallbackInput: AgentMetadata | undefined, @@ -114,7 +157,7 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( // AskUserQuestion tool expects answer keys to match the full question text. Merge // the original request payload back in so provider callbacks that only return // `{ answers }` still satisfy Claude's full tool input schema. - const merged = { ...fallback, ...base }; + const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base }); const questions = (Array.isArray(base.questions) ? base.questions : null) ?? (Array.isArray(fallback.questions) ? fallback.questions : null); @@ -3760,6 +3803,7 @@ class ClaudeAgentSession implements AgentSession { ): Promise => { const requestId = `permission-${randomUUID()}`; const kind = resolvePermissionKind(toolName, input); + const requestInput = normalizeClaudeAskUserQuestionRequestInput(toolName, input); const metadata: AgentMetadata = {}; if (options.toolUseID) { metadata.toolUseId = options.toolUseID; @@ -3782,7 +3826,7 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", name: toolName, kind, - input, + input: requestInput, detail: toolDetail, suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion,