Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 51 additions & 23 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,56 @@ function selectAutoApprovedPermissionOption(
);
}

export function acpElicitationQuestions(
properties: Record<string, unknown>,
message: string,
): OrchestrationV2UserInputQuestion[] {
return Object.entries(properties).map(([id, property], index) => {
const record = unknownRecord(property);
const multiSelect = record?.type === "array";
const choices = multiSelect ? unknownRecord(record.items) : record;
const enumValues = Array.isArray(choices?.enum)
? choices.enum.filter((value): value is string => typeof value === "string")
: [];
const titledChoices = multiSelect ? choices?.anyOf : choices?.oneOf;
const titledOptions = Array.isArray(titledChoices)
? titledChoices.flatMap((choice) => {
const option = unknownRecord(choice);
if (typeof option?.const !== "string") return [];
const label = nonEmptyText(
option.title,
option.const.trim() ? option.const : JSON.stringify(option.const),
);
return [
{
label,
value: option.const,
description: nonEmptyText(option.description, label),
},
];
})
: [];
const options =
enumValues.length > 0
? enumValues.map((value) => ({ label: value, description: value }))
: titledOptions.length > 0
? titledOptions
: record?.type === "boolean"
? [
{ label: "true", description: "Yes" },
{ label: "false", description: "No" },
]
: [];
return {
id,
header: nonEmptyText(record?.title, `Question ${index + 1}`),
question: nonEmptyText(record?.description, message),
options,
...(multiSelect ? { multiSelect: true } : {}),
};
});
}

function elicitationContent(
answers: ProviderUserInputAnswers,
allowedKeys: ReadonlySet<string>,
Expand Down Expand Up @@ -5443,29 +5493,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
const properties = unknownRecord(requestedSchema?.properties) ?? {};
const elicitationScopeId =
"sessionId" in params ? params.sessionId : `request:${params.requestId}`;
const questions = Object.entries(properties).map(
([id, property], index): OrchestrationV2UserInputQuestion => {
const record = unknownRecord(property);
const enumValues = Array.isArray(record?.enum)
? record.enum.filter((value): value is string => typeof value === "string")
: [];
const options =
enumValues.length > 0
? enumValues.map((value) => ({ label: value, description: value }))
: record?.type === "boolean"
? [
{ label: "true", description: "Yes" },
{ label: "false", description: "No" },
]
: [];
return {
id,
header: nonEmptyText(record?.title, `Question ${index + 1}`),
question: nonEmptyText(record?.description, params.message),
options,
};
},
);
const questions = acpElicitationQuestions(properties, params.message);
const userInput = yield* requestUserInputWithAdmission(
handlerGeneration,
Effect.gen(function* () {
Expand Down
124 changes: 124 additions & 0 deletions packages/provider-acp/src/server/elicitation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vite-plus/test";

import { acpElicitationQuestions } from "./adapter.ts";

describe("ACP elicitation choices", () => {
it("preserves titled single-select values and descriptions", () => {
const [question] = acpElicitationQuestions(
{
q0: {
type: "string",
title: "Choice display test",
description: "Which choices can you see?",
oneOf: [
{ const: "all", title: "All three choices", description: "All options are visible." },
{ const: "question", title: "Question only" },
{ const: "partial", title: "Partial or broken choices" },
],
},
},
"Answer the question",
);
expect(question).toEqual({
id: "q0",
header: "Choice display test",
question: "Which choices can you see?",
options: [
{ label: "All three choices", value: "all", description: "All options are visible." },
{ label: "Question only", value: "question", description: "Question only" },
{
label: "Partial or broken choices",
value: "partial",
description: "Partial or broken choices",
},
],
});
});

it("supports titled and untitled multi-select enums", () => {
const questions = acpElicitationQuestions(
{
titled: {
type: "array",
items: {
anyOf: [
{ const: "a", title: "Alpha" },
{ const: "b", title: "Beta" },
],
},
},
plain: { type: "array", items: { type: "string", enum: ["alpha", "beta"] } },
},
"Choose features",
);
expect(questions[0]).toMatchObject({
multiSelect: true,
options: [
{ label: "Alpha", value: "a", description: "Alpha" },
{ label: "Beta", value: "b", description: "Beta" },
],
});
expect(questions[1]).toMatchObject({
multiSelect: true,
options: [
{ label: "alpha", description: "alpha" },
{ label: "beta", description: "beta" },
],
});
});

it("keeps enum, boolean, and custom-text questions compatible", () => {
expect(
acpElicitationQuestions(
{
color: { type: "string", enum: ["red", "blue"] },
approved: { type: "boolean" },
q0__other: { type: "string", title: "Other (type your own)" },
},
"Choose",
),
).toEqual([
{
id: "color",
header: "Question 1",
question: "Choose",
options: [
{ label: "red", description: "red" },
{ label: "blue", description: "blue" },
],
},
{
id: "approved",
header: "Question 2",
question: "Choose",
options: [
{ label: "true", description: "Yes" },
{ label: "false", description: "No" },
],
},
{ id: "q0__other", header: "Other (type your own)", question: "Choose", options: [] },
]);
});

it("ignores malformed options and preserves exact values", () => {
const [question] = acpElicitationQuestions(
{
value: {
type: "string",
oneOf: [
null,
{ const: 42 },
{ title: "Missing value" },
{ const: " exact ", title: "Exact" },
{ const: "", title: "Empty value" },
],
},
},
"Choose",
);
expect(question?.options).toEqual([
{ label: "Exact", value: " exact ", description: "Exact" },
{ label: "Empty value", value: "", description: "Empty value" },
]);
});
});
Loading