Skip to content

Commit c52779e

Browse files
committed
fix(server): offer ACP array enum form fields as multi-select questions
Generic ACP form elicitation only read a property's top-level enum, so a field declared as type array with items.enum became a question with no options and no multiSelect. The mapping now lives in elicitationFormQuestion, which reads items.enum for array fields and marks them multiSelect. Fixes #30851
1 parent a07be97 commit c52779e

2 files changed

Lines changed: 89 additions & 22 deletions

File tree

‎packages/provider-acp/src/server/adapter.ts‎

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,39 @@ function elicitationContent(
13081308
return content;
13091309
}
13101310

1311+
function stringEnum(schema: Record<string, unknown> | undefined): ReadonlyArray<string> {
1312+
return Array.isArray(schema?.enum)
1313+
? schema.enum.filter((value): value is string => typeof value === "string")
1314+
: [];
1315+
}
1316+
1317+
export function elicitationFormQuestion(
1318+
id: string,
1319+
property: unknown,
1320+
index: number,
1321+
message: string,
1322+
): OrchestrationV2UserInputQuestion {
1323+
const record = unknownRecord(property);
1324+
const isArray = record?.type === "array";
1325+
const enumValues = stringEnum(isArray ? unknownRecord(record.items) : record);
1326+
const options =
1327+
enumValues.length > 0
1328+
? enumValues.map((value) => ({ label: value, description: value }))
1329+
: record?.type === "boolean"
1330+
? [
1331+
{ label: "true", description: "Yes" },
1332+
{ label: "false", description: "No" },
1333+
]
1334+
: [];
1335+
return {
1336+
id,
1337+
header: nonEmptyText(record?.title, `Question ${index + 1}`),
1338+
question: nonEmptyText(record?.description, message),
1339+
options,
1340+
...(isArray && options.length > 0 ? { multiSelect: true } : {}),
1341+
};
1342+
}
1343+
13111344
interface ActiveTextSegment {
13121345
readonly nativeItemId: string;
13131346
readonly startedAt: DateTime.Utc;
@@ -6625,28 +6658,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
66256658
"sessionId" in params ? params.sessionId : `request:${params.requestId}`;
66266659
const questions =
66276660
nativeForm?.questions ??
6628-
Object.entries(properties).map(
6629-
([id, property], index): OrchestrationV2UserInputQuestion => {
6630-
const record = unknownRecord(property);
6631-
const enumValues = Array.isArray(record?.enum)
6632-
? record.enum.filter((value): value is string => typeof value === "string")
6633-
: [];
6634-
const options =
6635-
enumValues.length > 0
6636-
? enumValues.map((value) => ({ label: value, description: value }))
6637-
: record?.type === "boolean"
6638-
? [
6639-
{ label: "true", description: "Yes" },
6640-
{ label: "false", description: "No" },
6641-
]
6642-
: [];
6643-
return {
6644-
id,
6645-
header: nonEmptyText(record?.title, `Question ${index + 1}`),
6646-
question: nonEmptyText(record?.description, params.message),
6647-
options,
6648-
};
6649-
},
6661+
Object.entries(properties).map(([id, property], index) =>
6662+
elicitationFormQuestion(id, property, index, params.message),
66506663
);
66516664
const userInput = yield* requestUserInputWithAdmission(
66526665
handlerGeneration,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { assert, describe, it } from "@effect/vitest";
2+
3+
import { elicitationFormQuestion } from "./adapter.ts";
4+
5+
describe("elicitationFormQuestion", () => {
6+
it("offers an array property's items.enum as a multi-select question", () => {
7+
const question = elicitationFormQuestion(
8+
"answer",
9+
{ type: "array", items: { type: "string", enum: ["PostgreSQL", "SQLite"] } },
10+
0,
11+
"Choose databases",
12+
);
13+
assert.deepStrictEqual(question, {
14+
id: "answer",
15+
header: "Question 1",
16+
question: "Choose databases",
17+
options: [
18+
{ label: "PostgreSQL", description: "PostgreSQL" },
19+
{ label: "SQLite", description: "SQLite" },
20+
],
21+
multiSelect: true,
22+
});
23+
});
24+
25+
it("keeps single-choice enums, booleans and free text as before", () => {
26+
const single = elicitationFormQuestion("db", { type: "string", enum: ["a", "b"] }, 0, "Pick");
27+
assert.deepStrictEqual(single.options, [
28+
{ label: "a", description: "a" },
29+
{ label: "b", description: "b" },
30+
]);
31+
assert.isUndefined(single.multiSelect);
32+
const yesNo = elicitationFormQuestion("ok", { type: "boolean" }, 1, "Continue?");
33+
assert.deepStrictEqual(
34+
yesNo.options.map((option) => option.label),
35+
["true", "false"],
36+
);
37+
assert.isUndefined(yesNo.multiSelect);
38+
const text = elicitationFormQuestion("name", { type: "string", title: "Name" }, 2, "Name?");
39+
assert.deepStrictEqual(text.options, []);
40+
assert.strictEqual(text.header, "Name");
41+
assert.isUndefined(text.multiSelect);
42+
});
43+
44+
it("leaves an array without enum items as free text", () => {
45+
const question = elicitationFormQuestion(
46+
"tags",
47+
{ type: "array", items: { type: "string" } },
48+
0,
49+
"Tags",
50+
);
51+
assert.deepStrictEqual(question.options, []);
52+
assert.isUndefined(question.multiSelect);
53+
});
54+
});

0 commit comments

Comments
 (0)