diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt index 7de158df1f..32c57329ad 100644 --- a/packages/core/src/plugin/command/dag-flow.txt +++ b/packages/core/src/plugin/command/dag-flow.txt @@ -11,13 +11,13 @@ execution; the router still owns any material Decision Checkpoint. Prefer composable blocks for a fresh flow. Load `workflow(action="guide", topic="blocks")` only if the block contract is not -already in context. Use inline `spec` for one-off work; use `spec_path` only -when a saved workflow already matches or persistence was requested. Preserve +already in context. Write one-off work to a task-local YAML file and pass its +`spec_path`; a matching saved workflow name is also a valid `spec_path`. Preserve the task, user constraints, named roles, read-only limits, acceptance checks, and confirmed decisions in the objective and block instructions. -Call the workflow tool with `action=start` in the first response after the -route is ready. Printing a plan, JSON, or YAML does not start it. Never invent +Validate the YAML path, then call the workflow tool with `action=start` in the +first response after the route is ready. Printing a plan or YAML does not start it. Never invent worker types or model IDs. If a configured capability or model is unavailable, report the actual gap and leave the workflow uncreated. diff --git a/packages/core/src/plugin/command/workflow-blocks.md b/packages/core/src/plugin/command/workflow-blocks.md index 95feef7e21..0e06745d42 100644 --- a/packages/core/src/plugin/command/workflow-blocks.md +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -1,8 +1,8 @@ # Composable Workflow Blocks -Blocks are the high-level interface for assembling a one-off workflow. The -tool compiles them into ordinary durable DAG nodes before validation and -persistence. Existing node-based YAML remains compatible. +Blocks are the high-level interface for assembling a one-off workflow YAML +file. The tool compiles them into ordinary durable DAG nodes before validation +and persistence. Existing node-based YAML remains compatible. ## Shape @@ -33,10 +33,9 @@ config: depends_on: [verify] ``` -The parameter schema owns the exact block field shapes; the tool rejects -unknown or missing fields by name, and `workflow(action="validate")` reports -each field error with its path. This guide covers semantics and constraints -only — compose blocks against the schema, not against prose. +This guide owns the author-written block fields and semantics. The action +schema stays shallow and accepts only `spec_path`; the YAML validator rejects +unknown or missing graph fields by name and reports each error with its path. `objective` is required and is injected into every generated node. Use blocks or nodes, never both. Block IDs use letters, numbers, underscores, and hyphens. diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index 92394309a4..e2112b7c6d 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -55,12 +55,13 @@ instructions specialize the task and never name external Skills. When a saved route matches the topology, read it, retarget its objective and block instructions, and prune or add justified blocks before starting the -edited inline spec. Start `spec_path` directly only when its target already -matches exactly. Use low-level nodes only for bindings, conditions, output -schemas, or lifecycle metadata blocks cannot express. +edited YAML file. Start the saved `spec_path` directly only when its target +already matches exactly. Use low-level nodes only for bindings, conditions, +output schemas, or lifecycle metadata blocks cannot express. -Validate the composed or edited spec before start. Fix every diagnostic and -validate again; validation creates no workflow. A successful start returns the +Write the composed or edited graph to YAML and validate that `spec_path` before +start. Fix every diagnostic in the same file and validate again; validation +creates no workflow. A successful start returns the exact workflow ID. The parent owns the brief, graph, user interaction, checkpoints, controls, and final report; children own bounded executable work. End after start and let the workflow wake the parent. Do not poll merely to @@ -74,5 +75,6 @@ wait, and never claim an unstarted graph is running. - `guide(topic="policy")`: gates, recovery, and bounded repair. - `guide(topic="patterns")`: larger domain playbooks. -The tool parameter schema owns required fields and exclusivity; author calls -from that schema rather than reconstructed prose. +The tool parameter schema owns action fields and requires `spec_path`; the +on-demand block/interface guides own author-written YAML fields, and validation +is the final authority for the file. diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index bc1e296b4e..c802c0a880 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -36,11 +36,11 @@ as independent workstreams, cross-domain uncertainty, high blast radius, conflicting constraints, evidence gathering, or multiple verification perspectives. -For a one-off graph, pass `spec` inline on `start`, `extend`, or -`control(replan)`. This is the default: do not create a transient YAML file. -Use `spec_path` only for a saved workflow name, a reusable workflow file, or an -explicitly requested file-backed spec. Exactly one of `spec` and `spec_path` is -valid. After a validation failure, correct the same source and retry the call. +Before `start`, `extend`, `control(replan)`, or `validate`, write the graph to a +`.yaml` or `.yml` file and pass only `spec_path`. A one-off graph may use a +task-local file such as `.opencode/.dag-specs/.yaml`; it does not need to +become a saved library workflow. After a validation failure, edit that same +file and retry with the same path. Before a deep start, qualify the request interactively in the parent session. The start spec places `mode: deep`, a versioned `READY` or informed `WAIVED` @@ -94,10 +94,11 @@ Prefer a saved workflow when the user names a recurring procedure ("run the code review workflow") and the saved target/inputs already match: starting it is one call, and its graph has already been reviewed. When only its topology matches, call `{ action: "read", spec_path: "code-review" }`, retarget its objective and block instructions to the current task, prune or add lanes, then -start that edited value as an inline spec. `read` never starts a workflow. -Compose a fresh inline `spec` when the task is one-off or no reference fits. -To turn a working one-off spec into a saved workflow, persist it as YAML in one -of the two workflow-library directories under a descriptive name. +write the edited value to a task-local YAML file and start its `spec_path`. +`read` never starts a workflow. Compose a fresh task-local YAML file when the +task is one-off or no reference fits. To turn a working one-off spec into a +saved workflow, move it into one of the two workflow-library directories under +a descriptive name. ## Orchestration Lifecycle @@ -151,9 +152,9 @@ the workflow uncreated so the user can configure a model and retry. ## Collaboration Patterns Four structural patterns cover the common cases. Real workflows often combine -them. Every block below shows the object shape for inline `spec`; pass the -selected shape with `{ action: "start", spec: { ... } }`. Persist it as YAML -and use `spec_path` only when the workflow itself should be saved. +them. Every block below is YAML file content. Save the selected shape, validate +it with `{ action: "validate", spec_path: ".yaml" }`, then start it with +`{ action: "start", spec_path: ".yaml" }`. ### 1. Staged Pipeline with Gate @@ -540,10 +541,9 @@ All nodes share the same workspace. Write conflicts are an orchestration concern ### Actions **start** — Create a workflow from `config` and optional `title`, `mode`, and -admission input. For a one-off graph call -`{ action: "start", spec: { config: { ... } } }`. Use `spec_path` for a saved -workflow name (`{ action: "start", spec_path: "code-review" }`) or an explicit -YAML path. +admission input stored in YAML. Pass a task-local YAML path for one-off work or +a saved workflow name such as +`{ action: "start", spec_path: "code-review" }`. Returns the workflow ID. Nodes declare `depends_on` (node IDs); layers and execution order are computed automatically. @@ -553,14 +553,15 @@ not running workflows; use `status` for a workflow's live state. **read** — Return one saved workflow as structured JSON without starting it. Pass `spec_path`, then retarget generic objectives and block instructions in -the parent before using the edited result as an inline `start` spec. +the parent, write the edited result to YAML, and start that file by path. **extend** — Add nodes to a running workflow. Existing nodes are unaffected; new nodes are immediately eligible for scheduling if their dependencies are met. It also accepts a genuinely additive wave after a reporting leaf checkpoint naturally completed the current graph; an early -`control(complete)` workflow remains terminal. Put the new nodes under `spec.nodes`, -then call `{ action: "extend", workflow_id: "dag_...", spec: { nodes: [...] } }`. +`control(complete)` workflow remains terminal. Put the new nodes under `nodes` +in a YAML file, then call +`{ action: "extend", workflow_id: "dag_...", spec_path: "extend.yaml" }`. **status** — Read the durable state of one workflow and all of its nodes. Pass `workflow_id`. Use it when the user explicitly asks for current state or once before a decision that requires fresh state, such as replan/control. Do not poll a running workflow merely to wait: node reports and terminal outcomes wake the parent session automatically. @@ -576,7 +577,7 @@ omitted content from its preview. - `pause` — let running nodes finish, don't spawn new ones (pause does NOT stop nodes that are already running). On a cancel/replan intent, always pause FIRST: it needs no fragment and freezes scheduling while you compose the replan, so the graph cannot terminalize under you. - `resume` — resume scheduling - `cancel` — cancel the entire workflow -- `replan` — pass `spec: { fragment: { ... } }` with the graph fields and node definitions; running nodes can be `restart: true` or `cancel: true`; pending nodes absent from the fragment are cancelled. Valid while paused — the pause → compose spec → replan → resume sequence is the safe path. Use `spec_path` only for a saved or explicitly file-backed fragment. +- `replan` — put `fragment: { ... }` with the graph fields and node definitions in YAML and pass its `spec_path`; running nodes can be `restart: true` or `cancel: true`; pending nodes absent from the fragment are cancelled. Valid while paused — the pause → write file → replan → resume sequence is the safe path. - `complete` — early-complete: remaining pending nodes are skipped (non-violation) - `step` — advance exactly one ready node (the first by node ID lexicographic order), then wait. Use for controlled debugging or staged verification of a critical path. Unlike `pause`, which freezes all scheduling, `step` advances one node and re-waits. A second `step` while the stepped node is still running is rejected. Use `resume` to return to full-speed scheduling. Nodes are selected in lexicographic ID order for determinism. diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 264fe05098..328c5424c4 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -107,10 +107,10 @@ describe("CommandPlugin.Plugin", () => { }), ) - it.effect("uses inline specs for one-off graphs without removing saved workflows", () => + it.effect("uses file-backed specs for one-off and saved workflows", () => Effect.sync(() => { - expect(CommandPlugin.WorkflowFactsContent).toContain("For a one-off graph, pass `spec` inline") - expect(CommandPlugin.WorkflowFactsContent).toContain("Use `spec_path` only") + expect(CommandPlugin.WorkflowFactsContent).toContain("write the graph to a") + expect(CommandPlugin.WorkflowFactsContent).toContain("task-local file") // The resident description keeps tool selection and the progressive // guide index only; per-action field semantics live in the parameter // schema (change repair-workflow-authoring-validation). @@ -118,9 +118,9 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.WorkflowContent).toContain("parameter schema") expect(CommandPlugin.WorkflowFactsContent).toContain('{ action: "read", spec_path: "code-review" }') expect(CommandPlugin.WorkflowFactsContent).toContain("retarget its objective and block instructions") - expect(CommandPlugin.WorkflowFactsContent).not.toContain("Never inline graph nodes") - expect(CommandPlugin.WorkflowFactsContent).not.toContain("Before any graph-carrying action") - expect(CommandPlugin.DagFlowContent).toContain("inline `spec`") + expect(CommandPlugin.WorkflowFactsContent).not.toContain("pass `spec` inline") + expect(CommandPlugin.DagFlowContent).toContain("task-local YAML file") + expect(CommandPlugin.DagFlowContent).toContain("`spec_path`") }), ) @@ -346,7 +346,8 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.WorkflowFactsContent).toContain( "the workflow boundary owns `protocol_version`, `state`, and\n`fingerprint`", ) - expect(CommandPlugin.WorkflowFactsContent).toContain("For a one-off graph, pass `spec` inline") + expect(CommandPlugin.WorkflowFactsContent).toContain("A one-off graph may use a") + expect(CommandPlugin.WorkflowFactsContent).toContain("task-local file") expect(CommandPlugin.WorkflowFactsContent).not.toContain("`config.mode`") }), ) diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md index 063b7ade16..3cd98f308e 100644 --- a/packages/opencode/src/dag/CONTEXT.md +++ b/packages/opencode/src/dag/CONTEXT.md @@ -1,12 +1,12 @@ # Workflow Orchestration Context -Workflow Orchestration turns one user objective into one durable DAG. It supports saved or inline custom workflows and recommends heuristic composition from reusable Blocks. Low-level Nodes remain available when a Block route cannot express the objective. +Workflow Orchestration turns one user objective into one durable DAG. Its model-facing tool accepts saved or file-backed custom workflows and recommends heuristic composition from reusable Blocks. Low-level Nodes remain available when a Block route cannot express the objective. ## Glossary | Term | Meaning | | --- | --- | -| Workflow Source | An inline object or YAML document supplied to start, extend, replan, read, validate, or release tooling. | +| Workflow Source | An in-memory object used by trusted internal callers or a YAML document supplied to runtime and release tooling. Model-authored graph actions use YAML through `spec_path`. | | Workflow Authoring Check | The side-effect-free source-to-graph boundary that parses, normalizes file compatibility, decodes the action shape, compiles Blocks, applies the selected validation profile, and returns diagnostics or a Prepared Workflow Graph. | | Prepared Workflow Graph | A strictly decoded and compiled graph that passed the requested authoring checks and is ready for a runtime mutation. | | Workflow Route | A complete Block or Node composition selected for one objective. It may be custom, saved, or assembled heuristically. | @@ -28,6 +28,7 @@ Workflow Orchestration turns one user objective into one durable DAG. It support - `portable` validation does not load user environment catalogs. `environment` validation reads current catalogs and verifies actual model availability. - No workflow event or durable mutation occurs before a valid Prepared Workflow Graph exists. - The model-facing schema contains fields the model owns. Session/Project identity, admission audit state, model assignment, and other runtime-derived fields remain hidden. +- Model-facing graph actions expose only `spec_path`; graph fields live in YAML so provider tool-call serialization cannot turn a nested graph into a string. - Legacy YAML may be adapted at the file boundary without making legacy fields valid inline input. - Runtime Admission and Workflow Authoring Check have separate names, state, and responsibilities. diff --git a/packages/opencode/src/dag/docs/adr/0001-workflow-authoring-check.md b/packages/opencode/src/dag/docs/adr/0001-workflow-authoring-check.md index 5c1779590b..2dc2f5da23 100644 --- a/packages/opencode/src/dag/docs/adr/0001-workflow-authoring-check.md +++ b/packages/opencode/src/dag/docs/adr/0001-workflow-authoring-check.md @@ -5,7 +5,7 @@ ## Context -Workflow input was interpreted independently by the provider-facing tool schema, start, validate, list/read, replan, CLI, generation, and packaging. Hidden YAML authoring removed the model's accidental examples while leaving it unable to infer required fields. Later patches added validators at individual callers, so accepted shapes and diagnostics drifted and some paths reached durable DAG operations before equivalent checks had run. +Workflow input was interpreted independently by the provider-facing tool schema, start, validate, list/read, replan, CLI, generation, and packaging. Earlier hidden YAML authoring left the model unable to infer required fields. Exposing the complete graph as an inline tool argument fixed discoverability but introduced another failure mode: providers or models could double-serialize the nested `spec` object into a JSON string before validation. Field guidance now belongs to the on-demand workflow guides, while the model-facing action remains shallow and file-backed. The product supports a single custom workflow, saved workflows, and heuristic Block composition. Those are source choices for one orchestration product, not separate validation systems. @@ -15,13 +15,18 @@ The product supports a single custom workflow, saved workflows, and heuristic Bl All tool graph actions and offline config/release consumers call this boundary. Callers may authorize and read files or perform durable DAG mutations, but they do not reinterpret source shape or decide graph validity. +Model-facing `start`, `extend`, `control(replan)`, and `validate` accept only +`spec_path`. One-off graphs use task-local YAML files; saved workflow names use +the same field. Trusted internal consumers may still pass an in-memory source +directly to `WorkflowAuthoring` without creating a second validation path. + The provider schema exposes only author-owned fields. Runtime identity, model assignment, and persisted admission audit fields are derived or adapted behind the boundary. Portable checks are environment-free; environment checks resolve live catalogs and are not cached as content-only facts. ## Consequences - A valid source has one compiled meaning across validate, start, extend, replan, read/list diagnostics, CI, generation, and packaging. -- Provider schema is sufficient for model authoring without exposing runtime-owned fields. -- Legacy YAML remains readable while new inline input stays strict. +- Provider schema stays shallow; on-demand guides describe author-owned YAML fields without exposing runtime-owned fields. +- File-backed custom and saved workflows share one YAML validation path; internal in-memory input stays strict. - Environment changes are observed on the next environment check. - Durable DAG methods retain lifecycle validation as defense in depth, but do not become a second raw-source validator. diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index f1a87358b0..b242722b80 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -47,34 +47,18 @@ export { Parameters as WorkflowParameters } // derives ownership from the calling session. // ============================================================================ -const specDescription = "Inline structured spec for a one-off graph. Use this or spec_path, never both" const specPathDescription = - '(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory' + '(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory' -const StartInline = Schema.Struct({ - action: Schema.Literal("start").annotate({ description: "Create a workflow" }), - spec: DagValidation.StartSpec.annotate({ description: specDescription }), -}) const StartPath = Schema.Struct({ action: Schema.Literal("start").annotate({ description: "Create a workflow" }), spec_path: Schema.String.annotate({ description: specPathDescription }), }) -const ExtendInline = Schema.Struct({ - action: Schema.Literal("extend").annotate({ description: "Add nodes or blocks to a live workflow" }), - workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), - spec: DagValidation.ExtendSpec.annotate({ description: specDescription }), -}) const ExtendPath = Schema.Struct({ action: Schema.Literal("extend").annotate({ description: "Add nodes or blocks to a live workflow" }), workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), spec_path: Schema.String.annotate({ description: specPathDescription }), }) -const ControlReplanInline = Schema.Struct({ - action: Schema.Literal("control").annotate({ description: "Control a live workflow" }), - operation: Schema.Literal("replan").annotate({ description: "Apply a node fragment (add/cancel/restart/replace)" }), - workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), - spec: DagValidation.ReplanSpec.annotate({ description: specDescription }), -}) const ControlReplanPath = Schema.Struct({ action: Schema.Literal("control").annotate({ description: "Control a live workflow" }), operation: Schema.Literal("replan").annotate({ description: "Apply a node fragment (add/cancel/restart/replace)" }), @@ -121,14 +105,7 @@ const Guide = Schema.Struct({ }) const ValidationProfile = Schema.optional(Schema.Literals(["portable", "environment"])).annotate({ description: - "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, inline and project/global specs environment", -}) -const ValidateInline = Schema.Struct({ - action: Schema.Literal("validate").annotate({ - description: "Pre-flight a custom spec without creating a workflow; returns diagnostics, never a workflow ID", - }), - spec: DagValidation.StartSpec.annotate({ description: specDescription }), - profile: ValidationProfile, + "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, project/global/path specs environment", }) const ValidatePath = Schema.Struct({ action: Schema.Literal("validate").annotate({ @@ -139,11 +116,8 @@ const ValidatePath = Schema.Struct({ }) export const Parameters = Schema.Union([ - StartInline, StartPath, - ExtendInline, ExtendPath, - ControlReplanInline, ControlReplanPath, ControlOther, Status, @@ -151,7 +125,6 @@ export const Parameters = Schema.Union([ List, Read, Guide, - ValidateInline, ValidatePath, ]) @@ -267,7 +240,7 @@ 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 | spec_path}; extend {workflow_id, spec | spec_path}; control {workflow_id, operation} plus spec/spec_path for replan; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec | spec_path, profile?}. Graph-carrying actions take exactly one source (spec or spec_path), and session/project identity is never a parameter.", + "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) => Effect.gen(function* () { @@ -372,21 +345,18 @@ export const WorkflowTool = Tool.define< } } case "validate": { - const loaded = - "spec" in params - ? { path: "", source: { kind: "inline" as const, value: params.spec } } - : yield* loadSpecFile(params.spec_path, callingSession.directory, ctx).pipe( - Effect.map((file) => ({ - path: file.path, - source: { kind: "yaml" as const, source: file.path, content: file.content }, - })), - Effect.catch((error: unknown) => - Effect.succeed({ - path: params.spec_path, - loadError: error instanceof Error ? error.message : String(error), - }), - ), - ) + const loaded = yield* loadSpecFile(params.spec_path, callingSession.directory, ctx).pipe( + Effect.map((file) => ({ + path: file.path, + source: { kind: "yaml" as const, source: file.path, content: file.content }, + })), + Effect.catch((error: unknown) => + Effect.succeed({ + path: params.spec_path, + loadError: error instanceof Error ? error.message : String(error), + }), + ), + ) const profile = params.profile ?? (DagWorkflows.isBuiltinPath(loaded.path) ? "portable" : "environment") const result = "loadError" in loaded @@ -537,11 +507,9 @@ export const WorkflowTool = Tool.define< } case "start": { const sessionID = SessionID.make(ctx.sessionID) - const source = yield* loadAuthoringSource( - "spec" in params ? { inline: params.spec } : { specPath: params.spec_path }, - callingSession.directory, - ctx, - ).pipe(Effect.orDie) + const source = yield* loadAuthoringSource(params.spec_path, callingSession.directory, ctx).pipe( + Effect.orDie, + ) const result = yield* authoring.prepare({ action: "start", source, @@ -612,11 +580,9 @@ export const WorkflowTool = Tool.define< const knownDependencies = (yield* dag.store.getNodes(params.workflow_id).pipe(Effect.orDie)).map( (node) => node.id, ) - const source = yield* loadAuthoringSource( - "spec" in params ? { inline: params.spec } : { specPath: params.spec_path }, - callingSession.directory, - ctx, - ).pipe(Effect.orDie) + const source = yield* loadAuthoringSource(params.spec_path, callingSession.directory, ctx).pipe( + Effect.orDie, + ) const result = yield* authoring.prepare({ action: "extend", source, @@ -642,11 +608,9 @@ export const WorkflowTool = Tool.define< if (params.operation === "replan") { const workflowDefaults = Dag.parseWorkflowConfig(workflow.config)?.node_defaults const knownDependencies = (yield* dag.store.getNodes(wfId).pipe(Effect.orDie)).map((node) => node.id) - const source = yield* loadAuthoringSource( - "spec" in params ? { inline: params.spec } : { specPath: params.spec_path }, - callingSession.directory, - ctx, - ).pipe(Effect.orDie) + const source = yield* loadAuthoringSource(params.spec_path, callingSession.directory, ctx).pipe( + Effect.orDie, + ) const result = yield* authoring.prepare({ action: "replan", source, @@ -783,12 +747,11 @@ function loadSpecFile(specPath: string, directory: string, ctx: Tool.Context) { } function loadAuthoringSource( - input: { inline: unknown; specPath?: never } | { inline?: never; specPath: string }, + specPath: string, directory: string, ctx: Tool.Context, ): Effect.Effect { - if ("inline" in input) return Effect.succeed({ kind: "inline" as const, value: input.inline }) - return loadSpecFile(input.specPath, directory, ctx).pipe( + return loadSpecFile(specPath, directory, ctx).pipe( Effect.map((file) => ({ kind: "yaml" as const, source: file.path, content: file.content })), ) } diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index c00efb1d2e..68f144bdbb 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -542,7 +542,7 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "guide", topic: "blocks" })).not.toThrow() }) - it("retains an inline structured spec", () => { + it("rejects inline structured specs and JSON-stringified specs", () => { const decode = Schema.decodeUnknownSync(Parameters) const spec = { config: { @@ -551,7 +551,8 @@ describe("workflow tool schema (negative tests)", () => { }, } - expect(decode({ action: "start", spec })).toEqual({ action: "start", spec }) + expect(() => decode({ action: "start", spec })).toThrow() + expect(() => decode({ action: "start", spec: JSON.stringify(spec) })).toThrow() }) it("action field rejects unknown actions", () => { @@ -585,7 +586,7 @@ describe("workflow tool schema (negative tests)", () => { } }) - it("control replan requires exactly one graph source", () => { + 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" }), @@ -597,7 +598,7 @@ describe("workflow tool schema (negative tests)", () => { operation: "replan", spec: { fragment: { name: "fragment", nodes: [] } }, }), - ).not.toThrow() + ).toThrow() expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "replan" })).toThrow() }) @@ -607,9 +608,9 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "start" })).toThrow() }) - it("keeps workflow graph and admission fields inside spec", () => { - const decode = Schema.decodeUnknownSync(Parameters) - expect( + it("keeps workflow graph and admission fields inside the YAML file", () => { + const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) + expect(() => decode({ action: "start", spec_path: ".opencode/workflows/deep.yaml", @@ -620,7 +621,8 @@ describe("workflow tool schema (negative tests)", () => { nodes: [], }, }), - ).toEqual({ + ).toThrow() + expect(decode({ action: "start", spec_path: ".opencode/workflows/deep.yaml" })).toEqual({ action: "start", spec_path: ".opencode/workflows/deep.yaml", }) @@ -841,7 +843,7 @@ describe("workflow tool execution", () => { ) const extendExit = yield* Effect.exit( workflow.execute( - { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec: { nodes: [] } }, + { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec_path: "foreign.yaml" }, foreignContext, ), ) @@ -908,25 +910,26 @@ describe("workflow tool execution", () => { }), ) - runtime.effect("starts from an inline structured spec without a file", () => + runtime.effect("starts from a YAML workflow file", () => Effect.gen(function* () { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() + const spec_path = yield* writeWorkflowSpec("file-start", { + config: { + name: "file-start", + nodes: [], + }, + }) const result = yield* workflow.execute( Schema.decodeUnknownSync(Parameters)({ action: "start", - spec: { - config: { - name: "inline-start", - nodes: [], - }, - }, + spec_path, }), toolContext(), ) - expect(result.title).toBe("Workflow started: inline-start") + expect(result.title).toBe("Workflow started: file-start") expect(result.metadata.workflowId).toBeDefined() expect(published.some((event) => event.type === DagEvent.WorkflowCreated.type)).toBe(true) }), @@ -937,20 +940,21 @@ describe("workflow tool execution", () => { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() + const spec_path = yield* writeWorkflowSpec("block-start", { + config: { + name: "block-start", + objective: "Implement and review session recovery", + blocks: [ + { id: "build", kind: "coding" }, + { id: "verify", kind: "verify", depends_on: ["build"] }, + { id: "review", kind: "review", depends_on: ["verify"] }, + ], + }, + }) const result = yield* workflow.execute( Schema.decodeUnknownSync(Parameters)({ action: "start", - spec: { - config: { - name: "block-start", - objective: "Implement and review session recovery", - blocks: [ - { id: "build", kind: "coding" }, - { id: "verify", kind: "verify", depends_on: ["build"] }, - { id: "review", kind: "review", depends_on: ["verify"] }, - ], - }, - }, + spec_path, }), toolContext(), ) @@ -973,33 +977,34 @@ describe("workflow tool execution", () => { }), ) - runtime.effect("extends from an inline structured spec without a file", () => + runtime.effect("extends from a YAML workflow file", () => Effect.gen(function* () { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() + const spec_path = yield* writeWorkflowSpec("file-extend", { + nodes: [ + { + id: "file-added", + name: "File added", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], + }) const result = yield* workflow.execute( Schema.decodeUnknownSync(Parameters)({ action: "extend", workflow_id: "dag_defaults", - spec: { - nodes: [ - { - id: "inline-added", - name: "Inline added", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }, - ], - }, + spec_path, }), toolContext(), ) expect(result.title).toBe("Workflow extended: 1 nodes added") expect(published.find((event) => event.type === DagEvent.NodeRegistered.type)?.data).toEqual( - expect.objectContaining({ nodeID: "inline-added" }), + expect.objectContaining({ nodeID: "file-added" }), ) }), ) @@ -1009,14 +1014,15 @@ describe("workflow tool execution", () => { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() + const spec_path = yield* writeWorkflowSpec("block-extend", { + objective: "Repair from the current diagnostic evidence", + blocks: [{ id: "repair", kind: "coding", depends_on: ["node_running"] }], + }) const result = yield* workflow.execute( Schema.decodeUnknownSync(Parameters)({ action: "extend", workflow_id: "dag_status", - spec: { - objective: "Repair from the current diagnostic evidence", - blocks: [{ id: "repair", kind: "coding", depends_on: ["node_running"] }], - }, + spec_path, }), toolContext(), ) @@ -1028,42 +1034,43 @@ describe("workflow tool execution", () => { }), ) - runtime.effect("replans from an inline structured spec without a file", () => + runtime.effect("replans from a YAML workflow file", () => Effect.gen(function* () { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() + const spec_path = yield* writeWorkflowSpec("file-replan", { + fragment: { + name: "file-replan", + nodes: [ + { + id: "file-replanned", + name: "File replanned", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], + }, + }) const result = yield* workflow.execute( Schema.decodeUnknownSync(Parameters)({ action: "control", workflow_id: "dag_defaults", operation: "replan", - spec: { - fragment: { - name: "inline-replan", - nodes: [ - { - id: "inline-replanned", - name: "Inline replanned", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }, - ], - }, - }, + spec_path, }), toolContext(), ) expect(result.title).toContain("Workflow replanned: +1") expect(published.find((event) => event.type === DagEvent.NodeRegistered.type)?.data).toEqual( - expect.objectContaining({ nodeID: "inline-replanned" }), + expect.objectContaining({ nodeID: "file-replanned" }), ) }), ) - runtime.effect("rejects ambiguous or missing spec sources before side effects", () => + runtime.effect("rejects inline or missing spec sources before side effects", () => Effect.gen(function* () { const info = yield* WorkflowTool const workflow = yield* info.init() @@ -1093,10 +1100,10 @@ describe("workflow tool execution", () => { expect(published).toHaveLength(0) } - // The recovery guidance names both valid source variants. + // Recovery guidance tells the model to move graph content into YAML. const guidance = workflow.formatValidationError?.(new Error("no branch matched")) ?? "" - expect(guidance).toContain("exactly one source") - expect(guidance).toContain("spec or spec_path") + expect(guidance).toContain("start {spec_path}") + expect(guidance).toContain("Put graph content in a .yaml/.yml file") }), ) @@ -1419,9 +1426,15 @@ config: depends_on: [], prompt_template: { inline: "work" }, } + const extendPath = path.join(missingModelDirectory, "unresolved-extend.yaml") + const replanPath = path.join(missingModelDirectory, "unresolved-replan.yaml") + yield* Effect.promise(() => Bun.write(extendPath, JSON.stringify({ nodes: [node] }))) + yield* Effect.promise(() => + Bun.write(replanPath, JSON.stringify({ fragment: { name: "unresolved-replan", nodes: [node] } })), + ) const extendExit = yield* workflow - .execute({ action: "extend", workflow_id: Dag.ID.make("dag_paused"), spec: { nodes: [node] } }, toolContext()) + .execute({ action: "extend", workflow_id: Dag.ID.make("dag_paused"), spec_path: extendPath }, toolContext()) .pipe(Effect.exit) const replanExit = yield* workflow .execute( @@ -1429,7 +1442,7 @@ config: action: "control", operation: "replan", workflow_id: Dag.ID.make("dag_paused"), - spec: { fragment: { name: "unresolved-replan", nodes: [node] } }, + spec_path: replanPath, }, toolContext(), ) @@ -1456,9 +1469,11 @@ config: depends_on: [], prompt_template: { inline: "work" }, } + const extendPath = path.join(missingModelDirectory, "modeled-extend.yaml") + 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: { nodes: [node] } }, + { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec_path: extendPath }, toolContext(), ) const replanned = yield* workflow.execute( @@ -1915,20 +1930,7 @@ config: decode({ action: "start", session_id: "ses_other_parent", - spec: { - config: { - name: "foreign-parent", - nodes: [ - { - id: "work", - name: "work", - worker_type: "build", - depends_on: [], - prompt_template: { inline: "work" }, - }, - ], - }, - }, + spec_path: "foreign-parent.yaml", }), ).toThrow() }), @@ -2133,12 +2135,15 @@ describe("workflow tool saved workflows", () => { environmentSkillListCalls = 0 const info = yield* WorkflowTool const workflow = yield* info.init() + const spec_path = yield* writeWorkflowSpec("portable-file", { + config: { name: "portable-file", nodes: [] }, + }) const result = yield* workflow.execute( { action: "validate", profile: "portable", - spec: { config: { name: "portable-inline", nodes: [] } }, + spec_path, }, toolContext(), ) @@ -2157,32 +2162,33 @@ describe("workflow tool saved workflows", () => { environmentProviderGetModelCalls = 0 const info = yield* WorkflowTool const workflow = yield* info.init() + const spec_path = yield* writeWorkflowSpec("catalog-snapshot", { + config: { + name: "catalog-snapshot", + nodes: [ + { + id: "first", + name: "First", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "First" }, + }, + { + id: "second", + name: "Second", + worker_type: "build", + depends_on: ["first"], + prompt_template: { inline: "Second" }, + }, + ], + }, + }) const result = yield* workflow.execute( { action: "validate", profile: "environment", - spec: { - config: { - name: "catalog-snapshot", - nodes: [ - { - id: "first", - name: "First", - worker_type: "build", - depends_on: [], - prompt_template: { inline: "First" }, - }, - { - id: "second", - name: "Second", - worker_type: "build", - depends_on: ["first"], - prompt_template: { inline: "Second" }, - }, - ], - }, - }, + spec_path, }, toolContext(), ) @@ -2200,7 +2206,7 @@ describe("workflow tool saved workflows", () => { Effect.gen(function* () { published.length = 0 // project scope shadows global for the same name; builtin fills the - // gap a file scope does not own; inline stays session-local. + // gap a file scope does not own; an explicit path stays session-local. const routeSpec = (name: string) => `title: ${name} title\nconfig:\n name: ${name}\n objective: Route objective\n blocks:\n - id: plan\n kind: plan\n` yield* Effect.promise(() => @@ -2211,6 +2217,7 @@ describe("workflow tool saved workflows", () => { path.join(workflowSpecDirectory, ".opencode", "workflows", "shared-route.yaml"), routeSpec("project-route"), ), + Bun.write(path.join(workflowSpecDirectory, "path-route.yaml"), routeSpec("path-route")), ]), ) const previousBuiltin = (globalThis as Record).OPENCODE_DAG_TEMPLATES @@ -2240,24 +2247,18 @@ describe("workflow tool saved workflows", () => { expect(builtinResult.profile).toBe("portable") expect(builtinResult.valid).toBe(true) - // inline source validates under the environment profile by default - const inlineValidate = yield* workflow.execute( + // explicit path source validates under the environment profile by default + const pathValidate = yield* workflow.execute( { action: "validate", - spec: { - config: { - name: "inline-route", - objective: "Inline objective", - blocks: [{ id: "plan", kind: "plan" }], - }, - }, + spec_path: "path-route.yaml", }, contextWith([]), ) - const inlineResult = JSON.parse(inlineValidate.output) - expect(inlineResult.source).toBe("") - expect(inlineResult.profile).toBe("environment") - expect(inlineResult.valid).toBe(true) + const pathResult = JSON.parse(pathValidate.output) + expect(pathResult.source).toBe(path.join(workflowSpecDirectory, "path-route.yaml")) + expect(pathResult.profile).toBe("environment") + expect(pathResult.valid).toBe(true) // validate and start see the same resolved content: validate passes, // start succeeds from the same name, and mutating the file changes diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 772564d18e..3dffba21cf 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -448,454 +448,6 @@ 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": { - "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", - "properties": { - "admission": { - "properties": { - "acknowledged_risks": { - "items": { - "type": "string", - }, - "type": "array", - }, - "brief": { - "properties": { - "acceptance_criteria": { - "items": { - "type": "string", - }, - "type": "array", - }, - "assumptions": { - "items": { - "type": "string", - }, - "type": "array", - }, - "blocking_questions": { - "items": { - "type": "string", - }, - "type": "array", - }, - "constraints": { - "items": { - "type": "string", - }, - "type": "array", - }, - "evidence_required": { - "items": { - "type": "string", - }, - "type": "array", - }, - "goal": { - "type": "string", - }, - "open_questions": { - "items": { - "type": "string", - }, - "type": "array", - }, - "review_plan": { - "items": { - "type": "string", - }, - "type": "array", - }, - "risks": { - "items": { - "type": "string", - }, - "type": "array", - }, - "scope": { - "properties": { - "in": { - "items": { - "type": "string", - }, - "type": "array", - }, - "out": { - "items": { - "type": "string", - }, - "type": "array", - }, - }, - "required": [ - "in", - "out", - ], - "type": "object", - }, - }, - "required": [ - "goal", - "scope", - "constraints", - "assumptions", - "acceptance_criteria", - "evidence_required", - "risks", - "review_plan", - "open_questions", - "blocking_questions", - ], - "type": "object", - }, - "brief_revision": { - "type": "number", - }, - "qa_mode": { - "enum": [ - "LIGHT", - "STANDARD", - "GRILL", - ], - "type": "string", - }, - "verdict": { - "enum": [ - "READY", - "NOT_READY", - "WAIVED", - ], - "type": "string", - }, - "waiver_reason": { - "type": "string", - }, - }, - "required": [ - "brief_revision", - "qa_mode", - "verdict", - "brief", - ], - "type": "object", - }, - "config": { - "anyOf": [ - { - "properties": { - "blocks": { - "description": "Composable blocks compiled into nodes by the runtime", - "items": { - "properties": { - "depends_on": { - "description": "Block IDs this block waits for. Defaults to []", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique block identifier; dependencies target block IDs", - "type": "string", - }, - "instruction": { - "description": "Task-specific instruction added to the block's built-in execution contract", - "type": "string", - }, - "kind": { - "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", - "enum": [ - "explore", - "plan", - "prototype", - "debug", - "coding", - "verify", - "review", - "synthesize", - ], - "type": "string", - }, - "report_to_parent": { - "description": "Override wake behavior. Review decisions and synthesis report by default", - "type": "boolean", - }, - "required": { - "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", - "type": "boolean", - }, - "worker_type": { - "description": "Optional configured agent override; defaults from the block kind", - "type": "string", - }, - }, - "required": [ - "id", - "kind", - ], - "type": "object", - }, - "type": "array", - }, - "max_concurrency": { - "description": "Max parallel nodes. Default: 5", - "type": "number", - }, - "max_node_replan_attempts": { - "description": "Max replan restarts per node ID. Default: 5", - "type": "number", - }, - "max_total_nodes": { - "description": "Cumulative node cap across the workflow lifetime. Default: 100", - "type": "number", - }, - "name": { - "description": "Workflow name", - "type": "string", - }, - "node_defaults": { - "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", - "properties": { - "report_to_parent": { - "type": "boolean", - }, - "required": { - "type": "boolean", - }, - "worker_config": { - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - }, - "type": "object", - }, - "objective": { - "description": "Injected into every generated child prompt; required for blocks", - "type": "string", - }, - }, - "required": [ - "name", - "objective", - "blocks", - ], - "type": "object", - }, - { - "properties": { - "max_concurrency": { - "description": "Max parallel nodes. Default: 5", - "type": "number", - }, - "max_node_replan_attempts": { - "description": "Max replan restarts per node ID. Default: 5", - "type": "number", - }, - "max_total_nodes": { - "description": "Cumulative node cap across the workflow lifetime. Default: 100", - "type": "number", - }, - "name": { - "description": "Workflow name", - "type": "string", - }, - "node_defaults": { - "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", - "properties": { - "report_to_parent": { - "type": "boolean", - }, - "required": { - "type": "boolean", - }, - "worker_config": { - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - }, - "type": "object", - }, - "nodes": { - "description": "Low-level node declarations", - "items": { - "properties": { - "cancel": { - "description": "(replan only) Cancel this node", - "type": "boolean", - }, - "condition": { - "description": "Expression evaluated before spawn; node is skipped if false", - "type": "string", - }, - "depends_on": { - "description": "Node IDs this node waits for ([] for root)", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique node identifier, used in depends_on", - "type": "string", - }, - "input_mapping": { - "additionalProperties": { - "type": "string", - }, - "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", - "type": "object", - }, - "name": { - "description": "Human-readable node name", - "type": "string", - }, - "output_schema": { - "description": "JSON Schema; child agent must call submit_result to submit structured output", - "type": "object", - }, - "prompt_template": { - "anyOf": [ - { - "properties": { - "inline": { - "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "inline", - ], - "type": "object", - }, - { - "properties": { - "id": { - "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "id", - ], - "type": "object", - }, - ], - "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", - }, - "report_to_parent": { - "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", - "type": "boolean", - }, - "required": { - "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", - "type": "boolean", - }, - "restart": { - "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", - "type": "boolean", - }, - "review": { - "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", - "properties": { - "implementation_node_id": { - "type": "string", - }, - "phase": { - "enum": [ - "design", - "diff", - ], - "type": "string", - }, - "verification_node_id": { - "type": "string", - }, - }, - "required": [ - "phase", - ], - "type": "object", - }, - "worker_config": { - "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - "worker_type": { - "description": "Agent type (explore, build, general, plan, or custom)", - "type": "string", - }, - }, - "required": [ - "id", - "name", - "worker_type", - "depends_on", - "prompt_template", - ], - "type": "object", - }, - "type": "array", - }, - }, - "required": [ - "name", - "nodes", - ], - "type": "object", - }, - ], - }, - "mode": { - "enum": [ - "standard", - "deep", - ], - "type": "string", - }, - "title": { - "type": "string", - }, - }, - "required": [ - "config", - ], - "type": "object", - }, - }, - "required": [ - "action", - "spec", - ], - "type": "object", - }, { "properties": { "action": { @@ -906,7 +458,7 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "type": "string", }, "spec_path": { - "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", "type": "string", }, }, @@ -916,238 +468,6 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` ], "type": "object", }, - { - "properties": { - "action": { - "description": "Add nodes or blocks to a live workflow", - "enum": [ - "extend", - ], - "type": "string", - }, - "spec": { - "anyOf": [ - { - "properties": { - "blocks": { - "items": { - "properties": { - "depends_on": { - "description": "Block IDs this block waits for. Defaults to []", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique block identifier; dependencies target block IDs", - "type": "string", - }, - "instruction": { - "description": "Task-specific instruction added to the block's built-in execution contract", - "type": "string", - }, - "kind": { - "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", - "enum": [ - "explore", - "plan", - "prototype", - "debug", - "coding", - "verify", - "review", - "synthesize", - ], - "type": "string", - }, - "report_to_parent": { - "description": "Override wake behavior. Review decisions and synthesis report by default", - "type": "boolean", - }, - "required": { - "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", - "type": "boolean", - }, - "worker_type": { - "description": "Optional configured agent override; defaults from the block kind", - "type": "string", - }, - }, - "required": [ - "id", - "kind", - ], - "type": "object", - }, - "type": "array", - }, - "objective": { - "description": "Injected into every generated child prompt", - "type": "string", - }, - }, - "required": [ - "objective", - "blocks", - ], - "type": "object", - }, - { - "properties": { - "nodes": { - "items": { - "properties": { - "cancel": { - "description": "(replan only) Cancel this node", - "type": "boolean", - }, - "condition": { - "description": "Expression evaluated before spawn; node is skipped if false", - "type": "string", - }, - "depends_on": { - "description": "Node IDs this node waits for ([] for root)", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique node identifier, used in depends_on", - "type": "string", - }, - "input_mapping": { - "additionalProperties": { - "type": "string", - }, - "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", - "type": "object", - }, - "name": { - "description": "Human-readable node name", - "type": "string", - }, - "output_schema": { - "description": "JSON Schema; child agent must call submit_result to submit structured output", - "type": "object", - }, - "prompt_template": { - "anyOf": [ - { - "properties": { - "inline": { - "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "inline", - ], - "type": "object", - }, - { - "properties": { - "id": { - "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "id", - ], - "type": "object", - }, - ], - "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", - }, - "report_to_parent": { - "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", - "type": "boolean", - }, - "required": { - "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", - "type": "boolean", - }, - "restart": { - "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", - "type": "boolean", - }, - "review": { - "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", - "properties": { - "implementation_node_id": { - "type": "string", - }, - "phase": { - "enum": [ - "design", - "diff", - ], - "type": "string", - }, - "verification_node_id": { - "type": "string", - }, - }, - "required": [ - "phase", - ], - "type": "object", - }, - "worker_config": { - "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - "worker_type": { - "description": "Agent type (explore, build, general, plan, or custom)", - "type": "string", - }, - }, - "required": [ - "id", - "name", - "worker_type", - "depends_on", - "prompt_template", - ], - "type": "object", - }, - "type": "array", - }, - }, - "required": [ - "nodes", - ], - "type": "object", - }, - ], - "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", - }, - "workflow_id": { - "description": "Target workflow ID", - "pattern": "^dag", - "type": "string", - }, - }, - "required": [ - "action", - "workflow_id", - "spec", - ], - "type": "object", - }, { "properties": { "action": { @@ -1158,7 +478,7 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "type": "string", }, "spec_path": { - "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), 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": { @@ -1174,330 +494,6 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` ], "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": { - "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", - "properties": { - "fragment": { - "anyOf": [ - { - "properties": { - "blocks": { - "description": "Composable blocks compiled into nodes by the runtime", - "items": { - "properties": { - "depends_on": { - "description": "Block IDs this block waits for. Defaults to []", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique block identifier; dependencies target block IDs", - "type": "string", - }, - "instruction": { - "description": "Task-specific instruction added to the block's built-in execution contract", - "type": "string", - }, - "kind": { - "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", - "enum": [ - "explore", - "plan", - "prototype", - "debug", - "coding", - "verify", - "review", - "synthesize", - ], - "type": "string", - }, - "report_to_parent": { - "description": "Override wake behavior. Review decisions and synthesis report by default", - "type": "boolean", - }, - "required": { - "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", - "type": "boolean", - }, - "worker_type": { - "description": "Optional configured agent override; defaults from the block kind", - "type": "string", - }, - }, - "required": [ - "id", - "kind", - ], - "type": "object", - }, - "type": "array", - }, - "max_concurrency": { - "description": "Max parallel nodes. Default: 5", - "type": "number", - }, - "max_node_replan_attempts": { - "description": "Max replan restarts per node ID. Default: 5", - "type": "number", - }, - "max_total_nodes": { - "description": "Cumulative node cap across the workflow lifetime. Default: 100", - "type": "number", - }, - "name": { - "description": "Workflow name", - "type": "string", - }, - "node_defaults": { - "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", - "properties": { - "report_to_parent": { - "type": "boolean", - }, - "required": { - "type": "boolean", - }, - "worker_config": { - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - }, - "type": "object", - }, - "objective": { - "description": "Injected into every generated child prompt; required for blocks", - "type": "string", - }, - }, - "required": [ - "name", - "objective", - "blocks", - ], - "type": "object", - }, - { - "properties": { - "max_concurrency": { - "description": "Max parallel nodes. Default: 5", - "type": "number", - }, - "max_node_replan_attempts": { - "description": "Max replan restarts per node ID. Default: 5", - "type": "number", - }, - "max_total_nodes": { - "description": "Cumulative node cap across the workflow lifetime. Default: 100", - "type": "number", - }, - "name": { - "description": "Workflow name", - "type": "string", - }, - "node_defaults": { - "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", - "properties": { - "report_to_parent": { - "type": "boolean", - }, - "required": { - "type": "boolean", - }, - "worker_config": { - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - }, - "type": "object", - }, - "nodes": { - "description": "Low-level node declarations", - "items": { - "properties": { - "cancel": { - "description": "(replan only) Cancel this node", - "type": "boolean", - }, - "condition": { - "description": "Expression evaluated before spawn; node is skipped if false", - "type": "string", - }, - "depends_on": { - "description": "Node IDs this node waits for ([] for root)", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique node identifier, used in depends_on", - "type": "string", - }, - "input_mapping": { - "additionalProperties": { - "type": "string", - }, - "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", - "type": "object", - }, - "name": { - "description": "Human-readable node name", - "type": "string", - }, - "output_schema": { - "description": "JSON Schema; child agent must call submit_result to submit structured output", - "type": "object", - }, - "prompt_template": { - "anyOf": [ - { - "properties": { - "inline": { - "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "inline", - ], - "type": "object", - }, - { - "properties": { - "id": { - "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "id", - ], - "type": "object", - }, - ], - "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", - }, - "report_to_parent": { - "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", - "type": "boolean", - }, - "required": { - "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", - "type": "boolean", - }, - "restart": { - "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", - "type": "boolean", - }, - "review": { - "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", - "properties": { - "implementation_node_id": { - "type": "string", - }, - "phase": { - "enum": [ - "design", - "diff", - ], - "type": "string", - }, - "verification_node_id": { - "type": "string", - }, - }, - "required": [ - "phase", - ], - "type": "object", - }, - "worker_config": { - "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - "worker_type": { - "description": "Agent type (explore, build, general, plan, or custom)", - "type": "string", - }, - }, - "required": [ - "id", - "name", - "worker_type", - "depends_on", - "prompt_template", - ], - "type": "object", - }, - "type": "array", - }, - }, - "required": [ - "name", - "nodes", - ], - "type": "object", - }, - ], - }, - }, - "required": [ - "fragment", - ], - "type": "object", - }, - "workflow_id": { - "description": "Target workflow ID", - "pattern": "^dag", - "type": "string", - }, - }, - "required": [ - "action", - "operation", - "workflow_id", - "spec", - ], - "type": "object", - }, { "properties": { "action": { @@ -1515,7 +511,7 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "type": "string", }, "spec_path": { - "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), 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": { @@ -1647,7 +643,7 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "type": "string", }, "spec_path": { - "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", "type": "string", }, }, @@ -1692,463 +688,7 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "type": "string", }, "profile": { - "description": "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, inline and project/global specs environment", - "enum": [ - "portable", - "environment", - ], - "type": "string", - }, - "spec": { - "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", - "properties": { - "admission": { - "properties": { - "acknowledged_risks": { - "items": { - "type": "string", - }, - "type": "array", - }, - "brief": { - "properties": { - "acceptance_criteria": { - "items": { - "type": "string", - }, - "type": "array", - }, - "assumptions": { - "items": { - "type": "string", - }, - "type": "array", - }, - "blocking_questions": { - "items": { - "type": "string", - }, - "type": "array", - }, - "constraints": { - "items": { - "type": "string", - }, - "type": "array", - }, - "evidence_required": { - "items": { - "type": "string", - }, - "type": "array", - }, - "goal": { - "type": "string", - }, - "open_questions": { - "items": { - "type": "string", - }, - "type": "array", - }, - "review_plan": { - "items": { - "type": "string", - }, - "type": "array", - }, - "risks": { - "items": { - "type": "string", - }, - "type": "array", - }, - "scope": { - "properties": { - "in": { - "items": { - "type": "string", - }, - "type": "array", - }, - "out": { - "items": { - "type": "string", - }, - "type": "array", - }, - }, - "required": [ - "in", - "out", - ], - "type": "object", - }, - }, - "required": [ - "goal", - "scope", - "constraints", - "assumptions", - "acceptance_criteria", - "evidence_required", - "risks", - "review_plan", - "open_questions", - "blocking_questions", - ], - "type": "object", - }, - "brief_revision": { - "type": "number", - }, - "qa_mode": { - "enum": [ - "LIGHT", - "STANDARD", - "GRILL", - ], - "type": "string", - }, - "verdict": { - "enum": [ - "READY", - "NOT_READY", - "WAIVED", - ], - "type": "string", - }, - "waiver_reason": { - "type": "string", - }, - }, - "required": [ - "brief_revision", - "qa_mode", - "verdict", - "brief", - ], - "type": "object", - }, - "config": { - "anyOf": [ - { - "properties": { - "blocks": { - "description": "Composable blocks compiled into nodes by the runtime", - "items": { - "properties": { - "depends_on": { - "description": "Block IDs this block waits for. Defaults to []", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique block identifier; dependencies target block IDs", - "type": "string", - }, - "instruction": { - "description": "Task-specific instruction added to the block's built-in execution contract", - "type": "string", - }, - "kind": { - "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", - "enum": [ - "explore", - "plan", - "prototype", - "debug", - "coding", - "verify", - "review", - "synthesize", - ], - "type": "string", - }, - "report_to_parent": { - "description": "Override wake behavior. Review decisions and synthesis report by default", - "type": "boolean", - }, - "required": { - "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", - "type": "boolean", - }, - "worker_type": { - "description": "Optional configured agent override; defaults from the block kind", - "type": "string", - }, - }, - "required": [ - "id", - "kind", - ], - "type": "object", - }, - "type": "array", - }, - "max_concurrency": { - "description": "Max parallel nodes. Default: 5", - "type": "number", - }, - "max_node_replan_attempts": { - "description": "Max replan restarts per node ID. Default: 5", - "type": "number", - }, - "max_total_nodes": { - "description": "Cumulative node cap across the workflow lifetime. Default: 100", - "type": "number", - }, - "name": { - "description": "Workflow name", - "type": "string", - }, - "node_defaults": { - "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", - "properties": { - "report_to_parent": { - "type": "boolean", - }, - "required": { - "type": "boolean", - }, - "worker_config": { - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - }, - "type": "object", - }, - "objective": { - "description": "Injected into every generated child prompt; required for blocks", - "type": "string", - }, - }, - "required": [ - "name", - "objective", - "blocks", - ], - "type": "object", - }, - { - "properties": { - "max_concurrency": { - "description": "Max parallel nodes. Default: 5", - "type": "number", - }, - "max_node_replan_attempts": { - "description": "Max replan restarts per node ID. Default: 5", - "type": "number", - }, - "max_total_nodes": { - "description": "Cumulative node cap across the workflow lifetime. Default: 100", - "type": "number", - }, - "name": { - "description": "Workflow name", - "type": "string", - }, - "node_defaults": { - "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", - "properties": { - "report_to_parent": { - "type": "boolean", - }, - "required": { - "type": "boolean", - }, - "worker_config": { - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - }, - "type": "object", - }, - "nodes": { - "description": "Low-level node declarations", - "items": { - "properties": { - "cancel": { - "description": "(replan only) Cancel this node", - "type": "boolean", - }, - "condition": { - "description": "Expression evaluated before spawn; node is skipped if false", - "type": "string", - }, - "depends_on": { - "description": "Node IDs this node waits for ([] for root)", - "items": { - "type": "string", - }, - "type": "array", - }, - "id": { - "description": "Unique node identifier, used in depends_on", - "type": "string", - }, - "input_mapping": { - "additionalProperties": { - "type": "string", - }, - "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", - "type": "object", - }, - "name": { - "description": "Human-readable node name", - "type": "string", - }, - "output_schema": { - "description": "JSON Schema; child agent must call submit_result to submit structured output", - "type": "object", - }, - "prompt_template": { - "anyOf": [ - { - "properties": { - "inline": { - "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "inline", - ], - "type": "object", - }, - { - "properties": { - "id": { - "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", - "type": "string", - }, - "input": { - "type": "object", - }, - }, - "required": [ - "id", - ], - "type": "object", - }, - ], - "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", - }, - "report_to_parent": { - "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", - "type": "boolean", - }, - "required": { - "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", - "type": "boolean", - }, - "restart": { - "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", - "type": "boolean", - }, - "review": { - "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", - "properties": { - "implementation_node_id": { - "type": "string", - }, - "phase": { - "enum": [ - "design", - "diff", - ], - "type": "string", - }, - "verification_node_id": { - "type": "string", - }, - }, - "required": [ - "phase", - ], - "type": "object", - }, - "worker_config": { - "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", - "properties": { - "timeout_ms": { - "type": "number", - }, - }, - "type": "object", - }, - "worker_type": { - "description": "Agent type (explore, build, general, plan, or custom)", - "type": "string", - }, - }, - "required": [ - "id", - "name", - "worker_type", - "depends_on", - "prompt_template", - ], - "type": "object", - }, - "type": "array", - }, - }, - "required": [ - "name", - "nodes", - ], - "type": "object", - }, - ], - }, - "mode": { - "enum": [ - "standard", - "deep", - ], - "type": "string", - }, - "title": { - "type": "string", - }, - }, - "required": [ - "config", - ], - "type": "object", - }, - }, - "required": [ - "action", - "spec", - ], - "type": "object", - }, - { - "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, inline and project/global specs environment", + "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", @@ -2156,7 +696,7 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "type": "string", }, "spec_path": { - "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Graph content belongs in that file; relative paths resolve from the session directory", "type": "string", }, }, 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 7a73cf124e..eb7276f030 100644 --- a/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json +++ b/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json @@ -1,126 +1,28 @@ { - "captured_from": "packages/opencode/src/tool/workflow.ts (discriminated-union Parameters)", - "schema_bytes": 29254, - "branch_count": 14, + "captured_from": "packages/opencode/src/tool/workflow.ts (file-backed discriminated-union Parameters)", + "schema_bytes": 4681, + "branch_count": 10, "session_id_exposed": false, "project_id_exposed": false, + "inline_spec_exposed": false, "transformed": { "openai": { - "bytes": 29306, - "branch_count": 14, - "start_inline_spec_config_present": true, - "blocks_branch_fields": [ - "name", - "objective", - "blocks", - "node_defaults", - "max_concurrency", - "max_node_replan_attempts", - "max_total_nodes" - ], - "block_item_fields": [ - "id", - "kind", - "depends_on", - "instruction", - "worker_type", - "required", - "report_to_parent" - ], - "node_item_fields": [ - "id", - "name", - "worker_type", - "depends_on", - "required", - "prompt_template", - "worker_config", - "input_mapping", - "report_to_parent", - "condition", - "restart", - "cancel", - "output_schema", - "review" - ] + "bytes": 4511, + "branch_count": 10, + "start_spec_path_present": true, + "inline_spec_exposed": false }, "azure": { - "bytes": 29306, - "branch_count": 14, - "start_inline_spec_config_present": true, - "blocks_branch_fields": [ - "name", - "objective", - "blocks", - "node_defaults", - "max_concurrency", - "max_node_replan_attempts", - "max_total_nodes" - ], - "block_item_fields": [ - "id", - "kind", - "depends_on", - "instruction", - "worker_type", - "required", - "report_to_parent" - ], - "node_item_fields": [ - "id", - "name", - "worker_type", - "depends_on", - "required", - "prompt_template", - "worker_config", - "input_mapping", - "report_to_parent", - "condition", - "restart", - "cancel", - "output_schema", - "review" - ] + "bytes": 4511, + "branch_count": 10, + "start_spec_path_present": true, + "inline_spec_exposed": false }, "gemini": { - "bytes": 29254, - "branch_count": 14, - "start_inline_spec_config_present": true, - "blocks_branch_fields": [ - "name", - "objective", - "blocks", - "node_defaults", - "max_concurrency", - "max_node_replan_attempts", - "max_total_nodes" - ], - "block_item_fields": [ - "id", - "kind", - "depends_on", - "instruction", - "worker_type", - "required", - "report_to_parent" - ], - "node_item_fields": [ - "id", - "name", - "worker_type", - "depends_on", - "required", - "prompt_template", - "worker_config", - "input_mapping", - "report_to_parent", - "condition", - "restart", - "cancel", - "output_schema", - "review" - ] + "bytes": 4681, + "branch_count": 10, + "start_spec_path_present": true, + "inline_spec_exposed": false } } } diff --git a/packages/opencode/test/tool/workflow-authoring.test.ts b/packages/opencode/test/tool/workflow-authoring.test.ts index 8330690420..97d44a9b3c 100644 --- a/packages/opencode/test/tool/workflow-authoring.test.ts +++ b/packages/opencode/test/tool/workflow-authoring.test.ts @@ -23,7 +23,9 @@ const WORKTREE_LIFECYCLE_BRIEF = { objective: "Repair worktree lifecycle handling: fix bootstrap cleanup races and cover both tiers with regression tests", route: ["plan", "coding(worktree-core)", "coding(callers-and-fixture)", "verify", "review"], - skips: ["explore — the confirmed brief already supplies file references, failure mechanism, package split, risks, and acceptance checks"], + skips: [ + "explore — the confirmed brief already supplies file references, failure mechanism, package split, risks, and acceptance checks", + ], packages: { "worktree-core": "worktree bootstrap/cleanup ownership in the core lifecycle", "callers-and-fixture": "call-site updates plus the isolated memory fixture in cli tests", @@ -79,6 +81,11 @@ const worktreeLifecycleStartInput = { }, } as const +const worktreeLifecyclePathInput = { + action: "start", + spec_path: ".opencode/workflows/worktree-lifecycle-repair.yaml", +} as const + // The previously observed polluted calls: a start that carries another // action's identifiers and control fields. The empty-workflow_id shape // already fails the Dag.ID brand today; the plausible-id shape passes the @@ -104,6 +111,21 @@ const decode = (input: unknown) => Result.isSuccess(Schema.decodeUnknownResult(Parameters, { onExcessProperty: "error" })(input)) 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({ 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) + }) + test("decision brief route compiles under the block compiler", () => { const nodes = DagBlocks.compileWorkflowBlocks({ objective: WORKTREE_LIFECYCLE_BRIEF.objective, @@ -131,22 +153,23 @@ describe("worktree-lifecycle regression fixtures", () => { ) }) - test("accepted start fixture carries only start-owned fields and a complete config.blocks", () => { - expect(Object.keys(worktreeLifecycleStartInput)).toEqual(["action", "spec"]) + test("accepted start carries only its YAML path while the authored fixture keeps complete blocks", () => { + expect(Object.keys(worktreeLifecyclePathInput)).toEqual(["action", "spec_path"]) expect(worktreeLifecycleStartInput.spec.config.blocks.length).toBe(5) - expect(decode(worktreeLifecycleStartInput)).toBe(true) + expect(decode(worktreeLifecyclePathInput)).toBe(true) }) - test("replay: the complete audit adds no explore block and strict decode keeps a clean start", () => { + test("replay: the complete audit adds no explore block and strict decode keeps a file-backed start", () => { // The confirmed brief already supplies repository evidence, so the route // starts at plan — no explore lane is added back. const blockIDs = worktreeLifecycleStartInput.spec.config.blocks.map((block) => block.id) expect(blockIDs.some((id) => id.includes("explore"))).toBe(false) 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" })(worktreeLifecycleStartInput) + const decoded = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" })(worktreeLifecyclePathInput) expect(decoded.action).toBe("start") - expect("spec" in decoded).toBe(true) + 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) @@ -165,17 +188,17 @@ describe("worktree-lifecycle regression fixtures", () => { }) test("validate rejects control and result fields it does not own", () => { - const spec = worktreeLifecycleStartInput.spec - expect(decode({ action: "validate", spec, workflow_id: "dag_2x9k4m" })).toBe(false) - expect(decode({ action: "validate", spec, node_id: "verify" })).toBe(false) - expect(decode({ action: "validate", spec, operation: "cancel" })).toBe(false) - expect(decode({ action: "validate", spec, cursor: "", limit: 500 })).toBe(false) - // The validate action itself stays clean with exactly one source. - expect(decode({ action: "validate", spec, profile: "portable" })).toBe(true) + 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) + // 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) }) - test("inline admission rejects boundary-owned audit fields; file reads strip them instead", () => { + test("model-facing start rejects inline admission objects entirely", () => { const brief = { goal: "Ship the change", scope: { in: ["dag"], out: [] }, @@ -197,13 +220,9 @@ describe("worktree-lifecycle regression fixtures", () => { acknowledged_risks: ["unresolved rollout"], } const spec = { ...worktreeLifecycleStartInput.spec, mode: "deep", admission: cleanAdmission } - expect(decode({ action: "start", spec })).toBe(true) - // System-generated fields never belong in the model-facing schema — the - // file-read boundary strips them for legacy YAML compatibility instead. + expect(decode({ 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({ 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 0aa59f8448..b7be8f53dc 100644 --- a/packages/opencode/test/tool/workflow-provider-schema.test.ts +++ b/packages/opencode/test/tool/workflow-provider-schema.test.ts @@ -4,11 +4,9 @@ import { Parameters } from "../../src/tool/workflow" import { ToolJsonSchema } from "../../src/tool/json-schema" import { ProviderTransform } from "../../src/provider/transform" -// Wire-shape regression for change repair-workflow-authoring-validation: -// the discriminated union must survive provider transformation — every action -// keeps its discriminator and required fields, and nested block/node fields -// stay visible to the model (the pre-change Record spec collapsed to -// `properties: {}` on OpenAI — see fixtures/workflow-parameters-pre-change.json). +// 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. 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 @@ -43,12 +41,12 @@ function record(node: JsonSchemaNode | undefined): Record { - test("base wire shape is the 14-branch discriminated union", async () => { + test("base wire shape is the 10-branch file-backed discriminated union", 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(14) + expect(schema.anyOf?.length).toBe(10) const flat = JSON.stringify(schema) expect(flat).not.toContain('"session_id"') expect(flat).not.toContain('"project_id"') @@ -71,36 +69,18 @@ describe("workflow provider-facing schema", () => { expect(Object.keys(status.properties ?? {})).toEqual(["action", "workflow_id"]) }) - test("OpenAI transformation exposes the nested blocks spec instead of properties: {}", () => { + test("OpenAI transformation exposes paths without inline graph objects", () => { const transformed = ProviderTransform.schema( openaiModel, ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode - const startInline = branchByAction(transformed, "start", "spec")[0] - const config = record(startInline)["spec"] - expect(record(config)["config"]).toBeDefined() - const configUnion = record(config)["config"].anyOf ?? [] - const blocksBranch = configUnion.find((branch) => branch.properties?.blocks !== undefined) - const nodesBranch = configUnion.find((branch) => branch.properties?.nodes !== undefined) - expect(blocksBranch).toBeDefined() - expect(nodesBranch).toBeDefined() - expect(record(blocksBranch)["objective"]).toBeDefined() - expect(blocksBranch?.required).toEqual(expect.arrayContaining(["name", "objective", "blocks"])) - expect(nodesBranch?.required).toEqual(expect.arrayContaining(["name", "nodes"])) - const blockItem = record(blocksBranch)["blocks"]?.items - expect(Object.keys(record(blockItem))).toEqual(expect.arrayContaining(["id", "kind", "depends_on", "instruction"])) - expect(Object.keys(record(blockItem))).not.toContain("skills") - const nodeItem = record(nodesBranch)["nodes"]?.items - expect(Object.keys(record(nodeItem))).toEqual( - expect.arrayContaining(["id", "name", "worker_type", "depends_on", "prompt_template"]), - ) - expect(Object.keys(record(nodeItem))).not.toContain("model") - expect(Object.keys(record(record(nodesBranch)["node_defaults"]))).not.toContain("model") - // Exactly-one-source prompt_template: both variants declared. - const promptTemplate = record(nodeItem)["prompt_template"] - const promptVariants = promptTemplate?.anyOf ?? [] - expect(promptVariants.some((variant) => variant.properties?.inline !== undefined)).toBe(true) - expect(promptVariants.some((variant) => variant.properties?.id !== undefined)).toBe(true) + for (const action of ["start", "extend", "validate"]) { + expect(branchByAction(transformed, action, "spec_path").length).toBe(1) + expect(branchByAction(transformed, action, "spec")).toEqual([]) + } + expect(branchByAction(transformed, "control", "spec_path").length).toBe(1) + expect(branchByAction(transformed, "control", "spec")).toEqual([]) + expect(Object.keys(record(branchByAction(transformed, "start", "spec_path")[0]))).toEqual(["action", "spec_path"]) }) test("pins the removed Skill-dependent block surface as red evidence", async () => { @@ -119,19 +99,19 @@ describe("workflow provider-facing schema", () => { azureModel, ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode - expect(transformed.anyOf?.length).toBe(14) - expect(branchByAction(transformed, "start", "spec").length).toBeGreaterThan(0) + expect(transformed.anyOf?.length).toBe(10) + expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) expect(branchByAction(transformed, "validate", "spec_path").length).toBeGreaterThan(0) }) - test("Gemini transformation keeps every branch and nested fields", () => { + test("Gemini transformation keeps every file-backed branch", () => { const transformed = ProviderTransform.schema( geminiModel, ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode - expect(transformed.anyOf?.length).toBe(14) - const startInline = branchByAction(transformed, "start", "spec")[0] - expect(record(record(startInline)["spec"])["config"]).toBeDefined() + expect(transformed.anyOf?.length).toBe(10) + expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) + expect(branchByAction(transformed, "start", "spec")).toEqual([]) const resultBranch = branchByAction(transformed, "result")[0] expect(resultBranch.required).toEqual(expect.arrayContaining(["workflow_id", "node_id"])) expect(Object.keys(record(resultBranch))).toEqual(expect.arrayContaining(["cursor", "limit"]))