Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andGrant Ammons 85fdc875f6 refactor(server): share the question card's multi-select answer splitting
OpenCode v2 and OMP both read the question card's comma-joined
multi-select answer back into option labels. Keep that in one helper
next to the providers so the two cannot drift.

Co-authored-by: Grant Ammons <grant@grant.dev>
2026-10-05 16:05:32 +02:00
Grant Ammons 7ae1ffc913 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
2026-10-05 16:03:50 +02:00
5 changed files with 169 additions and 19 deletions
@@ -3,6 +3,7 @@ import type {
AgentPermissionResponse,
AgentProvider,
} from "../../agent-sdk-types.js";
import { splitMultiSelectAnswer } from "../question-card-answer.js";
import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js";
type UiRequest = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
@@ -176,21 +177,7 @@ function selectedAnswer(
return labels.includes(answer)
? { selected: [answer], custom: null }
: { selected: [], custom: answer };
// The shared question UI sends a comma-joined answer in click order. Consume
// exact option labels from the front; any remaining text is the Other answer.
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 };
return splitMultiSelectAnswer(answer, labels);
}
/** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */
@@ -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", () => {
@@ -7,6 +7,7 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
} from "../../../agent-sdk-types.js";
import { splitMultiSelectAnswer } from "../../question-card-answer.js";
export class SessionPermissions {
private readonly pending = new Map<string, AgentPermissionRequest>();
@@ -103,7 +104,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 +138,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 +157,17 @@ 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 } = splitMultiSelectAnswer(
value,
field.options.map((option) => option.label),
);
if (custom === null) return selected;
return field.custom === true ? [...selected, custom] : undefined;
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { splitMultiSelectAnswer } from "./question-card-answer.js";
describe("splitMultiSelectAnswer", () => {
const labels = ["Delete both", "Delete both, fix code", "Email the user"];
test("splits checked labels in click order", () => {
expect(splitMultiSelectAnswer("Email the user, Delete both", labels)).toEqual({
selected: ["Email the user", "Delete both"],
custom: null,
});
});
test("keeps a label that contains a comma as one selection", () => {
expect(splitMultiSelectAnswer("Delete both, fix code, Email the user", labels)).toEqual({
selected: ["Delete both, fix code", "Email the user"],
custom: null,
});
});
test("returns trailing text as the typed answer", () => {
expect(splitMultiSelectAnswer("Delete both, Also check the logs", labels)).toEqual({
selected: ["Delete both"],
custom: "Also check the logs",
});
expect(splitMultiSelectAnswer("Only typed text", labels)).toEqual({
selected: [],
custom: "Only typed text",
});
});
});
@@ -0,0 +1,23 @@
/**
* The shared question card sends a multi-select answer as one string: the checked option
* labels in click order, then any typed answer, joined with ", ". Consume exact option
* labels from the front, longest first so a label containing ", " wins over its prefix;
* any remaining text is the typed answer.
*/
export function splitMultiSelectAnswer(
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 };
}