diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts index 50a6d98549..69da960c5c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -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[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[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", () => { 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 176fc455be..96a1bc7327 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -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; + +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 }; +}