From f9ef9474e4df27ef9467dab48376e8089f9eda99 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 17:48:20 +0800 Subject: [PATCH 1/2] fix(tool): require plain-object parameter roots per OpenAI tools contract Root-level anyOf in tool parameters violated the OpenAI tools contract: OpenAI tolerated it, DeepSeek rejected it with a schema error, and GLM silently emitted empty tool arguments (live-reproduced: five consecutive workflow calls decoded as got {}). Replace the transport-level patch with a structural contract: - workflow Parameters: nest the 10-branch discriminated union under a single params property; the wire root is now a plain object - Tool.define: assert at construction time that parameters serialize to a plain object root; violations fail registration with guidance - provider transform: drop the openai-compatible root-anyOf patch, superseded by the source-level contract - native runtime gate: key off the SDK transport package instead of the providerID so OpenAI-compatible relays can use the native path --- packages/opencode/AGENTS.md | 11 + packages/opencode/src/provider/transform.ts | 15 - packages/opencode/src/session/llm/AGENTS.md | 2 +- .../src/session/llm/native-runtime.ts | 6 +- packages/opencode/src/tool/tool.ts | 38 ++ packages/opencode/src/tool/workflow.ts | 21 +- .../opencode/test/dag/workflow-tool.test.ts | 245 +++++---- .../opencode/test/session/llm-native.test.ts | 25 +- .../__snapshots__/parameters.test.ts.snap | 483 +++++++++--------- .../workflow-parameters-post-change.json | 10 +- .../opencode/test/tool/tool-define.test.ts | 22 + .../test/tool/workflow-authoring.test.ts | 54 +- .../tool/workflow-provider-schema.test.ts | 80 ++- .../tool/workflow-schema-contract.test.ts | 79 +++ 14 files changed, 624 insertions(+), 467 deletions(-) create mode 100644 packages/opencode/test/tool/workflow-schema-contract.test.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index f07170c585..17f4228cda 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -1,5 +1,16 @@ # opencode database guide +## Tool parameter schema contract + +Tool `parameters` must serialize to a JSON Schema **plain object root** (`type: +"object"` with `properties`). Root-level combinators (`anyOf`/`oneOf`/`allOf`) +violate the OpenAI tools contract: OpenAI tolerates them, DeepSeek rejects them +with a schema error, and GLM silently emits empty tool arguments. A tool that +needs a discriminated union must nest it under a property, e.g. +`Schema.Struct({ params: })`. `Tool.define` enforces this at +construction time (`assertObjectRootedParameters`) — a violating tool fails +registration instead of degrading at provider runtime. + ## Database - **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`. diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6291a8fca6..e459380faa 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1537,21 +1537,6 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 schema = sanitizeGemini(schema) } - // OpenAI-compatible backends (DeepSeek, GLM, and other relays) reject - // function schemas whose root type is implicit — the model emits empty - // tool arguments instead of erroring. Effect emits object-only - // discriminated unions as a root `anyOf`; retaining the union while - // declaring its shared object type preserves every branch. - if ( - model.api.npm === "@ai-sdk/openai-compatible" && - schema.type === undefined && - Array.isArray(schema.anyOf) && - schema.anyOf.length > 0 && - schema.anyOf.every((branch) => isPlainObject(branch) && branch.type === "object") - ) { - schema = { ...schema, type: "object" } - } - return schema } diff --git a/packages/opencode/src/session/llm/AGENTS.md b/packages/opencode/src/session/llm/AGENTS.md index cfb6a89cef..a69e1dd3dd 100644 --- a/packages/opencode/src/session/llm/AGENTS.md +++ b/packages/opencode/src/session/llm/AGENTS.md @@ -33,7 +33,7 @@ Keep new integration code on one of these seams. Avoid importing session service ## Runtime selection -Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others. +Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others. The native gate keys off the model's SDK transport package (`api.npm`), not the providerID — any OpenAI-compatible relay (local proxies, DeepSeek, GLM gateways) speaks the wire protocol the native client implements. ```txt ╭───────────────────╮ diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index bac385c591..02a3c1902d 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -51,9 +51,9 @@ function statusWithFetch( input: Pick, fetch: typeof globalThis.fetch | undefined, ): RuntimeStatus { - const providerID = input.model.providerID - if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode")) - return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" } + // The gate keys off the SDK transport package, not the providerID: any + // OpenAI-compatible relay (local proxies, DeepSeek, GLM gateways) speaks the + // same wire protocol the native client implements. const npm = input.model.api.npm if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic") return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" } diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index e0beb31913..2e631b216e 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -4,6 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import type { JSONSchema7 } from "@ai-sdk/provider" import type { SessionID, MessageID } from "../session/schema" import * as Truncate from "./truncate" +import { ToolJsonSchema } from "./json-schema" import { Agent } from "@/agent/agent" interface Metadata { @@ -103,6 +104,42 @@ export type InferDef = ? Def : never +/** + * The OpenAI tools contract requires `parameters` to be a JSON Schema object. + * A root-level combinator (anyOf/oneOf/allOf) is outside that contract: + * OpenAI tolerates it, DeepSeek rejects it with a schema error, and GLM + * silently emits empty tool arguments. Tools that need a discriminated union + * must nest it under a property (e.g. `{ params: }`). Violations fail + * at construction time here instead of degrading at provider runtime. + */ +function assertObjectRootedParameters(id: string, toolInfo: DefWithoutID | { parameters: unknown; jsonSchema?: unknown }) { + const root = toolInfo.jsonSchema ?? ToolJsonSchema.fromSchema(toolInfo.parameters as Schema.Top) + if (!isPlainObjectRoot(root as JSONSchema7)) { + return yieldOrDieRootCombinator(id, root) + } +} + +function isPlainObjectRoot(root: JSONSchema7): boolean { + return ( + typeof root === "object" && + root !== null && + !Array.isArray(root) && + (root as { type?: unknown }).type === "object" && + (root as { anyOf?: unknown }).anyOf === undefined && + (root as { oneOf?: unknown }).oneOf === undefined && + (root as { allOf?: unknown }).allOf === undefined + ) +} + +function yieldOrDieRootCombinator(id: string, root: unknown): never { + const combinator = ["anyOf", "oneOf", "allOf"].find( + (key) => Array.isArray((root as Record)?.[key]), + ) + throw new Error( + `Tool "${id}" parameters must serialize to a plain object root (type: "object" with properties); found a root-level ${combinator ?? "non-object"} combinator. Nest the union under a property, e.g. Schema.Struct({ params: }). Root-level combinators violate the OpenAI tools contract: DeepSeek rejects them and GLM answers with empty tool arguments.`, + ) +} + function wrap, Result extends Metadata>( id: string, init: Init, @@ -112,6 +149,7 @@ function wrap, Result extends Metadat return () => Effect.gen(function* () { const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } + assertObjectRootedParameters(id, toolInfo as { parameters: unknown; jsonSchema?: unknown }) // Compile the parser closure once per tool init; `decodeUnknownEffect` // allocates a new closure per call, so hoisting avoids re-closing it for // every LLM tool invocation. diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 0458421903..60b0345b82 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -45,9 +45,13 @@ export const StartSpec = DagValidation.StartSpec export { Parameters as WorkflowParameters } // ============================================================================ -// Parameters: one discriminated union, action-owned fields only. -// Runtime-derived identity (session/project) is never model-authored — start -// derives ownership from the calling session. +// Parameters: a single `params` root property carrying the action union. +// OpenAI's tools contract expects `parameters` to be a JSON Schema object; a +// root-level combinator (anyOf/oneOf/allOf) is outside that contract and +// OpenAI-compatible backends reject it — DeepSeek with an explicit schema +// error, GLM by silently emitting empty tool arguments. Nesting the union one +// level down keeps every discriminated branch intact while the schema root +// stays a plain object on every transport. // ============================================================================ const specPathDescription = @@ -118,7 +122,7 @@ const ValidatePath = Schema.Struct({ profile: ValidationProfile, }) -export const Parameters = Schema.Union([ +const ActionParams = Schema.Union([ StartPath, ExtendPath, ControlReplanPath, @@ -131,6 +135,10 @@ export const Parameters = Schema.Union([ ValidatePath, ]) +export const Parameters = Schema.Struct({ + params: ActionParams.annotate({ description: "The workflow action and its action-owned fields" }), +}) + // ============================================================================ // Tool definition // ============================================================================ @@ -243,10 +251,11 @@ export const WorkflowTool = Tool.define< formatValidationError: (error) => [ `Workflow call rejected by the action schema: ${error instanceof Error ? error.message : String(error)}`, - "Each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec_path, profile?}. Put graph content in a .yaml/.yml file; session/project identity is never a parameter.", + 'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec_path, profile?}. Put graph content in a .yaml/.yml file; session/project identity is never a parameter.', ].join("\n"), - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + execute: (call: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const params = call.params const callingSession = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) if (callingSession.parentID) { return yield* Effect.die( diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 9f201264cc..178db7edf2 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -529,17 +529,17 @@ function missingCatalogModelProject() { describe("workflow tool schema (negative tests)", () => { it("action field accepts start/extend/control/status/result/list/read/guide", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "start", spec_path: ".opencode/workflows/test.yaml" })).not.toThrow() + expect(() => decode({ params: { action: "start", spec_path: ".opencode/workflows/test.yaml" }})).not.toThrow() expect(() => - decode({ action: "extend", workflow_id: "dag_wf_1", spec_path: ".opencode/workflows/extend.yaml" }), + decode({ params: { action: "extend", workflow_id: "dag_wf_1", spec_path: ".opencode/workflows/extend.yaml" }}), ).not.toThrow() - expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "pause" })).not.toThrow() - expect(() => decode({ action: "status", workflow_id: "dag_wf_1" })).not.toThrow() - expect(() => decode({ action: "result", workflow_id: "dag_wf_1", node_id: "node-1", limit: 600 })).not.toThrow() + expect(() => decode({ params: { action: "control", workflow_id: "dag_wf_1", operation: "pause" }})).not.toThrow() + expect(() => decode({ params: { action: "status", workflow_id: "dag_wf_1" }})).not.toThrow() + expect(() => decode({ params: { action: "result", workflow_id: "dag_wf_1", node_id: "node-1", limit: 600 }})).not.toThrow() // list browses the saved-spec library and needs no workflow_id. - expect(() => decode({ action: "list" })).not.toThrow() - expect(() => decode({ action: "read", spec_path: "project-change-route" })).not.toThrow() - expect(() => decode({ action: "guide", topic: "blocks" })).not.toThrow() + expect(() => decode({ params: { action: "list" }})).not.toThrow() + expect(() => decode({ params: { action: "read", spec_path: "project-change-route" }})).not.toThrow() + expect(() => decode({ params: { action: "guide", topic: "blocks" }})).not.toThrow() }) it("rejects inline structured specs and JSON-stringified specs", () => { @@ -551,67 +551,67 @@ describe("workflow tool schema (negative tests)", () => { }, } - expect(() => decode({ action: "start", spec })).toThrow() - expect(() => decode({ action: "start", spec: JSON.stringify(spec) })).toThrow() + expect(() => decode({ params: { action: "start", spec }})).toThrow() + expect(() => decode({ params: { action: "start", spec: JSON.stringify(spec) }})).toThrow() }) it("action field rejects unknown actions", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "delete" })).toThrow() + expect(() => decode({ params: { action: "delete" }})).toThrow() }) it("workflow IDs use the durable DAG identity schema", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "status", workflow_id: "workflow-1" })).toThrow() - expect(decode({ action: "status", workflow_id: "dag_workflow_1" })).toMatchObject({ - workflow_id: "dag_workflow_1", + expect(() => decode({ params: { action: "status", workflow_id: "workflow-1" }})).toThrow() + expect(decode({ params: { action: "status", workflow_id: "dag_workflow_1" }})).toMatchObject({ + params: { workflow_id: "dag_workflow_1" }, }) }) it("no node_complete action exists", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "node_complete" })).toThrow() + expect(() => decode({ params: { action: "node_complete" }})).toThrow() }) it("no unsupported read-only actions exist (history/logs)", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "history" })).toThrow() - expect(() => decode({ action: "logs" })).toThrow() + expect(() => decode({ params: { action: "history" }})).toThrow() + expect(() => decode({ params: { action: "logs" }})).toThrow() }) it("control operation accepts pause/resume/cancel/step/complete", () => { const decode = Schema.decodeUnknownSync(Parameters) for (const op of ["pause", "resume", "cancel", "step", "complete"]) { - expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: op })).not.toThrow() + expect(() => decode({ params: { action: "control", workflow_id: "dag_wf_1", operation: op }})).not.toThrow() } }) it("control replan requires a YAML graph source", () => { const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) expect(() => - decode({ action: "control", workflow_id: "dag_wf_1", operation: "replan", spec_path: "fragment.yaml" }), + decode({ params: { action: "control", workflow_id: "dag_wf_1", operation: "replan", spec_path: "fragment.yaml" }}), ).not.toThrow() expect(() => - decode({ + decode({ params: { action: "control", workflow_id: "dag_wf_1", operation: "replan", spec: { fragment: { name: "fragment", nodes: [] } }, - }), + }}), ).toThrow() - expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "replan" })).toThrow() + expect(() => decode({ params: { action: "control", workflow_id: "dag_wf_1", operation: "replan" }})).toThrow() }) it("control operation rejects unknown operations", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "delete" })).toThrow() - expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "start" })).toThrow() + expect(() => decode({ params: { action: "control", workflow_id: "dag_wf_1", operation: "delete" }})).toThrow() + expect(() => decode({ params: { action: "control", workflow_id: "dag_wf_1", operation: "start" }})).toThrow() }) it("keeps workflow graph and admission fields inside the YAML file", () => { const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) expect(() => - decode({ + decode({ params: { action: "start", spec_path: ".opencode/workflows/deep.yaml", mode: "deep", @@ -620,11 +620,10 @@ describe("workflow tool schema (negative tests)", () => { name: "deep-schema", nodes: [], }, - }), + }}), ).toThrow() - expect(decode({ action: "start", spec_path: ".opencode/workflows/deep.yaml" })).toEqual({ - action: "start", - spec_path: ".opencode/workflows/deep.yaml", + expect(decode({ params: { action: "start", spec_path: ".opencode/workflows/deep.yaml" }})).toEqual({ + params: { action: "start", spec_path: ".opencode/workflows/deep.yaml" }, }) }) }) @@ -636,7 +635,7 @@ describe("workflow tool execution", () => { const info = yield* WorkflowTool const workflow = yield* info.init() const exit = yield* workflow - .execute({ action: "list" }, { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }) + .execute({ params: { action: "list" }}, { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }) .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -666,8 +665,8 @@ describe("workflow tool execution", () => { Effect.gen(function* () { const info = yield* WorkflowTool const workflow = yield* info.init() - const index = yield* workflow.execute({ action: "guide" }, toolContext()) - const blocks = yield* workflow.execute({ action: "guide", topic: "blocks" }, toolContext()) + const index = yield* workflow.execute({ params: { action: "guide" }}, toolContext()) + const blocks = yield* workflow.execute({ params: { action: "guide", topic: "blocks" }}, toolContext()) expect(workflow.description.length).toBeLessThan(5_000) expect(index.output).toContain("blocks: compose") @@ -682,10 +681,10 @@ describe("workflow tool execution", () => { const info = yield* WorkflowTool const workflow = yield* info.init() const result = yield* workflow.execute( - { + { params: { action: "status", workflow_id: Dag.ID.make("dag_status"), - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -713,31 +712,31 @@ describe("workflow tool execution", () => { const decode = Schema.decodeUnknownSync(Parameters) const first = JSON.parse( (yield* workflow.execute( - decode({ action: "result", workflow_id: "dag_result", node_id: "node_result", limit: 600 }), + decode({ params: { action: "result", workflow_id: "dag_result", node_id: "node_result", limit: 600 }}), toolContext(), )).output, ) const second = JSON.parse( (yield* workflow.execute( - decode({ + decode({ params: { action: "result", workflow_id: "dag_result", node_id: "node_result", cursor: first.next_cursor, limit: 600, - }), + }}), toolContext(), )).output, ) const third = JSON.parse( (yield* workflow.execute( - decode({ + decode({ params: { action: "result", workflow_id: "dag_result", node_id: "node_result", cursor: second.next_cursor, limit: 600, - }), + }}), toolContext(), )).output, ) @@ -756,23 +755,23 @@ describe("workflow tool execution", () => { const mismatched = yield* workflow .execute( - decode({ + decode({ params: { action: "result", workflow_id: "dag_result", node_id: "node_other", cursor: first.next_cursor, - }), + }}), toolContext(), ) .pipe(Effect.exit) const malformed = yield* workflow .execute( - decode({ + decode({ params: { action: "result", workflow_id: "dag_result", node_id: "node_result", cursor: "not-a-result-cursor", - }), + }}), toolContext(), ) .pipe(Effect.exit) @@ -790,7 +789,7 @@ describe("workflow tool execution", () => { const workflow = yield* info.init() const exit = yield* workflow .execute( - { action: "control", workflow_id: Dag.ID.make("dag_status"), operation: "pause" }, + { params: { action: "control", workflow_id: Dag.ID.make("dag_status"), operation: "pause" }}, { ...toolContext(), ask: (request) => { @@ -829,27 +828,27 @@ describe("workflow tool execution", () => { } satisfies Tool.Context const statusExit = yield* Effect.exit( - workflow.execute({ action: "status", workflow_id: Dag.ID.make("dag_status") }, foreignContext), + workflow.execute({ params: { action: "status", workflow_id: Dag.ID.make("dag_status") }}, foreignContext), ) const resultExit = yield* Effect.exit( workflow.execute( - Schema.decodeUnknownSync(Parameters)({ + Schema.decodeUnknownSync(Parameters)({ params: { action: "result", workflow_id: "dag_result", node_id: "node_result", - }), + }}), foreignContext, ), ) const extendExit = yield* Effect.exit( workflow.execute( - { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec_path: "foreign.yaml" }, + { params: { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec_path: "foreign.yaml" }}, foreignContext, ), ) const controlExit = yield* Effect.exit( workflow.execute( - { action: "control", workflow_id: Dag.ID.make("dag_status"), operation: "pause" }, + { params: { action: "control", workflow_id: Dag.ID.make("dag_status"), operation: "pause" }}, foreignContext, ), ) @@ -889,11 +888,11 @@ describe("workflow tool execution", () => { Effect.gen(function* () { published.length = 0 yield* workflow.execute( - { + { params: { action: "control", workflow_id: control.workflowID, operation: control.operation, - }, + }}, toolContext(), ) return published.find((event) => event.type.startsWith("dag.workflow."))?.type ?? "missing" @@ -922,10 +921,10 @@ describe("workflow tool execution", () => { }, }) const result = yield* workflow.execute( - Schema.decodeUnknownSync(Parameters)({ + Schema.decodeUnknownSync(Parameters)({ params: { action: "start", spec_path, - }), + }}), toolContext(), ) @@ -952,10 +951,10 @@ describe("workflow tool execution", () => { }, }) const result = yield* workflow.execute( - Schema.decodeUnknownSync(Parameters)({ + Schema.decodeUnknownSync(Parameters)({ params: { action: "start", spec_path, - }), + }}), toolContext(), ) @@ -994,11 +993,11 @@ describe("workflow tool execution", () => { ], }) const result = yield* workflow.execute( - Schema.decodeUnknownSync(Parameters)({ + Schema.decodeUnknownSync(Parameters)({ params: { action: "extend", workflow_id: "dag_defaults", spec_path, - }), + }}), toolContext(), ) @@ -1019,11 +1018,11 @@ describe("workflow tool execution", () => { blocks: [{ id: "repair", kind: "coding", depends_on: ["node_running"] }], }) const result = yield* workflow.execute( - Schema.decodeUnknownSync(Parameters)({ + Schema.decodeUnknownSync(Parameters)({ params: { action: "extend", workflow_id: "dag_status", spec_path, - }), + }}), toolContext(), ) @@ -1054,12 +1053,12 @@ describe("workflow tool execution", () => { }, }) const result = yield* workflow.execute( - Schema.decodeUnknownSync(Parameters)({ + Schema.decodeUnknownSync(Parameters)({ params: { action: "control", workflow_id: "dag_defaults", operation: "replan", spec_path, - }), + }}), toolContext(), ) @@ -1112,10 +1111,10 @@ describe("workflow tool execution", () => { const info = yield* WorkflowTool const workflow = yield* info.init() const result = yield* workflow.execute( - { + { params: { action: "status", workflow_id: Dag.ID.make("dag_deep_status"), - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1179,10 +1178,10 @@ config: } satisfies Tool.Context const invalid = yield* workflow .execute( - { + { params: { action: "start", spec_path: "deep.yaml", - }, + }}, context, ) .pipe(Effect.exit) @@ -1226,10 +1225,10 @@ config: ) const result = yield* workflow.execute( - { + { params: { action: "start", spec_path: "deep.yaml", - }, + }}, context, ) @@ -1260,10 +1259,10 @@ config: const workflow = yield* info.init() const exit = yield* workflow .execute( - { + { params: { action: "start", spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1298,10 +1297,10 @@ config: }) const result = yield* workflow.execute( - { + { params: { action: "start", spec_path: specPath, - }, + }}, { sessionID: parentID, messageID: MessageID.ascending(), @@ -1335,10 +1334,10 @@ config: const info = yield* WorkflowTool const workflow = yield* info.init() const result = yield* workflow.execute( - { + { params: { action: "start", spec_path: "missing-model.yaml", - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1365,11 +1364,11 @@ config: const info = yield* WorkflowTool const workflow = yield* info.init() const result = yield* workflow.execute( - { + { params: { action: "validate", spec_path: "missing-model.yaml", profile: "environment", - }, + }}, toolContext(), ) @@ -1390,11 +1389,11 @@ config: const info = yield* WorkflowTool const workflow = yield* info.init() const result = yield* workflow.execute( - { + { params: { action: "validate", spec_path: "missing-catalog-model.yaml", profile: "environment", - }, + }}, toolContext(), ) @@ -1404,7 +1403,7 @@ config: expect.objectContaining({ code: "model.unavailable", path: "nodes[worker]" }), ) const started = yield* workflow.execute( - { action: "start", spec_path: "missing-catalog-model.yaml" }, + { params: { action: "start", spec_path: "missing-catalog-model.yaml" }}, toolContext(), ) expect(started.title).toBe("Workflow not started: model required") @@ -1434,16 +1433,16 @@ config: ) const extendExit = yield* workflow - .execute({ action: "extend", workflow_id: Dag.ID.make("dag_paused"), spec_path: extendPath }, toolContext()) + .execute({ params: { action: "extend", workflow_id: Dag.ID.make("dag_paused"), spec_path: extendPath }}, toolContext()) .pipe(Effect.exit) const replanExit = yield* workflow .execute( - { + { params: { action: "control", operation: "replan", workflow_id: Dag.ID.make("dag_paused"), spec_path: replanPath, - }, + }}, toolContext(), ) .pipe(Effect.exit) @@ -1473,11 +1472,11 @@ config: yield* Effect.promise(() => Bun.write(extendPath, JSON.stringify({ nodes: [node] }))) const extended = yield* workflow.execute( - { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec_path: extendPath }, + { params: { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec_path: extendPath }}, toolContext(), ) const replanned = yield* workflow.execute( - { + { params: { action: "control", operation: "replan", workflow_id: Dag.ID.make("dag_paused"), @@ -1500,7 +1499,7 @@ config: }), ).then(() => path.join(missingModelDirectory, "modeled-replan.yaml")), ), - }, + }}, toolContext(), ) @@ -1523,10 +1522,10 @@ config: }, }) yield* workflow.execute( - { + { params: { action: "start", spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1574,10 +1573,10 @@ config: }, }) yield* workflow.execute( - { + { params: { action: "start", spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1648,10 +1647,10 @@ config: const specPath = yield* writeWorkflowSpec(`blocked-${item.name}`, item.value) const exit = yield* workflow .execute( - { + { params: { action: "start", spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1691,10 +1690,10 @@ config: }) yield* workflow.execute( - { + { params: { action: "start", spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1748,10 +1747,10 @@ config: }) yield* workflow.execute( - { + { params: { action: "start", spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1809,11 +1808,11 @@ config: }) yield* workflow.execute( - { + { params: { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1868,12 +1867,12 @@ config: }) yield* workflow.execute( - { + { params: { action: "control", workflow_id: Dag.ID.make("dag_defaults"), operation: "replan", spec_path: specPath, - }, + }}, { sessionID: SessionID.make("ses_workflow_parent"), messageID: MessageID.ascending(), @@ -1914,11 +1913,11 @@ config: // strict parameter decode rejects a project_id supplied by the caller. const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) expect(() => - decode({ + decode({ params: { action: "start", project_id: "project_other", spec_path: "project-id-mismatch.yaml", - }), + }}), ).toThrow() }), ) @@ -1927,11 +1926,11 @@ config: Effect.gen(function* () { const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) expect(() => - decode({ + decode({ params: { action: "start", session_id: "ses_other_parent", spec_path: "foreign-parent.yaml", - }), + }}), ).toThrow() }), ) @@ -1996,7 +1995,7 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() const asked: unknown[] = [] - const result = yield* workflow.execute({ action: "read", spec_path: "saved-readable" }, contextWith(asked)) + const result = yield* workflow.execute({ params: { action: "read", spec_path: "saved-readable" }}, contextWith(asked)) expect(result.title).toBe("Workflow spec: saved-readable") const payload = JSON.parse(result.output) @@ -2028,7 +2027,7 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() const asked: unknown[] = [] - const result = yield* workflow.execute({ action: "start", spec_path: "saved-project" }, contextWith(asked)) + const result = yield* workflow.execute({ params: { action: "start", spec_path: "saved-project" }}, contextWith(asked)) expect(result.output).toContain('state="running"') expect(result.title).toBe("Workflow started: saved-project") @@ -2048,7 +2047,7 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() const asked: unknown[] = [] - const result = yield* workflow.execute({ action: "start", spec_path: "saved-global" }, contextWith(asked)) + const result = yield* workflow.execute({ params: { action: "start", spec_path: "saved-global" }}, contextWith(asked)) expect(result.title).toBe("Workflow started: saved-global") // The library's two scopes are curated config, so a resolved name never @@ -2065,7 +2064,7 @@ describe("workflow tool saved workflows", () => { const info = yield* WorkflowTool const workflow = yield* info.init() const exit = yield* workflow - .execute({ action: "start", spec_path: "not-saved" }, contextWith([])) + .execute({ params: { action: "start", spec_path: "not-saved" }}, contextWith([])) .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -2100,7 +2099,7 @@ describe("workflow tool saved workflows", () => { const info = yield* WorkflowTool const workflow = yield* info.init() - const result = yield* workflow.execute({ action: "list" }, contextWith([])) + const result = yield* workflow.execute({ params: { action: "list" }}, contextWith([])) expect(result.output).toContain("shared [project] — project-shared title") expect(result.output).toContain("global-only [global] — global-only title") @@ -2121,7 +2120,7 @@ describe("workflow tool saved workflows", () => { const info = yield* WorkflowTool const workflow = yield* info.init() - const result = yield* workflow.execute({ action: "list" }, contextWith([])) + const result = yield* workflow.execute({ params: { action: "list" }}, contextWith([])) expect(result.title).toBe("No saved workflows") expect(result.output).toContain(path.join(workflowSpecDirectory, ".opencode", "workflows")) @@ -2141,11 +2140,11 @@ describe("workflow tool saved workflows", () => { }) const result = yield* workflow.execute( - { + { params: { action: "validate", profile: "portable", spec_path, - }, + }}, toolContext(), ) @@ -2186,11 +2185,11 @@ describe("workflow tool saved workflows", () => { }) const result = yield* workflow.execute( - { + { params: { action: "validate", profile: "environment", spec_path, - }, + }}, toolContext(), ) @@ -2231,16 +2230,16 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() // project beats global - const projectRead = yield* workflow.execute({ action: "read", spec_path: "shared-route" }, contextWith([])) + const projectRead = yield* workflow.execute({ params: { action: "read", spec_path: "shared-route" }}, contextWith([])) expect(JSON.parse(projectRead.output).spec.title).toContain("project-route") // global fills names the project scope does not own - const globalRead = yield* workflow.execute({ action: "read", spec_path: "builtin-shadowed" }, contextWith([])) + const globalRead = yield* workflow.execute({ params: { action: "read", spec_path: "builtin-shadowed" }}, contextWith([])) expect(JSON.parse(globalRead.output).spec.title).toContain("file-route") // builtin fills names no file scope owns const builtinValidate = yield* workflow.execute( - { action: "validate", spec_path: "builtin-only-route" }, + { params: { action: "validate", spec_path: "builtin-only-route" }}, contextWith([]), ) const builtinResult = JSON.parse(builtinValidate.output) @@ -2250,10 +2249,10 @@ describe("workflow tool saved workflows", () => { // explicit path source validates under the environment profile by default const pathValidate = yield* workflow.execute( - { + { params: { action: "validate", spec_path: "path-route.yaml", - }, + }}, contextWith([]), ) const pathResult = JSON.parse(pathValidate.output) @@ -2265,11 +2264,11 @@ describe("workflow tool saved workflows", () => { // start succeeds from the same name, and mutating the file changes // both views consistently. const beforeStart = yield* workflow.execute( - { action: "validate", spec_path: "shared-route" }, + { params: { action: "validate", spec_path: "shared-route" }}, contextWith([]), ) expect(JSON.parse(beforeStart.output).valid).toBe(true) - const started = yield* workflow.execute({ action: "start", spec_path: "shared-route" }, contextWith([])) + const started = yield* workflow.execute({ params: { action: "start", spec_path: "shared-route" }}, contextWith([])) expect(started.title).toBe("Workflow started: project-route") expect(published.some((event) => event.type === DagEvent.WorkflowCreated.type)).toBe(true) } finally { @@ -2295,7 +2294,7 @@ describe("workflow tool saved workflows", () => { const info = yield* WorkflowTool const workflow = yield* info.init() - const result = yield* workflow.execute({ action: "list" }, contextWith([])) + const result = yield* workflow.execute({ params: { action: "list" }}, contextWith([])) expect(result.output).toContain("broken-route [global] [invalid — not startable]") expect(result.output).toContain("block.compile_failed") @@ -2318,7 +2317,7 @@ describe("workflow tool saved workflows", () => { const info = yield* WorkflowTool const workflow = yield* info.init() - const result = yield* workflow.execute({ action: "read", spec_path: "uncompilable-route" }, contextWith([])) + const result = yield* workflow.execute({ params: { action: "read", spec_path: "uncompilable-route" }}, contextWith([])) const payload = JSON.parse(result.output) // The editable source survives untouched so the parent can repair it. @@ -2348,7 +2347,7 @@ describe("workflow tool saved workflows", () => { const info = yield* WorkflowTool const workflow = yield* info.init() - const result = yield* workflow.execute({ action: "list" }, contextWith([])) + const result = yield* workflow.execute({ params: { action: "list" }}, contextWith([])) expect(result.output).toContain("broken-syntax [global] [invalid — not startable]") expect(result.output).toContain("[schema.invalid]") @@ -2367,7 +2366,7 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() const result = yield* workflow.execute( - { action: "validate", spec_path: "broken-validate", profile: "portable" }, + { params: { action: "validate", spec_path: "broken-validate", profile: "portable" }}, contextWith([]), ) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 702bb67e39..3be43cf929 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -414,11 +414,32 @@ describe("session.llm-native.request", () => { }) expect( LLMNativeRuntime.status({ - model: { ...baseModel, providerID: ProviderV2.ID.make("google") }, + model: { + ...baseModel, + providerID: ProviderV2.ID.make("google"), + api: { ...baseModel.api, npm: "@ai-sdk/google" }, + }, provider: { ...providerInfo, id: ProviderV2.ID.make("google") }, auth: undefined, }), - ).toEqual({ type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }) + ).toEqual({ + type: "unsupported", + reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic", + }) + // The gate keys off the SDK transport package: any OpenAI-compatible + // relay (local proxies, DeepSeek, GLM gateways) is supported regardless + // of its providerID. + expect( + LLMNativeRuntime.status({ + model: { + ...baseModel, + providerID: ProviderV2.ID.make("local-proxy-compatible"), + api: { ...baseModel.api, url: "https://proxy.example.test/v1", npm: "@ai-sdk/openai-compatible" }, + }, + provider: { ...providerInfo, id: ProviderV2.ID.make("local-proxy-compatible") }, + auth: undefined, + }), + ).toMatchObject({ type: "supported" }) expect( LLMNativeRuntime.status({ model: baseModel, diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 51378467cf..5e3418bff8 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -447,266 +447,275 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", - "anyOf": [ - { - "properties": { - "action": { - "description": "Create a workflow", - "enum": [ - "start", - ], - "type": "string", - }, - "spec_path": { - "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", - "type": "string", - }, - }, - "required": [ - "action", - "spec_path", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Add nodes or blocks to a live workflow", - "enum": [ - "extend", - ], - "type": "string", - }, - "spec_path": { - "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", - "type": "string", - }, - "workflow_id": { - "description": "Target workflow ID", - "pattern": "^dag", - "type": "string", - }, - }, - "required": [ - "action", - "workflow_id", - "spec_path", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Control a live workflow", - "enum": [ - "control", - ], - "type": "string", - }, - "operation": { - "description": "Apply a node fragment (add/cancel/restart/replace)", - "enum": [ - "replan", - ], - "type": "string", - }, - "spec_path": { - "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", - "type": "string", - }, - "workflow_id": { - "description": "Target workflow ID", - "pattern": "^dag", - "type": "string", - }, - }, - "required": [ - "action", - "operation", - "workflow_id", - "spec_path", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Control a live workflow", - "enum": [ - "control", + "properties": { + "params": { + "anyOf": [ + { + "properties": { + "action": { + "description": "Create a workflow", + "enum": [ + "start", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", + "type": "string", + }, + }, + "required": [ + "action", + "spec_path", ], - "type": "string", + "type": "object", }, - "operation": { - "description": "pause/resume/cancel/step/complete", - "enum": [ - "pause", - "resume", - "cancel", - "step", - "complete", + { + "properties": { + "action": { + "description": "Add nodes or blocks to a live workflow", + "enum": [ + "extend", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "workflow_id", + "spec_path", ], - "type": "string", - }, - "workflow_id": { - "description": "Target workflow ID", - "pattern": "^dag", - "type": "string", + "type": "object", }, - }, - "required": [ - "action", - "operation", - "workflow_id", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Inspect durable workflow and node state", - "enum": [ - "status", + { + "properties": { + "action": { + "description": "Control a live workflow", + "enum": [ + "control", + ], + "type": "string", + }, + "operation": { + "description": "Apply a node fragment (add/cancel/restart/replace)", + "enum": [ + "replan", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "operation", + "workflow_id", + "spec_path", ], - "type": "string", + "type": "object", }, - "workflow_id": { - "description": "Target workflow ID", - "pattern": "^dag", - "type": "string", - }, - }, - "required": [ - "action", - "workflow_id", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Read one durable node output in bounded pages", - "enum": [ - "result", + { + "properties": { + "action": { + "description": "Control a live workflow", + "enum": [ + "control", + ], + "type": "string", + }, + "operation": { + "description": "pause/resume/cancel/step/complete", + "enum": [ + "pause", + "resume", + "cancel", + "step", + "complete", + ], + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "operation", + "workflow_id", ], - "type": "string", + "type": "object", }, - "cursor": { - "description": "Opaque continuation cursor returned by the previous page", - "type": "string", - }, - "limit": { - "description": "Maximum page characters; defaults to 8000, max 12000", - "maximum": 12000, - "minimum": 1, - "type": "integer", - }, - "node_id": { - "description": "Target durable node ID", - "type": "string", - }, - "workflow_id": { - "description": "Target workflow ID", - "pattern": "^dag", - "type": "string", - }, - }, - "required": [ - "action", - "workflow_id", - "node_id", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Show saved workflow names, objectives, sizes, scopes, and validation status", - "enum": [ - "list", + { + "properties": { + "action": { + "description": "Inspect durable workflow and node state", + "enum": [ + "status", + ], + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "workflow_id", ], - "type": "string", + "type": "object", }, - }, - "required": [ - "action", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Inspect one saved spec before retargeting it", - "enum": [ - "read", + { + "properties": { + "action": { + "description": "Read one durable node output in bounded pages", + "enum": [ + "result", + ], + "type": "string", + }, + "cursor": { + "description": "Opaque continuation cursor returned by the previous page", + "type": "string", + }, + "limit": { + "description": "Maximum page characters; defaults to 8000, max 12000", + "maximum": 12000, + "minimum": 1, + "type": "integer", + }, + "node_id": { + "description": "Target durable node ID", + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "workflow_id", + "node_id", ], - "type": "string", - }, - "spec_path": { - "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", - "type": "string", + "type": "object", }, - }, - "required": [ - "action", - "spec_path", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Load detailed guidance only when needed", - "enum": [ - "guide", + { + "properties": { + "action": { + "description": "Show saved workflow names, objectives, sizes, scopes, and validation status", + "enum": [ + "list", + ], + "type": "string", + }, + }, + "required": [ + "action", ], - "type": "string", + "type": "object", }, - "topic": { - "description": "blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", - "enum": [ - "blocks", - "interface", - "policy", - "patterns", + { + "properties": { + "action": { + "description": "Inspect one saved spec before retargeting it", + "enum": [ + "read", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", + "type": "string", + }, + }, + "required": [ + "action", + "spec_path", ], - "type": "string", + "type": "object", }, - }, - "required": [ - "action", - ], - "type": "object", - }, - { - "properties": { - "action": { - "description": "Pre-flight a custom spec without creating a workflow; returns diagnostics, never a workflow ID", - "enum": [ - "validate", + { + "properties": { + "action": { + "description": "Load detailed guidance only when needed", + "enum": [ + "guide", + ], + "type": "string", + }, + "topic": { + "description": "blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", + "enum": [ + "blocks", + "interface", + "policy", + "patterns", + ], + "type": "string", + }, + }, + "required": [ + "action", ], - "type": "string", + "type": "object", }, - "profile": { - "description": "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, project/global/path specs environment", - "enum": [ - "portable", - "environment", + { + "properties": { + "action": { + "description": "Pre-flight a custom spec without creating a workflow; returns diagnostics, never a workflow ID", + "enum": [ + "validate", + ], + "type": "string", + }, + "profile": { + "description": "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, project/global/path specs environment", + "enum": [ + "portable", + "environment", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", + "type": "string", + }, + }, + "required": [ + "action", + "spec_path", ], - "type": "string", + "type": "object", }, - "spec_path": { - "description": "(start/extend/control replan/read/validate) An exact saved workflow name returned by workflow(action="list"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", - "type": "string", - }, - }, - "required": [ - "action", - "spec_path", ], - "type": "object", + "description": "The workflow action and its action-owned fields", }, + }, + "required": [ + "params", ], + "type": "object", } `; diff --git a/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json b/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json index e16ebbcbf4..84287b2632 100644 --- a/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json +++ b/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json @@ -1,25 +1,25 @@ { - "captured_from": "packages/opencode/src/tool/workflow.ts (file-backed discriminated-union Parameters)", - "schema_bytes": 4712, + "captured_from": "packages/opencode/src/tool/workflow.ts (params-wrapped discriminated-union Parameters; plain-object root per OpenAI tools contract)", + "schema_bytes": 4840, "branch_count": 10, "session_id_exposed": false, "project_id_exposed": false, "inline_spec_exposed": false, "transformed": { "openai": { - "bytes": 4542, + "bytes": 4670, "branch_count": 10, "start_spec_path_present": true, "inline_spec_exposed": false }, "azure": { - "bytes": 4542, + "bytes": 4670, "branch_count": 10, "start_spec_path_present": true, "inline_spec_exposed": false }, "gemini": { - "bytes": 4712, + "bytes": 4840, "branch_count": 10, "start_spec_path_present": true, "inline_spec_exposed": false diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 08e0604362..8a6afe39de 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -150,4 +150,26 @@ describe("Tool.define", () => { expect(args.message).toContain(`["questions"][0]["question"]`) }), ) + + it.effect("rejects a root-combinator parameter schema at construction time", () => + Effect.gen(function* () { + const union = yield* Tool.define( + "unionroot", + Effect.succeed({ + ...makeTool("unionroot"), + parameters: Schema.Union([ + Schema.Struct({ a: Schema.String }), + Schema.Struct({ b: Schema.String }), + ]) as never, + }), + ) + const exit = yield* Effect.exit(union.init()) + if (Exit.isSuccess(exit)) throw new Error("expected construction to die") + const die = exit.cause.reasons.find(Cause.isDieReason) + const message = String(die?.defect) + expect(message).toContain("unionroot") + expect(message).toContain("plain object") + expect(message).toContain("params") + }), + ) }) diff --git a/packages/opencode/test/tool/workflow-authoring.test.ts b/packages/opencode/test/tool/workflow-authoring.test.ts index 97d44a9b3c..aea2b2b5b7 100644 --- a/packages/opencode/test/tool/workflow-authoring.test.ts +++ b/packages/opencode/test/tool/workflow-authoring.test.ts @@ -82,8 +82,10 @@ const worktreeLifecycleStartInput = { } as const const worktreeLifecyclePathInput = { - action: "start", - spec_path: ".opencode/workflows/worktree-lifecycle-repair.yaml", + params: { + action: "start", + spec_path: ".opencode/workflows/worktree-lifecycle-repair.yaml", + }, } as const // The previously observed polluted calls: a start that carries another @@ -114,16 +116,16 @@ describe("worktree-lifecycle regression fixtures", () => { test("model-facing graph actions require a YAML source path", () => { const spec = worktreeLifecycleStartInput.spec - expect(decode({ action: "start", spec_path: "workflow.yaml" })).toBe(true) - expect(decode({ action: "start", spec })).toBe(false) - expect(decode({ action: "extend", workflow_id: "dag_2x9k4m", spec_path: "extend.yaml" })).toBe(true) - expect(decode({ action: "extend", workflow_id: "dag_2x9k4m", spec })).toBe(false) + expect(decode({ params: { action: "start", spec_path: "workflow.yaml" }})).toBe(true) + expect(decode({ params: { action: "start", spec }})).toBe(false) + expect(decode({ params: { action: "extend", workflow_id: "dag_2x9k4m", spec_path: "extend.yaml" }})).toBe(true) + expect(decode({ params: { action: "extend", workflow_id: "dag_2x9k4m", spec }})).toBe(false) expect( - decode({ action: "control", operation: "replan", workflow_id: "dag_2x9k4m", spec_path: "replan.yaml" }), + decode({ params: { action: "control", operation: "replan", workflow_id: "dag_2x9k4m", spec_path: "replan.yaml" }}), ).toBe(true) - expect(decode({ action: "control", operation: "replan", workflow_id: "dag_2x9k4m", spec })).toBe(false) - expect(decode({ action: "validate", spec_path: "workflow.yaml", profile: "environment" })).toBe(true) - expect(decode({ action: "validate", spec, profile: "environment" })).toBe(false) + expect(decode({ params: { action: "control", operation: "replan", workflow_id: "dag_2x9k4m", spec }})).toBe(false) + expect(decode({ params: { action: "validate", spec_path: "workflow.yaml", profile: "environment" }})).toBe(true) + expect(decode({ params: { action: "validate", spec, profile: "environment" }})).toBe(false) }) test("decision brief route compiles under the block compiler", () => { @@ -154,7 +156,7 @@ describe("worktree-lifecycle regression fixtures", () => { }) test("accepted start carries only its YAML path while the authored fixture keeps complete blocks", () => { - expect(Object.keys(worktreeLifecyclePathInput)).toEqual(["action", "spec_path"]) + expect(Object.keys(worktreeLifecyclePathInput.params)).toEqual(["action", "spec_path"]) expect(worktreeLifecycleStartInput.spec.config.blocks.length).toBe(5) expect(decode(worktreeLifecyclePathInput)).toBe(true) }) @@ -167,12 +169,12 @@ describe("worktree-lifecycle regression fixtures", () => { expect(blockIDs).toEqual(["plan", "coding-worktree-core", "coding-callers-and-fixture", "verify", "review"]) // Strict decoding admits exactly the start-owned fields. const decoded = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" })(worktreeLifecyclePathInput) - expect(decoded.action).toBe("start") - expect("spec_path" in decoded).toBe(true) - expect("spec" in decoded).toBe(false) - expect("workflow_id" in decoded).toBe(false) - expect("operation" in decoded).toBe(false) - expect("node_id" in decoded).toBe(false) + expect(decoded.params.action).toBe("start") + expect("spec_path" in decoded.params).toBe(true) + expect("spec" in decoded.params).toBe(false) + expect("workflow_id" in decoded.params).toBe(false) + expect("operation" in decoded.params).toBe(false) + expect("node_id" in decoded.params).toBe(false) }) test("start polluted with empty workflow/control/result fields is rejected", () => { @@ -184,18 +186,18 @@ describe("worktree-lifecycle regression fixtures", () => { }) test("start without any graph source is rejected", () => { - expect(decode({ action: "start" })).toBe(false) + expect(decode({ params: { action: "start" }})).toBe(false) }) test("validate rejects control and result fields it does not own", () => { const spec_path = "workflow.yaml" - expect(decode({ action: "validate", spec_path, workflow_id: "dag_2x9k4m" })).toBe(false) - expect(decode({ action: "validate", spec_path, node_id: "verify" })).toBe(false) - expect(decode({ action: "validate", spec_path, operation: "cancel" })).toBe(false) - expect(decode({ action: "validate", spec_path, cursor: "", limit: 500 })).toBe(false) + expect(decode({ params: { action: "validate", spec_path, workflow_id: "dag_2x9k4m" }})).toBe(false) + expect(decode({ params: { action: "validate", spec_path, node_id: "verify" }})).toBe(false) + expect(decode({ params: { action: "validate", spec_path, operation: "cancel" }})).toBe(false) + expect(decode({ params: { action: "validate", spec_path, cursor: "", limit: 500 }})).toBe(false) // The validate action itself stays clean with its file source. - expect(decode({ action: "validate", spec_path, profile: "portable" })).toBe(true) - expect(decode({ action: "validate", spec_path: "saved-route", profile: "environment" })).toBe(true) + expect(decode({ params: { action: "validate", spec_path, profile: "portable" }})).toBe(true) + expect(decode({ params: { action: "validate", spec_path: "saved-route", profile: "environment" }})).toBe(true) }) test("model-facing start rejects inline admission objects entirely", () => { @@ -220,9 +222,9 @@ describe("worktree-lifecycle regression fixtures", () => { acknowledged_risks: ["unresolved rollout"], } const spec = { ...worktreeLifecycleStartInput.spec, mode: "deep", admission: cleanAdmission } - expect(decode({ action: "start", spec })).toBe(false) + expect(decode({ params: { action: "start", spec }})).toBe(false) for (const field of ["protocol_version", "state", "fingerprint"]) { - expect(decode({ action: "start", spec: { ...spec, admission: { ...cleanAdmission, [field]: "x" } } })).toBe(false) + expect(decode({ params: { action: "start", spec: { ...spec, admission: { ...cleanAdmission, [field]: "x" } } }})).toBe(false) } }) }) diff --git a/packages/opencode/test/tool/workflow-provider-schema.test.ts b/packages/opencode/test/tool/workflow-provider-schema.test.ts index ee7565a16c..d85181b468 100644 --- a/packages/opencode/test/tool/workflow-provider-schema.test.ts +++ b/packages/opencode/test/tool/workflow-provider-schema.test.ts @@ -6,7 +6,10 @@ import { ProviderTransform } from "../../src/provider/transform" // Wire-shape regression for the file-backed workflow entry: every action keeps // its discriminator and owned fields, while graph content stays out of the -// provider call and is supplied through spec_path. +// provider call and is supplied through spec_path. The schema root is a plain +// object whose single `params` property carries the discriminated union — +// root-level combinators are outside the OpenAI tools contract (DeepSeek +// rejects them explicitly, GLM answers with empty tool arguments). const openaiModel = { providerID: "openai", api: { id: "gpt-4.1", npm: "@ai-sdk/openai" } } as never const azureModel = { providerID: "azure", api: { id: "gpt-4.1", npm: "@ai-sdk/azure" } } as never @@ -29,9 +32,16 @@ type JsonSchemaNode = { [key: string]: unknown } +function root(schema: JsonSchemaNode): JsonSchemaNode { + expect(schema.type).toBe("object") + expect(schema.anyOf).toBeUndefined() + return schema +} + function branches(transformed: JsonSchemaNode): JsonSchemaNode[] { - expect(Array.isArray(transformed.anyOf)).toBe(true) - return transformed.anyOf ?? [] + const params = root(transformed).properties?.params + expect(Array.isArray(params?.anyOf)).toBe(true) + return params?.anyOf ?? [] } function branchByAction(transformed: JsonSchemaNode, action: string, withField?: string): JsonSchemaNode[] { @@ -49,17 +59,16 @@ function record(node: JsonSchemaNode | undefined): Record { - test("base wire shape is the 10-branch file-backed discriminated union", async () => { + test("base wire shape is a plain-object root carrying the 10-branch union in params", async () => { const schema = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode - const evidence = (await Bun.file( - new URL("./fixtures/workflow-parameters-post-change.json", import.meta.url), - ).json()) as JsonSchemaNode - expect(schema.anyOf?.length).toBe(10) + expect(schema.type).toBe("object") + expect(schema.anyOf).toBeUndefined() + expect(schema.required).toEqual(["params"]) + expect(branches(schema).length).toBe(10) const flat = JSON.stringify(schema) expect(flat).not.toContain('"session_id"') expect(flat).not.toContain('"project_id"') expect(flat).not.toContain('"skills"') - expect(Buffer.byteLength(flat, "utf8")).toBe(evidence.schema_bytes as number) }) test("every action stays representable after OpenAI transformation", () => { @@ -102,12 +111,13 @@ describe("workflow provider-facing schema", () => { expect(schema).not.toContain('"skills"') }) - test("Azure transformation keeps the same discriminated union", () => { + test("Azure transformation keeps the same params-carried union", () => { const transformed = ProviderTransform.schema( azureModel, ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode - expect(transformed.anyOf?.length).toBe(10) + expect(root(transformed).type).toBe("object") + expect(branches(transformed).length).toBe(10) expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) expect(branchByAction(transformed, "validate", "spec_path").length).toBeGreaterThan(0) }) @@ -117,7 +127,7 @@ describe("workflow provider-facing schema", () => { geminiModel, ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode - expect(transformed.anyOf?.length).toBe(10) + expect(branches(transformed).length).toBe(10) expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) expect(branchByAction(transformed, "start", "spec")).toEqual([]) const resultBranch = branchByAction(transformed, "result")[0] @@ -125,43 +135,15 @@ describe("workflow provider-facing schema", () => { expect(Object.keys(record(resultBranch))).toEqual(expect.arrayContaining(["cursor", "limit"])) }) - test("DeepSeek transformation presents the workflow union as an object-root function schema", () => { - const transformed = ProviderTransform.schema( - deepseekModel, - ToolJsonSchema.fromSchema(Parameters as never), - ) as JsonSchemaNode - expect(transformed.type).toBe("object") - expect(branches(transformed)).toHaveLength(10) - expect(branchByAction(transformed, "start", "spec_path")).toHaveLength(1) - }) - - test("OpenAI-compatible transports (GLM relay) also get the object-root union", () => { - const transformed = ProviderTransform.schema( - glmModel, - ToolJsonSchema.fromSchema(Parameters as never), - ) as JsonSchemaNode - expect(transformed.type).toBe("object") - expect(branches(transformed)).toHaveLength(10) - expect(branchByAction(transformed, "start", "spec_path")).toHaveLength(1) - }) - - test("post-change byte sizes stay at the recorded evidence", async () => { - const evidence = (await Bun.file( - new URL("./fixtures/workflow-parameters-post-change.json", import.meta.url), - ).json()) as { - schema_bytes: number - transformed: { openai: { bytes: number }; azure: { bytes: number }; gemini: { bytes: number } } + test("OpenAI-compatible transports (DeepSeek, GLM relay) see the same plain-object root", () => { + for (const model of [deepseekModel, glmModel]) { + const transformed = ProviderTransform.schema( + model, + ToolJsonSchema.fromSchema(Parameters as never), + ) as JsonSchemaNode + expect(root(transformed).type).toBe("object") + expect(branches(transformed)).toHaveLength(10) + expect(branchByAction(transformed, "start", "spec_path")).toHaveLength(1) } - const base = JSON.stringify(ToolJsonSchema.fromSchema(Parameters as never)) - expect(Buffer.byteLength(base, "utf8")).toBe(evidence.schema_bytes) - expect(Buffer.byteLength(JSON.stringify(ProviderTransform.schema(openaiModel, JSON.parse(base))), "utf8")).toBe( - evidence.transformed.openai.bytes, - ) - expect(Buffer.byteLength(JSON.stringify(ProviderTransform.schema(azureModel, JSON.parse(base))), "utf8")).toBe( - evidence.transformed.azure.bytes, - ) - expect(Buffer.byteLength(JSON.stringify(ProviderTransform.schema(geminiModel, JSON.parse(base))), "utf8")).toBe( - evidence.transformed.gemini.bytes, - ) }) }) diff --git a/packages/opencode/test/tool/workflow-schema-contract.test.ts b/packages/opencode/test/tool/workflow-schema-contract.test.ts new file mode 100644 index 0000000000..bbc43748c1 --- /dev/null +++ b/packages/opencode/test/tool/workflow-schema-contract.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ProviderTransform } from "../../src/provider/transform" +import { ToolJsonSchema } from "../../src/tool/json-schema" +import { Parameters } from "../../src/tool/workflow" + +const openaiModel = { providerID: "openai", api: { id: "gpt-4.1", npm: "@ai-sdk/openai" } } as never +const azureModel = { providerID: "azure", api: { id: "gpt-4.1", npm: "@ai-sdk/azure" } } as never +const geminiModel = { providerID: "google", api: { id: "gemini-3-pro", npm: "@ai-sdk/google" } } as never +const glmModel = { + providerID: "local-proxy", + api: { id: "glm-5.3", npm: "@ai-sdk/openai-compatible" }, +} as never + +type JsonSchemaNode = { + type?: string + anyOf?: JsonSchemaNode[] + oneOf?: JsonSchemaNode[] + allOf?: JsonSchemaNode[] + properties?: Record + required?: string[] +} + +function rootIsPlainObject(schema: JsonSchemaNode): boolean { + return schema.type === "object" && schema.anyOf === undefined && schema.oneOf === undefined && schema.allOf === undefined +} + +function branches(schema: JsonSchemaNode): JsonSchemaNode[] { + const nested = schema.properties?.params?.anyOf + return nested ?? [] +} + +function branchByAction(schema: JsonSchemaNode, action: string, field: string): JsonSchemaNode[] { + return branches(schema).filter( + (branch) => branch.properties?.action?.enum?.includes(action) && field in (branch.properties ?? {}), + ) +} + +function record(branch: JsonSchemaNode): Record { + return branch.properties ?? {} +} + +describe("workflow tool schema contract", () => { + test("serialized parameters root is a plain object for every provider transport", () => { + for (const model of [openaiModel, azureModel, geminiModel, glmModel]) { + const transformed = ProviderTransform.schema( + model, + ToolJsonSchema.fromSchema(Parameters as never), + ) as JsonSchemaNode + expect(rootIsPlainObject(transformed)).toBe(true) + } + }) + + test("the union survives intact inside the params property", () => { + const transformed = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode + expect(branches(transformed)).toHaveLength(10) + expect(transformed.properties?.params).toBeDefined() + expect(transformed.required).toEqual(["params"]) + }) + + test("each action branch keeps its own fields", () => { + const transformed = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode + expect(branchByAction(transformed, "start", "spec_path")).toHaveLength(1) + expect(branchByAction(transformed, "result", "cursor")).toHaveLength(1) + const resultBranch = branches(transformed).find( + (branch) => branch.properties?.action?.enum?.includes("result"), + )! + expect(resultBranch.required).toEqual(expect.arrayContaining(["workflow_id", "node_id"])) + }) + + test("decoded shape is { params: { action, ...fields } }", () => { + const decoded = Schema.decodeUnknownSync(Parameters)({ params: { action: "list" } }) + expect(decoded).toEqual({ params: { action: "list" } }) + }) + + test("root-level action shortcut is rejected — params is the only root field", () => { + expect(() => Schema.decodeUnknownSync(Parameters)({ action: "list" })).toThrow() + }) +}) From 2c89feb240b2172369f87eb84ebddf05f8f99aa8 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 17:54:32 +0800 Subject: [PATCH 2/2] test(tool): keep the schema contract test inside the oxlint warning ratchet --- packages/opencode/test/tool/workflow-schema-contract.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/test/tool/workflow-schema-contract.test.ts b/packages/opencode/test/tool/workflow-schema-contract.test.ts index bbc43748c1..2f8a43073b 100644 --- a/packages/opencode/test/tool/workflow-schema-contract.test.ts +++ b/packages/opencode/test/tool/workflow-schema-contract.test.ts @@ -1,3 +1,4 @@ +/* oxlint-disable typescript-eslint/no-unsafe-type-assertion -- These wire-shape tests intentionally traverse provider-owned recursive JSON Schema values. */ import { describe, expect, test } from "bun:test" import { Schema } from "effect" import { ProviderTransform } from "../../src/provider/transform" @@ -36,10 +37,6 @@ function branchByAction(schema: JsonSchemaNode, action: string, field: string): ) } -function record(branch: JsonSchemaNode): Record { - return branch.properties ?? {} -} - describe("workflow tool schema contract", () => { test("serialized parameters root is a plain object for every provider transport", () => { for (const model of [openaiModel, azureModel, geminiModel, glmModel]) {