mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
fix(opencode): answer multi-select v2 questions from the question card
OpenCode v2 marks a multi-select question as a `multiselect` form field. The mapper sent it to the app as `multiple`, which the question card does not read, and the reply path only accepted a string array, while the card sends one comma-joined string. Answering any multi-select OpenCode v2 question threw "Invalid answer for OpenCode question q0" and the answer never reached OpenCode. Send `multiSelect` so the card shows checkboxes, and accept the card's comma-joined answer by consuming exact option labels from the front, longest first, the same way the OMP question bridge does. Leftover text becomes the typed answer only when the field allows one. Closes #6135
This commit is contained in:
committed by
paseo-bot[bot]
parent
9da676d709
commit
7ae1ffc913
@@ -109,7 +109,7 @@ describe("OpenCode v2 question tool", () => {
|
||||
{ value: "Explore", label: "Explore", description: "Map the codebase" },
|
||||
{ value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" },
|
||||
],
|
||||
multiple: false,
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
@@ -129,6 +129,99 @@ describe("OpenCode v2 question tool", () => {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("answers a multi-select question with the checked options and a typed answer", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.api.session.form.list = async () => [
|
||||
{
|
||||
id: "question",
|
||||
sessionID: "session",
|
||||
title: "Questions",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Fixes",
|
||||
description: "Which fixes should I make?",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "delete", label: "Delete both" },
|
||||
{ value: "delete-and-fix", label: "Delete both, fix code" },
|
||||
{ value: "email", label: "Email the user" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = [];
|
||||
harness.api.session.form.reply = async (input) => {
|
||||
answers.push(input);
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
const [request] = session.getPendingPermissions();
|
||||
expect(request.input).toMatchObject({ questions: [{ header: "Fixes", multiSelect: true }] });
|
||||
await session.respondToPermission("question", {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: { Fixes: "Delete both, fix code, Email the user, Also check the logs" },
|
||||
},
|
||||
});
|
||||
expect(answers).toEqual([
|
||||
{
|
||||
sessionID: "session",
|
||||
formID: "question",
|
||||
answer: { q0: ["delete-and-fix", "email", "Also check the logs"] },
|
||||
},
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects typed text on a multi-select question that has no typed answer", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.api.session.form.list = async () => [
|
||||
{
|
||||
id: "question",
|
||||
sessionID: "session",
|
||||
title: "Questions",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Fixes",
|
||||
type: "multiselect",
|
||||
options: [{ value: "delete", label: "Delete both" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = [];
|
||||
harness.api.session.form.reply = async (input) => {
|
||||
answers.push(input);
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
await expect(
|
||||
session.respondToPermission("question", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Fixes: "Delete both, Something else" } },
|
||||
}),
|
||||
).rejects.toThrow("Invalid answer for OpenCode question q0");
|
||||
expect(answers).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode v2 permission routing", () => {
|
||||
|
||||
@@ -103,7 +103,7 @@ export class SessionPermissions {
|
||||
header: field.title ?? field.key,
|
||||
question: field.description ?? field.title ?? field.key,
|
||||
options: "options" in field ? field.options : undefined,
|
||||
multiple: field.type === "multiselect",
|
||||
multiSelect: field.type === "multiselect",
|
||||
allowOther: "custom" in field && field.custom === true,
|
||||
})),
|
||||
},
|
||||
@@ -137,8 +137,8 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu
|
||||
);
|
||||
};
|
||||
if (field.type === "multiselect") {
|
||||
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string"))
|
||||
return value.map(labelValue);
|
||||
const selected = multiSelectAnswer(field, value);
|
||||
if (selected) return selected.map(labelValue);
|
||||
} else if (field.type === "string" && typeof value === "string") {
|
||||
return labelValue(value);
|
||||
} else if (field.type === "boolean") {
|
||||
@@ -156,3 +156,38 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu
|
||||
}
|
||||
throw new Error(`Invalid answer for OpenCode question ${field.key}`);
|
||||
}
|
||||
|
||||
type MultiSelectField = Extract<FormInfo["fields"][number], { type: "multiselect" }>;
|
||||
|
||||
function multiSelectAnswer(field: MultiSelectField, value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string"))
|
||||
return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
const { selected, custom } = splitJoinedLabels(
|
||||
value,
|
||||
field.options.map((option) => option.label),
|
||||
);
|
||||
if (custom === null) return selected;
|
||||
return field.custom === true ? [...selected, custom] : undefined;
|
||||
}
|
||||
|
||||
// The shared question card sends a multi-select answer as one comma-joined string in
|
||||
// click order. Consume exact option labels from the front, longest first so a label
|
||||
// containing ", " wins over its prefix; any remaining text is the typed answer.
|
||||
function splitJoinedLabels(
|
||||
answer: string,
|
||||
labels: string[],
|
||||
): { selected: string[]; custom: string | null } {
|
||||
let remaining = answer;
|
||||
const selected: string[] = [];
|
||||
while (remaining.length > 0) {
|
||||
const label = labels
|
||||
.filter((candidate) => !selected.includes(candidate))
|
||||
.sort((left, right) => right.length - left.length)
|
||||
.find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `));
|
||||
if (!label) break;
|
||||
selected.push(label);
|
||||
remaining = remaining === label ? "" : remaining.slice(label.length + 2);
|
||||
}
|
||||
return { selected, custom: remaining || null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user