fix(server): show Other input for Claude questions

Fixes #1421
This commit is contained in:
Aoxiong Yin
2026-06-10 16:49:24 +08:00
committed by GitHub
parent e446d9009f
commit 7be9cc0d0b
2 changed files with 158 additions and 2 deletions

View File

@@ -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<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: "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", () => {

View File

@@ -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<PermissionResult> => {
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,