mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
committed by
GitHub
parent
ca3e55813e
commit
f89e5604d2
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user