From f5094f9719a466dbc42884c3a2b495743733fab4 Mon Sep 17 00:00:00 2001 From: Sultan U <192680428+QRpeachKZ@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:11:03 +0700 Subject: [PATCH 1/5] fix(opencode): keep ACP question choices and fold the custom answer OpenCode 2.1.4 sends an ACP elicitation form with a qN choice field (choices in oneOf) and a qN_custom companion for free-form answers. The generic ACP mapping only reads enum and turns every property into its own question, so the card showed two option-less steps: the question and "Type your own answer". Recognize OpenCode's complete form shape at the provider flavor boundary, fold the companion into its question, read titled oneOf/items.anyOf choices, and restore the selected value or typed text on reply. Unrelated forms keep the generic fallback. --- .../Adapters/AcpAdapterV2.test.ts | 93 +++++++- .../src/server/adapter.ts | 12 +- .../src/server/opencodeAcp.test.ts | 212 ++++++++++++++++++ .../src/server/opencodeAcp.ts | 202 +++++++++++++++++ 4 files changed, 509 insertions(+), 10 deletions(-) create mode 100644 packages/provider-acp-registry/src/server/opencodeAcp.test.ts create mode 100644 packages/provider-acp-registry/src/server/opencodeAcp.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index a919726c9a56e..fdede7797a5e4 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -97,6 +97,11 @@ import { const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); const DROID_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({ agentId: "factory-droid" }); +const OPENCODE_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({ + source: "local", + commandPath: "/usr/local/bin/opencode", + commandArgs: ["acp"], +}); const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); @@ -546,7 +551,7 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - // Factory Droid 0.243.0 sends an optional text companion for every AskUser choice. + // Droid 0.243.0 and OpenCode 2.1 pair each choice with an optional free-form companion field. const droidChoice = { type: "string", title: "Format", @@ -577,6 +582,41 @@ describe("AcpAdapterV2", () => { ], })); + // OpenCode 2.1 pairs each qN choice with a qN_custom free-form companion. + const openCodeSchema = { + type: "object", + properties: { + q0: { + type: "string", + title: "Color preference", + description: "Which color do you prefer?", + oneOf: [ + { const: "red", title: "Red", description: "Choose the color red" }, + { const: "blue", title: "Blue" }, + ], + default: "red", + }, + q0_custom: { + type: "string", + title: "Color preference (other)", + description: "Type your own answer", + minLength: 1, + }, + }, + }; + const openCodeQuestions = [ + { + id: "q0", + header: "Color preference", + question: "Which color do you prefer?", + options: [ + { label: "Red", description: "Choose the color red", value: "red" }, + { label: "Blue", description: "Blue", value: "blue" }, + ], + allowCustomAnswer: true, + }, + ]; + // regression: #20417 Droid AskUser answers were sent as a cancel it.live.each([ { @@ -622,11 +662,46 @@ describe("AcpAdapterV2", () => { answers: { name: "Ada" }, result: { action: "accept", content: { name: "Ada" } }, }, - ])("Droid elicitation $scenario", ({ schema, questions, answers, result, withAttachments }) => - Effect.gen(function* () { + { + scenario: "OpenCode keeps the selected option and hides the custom companion", + settings: OPENCODE_SETTINGS, + schema: openCodeSchema, + questions: openCodeQuestions, + answers: { q0: "blue" }, + result: { action: "accept", content: { q0: "blue" } }, + }, + { + scenario: "OpenCode sends a typed answer to the custom companion", + settings: OPENCODE_SETTINGS, + schema: openCodeSchema, + questions: openCodeQuestions, + answers: { q0: "green" }, + result: { action: "accept", content: { q0_custom: "green" } }, + }, + { + scenario: "OpenCode cancels without accepting answers", + settings: OPENCODE_SETTINGS, + schema: openCodeSchema, + questions: openCodeQuestions, + answers: undefined, + result: { action: "cancel" }, + }, + { + scenario: "OpenCode falls back for an unrelated form", + settings: OPENCODE_SETTINGS, + schema: { type: "object", properties: { name: { type: "string" } } }, + questions: [{ id: "name", header: "Question 1", question: "Your answer?", options: [] }], + answers: { name: "Ada" }, + result: { action: "accept", content: { name: "Ada" } }, + }, + ])("Registry elicitation $scenario", (fixture) => { + const { schema, questions, answers, result } = fixture; + const withAttachments = "withAttachments" in fixture && fixture.withAttachments; + const settings = "settings" in fixture ? fixture.settings : DROID_SETTINGS; + return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-droid-form-" }); + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-registry-form-" }); const statusPath = path.join(directory, "status.json"); const expectedResult = typeof result === "function" ? result(directory) : result; let preparedAnswers: ProviderUserInputAnswers | undefined = answers; @@ -730,7 +805,7 @@ describe("AcpAdapterV2", () => { const adapter = yield* makeAcpRegistryAdapterV2({ instanceId, selfInvocation: yield* resolveSelfInvocation(), - settings: DROID_SETTINGS, + settings, environment: {}, makeRuntime: makeAcpReplayRuntime({ transcript, @@ -746,7 +821,7 @@ describe("AcpAdapterV2", () => { Layer.mock(AcpRegistryCatalog)({ resolve: () => Effect.die("runtime is injected") }), ), ); - const threadId = ThreadId.make("droid-form-thread"); + const threadId = ThreadId.make("registry-form-thread"); const modelSelection = { instanceId, model: "default" } as const; const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", @@ -783,7 +858,7 @@ describe("AcpAdapterV2", () => { ), ); if (item.type !== "turn_item.updated" || item.turnItem.type !== "user_input_request") - return yield* Effect.die("Expected Droid question"); + return yield* Effect.die("Expected a user input question"); assert.deepEqual(item.turnItem.questions, questions); yield* runtime.respondToRuntimeRequest({ requestId: item.turnItem.requestId, @@ -794,8 +869,8 @@ describe("AcpAdapterV2", () => { Stream.runHead, ); assert.equal(Option.getOrThrow(terminal).status, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); + }).pipe(Effect.provide(layerTest), Effect.scoped); + }); it.live.each(["failed", "recovered", "cancelled"] as const)( "projects Mistral retry notices and their %s outcome", diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 26a5319c12583..8818516e79b83 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -9,6 +9,7 @@ import { extractDevinSubagentUpdate, } from "./devinAcp.ts"; import { extractDroidElicitationForm } from "./droidAcp.ts"; +import { extractOpenCodeElicitationForm } from "./opencodeAcp.ts"; import { copilotClientCapabilitiesMeta, extractCopilotSubagentUpdate, @@ -186,6 +187,13 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu const startupKey = options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; const isDevin = registryAgentId === "devin"; + const isOpenCode = + registryAgentId === "opencode" || + registryAgentId === "opencode-v2" || + (options.settings.source === "local" && + /^opencode(?:-v2)?(\.exe|\.cmd)?$/i.test( + options.settings.commandPath.split(/[\\/]/).pop() ?? "", + )); // Local installs launch the `copilot` binary directly with no registry id. const isCopilot = registryAgentId === "github-copilot-cli" || @@ -215,7 +223,9 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" ? { extractElicitationForm: extractDroidElicitationForm } - : {}), + : isOpenCode + ? { extractElicitationForm: extractOpenCodeElicitationForm } + : {}), ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), diff --git a/packages/provider-acp-registry/src/server/opencodeAcp.test.ts b/packages/provider-acp-registry/src/server/opencodeAcp.test.ts new file mode 100644 index 0000000000000..86c940d08394b --- /dev/null +++ b/packages/provider-acp-registry/src/server/opencodeAcp.test.ts @@ -0,0 +1,212 @@ +import type { AcpAdapterV2Flavor } from "@t3tools/provider-acp/server/adapter"; +import { describe, expect, it } from "vite-plus/test"; + +import { extractOpenCodeElicitationForm } from "./opencodeAcp.ts"; + +type ElicitationRequest = Parameters>[0]; + +const request = (properties: Record): ElicitationRequest => + ({ + mode: "form", + sessionId: "session-id", + message: "Questions", + requestedSchema: { type: "object", properties }, + }) as ElicitationRequest; + +const colorQuestion = { + type: "string", + title: "Color preference", + description: "Which color do you prefer?", + oneOf: [ + { const: "red", title: "Red", description: "Choose the color red" }, + { const: "blue", title: "Blue" }, + ], + default: "red", +}; +const colorCompanion = { + type: "string", + title: "Color preference (other)", + description: "Type your own answer", + minLength: 1, +}; + +describe("OpenCode ACP question form", () => { + it("folds the custom-answer companion into its question", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.questions).toEqual([ + { + id: "q0", + header: "Color preference", + question: "Which color do you prefer?", + options: [ + { label: "Red", description: "Choose the color red", value: "red" }, + { label: "Blue", description: "Blue", value: "blue" }, + ], + allowCustomAnswer: true, + }, + ]); + }); + + it("returns the selected option value untouched", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({ q0: "blue" })).toEqual({ + action: "accept", + content: { q0: "blue" }, + }); + }); + + it("sends a free-form answer to the companion field", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({ q0: "green" })).toEqual({ + action: "accept", + content: { q0_custom: "green" }, + }); + }); + + it("keeps multi-select choices and custom answers together", () => { + const form = extractOpenCodeElicitationForm( + request({ + q0: { + type: "array", + title: "Colors", + description: "Which colors?", + items: { + anyOf: [ + { const: "red", title: "Red" }, + { const: "blue", title: "Blue" }, + ], + }, + }, + q0_custom: { type: "string", description: "Add your own answer" }, + }), + ); + + expect(form?.questions[0]).toMatchObject({ + id: "q0", + multiSelect: true, + allowCustomAnswer: true, + options: [ + { label: "Red", value: "red" }, + { label: "Blue", value: "blue" }, + ], + }); + expect(form?.respond({ q0: ["red", "green"] })).toEqual({ + action: "accept", + content: { q0: ["red"], q0_custom: "green" }, + }); + }); + + it("keeps multi-select attachment text alongside the selected values", () => { + const form = extractOpenCodeElicitationForm( + request({ + q0: { + type: "array", + title: "Colors", + description: "Which colors?", + items: { + anyOf: [ + { const: "red", title: "Red" }, + { const: "blue", title: "Blue" }, + ], + }, + }, + q0_custom: { type: "string", description: "Add your own answer" }, + }), + ); + const suffix = '\n\nAttached image "shot.png": "/tmp/shot.png"'; + + expect(form?.respond({ q0: ["red", `blue${suffix}`] })).toEqual({ + action: "accept", + content: { q0: ["red", "blue"], q0_custom: `Blue${suffix}` }, + }); + }); + + it("leaves a text-only form to the generic mapping", () => { + expect( + extractOpenCodeElicitationForm( + request({ + q0: { + type: "string", + title: "Name", + description: "What should I call you?", + }, + }), + ), + ).toBeUndefined(); + }); + + it("parses several questions independently", () => { + const form = extractOpenCodeElicitationForm( + request({ + q0: colorQuestion, + q0_custom: colorCompanion, + q1: { type: "string", title: "Notes", description: "Anything else?" }, + }), + ); + + expect(form?.questions.map((question) => question.id)).toEqual(["q0", "q1"]); + expect(form?.respond({ q0: "red", q1: "Later" })).toEqual({ + action: "accept", + content: { q0: "red", q1: "Later" }, + }); + }); + + it("keeps attachment text with the selected option label", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + const suffix = '\n\nAttached image "shot.png": "/tmp/shot.png"'; + + expect(form?.respond({ q0: `blue${suffix}` })).toEqual({ + action: "accept", + content: { q0: "blue", q0_custom: `Blue${suffix}` }, + }); + }); + + it("cancels instead of accepting an empty answer", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({})).toEqual({ action: "cancel" }); + expect(form?.respond({ q0: " " })).toEqual({ action: "cancel" }); + }); + + it("cancels a multi-value answer to a single-select question", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({ q0: ["red", "blue"] })).toEqual({ action: "cancel" }); + }); + + it("leaves unrelated and incomplete forms to the generic mapping", () => { + expect( + extractOpenCodeElicitationForm(request({ name: { type: "string", title: "Name" } })), + ).toBeUndefined(); + expect(extractOpenCodeElicitationForm(request({ q0_custom: colorCompanion }))).toBeUndefined(); + expect( + extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion, extra: { type: "string" } }), + ), + ).toBeUndefined(); + // A malformed choice must not silently become a text-only question. + expect( + extractOpenCodeElicitationForm( + request({ + q0: { type: "string", title: "Broken", oneOf: [{ const: "red" }] }, + q0_custom: colorCompanion, + }), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/provider-acp-registry/src/server/opencodeAcp.ts b/packages/provider-acp-registry/src/server/opencodeAcp.ts new file mode 100644 index 0000000000000..e7abd99e37ed3 --- /dev/null +++ b/packages/provider-acp-registry/src/server/opencodeAcp.ts @@ -0,0 +1,202 @@ +import { type OrchestrationV2UserInputQuestion, TrimmedNonEmptyString } from "@t3tools/contracts"; +import type { AcpAdapterV2Flavor } from "@t3tools/provider-acp/server/adapter"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +const isForm = Schema.is( + Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Unknown) }), +); +const choice = Schema.Struct({ + const: Schema.String, + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), +}); +const isChoiceQuestion = Schema.is( + Schema.Struct({ + type: Schema.Literal("string"), + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + oneOf: Schema.Array(choice), + }), +); +const isMultiChoiceQuestion = Schema.is( + Schema.Struct({ + type: Schema.Literal("array"), + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + items: Schema.Struct({ anyOf: Schema.Array(choice) }), + }), +); +const isTextQuestion = Schema.is( + Schema.Struct({ + type: Schema.Literal("string"), + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + }), +); +// The companion only carries a free-form answer, never a title we can show. +const isCustomCompanion = Schema.is( + Schema.Struct({ + type: Schema.Literal("string"), + description: Schema.Literals(["Type your own answer", "Add your own answer"]), + }), +); + +const questionId = /^q\d+$/; +const companionSuffix = "_custom"; + +type Choice = { label: string; description: string; value: string }; + +// Intake appends file references to a user's answer; keep them with the custom text. +const splitAttachments = (value: string) => { + const marker = value.indexOf("\n\n"); + if (marker < 0) return undefined; + const suffix = value.slice(marker); + if (!/^\n\nAttached (?:image|file) "(?:\\.|[^"\\\r\n])*": "/.test(suffix)) return undefined; + return { prefix: value.slice(0, marker), suffix }; +}; + +type Option = OrchestrationV2UserInputQuestion["options"][number]; + +const findChoice = (options: ReadonlyArray