fix: normalize Claude AskUserQuestion answers (#755) (#760)

Translate Paseo's header-keyed AskUserQuestion answers into the full question-text keys Claude expects before resolving question permissions.

Also preserve the original question payload when UI callbacks return an answers-only updatedInput, and lock the behavior down with provider-flow regression coverage in claude-agent tests.
This commit is contained in:
Somasundaram Ayyappan
2026-05-05 17:20:34 +05:30
committed by GitHub
parent ca3e55813e
commit f89e5604d2
2 changed files with 200 additions and 2 deletions

View File

@@ -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<unknown>((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();

View File

@@ -87,6 +87,61 @@ import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["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<string, string> = {};
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);