diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index a58a83bffa349..1ce481cee2dff 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -101,6 +101,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(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), @@ -1537,7 +1542,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", @@ -1568,6 +1573,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([ { @@ -1613,11 +1653,65 @@ 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 multi-select keeps values and custom text", + settings: OPENCODE_SETTINGS, + schema: { + ...openCodeSchema, + properties: { + q0: { + type: "array", + title: "Color preference", + description: "Which color do you prefer?", + items: { anyOf: openCodeSchema.properties.q0.oneOf }, + }, + q0_custom: { type: "string", description: "Add your own answer" }, + }, + }, + questions: openCodeQuestions.map((question) => ({ ...question, multiSelect: true })), + answers: { q0: ["blue", "green"] }, + result: { action: "accept", content: { q0: ["blue"], 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; @@ -1721,7 +1815,7 @@ describe("AcpAdapterV2", () => { const adapter = yield* makeAcpRegistryAdapterV2({ instanceId, selfInvocation: yield* resolveSelfInvocation(), - settings: DROID_SETTINGS, + settings, environment: {}, makeRuntime: makeAcpReplayRuntime({ transcript, @@ -1737,7 +1831,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", @@ -1774,7 +1868,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, @@ -1785,8 +1879,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/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 134b64aa0cf3c..86363e1b8382c 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -2,7 +2,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -13,6 +12,8 @@ import { Command, Flag } from "effect/cli"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { generateSchemaOutput } from "./schemaTransform.ts"; + const V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.3"; const V1_SCHEMA_RELEASE = "schema-v1.21.0"; @@ -127,306 +128,6 @@ const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* ( yield* fs.writeFileString(metaOutputPath, metaOutput); }); -function collectSchemaEntries( - chunk: string, -): ReadonlyArray<{ readonly name: string; readonly code: string }> { - const lines = chunk - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("//")); - const entries: Array<{ name: string; code: string }> = []; - - for (let index = 0; index < lines.length; index += 1) { - const typeLine = lines[index]; - if (!typeLine?.startsWith("export type ")) { - continue; - } - - const constLine = lines[index + 1]; - if (!constLine?.startsWith("export const ")) { - throw new Error(`Malformed generator output near: ${typeLine}`); - } - - const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine); - if (!match?.[1]) { - throw new Error(`Could not extract schema name from: ${typeLine}`); - } - - entries.push({ - name: match[1], - code: `${typeLine}\n${constLine}`, - }); - index += 1; - } - - return entries; -} - -function isJsonArray(value: Schema.Json | undefined): value is ReadonlyArray { - return Array.isArray(value); -} - -function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { - if (isJsonArray(value)) { - return value.map(normalizeSchemaKeywords); - } - if (value === null || typeof value !== "object") { - return value; - } - - const normalizedEntries = Object.entries(value).map(([key, child]) => [ - key, - normalizeSchemaKeywords(child), - ]); - const normalizedObject = Object.fromEntries(normalizedEntries) as Record; - // ACP's forward-compatible variants exclude known discriminator values. - // The Effect generator does not support `not`; express this exact exclusion - // as a string pattern while preserving validation of the known variants. - if (normalizedObject.not !== undefined) { - const exclusion = normalizedObject.not; - if ( - exclusion === null || - typeof exclusion !== "object" || - isJsonArray(exclusion) || - !isJsonArray(exclusion.anyOf) - ) { - throw new Error("Unsupported ACP schema exclusion"); - } - const excluded = exclusion.anyOf.map((variant) => { - if (variant === null || typeof variant !== "object" || isJsonArray(variant)) - throw new Error("Invalid ACP excluded variant"); - const properties = variant.properties; - if (properties === null || typeof properties !== "object" || isJsonArray(properties)) - throw new Error("Invalid ACP excluded properties"); - const entries = Object.entries(properties); - const entry = entries[0]; - if (entries.length !== 1 || entry === undefined) throw new Error("Invalid ACP discriminator"); - const [key, schema] = entry; - if ( - schema === null || - typeof schema !== "object" || - isJsonArray(schema) || - typeof schema.const !== "string" - ) - throw new Error("Invalid ACP discriminator value"); - return { key, value: schema.const }; - }); - const discriminator = excluded[0]?.key; - const properties = normalizedObject.properties; - if ( - discriminator === undefined || - excluded.some((entry) => entry.key !== discriminator) || - properties === null || - typeof properties !== "object" || - isJsonArray(properties) - ) - throw new Error("Unsupported ACP discriminator exclusion"); - const property = properties[discriminator]; - if (property === null || typeof property !== "object" || isJsonArray(property)) - throw new Error("Missing ACP discriminator property"); - normalizedObject.properties = { - ...properties, - [discriminator]: { - ...property, - pattern: `^(?!(?:${excluded.map(({ value }) => RegExp.escape(value)).join("|")})$)[\\s\\S]*$`, - }, - }; - delete normalizedObject.not; - } - if (normalizedObject.unevaluatedProperties !== undefined) { - if (normalizedObject.unevaluatedProperties !== true) - throw new Error("Unsupported ACP unevaluated properties constraint"); - normalizedObject.additionalProperties = true; - delete normalizedObject.unevaluatedProperties; - } - if (isJsonArray(normalizedObject.oneOf)) { - const discriminator = normalizedObject.discriminator; - const propertyName = - discriminator !== null && typeof discriminator === "object" && !isJsonArray(discriminator) - ? discriminator.propertyName - : undefined; - const values = normalizedObject.oneOf.map((entry) => { - if (entry === null || typeof entry !== "object" || isJsonArray(entry)) return undefined; - if (typeof entry.const === "string") return entry.const; - const properties = entry.properties; - if ( - typeof propertyName !== "string" || - properties === null || - typeof properties !== "object" || - isJsonArray(properties) - ) - return undefined; - const property = properties[propertyName]; - return property !== null && typeof property === "object" && !isJsonArray(property) - ? property.const - : undefined; - }); - if ( - values.every((entry) => typeof entry === "string") && - new Set(values).size === values.length - ) { - normalizedObject.anyOf = normalizedObject.oneOf; - delete normalizedObject.oneOf; - } - } - const typeValue = normalizedObject.type; - - if (!isJsonArray(typeValue)) { - return normalizedObject; - } - - const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string"); - if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) { - return normalizedObject; - } - - const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null"); - if (nonNullTypes.length !== 1) { - return normalizedObject; - } - const nonNullType = nonNullTypes[0]!; - - const nextObject: Record = {}; - for (const [key, child] of Object.entries(normalizedObject)) { - if (key !== "type") { - nextObject[key] = child; - } - } - - return { - anyOf: [ - { - ...nextObject, - type: nonNullType, - }, - { type: "null" }, - ], - }; -} - -const isAnnotationKeyword = (key: string): boolean => - ["description", "title", "$comment", "default", "examples", "discriminator"].includes(key) || - key.startsWith("x-"); - -// Distribute object intersections over unions before handing schemas to Effect. -// ACP uses sibling `properties`/`anyOf` and `allOf` references for elicitation scopes. -function flattenCompositions( - value: Schema.Json, - definitions: Readonly>, -): Schema.Json { - if (isJsonArray(value)) return value.map((child) => flattenCompositions(child, definitions)); - if (value === null || typeof value !== "object") return value; - const normalized = Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, flattenCompositions(child, definitions)]), - ); - const { allOf, anyOf, ...base } = normalized; - const merge = (left: Schema.Json, right: Schema.Json): Schema.Json => { - if ( - left === null || - typeof left !== "object" || - isJsonArray(left) || - right === null || - typeof right !== "object" || - isJsonArray(right) - ) - throw new Error("Unsupported ACP schema intersection"); - if (typeof right.$ref === "string") { - // Keep references when the sibling only describes the referenced schema. - // Inlining these repeats entire protocol unions in every request schema. - if (Object.keys(left).every(isAnnotationKeyword)) return { ...right, ...left }; - const target = definitions[right.$ref.replace("#/$defs/", "")]; - if (target === undefined) throw new Error(`Missing ACP definition ${right.$ref}`); - return merge(left, flattenCompositions(normalizeSchemaKeywords(target), definitions)); - } - if (isJsonArray(left.anyOf)) return { anyOf: left.anyOf.map((branch) => merge(branch, right)) }; - if (isJsonArray(right.anyOf)) - return { anyOf: right.anyOf.map((branch) => merge(left, branch)) }; - for (const key of Object.keys(left)) { - if (key === "properties" || key === "required" || isAnnotationKeyword(key)) continue; - if (right[key] !== undefined && JSON.stringify(left[key]) !== JSON.stringify(right[key])) { - throw new Error(`Unsupported conflicting ACP schema constraint: ${key}`); - } - } - const result = { ...left, ...right }; - if ( - left.properties && - right.properties && - typeof left.properties === "object" && - !isJsonArray(left.properties) && - typeof right.properties === "object" && - !isJsonArray(right.properties) - ) { - for (const key of Object.keys(left.properties)) { - if ( - right.properties[key] !== undefined && - JSON.stringify(left.properties[key]) !== JSON.stringify(right.properties[key]) - ) { - throw new Error(`Unsupported overlapping ACP schema property: ${key}`); - } - } - result.properties = { ...left.properties, ...right.properties }; - } - if (isJsonArray(left.required) || isJsonArray(right.required)) { - result.required = [ - ...new Set([ - ...(isJsonArray(left.required) ? left.required : []), - ...(isJsonArray(right.required) ? right.required : []), - ]), - ]; - } - return result; - }; - let combined: Schema.Json = base; - if (isJsonArray(allOf)) for (const part of allOf) combined = merge(combined, part); - if (isJsonArray(anyOf) && !isJsonArray(allOf) && Object.keys(base).every(isAnnotationKeyword)) { - return { ...base, anyOf }; - } - return isJsonArray(anyOf) ? merge(combined, { anyOf }) : combined; -} - -function generateSchemaOutput( - upstreamSchema: { readonly $defs: Readonly> }, - release: string, -): { readonly output: string; readonly count: number } { - const normalizedDefinitions = Object.fromEntries( - Object.entries(upstreamSchema.$defs).map(([name, schema]) => [ - name, - flattenCompositions(normalizeSchemaKeywords(schema), upstreamSchema.$defs), - ]), - ); - const sortedEntries = Object.entries(normalizedDefinitions).toSorted(([left], [right]) => - left.localeCompare(right), - ); - const generatedEntries = new Map(); - const generator = makeJsonSchemaGenerator(); - - for (const [name, schema] of sortedEntries) { - generator.addSchema(name, schema as never); - } - - const generated = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim(); - if (generated.length > 0) { - for (const entry of collectSchemaEntries(generated)) { - if (!generatedEntries.has(entry.name)) { - generatedEntries.set(entry.name, entry.code); - } - } - } - - return { - output: [ - "// This file is generated by the effect-acp package. Do not edit manually.", - `// Current ACP schema release: ${release}`, - "", - 'import * as Schema from "effect/Schema";', - "", - [...generatedEntries.values()].join("\n\n"), - "", - ].join("\n"), - count: generatedEntries.size, - }; -} - const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: boolean) { const { upstreamMetaPath, upstreamSchemaPath, upstreamV1MetaPath, upstreamV1SchemaPath } = yield* getGeneratedPaths(); diff --git a/packages/effect-acp/scripts/schemaTransform.test.ts b/packages/effect-acp/scripts/schemaTransform.test.ts new file mode 100644 index 0000000000000..8ae76e14867a1 --- /dev/null +++ b/packages/effect-acp/scripts/schemaTransform.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + flattenCompositions, + generateSchemaOutput, + normalizeSchemaKeywords, +} from "./schemaTransform.ts"; + +// Regression setup: ACP's `TitledMultiSelectItems` uses `anyOf` as a property name. The +// traversal must treat `properties` keys as property names, never as keywords. +const upstreamDefs = { + EnumOption: { + type: "object", + properties: { + const: { type: "string", description: "The value to submit." }, + title: { type: "string" }, + }, + required: ["const", "title"], + }, + TitledMultiSelectItems: { + type: "object", + properties: { + anyOf: { + type: "array", + items: { $ref: "#/$defs/EnumOption" }, + minItems: 1, + description: "Titled enum options.", + }, + }, + required: ["anyOf"], + }, +} as const; + +describe("ACP schema transform", () => { + it("keeps a property named anyOf through the schema traversal", () => { + const flattened = flattenCompositions( + normalizeSchemaKeywords(upstreamDefs.TitledMultiSelectItems), + upstreamDefs, + ); + + if (typeof flattened !== "object" || flattened === null || Array.isArray(flattened)) { + throw new Error("Expected an object schema"); + } + const properties = (flattened as { readonly properties?: Schema.Json }).properties; + if (typeof properties !== "object" || properties === null || Array.isArray(properties)) { + throw new Error("Expected an object schema with properties"); + } + expect(Object.keys(properties)).toContain("anyOf"); + }); + + it("generates a schema whose anyOf property survives instead of becoming Schema.Never", () => { + const { output } = generateSchemaOutput({ $defs: { ...upstreamDefs } }, "schema-test-release"); + + expect(output).toContain("TitledMultiSelectItems"); + expect(output).toMatch(/"anyOf": Schema\.Array\(EnumOption\)/); + expect(output).not.toContain("Schema.Never"); + }); +}); diff --git a/packages/effect-acp/scripts/schemaTransform.ts b/packages/effect-acp/scripts/schemaTransform.ts new file mode 100644 index 0000000000000..23c2704b7eb5e --- /dev/null +++ b/packages/effect-acp/scripts/schemaTransform.ts @@ -0,0 +1,328 @@ +import * as Schema from "effect/Schema"; + +import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; + +function collectSchemaEntries( + chunk: string, +): ReadonlyArray<{ readonly name: string; readonly code: string }> { + const lines = chunk + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("//")); + const entries: Array<{ name: string; code: string }> = []; + + for (let index = 0; index < lines.length; index += 1) { + const typeLine = lines[index]; + if (!typeLine?.startsWith("export type ")) { + continue; + } + + const constLine = lines[index + 1]; + if (!constLine?.startsWith("export const ")) { + throw new Error(`Malformed generator output near: ${typeLine}`); + } + + const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine); + if (!match?.[1]) { + throw new Error(`Could not extract schema name from: ${typeLine}`); + } + + entries.push({ + name: match[1], + code: `${typeLine}\n${constLine}`, + }); + index += 1; + } + + return entries; +} + +function isJsonArray(value: Schema.Json | undefined): value is ReadonlyArray { + return Array.isArray(value); +} + +// Keywords whose values are literal JSON data, not schemas. +const DATA_KEYWORDS = new Set(["const", "default", "enum", "examples"]); + +// Applies `transform` to every subschema of a schema object. `properties` maps names to +// schemas, so its keys stay property names: ACP has properties named `anyOf`, `oneOf`, and +// `type` that must not be read as keywords. Literal data values are left untouched. +function mapSubschemas( + value: { readonly [key: string]: Schema.Json }, + transform: (schema: Schema.Json) => Schema.Json, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => { + if (DATA_KEYWORDS.has(key)) return [key, child]; + if ( + key === "properties" && + child !== null && + typeof child === "object" && + !isJsonArray(child) + ) { + return [ + key, + Object.fromEntries( + Object.entries(child).map(([name, schema]) => [name, transform(schema)]), + ), + ]; + } + return [key, isJsonArray(child) ? child.map(transform) : transform(child)]; + }), + ); +} + +export function normalizeSchemaKeywords(value: Schema.Json): Schema.Json { + if (isJsonArray(value)) { + return value.map(normalizeSchemaKeywords); + } + if (value === null || typeof value !== "object") { + return value; + } + + const normalizedObject = mapSubschemas(value, normalizeSchemaKeywords); + // ACP's forward-compatible variants exclude known discriminator values. + // The Effect generator does not support `not`; express this exact exclusion + // as a string pattern while preserving validation of the known variants. + if (normalizedObject.not !== undefined) { + const exclusion = normalizedObject.not; + if ( + exclusion === null || + typeof exclusion !== "object" || + isJsonArray(exclusion) || + !isJsonArray(exclusion.anyOf) + ) { + throw new Error("Unsupported ACP schema exclusion"); + } + const excluded = exclusion.anyOf.map((variant) => { + if (variant === null || typeof variant !== "object" || isJsonArray(variant)) + throw new Error("Invalid ACP excluded variant"); + const properties = variant.properties; + if (properties === null || typeof properties !== "object" || isJsonArray(properties)) + throw new Error("Invalid ACP excluded properties"); + const entries = Object.entries(properties); + const entry = entries[0]; + if (entries.length !== 1 || entry === undefined) throw new Error("Invalid ACP discriminator"); + const [key, schema] = entry; + if ( + schema === null || + typeof schema !== "object" || + isJsonArray(schema) || + typeof schema.const !== "string" + ) + throw new Error("Invalid ACP discriminator value"); + return { key, value: schema.const }; + }); + const discriminator = excluded[0]?.key; + const properties = normalizedObject.properties; + if ( + discriminator === undefined || + excluded.some((entry) => entry.key !== discriminator) || + properties === null || + typeof properties !== "object" || + isJsonArray(properties) + ) + throw new Error("Unsupported ACP discriminator exclusion"); + const property = properties[discriminator]; + if (property === null || typeof property !== "object" || isJsonArray(property)) + throw new Error("Missing ACP discriminator property"); + normalizedObject.properties = { + ...properties, + [discriminator]: { + ...property, + pattern: `^(?!(?:${excluded.map(({ value }) => RegExp.escape(value)).join("|")})$)[\\s\\S]*$`, + }, + }; + delete normalizedObject.not; + } + if (normalizedObject.unevaluatedProperties !== undefined) { + if (normalizedObject.unevaluatedProperties !== true) + throw new Error("Unsupported ACP unevaluated properties constraint"); + normalizedObject.additionalProperties = true; + delete normalizedObject.unevaluatedProperties; + } + if (isJsonArray(normalizedObject.oneOf)) { + const discriminator = normalizedObject.discriminator; + const propertyName = + discriminator !== null && typeof discriminator === "object" && !isJsonArray(discriminator) + ? discriminator.propertyName + : undefined; + const values = normalizedObject.oneOf.map((entry) => { + if (entry === null || typeof entry !== "object" || isJsonArray(entry)) return undefined; + if (typeof entry.const === "string") return entry.const; + const properties = entry.properties; + if ( + typeof propertyName !== "string" || + properties === null || + typeof properties !== "object" || + isJsonArray(properties) + ) + return undefined; + const property = properties[propertyName]; + return property !== null && typeof property === "object" && !isJsonArray(property) + ? property.const + : undefined; + }); + if ( + values.every((entry) => typeof entry === "string") && + new Set(values).size === values.length + ) { + normalizedObject.anyOf = normalizedObject.oneOf; + delete normalizedObject.oneOf; + } + } + const typeValue = normalizedObject.type; + + if (!isJsonArray(typeValue)) { + return normalizedObject; + } + + const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string"); + if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) { + return normalizedObject; + } + + const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null"); + if (nonNullTypes.length !== 1) { + return normalizedObject; + } + const nonNullType = nonNullTypes[0]!; + + const nextObject: Record = {}; + for (const [key, child] of Object.entries(normalizedObject)) { + if (key !== "type") { + nextObject[key] = child; + } + } + + return { + anyOf: [ + { + ...nextObject, + type: nonNullType, + }, + { type: "null" }, + ], + }; +} + +const isAnnotationKeyword = (key: string): boolean => + ["description", "title", "$comment", "default", "examples", "discriminator"].includes(key) || + key.startsWith("x-"); + +// Distribute object intersections over unions before handing schemas to Effect. +// ACP uses sibling `properties`/`anyOf` and `allOf` references for elicitation scopes. +export function flattenCompositions( + value: Schema.Json, + definitions: Readonly>, +): Schema.Json { + if (isJsonArray(value)) return value.map((child) => flattenCompositions(child, definitions)); + if (value === null || typeof value !== "object") return value; + const normalized = mapSubschemas(value, (child) => flattenCompositions(child, definitions)); + const { allOf, anyOf, ...base } = normalized; + const merge = (left: Schema.Json, right: Schema.Json): Schema.Json => { + if ( + left === null || + typeof left !== "object" || + isJsonArray(left) || + right === null || + typeof right !== "object" || + isJsonArray(right) + ) + throw new Error("Unsupported ACP schema intersection"); + if (typeof right.$ref === "string") { + // Keep references when the sibling only describes the referenced schema. + // Inlining these repeats entire protocol unions in every request schema. + if (Object.keys(left).every(isAnnotationKeyword)) return { ...right, ...left }; + const target = definitions[right.$ref.replace("#/$defs/", "")]; + if (target === undefined) throw new Error(`Missing ACP definition ${right.$ref}`); + return merge(left, flattenCompositions(normalizeSchemaKeywords(target), definitions)); + } + if (isJsonArray(left.anyOf)) return { anyOf: left.anyOf.map((branch) => merge(branch, right)) }; + if (isJsonArray(right.anyOf)) + return { anyOf: right.anyOf.map((branch) => merge(left, branch)) }; + for (const key of Object.keys(left)) { + if (key === "properties" || key === "required" || isAnnotationKeyword(key)) continue; + if (right[key] !== undefined && JSON.stringify(left[key]) !== JSON.stringify(right[key])) { + throw new Error(`Unsupported conflicting ACP schema constraint: ${key}`); + } + } + const result = { ...left, ...right }; + if ( + left.properties && + right.properties && + typeof left.properties === "object" && + !isJsonArray(left.properties) && + typeof right.properties === "object" && + !isJsonArray(right.properties) + ) { + for (const key of Object.keys(left.properties)) { + if ( + right.properties[key] !== undefined && + JSON.stringify(left.properties[key]) !== JSON.stringify(right.properties[key]) + ) { + throw new Error(`Unsupported overlapping ACP schema property: ${key}`); + } + } + result.properties = { ...left.properties, ...right.properties }; + } + if (isJsonArray(left.required) || isJsonArray(right.required)) { + result.required = [ + ...new Set([ + ...(isJsonArray(left.required) ? left.required : []), + ...(isJsonArray(right.required) ? right.required : []), + ]), + ]; + } + return result; + }; + let combined: Schema.Json = base; + if (isJsonArray(allOf)) for (const part of allOf) combined = merge(combined, part); + if (isJsonArray(anyOf) && !isJsonArray(allOf) && Object.keys(base).every(isAnnotationKeyword)) { + return { ...base, anyOf }; + } + return isJsonArray(anyOf) ? merge(combined, { anyOf }) : combined; +} + +export function generateSchemaOutput( + upstreamSchema: { readonly $defs: Readonly> }, + release: string, +): { readonly output: string; readonly count: number } { + const normalizedDefinitions = Object.fromEntries( + Object.entries(upstreamSchema.$defs).map(([name, schema]) => [ + name, + flattenCompositions(normalizeSchemaKeywords(schema), upstreamSchema.$defs), + ]), + ); + const sortedEntries = Object.entries(normalizedDefinitions).toSorted(([left], [right]) => + left.localeCompare(right), + ); + const generatedEntries = new Map(); + const generator = makeJsonSchemaGenerator(); + + for (const [name, schema] of sortedEntries) { + generator.addSchema(name, schema as never); + } + + const generated = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim(); + if (generated.length > 0) { + for (const entry of collectSchemaEntries(generated)) { + if (!generatedEntries.has(entry.name)) { + generatedEntries.set(entry.name, entry.code); + } + } + } + + return { + output: [ + "// This file is generated by the effect-acp package. Do not edit manually.", + `// Current ACP schema release: ${release}`, + "", + 'import * as Schema from "effect/Schema";', + "", + [...generatedEntries.values()].join("\n\n"), + "", + ].join("\n"), + count: generatedEntries.size, + }; +} diff --git a/packages/effect-acp/src/_generated/schema-v1.gen.ts b/packages/effect-acp/src/_generated/schema-v1.gen.ts index 268118dab204e..84ef1c97197e6 100644 --- a/packages/effect-acp/src/_generated/schema-v1.gen.ts +++ b/packages/effect-acp/src/_generated/schema-v1.gen.ts @@ -946,8 +946,25 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +} & { readonly [x: string]: Schema.Json }; +export const TitledMultiSelectItems = Schema.StructWithRest( + Schema.Struct({ + anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + }), + Schema.Null, + ]), + ), + }), + [Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" }))], +).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts index ad12b7e197e48..b99a1fd247e9d 100644 --- a/packages/effect-acp/src/_generated/schema.gen.ts +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -1128,8 +1128,27 @@ export const EnumOption = Schema.Struct({ identifier: "EnumOption", }); -export type TitledMultiSelectItems = never; -export const TitledMultiSelectItems = Schema.Never.annotate({ +export type TitledMultiSelectItems = { + readonly anyOf: ReadonlyArray; + readonly _meta?: { readonly [x: string]: Schema.Json } | null; +} & { readonly [x: string]: Schema.Json }; +export const TitledMultiSelectItems = Schema.StructWithRest( + Schema.Struct({ + anyOf: Schema.Array(EnumOption) + .annotate({ description: "Titled enum options. Must contain at least one option." }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility)", + }), + Schema.Null, + ]), + ), + }), + [Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" }))], +).annotate({ description: "Items definition for titled multi-select enum properties.", identifier: "TitledMultiSelectItems", }); diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 1b8b4240fd00e..724ef060ccd71 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, @@ -187,6 +188,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" || @@ -222,7 +230,9 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" ? { extractElicitationForm: extractDroidElicitationForm } - : {}), + : isOpenCode + ? { extractElicitationForm: extractOpenCodeElicitationForm } + : {}), // glm-acp-agent is the one agent whose per-turn usage a live probe has verified. ...(registryAgentId === "glm-acp-agent" ? { reportsPerTurnPromptUsage: true } : {}), ...(registryAgentId === "mistral-vibe" 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..2184cb03eae0c --- /dev/null +++ b/packages/provider-acp-registry/src/server/opencodeAcp.test.ts @@ -0,0 +1,269 @@ +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 }), + ); + + for (const value of ["", " "]) { + const blankChoice = extractOpenCodeElicitationForm( + request({ + q0: { ...colorQuestion, oneOf: [{ const: value, title: "Blank" }] }, + q0_custom: colorCompanion, + }), + ); + expect(blankChoice?.respond({ q0: value })).toEqual({ + action: "accept", + content: { q0: value }, + }); + } + + expect(form?.respond({ q0: "blue" })).toEqual({ + action: "accept", + content: { q0: "blue" }, + }); + }); + + it("prefers an option value over an earlier option label", () => { + const form = extractOpenCodeElicitationForm( + request({ + q0: { + type: "string", + title: "Which one?", + description: "Pick one", + oneOf: [ + { const: "a", title: "b" }, + { const: "b", title: "B" }, + ], + }, + q0_custom: colorCompanion, + }), + ); + + expect(form?.respond({ q0: "b" })).toEqual({ + action: "accept", + content: { q0: "b" }, + }); + }); + + it("sends a free-form answer to the companion field", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({ q0: "Red" })).toEqual({ + action: "accept", + content: { q0_custom: "Red" }, + }); + 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 enum-based choices to the generic mapping", () => { + expect( + extractOpenCodeElicitationForm( + request({ + q0: { type: "string", title: "Color", description: "Pick one", enum: ["red", "blue"] }, + q0_custom: colorCompanion, + q1: { + type: "string", + title: "Size", + description: "Pick one", + oneOf: [{ const: "small", title: "Small" }], + }, + q1_custom: { ...colorCompanion, title: "Size (other)" }, + }), + ), + ).toBeUndefined(); + }); + + 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..de83cc3d2bd3b --- /dev/null +++ b/packages/provider-acp-registry/src/server/opencodeAcp.ts @@ -0,0 +1,165 @@ +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"; + +// 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