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:
@@ -2,7 +2,11 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
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";
|
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
interface TestClaudeSession {
|
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", () => {
|
describe("ClaudeAgentSession context window usage", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,61 @@ import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js
|
|||||||
const fsPromises = promises;
|
const fsPromises = promises;
|
||||||
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
|
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";
|
type TurnState = "idle" | "foreground" | "autonomous";
|
||||||
|
|
||||||
interface EventIdentifiers {
|
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 = {
|
const result: PermissionResult = {
|
||||||
behavior: "allow",
|
behavior: "allow",
|
||||||
updatedInput: response.updatedInput ?? pending.request.input ?? {},
|
updatedInput,
|
||||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||||
};
|
};
|
||||||
pending.resolve(result);
|
pending.resolve(result);
|
||||||
|
|||||||
Reference in New Issue
Block a user