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>
This commit is contained in:
paseo-bot[bot]
2026-10-05 16:05:32 +02:00
co-authored by Grant Ammons
parent 7ae1ffc913
commit 85fdc875f6
4 changed files with 59 additions and 37 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. */
@@ -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>();
@@ -163,31 +164,10 @@ function multiSelectAnswer(field: MultiSelectField, value: unknown): string[] |
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string"))
return value;
if (typeof value !== "string") return undefined;
const { selected, custom } = splitJoinedLabels(
const { selected, custom } = splitMultiSelectAnswer(
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 };
}
@@ -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 };
}