diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 24808690a98..02271ebc583 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -485,6 +485,12 @@ stable free-text option IDs. Legacy callers remain supported. Calls that supply both forms must describe the same complete form; partial forms remain invalid. The native recovery bridge uses the same projection for answer delivery. +OpenCode 1.18.34 native questions allow a written answer by default when `custom` +is omitted. The driver preserves that default and explicit `custom:false`; the +native flag takes precedence over the legacy `allowCustom` alias. Choice options +remain intact. Questions without options remain text-only, without a separate +custom-answer field. See the [pinned native question contract](https://github.com/anomalyco/opencode/blob/v1.18.34/packages/schema/src/v1/question.ts). + The server validates canonical answer constraints before persistence. Regex matching runs in isolated workers with a one-second deadline and at most four active workers. A timeout or capacity error leaves the question pending. The diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-question-normalization.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-question-normalization.test.ts new file mode 100644 index 00000000000..9969fc30c0b --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-question-normalization.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parsePaperclipQuestionResponse } from "../../contracts/question-set.js"; +import { normalizeOpenCodeQuestionSet } from "./opencode-server-driver.js"; + +const question = { id: "reference", header: "Reference", question: "Which reference should appear in the note?", + options: [{ label: "No reference", description: "Do not include a reference" }] }; +const response = { schema: "paperclip.question_response.v1", answers: { reference: { customText: "Use AMBERexact." } } }; + +describe("OpenCode native written-answer defaults", () => { + it.each([ + { name: "omitted native default", fields: {}, enabled: true }, + { name: "explicit native true", fields: { custom: true }, enabled: true }, + { name: "explicit native false", fields: { custom: false }, enabled: false }, + { name: "legacy alias true", fields: { allowCustom: true }, enabled: true }, + { name: "legacy alias false", fields: { allowCustom: false }, enabled: false }, + { name: "native opt-out takes precedence", fields: { custom: false, allowCustom: true }, enabled: false }, + ])("preserves $name without changing the supplied choice", ({ fields, enabled }) => { + const set = normalizeOpenCodeQuestionSet([{ ...question, ...fields }]); + expect(set.questions[0]).toMatchObject({ id: "reference", prompt: question.question, answerMode: "single_select", + options: [{ id: "option-1", label: "No reference", description: "Do not include a reference" }] }); + if (enabled) { + expect(set.questions[0].customAnswer).toEqual({ enabled: true, label: "Other", placeholder: "Enter another answer" }); + expect(parsePaperclipQuestionResponse(set, response)).toEqual(response); + } else { + expect(set.questions[0]).not.toHaveProperty("customAnswer"); + expect(() => parsePaperclipQuestionResponse(set, response)).toThrow("custom answers are not enabled"); + } + }); + it.each([undefined, true, false])("keeps a question without choices text-only (custom=%s)", custom => { + const set = normalizeOpenCodeQuestionSet([{ ...question, options: [], ...(custom === undefined ? {} : { custom }) }]); + expect(set.questions[0]).toMatchObject({ id: "reference", answerMode: "text" }); + expect(set.questions[0]).not.toHaveProperty("options"); + expect(set.questions[0]).not.toHaveProperty("customAnswer"); + expect(parsePaperclipQuestionResponse(set, { ...response, answers: { reference: { text: "Use AMBERexact." } } })) + .toEqual({ ...response, answers: { reference: { text: "Use AMBERexact." } } }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index f89dc89059c..58af69bad4b 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -1799,7 +1799,7 @@ describe("OpenCodeServerDriver", () => { await recovered!.session!.close({ reason: "recovery-test" }); }); - it("normalizes native question.asked events and replies with ordered OpenCode answer arrays", async () => { + it("preserves omitted native custom defaults and replies with ordered OpenCode answer arrays", async () => { await chmod(fixture, 0o755); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-")); const workspace = await mkdtemp( diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 45fb78ccefc..6576fe894e7 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2654,6 +2654,7 @@ export function normalizeOpenCodeQuestionSet( ? { description: text(option.description).slice(0, 4_000) } : {}), })); + const custom = question.custom ?? question.allowCustom ?? true; return { id: openCodeQuestionId(question, index), ...(text(question.header) @@ -2674,7 +2675,7 @@ export function normalizeOpenCodeQuestionSet( ? "multi_select" : "single_select", ...(options.length > 0 ? { options } : {}), - ...(question.custom === true || question.allowCustom === true + ...(options.length > 0 && custom === true ? { customAnswer: { enabled: true, diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index 3b03d4d52e2..38da64e96a1 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -41,7 +41,6 @@ function nativeQuestion() { description: "Deploy directly to production.", }, ], - custom: true, }, { id: "regions",