From 85fdc875f61980cab1e0daec8d9301240f69dc2b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:05:32 +0200 Subject: [PATCH] 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 --- .../server/agent/providers/omp/question-ui.ts | 17 ++-------- .../providers/opencode/v2/permissions.ts | 24 ++------------ .../providers/question-card-answer.test.ts | 32 +++++++++++++++++++ .../agent/providers/question-card-answer.ts | 23 +++++++++++++ 4 files changed, 59 insertions(+), 37 deletions(-) create mode 100644 packages/server/src/server/agent/providers/question-card-answer.test.ts create mode 100644 packages/server/src/server/agent/providers/question-card-answer.ts diff --git a/packages/server/src/server/agent/providers/omp/question-ui.ts b/packages/server/src/server/agent/providers/omp/question-ui.ts index 4fdb37f61c..2a90d7288e 100644 --- a/packages/server/src/server/agent/providers/omp/question-ui.ts +++ b/packages/server/src/server/agent/providers/omp/question-ui.ts @@ -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; @@ -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. */ diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index 96a1bc7327..ca5830ecd1 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -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(); @@ -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 }; -} diff --git a/packages/server/src/server/agent/providers/question-card-answer.test.ts b/packages/server/src/server/agent/providers/question-card-answer.test.ts new file mode 100644 index 0000000000..c7049fa7dc --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.test.ts @@ -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", + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/question-card-answer.ts b/packages/server/src/server/agent/providers/question-card-answer.ts new file mode 100644 index 0000000000..01513869c9 --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.ts @@ -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 }; +}