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 22076c3d5..9f92f49f8 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -2,7 +2,11 @@ import { describe, expect, test, vi } from "vitest"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js"; +import { + ClaudeAgentClient, + convertClaudeHistoryEntry, + normalizeClaudeAskUserQuestionUpdatedInput, +} from "./claude-agent.js"; import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js"; interface TestClaudeSession { @@ -364,6 +368,138 @@ describe("ClaudeAgentClient.listModels", () => { }); }); +describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { + test("maps frontend header-keyed answers to Claude question text keys", () => { + expect( + normalizeClaudeAskUserQuestionUpdatedInput( + { + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [], + multiSelect: false, + }, + ], + answers: { Provider: "Claude" }, + }, + undefined, + ), + ).toEqual({ + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [], + multiSelect: false, + }, + ], + answers: { "Which provider should I use?": "Claude" }, + }); + }); + + test("uses fallback request questions when response only includes answers", () => { + expect( + normalizeClaudeAskUserQuestionUpdatedInput( + { + answers: { Provider: "Codex" }, + }, + { + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [], + multiSelect: false, + }, + ], + }, + ), + ).toEqual({ + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [], + multiSelect: false, + }, + ], + answers: { "Which provider should I use?": "Codex" }, + }); + }); + + test("respondToPermission preserves full question input when UI returns answers-only payload", async () => { + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const request = { + id: "permission-question-1", + provider: "claude", + name: "AskUserQuestion", + kind: "question", + input: { + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [], + 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: "Claude" }, + }, + }); + + await expect(resultPromise).resolves.toEqual({ + behavior: "allow", + updatedInput: { + questions: [ + { + question: "Which provider should I use?", + header: "Provider", + options: [], + multiSelect: false, + }, + ], + answers: { "Which provider should I use?": "Claude" }, + }, + updatedPermissions: undefined, + }); + } finally { + await session.close(); + } + }); +}); + describe("ClaudeAgentSession context window usage", () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index e51d31c79..38c90daa5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -87,6 +87,61 @@ import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js const fsPromises = promises; const CLAUDE_SETTING_SOURCES: NonNullable = ["user", "project"]; +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +export function normalizeClaudeAskUserQuestionUpdatedInput( + updatedInput: AgentMetadata | undefined, + fallbackInput: AgentMetadata | undefined, +): AgentMetadata { + const fallback = isMetadata(fallbackInput) ? fallbackInput : {}; + const base = isMetadata(updatedInput) ? updatedInput : {}; + // Paseo's shared question UI serializes answers by question header, but Claude's + // 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 questions = + (Array.isArray(base.questions) ? base.questions : null) ?? + (Array.isArray(fallback.questions) ? fallback.questions : null); + const answers = isMetadata(base.answers) ? base.answers : null; + + if (!questions || !answers) { + return merged; + } + + const normalizedAnswers: Record = {}; + for (const item of questions) { + const question = isMetadata(item) ? item : null; + if (!question) { + continue; + } + + const questionText = readNonEmptyString(question.question); + if (!questionText) { + continue; + } + + const header = readNonEmptyString(question.header); + const answer = + readNonEmptyString(answers[questionText]) ?? + (header ? readNonEmptyString(answers[header]) : null); + if (answer) { + normalizedAnswers[questionText] = answer; + } + } + + if (Object.keys(normalizedAnswers).length === 0) { + return merged; + } + + return { + ...merged, + answers: normalizedAnswers, + }; +} + type TurnState = "idle" | "foreground" | "autonomous"; interface EventIdentifiers { @@ -1772,9 +1827,16 @@ class ClaudeAgentSession implements AgentSession { }), ); } + const updatedInput = + pending.request.kind === "question" + ? normalizeClaudeAskUserQuestionUpdatedInput( + response.updatedInput, + pending.request.input ?? undefined, + ) + : (response.updatedInput ?? pending.request.input ?? {}); const result: PermissionResult = { behavior: "allow", - updatedInput: response.updatedInput ?? pending.request.input ?? {}, + updatedInput, updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions), }; pending.resolve(result);