From c7ccc9e636d8804b122d672bb3e1a81f193763c7 Mon Sep 17 00:00:00 2001 From: lex Date: Thu, 30 Jul 2026 09:28:19 +0800 Subject: [PATCH 1/3] fix(dag): harden workflow startup controls --- .../plugin/command/orchestration-policy.md | 15 +- packages/core/src/plugin/command/workflow.md | 48 ++--- packages/opencode/src/dag/config.ts | 4 +- packages/opencode/src/dag/model.ts | 20 +++ packages/opencode/src/dag/runtime/spawn.ts | 19 +- packages/opencode/src/tool/workflow.ts | 90 ++++++++-- .../test/dag/spawn-completion.test.ts | 33 ++++ .../opencode/test/dag/workflow-tool.test.ts | 168 +++++++++++------- .../feature-plugins/system/dag-inspector.tsx | 49 +++++ .../feature-plugins/dag-inspector.test.tsx | 41 +++++ 10 files changed, 365 insertions(+), 122 deletions(-) create mode 100644 packages/opencode/src/dag/model.ts diff --git a/packages/core/src/plugin/command/orchestration-policy.md b/packages/core/src/plugin/command/orchestration-policy.md index 53bc9338c..48e73c527 100644 --- a/packages/core/src/plugin/command/orchestration-policy.md +++ b/packages/core/src/plugin/command/orchestration-policy.md @@ -158,18 +158,21 @@ If a required capability has no eligible role, report the missing capability and ## Model Assignment -Omit `node.model` by default. Let the existing configuration fallback remain authoritative: +Never emit `node.model` or `config.node_defaults.model`. Model assignment belongs +to runtime configuration, not the workflow graph: -`node.model` → `config.node_defaults.model` → configured agent model → parent session model +`dag.jsonc` tier → configured agent model → parent session model -Pin a model only when the user supplies an exact provider/model pair for a node or policy slot. Store the provider in `providerID` and only the provider-local `modelID` in `modelID`; never repeat the provider prefix inside `modelID`. - -Qualitative labels such as "strong", "fast", or "cheap" may guide capability and role selection, but you MUST NOT invent a model identifier. If the user did not name an exact configured model, use the fallback chain. +Qualitative labels such as "strong", "fast", or "cheap" guide tier placement, +but you MUST NOT invent a model identifier. If every configured source is +missing, the workflow tool starts parent-session QA and does not create the +workflow. Ask the user to configure a `dag.jsonc` tier, the selected agent, or +the parent-session model, then retry. Prefer expressing "strong model for judgment, fast model for volume" through tier placement — `required: true` and `review`/`review-*` workers resolve to the advanced tier of `dag.jsonc`, everything else to standard — rather than -per-node pins. +graph-level model fields. ## Profile: Brainstorm diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index 0413738a7..546ab4228 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -5,7 +5,7 @@ # Workflow Orchestration -The `workflow` tool orchestrates heavy tasks as dependency-graph multi-agent workflows. Each node runs as a real child session with its own agent, tools, and optionally its own model. This skill covers when to start a workflow, how to structure it, and how to adapt it at runtime. +The `workflow` tool orchestrates heavy tasks as dependency-graph multi-agent workflows. Each node runs as a real child session with its own agent and tools. This skill covers when to start a workflow, how to structure it, and how to adapt it at runtime. Compile every graph under the Tiered Orchestration Doctrine and Depth Ladder in the orchestration policy below: advanced-tier judgment nodes conduct and check, standard-tier nodes carry the volume, and accuracy is bought with breadth (concurrent fan-out) and depth (verdict-gated waves) rather than with a single trusted pass. @@ -81,27 +81,14 @@ config: report_to_parent: false worker_config: timeout_ms: 600000 - model: - providerID: local-proxy-compatible - modelID: glm-5.2 ``` -This is the preferred place for a workflow-wide model. If both the node and -`config.node_defaults.model` omit it, resolution continues to the selected -agent model and then the parent session model. - -When overriding a model, split the provider and provider-local model ID: - -```yaml -model: - providerID: local-proxy-compatible - modelID: glm-5.2 -``` - -Do not put `local-proxy-compatible/glm-5.2` into `modelID` while also setting `providerID`; that repeats the provider prefix. -Omit `node.model` unless the user supplied an exact provider/model selection. -Qualitative requests such as "use a strong model" must not be converted into an -invented model identifier. +Never emit `node.model` or `config.node_defaults.model`. Model selection is +configuration-owned: critical nodes (`required: true` and review workers) use +the `advanced` tier in `dag.jsonc`, other nodes use `standard`, then resolution +falls back to the selected agent model and the parent-session model. If no +source provides a model, the workflow tool starts parent-session QA and leaves +the workflow uncreated so the user can configure a model and retry. ## Collaboration Patterns @@ -270,9 +257,8 @@ config: inline: "The arbiter did not accept. Verify each required action against the actual code and produce a corrected, evidence-backed action plan." ``` -Reviewer nodes may use different exact models when the user selected them; -otherwise omit `model` and let workflow, agent, and parent configuration provide -the defaults. The arbiter is `required: true` — its execution failure signals +Reviewer nodes use the `advanced` tier from `dag.jsonc`. The arbiter is +`required: true` — its execution failure signals that the artifact could not be confidently accepted, while its successful business verdict must still be interpreted. On `ACCEPT` the conditioned `deep-dive` node is skipped and the workflow completes; on any other verdict @@ -358,20 +344,19 @@ paused, until you act. ## Model Assignment Strategy -Each node MAY specify `model: { modelID, providerID }` to pin a specific model. -`modelID` is the provider-local model ID; never repeat `providerID` inside it. -If omitted, resolution follows `config.node_defaults.model`, then the configured -agent model and then the parent session model. Pin only an exact user-supplied -selection and never invent an identifier from a qualitative request. +Workflow definitions MUST NOT specify `node.model` or +`config.node_defaults.model`. Resolution follows the `dag.jsonc` tier, then the +configured agent model, then the parent-session model. If all three are absent, +the workflow tool asks the user to configure a model and does not create the +workflow. - Expensive models for planning, review, and arbitration — high-stakes decisions where reasoning quality matters. - Fast models for mechanical implementation — well-specified edits where speed and cost matter. - Diverse models in adversarial review — reduces single-model blind spots. -The two-tier defaults in `dag.jsonc` implement this split mechanically: +The two-tier defaults in `dag.jsonc` implement the split mechanically: `required: true` nodes and `review`/`review-*` workers resolve to the -`advanced` tier, every other node to `standard`. Prefer expressing the split -through tier placement rather than per-node pins. +`advanced` tier, every other node to `standard`. ## Prompt Templates @@ -462,7 +447,6 @@ checkpoint naturally completed the current graph; an early | `depends_on` | yes | Array of node IDs this node waits for (`[]` for root) | | `required` | no | If true and this node fails, the workflow terminalizes as failed. Default: false | | `prompt_template` | yes | `{ id: "..." }` or `{ inline: "...", input: {...} }` | -| `model` | no | `{ modelID, providerID }` override | | `condition` | no | Expression evaluated before spawn; node is skipped if false | | `input_mapping` | no | Map upstream node outputs into template variables | | `report_to_parent` | no | If true, the parent agent is woken when this node completes or fails. The workflow's terminal status always wakes the parent regardless of this flag | diff --git a/packages/opencode/src/dag/config.ts b/packages/opencode/src/dag/config.ts index 717f2605e..7be8c8830 100644 --- a/packages/opencode/src/dag/config.ts +++ b/packages/opencode/src/dag/config.ts @@ -40,8 +40,8 @@ export type Info = typeof Info.Type const DEFAULT_CONTENT = `{ // DAG workflow defaults — applies to every DAG child session. - // Model resolution per node: node.model → config.node_defaults.model - // → worker agent model → this file's tier → parent session model. + // Model resolution per node: persisted legacy node model + // → this file's tier → worker agent model → parent session model. // Format: "provider/model", e.g. "anthropic/claude-sonnet-4-5". "model": { // Advanced tier — critical nodes: required: true and review/arbiter workers. diff --git a/packages/opencode/src/dag/model.ts b/packages/opencode/src/dag/model.ts new file mode 100644 index 000000000..14224d151 --- /dev/null +++ b/packages/opencode/src/dag/model.ts @@ -0,0 +1,20 @@ +export * as DagModel from "./model" + +export type Ref = { + modelID: string + providerID: string +} + +/** + * Resolve one DAG node model from most specific to broadest. Persisted node + * models remain first for compatibility with existing workflows; new workflow + * tool inputs omit them and normally resolve through the configured DAG tier. + */ +export function resolve(input: { + node?: Ref + tier?: Ref + agent?: Ref + parent?: Ref +}) { + return input.node ?? input.tier ?? input.agent ?? input.parent +} diff --git a/packages/opencode/src/dag/runtime/spawn.ts b/packages/opencode/src/dag/runtime/spawn.ts index db0183d22..1e00ef875 100644 --- a/packages/opencode/src/dag/runtime/spawn.ts +++ b/packages/opencode/src/dag/runtime/spawn.ts @@ -27,6 +27,7 @@ import { SessionID, MessageID } from "@/session/schema" import { deriveSubagentSessionPermission } from "@/agent/subagent-permissions" import { SessionPrompt } from "@/session/prompt" import { Dag } from "../dag" +import { DagModel } from "../model" import { validateReviewResult } from "../review-lifecycle" import { InvalidTransitionError, TerminalViolationError } from "@opencode-ai/core/dag/core/types" import type { DagStore } from "@opencode-ai/core/dag/store" @@ -47,7 +48,7 @@ export interface NodeSpawnInput { timeoutMs?: number reportToParent?: boolean reviewImplementationFingerprint?: string - /** dag.jsonc tier default — slots between the agent model and the parent-session model. */ + /** dag.jsonc tier default — authoritative unless a persisted legacy node model exists. */ fallbackModel?: { modelID: string; providerID: string } /** dag.jsonc thinking_depth — forwarded as the prompt variant (no-op unless the model defines it). */ variant?: string @@ -90,14 +91,20 @@ export function spawnNode( providerID: persistedNodeModel.providerID as never, } : undefined - const model = nodeModel - ?? (agent.model ? { modelID: agent.model.modelID, providerID: agent.model.providerID } : undefined) - ?? (input.fallbackModel ? { modelID: input.fallbackModel.modelID as never, providerID: input.fallbackModel.providerID as never } : undefined) - ?? (parent.model ? { modelID: parent.model.id, providerID: parent.model.providerID } : undefined) - if (!model) { + const resolvedModel = DagModel.resolve({ + node: nodeModel, + tier: input.fallbackModel, + agent: agent.model, + parent: parent.model ? { modelID: parent.model.id, providerID: parent.model.providerID } : undefined, + }) + if (!resolvedModel) { yield* dag.nodeFailed(input.dagID, input.nodeID, `no model configured for agent: ${agent.name}`, "exec_failed") return yield* Effect.fail(new Error(`No model configured for agent: ${agent.name}`)) } + const model = { + modelID: resolvedModel.modelID as never, + providerID: resolvedModel.providerID as never, + } const childPermission = deriveSubagentSessionPermission({ parentSessionPermission: parent.permission ?? [], diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 1179b85b4..ea0ffaff7 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -2,6 +2,10 @@ import * as Tool from "./tool" import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { Effect, Schema } from "effect" import { Dag } from "@/dag/dag" +import { DagConfig } from "@/dag/config" +import { DagModel } from "@/dag/model" +import { Agent } from "@/agent/agent" +import { Question } from "@/question" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" import type { NodeConfig, WorkflowConfig } from "@/dag/dag" @@ -41,9 +45,6 @@ const NodeSchema = Schema.Struct({ description: "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", }), condition: Schema.optional(Schema.String).annotate({ description: "Expression evaluated before spawn; node is skipped if false" }), - model: Schema.optional(Schema.Struct({ modelID: Schema.String, providerID: Schema.String })).annotate({ - description: 'Optional node override. modelID is provider-local, e.g. { providerID: "local-proxy-compatible", modelID: "glm-5.2" }, never repeat providerID inside modelID. Omit to inherit config.node_defaults.model, then the agent or parent-session model', - }), restart: Schema.optional(Schema.Boolean).annotate({ 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" }), cancel: Schema.optional(Schema.Boolean).annotate({ description: "(replan only) Cancel this node" }), output_schema: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ description: "JSON Schema; child agent must call submit_result to submit structured output" }), @@ -67,15 +68,9 @@ const WorkflowGraphSchema = Schema.Struct({ }), ), report_to_parent: Schema.optional(Schema.Boolean), - model: Schema.optional( - Schema.Struct({ - modelID: Schema.String, - providerID: Schema.String, - }), - ), }), ).annotate({ - description: "Defaults inherited by nodes that omit required, worker_config, report_to_parent, or model", + description: "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", }), max_concurrency: Schema.optional(Schema.Number).annotate({ description: "Max parallel nodes. Default: 5" }), max_node_replan_attempts: Schema.optional(Schema.Number).annotate({ description: "Max replan restarts per node ID. Default: 5" }), @@ -103,11 +98,17 @@ export const Parameters = Schema.Struct({ type Metadata = { workflowId?: string; added?: string[]; cancel?: string[]; restart?: string[]; replace?: string[] } -export const WorkflowTool = Tool.define( +export const WorkflowTool = Tool.define< + typeof Parameters, + Metadata, + Dag.Service | Session.Service | Agent.Service | Question.Service +>( id, Effect.gen(function* () { const dag = yield* Dag.Service const sessions = yield* Session.Service + const agents = yield* Agent.Service + const question = yield* Question.Service return { description: CommandPlugin.WorkflowContent, @@ -170,6 +171,39 @@ export const WorkflowTool = Tool.define 0) { + yield* question.ask({ + sessionID, + questions: [{ + header: "DAG model", + question: `No model is available for DAG node${missingModels.length > 1 ? "s" : ""} ${missingModels.map((node) => `"${node}"`).join(", ")}. Configure the advanced/standard tiers in dag.jsonc, a model on the selected worker agent, or a parent-session model before starting. How would you like to proceed?`, + custom: false, + options: [ + { + label: "Configure first", + description: "Do not start the workflow; configure a model and retry.", + }, + { + label: "Cancel workflow", + description: "Abandon this workflow start.", + }, + ], + }], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }).pipe(Effect.orDie) + return { + title: "Workflow not started: model required", + output: `No workflow was created. Missing model for: ${missingModels.join(", ")}. Configure dag.jsonc, the worker agent, or the parent session, then retry.`, + metadata: {}, + } + } const dagID = yield* dag.create({ projectID: session.projectID, sessionID, @@ -256,3 +290,37 @@ export const WorkflowTool = Tool.define }), ) + +function findNodesWithoutModel(input: { + nodes: ReadonlyArray> + defaults?: Schema.Schema.Type["node_defaults"] + directory: string + parent?: Session.Info["model"] + agents: Agent.Interface +}) { + if (input.nodes.length === 0) return Effect.succeed([]) + return Effect.gen(function* () { + const config = yield* DagConfig.load(input.directory) + return yield* Effect.filter( + input.nodes, + (node) => + Effect.gen(function* () { + const agent = yield* input.agents.get(node.worker_type).pipe( + Effect.map((info) => info as Agent.Info | undefined), + Effect.catchCause(() => Effect.succeed(undefined)), + ) + return DagModel.resolve({ + tier: DagConfig.tierModel(config, { + required: node.required ?? input.defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, + workerType: node.worker_type, + }), + agent: agent?.model, + parent: input.parent + ? { modelID: input.parent.id, providerID: input.parent.providerID } + : undefined, + }) === undefined + }), + { concurrency: "unbounded" }, + ).pipe(Effect.map((nodes) => nodes.map((node) => node.id))) + }) +} diff --git a/packages/opencode/test/dag/spawn-completion.test.ts b/packages/opencode/test/dag/spawn-completion.test.ts index 87600e66a..2a5294840 100644 --- a/packages/opencode/test/dag/spawn-completion.test.ts +++ b/packages/opencode/test/dag/spawn-completion.test.ts @@ -154,6 +154,39 @@ describe("spawnNode completion bridge", () => { expect(findEvent(events, "nodeCompleted")).toBeDefined() }) + it("prefers the configured DAG tier over the worker agent model", async () => { + const { events, dagLayer } = makeEventTracker() + let promptModel: SessionPrompt.PromptInput["model"] + const prompt = Layer.mock(SessionPrompt.Service, { + prompt: (input) => + Effect.sync(() => { + promptModel = input.model + return reply("done") + }), + }) + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const result = yield* spawnNode(Semaphore.makeUnsafe(1), { + ...makeSpawnInput(), + fallbackModel: { + providerID: "configured", + modelID: "dag-tier-model", + }, + }) + yield* Fiber.await(result.fiber) + }), + ).pipe(Effect.provide(Layer.mergeAll(dagLayer, agentLayer, sessionLayer, prompt))) as Effect.Effect, + ) + + expect(promptModel as unknown).toEqual({ + providerID: "configured", + modelID: "dag-tier-model", + }) + expect(findEvent(events, "nodeCompleted")).toBeDefined() + }) + it("canonicalizes a provider-qualified model from a persisted node", async () => { const { events, dagLayer } = makeEventTracker() let promptModel: SessionPrompt.PromptInput["model"] diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index cce32e853..3e2f100fe 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "bun:test" import { Effect, Exit, Layer, Schema } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" import { Dag } from "@/dag/dag" import { Agent } from "@/agent/agent" import { DagStore } from "@opencode-ai/core/dag/store" import { DagEvent } from "@opencode-ai/schema/dag-event" import { EventV2Bridge } from "@/event-v2-bridge" +import { Question } from "@/question" import { Session } from "@/session/session" import { MessageID, SessionID } from "@/session/schema" import type { Tool } from "@/tool/tool" @@ -190,9 +194,53 @@ const runtime = testEffect( Layer.mock(Truncate.Service, { output: (content) => Effect.succeed({ content, truncated: false }), }), + Layer.mock(Question.Service, { + ask: () => Effect.succeed([["Configure first"]]), + }), dag, Layer.mock(Session.Service, { - get: (id: Parameters[0]) => Effect.succeed({ id, projectID } as Session.Info), + get: (id: Parameters[0]) => + Effect.succeed({ + id, + projectID, + directory: "/project", + model: { providerID: "test" as never, id: "test-model" as never }, + } as unknown as Session.Info), + }), + ), +) + +let missingModelDirectory = "" +const questionsAsked: Question.Info[] = [] +const missingModelRuntime = testEffect( + Layer.mergeAll( + Layer.mock(Agent.Service, { + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + }), + }), + Layer.mock(Truncate.Service, { + output: (content) => Effect.succeed({ content, truncated: false }), + }), + Layer.mock(Question.Service, { + ask: (input) => + Effect.sync(() => { + questionsAsked.push(...input.questions) + return [["Configure first"]] + }), + }), + dag, + Layer.mock(Session.Service, { + get: (id: Parameters[0]) => + Effect.succeed({ + id, + projectID, + directory: missingModelDirectory, + } as Session.Info), }), ), ) @@ -236,7 +284,7 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "control", workflow_id: "wf-1", operation: "start" })).toThrow() }) - it("leaves omitted node defaults unresolved for the workflow config", () => { + it("leaves omitted node defaults unresolved and strips graph-level model fields", () => { const decode = Schema.decodeUnknownSync(Parameters) const input = decode({ action: "start", @@ -255,6 +303,7 @@ describe("workflow tool schema (negative tests)", () => { worker_type: "build", depends_on: [], prompt_template: { inline: "work" }, + model: { providerID: "configured", modelID: "configured/model" }, }, ], }, @@ -265,8 +314,8 @@ describe("workflow tool schema (negative tests)", () => { required: true, report_to_parent: true, worker_config: { timeout_ms: 1234 }, - model: { providerID: "configured", modelID: "configured/model" }, }) + expect(input.config?.nodes[0]).not.toHaveProperty("model") }) it("decodes deep mode and structured admission", () => { @@ -403,6 +452,57 @@ describe("workflow tool execution", () => { }), ) + missingModelRuntime.effect("start asks QA and creates nothing when no model can be resolved", () => + Effect.gen(function* () { + published.length = 0 + questionsAsked.length = 0 + missingModelDirectory = yield* Effect.acquireRelease( + Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "workflow-model-"))), + (directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })), + ) + yield* Effect.promise(() => fs.mkdir(path.join(missingModelDirectory, ".opencode"), { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(missingModelDirectory, ".opencode", "dag.jsonc"), + '{ "model": {} }\n', + ) + ) + + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + { + action: "start", + config: { + name: "missing-model", + nodes: [{ + id: "worker", + name: "Worker", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + }], + }, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + + expect(result.title).toBe("Workflow not started: model required") + expect(result.metadata.workflowId).toBeUndefined() + expect(questionsAsked).toHaveLength(1) + expect(questionsAsked[0]?.question).toContain('"worker"') + expect(published).toHaveLength(0) + }), + ) + runtime.effect("deep start consumes and persists a READY admission", () => Effect.gen(function* () { published.length = 0 @@ -582,10 +682,6 @@ describe("workflow tool execution", () => { required: true, report_to_parent: true, worker_config: { timeout_ms: 1234 }, - model: { - providerID: "local-proxy-compatible", - modelID: "local-proxy-compatible/glm-5.2", - }, }, nodes: [ { @@ -604,7 +700,6 @@ describe("workflow tool execution", () => { report_to_parent: false, worker_config: { timeout_ms: 4321 }, prompt_template: { inline: "work" }, - model: { providerID: "other", modelID: "other-model" }, }, ], }, @@ -636,7 +731,6 @@ describe("workflow tool execution", () => { required: true, report_to_parent: true, worker_config: { timeout_ms: 1234 }, - model: { providerID: "local-proxy-compatible", modelID: "glm-5.2" }, }), ) expect(config.nodes[1]).toEqual( @@ -644,64 +738,8 @@ describe("workflow tool execution", () => { required: false, report_to_parent: false, worker_config: { timeout_ms: 4321 }, - model: { providerID: "other", modelID: "other-model" }, - }), - ) - }), - ) - - runtime.effect("start canonicalizes a provider-qualified model ID", () => - Effect.gen(function* () { - published.length = 0 - const info = yield* WorkflowTool - const workflow = yield* info.init() - - yield* workflow.execute( - { - action: "start", - config: { - name: "canonical-model", - nodes: [ - { - id: "worker", - name: "Worker", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - model: { - providerID: "local-proxy-compatible", - modelID: "local-proxy-compatible/glm-5.2", - }, - }, - ], - }, - }, - { - sessionID: SessionID.make("ses_workflow_parent"), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ) - - expect(published.find((event) => event.type === DagEvent.NodeRegistered.type)?.data).toEqual( - expect.objectContaining({ - model: { - providerID: "local-proxy-compatible", - modelID: "glm-5.2", - }, }), ) - const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { - config?: string - } - expect(JSON.parse(created.config ?? "{}").nodes[0].model).toEqual({ - providerID: "local-proxy-compatible", - modelID: "glm-5.2", - }) }), ) diff --git a/packages/tui/src/feature-plugins/system/dag-inspector.tsx b/packages/tui/src/feature-plugins/system/dag-inspector.tsx index 62ab92f88..159d3a193 100644 --- a/packages/tui/src/feature-plugins/system/dag-inspector.tsx +++ b/packages/tui/src/feature-plugins/system/dag-inspector.tsx @@ -48,6 +48,45 @@ function scrollRowIntoView(scroll: ScrollBoxRenderable | undefined, index: numbe } } +function cancelActiveWorkflow(api: TuiPluginApi) { + const current = api.route.current + const params = ("params" in current ? current.params : undefined) as { sessionID?: string } | undefined + const sessionID = params?.sessionID + if (!sessionID) { + api.ui.toast({ variant: "info", message: "No session selected for DAG cancellation" }) + return + } + void api.client.dag + .summary({ sessionID }) + .then((response) => { + const active = (response.data ?? []).filter((workflow) => + ["running", "paused", "stepping"].includes(String(workflow.status)), + ) + if (active.length === 0) { + api.ui.toast({ variant: "info", message: "No active DAG workflow to cancel" }) + return + } + if (active.length > 1) { + api.ui.toast({ variant: "info", message: "Multiple active workflows; select one in the DAG inspector" }) + api.route.navigate(ROUTE, { sessionID, returnRoute: current }) + api.ui.dialog.clear() + return + } + const workflow = active[0] + if (!workflow) return + return api.client.dag.control({ dagID: workflow.id, operation: "cancel" }).then(() => { + api.ui.toast({ variant: "info", message: `Workflow ${workflow.title} cancel requested` }) + api.ui.dialog.clear() + }) + }) + .catch((error: unknown) => { + api.ui.toast({ + variant: "error", + message: `DAG cancel failed: ${error instanceof Error ? error.message : String(error)}`, + }) + }) +} + function DagInspector(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current // The plugin-facing theme omits the resolver flags selectedForeground needs, @@ -676,6 +715,16 @@ const tui: TuiPlugin = async (api) => { api.ui.dialog.clear() }, }, + { + name: "dag.cancel.active", + title: "Cancel active DAG workflow", + slashName: "dag-cancel", + category: "Workflow", + namespace: "palette", + run() { + cancelActiveWorkflow(api) + }, + }, ], }) } diff --git a/packages/tui/test/feature-plugins/dag-inspector.test.tsx b/packages/tui/test/feature-plugins/dag-inspector.test.tsx index 6dab22c9f..578ffb2cb 100644 --- a/packages/tui/test/feature-plugins/dag-inspector.test.tsx +++ b/packages/tui/test/feature-plugins/dag-inspector.test.tsx @@ -240,6 +240,47 @@ describe("DagInspector", () => { } }) + test("/dag-cancel cancels the only active workflow in the current session", async () => { + const viewer = await renderDagInspector({ + initialRoute: { name: "session", params: { sessionID: SESSION_ID } }, + serverWorkflows: [ + wfSummary({ id: "wf-running", status: "running" }), + wfSummary({ id: "wf-complete", status: "completed" }), + ], + }) + try { + expect(viewer.commands.get("dag.cancel.active")?.slashName).toBe("dag-cancel") + runCommand(viewer.commands, "dag.cancel.active") + await waitForCondition(() => viewer.controlCalls().length > 0) + expect(viewer.controlCalls()).toEqual([{ dagID: "wf-running", operation: "cancel" }]) + } finally { + viewer.app.renderer.destroy() + } + }) + + test("/dag-cancel opens the inspector instead of guessing when multiple workflows are active", async () => { + const returnRoute = { name: "session", params: { sessionID: SESSION_ID } } + const viewer = await renderDagInspector({ + initialRoute: returnRoute, + serverWorkflows: [ + wfSummary({ id: "wf-running", status: "running" }), + wfSummary({ id: "wf-paused", status: "paused" }), + ], + }) + try { + runCommand(viewer.commands, "dag.cancel.active") + await waitForCondition(() => viewer.navigations().length > 0) + expect(viewer.controlCalls()).toEqual([]) + expect(viewer.navigations().at(-1)).toEqual({ + name: "dag", + params: { sessionID: SESSION_ID, returnRoute }, + }) + expect(viewer.toasts().at(-1)?.message).toContain("Multiple active workflows") + } finally { + viewer.app.renderer.destroy() + } + }) + test("opening dag refreshes workflows from the server when sync state is empty", async () => { const viewer = await renderDagInspector({ serverWorkflows: [wfSummary({ id: "wf-server", title: "Live server workflow", nodeCount: 1 })], From dbd2e877f98275d9fd0ebd41f534209df4bedeb1 Mon Sep 17 00:00:00 2001 From: lex Date: Thu, 30 Jul 2026 09:35:04 +0800 Subject: [PATCH 2/3] fix(ci): reduce dag lint warnings --- packages/opencode/src/dag/runtime/spawn.ts | 10 +++++---- .../opencode/test/dag/workflow-tool.test.ts | 22 +++++++++++++++---- .../feature-plugins/system/dag-inspector.tsx | 6 +++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/dag/runtime/spawn.ts b/packages/opencode/src/dag/runtime/spawn.ts index 1e00ef875..6eef90e76 100644 --- a/packages/opencode/src/dag/runtime/spawn.ts +++ b/packages/opencode/src/dag/runtime/spawn.ts @@ -31,6 +31,8 @@ import { DagModel } from "../model" import { validateReviewResult } from "../review-lifecycle" import { InvalidTransitionError, TerminalViolationError } from "@opencode-ai/core/dag/core/types" import type { DagStore } from "@opencode-ai/core/dag/store" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { registerCaptureSlot, clearCaptureSlot } from "./capture" type PromptParts = SessionPrompt.PromptInput["parts"] @@ -87,8 +89,8 @@ export function spawnNode( : undefined const nodeModel = persistedNodeModel ? { - modelID: persistedNodeModel.modelID as never, - providerID: persistedNodeModel.providerID as never, + modelID: persistedNodeModel.modelID, + providerID: persistedNodeModel.providerID, } : undefined const resolvedModel = DagModel.resolve({ @@ -102,8 +104,8 @@ export function spawnNode( return yield* Effect.fail(new Error(`No model configured for agent: ${agent.name}`)) } const model = { - modelID: resolvedModel.modelID as never, - providerID: resolvedModel.providerID as never, + modelID: ModelV2.ID.make(resolvedModel.modelID), + providerID: ProviderV2.ID.make(resolvedModel.providerID), } const childPermission = deriveSubagentSessionPermission({ diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 3e2f100fe..b55bbceb6 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -16,8 +16,11 @@ import { Truncate } from "@/tool/truncate" import { Parameters, WorkflowTool } from "@/tool/workflow" import { testEffect } from "../lib/effect" import { fingerprintBrief, type State } from "@/dag/admission" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" -const projectID = "project_test" +const projectID = ProjectV2.ID.make("project_test") const admissionBrief = { goal: "Qualify and execute a deep workflow", scope: { @@ -202,10 +205,17 @@ const runtime = testEffect( get: (id: Parameters[0]) => Effect.succeed({ id, + slug: "workflow-test", projectID, directory: "/project", - model: { providerID: "test" as never, id: "test-model" as never }, - } as unknown as Session.Info), + title: "Workflow test", + version: "test", + time: { created: 0, updated: 0 }, + model: { + providerID: ProviderV2.ID.make("test"), + id: ModelV2.ID.make("test-model"), + }, + } satisfies Session.Info), }), ), ) @@ -238,9 +248,13 @@ const missingModelRuntime = testEffect( get: (id: Parameters[0]) => Effect.succeed({ id, + slug: "workflow-test", projectID, directory: missingModelDirectory, - } as Session.Info), + title: "Workflow test", + version: "test", + time: { created: 0, updated: 0 }, + } satisfies Session.Info), }), ), ) diff --git a/packages/tui/src/feature-plugins/system/dag-inspector.tsx b/packages/tui/src/feature-plugins/system/dag-inspector.tsx index 159d3a193..0c1717b58 100644 --- a/packages/tui/src/feature-plugins/system/dag-inspector.tsx +++ b/packages/tui/src/feature-plugins/system/dag-inspector.tsx @@ -48,6 +48,8 @@ function scrollRowIntoView(scroll: ScrollBoxRenderable | undefined, index: numbe } } +const ACTIVE_WORKFLOW_STATUSES = new Set(["running", "paused", "stepping"]) + function cancelActiveWorkflow(api: TuiPluginApi) { const current = api.route.current const params = ("params" in current ? current.params : undefined) as { sessionID?: string } | undefined @@ -60,7 +62,7 @@ function cancelActiveWorkflow(api: TuiPluginApi) { .summary({ sessionID }) .then((response) => { const active = (response.data ?? []).filter((workflow) => - ["running", "paused", "stepping"].includes(String(workflow.status)), + ACTIVE_WORKFLOW_STATUSES.has(workflow.status), ) if (active.length === 0) { api.ui.toast({ variant: "info", message: "No active DAG workflow to cancel" }) @@ -74,7 +76,7 @@ function cancelActiveWorkflow(api: TuiPluginApi) { } const workflow = active[0] if (!workflow) return - return api.client.dag.control({ dagID: workflow.id, operation: "cancel" }).then(() => { + void api.client.dag.control({ dagID: workflow.id, operation: "cancel" }).then(() => { api.ui.toast({ variant: "info", message: `Workflow ${workflow.title} cancel requested` }) api.ui.dialog.clear() }) From fa9af0ad36c10196f6e603d01130732dfaee2cf0 Mon Sep 17 00:00:00 2001 From: lex Date: Thu, 30 Jul 2026 09:50:35 +0800 Subject: [PATCH 3/3] test(core): align dag model policy assertions --- packages/core/test/plugin/command.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index fc9e4ea54..405c19792 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -81,13 +81,14 @@ describe("CommandPlugin.Plugin", () => { it.effect("documents config-first model fallback without invented identifiers", () => Effect.sync(() => { - expect(CommandPlugin.OrchestrationPolicyContent).toContain("Omit `node.model` by default") expect(CommandPlugin.OrchestrationPolicyContent).toContain( - "`node.model` → `config.node_defaults.model` → configured agent model → parent session model", + "Never emit `node.model` or `config.node_defaults.model`", ) - expect(CommandPlugin.OrchestrationPolicyContent).toContain("exact provider/model pair") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("providerID") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("provider-local `modelID`") + expect(CommandPlugin.OrchestrationPolicyContent).toContain( + "`dag.jsonc` tier → configured agent model → parent session model", + ) + expect(CommandPlugin.OrchestrationPolicyContent).toContain("workflow tool starts parent-session QA") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("does not create the\nworkflow") expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT invent a model identifier") }), ) @@ -327,8 +328,12 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.WorkflowFactsContent).not.toContain('input: { findings: "from explore" }') expect(CommandPlugin.WorkflowFactsContent).not.toContain("Gate failure cancels the workflow automatically") expect(CommandPlugin.WorkflowFactsContent).toContain("Static `prompt_template.input`") - expect(CommandPlugin.WorkflowFactsContent).toContain("provider-local model ID") - expect(CommandPlugin.WorkflowFactsContent).toContain("agent model and then the parent session model") + expect(CommandPlugin.WorkflowFactsContent).toContain( + "Workflow definitions MUST NOT specify `node.model` or\n`config.node_defaults.model`", + ) + expect(CommandPlugin.WorkflowFactsContent).toMatch( + /`dag\.jsonc` tier, then the\s+configured agent model, then the parent-session model/, + ) expect(CommandPlugin.WorkflowFactsContent).toContain("Propose-then-assemble") const reviewExample = CommandPlugin.WorkflowFactsContent .slice(