From cff1423773e52b40b56c8bd53f4cae235c028c41 Mon Sep 17 00:00:00 2001 From: Lex Date: Sat, 10 Oct 2026 22:40:46 +0800 Subject: [PATCH 01/11] fix(dag): capture final answers and deliver parent reports once --- .../regression/dag-delivery-render.test.ts | 112 ++++ packages/core/src/plugin/command/workflow.md | 17 +- packages/opencode/src/cli/cmd/run.ts | 41 +- packages/opencode/src/cli/cmd/run/dag-hold.ts | 158 ++++- packages/opencode/src/dag/authoring.ts | 9 +- packages/opencode/src/dag/dag.ts | 22 +- packages/opencode/src/dag/report-identity.ts | 25 + packages/opencode/src/dag/runtime/capture.ts | 15 + .../src/dag/runtime/final-response.ts | 109 ++++ packages/opencode/src/dag/runtime/loop.ts | 91 ++- packages/opencode/src/dag/runtime/recovery.ts | 264 +++++++- .../src/dag/runtime/report-delivery.ts | 265 ++++++++ packages/opencode/src/dag/runtime/spawn.ts | 100 ++- packages/opencode/src/dag/validation.ts | 55 +- packages/opencode/src/session/tools.ts | 7 +- packages/opencode/src/tool/agent.ts | 1 + packages/opencode/src/tool/submit_result.ts | 77 +-- .../opencode/test/cli/run/dag-hold.test.ts | 242 +++++++- .../test/cli/run/run-dag-hold-process.test.ts | 296 ++++++++- .../test/dag/dag-final-delivery.test.ts | 357 +++++++++++ .../test/dag/dag-final-output.live.test.ts | 438 +++++++++++++ .../test/dag/dag-final-response.test.ts | 579 ++++++++++++++++++ .../test/dag/dag-goal-wake-retrigger.test.ts | 4 +- .../dag/dag-loop-recovery-integration.test.ts | 2 +- .../dag/dag-replan-stale-nodefailed.test.ts | 6 +- .../dag/dag-schema-prompt-contract.test.ts | 33 +- .../test/dag/dag-timeout-escalation.test.ts | 2 +- .../dag/dag-wake-inflight-release.test.ts | 4 +- .../test/dag/dag-wake-integration.test.ts | 14 +- .../test/cli/tui/dag-delivery-render.test.tsx | 221 +++++++ 30 files changed, 3417 insertions(+), 149 deletions(-) create mode 100644 packages/app/e2e/regression/dag-delivery-render.test.ts create mode 100644 packages/opencode/src/dag/report-identity.ts create mode 100644 packages/opencode/src/dag/runtime/final-response.ts create mode 100644 packages/opencode/src/dag/runtime/report-delivery.ts create mode 100644 packages/opencode/test/dag/dag-final-delivery.test.ts create mode 100644 packages/opencode/test/dag/dag-final-output.live.test.ts create mode 100644 packages/opencode/test/dag/dag-final-response.test.ts create mode 100644 packages/tui/test/cli/tui/dag-delivery-render.test.tsx diff --git a/packages/app/e2e/regression/dag-delivery-render.test.ts b/packages/app/e2e/regression/dag-delivery-render.test.ts new file mode 100644 index 000000000..81cfa71bb --- /dev/null +++ b/packages/app/e2e/regression/dag-delivery-render.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/DagDeliveryRender" +const projectID = "proj_dag_delivery_render" +const sessionID = "ses_dag_delivery_render" +const requestID = "msg_dag_delivery_request" +const deliveryID = "msg_dag_delivery_generated" +const followupID = "msg_dag_delivery_followup" +const sourceID = "prt_dag_delivery_source" +const answerID = "prt_dag_delivery_answer" +const followupTextID = "prt_dag_delivery_followup_text" +const title = "DAG delivery render" +const sourceText = "DAG · Release workflow · final-report" +const answerText = "DAG DELIVERY BODY: the release report is ready." +const followupText = "FOLLOWUP USER TURN remains visible." + +const messages = [ + { + info: { + id: requestID, + sessionID, + role: "user", + time: { created: 10 }, + }, + parts: [{ id: "prt_dag_delivery_request", sessionID, messageID: requestID, type: "text", text: "run workflow" }], + }, + { + info: { + id: deliveryID, + sessionID, + parentID: requestID, + role: "assistant", + agent: "build", + mode: "build", + modelID: "test-model", + providerID: "opencode", + path: { cwd: directory, root: directory }, + time: { created: 20, completed: 20 }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }, + parts: [ + { + id: sourceID, + sessionID, + messageID: deliveryID, + type: "text", + text: sourceText, + time: { start: 20, end: 20 }, + metadata: { dag_delivery: { kind: "source", workflow_id: "wf_release", node_id: "final-report" } }, + }, + { + id: answerID, + sessionID, + messageID: deliveryID, + type: "text", + text: answerText, + time: { start: 20, end: 20 }, + metadata: { dag_delivery: { kind: "answer", workflow_id: "wf_release", node_id: "final-report" } }, + }, + ], + }, + { + info: { + id: followupID, + sessionID, + role: "user", + time: { created: 30 }, + }, + parts: [{ id: followupTextID, sessionID, messageID: followupID, type: "text", text: followupText }], + }, +] + +test("renders DAG delivery source and answer once, then renders the next user message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: [ + { + id: sessionID, + slug: "dag-delivery-render", + projectID, + title, + directory, + version: "test", + time: { created: 10, updated: 30 }, + }, + ], + provider: { all: [], default: {}, connected: [] }, + directory, + project: { id: projectID, worktree: directory, sandboxes: [] }, + pageMessages: () => ({ items: messages }), + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const source = page.locator(`[data-timeline-part-id="${sourceID}"]`) + const answer = page.locator(`[data-timeline-part-id="${answerID}"]`) + const followup = page.locator(`[data-timeline-part-id="${followupTextID}"]`) + await expect(source).toHaveCount(1) + await expect(source).toContainText(sourceText) + await expect(source).toBeVisible() + await expect(answer).toHaveCount(1) + await expect(answer).toContainText(answerText) + await expect(answer).toBeVisible() + await expect(followup).toHaveCount(1) + await expect(followup).toContainText(followupText) + await expect(followup).toBeVisible() +}) diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index f0191bb7b..83becb655 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -527,7 +527,7 @@ All nodes share the same workspace. Write conflicts are an orchestration concern **Tier A — Disjoint write sets**: parallel nodes that write to non-overlapping files/paths can run concurrently without coordination. Structure the decomposition so each node owns a distinct module or file set. -**Tier B — Propose-then-assemble**: when disjoint write sets cannot be guaranteed, parallel nodes should only produce proposals (structured output via `output_schema` + `submit_result`), not directly write files. A single assembly node then applies the changes sequentially. The review point converges on the assembly node's diff, not on scattered parallel edits. +**Tier B — Propose-then-assemble**: when disjoint write sets cannot be guaranteed, parallel nodes should only produce proposals (structured output via `output_schema` and the workflow's `result_protocol`), not directly write files. A single assembly node then applies the changes sequentially. The review point converges on the assembly node's diff, not on scattered parallel edits. ## Design Principles @@ -538,7 +538,9 @@ All nodes share the same workspace. Write conflicts are an orchestration concern outputs, or a claim that inputs were aggregated are not successful aggregation. - Layers are computed automatically from `depends_on`. Nodes in the same layer execute concurrently up to `max_concurrency`. Do not try to control execution order beyond declaring dependencies. -- When a node declares `output_schema`, the child agent must call `submit_result` to submit its structured result. Failure to call `submit_result` before the session ends results in node failure (`verdict_fail`). Nodes without `output_schema` use plain text output (the final text part of the session). +- Newly created workflows default to `result_protocol: final_response`. With this protocol, a node that declares `output_schema` must end with exactly one JSON value matching the schema, without Markdown fences or surrounding prose. The runtime validates and captures that final answer; the child must not send the same result through `agent.send`. A node without `output_schema` returns its complete final answer as plain text. Use `agent.send` for questions, decisions, and collaboration. +- Existing persisted workflows that omit `result_protocol` retain the legacy `submit_result` protocol. Under that protocol, a node with `output_schema` must call `submit_result`; ending without a successful submission fails the node (`verdict_fail`). Set `result_protocol: submit_result` explicitly when a newly authored workflow needs this legacy behavior. +- A completed workflow delivers its selected result directly to the parent assistant message once; the parent should not generate a second summary of that result. Set `delivery_node` to choose the result node. In composable-block workflows, the sole leaf `synthesize` block is selected automatically. If no unique delivery node is specified, the parent receives a deterministic index of the available results. Result delivery does not change scheduling, pause, failure, timeout, or actionable-wake behavior. ## Tool Reference @@ -608,6 +610,15 @@ omitted content from its preview. - `complete` — early-complete: remaining pending nodes are skipped (non-violation) - `step` — Advance exactly one ready node, selected by lexicographic node ID, then wait. Use it for controlled debugging or staged verification of a critical path. Unlike `pause`, it advances one node and waits again. A second `step` is rejected while that node is running. Use `resume` to restore full-speed scheduling. Lexicographic selection keeps the order deterministic. +### Workflow result delivery + +| Field | Required | Description | +| ----- | -------- | ----------- | +| `result_protocol` | no | `final_response` by default for newly created workflows. With `output_schema`, the node's final assistant answer must be one JSON value matching the schema; without a schema, the complete final answer is captured as text. Historical workflows without this field keep `submit_result` behavior. | +| `delivery_node` | no | Node whose completed output is copied once into the parent assistant message. A composable-block workflow with exactly one leaf `synthesize` block selects it automatically. When no unique delivery node is configured, completion provides a deterministic index of available results. | + +With `final_response`, the child must not report its final result again through `agent.send`. Use `agent.send` for clarification, decisions, and collaboration. A failed, paused, timed-out, or otherwise actionable workflow still follows its normal wake and control path. + ### Node Fields | Field | Required | Description | @@ -622,7 +633,7 @@ omitted content from its preview. | `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 | | `worker_config` | no | `{ timeout_ms }` — bounds the node from admission to completion (defaults to 10 minutes if omitted); queue wait counts toward the budget, an expired queued node fails without spawning, and a running node that exceeds it escalates to the parent for adjudication (capped deadline extensions) before failing | -| `output_schema` | no | JSON Schema; when declared, the child agent must call `submit_result` to submit structured output — failure to submit results in node failure | +| `output_schema` | no | JSON Schema for the node result. Under `final_response`, the complete final answer must be exactly one JSON value matching the schema. Under legacy `submit_result`, the child must submit the value through that tool or the node fails | | `restart` | no | (replan only) Re-spawn this running node with new prompt | | `cancel` | no | (replan only) Cancel this node | | `review` | no | (deep review workers) `{ phase: "design" \| "diff" }`; a `diff` review must also declare `implementation_node_id` / `verification_node_id` and wire them: transitive review→verification→implementation dependencies, `input_mapping` for the diff artifact + fingerprint + verification output, a PASS-gated `condition`, and a `verdict`+`implementation_fingerprint` `output_schema`. Authoring rejects violations in deep mode and warns in standard | diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 9d619d185..2d5b82852 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -26,6 +26,7 @@ import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@openc import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" import { DagHold } from "./run/dag-hold" +import { MessageID } from "@/session/schema" type ModelInput = Parameters[0]["model"] @@ -678,16 +679,26 @@ export const RunCommand = effectCmd({ // to stdout/UI. `client` is passed explicitly because attach mode may // rebind the SDK to the session's directory after the subscription is // created, and replies issued from inside the loop must use that client. - async function loop(client: OpencodeClient, events: Awaited>) { + async function loop( + client: OpencodeClient, + events: Awaited>, + dagHold?: DagHold.DagHold, + ) { const toggles = new Map() let error: string | undefined // Issue 614: consumer-only DAG hold. Poll dag.bySession on the // busy/idle transitions of the target session and keep consuming // events while a session workflow may still owe a wake turn. Attach // mode keeps today's break-on-first-idle exit. - const dagHold = args.attach ? undefined : DagHold.createDagHold(() => DagHold.dagWorkflowsBySession(client, sessionID)) for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.sessionID === sessionID && + event.properties.info.role === "user" && + dagHold + ) + dagHold.onUserMessage(event.properties.info.id) if ( event.type === "message.updated" && event.properties.sessionID === sessionID && @@ -735,16 +746,26 @@ export const RunCommand = effectCmd({ } if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue + if (emit("text", { part })) { + dagHold?.onText(part) + continue + } const text = part.text.trim() - if (!text) continue + if (!text) { + // Empty final answers are intentionally not printed, but + // their event still crossed this consumer boundary. + dagHold?.onText(part) + continue + } if (!process.stdout.isTTY) { process.stdout.write(text + EOL) + dagHold?.onText(part) continue } UI.empty() UI.println(text) UI.empty() + dagHold?.onText(part) } if (part.type === "reasoning" && part.time?.end && thinking) { @@ -778,9 +799,7 @@ export const RunCommand = effectCmd({ if (event.properties.status.type === "busy") { await dagHold?.onBusy() } - if (event.properties.status.type === "idle" && (await dagHold?.onIdle()) !== "hold") { - break - } + if (event.properties.status.type === "idle" && (await dagHold?.onIdle()) !== "hold") break } if (event.type === "permission.asked") { @@ -817,7 +836,11 @@ export const RunCommand = effectCmd({ if (!interactive) { const events = await client.event.subscribe() - const completed = loop(client, events).catch((e) => { + const requestedMessageID = args.attach ? undefined : MessageID.ascending() + const dagHold = args.attach + ? undefined + : await DagHold.createDagRunHold(client, sessionID, requestedMessageID) + const completed = loop(client, events, dagHold).catch((e) => { console.error(e) process.exitCode = 1 }) @@ -830,6 +853,7 @@ export const RunCommand = effectCmd({ if (args.command) { const result = await client.session.command({ sessionID, + messageID: requestedMessageID, agent, model: args.model, command: args.command, @@ -848,6 +872,7 @@ export const RunCommand = effectCmd({ const model = pick(args.model) const result = await client.session.prompt({ sessionID, + messageID: requestedMessageID, agent, model, variant: args.variant, diff --git a/packages/opencode/src/cli/cmd/run/dag-hold.ts b/packages/opencode/src/cli/cmd/run/dag-hold.ts index 27713481e..bbff91c5b 100644 --- a/packages/opencode/src/cli/cmd/run/dag-hold.ts +++ b/packages/opencode/src/cli/cmd/run/dag-hold.ts @@ -8,7 +8,7 @@ // exits and the eventual wake reply is silently lost. The durable dag.* events // are not on the client wire and `wake_reported` is not part of the // dag.bySession DTO, so the consumer cannot subscribe to completion — it can -// only poll. This module is that poll: a pure three-condition break rule plus a +// only poll. This module is that poll: a pure four-condition break rule plus a // thin wrapper the run loop calls on the busy/idle transitions it already // observes. // @@ -31,20 +31,30 @@ // 3. every id in poll(now) was already known (a first-seen workflow id means // a wake for it may still be in flight — covers workflows that // terminalize within turn one and chained DAGs started during a wake turn) +// 4. every completed final-response workflow has its answer receipt in the +// parent transcript and a newly delivered answer has been emitted by this +// run (multiple completions may be delivered separately) // -// Degradation is deliberately silent and matches pre-614 behavior: -// - a poll failure at idle breaks the loop (today's exit, no error surfaced) +// Degradation is deliberately silent and matches pre-614 behavior for the DAG +// list itself: +// - a DAG list failure at idle breaks the loop (today's exit, no error surfaced) // - a poll failure while busy leaves the state untouched +// A report receipt read failure stays pending, since breaking could hide a +// completed workflow's answer from the parent transcript. // Paused and stepping workflows are not active statuses, so they never hold the // loop open by themselves — the CLI exits as it does today once quiescent and // known. There is no timer and no reconnect: an engine stall is bounded by the // engine watchdog, and a visible hang is accepted over silent wake loss. import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import { reportIdentity } from "@/dag/report-identity" +import { isRecord } from "@/util/record" export type DagWorkflowSnapshot = { readonly id: string readonly status: string + /** The completion episode still lacks its parent answer receipt. */ + readonly awaitingReport?: boolean } export type DagHoldDecision = "hold" | "break" @@ -90,8 +100,9 @@ export function observeDagHoldBusy(state: DagHoldState, snapshot: readonly DagWo * adds it right after. */ export function applyDagHoldSnapshot(state: DagHoldState, snapshot: readonly DagWorkflowSnapshot[]): DagHoldDecision { const active = snapshot.some((workflow) => isDagActiveStatus(workflow.status)) + const awaitingReport = snapshot.some((workflow) => workflow.awaitingReport === true) const known = snapshot.every((workflow) => state.busyIds.has(workflow.id)) - const decision: DagHoldDecision = !active && !state.busyActive && known ? "break" : "hold" + const decision: DagHoldDecision = !active && !state.busyActive && known && !awaitingReport ? "break" : "hold" state.inTurn = false foldSnapshot(state, snapshot) return decision @@ -106,10 +117,25 @@ export type DagHold = { /** session.status=idle for the target session: poll and decide. A failed * poll breaks — exactly the pre-614 exit, silently. */ readonly onIdle: () => Promise + /** Record a finished answer text part after this run emits it to the user. */ + readonly onText: (part: { + id: string + sessionID: string + messageID: string + text?: string + metadata?: Record + }) => void + /** The current command/prompt user message entered this event stream. */ + readonly onUserMessage: (messageID: string) => void } -export function createDagHold(poll: DagHoldPoll): DagHold { +export function createDagHold( + poll: DagHoldPoll, + onText: DagHold["onText"] = () => {}, + requestedMessageID?: string, +): DagHold { const state = initialDagHoldState() + let requestObserved = requestedMessageID === undefined return { onBusy: async () => { const snapshot = await poll().catch(() => undefined) @@ -117,22 +143,134 @@ export function createDagHold(poll: DagHoldPoll): DagHold { }, onIdle: async () => { const snapshot = await poll().catch(() => undefined) - if (!snapshot) return "break" - return applyDagHoldSnapshot(state, snapshot) + const decision = snapshot ? applyDagHoldSnapshot(state, snapshot) : "break" + return requestObserved ? decision : "hold" + }, + onText, + onUserMessage: (messageID) => { + if (messageID === requestedMessageID) requestObserved = true }, } } +function finalResponseConfig(raw: string): boolean { + try { + const parsed: unknown = JSON.parse(raw) + return isRecord(parsed) && parsed.result_protocol === "final_response" + } catch { + return false + } +} + +type ReportObservation = { + readonly baseline: ReadonlySet + readonly emitted: ReadonlySet +} + +function episodeKey(sessionID: string, workflowID: string, seq: number): string { + return JSON.stringify([sessionID, workflowID, seq]) +} + +function answerKey(sessionID: string, messageID: string, partID: string, workflowID: string, seq: number): string { + return JSON.stringify([sessionID, messageID, partID, workflowID, seq]) +} + +function answerWasEmitted(part: Parameters[0]): string | undefined { + if (!isRecord(part.metadata)) return undefined + const delivery = part.metadata.dag_delivery + if (!isRecord(delivery) || delivery.kind !== "answer") return undefined + if (typeof delivery.workflow_id !== "string" || typeof delivery.completion_seq !== "number") return undefined + return answerKey(part.sessionID, part.messageID, part.id, delivery.workflow_id, delivery.completion_seq) +} + /** Poll implementation over the in-process SDK client (GET /dag/session/:id — - * session-scoped, project-ownership enforced server-side). Any failure maps to - * undefined so the wrapper degrades silently. */ + * session-scoped, project-ownership enforced server-side). A completed final + * response remains pending until its exact answer receipt is visible. */ export async function dagWorkflowsBySession( client: OpencodeClient, sessionID: string, + verifiedReceipts: Set = new Set(), + observation?: ReportObservation, ): Promise { const response = await client.dag.bySession({ sessionID }) if (response.error) return undefined - return (response.data ?? []).map((workflow) => ({ id: workflow.id, status: workflow.status })) + return Promise.all( + (response.data ?? []).map(async (workflow): Promise => { + const basic = { id: workflow.id, status: workflow.status } + if (workflow.status !== "completed" || !finalResponseConfig(workflow.config)) return basic + if (typeof workflow.seq !== "number" || !Number.isSafeInteger(workflow.seq)) + return { ...basic, awaitingReport: true } + const seq = workflow.seq + if (typeof workflow.time_updated !== "number" || !Number.isFinite(workflow.time_updated)) + return { ...basic, awaitingReport: true } + const completedAt = workflow.completed_at + if (completedAt !== undefined && (typeof completedAt !== "number" || !Number.isFinite(completedAt))) + return { ...basic, awaitingReport: true } + const identity = reportIdentity({ + sessionId: sessionID, + id: workflow.id, + seq, + completedAt: completedAt ?? null, + timeUpdated: workflow.time_updated, + }) + const episode = episodeKey(sessionID, workflow.id, seq) + const awaitingEmission = () => + observation !== undefined && + !observation.baseline.has(episode) && + !observation.emitted.has(answerKey(sessionID, identity.messageID, identity.answerID, workflow.id, seq)) + if (verifiedReceipts.has(episode)) return { ...basic, awaitingReport: awaitingEmission() } + try { + const receipt = await client.session.message({ sessionID, messageID: identity.messageID }) + const found = + !receipt.error && + receipt.data?.info.id === identity.messageID && + receipt.data.parts.some((part) => { + if (part.type !== "text" || part.id !== identity.answerID || !isRecord(part.metadata)) return false + const delivery = part.metadata.dag_delivery + return ( + isRecord(delivery) && + delivery.workflow_id === workflow.id && + delivery.completion_seq === seq && + delivery.kind === "answer" + ) + }) + if (found) verifiedReceipts.add(episode) + return { ...basic, awaitingReport: !found || awaitingEmission() } + } catch { + return { ...basic, awaitingReport: true } + } + }), + ) +} + +/** Cache only verified receipts for this one CLI run. New completion seqs are + * still read, while historic completions cost one read at most. */ +export function makeDagWorkflowPoll(client: OpencodeClient, sessionID: string): DagHoldPoll { + const verifiedReceipts = new Set() + return () => dagWorkflowsBySession(client, sessionID, verifiedReceipts) +} + +/** Subscribe first, then await this baseline before starting the prompt. A + * historic answer already present at startup need not be replayed by the + * current stream; every later completion must both persist and be emitted. */ +export async function createDagRunHold( + client: OpencodeClient, + sessionID: string, + requestedMessageID?: string, +): Promise { + const verifiedReceipts = new Set() + await dagWorkflowsBySession(client, sessionID, verifiedReceipts).catch(() => undefined) + const baseline = new Set(verifiedReceipts) + const emitted = new Set() + const poll = () => dagWorkflowsBySession(client, sessionID, verifiedReceipts, { baseline, emitted }) + return createDagHold( + poll, + (part) => { + const key = answerWasEmitted(part) + if (key) emitted.add(key) + }, + requestedMessageID, + ) } export * as DagHold from "./dag-hold" diff --git a/packages/opencode/src/dag/authoring.ts b/packages/opencode/src/dag/authoring.ts index 51fd2814f..8d2e30dec 100644 --- a/packages/opencode/src/dag/authoring.ts +++ b/packages/opencode/src/dag/authoring.ts @@ -151,12 +151,19 @@ export function make(options: Options = {}) { function prepareGraph(decoded: DecodedAction, nodes: NodeConfig[], nodeDefaults?: NodeDefaults): PreparedGraph { if (decoded.action !== "start") return { action: decoded.action, nodes } const spec = decoded.spec + const blocks = "blocks" in spec.config ? spec.config.blocks : [] + const syntheses = blocks.filter( + (block) => block.kind === "synthesize" && !blocks.some((other) => other.depends_on?.includes(block.id)), + ) + const deliveryNode = spec.config.delivery_node ?? (syntheses.length === 1 ? syntheses[0].id : undefined) return { action: decoded.action, nodes, title: spec.title ?? spec.config.name, config: { name: spec.config.name, + ...(spec.config.result_protocol ? { result_protocol: spec.config.result_protocol } : {}), + ...(deliveryNode ? { delivery_node: deliveryNode } : {}), mode: spec.mode ?? "standard", ...(spec.config.max_concurrency !== undefined ? { max_concurrency: spec.config.max_concurrency } : {}), ...(spec.config.max_node_replan_attempts !== undefined @@ -227,7 +234,7 @@ function compileAction( diagnostics: DagValidation.Diagnostic[] blocks?: readonly import("./blocks").DagBlocks.WorkflowBlock[] node_defaults?: NodeDefaults - config: { mode?: "standard" | "deep"; max_total_nodes?: number } + config: { mode?: "standard" | "deep"; max_total_nodes?: number; delivery_node?: string } } { if (decoded.action === "extend") { const extend = decoded.spec diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index a6456ddb3..80d775fde 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -136,8 +136,17 @@ interface NormalizedNodeConfig extends NodeConfig { required: boolean } +export type ResultProtocol = "final_response" | "submit_result" + +/** Missing on historical persisted workflows means the original tool protocol. */ +export function resultProtocol(config: Pick | undefined): ResultProtocol { + return config?.result_protocol ?? "submit_result" +} + export interface WorkflowConfig { name: string + result_protocol?: ResultProtocol + delivery_node?: string mode?: ExecutionMode admission?: AdmissionRecord max_concurrency?: number @@ -248,6 +257,7 @@ function normalizeWorkflowConfig(config: WorkflowConfig): WorkflowConfig & { nod const defaults = normalizeNodeDefaults(config.node_defaults) return { ...config, + result_protocol: config.result_protocol ?? "final_response", mode: config.mode ?? "standard", max_concurrency: config.max_concurrency ?? DEFAULT_WORKFLOW_CONFIG.maxConcurrency, max_node_replan_attempts: config.max_node_replan_attempts ?? DEFAULT_WORKFLOW_CONFIG.maxNodeReplanAttempts, @@ -292,6 +302,13 @@ export function parseWorkflowConfig(raw: string): WorkflowConfig | undefined { if (Option.isNone(parsed)) return undefined const candidate: unknown = parsed.value if (!isRecord(candidate)) return undefined + if ( + candidate.result_protocol !== undefined && + candidate.result_protocol !== "final_response" && + candidate.result_protocol !== "submit_result" + ) + return undefined + if (candidate.delivery_node !== undefined && typeof candidate.delivery_node !== "string") return undefined // Guard the invariants every caller relies on (config.nodes.map, node.id, // depends_on iteration) instead of trusting the persisted row blindly. Kept // structural rather than a full strict schema: rejecting a legacy row that @@ -508,6 +525,7 @@ export const layer = Layer.effect( // before any event publication. const structural = DagValidation.structuralDiagnostics({ nodes: config.nodes, + delivery_node: config.delivery_node, mode: config.mode, max_total_nodes: config.max_total_nodes, }) @@ -828,7 +846,9 @@ export const layer = Layer.effect( // create/replan parity the spec requires: one authority, two entry points // that differ only in scoping (fragment + rerun-only vs whole-graph). const maxReplanAttempts = wfConfig.max_node_replan_attempts ?? DEFAULT_WORKFLOW_CONFIG.maxNodeReplanAttempts - const terminalNodeIds = new Set(nodes.filter((n) => isNodeTerminalStatus(n.status as NodeStatus)).map((n) => n.id)) + const terminalNodeIds = new Set( + nodes.filter((n) => isNodeTerminalStatus(n.status as NodeStatus)).map((n) => n.id), + ) const merged = computeMergedConfig(wfConfig, normalizedFragment, plan) const replanDiagnostics = DagValidation.replanStructuralDiagnostics({ fragmentNodes: normalizedFragment.nodes, diff --git a/packages/opencode/src/dag/report-identity.ts b/packages/opencode/src/dag/report-identity.ts new file mode 100644 index 000000000..0c850692d --- /dev/null +++ b/packages/opencode/src/dag/report-identity.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Hash } from "@opencode-ai/core/util/hash" +import { MessageID, PartID } from "@/session/schema" + +/** A durable workflow event sequence identifies one completion across graph revisions. */ +export function reportIdentity(workflow: { + sessionId: string + id: string + seq: number + completedAt: number | null + timeUpdated: number +}) { + const digest = Hash.sha256(JSON.stringify([workflow.sessionId, workflow.id, workflow.seq])) + const time = workflow.completedAt ?? workflow.timeUpdated + const stamp = time.toString(16).padStart(12, "0") + const anchorStamp = Math.max(0, time - 1).toString(16).padStart(12, "0") + return { + messageID: MessageID.make(`msg_${stamp}1${digest.slice(0, 13)}`), + sourceID: PartID.make(`prt_${stamp}0${digest.slice(14, 27)}`), + answerID: PartID.make(`prt_${stamp}1${digest.slice(28, 41)}`), + anchorID: MessageID.make(`msg_${anchorStamp}0${digest.slice(42, 55)}`), + } +} diff --git a/packages/opencode/src/dag/runtime/capture.ts b/packages/opencode/src/dag/runtime/capture.ts index fcb6b803e..7f9484e8d 100644 --- a/packages/opencode/src/dag/runtime/capture.ts +++ b/packages/opencode/src/dag/runtime/capture.ts @@ -20,6 +20,21 @@ type CaptureSlot = { schema: Record; generation: number; pendin const schemas = new Map() let generation = 0 const snapshots = new Map() +// A child session owns its protocol. This is registered before prompting and +// removed with the execution fiber, so unrelated sessions keep their tools. +const finalResponseSessions = new Set() + +export function registerFinalResponseSession(sessionID: string): void { + finalResponseSessions.add(sessionID) +} + +export function isFinalResponseSession(sessionID: string): boolean { + return finalResponseSessions.has(sessionID) +} + +export function clearFinalResponseSession(sessionID: string): void { + finalResponseSessions.delete(sessionID) +} export function setCaptureSnapshot(sessionID: string, snapshotID: string): void { snapshots.set(sessionID, snapshotID) diff --git a/packages/opencode/src/dag/runtime/final-response.ts b/packages/opencode/src/dag/runtime/final-response.ts new file mode 100644 index 000000000..3e2c64cdf --- /dev/null +++ b/packages/opencode/src/dag/runtime/final-response.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Effect, Option, Scheduler } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { DagMessages } from "@opencode-ai/core/dag/messages" +import type { DagStore } from "@opencode-ai/core/dag/store" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { isCaptureValidationCurrent, validatePayloadAsync, type CaptureValidation } from "./capture" + +export type FinalText = { ok: true; text: string } | { ok: false; reason: string } + +/** Read one completed assistant message, preserving every visible text part. */ +export function finalAssistantText(message: SessionV1.WithParts | undefined, allowMissingFinish = false): FinalText { + if (!message || message.info.role !== "assistant" || message.info.error) + return { ok: false, reason: "child model turn stopped before a successful result" } + const finish = message.info.finish + if (finish !== "stop" && !(allowMissingFinish && finish === undefined)) + return { ok: false, reason: `child model turn did not end normally (${finish ?? "unfinished"})` } + const text = message.parts + .filter((part): part is SessionV1.TextPart => part.type === "text" && !part.synthetic && !part.ignored) + .map((part) => part.text) + .join("") + if (text.trim() === "") return { ok: false, reason: "provider returned empty output" } + return { ok: true, text } +} + +export type ParsedFinalResponse = { ok: true; payload: unknown } | { ok: false; reason: string } + +/** JSON.parse consumes exactly one complete JSON value (including falsy values). */ +export function parseFinalResponse(text: string): ParsedFinalResponse { + try { + return { ok: true, payload: JSON.parse(text) as unknown } + } catch { + return { ok: false, reason: "final response must be one JSON value matching output_schema" } + } +} + +export type CaptureReceipt = { ok: true } | { ok: false; reason: string; code?: string } +export type FinalCaptureResult = + { ok: true; output: unknown; snapshotID?: string } | { ok: false; reason: string; code?: string } + +/** Shared guarded persistence for submit_result and final-response capture. */ +export function persistValidatedCapture(input: { + store: DagStore.Interface + sessionID: string + validated: Extract + abort: AbortSignal + snapshotID?: string + caller?: DagMessages.Caller + guard?: Omit +}) { + return Effect.gen(function* () { + const messages = yield* Effect.serviceOption(DagMessages.Service) + const commit = Effect.gen(function* () { + if (input.abort.aborted || !isCaptureValidationCurrent(input.sessionID, input.validated)) return false + yield* input.store + .setCapturedOutput(input.sessionID, input.validated.payload, input.snapshotID) + .pipe(Effect.orDie) + return true + }).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true)) + if (Option.isSome(messages) && input.caller && input.guard) { + const receipt = yield* messages.value.guard( + input.caller, + { ...input.guard, snapshotID: input.snapshotID, close: false }, + commit, + ) + if (!receipt.ok) return { ok: false, reason: receipt.reason, code: receipt.reason } as const + if (!receipt.value) return { ok: false, reason: "capture slot changed or cancelled" } as const + return { ok: true } as const + } + const database = yield* Effect.serviceOption(Database.Service) + const captured = yield* Option.isSome(database) + ? database.value.db.transaction(() => commit).pipe(Effect.orDie) + : commit + return captured ? ({ ok: true } as const) : ({ ok: false, reason: "capture slot changed or cancelled" } as const) + }) +} + +export function captureFinalResponse(input: { + store: DagStore.Interface + sessionID: string + message: SessionV1.WithParts + abort?: AbortSignal + snapshotID?: string + caller?: DagMessages.Caller + guard?: Omit +}) { + return Effect.gen(function* () { + const final = finalAssistantText(input.message, true) + if (!final.ok) return { ...final, code: undefined } as const + const parsed = parseFinalResponse(final.text) + if (!parsed.ok) return { ...parsed, code: undefined } as const + let validationSignal: AbortSignal | undefined + const validated = yield* Effect.promise((signal) => { + validationSignal = signal + return validatePayloadAsync( + input.sessionID, + parsed.payload, + input.abort ? AbortSignal.any([signal, input.abort]) : signal, + ) + }) + if (!validated.ok) return { ok: false, reason: `final response schema validation failed: ${validated.error}` } as const + const receipt = yield* persistValidatedCapture({ ...input, validated, abort: input.abort ?? validationSignal! }) + if (!receipt.ok) + return { ok: false, reason: `final response capture rejected: ${receipt.reason}`, code: receipt.code } as const + return { ok: true, output: validated.payload, snapshotID: input.snapshotID } as const + }) +} diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 874f269f7..1a2fb5381 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -29,6 +29,7 @@ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { DagStore } from "@opencode-ai/core/dag/store" import { DagMessages } from "@opencode-ai/core/dag/messages" import { Hash } from "@opencode-ai/core/util/hash" +import { Database } from "@opencode-ai/core/database/database" import { DagLocation } from "../location" import { WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/scheduling" import { @@ -37,7 +38,7 @@ import { isWorkflowTerminalStatus, isTerminalWorkflowStatusValue, } from "@opencode-ai/core/dag/core/types" -import { Dag, type WorkflowConfig, parseWorkflowConfig } from "../dag" +import { Dag, type WorkflowConfig, parseWorkflowConfig, resultProtocol } from "../dag" import { projectBriefForNode } from "../admission" import { reviewImplementationFingerprint, @@ -62,11 +63,13 @@ import { continueRecoveredMessageNode, makeSessionStatusChecker, makeLastAssistantTextReader, + makeLastAssistantMessageReader, } from "./recovery" import { Checkpoint } from "./checkpoint" import { verifyOutputFileRef, isManagedOutputFileRef } from "./output-ref" import { makeArtifactSourceAuthorizer } from "./artifact-permissions" import { Permission } from "@/permission" +import { deliverReport, selectReportNode } from "./report-delivery" const parseJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const PAUSED_REMINDER_DELAY_MS = 5 * 60_000 @@ -151,6 +154,7 @@ const serviceLayer = Layer.effect( const permissionSvc = Option.getOrUndefined(yield* Effect.serviceOption(Permission.Service)) const statusSvc = yield* SessionStatus.Service const automation = yield* SessionAutomationLease.Service + const database = yield* Database.Service const state = yield* InstanceState.make( Effect.fn("DagLoop.state")(function* (ctx) { @@ -521,7 +525,16 @@ const serviceLayer = Layer.effect( if (nodeConfig?.output_schema) { promptParts.push({ type: "text", - text: `\n\n[OUTPUT CONTRACT — this node's success depends on it] You MUST call the submit_result tool with a JSON payload matching this schema before ending your turn:\n${JSON.stringify(nodeConfig.output_schema, null, 2)}\nPut your full summary inside the payload. Do not repeat the payload in your message text. Writing the payload in message text does NOT count as submitting: if you end your turn without a successful submit_result call, this node FAILS and no structured result is accepted. File changes and other side effects are not rolled back; inspect them before retrying. After submit_result succeeds, end your turn without restating the result.`, + text: + resultProtocol(entry.config) === "final_response" + ? `\n\n[OUTPUT CONTRACT] End with exactly one JSON value matching this schema:\n${JSON.stringify(nodeConfig.output_schema, null, 2)}\nUse your final assistant answer only: no Markdown fence or surrounding prose. The runtime validates and captures it automatically. Do not send the final result through agent.send. File changes and other side effects are not rolled back if validation fails.` + : `\n\n[OUTPUT CONTRACT — this node's success depends on it] You MUST call the submit_result tool with a JSON payload matching this schema before ending your turn:\n${JSON.stringify(nodeConfig.output_schema, null, 2)}\nPut your full summary inside the payload. Do not repeat the payload in your message text. Writing the payload in message text does NOT count as submitting: if you end your turn without a successful submit_result call, this node FAILS and no structured result is accepted. File changes and other side effects are not rolled back; inspect them before retrying. After submit_result succeeds, end your turn without restating the result.`, + }) + } + if (resultProtocol(entry.config) === "final_response" && !nodeConfig?.output_schema) { + promptParts.push({ + type: "text", + text: "\n\n[FINAL ANSWER] End with your complete final answer. It is captured automatically; do not also report it through agent.send. Use agent.send for questions, decisions and collaboration.", }) } @@ -546,6 +559,7 @@ const serviceLayer = Layer.effect( inputArtifacts: inputSources.map((source) => source.capturedOutput).filter(isManagedOutputFileRef), promptParts, outputSchema: nodeConfig?.output_schema as Record | undefined, + resultProtocol: resultProtocol(entry.config), timeoutMs: nodeConfig?.worker_config?.timeout_ms, reportToParent: nodeConfig?.report_to_parent, reviewImplementationFingerprint: nodeConfig @@ -628,6 +642,7 @@ const serviceLayer = Layer.effect( // #345: schemaless recovered nodes settle with the child's last // assistant text, mirroring the live spawn path. const lastAssistantText = makeLastAssistantTextReader(sessionSvc) + const lastAssistantMessage = makeLastAssistantMessageReader(sessionSvc) // Best-effort abort of a durable child session, independent of whether // a local wrapper fiber still exists. Used at every replacement, @@ -777,6 +792,7 @@ const serviceLayer = Layer.effect( lastAssistantText, ctx.directory, makeArtifactSourceAuthorizer(sessionSvc, agentSvc, ctx.worktree, permissionSvc), + lastAssistantMessage, ).pipe(Effect.provideService(Dag.Service, dag), (self) => messageSvc ? Effect.provideService(self, DagMessages.Service, messageSvc) : self, ) @@ -1642,6 +1658,11 @@ const serviceLayer = Layer.effect( } satisfies DagStore.WakeBatch return { batch, + completedReports: boundaryWorkflows.filter( + (workflow) => + workflow.status === "completed" && + resultProtocol(parseWorkflowConfig(workflow.config)) === "final_response", + ), actionableDagIDs: new Set( boundaryWorkflows .filter((workflow) => !isWorkflowTerminalStatus(workflow.status as never)) @@ -1685,7 +1706,7 @@ const serviceLayer = Layer.effect( const deliveredUnresponsiveDagIDs = new Set() for (;;) { const plan = yield* readWakeBatch(sessionID) - const batch = plan.batch + let batch = plan.batch const hasUnreported = batch.nodes.length > 0 || batch.workflows.length > 0 if (!hasUnreported) { @@ -1745,6 +1766,68 @@ const serviceLayer = Layer.effect( if ((yield* statusSvc.get(SessionID.make(sessionID))).type !== "idle") return + // Completed final-response workflows display their result without a model turn. + // Other decisions in the same snapshot take precedence; their wake excludes + // these completed reports so they cannot invite a duplicate summary. + const completedIDs = new Set(plan.completedReports.map((workflow) => workflow.id)) + const decisions = { + nodes: batch.nodes.filter((node) => !completedIDs.has(node.workflowId)), + workflows: batch.workflows.filter((workflow) => !completedIDs.has(workflow.id)), + } satisfies DagStore.WakeBatch + if ( + plan.completedReports.length > 0 && + decisions.nodes.length === 0 && + decisions.workflows.length === 0 + ) { + for (const workflow of plan.completedReports) { + const reportBatch = { + nodes: batch.nodes.filter((node) => node.workflowId === workflow.id), + workflows: batch.workflows.filter((row) => row.id === workflow.id), + } + if (reportBatch.nodes.length === 0 && reportBatch.workflows.length === 0) continue + yield* automation.register(SessionID.make(sessionID), { kind: "dag", id: workflow.id }) + const lease = Option.getOrUndefined( + yield* automation.claim(SessionID.make(sessionID), { kind: "dag" }), + ) + if (!lease) return + const config = parseWorkflowConfig(workflow.config) + const reportNodes = yield* store.getCurrentNodes(workflow.id).pipe(Effect.orDie) + const node = + (config ? selectReportNode(config, reportNodes) : undefined) ?? + reportNodes.find((node) => node.status === "completed") + const source = node?.childSessionId + ? yield* lastAssistantMessage(node.childSessionId).pipe(Effect.orDie) + : undefined + const delivered = yield* automation.use( + lease, + promptSvc.withIdle( + SessionID.make(sessionID), + deliverReport({ + workflow, + batch: reportBatch, + store, + sessions: sessionSvc, + directory: ctx.directory, + source, + }).pipe( + Effect.provideService(Database.Service, database), + Effect.tap((didDeliver) => + didDeliver + ? statusSvc + .set(SessionID.make(sessionID), { type: "busy" }) + .pipe(Effect.ensuring(statusSvc.set(SessionID.make(sessionID), { type: "idle" }))) + : Effect.void, + ), + ), + ), + ) + if (Option.isNone(delivered) || Option.isNone(delivered.value) || !delivered.value.value) return + yield* automation.unregister(SessionID.make(sessionID), { kind: "dag", id: workflow.id }) + } + continue + } + if (completedIDs.size > 0) batch = decisions + const wakeIdentity = wakeBatchIdentity(sessionID, batch) const durableReceipt = deliveredWakeSummaries.get(sessionID) === wakeIdentity.messageID @@ -2317,6 +2400,7 @@ const serviceLayer = Layer.effect( export const layer = serviceLayer.pipe(Layer.provide(SessionAutomationLease.defaultLayer)) export const defaultLayer = layer.pipe( + Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(DagStore.defaultLayer), Layer.provide(DagMessages.defaultLayer), @@ -2329,6 +2413,7 @@ export const defaultLayer = layer.pipe( ) export const node = LayerNode.make(layer, [ + Database.node, EventV2Bridge.node, DagStore.node, DagMessages.node, diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index 0ab094746..5e7f0a9c0 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -23,8 +23,9 @@ import { Effect, Clock, Cause, Option } from "effect" import { Dag, isStaleMessageInput } from "../dag" import { DagMessages } from "@opencode-ai/core/dag/messages" +import { FSUtil } from "@opencode-ai/core/fs-util" import { SessionPrompt } from "@/session/prompt" -import type { NodeConfig } from "../dag" +import type { NodeConfig, ResultProtocol } from "../dag" import { Session } from "@/session/session" import { SessionID, MessageID } from "@/session/schema" import type { SessionV1 } from "@opencode-ai/core/v1/session" @@ -32,8 +33,16 @@ import type { DagStore } from "@opencode-ai/core/dag/store" import { isTransitionRejection } from "@opencode-ai/core/dag/core/types" import { reviewImplementationFingerprint } from "../review-lifecycle" import { resolveInputMapping } from "./eval" -import { settleCapturedOutput, settlePlainTextOutput, registerCaptureSlot, clearCaptureSlot } from "./capture" +import { + settleCapturedOutput, + settlePlainTextOutput, + registerCaptureSlot, + clearCaptureSlot, + registerFinalResponseSession, + clearFinalResponseSession, +} from "./capture" import type { CapturedSettlement } from "./capture" +import { captureFinalResponse, finalAssistantText, type FinalCaptureResult } from "./final-response" import { commitOutputFileRef, ensureReportAreaGitignore, @@ -45,10 +54,14 @@ export function reconcileWorkflow( dagID: string, checkSessionStatus: (childSessionID: string) => Effect.Effect<"active" | "completed" | "failed" | "unknown", Error>, cancelSession?: (sessionID: string) => Effect.Effect, - workflowConfig?: { nodes: Pick[] } | null, + workflowConfig?: { + nodes: Pick[] + result_protocol?: ResultProtocol + } | null, lastAssistantText?: (childSessionID: string) => Effect.Effect, directory?: string, authorizeSource?: (childSessionID: string, source: string, workerType?: string) => Effect.Effect, + lastAssistantMessage?: (childSessionID: string) => Effect.Effect, ): Effect.Effect<{ reconciled: number; ownershipLost: number; continuations?: string[] }, Error, Dag.Service> { return Effect.gen(function* () { const dag = yield* Dag.Service @@ -175,7 +188,63 @@ export function reconcileWorkflow( continue } const nodeConfig = workflowConfig?.nodes.find((n) => n.id === node.id) - if (nodeConfig?.output_schema) { + if (nodeConfig?.output_schema && workflowConfig?.result_protocol === "final_response") { + const message = lastAssistantMessage ? yield* lastAssistantMessage(node.childSessionId) : undefined + const caller = workflow?.directory + ? { projectID: workflow.projectId, directory: workflow.directory, sessionID: node.childSessionId } + : undefined + const frozen = + Option.isSome(messages) && caller && message + ? yield* messages.value.snapshotForTurn(caller, message.info.id) + : undefined + const snapshotID = frozen?.ok ? frozen.value?.id : undefined + let captured: FinalCaptureResult + if ( + message && + finalAssistantText(message).ok && + snapshotID && + node.capturedOutputPresent && + node.capturedSnapshotID === snapshotID + ) { + // The guarded, validated receipt belongs to this exact final + // model turn. A crash after capture but before NodeCompleted can + // settle it without starting another validation worker. + captured = { ok: true, output: node.capturedOutput, snapshotID } + } else if (!message || (Option.isSome(messages) && caller && !snapshotID)) { + captured = { ok: false, reason: "recovered final assistant response or input snapshot is unavailable" } + } else { + registerCaptureSlot(node.childSessionId, nodeConfig.output_schema) + captured = yield* captureFinalResponse({ + store: dag.store, + sessionID: node.childSessionId, + message, + snapshotID, + caller, + guard: { + workflowID: dagID, + nodeID: node.id, + attemptID: DagMessages.nodeAttemptID(node.childSessionId, node.replanAttempts), + }, + }).pipe(Effect.ensuring(Effect.sync(() => clearCaptureSlot(node.childSessionId!)))) + } + if (!captured.ok && (captured.code === "stale_input" || captured.code === "unassociated")) { + continuations.add(node.id) + continue + } + const settlement = captured.ok + ? recoveredSettlement(nodeConfig, nodes, captured.output, true) + : ({ kind: "fail", reason: captured.reason } as const) + const completed = yield* settle( + node.id, + settlement.kind === "complete" + ? dag.nodeCompleted(dagID, node.id, settlement.output, { + ...attempt, + inputSnapshotID: captured.ok ? captured.snapshotID : undefined, + }) + : dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", attempt), + ) + if (completed) reconciled++ + } else if (nodeConfig?.output_schema) { // Same settlement decision as spawn's completion gate — recovery // must not become a bypass of the review-result contract again (B1). const settlement = recoveredSettlement(nodeConfig, nodes, node.capturedOutput, node.capturedOutputPresent) @@ -187,7 +256,53 @@ export function reconcileWorkflow( ) if (settled) reconciled++ } else { + const finalMessage = + workflowConfig?.result_protocol === "final_response" && lastAssistantMessage + ? yield* lastAssistantMessage(node.childSessionId) + : undefined + const final = + workflowConfig?.result_protocol === "final_response" ? finalAssistantText(finalMessage) : undefined + const caller = workflow?.directory + ? { projectID: workflow.projectId, directory: workflow.directory, sessionID: node.childSessionId } + : undefined + const frozen = + final?.ok && Option.isSome(messages) && caller && finalMessage + ? yield* messages.value.snapshotForTurn(caller, finalMessage.info.id) + : undefined + const snapshotID = frozen?.ok ? frozen.value?.id : undefined + if (final?.ok && Option.isSome(messages) && !snapshotID) { + if ( + yield* settle( + node.id, + dag.nodeFailed( + dagID, + node.id, + "recovered final assistant response has no associated input snapshot", + "exec_failed", + attempt, + ), + ) + ) + reconciled++ + continue + } + const finalSource = final?.ok + ? process.platform === "win32" + ? FSUtil.normalizePath(final.text.trim()) + : final.text.trim() + : undefined + const restorableFinalRef = yield* Effect.gen(function* () { + if ( + !final?.ok || + !isManagedOutputFileRef(node.capturedOutput) || + finalSource !== node.capturedOutput.source_path + ) + return false + if (Option.isNone(messages)) return true + return !!snapshotID && snapshotID === node.capturedSnapshotID + }) const previousRefValid = yield* Effect.gen(function* () { + if (workflowConfig?.result_protocol === "final_response" && !restorableFinalRef) return if (isManagedOutputFileRef(node.capturedOutput) && authorizeSource) yield* authorizeSource(node.childSessionId!, node.capturedOutput.source_path, node.workerType) yield* verifyOutputFileRef(node.capturedOutput) @@ -205,7 +320,10 @@ export function reconcileWorkflow( ), ) if (!previousRefValid) continue - if (isManagedOutputFileRef(node.capturedOutput)) { + if ( + isManagedOutputFileRef(node.capturedOutput) && + (workflowConfig?.result_protocol !== "final_response" || restorableFinalRef) + ) { const ref = node.capturedOutput const restored = yield* Effect.gen(function* () { if ( @@ -215,7 +333,10 @@ export function reconcileWorkflow( ref.provenance?.replan_attempt !== node.replanAttempts ) return yield* Effect.fail(new Error("Managed DAG artifact belongs to another execution attempt")) - return yield* settle(node.id, dag.nodeCompleted(dagID, node.id, ref.path, attempt, ref)) + return yield* settle( + node.id, + dag.nodeCompleted(dagID, node.id, ref.path, { ...attempt, inputSnapshotID: snapshotID }, ref), + ) }).pipe( Effect.catchCause((cause) => Cause.hasInterrupts(cause) @@ -227,7 +348,13 @@ export function reconcileWorkflow( continue } const settlement = settlePlainTextOutput( - lastAssistantText ? yield* lastAssistantText(node.childSessionId) : undefined, + final + ? final.ok + ? final.text + : undefined + : lastAssistantText + ? yield* lastAssistantText(node.childSessionId) + : undefined, ) if (settlement.kind === "fail") { if (yield* settle(node.id, dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", attempt))) @@ -247,13 +374,49 @@ export function reconcileWorkflow( ? (source) => authorizeSource(node.childSessionId!, source, node.workerType) : undefined, ) + if ( + workflowConfig?.result_protocol === "final_response" && + !restorableFinalRef && + !fileRef && + isManagedOutputFileRef(node.capturedOutput) && + finalSource === node.capturedOutput.source_path + ) + return yield* Effect.fail( + new Error("previous file receipt belongs to an older input snapshot; source is unavailable"), + ) if (fileRef) { - yield* dag.store.setCapturedOutput(node.childSessionId!, fileRef) + const receipt = dag.store.setCapturedOutput(node.childSessionId!, fileRef, snapshotID) + if (workflowConfig?.result_protocol === "final_response" && Option.isSome(messages) && caller) { + const guarded = yield* messages.value.guard( + caller, + { + workflowID: dagID, + nodeID: node.id, + attemptID: DagMessages.nodeAttemptID(node.childSessionId!, node.replanAttempts), + snapshotID, + close: false, + }, + receipt, + ) + if (!guarded.ok) { + if (guarded.reason === "stale_input" || guarded.reason === "unassociated") { + continuations.add(node.id) + return false + } + return yield* Effect.fail(new Error(`Recovered output receipt rejected: ${guarded.reason}`)) + } + } else yield* receipt if (directory) yield* ensureReportAreaGitignore(directory, fileRef.source_path) } return yield* settle( node.id, - dag.nodeCompleted(dagID, node.id, fileRef?.path ?? rawText, attempt, fileRef), + dag.nodeCompleted( + dagID, + node.id, + fileRef?.path ?? rawText, + { ...attempt, inputSnapshotID: snapshotID }, + fileRef, + ), ) }).pipe( Effect.catchCause((cause) => @@ -335,7 +498,13 @@ export function reconcileWorkflow( export function continueRecoveredMessageNode( dagID: string, nodeID: string, - config: { nodes: Pick[] } | null | undefined, + config: + | { + nodes: Pick[] + result_protocol?: ResultProtocol + } + | null + | undefined, directory?: string, authorizeSource?: (childSessionID: string, source: string, workerType?: string) => Effect.Effect, ): Effect.Effect { @@ -397,6 +566,7 @@ export function continueRecoveredMessageNode( } }) if (!(yield* admit)) return + if (config?.result_protocol === "final_response") registerFinalResponseSession(childSessionID) if (nodeConfig?.output_schema) registerCaptureSlot(childSessionID, nodeConfig.output_schema) let result = yield* prompt.loop({ sessionID: SessionID.make(childSessionID) }) for (;;) { @@ -425,6 +595,33 @@ export function continueRecoveredMessageNode( const frozen = yield* messages.value.snapshotForTurn(caller, result.info.id) const snapshotID = frozen.ok ? frozen.value?.id : undefined const settled = yield* Effect.gen(function* () { + if (nodeConfig?.output_schema && config?.result_protocol === "final_response") { + if (!snapshotID) yield* new Dag.StaleMessageInputError({ dagID, nodeID, reason: "unassociated" }) + const captured = yield* captureFinalResponse({ + store: dag.store, + sessionID: childSessionID, + message: result, + snapshotID, + caller, + guard: { + workflowID: dagID, + nodeID, + attemptID: DagMessages.nodeAttemptID(childSessionID, attempt.replanAttempts), + }, + }) + if (!captured.ok && (captured.code === "stale_input" || captured.code === "unassociated")) + yield* new Dag.StaleMessageInputError({ dagID, nodeID, reason: captured.code }) + const all = yield* dag.store.getNodes(dagID) + const settlement = captured.ok + ? recoveredSettlement(nodeConfig, all, captured.output, true) + : ({ kind: "fail", reason: captured.reason } as const) + if (settlement.kind === "fail") + return yield* dag.nodeFailed(dagID, nodeID, settlement.reason, "verdict_fail", attempt) + return yield* dag.nodeCompleted(dagID, nodeID, settlement.output, { + ...attempt, + inputSnapshotID: snapshotID, + }) + } if (nodeConfig?.output_schema) { const all = yield* dag.store.getNodes(dagID) const settlement = recoveredSettlement( @@ -440,7 +637,8 @@ export function continueRecoveredMessageNode( inputSnapshotID: current.capturedSnapshotID ?? undefined, }) } - const settlement = settlePlainTextOutput(result.parts.findLast((p) => p.type === "text")?.text) + const final = finalAssistantText(result, true) + const settlement = settlePlainTextOutput(final.ok ? final.text : undefined) if (settlement.kind === "fail") return yield* dag.nodeFailed(dagID, nodeID, settlement.reason, "verdict_fail", attempt) const ref = yield* commitOutputFileRef( @@ -496,7 +694,12 @@ export function continueRecoveredMessageNode( } const inbox = yield* messages.value.revisions(caller) if (!(yield* admit)) return - let nudge = !!(nodeConfig?.output_schema && inbox.ok && inbox.value.queued === 0) + let nudge = !!( + nodeConfig?.output_schema && + config?.result_protocol !== "final_response" && + inbox.ok && + inbox.value.queued === 0 + ) if (nudge && inbox.ok) { const claim = yield* messages.value.claimResultNudge(caller, inbox.value.accepted) if (!claim.ok && claim.reason === "stale_input") nudge = false @@ -540,7 +743,12 @@ export function continueRecoveredMessageNode( .nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed", attempt) .pipe(Effect.catchIf(isTransitionRejection, () => Effect.void)), ), - Effect.ensuring(Effect.sync(() => clearCaptureSlot(childSessionID))), + Effect.ensuring( + Effect.sync(() => { + clearCaptureSlot(childSessionID) + clearFinalResponseSession(childSessionID) + }), + ), ) }) } @@ -625,20 +833,30 @@ export function makeSessionStatusChecker( }) } -/** - * #345: the schemaless-node completion mirror of the live path — the child's - * last assistant text part, the exact value spawn.ts settles a schemaless - * node with. Recovery reads it so a crash cannot erase a string verdict. - */ -export function makeLastAssistantTextReader( +/** The latest message only: an older assistant turn is never a fallback result. */ +export function makeLastAssistantMessageReader( sessions: Session.Interface, -): (childSessionID: string) => Effect.Effect { +): (childSessionID: string) => Effect.Effect { return (childSessionID) => Effect.gen(function* () { const msgs = yield* sessions - .messages({ sessionID: SessionID.make(childSessionID), limit: 20 }) + .messages({ sessionID: SessionID.make(childSessionID), limit: 1 }) .pipe(Effect.catchTag("NotFoundError", () => Effect.succeed([] as SessionV1.WithParts[]))) - const last = [...msgs].reverse().find((msg) => msg.info.role === "assistant") - return last?.parts.findLast((part): part is Extract => part.type === "text")?.text + const last = msgs.at(-1) + return last?.info.role === "assistant" ? last : undefined + }) +} + +/** Plaintext compatibility reader with the same complete-message extraction. */ +export function makeLastAssistantTextReader( + sessions: Session.Interface, +): (childSessionID: string) => Effect.Effect { + const read = makeLastAssistantMessageReader(sessions) + return (childSessionID) => + Effect.gen(function* () { + // Legacy test transcripts can omit finish; session-status recovery has + // already established completion before this compatibility reader runs. + const final = finalAssistantText(yield* read(childSessionID), true) + return final.ok ? final.text : undefined }) } diff --git a/packages/opencode/src/dag/runtime/report-delivery.ts b/packages/opencode/src/dag/runtime/report-delivery.ts new file mode 100644 index 000000000..6ce807b45 --- /dev/null +++ b/packages/opencode/src/dag/runtime/report-delivery.ts @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Effect } from "effect" +import { mkdir, writeFile, unlink } from "node:fs/promises" +import path from "node:path" +import { randomUUID } from "node:crypto" +import { Global } from "@opencode-ai/core/global" +import type { DagStore } from "@opencode-ai/core/dag/store" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { Database } from "@opencode-ai/core/database/database" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import type { Session } from "@/session/session" +import { PartID, SessionID } from "@/session/schema" +import { parseWorkflowConfig, type WorkflowConfig } from "../dag" +import { + commitOutputFileRef, + isManagedOutputFileRef, + verifyOutputFileRef, + type ManagedOutputFileRef, +} from "./output-ref" +import { finalAssistantText, parseFinalResponse } from "./final-response" +import { isRecord } from "@/util/record" +import { before } from "@/session/message-v2" +import { reportIdentity } from "../report-identity" + +export { reportIdentity } from "../report-identity" + +const INLINE_REPORT_BYTES = 64 * 1024 + +const newestFirst = (a: SessionV1.WithParts, b: SessionV1.WithParts) => before(a.info, b.info) ? 1 : before(b.info, a.info) ? -1 : 0 + +/** No guessed required node or agent name: ambiguous graphs get a stable index. */ +export function selectReportNode(config: WorkflowConfig, nodes: readonly DagStore.NodeRow[]) { + return config.delivery_node === undefined + ? undefined + : nodes.find((node) => node.id === config.delivery_node && !node.superseded && node.status === "completed") +} + +export function resultIndex(workflow: DagStore.WorkflowRow, nodes: readonly DagStore.NodeRow[]) { + return [ + `Workflow "${workflow.title}" completed. Results:`, + ...nodes + .filter((node) => !node.superseded) + .toSorted((a, b) => a.id.localeCompare(b.id)) + .map( + (node) => `- ${node.name} (${node.status}): workflow result workflow_id="${workflow.id}" node_id="${node.id}"`, + ), + ].join("\n") +} + +function reportText( + node: DagStore.NodeRow, + config: WorkflowConfig, + source?: SessionV1.WithParts, + artifact?: ManagedOutputFileRef, +) { + if (artifact) return `${artifact.summary}\n\n[Complete report](${artifact.path})` + if (typeof node.output === "string") return node.output + if (isManagedOutputFileRef(node.output)) return `${node.output.summary}\n\n[Complete report](${node.output.path})` + const schema = config.nodes.find((item) => item.id === node.id)?.output_schema + const properties = isRecord(schema?.properties) ? schema.properties : undefined + if (isRecord(node.output)) { + const value = node.output + for (const field of ["report", "summary"]) { + if (properties?.[field] && typeof value[field] === "string") return value[field] + } + } + const final = finalAssistantText(source) + if (final.ok) { + const parsed = parseFinalResponse(final.text) + if (parsed.ok && JSON.stringify(parsed.payload) === JSON.stringify(node.output)) return final.text + } + return JSON.stringify(node.output) ?? "(no output)" +} + +/** Large generated text uses the existing integrity-checked artifact storage. */ +function boundReport(text: string, workflow: DagStore.WorkflowRow, node?: DagStore.NodeRow) { + if (Buffer.byteLength(text) <= INLINE_REPORT_BYTES) + return Effect.succeed({ text, artifact: undefined as ManagedOutputFileRef | undefined }) + return Effect.gen(function* () { + const directory = path.join(Global.Path.data, "workflow-artifacts", "delivery") + const filename = path.join(directory, `${randomUUID()}.txt`) + // Bytes already belong to the durable node output; this never reads a child-supplied path. + yield* Effect.promise(async () => { + await mkdir(directory, { recursive: true, mode: 0o700 }) + await writeFile(filename, text, { mode: 0o600, flag: "wx" }) + }) + const artifact = yield* commitOutputFileRef(filename, { + workflow_id: workflow.id, + node_id: node?.id ?? "result-index", + child_session_id: node?.childSessionId ?? workflow.sessionId, + replan_attempt: node?.replanAttempts ?? 0, + graph_rev: workflow.graphRev, + }).pipe(Effect.ensuring(Effect.promise(() => unlink(filename).catch(() => {})))) + if (!artifact) return yield* Effect.die(new Error("DAG report artifact could not be captured")) + return { text: `${artifact.summary}\n\n[Complete report](${artifact.path})`, artifact } + }) +} + +/** Caller holds automation + idle admission locks. Transcript and wake flags commit together. */ +export function deliverReport(input: { + workflow: DagStore.WorkflowRow + batch: DagStore.WakeBatch + store: DagStore.Interface + sessions: Session.Interface + directory: string + source?: SessionV1.WithParts +}): Effect.Effect { + return Effect.gen(function* () { + const { workflow, store, sessions } = input + const config = parseWorkflowConfig(workflow.config) + if (workflow.status !== "completed" || config?.result_protocol !== "final_response") return false + const identity = reportIdentity(workflow) + const sessionID = SessionID.make(workflow.sessionId) + const nodes = yield* store.getCurrentNodes(workflow.id).pipe(Effect.orDie) + const node = selectReportNode(config, nodes) + let artifact: ManagedOutputFileRef | undefined + if ( + node && + !config.nodes.find((item) => item.id === node.id)?.output_schema && + node.capturedOutput && + typeof node.capturedOutput === "object" && + "storage" in node.capturedOutput && + node.capturedOutput.storage === "managed-v1" + ) { + yield* verifyOutputFileRef(node.capturedOutput).pipe(Effect.orDie) + if ( + !isManagedOutputFileRef(node.capturedOutput) || + node.output !== node.capturedOutput.path || + node.capturedOutput.provenance.workflow_id !== workflow.id || + node.capturedOutput.provenance.node_id !== node.id || + node.capturedOutput.provenance.child_session_id !== node.childSessionId || + node.capturedOutput.provenance.replan_attempt !== node.replanAttempts + ) + return yield* Effect.die(new Error("DAG report artifact does not belong to the completed attempt")) + artifact = node.capturedOutput + } + const body = yield* boundReport( + node ? reportText(node, config, input.source, artifact) : resultIndex(workflow, nodes), + workflow, + node, + ).pipe(Effect.orDie) + const metadata = { + workflow_id: workflow.id, + graph_rev: workflow.graphRev, + completion_seq: workflow.seq, + node_id: node?.id, + child_session_id: node?.childSessionId, + replan_attempt: node?.replanAttempts, + ...((artifact ?? body.artifact) ? { artifact: artifact ?? body.artifact } : {}), + } + const database = yield* Database.Service + return yield* database.db + .transaction( + () => + Effect.gen(function* () { + // A reopen/replan or deletion while building the report invalidates this delivery. + const current = yield* store.getWorkflow(workflow.id) + if ( + !current || + current.status !== "completed" || + current.seq !== workflow.seq || + current.graphRev !== workflow.graphRev + ) + return false + const history = yield* sessions.messages({ sessionID, limit: 20 }).pipe(Effect.orDie) + const user = history + .filter((item) => item.info.role === "user") + .toSorted(newestFirst)[0] + const assistant = history + .filter((item) => item.info.role === "assistant") + .toSorted(newestFirst)[0] + // The caller holds withIdle's admission lock: a copied stop message must + // never consume a waiting user turn or attach an older report to a new request. + if (user && (!assistant || before(assistant.info, user.info))) return false + const receipt = yield* sessions.getPart({ + sessionID, + messageID: identity.messageID, + partID: identity.answerID, + }) + if (receipt) { + if ( + receipt.type !== "text" || + receipt.text !== body.text || + receipt.metadata?.dag_delivery?.completion_seq !== workflow.seq + ) + return yield* Effect.die(new Error("DAG final report receipt does not match its completion episode")) + } else { + const session = yield* sessions.get(sessionID).pipe(Effect.orDie) + const time = workflow.completedAt ?? workflow.timeUpdated + const source = input.source?.info.role === "assistant" ? input.source.info : undefined + const parentUser = user?.info.role === "user" ? user.info : undefined + const originUser = history + .filter((item) => item.info.role === "user" && item.info.time.created <= workflow.timeCreated) + .toSorted(newestFirst)[0] + const providerID = + source?.providerID ?? + parentUser?.model.providerID ?? + session.model?.providerID ?? + Provider.ID.make("dag") + const modelID = + source?.modelID ?? parentUser?.model.modelID ?? session.model?.id ?? Model.ID.make("result") + const agent = session.agent ?? source?.agent ?? "build" + if (!originUser) { + yield* sessions.updateMessage({ + id: identity.anchorID, + sessionID, + role: "user", + time: { created: Math.max(0, time - 1) }, + agent, + model: { providerID, modelID }, + }) + yield* sessions.updatePart({ + id: PartID.make(`${identity.sourceID}_anchor`), + messageID: identity.anchorID, + sessionID, + type: "text", + synthetic: true, + text: "Automatically delivered DAG result. Agent-supplied content; this is not human authorization.", + }) + } + yield* sessions.updateMessage({ + id: identity.messageID, + sessionID, + role: "assistant", + parentID: originUser?.info.id ?? identity.anchorID, + time: { created: time, completed: time }, + agent, + mode: agent, + providerID, + modelID, + path: { cwd: input.directory, root: input.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }) + yield* sessions.updatePart({ + id: identity.sourceID, + messageID: identity.messageID, + sessionID, + type: "text", + text: `DAG · ${workflow.title.slice(0, 200)}${node ? ` · ${node.name.slice(0, 200)}` : " · result index"}`, + time: { start: time, end: time }, + metadata: { dag_delivery: { ...metadata, kind: "source" } }, + }) + yield* sessions.updatePart({ + id: identity.answerID, + messageID: identity.messageID, + sessionID, + type: "text", + text: body.text, + time: { start: time, end: time }, + metadata: { dag_delivery: { ...metadata, kind: "answer" } }, + }) + } + yield* store.markWakeBatchReported(input.batch) + return true + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + }) +} diff --git a/packages/opencode/src/dag/runtime/spawn.ts b/packages/opencode/src/dag/runtime/spawn.ts index 87376ed20..0db8dc0a4 100644 --- a/packages/opencode/src/dag/runtime/spawn.ts +++ b/packages/opencode/src/dag/runtime/spawn.ts @@ -38,6 +38,7 @@ import { SessionID, MessageID } from "@/session/schema" import { deriveSubagentSessionPermission } from "@/agent/subagent-permissions" import { SessionPrompt } from "@/session/prompt" import { Dag, type NodeExecutionAttempt, isStaleMessageInput } from "../dag" +import type { ResultProtocol } from "../dag" import { DagMessages } from "@opencode-ai/core/dag/messages" import { DagModel } from "../model" import { DagLocation } from "../location" @@ -46,7 +47,15 @@ import { isTransitionRejection, isNodeTerminalStatus } from "@opencode-ai/core/d 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, settleCapturedOutput, settlePlainTextOutput } from "./capture" +import { + registerCaptureSlot, + clearCaptureSlot, + registerFinalResponseSession, + clearFinalResponseSession, + settleCapturedOutput, + settlePlainTextOutput, +} from "./capture" +import { captureFinalResponse, finalAssistantText } from "./final-response" import { commitOutputFileRef, ensureReportAreaGitignore, @@ -73,6 +82,8 @@ export interface NodeSpawnInput { /** Verified completed dependency artifacts, scoped to this child session. */ inputArtifacts?: ManagedOutputFileRef[] outputSchema?: Record + /** Missing means the persisted submit_result protocol for direct legacy callers. */ + resultProtocol?: ResultProtocol timeoutMs?: number reportToParent?: boolean reviewImplementationFingerprint?: string @@ -597,6 +608,7 @@ export function spawnNode( childSessionID: childSession.id, } satisfies NodeExecutionAttempt + if (input.resultProtocol === "final_response") registerFinalResponseSession(childSession.id) if (input.outputSchema) registerCaptureSlot(childSession.id, input.outputSchema) // The prompt runs WITHOUT a timeout — the deadline watcher owns the @@ -658,7 +670,47 @@ export function spawnNode( ) return } - if (input.outputSchema) { + if (input.outputSchema && input.resultProtocol === "final_response") { + if (Option.isSome(messageService) && caller && !snapshotID) + yield* new Dag.StaleMessageInputError({ + dagID: input.dagID, + nodeID: input.nodeID, + reason: "unassociated", + }) + const captured = yield* captureFinalResponse({ + store: dag.store, + sessionID: childSession.id, + message: result, + snapshotID, + caller, + guard: { + workflowID: input.dagID, + nodeID: input.nodeID, + attemptID: DagMessages.nodeAttemptID(childSession.id, settlementAttempt.replanAttempts), + }, + }) + if (!captured.ok && (captured.code === "stale_input" || captured.code === "unassociated")) + yield* new Dag.StaleMessageInputError({ + dagID: input.dagID, + nodeID: input.nodeID, + reason: captured.code, + }) + const settlement = captured.ok + ? settleCapturedOutput(captured.output, input.reviewImplementationFingerprint, "", true) + : ({ kind: "fail", reason: captured.reason } as const) + yield* ( + settlement.kind === "complete" + ? dag.nodeCompleted(input.dagID, input.nodeID, settlement.output, { + ...settlementAttempt, + inputSnapshotID: snapshotID, + }) + : dag.nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail", settlementAttempt) + ).pipe( + Effect.catchIf(isTransitionRejection, () => + Effect.logWarning("final-response settlement guard rejected — node already terminal"), + ), + ) + } else if (input.outputSchema) { const readSettlement = Effect.fn("DagRuntime.spawn.readSettlement")(function* () { const updatedNode = yield* dag.store.getNode(input.dagID, input.nodeID).pipe(Effect.orDie) if ( @@ -693,19 +745,22 @@ export function spawnNode( if (verdict.settlement.kind === "fail" && verdict.neverCalled) { if (!(yield* claimResubmission)) return registerCaptureSlot(childSession.id, input.outputSchema) - result = yield* promptSvc.prompt({ - messageID: MessageID.ascending(), - sessionID: childSession.id, - model, - agent: agent.name, - ...(input.variant ? { variant: input.variant } : {}), - parts: [ - { - type: "text", - text: `You ended your turn without calling the submit_result tool, so this node recorded no structured output. Do not redo the work. If tools remain available, call submit_result with your full result matching the schema from your instructions. This follow-up retains your existing tool-call budget. If tools are disabled because that budget is exhausted, report the blocker in text; the node cannot succeed without a submitted result.`, - }, - ], - }, { continueToolBudget: true }) + result = yield* promptSvc.prompt( + { + messageID: MessageID.ascending(), + sessionID: childSession.id, + model, + agent: agent.name, + ...(input.variant ? { variant: input.variant } : {}), + parts: [ + { + type: "text", + text: `You ended your turn without calling the submit_result tool, so this node recorded no structured output. Do not redo the work. If tools remain available, call submit_result with your full result matching the schema from your instructions. This follow-up retains your existing tool-call budget. If tools are disabled because that budget is exhausted, report the blocker in text; the node cannot succeed without a submitted result.`, + }, + ], + }, + { continueToolBudget: true }, + ) inputSnapshot = Option.isSome(messageService) && caller ? yield* messageService.value.snapshotForTurn(caller, result.info.id) @@ -730,7 +785,8 @@ export function spawnNode( ), ) } else { - const settlement = settlePlainTextOutput(result.parts.findLast((p) => p.type === "text")?.text) + const final = finalAssistantText(result, true) + const settlement = settlePlainTextOutput(final.ok ? final.text : undefined) if (settlement.kind === "fail") { yield* dag .nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail", settlementAttempt) @@ -857,7 +913,12 @@ export function spawnNode( ) break } - let nudge = !!(input.outputSchema && revisions?.ok && revisions.value.queued === 0) + let nudge = !!( + input.outputSchema && + input.resultProtocol !== "final_response" && + revisions?.ok && + revisions.value.queued === 0 + ) if (nudge) { const admitted = yield* claimResubmission.pipe( Effect.catchIf(isStaleMessageInput, () => Effect.succeed(undefined)), @@ -888,7 +949,10 @@ export function spawnNode( // The prompt finished (or this fiber was interrupted) — the // deadline watcher has no further job. yield* Fiber.interrupt(watcherFiber).pipe(Effect.ignore) - if (childSessionID) clearCaptureSlot(childSessionID) + if (childSessionID) { + clearCaptureSlot(childSessionID) + clearFinalResponseSession(childSessionID) + } }), ), // The fiber can be interrupted between session creation and node diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index b9835c8d6..c9df09845 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -228,7 +228,8 @@ export const NodeSchema = Schema.Struct({ }), 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", + description: + "JSON Schema for the child's final JSON answer (historical submit_result workflows keep their tool protocol)", }), review: Schema.optional( Schema.Struct({ @@ -253,6 +254,14 @@ const NodeDefaults = Schema.Struct({ }) const GraphBudgetFields = { + result_protocol: Schema.optional(Schema.Literals(["final_response", "submit_result"])).annotate({ + description: + "Result capture protocol. New workflows default to final_response; submit_result is legacy compatibility.", + }), + delivery_node: Schema.optional(Schema.String).annotate({ + description: + "Final node whose answer is copied once into the main conversation. A unique terminal synthesize block is selected automatically; otherwise delivery is a result index.", + }), 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", @@ -385,7 +394,11 @@ function driftHint(path: string, message: string) { export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] { const leaves: LeafIssue[] = [] - collectLeafIssues(isRecord(error) && error.issue !== undefined ? error.issue : error, basePath ? [basePath] : [], leaves) + collectLeafIssues( + isRecord(error) && error.issue !== undefined ? error.issue : error, + basePath ? [basePath] : [], + leaves, + ) if (leaves.length === 0) { return [diagnostic({ code: DIAGNOSTIC_CODES.schemaInvalid, path: basePath || "$", message: String(error) })] } @@ -437,8 +450,7 @@ export function replanEnvelopeDiagnostic(value: unknown): Diagnostic | undefined // ============================================================================ export type BlockSource = - | { objective: string; blocks: readonly DagBlocks.WorkflowBlock[] } - | { nodes: readonly NodeSpec[] } + { objective: string; blocks: readonly DagBlocks.WorkflowBlock[] } | { nodes: readonly NodeSpec[] } export function compileBlockSource( source: BlockSource, @@ -531,6 +543,7 @@ export function templateBindingErrors(nodes: readonly NodeConfig[]): string[] { export interface StructuralInput { nodes: readonly NodeConfig[] + delivery_node?: string mode?: ExecutionMode max_total_nodes?: number /** Nodes already registered in a live workflow; counts toward the ceiling. */ @@ -751,11 +764,11 @@ export function checkpointGateDiagnostics( code: DIAGNOSTIC_CODES.dagInvalid, path: `nodes[${dependent.id}].condition`, message: - `reporting checkpoint "${checkpoint.id}" has dependent "${dependent.id}" that is not gated on its output` - + ` — the engine spawns "${dependent.id}" as soon as "${checkpoint.id}" completes, so the checkpoint verdict cannot be acted on first`, + `reporting checkpoint "${checkpoint.id}" has dependent "${dependent.id}" that is not gated on its output` + + ` — the engine spawns "${dependent.id}" as soon as "${checkpoint.id}" completes, so the checkpoint verdict cannot be acted on first`, hint: - `Gate "${dependent.id}" with condition: "${checkpoint.id}.output. == ..." (e.g. on its verdict) and declare output_schema on "${checkpoint.id}" so the gate reads a schema-validated verdict,` - + ` keep "${checkpoint.id}" a reporting leaf, or set report_to_parent: false on "${checkpoint.id}" if downstream must run unconditionally`, + `Gate "${dependent.id}" with condition: "${checkpoint.id}.output. == ..." (e.g. on its verdict) and declare output_schema on "${checkpoint.id}" so the gate reads a schema-validated verdict,` + + ` keep "${checkpoint.id}" a reporting leaf, or set report_to_parent: false on "${checkpoint.id}" if downstream must run unconditionally`, }), ) // DAG-01: a checkpoint whose output a gate reads must declare @@ -773,11 +786,11 @@ export function checkpointGateDiagnostics( code: DIAGNOSTIC_CODES.dagInvalid, path: `nodes[${checkpoint.id}].output_schema`, message: - `reporting checkpoint "${checkpoint.id}" is gated on its output but declares no output_schema` - + ` — without a schema the child may complete with prose that resolves no fields, leaving the gate permanently false and silently skipping the gated subtree`, + `reporting checkpoint "${checkpoint.id}" is gated on its output but declares no output_schema` + + ` — without a schema the child may complete with prose that resolves no fields, leaving the gate permanently false and silently skipping the gated subtree`, hint: - `Declare output_schema on "${checkpoint.id}" (e.g. the verdict shape),` - + ` keep "${checkpoint.id}" a reporting leaf, or set report_to_parent: false if downstream must run unconditionally`, + `Declare output_schema on "${checkpoint.id}" (e.g. the verdict shape),` + + ` keep "${checkpoint.id}" a reporting leaf, or set report_to_parent: false if downstream must run unconditionally`, }), ] : [] @@ -948,8 +961,22 @@ function outputSchemaKeywordDiagnostics(nodes: readonly NodeConfig[]): Diagnosti * decide how to surface the diagnostics (tool output vs. create rejection). */ export function structuralDiagnostics(input: StructuralInput): Diagnostic[] { const duplicates = duplicateNodeIds(input.nodes) - const review = reviewLifecycleDiagnostics({ mode: input.mode, nodes: input.nodes, node_defaults: input.node_defaults }) + const review = reviewLifecycleDiagnostics({ + mode: input.mode, + nodes: input.nodes, + node_defaults: input.node_defaults, + }) return sortDiagnostics([ + ...(input.delivery_node !== undefined && !input.nodes.some((node) => node.id === input.delivery_node) + ? [ + diagnostic({ + severity: "error", + code: "schema.invalid", + path: "config.delivery_node", + message: `delivery_node "${input.delivery_node}" does not exist in the graph`, + }), + ] + : []), ...tagLegacyClass(duplicateIdDiagnostics(duplicates), 0), ...tagLegacyClass(danglingDependencyDiagnostics(input.nodes, input.known_node_ids), 1), ...tagLegacyClass(inputMappingDiagnostics(input.nodes), 1), @@ -1191,6 +1218,7 @@ export function validatePostCompile(input: { profile: Profile config: { mode?: ExecutionMode + delivery_node?: string max_total_nodes?: number node_defaults?: { required?: boolean; report_to_parent?: boolean; model?: { modelID: string; providerID: string } } } @@ -1210,6 +1238,7 @@ export function validatePostCompile(input: { ? [] : structuralDiagnostics({ nodes: input.nodes, + delivery_node: input.config.delivery_node, mode: input.config.mode, max_total_nodes: input.config.max_total_nodes, node_defaults: input.config.node_defaults, diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index e1d110ba8..598d31602 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -8,6 +8,7 @@ import { Permission } from "@/permission" import { Tool } from "@/tool/tool" import { ToolJsonSchema } from "@/tool/json-schema" import { ToolRegistry } from "@/tool/registry" +import { isFinalResponseSession } from "@/dag/runtime/capture" import { MemorySearch } from "@/tool/memory-search" import { Truncate } from "@/tool/truncate" @@ -99,7 +100,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { providerID: input.model.providerID, agent: input.agent, } - const registryItems = yield* registry.registrations(registryContext) + const registryItems = (yield* registry.registrations(registryContext)).filter( + (registration) => !(registration.definition.id === "submit_result" && isFinalResponseSession(input.session.id)), + ) const mcpItems = yield* mcp.tools() const hasMcpResourceServer = Object.values(yield* mcp.clients()).some( (client) => !!client.getServerCapabilities()?.resources, @@ -126,6 +129,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } for (const key of Object.keys(mcpItems).toSorted()) { + if (key === "submit_result" && isFinalResponseSession(input.session.id)) continue effectiveSources.set(key, { sourceKind: "mcp", registrationID: `mcp:${key}` }) } const activeRegistrations = [...effectiveSources.entries()] @@ -688,6 +692,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } for (const [key, item] of Object.entries(mcpItems)) { + if (key === "submit_result" && isFinalResponseSession(input.session.id)) continue const execute = item.execute if (!execute) continue diff --git a/packages/opencode/src/tool/agent.ts b/packages/opencode/src/tool/agent.ts index d1580b9a8..3d8cf7a44 100644 --- a/packages/opencode/src/tool/agent.ts +++ b/packages/opencode/src/tool/agent.ts @@ -59,6 +59,7 @@ export const AgentTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const storeOpt = yield* Effect.serviceOption(DagStore.Service) if (Option.isNone(storeOpt)) { @@ -52,22 +52,13 @@ export const SubmitResultTool = Tool.define( metadata: {} as Metadata, } } - const payload = result.payload let snapshotID = getCaptureSnapshot(ctx.sessionID) const messages = yield* Effect.serviceOption(DagMessages.Service) - const commit = () => - Effect.gen(function* () { - if (ctx.abort.aborted || !isCaptureValidationCurrent(ctx.sessionID, result)) return false - yield* storeOpt.value.setCapturedOutput(ctx.sessionID, payload, snapshotID).pipe(Effect.orDie) - return true - }).pipe( - // The transaction already owns the synchronous SQLite connection. - // Keep slot authority and the write in one JS execution segment. - Effect.provideService(Scheduler.PreventSchedulerYield, true), - ) + let caller: DagMessages.Caller | undefined + let guard: Omit | undefined if (Option.isSome(messages)) { const instance = yield* InstanceState.context - const caller = { projectID: instance.project.id, directory: instance.directory, sessionID: ctx.sessionID } + caller = { projectID: instance.project.id, directory: instance.directory, sessionID: ctx.sessionID } const identity = yield* messages.value.revisions(caller) if (!identity.ok || identity.value.endpoint.kind !== "node") return { @@ -85,43 +76,35 @@ export const SubmitResultTool = Tool.define( } snapshotID = snapshot.value.id const endpoint = identity.value.endpoint - const result = yield* messages.value.guard( - caller, - { - workflowID: endpoint.workflowID!, - nodeID: endpoint.nodeID!, - attemptID: endpoint.attemptID!, - snapshotID, - close: false, - }, - commit(), - ) - if (!result.ok || !result.value) - return { - title: "submit_result input changed", - output: `Submission was not captured (${result.ok ? "capture slot changed or cancelled" : result.reason}). Consume the current agent input before submitting an updated result. Previously completed tools remain recorded; do not repeat their writes.`, - metadata: {}, - } - } else { - const database = yield* Effect.serviceOption(Database.Service) - // Acquire a connection before checking the slot in the legacy path. - // Connection contention can suspend; the guarded write cannot. - const captured = yield* Option.isSome(database) - ? database.value.db.transaction(() => commit()).pipe(Effect.orDie) - : commit() - if (!captured) - return { - title: "submit_result input changed", - output: "Submission was not captured because its capture slot changed or validation was cancelled.", - metadata: {}, - } + guard = { + workflowID: endpoint.workflowID!, + nodeID: endpoint.nodeID!, + attemptID: endpoint.attemptID!, + } } + const captured = yield* persistValidatedCapture({ + store: storeOpt.value, + sessionID: ctx.sessionID, + validated: result, + abort: ctx.abort, + snapshotID, + caller, + guard, + }) + if (!captured.ok) + return { + title: "submit_result input changed", + output: Option.isSome(messages) + ? `Submission was not captured (${captured.reason}). Consume the current agent input before submitting an updated result. Previously completed tools remain recorded; do not repeat their writes.` + : "Submission was not captured because its capture slot changed or validation was cancelled.", + metadata: {}, + } return { title: "Structured output submitted", output: "submit_result succeeded. Your structured output has been captured.", metadata: { captured: true } as Metadata, } }), - } satisfies Tool.DefWithoutID + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/test/cli/run/dag-hold.test.ts b/packages/opencode/test/cli/run/dag-hold.test.ts index 20547b273..77057ec48 100644 --- a/packages/opencode/test/cli/run/dag-hold.test.ts +++ b/packages/opencode/test/cli/run/dag-hold.test.ts @@ -1,8 +1,11 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- The SDK mock exposes only the two read endpoints used by this consumer. // Unit tests for the consumer-only DAG hold decision rule (issue 614). // Table-driven over the busy/idle event orderings the run loop feeds the // wrapper: each row is one CLI run's poll sequence with the expected idle // decisions. Real-CLI lifecycle coverage lives in run-dag-hold-process.test.ts. import { describe, expect, test } from "bun:test" +import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import { reportIdentity } from "@/dag/report-identity" import { DagHold } from "../../../src/cli/cmd/run/dag-hold" type Snapshot = readonly DagHold.DagWorkflowSnapshot[] @@ -39,7 +42,11 @@ function drive(steps: readonly Step[]): Promise { })() } -const wf = (id: string, status: string): DagHold.DagWorkflowSnapshot => ({ id, status }) +const wf = (id: string, status: string, awaitingReport = false): DagHold.DagWorkflowSnapshot => ({ + id, + status, + awaitingReport, +}) describe("dag hold decision rule (issue 614)", () => { test("table: every busy/idle ordering resolves to the adjudicated hold/break sequence", async () => { @@ -135,6 +142,40 @@ describe("dag hold decision rule (issue 614)", () => { { event: "idle", snapshot: [wf("W1", "completed"), wf("W2", "completed")], expect: "break" }, ], }, + { + // One delivered report does not discharge a second completed workflow. + name: "two completed workflows wait for both answer receipts", + steps: [ + { event: "busy", snapshot: [wf("W1", "completed", true), wf("W2", "completed", true)] }, + { event: "idle", snapshot: [wf("W1", "completed", true), wf("W2", "completed", true)], expect: "hold" }, + { event: "busy", snapshot: [wf("W1", "completed"), wf("W2", "completed", true)] }, + { event: "idle", snapshot: [wf("W1", "completed"), wf("W2", "completed", true)], expect: "hold" }, + { event: "busy", snapshot: [wf("W1", "completed"), wf("W2", "completed")] }, + { event: "idle", snapshot: [wf("W1", "completed"), wf("W2", "completed")], expect: "break" }, + ], + }, + { + // Paused work is actionable but not active; it must not mask an owed + // answer from another completed final-response workflow. + name: "completed report remains held beside paused actionable work", + steps: [ + { event: "busy", snapshot: [wf("done", "completed", true), wf("paused", "paused")] }, + { event: "idle", snapshot: [wf("done", "completed", true), wf("paused", "paused")], expect: "hold" }, + { event: "busy", snapshot: [wf("done", "completed"), wf("paused", "paused")] }, + { event: "idle", snapshot: [wf("done", "completed"), wf("paused", "paused")], expect: "break" }, + ], + }, + { + name: "new completion waits after an older report is already delivered", + steps: [ + { event: "busy", snapshot: [wf("old", "completed"), wf("new", "running")] }, + { event: "idle", snapshot: [wf("old", "completed"), wf("new", "running")], expect: "hold" }, + { event: "busy", snapshot: [wf("old", "completed"), wf("new", "completed", true)] }, + { event: "idle", snapshot: [wf("old", "completed"), wf("new", "completed", true)], expect: "hold" }, + { event: "busy", snapshot: [wf("old", "completed"), wf("new", "completed")] }, + { event: "idle", snapshot: [wf("old", "completed"), wf("new", "completed")], expect: "break" }, + ], + }, { // Paused workflows are not active: after the pause wake turn the loop // exits as it did before the hold existed. @@ -204,6 +245,16 @@ describe("dag hold decision rule (issue 614)", () => { expect(decisions).toEqual(["break"]) }) + test("queued pre-prompt idle cannot end the run before its exact user message is observed", async () => { + const hold = DagHold.createDagHold(async () => [], undefined, "msg_current") + await hold.onBusy() + expect(await hold.onIdle()).toBe("hold") + hold.onUserMessage("msg_older") + expect(await hold.onIdle()).toBe("hold") + hold.onUserMessage("msg_current") + expect(await hold.onIdle()).toBe("break") + }) + test("pure helpers: active statuses, turn reset, and first-seen id evaluation order", () => { expect(DagHold.isDagActiveStatus("pending")).toBe(true) expect(DagHold.isDagActiveStatus("running")).toBe(true) @@ -234,3 +285,192 @@ describe("dag hold decision rule (issue 614)", () => { expect(DagHold.applyDagHoldSnapshot(state, [wf("W1", "completed"), wf("W2", "completed")])).toBe("break") }) }) + +describe("final-response answer receipt polling", () => { + const completed = (id: string, seq: number) => ({ + id, + status: "completed", + config: JSON.stringify({ result_protocol: "final_response" }), + seq, + completed_at: 1000 + seq, + time_updated: 1000 + seq, + }) + type Row = ReturnType + const identity = (row: Row) => + reportIdentity({ + sessionId: "parent", + id: row.id, + seq: row.seq, + completedAt: row.completed_at, + timeUpdated: row.time_updated, + }) + const answer = (row: Row) => { + const ids = identity(row) + return { + data: { + info: { id: ids.messageID }, + parts: [ + { + id: ids.answerID, + sessionID: "parent", + messageID: ids.messageID, + type: "text", + text: "report", + metadata: { dag_delivery: { workflow_id: row.id, completion_seq: row.seq, kind: "answer" } }, + }, + ], + }, + } + } + + test("two completed workflows wait for both receipts and memoize only verified episodes", async () => { + const first = completed("first", 4) + const second = completed("second", 8) + const received = new Set([identity(first).messageID]) + const reads: string[] = [] + const client = { + dag: { bySession: async () => ({ data: [first, second] }) }, + session: { + message: async ({ messageID }: { messageID: string }) => { + reads.push(messageID) + return received.has(messageID) + ? answer(messageID === identity(first).messageID ? first : second) + : { error: "missing" } + }, + }, + } as unknown as OpencodeClient + const poll = DagHold.makeDagWorkflowPoll(client, "parent") + const firstPoll = await poll() + expect(firstPoll?.map((row) => row.awaitingReport)).toEqual([false, true]) + const secondPoll = await poll() + expect(secondPoll?.map((row) => row.awaitingReport)).toEqual([false, true]) + expect(reads).toEqual([identity(first).messageID, identity(second).messageID, identity(second).messageID]) + received.add(identity(second).messageID) + expect((await poll())?.every((row) => row.awaitingReport !== true)).toBe(true) + expect(reads.at(-1)).toBe(identity(second).messageID) + expect((await poll())?.every((row) => row.awaitingReport !== true)).toBe(true) + expect(reads).toHaveLength(4) + }) + + test("a new completion seq invalidates the old receipt cache", async () => { + let row = completed("same", 10) + const reads: string[] = [] + const client = { + dag: { bySession: async () => ({ data: [row] }) }, + session: { + message: async ({ messageID }: { messageID: string }) => { + reads.push(messageID) + return answer(row) + }, + }, + } as unknown as OpencodeClient + const poll = DagHold.makeDagWorkflowPoll(client, "parent") + expect((await poll())?.[0]?.awaitingReport).toBe(false) + row = completed("same", 11) + expect((await poll())?.[0]?.awaitingReport).toBe(false) + expect(reads).toEqual([identity(completed("same", 10)).messageID, identity(row).messageID]) + }) + + test("missing, mismatched, and failed receipt reads remain pending while legacy rows stay quiescent", async () => { + const row = completed("needs-answer", 12) + const legacy = { ...completed("legacy", 1), config: "{}" } + const paused = { ...completed("paused", 2), status: "paused" } + let mode: "throw" | "wrong-kind" | "valid" = "throw" + let reads = 0 + const client = { + dag: { bySession: async () => ({ data: [row, legacy, paused] }) }, + session: { + message: async () => { + reads++ + if (mode === "throw") throw new Error("temporary read failure") + const receipt = answer(row) + if (mode === "wrong-kind") receipt.data.parts[0].metadata.dag_delivery.kind = "source" + return receipt + }, + }, + } as unknown as OpencodeClient + const poll = DagHold.makeDagWorkflowPoll(client, "parent") + expect((await poll())?.map((item) => item.awaitingReport)).toEqual([true, undefined, undefined]) + mode = "wrong-kind" + expect((await poll())?.[0]?.awaitingReport).toBe(true) + mode = "valid" + expect((await poll())?.[0]?.awaitingReport).toBe(false) + expect(reads).toBe(3) + }) + + test("an idle poll cannot exit before an already-persisted second answer is emitted", async () => { + const old = completed("old", 20) + const next = completed("next", 21) + let rows = [old] + const client = { + dag: { bySession: async () => ({ data: rows }) }, + session: { + message: async ({ messageID }: { messageID: string }) => + answer(messageID === identity(old).messageID ? old : next), + }, + } as unknown as OpencodeClient + // Startup happens before the prompt. The old answer is historical and + // does not need replay; the second answer appears after this baseline. + const hold = await DagHold.createDagRunHold(client, "parent") + rows = [old, next] + await hold.onBusy() + expect(await hold.onIdle()).toBe("hold") + // The SDK poll sees both durable receipts, but the stream has not yet + // delivered next's text event to the output branch. + expect(await hold.onIdle()).toBe("hold") + // The plain renderer intentionally skips whitespace, but still consumes + // the completed part event and must release the hold. + hold.onText({ ...answer(next).data.parts[0], text: " \n" }) + expect(await hold.onIdle()).toBe("break") + }) + + test("only the exact new answer part and metadata discharge a new completion seq", async () => { + const prior = completed("same", 30) + const current = completed("same", 31) + let row = prior + const client = { + dag: { bySession: async () => ({ data: [row] }) }, + session: { message: async () => answer(row) }, + } as unknown as OpencodeClient + const hold = await DagHold.createDagRunHold(client, "parent") + row = current + await hold.onBusy() + expect(await hold.onIdle()).toBe("hold") + hold.onText(answer(prior).data.parts[0]) + expect(await hold.onIdle()).toBe("hold") + const wrongKind = answer(current).data.parts[0] + wrongKind.metadata.dag_delivery.kind = "source" + hold.onText(wrongKind) + expect(await hold.onIdle()).toBe("hold") + hold.onText({ ...answer(current).data.parts[0], id: "prt_wrong" }) + expect(await hold.onIdle()).toBe("hold") + hold.onText({ ...answer(current).data.parts[0], messageID: "msg_wrong" }) + expect(await hold.onIdle()).toBe("hold") + hold.onText({ ...answer(current).data.parts[0], sessionID: "other" }) + expect(await hold.onIdle()).toBe("hold") + hold.onText(answer(current).data.parts[0]) + expect(await hold.onIdle()).toBe("break") + }) + + test("an emitted answer still waits through a receipt read failure", async () => { + const row = completed("later", 40) + let rows: Row[] = [] + let fails = true + const client = { + dag: { bySession: async () => ({ data: rows }) }, + session: { + message: async () => { + if (fails) throw new Error("receipt temporarily unavailable") + return answer(row) + }, + }, + } as unknown as OpencodeClient + const hold = await DagHold.createDagRunHold(client, "parent") + rows = [row] + await hold.onBusy() + hold.onText(answer(row).data.parts[0]) + expect(await hold.onIdle()).toBe("hold") + fails = false + expect(await hold.onIdle()).toBe("break") + }) +}) diff --git a/packages/opencode/test/cli/run/run-dag-hold-process.test.ts b/packages/opencode/test/cli/run/run-dag-hold-process.test.ts index 2bfde2dab..a8c3f2430 100644 --- a/packages/opencode/test/cli/run/run-dag-hold-process.test.ts +++ b/packages/opencode/test/cli/run/run-dag-hold-process.test.ts @@ -60,6 +60,7 @@ const wakeCompleted = bodyIncludes("[DAG Workflow completed]") const chainSpec = `title: cli-hold-chain config: name: cli-hold-chain + result_protocol: submit_result nodes: - id: first name: first @@ -80,6 +81,7 @@ config: const singleSpec = `title: cli-hold-single config: name: cli-hold-single + result_protocol: submit_result nodes: - id: only name: only @@ -90,6 +92,117 @@ config: report_to_parent: true ` +const parentPlanMarker = "DAG_FINAL_RESPONSE_PARENT_PLAN" +const childFinalMarker = "DAG_FINAL_RESPONSE_CHILD" +const childFinalText = "DAG_FINAL_RESPONSE_RAW_ONCE_52AF" +const parentPlanText = "The implementation plan is ready." +const finalResponseSpec = `title: cli-final-response +config: + name: cli-final-response + result_protocol: final_response + delivery_node: final + max_concurrency: 1 + nodes: + - id: final + name: final + worker_type: general + depends_on: [] + report_to_parent: false + prompt_template: + inline: "${childFinalMarker}: return the requested final response." +` +const multiParentMarker = "DAG_MULTI_FINAL_RESPONSE_PARENT" +const multiPlanText = "Both workflow plans are ready." +const multiFinals = [ + { name: "alpha", marker: "DAG_MULTI_FINAL_ALPHA", answer: "DAG_MULTI_FINAL_ALPHA_RAW_03A1" }, + { name: "beta", marker: "DAG_MULTI_FINAL_BETA", answer: "DAG_MULTI_FINAL_BETA_RAW_94BC" }, +] as const +const mixedParentMarker = "DAG_MIXED_FINAL_RESPONSE_PARENT" +const mixedPlanText = "The workflows are underway." +const mixedDecisionText = "The checkpoint remains paused pending human approval." +const mixedFinalMarker = "DAG_MIXED_FINAL_REPORT" +const mixedFinalAnswer = "DAG_MIXED_FINAL_REPORT_RAW_779D" +const policyGuideMarker = "blocks: compose explore/plan/prototype/debug/coding/verify/review/synthesize blocks" +const mixedFinalSpec = `title: cli-mixed-final-report +config: + name: cli-mixed-final-report + result_protocol: final_response + delivery_node: final + max_concurrency: 1 + nodes: + - id: final + name: final + worker_type: general + depends_on: [] + report_to_parent: false + prompt_template: + inline: "${mixedFinalMarker}: return the final report." +` +const mixedDecisionSpec = `title: cli-mixed-decision +config: + name: cli-mixed-decision + result_protocol: submit_result + nodes: + - id: gate + name: gate + worker_type: general + depends_on: [] + report_to_parent: true + output_schema: + type: object + properties: + verdict: + type: string + enum: [replan] + required: [verdict] + additionalProperties: false + prompt_template: + inline: "MIXED-DECISION-GATE: return the replan verdict." + - id: waiting + name: waiting + worker_type: general + depends_on: [gate] + condition: 'gate.output.verdict == "replan"' + prompt_template: + inline: "MIXED-DECISION-WAITING: this node should remain paused." +` + +function finalResponseSpecFor(name: string, marker: string) { + return `title: cli-multi-final-${name} +config: + name: cli-multi-final-${name} + result_protocol: final_response + delivery_node: final + max_concurrency: 1 + nodes: + - id: final + name: final + worker_type: general + depends_on: [] + report_to_parent: false + prompt_template: + inline: "${marker}: return the final report." +` +} + +function emittedText( + result: RunResult, + opencode: { parseJsonEvents(stdout: string): Array> }, + format: "default" | "json", +) { + if (format === "default") return result.stdout.trimEnd().split("\n") + return opencode + .parseJsonEvents(result.stdout) + .filter((event) => event.type === "text") + .map((event) => event.part) + .map((part) => { + if (!part || typeof part !== "object" || !("text" in part) || typeof part.text !== "string") { + throw new Error("JSON run emitted a text event without a text part") + } + return part.text + }) +} + // Required node declaring an output_schema the child never satisfies (the // auto-reply never calls submit_result): deterministic verdict_fail bound. const failSpec = `title: cli-hold-failure @@ -119,6 +232,7 @@ config: const checkpointSpec = `title: cli-hold-checkpoint config: name: cli-hold-checkpoint + result_protocol: submit_result nodes: - id: gate name: gate @@ -149,6 +263,179 @@ function writeSpec(home: string, name: string, content: string) { } describe("opencode run DAG hold (issue 614)", () => { + for (const format of ["default", "json"] as const) { + cliIt.concurrent( + `prints the final_response delivery node exactly once and exits (${format})`, + ({ home, llm, opencode, target }) => + Effect.gen(function* () { + const spec = path.join(home, "wf-final-response.yaml") + yield* writeSpec(home, "wf-final-response.yaml", finalResponseSpec) + yield* llm.pushMatch( + bodyIncludes(parentPlanMarker), + reply() + .tool("workflow", { params: { action: "start", spec_path: spec } }) + .item(), + ) + yield* llm.pushMatch(bodyIncludes(parentPlanMarker), reply().text(parentPlanText).stop().item()) + yield* llm.pushMatch(bodyIncludes(childFinalMarker), reply().text(childFinalText).stop().item()) + + const result = yield* opencode.run(`${parentPlanMarker}: start the workflow and report the plan`, { + format, + extraArgs: SKIP_PERMISSIONS, + }) + opencode.expectExit(result, 0) + expect(result.target).toEqual(target) + + if (format === "default") { + const lines = result.stdout.trimEnd().split("\n") + expect(lines.filter((line) => line === parentPlanText)).toHaveLength(1) + expect(lines.filter((line) => line === childFinalText)).toHaveLength(1) + expect(lines.at(-1)).toBe(childFinalText) + } else { + const events = opencode.parseJsonEvents(result.stdout) + const text = events + .filter((event) => event.type === "text") + .map((event) => event.part) + .map((part) => { + if (!part || typeof part !== "object" || !("text" in part) || typeof part.text !== "string") { + throw new Error("JSON run emitted a text event without a text part") + } + return part.text + }) + expect(text.filter((value) => value === parentPlanText)).toHaveLength(1) + expect(text.filter((value) => value === childFinalText)).toHaveLength(1) + expect(text.at(-1)).toBe(childFinalText) + } + + const requests = (yield* llm.inputs).filter( + (request) => !JSON.stringify(request).includes("Generate a title for this conversation"), + ) + const parentRequests = requests.filter((request) => JSON.stringify(request).includes(parentPlanMarker)) + expect(parentRequests).toHaveLength(2) + expect(requests.some((request) => JSON.stringify(request).includes(childFinalText))).toBe(false) + expect(requests.some((request) => JSON.stringify(request).includes("[DAG Workflow completed]"))).toBe(false) + }), + 180_000, + ) + } + + for (const format of ["default", "json"] as const) { + cliIt.concurrent( + `waits for both completed final_response workflows and emits each answer once (${format})`, + ({ home, llm, opencode }) => + Effect.gen(function* () { + const specs = multiFinals.map((workflow) => ({ + ...workflow, + path: path.join(home, `wf-multi-final-${workflow.name}.yaml`), + })) + for (const workflow of specs) { + yield* writeSpec(home, path.basename(workflow.path), finalResponseSpecFor(workflow.name, workflow.marker)) + yield* llm.pushMatch( + bodyIncludes(multiParentMarker), + reply() + .tool("workflow", { params: { action: "start", spec_path: workflow.path } }) + .item(), + ) + } + yield* llm.pushMatch(bodyIncludes(multiParentMarker), reply().text(multiPlanText).stop().item()) + for (const workflow of specs) { + yield* llm.pushMatch(bodyIncludes(workflow.marker), reply().text(workflow.answer).stop().item()) + } + + const result = yield* opencode.run(`${multiParentMarker}: start both workflows`, { + format, + extraArgs: SKIP_PERMISSIONS, + }) + opencode.expectExit(result, 0) + const text = emittedText(result, opencode, format) + expect(text.filter((value) => value === multiPlanText)).toHaveLength(1) + for (const workflow of specs) expect(text.filter((value) => value === workflow.answer)).toHaveLength(1) + expect(new Set(specs.map((workflow) => workflow.answer)).has(text.at(-1) ?? "")).toBe(true) + + const requests = (yield* llm.inputs).filter( + (request) => !JSON.stringify(request).includes("Generate a title for this conversation"), + ) + expect(requests.filter((request) => JSON.stringify(request).includes(multiParentMarker))).toHaveLength(3) + for (const workflow of specs) { + expect(requests.some((request) => JSON.stringify(request).includes(workflow.answer))).toBe(false) + } + expect(requests.some((request) => JSON.stringify(request).includes("[DAG Workflow completed]"))).toBe(false) + }), + 180_000, + ) + } + + cliIt.concurrent( + "finishes the parent decision turn and then emits the completed mixed-batch report once", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const finalPath = path.join(home, "wf-mixed-final.yaml") + const decisionPath = path.join(home, "wf-mixed-decision.yaml") + yield* writeSpec(home, path.basename(finalPath), mixedFinalSpec) + yield* writeSpec(home, path.basename(decisionPath), mixedDecisionSpec) + yield* llm.pushMatch( + bodyIncludes(mixedParentMarker), + reply() + .tool("workflow", { params: { action: "start", spec_path: finalPath } }) + .item(), + ) + yield* llm.pushMatch( + bodyIncludes(mixedParentMarker), + reply() + .tool("workflow", { params: { action: "start", spec_path: decisionPath } }) + .item(), + ) + yield* llm.pushMatch((hit) => { + const body = JSON.stringify(hit.body) + return body.includes(mixedParentMarker) && !body.includes("[DAG Node Result") + }, reply().text(mixedPlanText).stop().item()) + yield* llm.pushMatch( + bodyIncludes("[DAG Node Result"), + reply() + .tool("workflow", { params: { action: "guide" } }) + .item(), + ) + let releaseFinal = () => {} + const finalRelease = new Promise((resolve) => { + releaseFinal = resolve + }) + yield* llm.pushMatch((hit) => { + const body = JSON.stringify(hit.body) + if (!body.includes(policyGuideMarker)) return false + releaseFinal() + return true + }, reply().text(mixedDecisionText).stop().item()) + yield* llm.pushMatch( + bodyIncludes(mixedFinalMarker), + reply().wait(finalRelease).text(mixedFinalAnswer).stop().item(), + ) + yield* llm.pushMatch( + bodyIncludes("MIXED-DECISION-GATE"), + reply() + .tool("submit_result", { payload: { verdict: "replan" } }) + .item(), + ) + + const result = yield* opencode.run(`${mixedParentMarker}: start report and checkpoint workflows`, { + extraArgs: SKIP_PERMISSIONS, + }) + opencode.expectExit(result, 0) + const lines = emittedText(result, opencode, "default") + expect(lines.filter((line) => line === mixedPlanText)).toHaveLength(1) + expect(lines).toContain(mixedDecisionText) + expect(lines.filter((line) => line === mixedFinalAnswer)).toHaveLength(1) + expect(lines.at(-1)).toBe(mixedFinalAnswer) + + const requests = (yield* llm.inputs).filter( + (request) => !JSON.stringify(request).includes("Generate a title for this conversation"), + ) + expect(requests.some((request) => JSON.stringify(request).includes("[DAG Node Result"))).toBe(true) + expect(requests.some((request) => JSON.stringify(request).includes(mixedFinalAnswer))).toBe(false) + expect(requests.some((request) => JSON.stringify(request).includes("[DAG Workflow completed]"))).toBe(false) + }), + 180_000, + ) + cliIt.concurrent( "no-DAG single-turn prompt exits with the exact reply", ({ llm, opencode, target }) => @@ -203,10 +490,7 @@ describe("opencode run DAG hold (issue 614)", () => { release = resolve }) yield* llm.tool("workflow", { params: { action: "start", spec_path: spec } }) - yield* llm.pushMatch( - bodyIncludes("EARLY-TOKEN"), - reply().wait(released).text("turn one done").stop().item(), - ) + yield* llm.pushMatch(bodyIncludes("EARLY-TOKEN"), reply().wait(released).text("turn one done").stop().item()) yield* llm.pushMatch(wakeCompleted, reply().text("wake handled").stop().item()) const run = yield* opencode.startRun("EARLY-TOKEN start the single node workflow", { @@ -270,7 +554,9 @@ describe("opencode run DAG hold (issue 614)", () => { yield* llm.tool("workflow", { params: { action: "start", spec_path: spec } }) yield* llm.pushMatch( bodyIncludes("ADOPT-GATE"), - reply().tool("submit_result", { payload: { verdict: "replan" } }).item(), + reply() + .tool("submit_result", { payload: { verdict: "replan" } }) + .item(), ) yield* llm.pushMatch(bodyIncludes("[DAG Node Result"), reply().text("checkpoint seen").stop().item()) diff --git a/packages/opencode/test/dag/dag-final-delivery.test.ts b/packages/opencode/test/dag/dag-final-delivery.test.ts new file mode 100644 index 000000000..eece07028 --- /dev/null +++ b/packages/opencode/test/dag/dag-final-delivery.test.ts @@ -0,0 +1,357 @@ +/* oxlint-disable typescript-eslint/no-unsafe-type-assertion -- Synthetic branded DB rows and Session service mocks exercise transaction rollback without starting a model. */ +import { expect, it } from "bun:test" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" +import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Database } from "@opencode-ai/core/database/database" +import { DagStore } from "@opencode-ai/core/dag/store" +import { WorkflowNodeTable, WorkflowTable } from "@opencode-ai/core/dag/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import type { Session } from "@/session/session" +import { deliverReport, reportIdentity, selectReportNode } from "@/dag/runtime/report-delivery" +import { commitOutputFileRef, verifyOutputFileRef } from "@/dag/runtime/output-ref" +import { makeNodeRow, makeWorkflowRow } from "./fixtures" +import { WorkflowAuthoring } from "@/dag/authoring" +import { parseWorkflowConfig, resultProtocol } from "@/dag/dag" +import { before } from "@/session/message-v2" + +function fixture( + work: (input: { + database: Database.Interface + store: DagStore.Interface + sessions: Session.Interface + workflow: DagStore.WorkflowRow + deliver: () => Effect.Effect + crash: (value: boolean) => void + }) => Effect.Effect, +) { + const database = Database.layerFromPath(":memory:") + const layer = Layer.merge(database, DagStore.layer.pipe(Layer.provide(database))) + return Effect.gen(function* () { + const database = yield* Database.Service + const store = yield* DagStore.Service + const db = database.db + const text = " Exact final report\n\nwith **formatting** and trailing space. " + const config = { + name: "delivery", + result_protocol: "final_response", + delivery_node: "final", + nodes: [{ id: "final", depends_on: [] }], + } + yield* db + .insert(ProjectTable) + .values({ id: "proj-1" as never, worktree: "/tmp" as never, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: "ses_parent" as never, + project_id: "proj-1" as never, + directory: "/tmp", + title: "parent", + slug: "parent", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + yield* db + .insert(WorkflowTable) + .values({ + id: "wf-1", + project_id: "proj-1" as never, + session_id: "ses_parent" as never, + title: "delivery", + status: "completed", + config: JSON.stringify(config), + graph_rev: 2, + seq: 7, + completed_at: 100, + time_created: 1, + time_updated: 100, + }) + .run() + yield* db + .insert(WorkflowNodeTable) + .values({ + workflow_id: "wf-1", + id: "final", + name: "Final", + worker_type: "general", + status: "completed", + depends_on: [], + output: text, + seq: 6, + child_session_id: "ses_child", + time_created: 1, + time_updated: 100, + }) + .run() + const workflow = (yield* store.getWorkflow("wf-1"))! + let fail = false + const sessions = { + get: () => Effect.succeed({ id: "ses_parent", agent: "build", model: { providerID: "test", id: "test-model" } }), + messages: () => + db + .select() + .from(MessageTable) + .pipe( + Effect.map((rows) => + rows.map((row) => ({ info: { ...row.data, id: row.id, sessionID: row.session_id }, parts: [] })), + ), + ), + getPart: (input: { partID: string }) => + db + .select() + .from(PartTable) + .where(eq(PartTable.id, input.partID as never)) + .pipe( + Effect.map((rows) => + rows[0] + ? { ...rows[0].data, id: rows[0].id, sessionID: rows[0].session_id, messageID: rows[0].message_id } + : undefined, + ), + ), + updateMessage: (info: SessionV1.Info) => { + const { id, sessionID, ...data } = info + return db + .insert(MessageTable) + .values({ id, session_id: sessionID, data, time_created: info.time.created, time_updated: info.time.created }) + .run() + .pipe(Effect.as(info)) + }, + updatePart: (part: SessionV1.Part) => { + if (fail && part.type === "text" && part.metadata?.dag_delivery?.kind === "answer") + return Effect.die(new Error("simulated process failure before receipt")) + const { id, sessionID, messageID, ...data } = part + return db + .insert(PartTable) + .values({ id, session_id: sessionID, message_id: messageID, data, time_created: 100, time_updated: 100 }) + .run() + .pipe(Effect.as(part)) + }, + } as unknown as Session.Interface + const deliver = () => + Effect.gen(function* () { + const nodes = yield* store.getCurrentNodes("wf-1") + return yield* deliverReport({ + workflow, + store, + sessions, + directory: "/tmp", + batch: { nodes, workflows: [workflow] }, + }) + }).pipe(Effect.orDie) + yield* work({ + database, + store, + sessions, + workflow, + deliver, + crash: (value) => { + fail = value + }, + }) + }).pipe(Effect.provide(layer), Effect.runPromise) +} + +it("copies the answer verbatim with source metadata and adopts the same receipt after restart", () => + fixture(({ database, store, workflow, deliver }) => + Effect.gen(function* () { + expect(yield* deliver()).toBe(true) + const first = yield* database.db.select().from(PartTable) + const answers = first.filter( + (part) => + part.data.type === "text" && (part.data as SessionV1.TextPart).metadata?.dag_delivery?.kind === "answer", + ) + expect(answers).toHaveLength(1) + expect((answers[0].data as SessionV1.TextPart).text).toBe( + " Exact final report\n\nwith **formatting** and trailing space. ", + ) + expect(answers[0].id).toBe(reportIdentity(workflow).answerID) + expect((answers[0].data as SessionV1.TextPart).time?.end).toBe(100) + expect(reportIdentity(workflow).sourceID < reportIdentity(workflow).answerID).toBe(true) + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(true) + // Reconstructed callers have no in-memory receipt cache. A durable receipt + // still suppresses re-insertion if an old wake flag needs re-acknowledging. + yield* database.db.update(WorkflowTable).set({ wake_reported: false }).where(eq(WorkflowTable.id, "wf-1")).run() + expect(yield* deliver()).toBe(true) + expect(yield* database.db.select().from(PartTable)).toHaveLength(first.length) + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(true) + }), + )) + +it("rolls back the entire transcript and wake mark when receipt persistence fails", () => + fixture(({ database, store, deliver, crash }) => + Effect.gen(function* () { + crash(true) + expect((yield* deliver().pipe(Effect.exit))._tag).toBe("Failure") + expect(yield* database.db.select().from(MessageTable)).toHaveLength(0) + expect(yield* database.db.select().from(PartTable)).toHaveLength(0) + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(false) + crash(false) + expect(yield* deliver()).toBe(true) + expect( + (yield* database.db.select().from(MessageTable)).filter((row) => row.data.role === "assistant"), + ).toHaveLength(1) + }), + )) + +it("does not copy a completion superseded by a reopen or a newer graph revision", () => + fixture(({ database, deliver }) => + Effect.gen(function* () { + yield* database.db + .update(WorkflowTable) + .set({ status: "running", seq: 8, graph_rev: 3 }) + .where(eq(WorkflowTable.id, "wf-1")) + .run() + expect(yield* deliver()).toBe(false) + expect(yield* database.db.select().from(MessageTable)).toHaveLength(0) + }), + )) + +it("rejects a mismatched durable receipt instead of acknowledging lost content", () => + fixture(({ database, store, workflow, deliver }) => + Effect.gen(function* () { + yield* deliver() + const id = reportIdentity(workflow).answerID + const part = (yield* database.db.select().from(PartTable).where(eq(PartTable.id, id)))[0] + yield* database.db + .update(PartTable) + .set({ data: { ...part.data, text: "tampered" } as never }) + .where(eq(PartTable.id, id)) + .run() + yield* database.db.update(WorkflowTable).set({ wake_reported: false }).where(eq(WorkflowTable.id, "wf-1")).run() + expect((yield* deliver().pipe(Effect.exit))._tag).toBe("Failure") + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(false) + }), + )) + +it("uses explicit delivery selection and never guesses from required nodes or old attempts", () => { + const nodes = [ + makeNodeRow({ id: "required", required: true, status: "completed" }), + makeNodeRow({ id: "final", status: "completed", superseded: true }), + ] + expect(selectReportNode({ name: "ambiguous", nodes: [] }, nodes)).toBeUndefined() + expect(selectReportNode({ name: "old", nodes: [], delivery_node: "final" }, nodes)).toBeUndefined() + expect(reportIdentity(makeWorkflowRow({ seq: 2 }))).not.toEqual(reportIdentity(makeWorkflowRow({ seq: 3 }))) +}) + +it("persists the unique leaf synthesis selection and preserves the legacy protocol default", async () => { + const authoring = WorkflowAuthoring.make() + const result = await Effect.runPromise(authoring.prepare({ action: "start", profile: "portable", source: { kind: "inline", value: { config: { name: "synthesis", objective: "Produce the final report", blocks: [{ id: "final", kind: "synthesize" }] } } } })) + expect(result.valid).toBe(true) + expect(result.prepared?.action === "start" && result.prepared.config.delivery_node).toBe("final") + const ambiguous = await Effect.runPromise(authoring.prepare({ action: "start", profile: "portable", source: { kind: "inline", value: { config: { name: "ambiguous", objective: "Produce reports", blocks: [{ id: "a", kind: "synthesize" }, { id: "b", kind: "synthesize" }] } } } })) + expect(ambiguous.prepared?.action === "start" && ambiguous.prepared.config.delivery_node).toBeUndefined() + const invalid = await Effect.runPromise(authoring.prepare({ action: "start", profile: "portable", source: { kind: "inline", value: { config: { name: "invalid", delivery_node: "missing", nodes: [{ id: "a", name: "A", worker_type: "general", depends_on: [], prompt_template: { inline: "A" } }] } } } })) + expect(invalid.valid).toBe(false) + expect(resultProtocol(parseWorkflowConfig(JSON.stringify({ name: "legacy", nodes: [] })))).toBe("submit_result") + expect(parseWorkflowConfig(JSON.stringify({ name: "bad", nodes: [], result_protocol: "unknown" }))).toBeUndefined() +}) + +it("preserves an unprocessed user request while idle and delivers after it has an answer", () => + fixture(({ database, sessions, deliver }) => + Effect.gen(function* () { + yield* sessions.updateMessage({ + id: "msg_pending" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + expect(yield* deliver()).toBe(false) + expect( + (yield* database.db.select().from(MessageTable)).filter((row) => row.data.role === "assistant"), + ).toHaveLength(0) + yield* sessions.updateMessage({ + id: "msg_answered" as never, + sessionID: "ses_parent" as never, + parentID: "msg_pending" as never, + role: "assistant", + time: { created: 60, completed: 60 }, + agent: "build", + mode: "build", + path: { cwd: "/tmp", root: "/tmp" }, + providerID: "test" as never, + modelID: "test" as never, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }) + expect(yield* deliver()).toBe(true) + }), + )) + +it("stores an oversized answer as a verified artifact with bounded parent text", () => + fixture(({ database, deliver }) => + Effect.gen(function* () { + const text = "large report\n".repeat(6000) + yield* database.db + .update(WorkflowNodeTable) + .set({ output: text }) + .where(eq(WorkflowNodeTable.workflow_id, "wf-1")) + .run() + expect(yield* deliver()).toBe(true) + const receipt = (yield* database.db.select().from(PartTable)) + .map((row) => row.data as SessionV1.TextPart) + .find((part) => part.metadata?.dag_delivery?.kind === "answer")! + expect(receipt.text.length).toBeLessThan(2000) + const artifact = receipt.metadata!.dag_delivery.artifact + yield* verifyOutputFileRef(artifact) + expect(yield* Effect.promise(() => readFile(artifact.path, "utf8"))).toBe(text) + }), + )) + +it("uses the captured file receipt and refuses to acknowledge a missing managed object", () => + fixture(({ database, deliver }) => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "dag-delivery-file-"))) + yield* Effect.gen(function* () { + const source = path.join(directory, "report.txt") + yield* Effect.promise(() => writeFile(source, `file report ${directory}`)) + const artifact = (yield* commitOutputFileRef(source, { + workflow_id: "wf-1", + node_id: "final", + child_session_id: "ses_child", + replan_attempt: 0, + graph_rev: 2, + }))! + yield* database.db + .update(WorkflowNodeTable) + .set({ output: artifact.path, captured_output: artifact, captured_output_present: true }) + .where(eq(WorkflowNodeTable.workflow_id, "wf-1")) + .run() + yield* deliver() + const receipt = (yield* database.db.select().from(PartTable)) + .map((row) => row.data as SessionV1.TextPart) + .find((part) => part.metadata?.dag_delivery?.kind === "answer")! + expect(receipt.text).toContain("[Complete report]") + expect(receipt.metadata?.dag_delivery.artifact.sha256).toBe(artifact.sha256) + yield* Effect.promise(() => rm(artifact.path)) + yield* database.db.update(WorkflowTable).set({ wake_reported: false }).where(eq(WorkflowTable.id, "wf-1")).run() + expect((yield* deliver().pipe(Effect.exit))._tag).toBe("Failure") + }).pipe(Effect.ensuring(Effect.promise(() => rm(directory, { recursive: true, force: true })))) + }), + )) + +it("keeps the fallback synthetic anchor before its assistant using canonical ordering", () => fixture(({ database, deliver }) => Effect.gen(function* () { + yield* deliver() + const messages = yield* database.db.select().from(MessageTable) + const user = messages.find((row) => row.data.role === "user")! + const answer = messages.find((row) => row.data.role === "assistant")! + expect(before({ id: user.id, time: user.data.time }, { id: answer.id, time: answer.data.time })).toBe(true) + const zero = reportIdentity(makeWorkflowRow({ completedAt: 0, timeUpdated: 0 })) + expect(before({ id: zero.anchorID, time: { created: 0 } }, { id: zero.messageID, time: { created: 0 } })).toBe(true) +}))) + +it("does not consume a same-millisecond user that sorts after the previous assistant", () => fixture(({ sessions, deliver }) => Effect.gen(function* () { + yield* sessions.updateMessage({ id: "msg_z_pending" as never, sessionID: "ses_parent" as never, role: "user", time: { created: 50 }, agent: "build", model: { providerID: "test" as never, modelID: "test" as never } }) + yield* sessions.updateMessage({ id: "msg_a_previous" as never, sessionID: "ses_parent" as never, parentID: "msg_previous" as never, role: "assistant", time: { created: 50, completed: 50 }, agent: "build", mode: "build", path: { cwd: "/tmp", root: "/tmp" }, providerID: "test" as never, modelID: "test" as never, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "stop" }) + expect(yield* deliver()).toBe(false) +}))) diff --git a/packages/opencode/test/dag/dag-final-output.live.test.ts b/packages/opencode/test/dag/dag-final-output.live.test.ts new file mode 100644 index 000000000..c23326e45 --- /dev/null +++ b/packages/opencode/test/dag/dag-final-output.live.test.ts @@ -0,0 +1,438 @@ +// Live acceptance for final_response DAG delivery through the installed CLI. +// Opt in with OPENCODE_DAG_LIVE_CONFIG= (or `1` to use the +// standard user config). The provider config is read only into memory and is +// passed to the isolated child process as OPENCODE_CONFIG_CONTENT. +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { mkdir, readFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { artifactCliTarget, resolveCliTarget, verifyCliTarget, withCliFixture } from "../lib/cli-process" + +type RecordValue = Record +type ModelCandidate = { + providerID: string + modelID: string + provider: RecordValue +} + +const liveConfigSetting = process.env.OPENCODE_DAG_LIVE_CONFIG +const liveEnabled = Boolean(liveConfigSetting) +const cliTarget = artifactCliTarget("/usr/local/bin/opencode") +const candidateModelIDs = ["qwen-max", "glm", "deepseek"] as const +const selectedModelSetting = process.env.OPENCODE_DAG_LIVE_MODELS +const schemaToken = "LIVE_DAG_SCHEMA_PROOF_71C8" +const plainAnswer = "LIVE_DAG_FINAL_RAW_4F2A" +const deliveryMetaKey = "dag_delivery" + +function isRecord(value: unknown): value is RecordValue { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function record(value: unknown): RecordValue { + if (!isRecord(value)) throw new Error("expected JSON object") + return value +} + +function list(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error("expected JSON array") + return value +} + +function stringField(value: RecordValue, key: string): string { + const result = value[key] + if (typeof result !== "string") throw new Error(`expected ${key} to be a string`) + return result +} + +function liveConfigPath(): string { + if (liveConfigSetting === "1" || liveConfigSetting === "true") + return path.join(os.homedir(), ".config/opencode/opencode.json") + return liveConfigSetting! +} + +async function readModelCandidates(configPath: string): Promise { + let config: RecordValue + try { + config = record(JSON.parse(await readFile(configPath, "utf8"))) + } catch { + throw new Error("OPENCODE_DAG_LIVE_CONFIG must name a readable JSON opencode config") + } + const providers = record(config.provider ?? {}) + const result: ModelCandidate[] = [] + const selected = selectedModelSetting + ? new Set( + selectedModelSetting + .split(/[|,]/) + .map((value) => value.trim()) + .filter(Boolean), + ) + : new Set(candidateModelIDs) + const requested = candidateModelIDs.filter((modelID) => selected.has(modelID)) + if (requested.length === 0) throw new Error("OPENCODE_DAG_LIVE_MODELS must select qwen-max, glm, or deepseek") + for (const modelID of requested) { + for (const [providerID, rawProvider] of Object.entries(providers)) { + const provider = record(rawProvider) + const models = record(provider.models ?? {}) + if (Object.hasOwn(models, modelID)) result.push({ providerID, modelID, provider }) + } + } + if (result.length === 0) throw new Error("live config has no qwen-max, glm, or deepseek model") + return result +} + +async function requestJson( + baseURL: string, + directory: string, + route: string, + input: { method?: string; body?: unknown } = {}, +): Promise { + const response = await fetch(new URL(route, baseURL), { + method: input.method ?? "GET", + headers: { + "x-opencode-directory": directory, + ...(input.body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }), + }) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`HTTP ${input.method ?? "GET"} ${route} failed with status ${response.status}`) + } + if (response.status === 204) return undefined + try { + return await response.json() + } catch { + throw new Error(`HTTP ${input.method ?? "GET"} ${route} returned invalid JSON`) + } +} + +function providerUnavailable(reason: string): boolean { + return /\b(401|403|404|408|429|5\d\d)\b|quota|rate.?limit|unauthor|api.?key|fetch failed|network|ECONN|timed? ?out|temporarily unavailable/i.test( + reason, + ) +} + +function deliveryMeta(part: RecordValue): RecordValue | undefined { + const metadata = part.metadata + if (!isRecord(metadata)) return undefined + const value = metadata[deliveryMetaKey] + return isRecord(value) ? value : undefined +} + +function visibleText(message: RecordValue): string { + return list(message.parts) + .map(record) + .filter((part) => part.type === "text" && part.synthetic !== true && part.ignored !== true) + .map((part) => (typeof part.text === "string" ? part.text : "")) + .join("") +} + +async function waitForWorkflow( + baseURL: string, + directory: string, + workflowID: string, + timeoutMs: number, +): Promise<{ detail: RecordValue; nodes: RecordValue[]; timedOut: boolean }> { + const deadline = Date.now() + timeoutMs + let detail: RecordValue = {} + let nodes: RecordValue[] = [] + while (Date.now() < deadline) { + detail = record(await requestJson(baseURL, directory, `/dag/${encodeURIComponent(workflowID)}`)) + nodes = list(await requestJson(baseURL, directory, `/dag/${encodeURIComponent(workflowID)}/nodes`)).map(record) + const status = stringField(detail, "status") + if (status === "completed" || status === "failed" || status === "cancelled") + return { detail, nodes, timedOut: false } + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { detail, nodes, timedOut: true } +} + +async function waitForParentMessages( + baseURL: string, + directory: string, + sessionID: string, + predicate: (messages: RecordValue[]) => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs + let messages: RecordValue[] = [] + while (Date.now() < deadline) { + messages = list(await requestJson(baseURL, directory, `/session/${encodeURIComponent(sessionID)}/message`)).map( + record, + ) + if (predicate(messages)) return messages + await new Promise((resolve) => setTimeout(resolve, 500)) + } + return messages +} + +function assertReceipt(messages: RecordValue[], workflowID: string, graphRev: number, childSessionID: string): string { + const assistant = messages.filter((message) => record(message.info).role === "assistant") + expect(assistant).toHaveLength(1) + const deliveryParts = assistant.flatMap((message) => + list(message.parts) + .map(record) + .map((part) => ({ part, meta: deliveryMeta(part) })), + ) + const answers = deliveryParts.filter((item) => item.meta?.workflow_id === workflowID && item.meta?.kind === "answer") + const sources = deliveryParts.filter((item) => item.meta?.workflow_id === workflowID && item.meta?.kind === "source") + expect(answers).toHaveLength(1) + expect(sources).toHaveLength(1) + const receipt = answers[0].meta! + expect(receipt.workflow_id).toBe(workflowID) + expect(receipt.graph_rev).toBe(graphRev) + expect(receipt.node_id).toBe("delivery") + expect(receipt.child_session_id).toBe(childSessionID) + expect(receipt.replan_attempt).toBe(0) + expect(typeof receipt.completion_seq).toBe("number") + const text = answers[0].part.text + if (typeof text !== "string") throw new Error("answer receipt has no text") + return text +} + +async function tryCandidate(input: { + serverURL: string + home: string + serverEnv: Record + providerID: string + modelID: string +}): Promise< + | "unavailable" + | { + status: "passed" + directory: string + parentID: string + workflowID: string + childSessionID: string + graphRev: number + serverEnv: Record + } +> { + const { serverURL, home, serverEnv, providerID, modelID } = input + const directory = path.join(home, `workspace-${modelID}`) + await mkdir(directory, { recursive: true }) + const parent = record( + await requestJson(serverURL, directory, "/session", { + method: "POST", + body: { title: `DAG final response live ${modelID}`, model: { id: modelID, providerID } }, + }), + ) + const parentID = stringField(parent, "id") + + // Starting the workflow through HTTP bypasses parent model planning. The + // runtime owns the synthetic user anchor required to attach its receipt. + const start = record( + await requestJson(serverURL, directory, "/dag", { + method: "POST", + body: { + session_id: parentID, + title: `Final response capture ${modelID}`, + config: { + name: `live-final-response-${modelID}`, + result_protocol: "final_response", + delivery_node: "delivery", + max_concurrency: 1, + nodes: [ + { + id: "schema", + name: "Return the structured proof", + worker_type: "general", + depends_on: [], + required: true, + prompt_template: { + inline: `Return exactly one JSON object and no Markdown: {"token":"${schemaToken}"}.`, + }, + output_schema: { + type: "object", + properties: { token: { type: "string" } }, + required: ["token"], + additionalProperties: false, + }, + }, + { + id: "delivery", + name: "Return the raw final answer", + worker_type: "general", + depends_on: ["schema"], + required: true, + input_mapping: { proof: "schema.output.token" }, + prompt_template: { + inline: `The upstream proof token is {{proof}}. Finish with exactly this plain-text line and no other text: ${plainAnswer}`, + }, + }, + ], + }, + }, + }), + ) + const workflowID = stringField(start, "id") + const workflow = await waitForWorkflow(serverURL, directory, workflowID, 180_000) + if (workflow.timedOut) return "unavailable" + if (workflow.detail.status !== "completed") { + const failed = workflow.nodes.find((node) => node.status === "failed") + const reason = typeof failed?.error_reason === "string" ? failed.error_reason : "" + if (providerUnavailable(reason)) return "unavailable" + throw new Error(`live DAG did not complete for ${modelID} (workflow status ${String(workflow.detail.status)})`) + } + + expect(workflow.nodes).toHaveLength(2) + const storedConfig = record(JSON.parse(stringField(workflow.detail, "config"))) + expect(storedConfig.result_protocol).toBe("final_response") + expect(storedConfig.delivery_node).toBe("delivery") + const schemaNode = workflow.nodes.find((node) => node.id === "schema")! + const deliveryNode = workflow.nodes.find((node) => node.id === "delivery")! + expect(schemaNode.status).toBe("completed") + expect(deliveryNode.status).toBe("completed") + expect(schemaNode.output).toEqual({ token: schemaToken }) + const deliveryOutput = deliveryNode.output + if (typeof deliveryOutput !== "string") throw new Error("delivery node output is not a string") + expect(deliveryOutput).toBe(plainAnswer) + expect(typeof schemaNode.child_session_id).toBe("string") + expect(typeof deliveryNode.child_session_id).toBe("string") + const schemaSessionID = String(schemaNode.child_session_id) + const childSessionID = String(deliveryNode.child_session_id) + + const [schemaMessages, deliveryMessages] = await Promise.all( + [schemaSessionID, childSessionID].map(async (sessionID) => + list(await requestJson(serverURL, directory, `/session/${encodeURIComponent(sessionID)}/message`)).map(record), + ), + ) + const schemaAssistant = schemaMessages.filter((message) => record(message.info).role === "assistant") + const schemaFinalText = visibleText(schemaAssistant.at(-1) ?? {}) + expect(JSON.parse(schemaFinalText)).toEqual({ token: schemaToken }) + const childAssistant = deliveryMessages.filter((message) => record(message.info).role === "assistant") + const childFinalText = visibleText(childAssistant.at(-1) ?? {}) + expect(childFinalText).toBe(plainAnswer) + const usedSubmitResult = [...schemaMessages, ...deliveryMessages] + .flatMap((message) => list(message.parts).map(record)) + .some((part) => part.type === "tool" && part.tool === "submit_result") + expect(usedSubmitResult).toBe(false) + + const summary = list(await requestJson(serverURL, directory, `/dag/session/${encodeURIComponent(parentID)}/summary`)) + .map(record) + .find((item) => item.id === workflowID) + if (!summary || typeof summary.graphRev !== "number") throw new Error("workflow summary omitted graphRev") + const parentMessages = await waitForParentMessages( + serverURL, + directory, + parentID, + (messages) => + messages.some((message) => + list(message.parts).some((part) => deliveryMeta(record(part))?.workflow_id === workflowID), + ), + 20_000, + ) + const reportedText = assertReceipt(parentMessages, workflowID, summary.graphRev, childSessionID) + expect(reportedText).toBe(childFinalText) + expect(reportedText).toBe(deliveryOutput) + expect( + parentMessages.flatMap((message) => list(message.parts).map(record)).filter((part) => part.text === childFinalText), + ).toHaveLength(1) + expect( + parentMessages + .filter((message) => record(message.info).role === "assistant") + .flatMap((message) => list(message.parts).map(record)) + .some((part) => part.type === "tool" && part.tool === "submit_result"), + ).toBe(false) + + return { + status: "passed", + directory, + parentID, + workflowID, + childSessionID, + graphRev: summary.graphRev, + serverEnv, + } +} + +test.skipIf(!liveEnabled)( + "installed opencode captures and delivers final DAG output once without a parent model turn", + async () => { + const candidates = await readModelCandidates(liveConfigPath()) + await Effect.runPromise( + Effect.scoped( + withCliFixture( + ({ opencode, home, env, target }) => + Effect.gen(function* () { + const resolved = yield* Effect.promise(() => resolveCliTarget(cliTarget)) + yield* Effect.promise(() => verifyCliTarget(resolved)) + const version = yield* opencode.spawn(["--version"], { timeoutMs: 30_000 }) + if (version.exitCode !== 0 || version.stdout.trim().length === 0) + throw new Error("installed /usr/local/bin/opencode did not report a version") + + const unavailable: string[] = [] + for (const candidate of candidates) { + const configJson = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { [candidate.providerID]: candidate.provider }, + compaction: { auto: false }, + reasoningDistillation: { enabled: false }, + }) + const serverEnv = { + ...env, + OPENCODE_CONFIG: "", + OPENCODE_CONFIG_DIR: "", + OPENCODE_CONFIG_CONTENT: configJson, + OPENCODE_DB: path.join(home, "isolated-opencode.db"), + } + const server = yield* opencode.serve({ port: 0, hostname: "127.0.0.1", env: serverEnv }) + const result = yield* Effect.promise(() => + tryCandidate({ + serverURL: server.url, + home, + serverEnv, + providerID: candidate.providerID, + modelID: candidate.modelID, + }), + ) + server.kill() + yield* Effect.promise(() => server.exited) + if (result === "unavailable") { + unavailable.push(candidate.modelID) + continue + } + + // Restart and re-read durable output + the delivery receipt. + const restarted = yield* opencode.serve({ port: 0, hostname: "127.0.0.1", env: result.serverEnv }) + const persisted = yield* Effect.promise(() => + waitForWorkflow(restarted.url, result.directory, result.workflowID, 30_000), + ) + expect(persisted.timedOut).toBe(false) + expect(persisted.detail.status).toBe("completed") + expect(persisted.nodes.find((node) => node.id === "schema")?.output).toEqual({ token: schemaToken }) + expect(persisted.nodes.find((node) => node.id === "delivery")?.output).toBe(plainAnswer) + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 2_000))) + const afterRestart = yield* Effect.promise(() => + waitForParentMessages( + restarted.url, + result.directory, + result.parentID, + (messages) => messages.some((message) => record(message.info).role === "assistant"), + 10_000, + ), + ) + expect(assertReceipt(afterRestart, result.workflowID, result.graphRev, result.childSessionID)).toBe( + plainAnswer, + ) + const parentAssistantMessages = afterRestart.filter( + (message) => record(message.info).role === "assistant", + ) + process.stderr.write( + `[dag-final-response-live] model=${candidate.modelID} status=passed workflow_nodes=${persisted.nodes.length} ` + + `submit_result_calls=0 parent_assistant_messages=${parentAssistantMessages.length} ` + + `answer_receipts=1 source_receipts=1 binary_version=${version.stdout.trim()} ` + + `binary_sha256=${target.mode === "artifact" ? target.sha256 : "source"}\n`, + ) + return + } + throw new Error(`no configured live model completed acceptance; unavailable: ${unavailable.join(", ")}`) + }), + cliTarget, + ), + ), + ) + }, + 15 * 60_000, +) diff --git a/packages/opencode/test/dag/dag-final-response.test.ts b/packages/opencode/test/dag/dag-final-response.test.ts new file mode 100644 index 000000000..9ff31e0b6 --- /dev/null +++ b/packages/opencode/test/dag/dag-final-response.test.ts @@ -0,0 +1,579 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- Synthetic branded IDs and narrow Effect service mocks exercise only the runtime surface used by these tests. +import { describe, expect, it } from "bun:test" +import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Effect, Fiber, Layer, Semaphore } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import type { DagStore } from "@opencode-ai/core/dag/store" +import { DagMessages } from "@opencode-ai/core/dag/messages" +import { Agent } from "@/agent/agent" +import { Dag } from "@/dag/dag" +import { finalAssistantText, parseFinalResponse } from "@/dag/runtime/final-response" +import { commitOutputFileRef } from "@/dag/runtime/output-ref" +import { continueRecoveredMessageNode, makeLastAssistantMessageReader, reconcileWorkflow } from "@/dag/runtime/recovery" +import { spawnNode } from "@/dag/runtime/spawn" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { MessageID } from "@/session/schema" +import { makeNodeRow } from "./fixtures" + +function assistant(parts: Array>, finish = "stop"): SessionV1.WithParts { + return { + info: { + id: MessageID.ascending(), + role: "assistant", + parentID: MessageID.ascending(), + sessionID: "ses_final_child" as never, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test-model" as never, + providerID: "test" as never, + time: { created: Date.now() }, + finish, + }, + parts: parts.map((part) => ({ type: "text", text: "", ...part })) as SessionV1.TextPart[], + } +} + +describe("final assistant response", () => { + it("joins visible parts exactly and rejects an intermediate tool-call turn", () => { + const final = assistant([ + { text: '{"status":' }, + { text: "ignored", synthetic: true }, + { text: '"ok"}' }, + { text: "ignored", ignored: true }, + ]) + expect(finalAssistantText(final)).toEqual({ ok: true, text: '{"status":"ok"}' }) + expect(finalAssistantText(assistant([{ text: "old result" }], "tool-calls")).ok).toBe(false) + expect(finalAssistantText(assistant([{ text: "truncated" }], "length")).ok).toBe(false) + expect(parseFinalResponse('{"status":"ok"}\nextra').ok).toBe(false) + expect(parseFinalResponse("null")).toEqual({ ok: true, payload: null }) + }) + + it("recovery reads the latest message only", async () => { + const old = assistant([{ text: "old" }]) + const latest = assistant([{ text: "partial" }], "tool-calls") + const reader = makeLastAssistantMessageReader({ + messages: () => Effect.succeed([old, latest]), + } as unknown as Session.Interface) + const found = await Effect.runPromise(reader("ses_final_child")) + expect(found?.info.id).toBe(latest.info.id) + expect(finalAssistantText(found).ok).toBe(false) + }) +}) + +describe("spawn final-response protocol", () => { + const agent = Layer.mock(Agent.Service, { + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), + }) + const sessions = Layer.mock(Session.Service, { + get: () => Effect.succeed({ id: "ses_parent" as never, permission: [], agent: "build" } as never), + create: () => Effect.succeed({ id: "ses_final_child" as never } as never), + }) + + async function run(message: SessionV1.WithParts, schema?: Record) { + const events: Array<{ type: string; output?: unknown; reason?: string }> = [] + let receipt: { present: boolean; value: unknown } | undefined + let prompts = 0 + const store = { + tryClaimAdoption: () => Effect.succeed(true), + getNode: () => + Effect.succeed({ + ...makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }), + capturedOutput: receipt?.value, + capturedOutputPresent: receipt?.present, + }), + setCapturedOutput: (_sessionID: string, value: unknown) => + Effect.sync(() => { + receipt = { present: true, value } + }), + } as unknown as DagStore.Interface + const dag = Layer.mock(Dag.Service, { + store, + nodeQueued: () => Effect.void, + nodeStarted: () => Effect.void, + nodeCompleted: (_dagID: string, _nodeID: string, output: unknown) => + Effect.sync(() => { + events.push({ type: "completed", output }) + }), + nodeFailed: (_dagID: string, _nodeID: string, reason: string) => + Effect.sync(() => { + events.push({ type: "failed", reason }) + }), + }) + const prompt = Layer.mock(SessionPrompt.Service, { + prompt: () => + Effect.sync(() => { + prompts++ + return message + }), + }) + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const result = yield* spawnNode(Semaphore.makeUnsafe(1), { + dagID: "wf", + nodeID: "n", + node: makeNodeRow({ id: "n" }), + parentSessionID: "ses_parent", + promptParts: [{ type: "text", text: "work" }], + outputSchema: schema, + resultProtocol: "final_response", + }) + yield* Fiber.await(result.fiber) + }).pipe(Effect.provide(Layer.mergeAll(dag, agent, sessions, prompt))), + ) as Effect.Effect, + ) + return { events, receipt, prompts } + } + + it("captures a null JSON result as present without a submit_result call", async () => { + const result = await run(assistant([{ text: "null" }]), { type: "null" }) + expect(result.events).toEqual([{ type: "completed", output: null }]) + expect(result.receipt).toEqual({ present: true, value: null }) + expect(result.prompts).toBe(1) + }) + + for (const [raw, schema, value] of [ + ["false", { type: "boolean" }, false], + ["0", { type: "number" }, 0], + ] as const) { + it(`captures ${raw} without losing presence`, async () => { + const result = await run(assistant([{ text: raw }]), schema) + expect(result.events).toEqual([{ type: "completed", output: value }]) + expect(result.receipt).toEqual({ present: true, value }) + }) + } + + it("rejects prose around JSON and never asks for a legacy tool nudge", async () => { + const result = await run(assistant([{ text: 'Here: {"ok":true}' }]), { type: "object" }) + expect(result.events[0]?.type).toBe("failed") + expect(result.events[0]?.reason).toContain("one JSON value") + expect(result.prompts).toBe(1) + }) + + it("preserves every plaintext part for a node without a schema", async () => { + const result = await run(assistant([{ text: "first " }, { text: "second" }])) + expect(result.events).toEqual([{ type: "completed", output: "first second" }]) + }) +}) + +describe("recovered final-response settlement", () => { + it("continues a live child transcript and settles its current structured final turn", async () => { + const message = assistant([{ text: "false" }]) + const node = makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }) + const writes: Array<{ value: unknown; snapshotID?: string }> = [] + const completions: Array<{ output: unknown; snapshotID?: string }> = [] + const dag = Layer.mock(Dag.Service, { + store: { + getNode: () => Effect.succeed(node), + getNodes: () => Effect.succeed([node]), + getWorkflow: () => + Effect.succeed({ status: "running", projectId: "project", directory: "/tmp/project" } as never), + setCapturedOutput: (_sessionID: string, value: unknown, snapshotID?: string) => + Effect.sync(() => { + writes.push({ value, snapshotID }) + }), + } as unknown as DagStore.Interface, + nodeCompleted: (_dagID, _nodeID, output, attempt) => + Effect.sync(() => { + completions.push({ output, snapshotID: attempt?.inputSnapshotID }) + }), + }) + const prompt = Layer.mock(SessionPrompt.Service, { + loop: () => Effect.succeed(message), + }) + const messages = Layer.mock(DagMessages.Service, { + latestSnapshot: () => Effect.succeed({ ok: true, value: undefined } as never), + snapshotForTurn: (_caller, turnID) => + Effect.sync(() => { + expect(turnID).toBe(message.info.id) + return { ok: true, value: { id: "snap-current" } } as never + }), + guard: (_caller, input, commit) => + Effect.gen(function* () { + expect(input.snapshotID).toBe("snap-current") + return { ok: true, value: yield* commit } as const + }), + }) + await Effect.runPromise( + continueRecoveredMessageNode("wf", "n", { + result_protocol: "final_response", + nodes: [{ id: "n", output_schema: { type: "boolean" } }], + }).pipe(Effect.provide(Layer.mergeAll(dag, prompt, messages))), + ) + expect(writes).toEqual([{ value: false, snapshotID: "snap-current" }]) + expect(completions).toEqual([{ output: false, snapshotID: "snap-current" }]) + }) + + it("binds recovered plaintext completion to its final message snapshot when mailbox revisions exist", async () => { + const message = assistant([{ text: "first " }, { text: "second" }]) + let completed: { output: unknown; snapshotID?: string } | undefined + let frozenTurn: string | undefined + const node = makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }) + const dag = Layer.mock(Dag.Service, { + store: { + getNodes: () => Effect.succeed([node]), + getWorkflow: () => Effect.succeed({ projectId: "project", directory: "/tmp/project" } as never), + } as unknown as DagStore.Interface, + nodeCompleted: (_dagID, _nodeID, output, attempt) => + Effect.sync(() => { + completed = { output, snapshotID: attempt?.inputSnapshotID } + }), + }) + const messages = Layer.mock(DagMessages.Service, { + revisions: () => Effect.succeed({ ok: true, value: { queued: 0, accepted: 3 } } as never), + snapshotForTurn: (_caller, turnID) => + Effect.sync(() => { + frozenTurn = turnID + return { ok: true, value: { id: "snap-final" } } as never + }), + }) + const recovered = await Effect.runPromise( + reconcileWorkflow( + "wf", + () => Effect.succeed("completed"), + undefined, + { result_protocol: "final_response", nodes: [{ id: "n" }] }, + undefined, + undefined, + undefined, + () => Effect.succeed(message), + ).pipe(Effect.provide(Layer.mergeAll(dag, messages))), + ) + expect(recovered.reconciled).toBe(1) + expect(frozenTurn).toBe(message.info.id) + expect(completed).toEqual({ output: "first second", snapshotID: "snap-final" }) + }) + + it("fails closed when the final message has no associated snapshot", async () => { + const message = assistant([{ text: "complete report" }]) + let completed = false + let failure: string | undefined + const node = makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }) + const dag = Layer.mock(Dag.Service, { + store: { + getNodes: () => Effect.succeed([node]), + getWorkflow: () => Effect.succeed({ projectId: "project", directory: "/tmp/project" } as never), + } as unknown as DagStore.Interface, + nodeCompleted: () => + Effect.sync(() => { + completed = true + }), + nodeFailed: (_dagID, _nodeID, reason) => + Effect.sync(() => { + failure = reason + }), + }) + const messages = Layer.mock(DagMessages.Service, { + revisions: () => Effect.succeed({ ok: true, value: { queued: 0, accepted: 4 } } as never), + snapshotForTurn: () => Effect.succeed({ ok: true, value: undefined } as never), + }) + const recovered = await Effect.runPromise( + reconcileWorkflow( + "wf", + () => Effect.succeed("completed"), + undefined, + { result_protocol: "final_response", nodes: [{ id: "n" }] }, + undefined, + undefined, + undefined, + () => Effect.succeed(message), + ).pipe(Effect.provide(Layer.mergeAll(dag, messages))), + ) + expect(recovered.reconciled).toBe(1) + expect(completed).toBe(false) + expect(failure).toContain("no associated input snapshot") + }) + + it("keeps the frozen file receipt and its snapshot across a second recovery", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "dag-final-twice-")) + try { + const source = path.join(directory, "report.txt") + await writeFile(source, "durable two-stage report") + const message = assistant([{ text: source }]) + const node = { + ...makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }), + capturedOutput: undefined as unknown, + capturedOutputPresent: false, + capturedSnapshotID: null as string | null, + } + let completions = 0 + let guardedSnapshot: string | undefined + let completedOutput: unknown + const dag = Layer.mock(Dag.Service, { + store: { + getNodes: () => Effect.succeed([node]), + getWorkflow: () => Effect.succeed({ projectId: "project", directory } as never), + setCapturedOutput: (_sessionID: string, value: unknown, snapshotID?: string) => + Effect.sync(() => { + node.capturedOutput = value + node.capturedOutputPresent = true + node.capturedSnapshotID = snapshotID ?? null + }), + } as unknown as DagStore.Interface, + nodeCompleted: (_dagID, _nodeID, output, attempt) => + Effect.gen(function* () { + expect(attempt?.inputSnapshotID).toBe("snap-final") + completions++ + if (completions === 1) + return yield* new Dag.StaleMessageInputError({ dagID: "wf", nodeID: "n", reason: "stale_input" }) + completedOutput = output + return undefined + }), + }) + const messages = Layer.mock(DagMessages.Service, { + revisions: () => Effect.succeed({ ok: true, value: { queued: 0, accepted: 2 } } as never), + snapshotForTurn: (_caller, turnID) => + Effect.sync(() => { + expect(turnID).toBe(message.info.id) + return { ok: true, value: { id: "snap-final" } } as never + }), + guard: (_caller, input, commit) => + Effect.gen(function* () { + guardedSnapshot = input.snapshotID + return { ok: true, value: yield* commit } as const + }), + }) + const reconcile = () => + reconcileWorkflow( + "wf", + () => Effect.succeed("completed"), + undefined, + { result_protocol: "final_response", nodes: [{ id: "n" }] }, + undefined, + directory, + undefined, + () => Effect.succeed(message), + ).pipe(Effect.provide(Layer.mergeAll(dag, messages))) + const first = await Effect.runPromise(reconcile()) + expect(first.continuations).toEqual(["n"]) + expect(guardedSnapshot).toBe("snap-final") + expect(node.capturedSnapshotID).toBe("snap-final") + const frozen = node.capturedOutput as { path: string } + await unlink(source) + const second = await Effect.runPromise(reconcile()) + expect(second.reconciled).toBe(1) + expect(completedOutput).toBe(frozen.path) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it("restores a matching frozen file receipt after its source is deleted", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "dag-final-ref-")) + try { + const source = path.join(directory, "report.txt") + await writeFile(source, "durable report") + const ref = await Effect.runPromise( + commitOutputFileRef(source, { + workflow_id: "wf", + node_id: "n", + child_session_id: "ses_final_child", + replan_attempt: 0, + }), + ) + expect(ref).toBeDefined() + await unlink(source) + const message = assistant([{ text: source }]) + let completed: unknown + let authorized = false + const node = { + ...makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }), + capturedOutput: ref, + capturedOutputPresent: true, + capturedSnapshotID: "snap-final", + } + const dag = Layer.mock(Dag.Service, { + store: { + getNodes: () => Effect.succeed([node]), + getWorkflow: () => Effect.succeed({ projectId: "project", directory } as never), + } as unknown as DagStore.Interface, + nodeCompleted: (_dagID: string, _nodeID: string, output: unknown) => + Effect.sync(() => { + completed = output + }), + }) + const messages = Layer.mock(DagMessages.Service, { + revisions: () => Effect.succeed({ ok: true, value: { queued: 0 } } as never), + snapshotForTurn: () => Effect.succeed({ ok: true, value: { id: "snap-final" } } as never), + }) + const recovered = await Effect.runPromise( + reconcileWorkflow( + "wf", + () => Effect.succeed("completed"), + undefined, + { result_protocol: "final_response", nodes: [{ id: "n" }] }, + undefined, + directory, + (_sessionID, expected) => + Effect.sync(() => { + expect(expected).toBe(source) + authorized = true + }), + () => Effect.succeed(message), + ).pipe(Effect.provide(Layer.mergeAll(dag, messages))), + ) + expect(recovered.reconciled).toBe(1) + expect(completed).toBe(ref?.path) + expect(authorized).toBe(true) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it("uses an exact-snapshot durable receipt without revalidating after a crash", async () => { + const message = assistant([{ text: "false" }]) + let writes = 0 + let completed: unknown + const node = { + ...makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }), + capturedOutput: false, + capturedOutputPresent: true, + capturedSnapshotID: "snap-final", + } + const dag = Layer.mock(Dag.Service, { + store: { + getNodes: () => Effect.succeed([node]), + getWorkflow: () => Effect.succeed({ projectId: "project", directory: "/tmp/project" } as never), + setCapturedOutput: () => + Effect.sync(() => { + writes++ + }), + } as unknown as DagStore.Interface, + nodeCompleted: (_dagID: string, _nodeID: string, output: unknown) => + Effect.sync(() => { + completed = output + }), + }) + const messages = Layer.mock(DagMessages.Service, { + revisions: () => Effect.succeed({ ok: true, value: { queued: 0 } } as never), + snapshotForTurn: () => Effect.succeed({ ok: true, value: { id: "snap-final" } } as never), + }) + const recovered = await Effect.runPromise( + reconcileWorkflow( + "wf", + () => Effect.succeed("completed"), + undefined, + { result_protocol: "final_response", nodes: [{ id: "n", output_schema: { type: "boolean" } }] }, + undefined, + undefined, + undefined, + () => Effect.succeed(message), + ).pipe(Effect.provide(Layer.mergeAll(dag, messages))), + ) + expect(recovered.reconciled).toBe(1) + expect(completed).toBe(false) + expect(writes).toBe(0) + }) + + it("does not attach a prior turn's capture to the latest snapshot", async () => { + const message = assistant([{ text: "false" }]) + let persisted: unknown = true + let completed: unknown + let guardedSnapshot: string | undefined + const node = { + ...makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }), + capturedOutput: true, + capturedOutputPresent: true, + capturedSnapshotID: "snap-old", + } + const dag = Layer.mock(Dag.Service, { + store: { + getNodes: () => Effect.succeed([node]), + getWorkflow: () => Effect.succeed({ projectId: "project", directory: "/tmp/project" } as never), + setCapturedOutput: (_sessionID: string, value: unknown) => + Effect.sync(() => { + persisted = value + }), + } as unknown as DagStore.Interface, + nodeCompleted: (_dagID: string, _nodeID: string, output: unknown) => + Effect.sync(() => { + completed = output + }), + }) + const messages = Layer.mock(DagMessages.Service, { + revisions: () => Effect.succeed({ ok: true, value: { queued: 0 } } as never), + snapshotForTurn: () => Effect.succeed({ ok: true, value: { id: "snap-current" } } as never), + guard: (_caller, input, commit) => + Effect.gen(function* () { + guardedSnapshot = input.snapshotID + return { ok: true, value: yield* commit } as const + }), + }) + const recovered = await Effect.runPromise( + reconcileWorkflow( + "wf", + () => Effect.succeed("completed"), + undefined, + { result_protocol: "final_response", nodes: [{ id: "n", output_schema: { type: "boolean" } }] }, + undefined, + undefined, + undefined, + () => Effect.succeed(message), + ).pipe(Effect.provide(Layer.mergeAll(dag, messages))), + ) + expect(recovered.reconciled).toBe(1) + expect(guardedSnapshot).toBe("snap-current") + expect(persisted).toBe(false) + expect(completed).toBe(false) + }) + + it("settles the current final response after a crash between capture and completion", async () => { + const events: Array<{ type: string; output?: unknown; reason?: string }> = [] + let persisted: unknown = { old: true } + const node = { + ...makeNodeRow({ id: "n", status: "running", childSessionId: "ses_final_child" }), + capturedOutput: persisted, + capturedOutputPresent: true, + } + const dag = Layer.mock(Dag.Service, { + store: { + getNodes: () => Effect.succeed([node]), + getWorkflow: () => Effect.succeed(undefined), + setCapturedOutput: (_sessionID: string, value: unknown) => + Effect.sync(() => { + persisted = value + }), + } as unknown as DagStore.Interface, + nodeCompleted: (_dagID: string, _nodeID: string, output: unknown) => + Effect.sync(() => { + events.push({ type: "completed", output }) + }), + nodeFailed: (_dagID: string, _nodeID: string, reason: string) => + Effect.sync(() => { + events.push({ type: "failed", reason }) + }), + }) + const recovered = await Effect.runPromise( + reconcileWorkflow( + "wf", + () => Effect.succeed("completed"), + undefined, + { result_protocol: "final_response", nodes: [{ id: "n", output_schema: { type: "boolean" } }] }, + undefined, + undefined, + undefined, + () => Effect.succeed(assistant([{ text: "false" }])), + ).pipe(Effect.provide(dag)), + ) + expect(recovered.reconciled).toBe(1) + expect(events).toEqual([{ type: "completed", output: false }]) + expect(persisted).toBe(false) + }) +}) diff --git a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts index 4f1373fd2..c15eeab4c 100644 --- a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts +++ b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts @@ -364,7 +364,7 @@ describe("DagLoop final wake delivery re-triggers the goal (GOAL-FP-01-02)", () projectID: PROJECT_ID, sessionID: PARENT_SESSION, title: "wake retrigger", - config: { name: "wake-retrigger", nodes: [node("implement")] }, + config: { name: "wake-retrigger", result_protocol: "submit_result", nodes: [node("implement")] }, }) // Adoption (WorkflowStarted) registered the dag lease for the parent. @@ -443,7 +443,7 @@ describe("DagLoop wake delivery — a mark failure retry must not re-inject the projectID: PROJECT_ID, sessionID: PARENT_SESSION, title: "mark failure retry", - config: { name: "mark-fail", nodes: [node("implement")] }, + config: { name: "mark-fail", result_protocol: "submit_result", nodes: [node("implement")] }, }) const child = yield* takeWithin(childPrompts, "implement did not start") diff --git a/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts b/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts index 6c1e86895..cdfa3d21e 100644 --- a/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts +++ b/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts @@ -279,7 +279,7 @@ describe("DagLoop crash recovery integration", () => { Effect.gen(function* () { const dagID = yield* createRunningNode(dag, database, [ node({ output_schema: { type: "object" } }), - ]) + ], undefined, undefined, { result_protocol: "submit_result" }) yield* store.setCapturedOutput("ses_child1", { summary: "done" }) yield* loop.init() diff --git a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts index 378c30934..d472a67ac 100644 --- a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts +++ b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts @@ -446,7 +446,7 @@ describe("DagLoop replan vs stale NodeFailed", () => { projectID: "project-1", sessionID: "ses_parent", title: "Replan stale", - config: { name: "replan-stale", nodes: [node("a", [], 500)] }, + config: { name: "replan-stale", result_protocol: "submit_result", nodes: [node("a", [], 500)] }, }) const firstA = yield* takeWithin(childPrompts, "a did not start") expect(firstA.title).toBe("a") @@ -585,7 +585,7 @@ describe("DagLoop replan vs stale NodeFailed", () => { projectID: "project-1", sessionID: "ses_parent", title: "Restart rewire", - config: { name: "restart-rewire", nodes: [node("a"), node("b", ["a"])] }, + config: { name: "restart-rewire", result_protocol: "submit_result", nodes: [node("a"), node("b", ["a"])] }, }) const gateA = yield* takeWithin(childPrompts, "a did not start") expect(gateA.title).toBe("a") @@ -641,7 +641,7 @@ describe("DagLoop replan vs stale NodeFailed", () => { projectID: "project-1", sessionID: "ses_parent", title: "Restart ready", - config: { name: "restart-ready", nodes: [node("a", [], 500)] }, + config: { name: "restart-ready", result_protocol: "submit_result", nodes: [node("a", [], 500)] }, }) const firstA = yield* takeWithin(childPrompts, "a did not start") expect(firstA.title).toBe("a") diff --git a/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts b/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts index f26c56c3e..209429178 100644 --- a/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts +++ b/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts @@ -174,6 +174,31 @@ function promptText(input: SessionPrompt.PromptInput) { } describe("DAG schema prompt contract (issue #386)", () => { + it("new workflows request one final JSON value and capture it automatically", async () => { + await Effect.runPromise( + runContractTest(({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Final response schema contract", + config: { name: "final-response-contract", nodes: [schemaNode()] }, + }) + const gate = yield* Queue.take(childPrompts) + const prompt = promptText(gate.input) + expect(prompt).toContain("exactly one JSON value") + expect(prompt).not.toContain("submit_result") + yield* Deferred.succeed(gate.release, '{"summary":"Captured from final answer."}') + const row = yield* pollWithTimeout( + store.getNode(dagID, "report").pipe(Effect.map((node) => node?.status === "completed" ? node : undefined)), + "new protocol did not complete from final JSON", + ) + expect(row.output).toEqual({ summary: "Captured from final answer." }) + }), + ), + ) + }) + it("schema instruction tells the child the payload is the single authoritative report", async () => { await Effect.runPromise( runContractTest(({ dag, childPrompts }) => @@ -182,7 +207,7 @@ describe("DAG schema prompt contract (issue #386)", () => { projectID: "project-1", sessionID: "ses_parent", title: "Schema prompt contract", - config: { name: "schema-prompt-contract", nodes: [schemaNode()] }, + config: { name: "schema-prompt-contract", result_protocol: "submit_result", nodes: [schemaNode()] }, }) const gate = yield* Queue.take(childPrompts) const prompt = promptText(gate.input) @@ -217,7 +242,7 @@ describe("DAG schema prompt contract (issue #386)", () => { projectID: "project-1", sessionID: "ses_parent", title: "Schema prompt contract", - config: { name: "schema-prompt-contract", nodes: [schemaNode()] }, + config: { name: "schema-prompt-contract", result_protocol: "submit_result", nodes: [schemaNode()] }, }) const gate = yield* Queue.take(childPrompts) const payload = { summary: "Delivered through submit_result only." } @@ -257,7 +282,7 @@ describe("DAG schema prompt contract (issue #386)", () => { projectID: "project-1", sessionID: "ses_parent", title: "Nudge completes", - config: { name: "schema-prompt-contract", nodes: [schemaNode()] }, + config: { name: "schema-prompt-contract", result_protocol: "submit_result", nodes: [schemaNode()] }, }) // First turn: full analysis in prose, no submit_result call. const first = yield* Queue.take(childPrompts) @@ -291,7 +316,7 @@ describe("DAG schema prompt contract (issue #386)", () => { projectID: "project-1", sessionID: "ses_parent", title: "Nudge exhausted", - config: { name: "schema-prompt-contract", nodes: [schemaNode()] }, + config: { name: "schema-prompt-contract", result_protocol: "submit_result", nodes: [schemaNode()] }, }) const first = yield* Queue.take(childPrompts) yield* Deferred.succeed(first.release, "Prose only, no submission.") diff --git a/packages/opencode/test/dag/dag-timeout-escalation.test.ts b/packages/opencode/test/dag/dag-timeout-escalation.test.ts index 788f902eb..c0a9d0f02 100644 --- a/packages/opencode/test/dag/dag-timeout-escalation.test.ts +++ b/packages/opencode/test/dag/dag-timeout-escalation.test.ts @@ -1100,7 +1100,7 @@ describe("DagLoop timeout escalation", () => { projectID: "project-1", sessionID: "ses_parent", title: "Completion wake after escalation", - config: { name: "f2b", nodes: [node("a", [], 300)] }, + config: { name: "f2b", result_protocol: "submit_result", nodes: [node("a", [], 300)] }, }) const gate = yield* takeWithin(childPrompts, "a did not start") const escalated = yield* pollWithTimeout( diff --git a/packages/opencode/test/dag/dag-wake-inflight-release.test.ts b/packages/opencode/test/dag/dag-wake-inflight-release.test.ts index a63755971..0e7f6df48 100644 --- a/packages/opencode/test/dag/dag-wake-inflight-release.test.ts +++ b/packages/opencode/test/dag/dag-wake-inflight-release.test.ts @@ -185,7 +185,7 @@ const scenario = (mode: "none" | "expire" | "defect") => projectID: "project-1", sessionID: "ses_parent", title: "First", - config: { name: "first", nodes: [node("a")] }, + config: { name: "first", result_protocol: "submit_result", nodes: [node("a")] }, }) const childA = yield* takeReal(childPrompts) expect(childA).toBeDefined() @@ -208,7 +208,7 @@ const scenario = (mode: "none" | "expire" | "defect") => projectID: "project-1", sessionID: "ses_parent", title: "Second", - config: { name: "second", nodes: [node("b")] }, + config: { name: "second", result_protocol: "submit_result", nodes: [node("b")] }, }) const childB = yield* takeReal(childPrompts) expect(childB).toBeDefined() diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 39d16b369..342cdb144 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -263,7 +263,17 @@ function runWakeTest( }).run().pipe(Effect.orDie) if (beforeInit) yield* beforeInit({ database }) yield* loop.init() - return yield* test({ dag, loop, store, status, childPrompts, parentPrompts, parentSettled, workflow }) + // This suite exercises the original parent wake contract. Its mocked + // sessions do not persist direct final-response receipts, so keep the + // workflow protocol explicit while preserving any case override. + const legacyDag: Dag.Interface = { + ...dag, + create: (input) => dag.create({ + ...input, + config: { ...input.config, result_protocol: input.config.result_protocol ?? "submit_result" }, + }), + } + return yield* test({ dag: legacyDag, loop, store, status, childPrompts, parentPrompts, parentSettled, workflow }) }).pipe( Effect.provide(wakeLayer({ childPrompts, parentPrompts, parentSettled, agentPermissions })), Effect.provideService(InstanceRef, { @@ -1607,7 +1617,7 @@ describe("DagLoop atomic wake integration", () => { projectID: "project-1", sessionID: "ses_parent", title: "Restart wake", - config: { name: "restart-wake", nodes: [node("restart-node")] }, + config: { name: "restart-wake", result_protocol: "submit_result", nodes: [node("restart-node")] }, }) const child = yield* takeWithin(q1.childPrompts, "restart node did not start") yield* Deferred.succeed(child.release, "done") diff --git a/packages/tui/test/cli/tui/dag-delivery-render.test.tsx b/packages/tui/test/cli/tui/dag-delivery-render.test.tsx new file mode 100644 index 000000000..81ba67871 --- /dev/null +++ b/packages/tui/test/cli/tui/dag-delivery-render.test.tsx @@ -0,0 +1,221 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender, useRenderer } from "@opentui/solid" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import type { AssistantMessage, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import { onCleanup } from "solid-js" +import { TuiConfigProvider } from "../../../src/config" +import { ArgsProvider } from "../../../src/context/args" +import { ClipboardProvider } from "../../../src/context/clipboard" +import { DataProvider } from "../../../src/context/data" +import { EditorContextProvider } from "../../../src/context/editor" +import { EpilogueProvider } from "../../../src/context/epilogue" +import { ExitProvider } from "../../../src/context/exit" +import { KVProvider } from "../../../src/context/kv" +import { LocalProvider } from "../../../src/context/local" +import { LocationProvider } from "../../../src/context/location" +import { ProjectProvider } from "../../../src/context/project" +import { PromptRefProvider } from "../../../src/context/prompt" +import { RouteProvider } from "../../../src/context/route" +import { SDKProvider } from "../../../src/context/sdk" +import { SyncProvider, useSync } from "../../../src/context/sync" +import { ThemeProvider } from "../../../src/context/theme" +import { registerOpencodeKeymap, OpencodeKeymapProvider } from "../../../src/keymap" +import { createPluginRuntime, PluginRuntimeProvider } from "../../../src/plugin/runtime" +import { FrecencyProvider } from "../../../src/prompt/frecency" +import { PromptHistoryProvider } from "../../../src/prompt/history" +import { PromptStashProvider } from "../../../src/prompt/stash" +import { Session } from "../../../src/routes/session" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { tmpdir } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" + +const sessionID = "ses_dag_delivery_render" +const requestID = "msg_dag_delivery_request" +const deliveryID = "msg_dag_delivery_generated" +const followupID = "msg_dag_delivery_followup" + +const session = { + id: sessionID, + title: "DAG delivery render", + slug: "dag-delivery-render", + projectID: "proj_test", + time: { created: 1, updated: 20 }, + version: "test", + directory, +} + +const request: UserMessage = { + id: requestID, + sessionID, + role: "user", + agent: "build", + model: { providerID: "opencode", modelID: "test-model" }, + time: { created: 1 }, +} + +const source: TextPart = { + id: "prt_dag_delivery_source", + messageID: deliveryID, + sessionID, + type: "text", + text: "DAG · Release workflow · final-report", + time: { start: 10, end: 10 }, + metadata: { dag_delivery: { kind: "source", workflow_id: "wf_release", node_id: "final-report" } }, +} + +const answer: TextPart = { + id: "prt_dag_delivery_answer", + messageID: deliveryID, + sessionID, + type: "text", + text: "DELIVERY BODY: the release report is ready.", + time: { start: 10, end: 10 }, + metadata: { dag_delivery: { kind: "answer", workflow_id: "wf_release", node_id: "final-report" } }, +} + +const delivery: AssistantMessage = { + id: deliveryID, + sessionID, + parentID: requestID, + role: "assistant", + agent: "build", + mode: "build", + modelID: "test-model", + providerID: "opencode", + path: { cwd: directory, root: directory }, + time: { created: 10, completed: 10 }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", +} + +const followup: UserMessage = { + id: followupID, + sessionID, + role: "user", + agent: "build", + model: { providerID: "opencode", modelID: "test-model" }, + time: { created: 20 }, +} + +async function waitForFrame(app: Awaited>, text: string) { + for (let attempt = 0; attempt < 100; attempt++) { + await app.renderOnce() + const frame = app.captureCharFrame() + if (frame.includes(text)) return frame + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${text}:\n${app.captureCharFrame()}`) +} + +test("renders both DAG delivery text parts once and keeps a following user turn visible", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const events = createEventSource() + const messages = [ + { + info: request, + parts: [{ id: "prt_request", sessionID, messageID: requestID, type: "text", text: "run workflow" }], + }, + { info: delivery, parts: [source, answer] }, + { + info: followup, + parts: [{ id: "prt_followup", sessionID, messageID: followupID, type: "text", text: "FOLLOWUP USER TURN" }], + }, + ] + const calls = createFetch((url) => { + if (url.pathname === "/session") return json([session]) + if (url.pathname === `/session/${sessionID}`) return json(session) + if (url.pathname === `/session/${sessionID}/message`) return json(messages) + if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === `/session/${sessionID}/goal`) return json(undefined) + return undefined + }, events) + let sync!: ReturnType + + function Probe() { + sync = useSync() + return + } + + const app = await testRender( + () => { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = createTuiResolvedConfig() + onCleanup(registerOpencodeKeymap(keymap, renderer, config)) + return ( + + {}}> + {}}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, + { width: 90, height: 28 }, + ) + + try { + await waitForFrame(app, "DELIVERY BODY") + const frame = await waitForFrame(app, "FOLLOWUP USER TURN") + expect(frame.match(/DAG · Release workflow · final-report/g)).toHaveLength(1) + expect(frame.match(/DELIVERY BODY/g)).toHaveLength(1) + expect(sync.data.message[sessionID]?.map((item) => item.id)).toEqual([requestID, deliveryID, followupID]) + expect(sync.data.part[deliveryID]?.map((part) => part.id)).toEqual([source.id, answer.id]) + } finally { + app.renderer.destroy() + } +}) From 1dd9854a6db30e9b7282e10868b63c9cdff5e896 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 03:35:22 +0800 Subject: [PATCH 02/11] docs: add GraphAgent 1.0.69 release notes --- .github/releases/v1.0.69.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/releases/v1.0.69.md diff --git a/.github/releases/v1.0.69.md b/.github/releases/v1.0.69.md new file mode 100644 index 000000000..5a9fcb2c4 --- /dev/null +++ b/.github/releases/v1.0.69.md @@ -0,0 +1,36 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. DAG workers now return their final answer directly and deliver the selected report once to the parent conversation. + +--- + +### 🐛 Bug Fixes + +- **Capture the worker's complete final answer**: New workflows use the persisted `final_response` protocol instead of requiring a separate `submit_result` report. The runtime collects all ordinary text parts of the final assistant answer, or validates one final JSON value when an output schema is present. Valid `null`, `false`, and zero results retain their original shape. Historical workflows without a protocol field continue using `submit_result` (#735). +- **Deliver the final report once**: An explicit delivery node or a unique terminal synthesis node supplies the report shown verbatim in the parent conversation. Durable receipts and wake markers commit together, preventing duplicate delivery after restart. Normal completed reports no longer start a parent model turn to repeat the answer; pause, failure, timeout, and clarification still use the existing decision path. Ambiguous outputs produce a deterministic result index (#735). +- **Wait for CLI report rendering before exit**: The CLI checks both the durable delivery receipt and the answer event consumed by the current process. Regressions cover multiple workflows, old idle events, equal-time message ordering, resumed sessions, and mixed report delivery with pending parent decisions (#735). + +--- + +### 🧪 Test Summary + +```text +DAG gate: core 153, runtime 909, schema 3, TUI 63 passed +DAG gate opt-in tests: 2 skipped; existing coverage floors passed +workspace typecheck: 31/31 tasks passed +lint: 4736 warnings, 0 errors (cap 4750) +installed CLI regression: 10 passed, 46 assertions +QWEN live acceptance: 1 passed, 39 assertions +GLM live acceptance: 1 passed, 39 assertions +DeepSeek live acceptance: 1 passed, 39 assertions +``` + +--- + +### 🔍 Verification + +ASTRA approved implementation commit `cff1423773e52b40b56c8bd53f4cae235c028c41` after reviewing the final CLI event-consumption fix. Local source and installed CLI regressions passed, as did app Chromium and TUI rendering checks. SDK generation had no drift. Each configured QWEN, GLM, and DeepSeek acceptance run used an isolated process and temporary database with schema and plain-text nodes: no `submit_result` calls, one parent assistant answer, and one answer receipt and source receipt, preserved after restart. These live checks used the local test build `0.0.0-dag-final-output.2`; they are separate from the stable release artifact verification. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) From 5afad3162a731a0fa13c5bb23cb57ee061b7fdf7 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 04:02:12 +0800 Subject: [PATCH 03/11] test(dag): sync final response workflow schema snapshot --- .../__snapshots__/parameters.test.ts.snap | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 6e0554eec..f3698cccd 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -1041,6 +1041,10 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` }, "type": "array", }, + "delivery_node": { + "description": "Final node whose answer is copied once into the main conversation. A unique terminal synthesize block is selected automatically; otherwise delivery is a result index.", + "type": "string", + }, "max_concurrency": { "description": "Max parallel nodes. Default: 5", "type": "number", @@ -1081,6 +1085,14 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "description": "Injected into every generated child prompt; required for blocks", "type": "string", }, + "result_protocol": { + "description": "Result capture protocol. New workflows default to final_response; submit_result is legacy compatibility.", + "enum": [ + "final_response", + "submit_result", + ], + "type": "string", + }, }, "required": [ "name", @@ -1091,6 +1103,10 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` }, { "properties": { + "delivery_node": { + "description": "Final node whose answer is copied once into the main conversation. A unique terminal synthesize block is selected automatically; otherwise delivery is a result index.", + "type": "string", + }, "max_concurrency": { "description": "Max parallel nodes. Default: 5", "type": "number", @@ -1162,7 +1178,7 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` "type": "string", }, "output_schema": { - "description": "JSON Schema; child agent must call submit_result to submit structured output", + "description": "JSON Schema for the child's final JSON answer (historical submit_result workflows keep their tool protocol)", "type": "object", }, "prompt_template": { @@ -1259,6 +1275,14 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` }, "type": "array", }, + "result_protocol": { + "description": "Result capture protocol. New workflows default to final_response; submit_result is legacy compatibility.", + "enum": [ + "final_response", + "submit_result", + ], + "type": "string", + }, }, "required": [ "name", From ea1b2cf326d8e42ddd70223aae80598819c448ad Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 04:45:01 +0800 Subject: [PATCH 04/11] fix: document hook capabilities and asyncRewake Audit the built-in hook skills, command prompts and session guidance against the runtime. Explain supported events, handlers, protocols and lifecycle limits, and teach background polling that resumes an idle conversation. Validate independent hook creation from a natural-language request with an isolated Qwen session, including idle-to-rewake continuation and cleanup. Both affected package typechecks and 59 focused tests passed. Refs #737 --- AGENTS.md | 3 +- packages/core/src/plugin/skill.ts | 2 +- .../core/src/plugin/skill/configure-hooks.md | 517 +++++++++++------- .../src/plugin/skill/customize-opencode.md | 50 +- packages/opencode/src/command/index.ts | 2 +- .../src/command/template/create-hook.txt | 87 ++- .../command/template/import-claude-hooks.txt | 41 +- packages/opencode/src/session/system.ts | 1 + packages/opencode/test/session/system.test.ts | 25 +- 9 files changed, 461 insertions(+), 267 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e913e689e..c259aa3cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Issue/PR writes require existing user authorization. Declarations and previews g ## SpecGit 2 -Runtime: 2.5.0. Declaration: `.specgit.yaml` (v2, local configuration). +Runtime: 2.6.1. Declaration: `.specgit.yaml` (v2, local configuration). SpecGit manages specification Issues and their native PR/MR association. Load the specgit-native skill for the full workflow and recovery steps. @@ -155,4 +155,5 @@ Hooks and observation state belong to this project and its Git metadata. Installed hooks check local checkpoints. They are not a general file-write sandbox. Declared rules: `{"agent":{"close_issues_after_merge":false,"native_auto_merge":false},"issue_template":"builtin","language":"en","pr_template":"builtin","validation":{"bodies":true,"labels":"off","titles":true}}` + diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 65c3c1e87..ab20cb822 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -18,7 +18,7 @@ export const CustomizeOpencodeDescription = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." export const ConfigureHooksDescription = - "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 26 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." + "Use when the user wants opencode to react to a tool or lifecycle event, run a background check, or poll for a result and resume the same idle conversation. Also use for questions about hooks, hooks.json, or migrating Claude Code hooks. Explains the 26 events, 5 handler types, matching, configuration scopes, trust, input/output protocols, synchronous decisions, async/asyncRewake continuation, and how to verify execution. Load this skill before creating or changing a lifecycle hook." export const CreateDagWorkflowDescription = "Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers project/global scopes, composable blocks, low-level nodes, and verification. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those." diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md index b8a61c66a..ca8f27dd4 100644 --- a/packages/core/src/plugin/skill/configure-hooks.md +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -1,229 +1,346 @@ # Configuring OpenCode hooks -Hooks let you run code (shell command, MCP tool, HTTP call, LLM prompt, or an -autonomous sub-agent) automatically when specific events happen during a -session — before/after a tool call, on session start, on compaction, etc. -Config lives in dedicated `hooks.json` files (NOT `opencode.json`, NOT -`.claude/settings.json` — `.claude/` is never read for hooks). +Hooks run a command, MCP tool, HTTP endpoint, prompt, or agent when an event +occurs. Configure them in `hooks.json`; `opencode.json` and Claude's settings +files are not hook sources. `/import-claude-hooks` migrates Claude settings. -## Where files live +## Files, merge, reload, and trust -| Scope | Path | Hot-reloaded? | -| -------- | --------------------------------- | --------------------------------- | -| Global | `~/.config/opencode/hooks.json` | Yes — polled every ~2s | -| Project | `.opencode/hooks.json` | Yes — polled every ~2s | -| Worktree | `/.opencode/hooks.json` | Yes (when worktree ≠ project dir) | +| Scope | File | Behavior | +| -------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Global | `${Global.Path.config}/hooks.json` | Shared across projects; follows the XDG config directory (commonly `$XDG_CONFIG_HOME/opencode/hooks.json`, usually `~/.config/opencode/hooks.json`) | +| Project | `.opencode/hooks.json` | Project configuration | +| Worktree | `/.opencode/hooks.json` | Loaded when the worktree directory differs from the project directory | -Layers concat-append (do NOT override by key): global hooks run, then project -hooks are appended after, in file order. A single event can have hooks from -multiple layers all firing. +Layers concatenate in global, project, then worktree order; entries are not +overrides. Entries inside each event retain file order. All three files are +watched and reload after a change (about 2 seconds polling plus debounce); a +restart is normally unnecessary. Invalid entries are logged and skipped while +valid siblings remain active. `settings.json` and `.claude/settings*.json` +hook fields are ignored. -If you find a `hooks` field left inside `settings.json` or `.claude/`, it is -ignored — point the user at `/import-claude-hooks` to migrate it. +Optional top-level trust controls: -## File format +- `requireTrust: true` enables the hook trust gate for the merged chain (also + enabled by `OPENCODE_HOOKS_REQUIRE_TRUST=1`). Hooks are skipped for an + untrusted working directory. +- `allowUntrusted: true` opts out of that gate only when declared in the global + hooks file. Project/worktree values are ignored; a repository cannot grant + itself trust. With neither setting, there is no trust gate. -Top-level keys are event names; each maps to a list of matcher blocks: +Example: ```json { "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] - } + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] } ], - "SessionStart": [ - { - "matcher": "*", - "hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }] - } - ] + "SessionStart": [{ "matcher": "startup", "hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }] }] } ``` -`matcher` selects which tool/target the block applies to: - -- `"*"` or omitted — matches everything -- `"Bash"` — exact match (case-insensitive) -- `"Bash|Edit|Write"` — pipe-separated list -- names using letters, digits or underscores — exact match (case-insensitive), optionally pipe-separated -- other strings — treated as a regex tested against the target - -## Events (26 total) - -Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure` -Permission: `PermissionRequest`, `PermissionDenied` -Session lifecycle: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure` -Subagents: `SubagentStart`, `SubagentStop` -Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact` -Tasks/goals: `TaskCreated`, `TaskCompleted` -MCP elicitation: `Elicitation`, `ElicitationResult` -Other: `Notification`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, -`InstructionsLoaded`, `CwdChanged`, `FileChanged` - -Removed event: `TeammateIdle` — no teammate concept exists in opencode, so it -could never fire. Entries naming it in hooks.json are silently skipped; delete -them. - -`Elicitation` / `ElicitationResult` fire when an MCP server issues -`elicitation/create`; the ask is mapped onto the Question UI, validated against -the requested schema, and answered accept/decline/cancel. `Notification` fires -for "agent needs attention" moments (permission asks, MCP elicitation asks) -through the internal Notification emitter; hook commands are the only external -effect today (OS/desktop delivery is a future change). A blocking `Elicitation` -hook deny short-circuits to `decline` without surfacing; unanswered elicitations -decline after 5 minutes (headless compositions decline immediately). - -If you need the exact input/output shape for a specific event, read -`packages/opencode/src/hook/settings.ts` (`HookEvent`, `HookSpecificOutput`) — -this skill is a map, not the full schema. - -## Hook types (all 5 implemented) - -| `type` | What it does | -| --------- | ---------------------------------------------------------------------------------------------- | -| `command` | Runs a shell command. Event data is piped to stdin as JSON; stdout/exit code drive the result. | -| `mcp` | Invokes an MCP tool, addressed as `mcp____`. | -| `http` | POSTs the event envelope to `url`; response body is parsed as JSON. | -| `prompt` | Sends the event to an LLM, constrained to structured JSON output. | -| `agent` | Runs an autonomous sub-agent loop (bash/read_file/list_dir/grep) to react to the event. | - -### `command` protocol - -- stdin: JSON envelope with event data -- exit code `0`: success, stdout optionally parsed as `HookJSONOutput` JSON -- exit code `2`: **block** — stderr becomes the block reason, shown to the agent -- any other exit code, or timeout: logged as a warning; stdout control fields are ignored -- `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` expand to the directory the - hook was declared in / its data dir — usable in `command` -- `options` (fork-only field, no CC equivalent): exported as - `CLAUDE_PLUGIN_OPTION_` env vars in the subprocess - -### `inputFormat` — Claude Code envelope naming (command hooks only) - -Command hooks accept an optional `inputFormat` field: - -- `"opencode"` (default): the stdin envelope carries native OpenCode naming — - lowercase tool ids (`bash`, `read`, `grep`, …) and native input keys - (`filePath`, `oldString`, `include`, …). -- `"claude-code"`: the envelope is translated toward Claude Code naming so an - unmodified CC hook script keeps working: - - `tool_name` is canonicalized case-insensitively for known builtins only: - `bash`→`Bash`, `read`→`Read`, `write`→`Write`, `edit`→`Edit`, - `grep`→`Grep`, `glob`→`Glob`. Names outside this six-name set, including - MCP and custom names, pass through unchanged. A custom tool shadowing one - of these names receives the same naming translation. - - `tool_input` keys are mapped: `filePath`→`file_path` (read/write/edit); - `oldString`/`newString`/`replaceAll`→`old_string`/`new_string`/`replace_all` - (edit); `include`→`glob` (grep). Original keys and unrelated fields are - preserved; where a Claude key is already present with a non-null value, - it wins and the native key is not written over it. - - On `PreToolUse`, for the same six known names, a - `hookSpecificOutput.updatedInput` returned by the hook is translated back - to native keys before the tool runs; explicit Claude keys win over - translated aliases. The rewrite direction also applies only to known - names: for tools outside the set, a returned `updatedInput` is passed - through unchanged, so such a hook must emit native keys (`filePath`, - `oldString`, `include`, …) to rewrite a call. - -This is naming compatibility only, not full tool semantic equivalence: hook -output handling and tool responses are unchanged, and behavioral differences -between Claude Code and OpenCode tools still surface. Verify a ported hook by -actually running it and checking what it receives on stdin, what it returns, -and any context it injects into the session — the hook listing in the Active -Hooks block, or exiting 0, does not prove its augmentation logic fired. - -Example — reusing a Claude Code edit-guard hook unchanged: +## Events, input, and matcher targets + +Every handler receives a JSON envelope with `hook_event_name`, `session_id`, +`transcript_path`, and `cwd`; `transcript_path` is currently an empty string. +When available it also has `permission_mode` (`plan` or `default`), +`agent_id`, and `agent_type`. Event-specific fields below use the actual +snake_case names in command stdin and HTTP request bodies. Optional values are +omitted unless noted. `inputFormat: "claude-code"` changes only the documented +tool names/argument keys for command hooks; see below. + +| Event | When it fires; event-specific fields | Matcher target | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| `PreToolUse` | Before a dispatched tool call; `tool_use_id?`, `tool_name`, `tool_input` | `tool_name` | +| `PostToolUse` | After a successful tool call; `tool_use_id?`, `tool_name`, `tool_input`, `tool_response` | `tool_name` | +| `PostToolUseFailure` | After a non-interruption tool failure (aborts do not emit this event); `tool_use_id?`, `tool_name`, `tool_input`, `error`, `is_interrupt?` (accepted, not currently populated) | `tool_name` | +| `PermissionRequest` | When a tool needs permission; `tool_use_id?`, `tool_name`, `tool_input`, `permission_suggestions?` | `tool_name` | +| `PermissionDenied` | When a tool call is denied; `tool_use_id?`, `tool_name`, `tool_input`, `reason` | `tool_name` | +| `Setup` | Once per instance bootstrap; `trigger` (`startup`), with empty `session_id` | `trigger` | +| `SessionStart` | For a new top-level session only; currently `source` is always `startup` (other values in the accepted type are not emitted), with `session_id` set | `source` | +| `SessionEnd` | On session deletion only; currently `reason` is always `delete` (clear/logout/CLI exit do not emit it) | `reason` | +| `Stop` | When the assistant finishes a turn cleanly; `stop_hook_active`, `last_assistant_message?` | none | +| `StopFailure` | When a turn ends with an error; `stop_hook_active`, `error`, `last_assistant_message?` | none | +| `SubagentStart` | When the task tool starts a subagent; `agent_id` (child ID), `agent_type`; `session_id` is the parent | `agent_type` | +| `SubagentStop` | After a foreground task-tool child finishes cleanly; `stop_hook_active`, `agent_id`, `agent_type`. `session_id` is the parent; `agent_id` identifies the child. `agent_transcript_path` is accepted but not populated. | `agent_type` | +| `UserPromptSubmit` | On prompt admission (async-hook reminders skip this event to prevent recursion); `prompt` | none | +| `PreCompact` | Before context compaction; `trigger` (`manual`/`auto`), `custom_instructions` (empty when absent) | `trigger` | +| `PostCompact` | After compaction; `trigger?`, `compact_summary?`, `custom_instructions?` | `trigger` | +| `TaskCreated` | When the task tool creates a task; `task_id`, `task_title`. `task_description` is accepted but not populated. | none | +| `TaskCompleted` | After the task tool child prompt succeeds; `task_id`, `task_title`, `result` | none | +| `Elicitation` | For supported MCP `elicitation/create` form requests with a valid form schema, session, and Question UI; `prompt`, `schema`. Unsupported modes/schemas or missing interaction/session context decline before this hook. | none | +| `ElicitationResult` | After that form flow resolves; `result` on accept or `cancelled` on decline/dismiss/timeout | none | +| `Notification` | For current internal permission-ask and MCP-elicitation attention notifications; `message`, `title?`, `notification_type?` | `notification_type` | +| `ConfigChange` | When a watched `hooks.json` layer reloads (not arbitrary configuration changes); `config_path`, empty `session_id` | none | +| `WorktreeCreate` | After a worktree boots successfully; `path?`, `branch?` | none | +| `WorktreeRemove` | Before registered-worktree cleanup, including a stale or missing path; it does not certify successful removal; `path?`, `branch?` | none | +| `InstructionsLoaded` | When instruction content is loaded; `path?`, `content?` | none | +| `CwdChanged` | When OpenCode switches into a newly booted worktree, not an arbitrary shell directory change; `old_cwd`, `new_cwd`, empty `session_id` | none | +| `FileChanged` | After successful internal `write`, `edit`, `apply_patch`, `multiedit`, or `patch` tool operations; `path` (absolute), `change_type` (`add`, `change`, `delete`). It is not an external filesystem watcher. | basename of `path` | + +`matcher` supports case-insensitive exact names, pipe-separated names, `*`, or +regex. Its target is event-specific: the five tool/permission events use the +tool name; `SessionStart`, `SessionEnd`, `Setup`, `PreCompact`/`PostCompact`, +`Notification`, `SubagentStart`/`SubagentStop`, and `FileChanged` use the +target shown above. For `FileChanged`, match a basename such as `package.json`, +not the full path; its envelope `path` is absolute, and a rename can report the +removed path and added destination separately. When the target is `none`, a +configured matcher is ignored and every group runs (including `Stop`); omit it +for clarity. An omitted +matcher or `"*"` matches all values of a supported target. + +`TeammateIdle` is unsupported and silently skipped. The event type schema +accepts some fields that current producers do not populate; those are marked +above so a hook should not wait for them. `InstructionsLoaded` fires only for +local instruction files, once per path per instance, with content truncated +to the first 32,768 JavaScript string code units; remote instruction URLs do +not fire it. Task and subagent events come from the task-tool workflow; background promotion does not mean the child +has stopped. `Notification` is currently emitted for permission asks and +supported MCP elicitation attention; it does not itself send an OS notification. + +## Hook types and configuration fields + +All five types are implemented. Each handler has a positive finite `timeout` +in seconds (default 60); timeout cancels the running process/request. `type` +is required. Provide the matching action: `command` for command and MCP, +`url` for HTTP, `prompt` for prompt and agent. For compatibility, HTTP also +accepts `command` as its URL and prompt/agent accept `command` as their text. + +| Field | Applies to | Meaning and limits | +| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | command, MCP; legacy action alias for HTTP/prompt/agent | Shell command, `mcp____` name, or legacy URL/prompt text. | +| `url` | HTTP | POST target. | +| `prompt` | prompt, agent | Prompt text or agent goal. | +| `headers` | HTTP | Request headers. | +| `allowedEnvVars` | HTTP | Expand `$NAME` / `${NAME}` in headers only for listed, set names. Unlisted or unset names become empty; when omitted, header values remain literal. | +| `shell` | command | `bash` or `powershell`; must be installed. Omitted uses `/bin/sh` on POSIX or `cmd.exe` on Windows. | +| `inputFormat` | command | `opencode` (default) or `claude-code`; naming adapter described below. | +| `statusMessage` | all | Logged before dispatch; does not show UI progress. | +| `once` | all | Run this loaded entry once per session, including async/concurrent triggers. Reload creates fresh entries. Dynamic session registrations also support group-level `once`, claimed only after conditions match. | +| `if` | all; effective only for tool/permission events | Condition such as `Bash(npm install *)` or `Edit(*.ts)`. Empty, `*`, and malformed conditions fail open. On non-tool events it is ignored. | +| `async` | all | Run in a background fiber. Its result cannot affect the current trigger's permission, block, or input decision. Work is process-local and is not persisted across restart. | +| `asyncRewake` | paired with `async` | Admit useful completion feedback into the originating session. It is inert without `async`; `SessionEnd` and missing session IDs cannot rewake. Details below. | +| `options` | command | Export each value as `CLAUDE_PLUGIN_OPTION_` containing its JSON serialization. | +| `__sourceDir` | runtime metadata | Injected by OpenCode; do not configure it. It identifies the declaring directory for root and data-path resolution. | + +Agent hooks have `read_file`, `list_dir`, `grep`, and restricted `bash` tools. +That `bash` executes validated installed utilities directly, without a shell; +interpreters, shell composition, and project-controlled PATH commands are +unavailable. On Windows use the file tools. + +| Type | Input / result protocol | Failure behavior | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `command` | JSON envelope on stdin; exit 0 parses JSON stdout; exit 2 blocks with stderr; plain-text exit-0 stdout is model context only for `UserPromptSubmit` / `SessionStart`. | Other nonzero exit, spawn failure, or timeout logs and allows the flow to continue. | +| `mcp` | Calls the named `mcp____` with the full envelope as tool arguments; parses the first text content item as JSON output. | Missing tool, no text/invalid JSON, MCP error, or timeout logs and allows the flow to continue. | +| `http` | POSTs the full envelope as JSON; parses a 2xx response body as JSON output. | Non-2xx blocks. Network failure, missing HTTP service, or timeout logs and allows the flow to continue. | +| `prompt` | Sends hook prompt as system message and envelope JSON as user input; requests structured hook output using OpenCode's configured default model, not the session model. | Missing provider/auth, unsupported OpenAI OAuth, generation/schema failure, or timeout logs and allows the flow to continue. | +| `agent` | Sends prompt and envelope to OpenCode's configured default model with read-only inspection tools and `synthetic_output` for the final hook result. Up to 200 model turns, but default timeout is 60 seconds. | Missing final output, missing services, OAuth, model failure, or timeout logs and allows the flow to continue. | + +### Command stdin and Claude Code naming + +Command stdin is the event envelope above. Exit code 0 is success; JSON stdout +is parsed as hook output. Exit code 2 blocks and stderr supplies the reason. +Other exit codes and timeouts are warnings; their stdout control fields are +ignored. Plain-text exit-0 stdout is injected as context only for +`UserPromptSubmit` and `SessionStart`. + +`inputFormat: "claude-code"` adapts command stdin and PreToolUse `updatedInput` +for six known built-ins only: `bash`/`read`/`write`/`edit`/`grep`/`glob` become +`Bash`/`Read`/`Write`/`Edit`/`Grep`/`Glob`; known argument keys such as +`filePath`→`file_path`, edit's `oldString`→`old_string`, and grep's +`include`→`glob` are translated. Other tools, including MCP/custom tools, pass +through unchanged; emit native keys when rewriting those tools. This is naming +compatibility, not full Claude Code behavior or output compatibility. + +`${CLAUDE_PLUGIN_ROOT}` expands to the directory containing the declaring +hooks file. `${CLAUDE_PLUGIN_DATA}` expands to a path under +`${Global.Path.data}/hooks/` with an identifier derived from that directory; +the script may need to create it. Both values are also exported as environment +variables; `CLAUDE_PROJECT_DIR` contains the hook execution directory (`cwd`). +Command hooks inherit the host process environment and run with its OS +permissions. `options` values are serialized into `CLAUDE_PLUGIN_OPTION_` +variables (keys uppercased, non-alphanumeric characters replaced with `_`). +`${user_config.}` interpolates an option directly into the command (strings unchanged, other values serialized as JSON). Unknown or +unresolved templates remain unchanged for the shell to handle. Quote paths +and option values appropriately for the chosen shell. + +## Hook output: accepted fields and actual effects + +Handlers may return a JSON object with these top-level fields: + +| Field | Runtime effect / limit | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `continue: false` | Stops prompt admission/continuation where the event consumer honors it; it takes precedence over a Stop hook's request to continue. | +| `stopReason` | Used as the reason when continuation is stopped. | +| `decision: "block"` | Requests a block with `reason` (or a generic reason), where the event caller honors it. `"approve"` does not grant tool permission; use `hookSpecificOutput.permissionDecision` on permission events. | +| `reason` | Block explanation for `decision: "block"`. | +| `systemMessage` | Logged, and delivered as session feedback by consumers that land hook system messages. | +| `suppressOutput` | Accepted for compatibility; no-op. Hook stdout is not shown directly in the UI. | +| `hookSpecificOutput` | Event-tagged object below. The runtime aggregates supported fields by property; the tag does not make an unsupported field effective. | + +Supported `hookSpecificOutput` fields: + +- `hookEventName`: conventional tag (for example `PreToolUse`); it does not + override the actual triggering event. +- `permissionDecision`: `allow`, `deny`, or `ask`; consumed by + `PreToolUse`/`PermissionRequest`. Most restrictive wins (`deny` > `ask` > + `allow`). `ask` always asks the user; `permissionDecisionReason` supplies + its explanation. On `PreToolUse`, `ask` shows the proposed input, including + any `updatedInput`. On a forced ask handled by `PermissionRequest`, `deny` + may reject the request; `allow` is ignored and logged. +- `updatedInput`: changes arguments before a `PreToolUse` tool runs. It cannot + change a tool after execution or change non-tool events. +- `additionalContext`: model-facing context where the triggering event's + consumer supports it. For post-tool events it is feedback after the action + and cannot undo that action; repeated context may be deduplicated. +- `permissionDecisionReason`: reason shown with a permission decision. + +The synchronous caller determines which aggregate results change control flow; +the same schema does not mean every event consumes every field: + +| Event(s) | What the caller actually consumes | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PreToolUse` | `blocked` or `permissionDecision: deny` denies; `ask` forces a user dialog where available (the task-tool path degrades it to deny); `allow` does not bypass the tool's normal permission checks. `updatedInput` shallow-merges into tool args before execution; `continue: false` stops the call/turn. `additionalContext` is added to tool feedback if execution continues. `systemMessage` is logged. | +| `PermissionRequest` | `blocked`, `continue: false`, or `permissionDecision: deny` rejects the ask; `allow` approves only a normal ask, not a hook-forced ask. `ask` has no extra effect because the user is already being asked. `systemMessage` is logged; `additionalContext` is not injected. | +| `PermissionDenied` | Fired after a permission-rule denial; only `systemMessage` is logged. Other output cannot reverse the denial or add model feedback. | +| `UserPromptSubmit` | `blocked` or `continue: false` stops the prompt before model admission. If admitted, `additionalContext` and `systemMessage` are added as synthetic model-visible parts; on a blocked prompt, `systemMessage` is logged only. | +| `SessionStart` | `additionalContext` is queued for the session's next prompt; `systemMessage` is logged. Current producer fires only for top-level startup, with empty transcript path. | +| `PostToolUse`, `PostToolUseFailure`, `FileChanged` | Supported context, system message, block reason, and stop reason are appended to the completed tool result/error as model feedback. They cannot undo a successful side effect. `PostToolUse` `continue: false` ends the turn after recording its result. | +| `PreCompact` | `blocked` cancels compaction. The caller records `systemMessage`; it ignores `additionalContext`. `continue: false` is not a compaction control. | +| `PostCompact` | `systemMessage` is logged. `additionalContext` and display/summary fields are not injected by this caller. | +| `Stop` | A clean-turn `blocked` result requests another model turn; `continue: false` prevents that continuation. Context and system messages are included in turn feedback. Continuation is capped at five. | +| `StopFailure` | Error-turn feedback records `additionalContext` and `systemMessage`; the synchronous block reason is not separately injected because an errored turn cannot continue. A completion callback may rewake the originating session. | +| `SubagentStop` | A foreground task-tool child is re-prompted when blocked, unless `continue: false`; at most five continuations. `session_id` is the parent session and `agent_id` is the child. `systemMessage` is logged; `additionalContext` is not separately injected. | +| `TaskCreated`, `TaskCompleted`, `SubagentStart` | The task-tool caller logs `systemMessage`; it ignores block, continuation, and context output. | +| `Elicitation` | A blocking result declines the supported form request before showing the Question UI. `systemMessage` is logged. | +| `ElicitationResult`, `Notification`, `Setup`, `SessionEnd`, `ConfigChange`, `CwdChanged`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded` | These callers do not use hook output to alter the surrounding operation. Whether `systemMessage` or context is landed varies by caller and is not guaranteed model feedback. | + +The schema also accepts `initialUserMessage`, `watchPaths`, +`updatedMCPToolOutput`, `displayMessage`, `compactSummary`, and `customSummary` +for compatibility, but OpenCode warns that they are unsupported and ignores +them. Use `additionalContext` for supported model feedback. Invalid JSON or +output shapes are logged and ignored. Prompt/agent hooks using OpenAI OAuth are +skipped with a warning; use a supported API-key provider. + +## Transient session hooks + +An HTTP client can register a hook for one existing session with `POST +/session/{sessionID}/hook`, list that session's registrations with `GET` on +the same path, and remove one by ID with `DELETE +/session/{sessionID}/hook/{hookID}`. A registration body has required `event` +and non-empty `hooks`, plus optional top-level `matcher` and `once`. Each hook +entry supports the normal handler fields, including optional `if` and its own +`once`; entry-level `once` removes the whole registration before its first +matching execution attempt, while hook-level `once` suppresses that handler +after its first execution attempt for the session. `if` is only evaluated on +tool/permission events. +These entries join the regular matcher and result-aggregation pipeline but are +held in process memory, scoped to the session and its OpenCode instance. They +are cleared after `SessionEnd` (currently deletion only), when the owning +instance directory is disposed, and on process restart. Keep the same host and +instance alive while relying on them. The **Active Hooks** file summary lists +the on-disk chain, not transient session registrations; use the session hook +list endpoint to inspect those. + +## Create and verify + +For a hook requested in ordinary conversation, load this skill, read the +relevant global/project/worktree files, and append to the existing event list +without overwriting sibling hooks. Choose the narrowest event and matcher; +use `if` only for a tool/permission event. Keep command stdout clean JSON when +returning hook controls (send diagnostics to stderr), and set a bounded timeout +with margin for expected completion. + +After the final write, wait at least the 2-second polling interval plus the +500-millisecond debounce before ending the first Stop turn or treating the +change as reloaded. The **Active Hooks** block confirms loaded file +configuration only. Verify execution with a controlled trigger and an +independent, safe signal such as a temporary marker or test endpoint response; +do not infer execution from the listing alone. + +### Async completion and `asyncRewake` + +Use `async: true` when a handler may take time and should not hold up the +current hook trigger. OpenCode requires both flags (`async: true` and +`asyncRewake: true`) for a completion callback; `asyncRewake` by itself is +inert. Claude Code also has an `asyncRewake` field, but its behavior differs: it +implicitly runs it asynchronously and wakes on exit code 2, while OpenCode +requires both flags and can wake on exit-0 JSON `systemMessage` or other output +listed below. `asyncRewake: true` then delivers useful output only +after that handler has completely exited/settled; it does not poll or wake the +model while the handler is still running. A simple bounded poll can run inside +the command, for example up to the default 60-second timeout, then exit with a +result. The callback is in memory: it is lost if OpenCode exits, and it is +discarded if a newer non-hook prompt or session end makes a pending Stop +callback stale. For task-tool `SubagentStop`, `session_id` is the parent and +`agent_id` is the stopped child; an async rewake targets the parent session and +does not resume that child. + +Completion feedback that can trigger a rewake is: exit code 2 stderr, +`systemMessage`, a `decision: "block"` with a `reason`, +`hookSpecificOutput.additionalContext`, or non-JSON plain text on exit 0 for +`UserPromptSubmit` / `SessionStart`. Permission decisions, `updatedInput`, +`stopReason`, and other fields do not by themselves trigger a rewake. The +completed output is wrapped in a synthetic system reminder and admitted into +the same session. In particular, valid JSON stdout with `systemMessage` on +exit 0 is completion feedback and rewakes. `SessionEnd` never rewakes, even if +output is present. ```json { - "PreToolUse": [ + "Stop": [ { - "matcher": "Edit", - "hooks": [{ "type": "command", "command": "./scripts/guard.py", "inputFormat": "claude-code" }] + "hooks": [ + { + "type": "command", + "command": "./scripts/poll-and-report.sh", + "timeout": 60, + "async": true, + "asyncRewake": true + } + ] } ] } ``` -`guard.py` sees `tool_name: "Edit"` and `tool_input.file_path` / -`old_string` / `new_string`; its permission-decision output is handled exactly -as in Claude Code. - -### Handler options and agent tools - -- `timeout`: positive seconds; cancellation reaches the running process, model request or MCP tool. -- `shell`: command hooks can explicitly select `bash` or `powershell`; that interpreter must be installed. Omit it for `/bin/sh` on POSIX or `cmd.exe` on Windows. -- `inputFormat`: command hooks only — `"opencode"` (default) or `"claude-code"` for Claude Code envelope naming; contract in the `inputFormat` section above. -- `allowedEnvVars`: when supplied, HTTP hooks expand `$NAME` and `${NAME}` in headers only for names in this list. Unlisted or unset variables expand to an empty string. When omitted, headers remain literal. -- `statusMessage`: recorded in the hook execution log before dispatch; it does not create a UI progress indicator. -- Command-level `once`: runs once per session for the current loaded configuration entry, including concurrent/async triggers. Reloading the file creates fresh entries. Session registration-level `once` atomically claims the whole matching group; unmatched conditions leave it available. -- Dynamic session registration supports the same command fields, including `options`. - -Agent hooks provide `read_file`, `list_dir`, `grep`, and a restricted `bash` tool. -The latter directly executes installed POSIX system utilities with validated arguments; -it does not invoke a shell. It supports common read-only options for file inspection, -`find` predicates and `git status/log/diff/show`. `sed` is limited to -`-n '[,]p' `. Interpreters such as `awk`, shell composition, -output-file options, external Git diff drivers and commands found through a -project-controlled PATH are unavailable. Use the three file tools on Windows. - -### Common output fields (`HookJSONOutput`, applies across types) - -```json -{ - "decision": "approve" | "block", - "reason": "shown when blocking", - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow" | "deny" | "ask", - "additionalContext": "text injected into the session" - } -} -``` - -`continue: false` stops prompt admission and wins over a Stop hook's request to -continue. Permission hooks can reject using `permissionDecision: "deny"`, a block -decision, or exit code 2. - -A PreToolUse `permissionDecision: "ask"` is a forced ask: the user always -confirms it, even after an earlier "always" approval, and the dialog shows the -input the tool will run with, including any `updatedInput`, plus the hook's -`permissionDecisionReason`. A PermissionRequest hook can deny a forced ask, but -its `allow` is ignored (logged). Once confirmed, the tool's own ask-level -permission checks for that call do not prompt again; deny rules still apply. -`opencode run --dangerously-skip-permissions` is an automatic mode and approves -forced asks once; without it, `run` rejects them. - -Post-tool block reasons and contexts are appended to -model-facing tool feedback after execution; they cannot undo a completed write. -`FileChanged` carries an absolute path and `add`, `change` or `delete`; patch -renames report both the removed path and the added destination. - -Some fields are retained only for schema compatibility. `initialUserMessage`, -`watchPaths`, `updatedMCPToolOutput`, `displayMessage`, `compactSummary` and -`customSummary` emit unsupported-output warnings and do not alter runtime state. -Use `additionalContext` for model feedback. `suppressOutput` remains a no-op: -hook stdout is not displayed directly in the UI. Prompt/agent hooks using OpenAI -OAuth are currently skipped with a warning; use a supported API-key provider. -Invalid output shapes are logged and ignored before aggregation. - -## Applying changes - -Global, project and worktree `hooks.json` files are polled every ~2 seconds, -with a 500 ms debounce. Changes take effect within a few seconds. Invalid -matcher/command entries are logged and skipped while valid siblings remain active. - -## Migrating from Claude Code - -`/import-claude-hooks` reads `~/.claude/settings.json` / `./.claude/settings.json` -/ `.claude/settings.local.json`, walks the user through importing each hook, -and writes approved ones into the right `hooks.json`. Point users here instead -of hand-copying Claude Code hook config. +The script may poll for readiness within its timeout. On completion, emit a +useful JSON result such as `{"systemMessage":"The background check is ready."}` +to stdout and exit 0. The model receives that message only after the script +exits; do not use `asyncRewake` as a progress signal. + +`Stop` is the clean end of an assistant turn; a blocking synchronous Stop hook +can request another turn. `StopFailure` is selected when the turn ended with +an error and does not drive that synchronous clean-turn continuation; an async +completion can still deliver useful feedback. Async Stop completions share a +maximum of five continuations in the hook chain. After a +rewake, subsequent Stop envelopes carry `stop_hook_active: true`; check this +flag and make the script idempotent or it can repeatedly block and consume +the full cap. `stop_hook_active` does not mean the session ended. `SessionEnd` +currently fires on session deletion only, clears session hook state, and +cannot deliver an async rewake. + +## Migration + +`/import-claude-hooks` reads Claude settings and offers each candidate for +review. Claude hook settings are not loaded directly. OpenCode's deprecated +`settings.json` hook fields are ignored and produce a warning. Preserve Claude +files and scripts unless the user explicitly asks to remove confirmed-unused +items. + +Imported command hooks default to `inputFormat: "claude-code"`; other hook +types receive no envelope translation, so their behavior needs manual review. +Claude Code also supports an `asyncRewake` field, but its semantics differ: +that field implies background execution and wakes on exit code 2. OpenCode +requires both `async: true` and `asyncRewake: true`, and its callback can wake +for additional result types. Inspect each source hook's intended behavior and +explicitly adapt it; do not blindly copy the field or assume its implicit +async behavior. Preserve ordinary source `async` only after checking that its +OpenCode handler type and timeout behavior are appropriate. Claude's +`TeammateIdle` has no OpenCode equivalent and is skipped. diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 74eeeb857..4db536ef1 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -40,7 +40,7 @@ already-loaded config until then. | Scope | Path | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | -| Global config | `~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc` (NOT `~/.opencode/`) | +| Global config | `~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc` (NOT `~/.opencode/`) | | Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | | Global agents | `~/.config/opencode/agent(s)/.md` | | Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | @@ -342,19 +342,41 @@ export default (async ({ client, project, directory, $ }) => { }) satisfies Plugin ``` -Hook surface (mutate `output` in place; return `void`): - -- `event(input)`: every bus event -- `config(cfg)`: once on init with the merged config -- `chat.message`, `chat.params`, `chat.headers` -- `tool.execute.before`, `tool.execute.after` -- `tool.definition` -- `command.execute.before` -- `shell.env` -- `permission.ask` -- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, - `experimental.session.compacting`, `experimental.compaction.autocontinue`, - `experimental.text.complete` +These are JavaScript plugin callbacks. For lifecycle handlers configured in +`hooks.json` (command, MCP, HTTP, prompt or agent), including background polling +that resumes an idle conversation, load the `configure-hooks` skill. Its event +names, output protocol and `async` / `asyncRewake` fields belong to that system; +they are not fields to add to the plugin callback object below. + +Legacy JavaScript plugin callbacks are awaited sequentially. Callbacks with an +`output` argument mutate that object in place and resolve to `void`; returning +a replacement object does not apply it. Their inputs use JavaScript field +names such as `sessionID`, unlike lifecycle command stdin. + +| Callback | Capability and output | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `event({ event })` | Observe published bus events; no decision output. | +| `config(cfg)` | Mutate the merged configuration during plugin initialization. | +| `dispose()` | Release plugin-owned resources when the plugin instance is disposed; this is not the end of every assistant turn. | +| `chat.message` | Receive session/agent/model/message identity and mutate `output.message` / `output.parts` for an incoming message. | +| `chat.params` | Adjust `temperature`, `topP`, `topK`, `maxOutputTokens`, and provider `options` before a model request. | +| `chat.headers` | Mutate `output.headers` for a model request. | +| `tool.execute.before` | Receive tool/session/call identity and mutate `output.args` before execution. | +| `tool.execute.after` | Receive identity and arguments, then mutate result `title`, `output`, and `metadata`. | +| `tool.definition` | Mutate a tool's model-facing `description` and `parameters`; preserve the object-rooted parameter schema. | +| `command.execute.before` | Mutate prompt `parts` in the LLM command-template path; directly dispatched commands can bypass this path. | +| `shell.env` | Receive `cwd` and optional session/call identity; add or change `output.env` when a shell environment is assembled. | +| `experimental.chat.messages.transform` | Mutate the model-bound `output.messages` array. | +| `experimental.chat.system.transform` | Receive model and optional session identity; mutate `output.system` strings. | +| `experimental.provider.small_model` | Provide `output.model` when small-model selection needs a fallback; a usable configured `small_model` takes precedence and skips this callback. | +| `experimental.session.compacting` | Add `output.context` or replace the compaction prompt with `output.prompt`. | +| `experimental.compaction.autocontinue` | Set `output.enabled` to control the synthetic continuation after successful compaction. | +| `experimental.text.complete` | Mutate `output.text` when an assistant text part completes. | + +`permission.ask` remains in the legacy TypeScript interface, but the current +runtime has no dispatch callsite. Do not use it to implement permission +interception; configure permission rules or the supported lifecycle +`PreToolUse` / `PermissionRequest` contracts instead. Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, `auth: { ... }`, `provider: { ... }`. diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index bc109df45..1b89031aa 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -132,7 +132,7 @@ export const layer = Layer.effect( } commands[Default.CREATE_HOOK] = { name: Default.CREATE_HOOK, - description: "Create a new hook interactively and write it to hooks.json", + description: "Create lifecycle hooks, including background polling that resumes an idle conversation", source: "command", template: PROMPT_CREATE_HOOK, subtask: true, diff --git a/packages/opencode/src/command/template/create-hook.txt b/packages/opencode/src/command/template/create-hook.txt index 064534c7d..3816cb7fd 100644 --- a/packages/opencode/src/command/template/create-hook.txt +++ b/packages/opencode/src/command/template/create-hook.txt @@ -12,9 +12,12 @@ Load the `configure-hooks` skill first. It defines the 26 events, 5 hook types, ## Process -### 1. Gather inputs interactively +### 1. Resolve the requested configuration -Ask the user for each field, one at a time: +Infer every field the user already specified. Ask only for missing information +that materially affects correct behavior; do not re-ask for supplied values or +write authorization already given. Use `configure-hooks` for the full field, +event-input, output-effect, trust, and async contracts. **Event** — choose from the `configure-hooks` skill's 26-event list. It names all 26 events: @@ -44,7 +47,11 @@ Ask the user for each field, one at a time: - `http` → the endpoint URL - `prompt` / `agent` → the prompt / goal text -**Matcher** (only for tool-bound events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied`): +**Matcher** — optional. The five tool/permission events match `tool_name`. +Other supported matcher targets are `SessionStart` → `source`, `SessionEnd` → +`reason`, `Setup` → `trigger`, `PreCompact` / `PostCompact` → `trigger`, +`Notification` → `notification_type`, `SubagentStart` / `SubagentStop` → +`agent_type`, and `FileChanged` → basename of `path`. - Empty / omitted / `*` — matches all tools - `Bash` — exact tool name @@ -52,18 +59,31 @@ Ask the user for each field, one at a time: - Names using letters, digits or underscores — exact match (case-insensitive), optionally pipe-separated - Other strings — treated as a regex -For non-tool events, omit the matcher entirely. +For events without a matcher target (including `Stop`, `StopFailure`, +`UserPromptSubmit`, task, elicitation, config, worktree, instruction, and cwd +events), any matcher is ignored and every group runs; omit it for clarity. -**Optional fields** — ask if the user wants to set any: +**Optional fields** — apply specified requirements and defaults; ask only when +a choice is necessary. Fields include `timeout` (positive seconds, default +60), `statusMessage` (logged label, not UI progress), `once` (once per loaded +entry/session), and `if` (tool/permission events only; ignored elsewhere). +Command hooks may set `shell` (`bash`/`powershell`) and `inputFormat` +(`opencode` by default, `claude-code` for an unchanged Claude Code stdin +script). HTTP hooks may set `headers` and `allowedEnvVars`; command hooks may +set `options`. -- `timeout` — seconds before the hook is killed (default 60) -- `statusMessage` — short label logged before the hook runs; it does not guarantee visible UI progress -- `inputFormat` (command hooks only) — `"opencode"` (default) or `"claude-code"`. Offer `"claude-code"` only for an unchanged script written for Claude Code's hook stdin. Such scripts expect `Bash`/`Read`/`Grep`-style tool names and `file_path`-style keys. See the `configure-hooks` skill for the contract. +For background work that should return completion feedback, set both +`async: true` and `asyncRewake: true`. The running handler must finish before +the same session can be rewoken; it is not an in-progress notification. Use a +finite timeout (default 60 seconds) and make Stop hooks honor +`stop_hook_active` and be idempotent. `asyncRewake` alone has no effect; see +`configure-hooks` for which outputs cause a rewake and its limits. -**Scope** — `project` or `global`: +**Scope** — `project`, `global`, or `worktree`: - `project` → `.opencode/hooks.json` in the current project (hot-reloads in ~2s) -- `global` → `~/.config/opencode/hooks.json` (hot-reloads in ~2s as well) +- `global` → `${Global.Path.config}/hooks.json` (hot-reloads in ~2s as well; follows the XDG config directory, commonly `$XDG_CONFIG_HOME/opencode/hooks.json` and usually `~/.config/opencode/hooks.json`) +- `worktree` → `/.opencode/hooks.json` when the worktree is separate ### 2. Build the JSON entry @@ -82,7 +102,7 @@ Build the entry in `hooks.json` format. Each event key maps to an array of match } ``` -For non-tool events, omit the `matcher` field: +For an event without a matcher target, omit the `matcher` field: ```json { @@ -96,20 +116,29 @@ For non-tool events, omit the `matcher` field: } ``` -### 3. Confirm with the user +If `requireTrust` / `allowUntrusted` matters, it is a top-level setting, not a +hook-entry field. Follow the trust rules in `configure-hooks`; project and +worktree files cannot opt themselves out of a trust gate. -Show the full JSON entry, including the event name, matcher block, and hook object. Ask for confirmation before writing. If the user requests changes, edit and show it again. +### 3. Confirm with the user when needed + +Show the exact JSON addition and destination before writing when the user has +not already authorized the write. If they clearly asked you to create and +write the hook, proceed without asking them to confirm that action again. If +they request changes, incorporate them and ask only about genuinely missing +information. ### 4. Write to hooks.json (merge-append) Read the target `hooks.json` first: - **Project scope**: `${PROJECT_DIR}/.opencode/hooks.json` -- **Global scope**: `~/.config/opencode/hooks.json` +- **Global scope**: `${Global.Path.config}/hooks.json` (resolve using the XDG config directory) +- **Worktree scope**: `${WORKTREE_DIR}/.opencode/hooks.json` (only when the active worktree differs from the project directory) Then: -1. If the file does not exist, create it (create the `.opencode/` directory for project scope if needed). +1. If the file does not exist, create it (create the relevant `.opencode/` directory for project or worktree scope if needed). 2. If the file exists, parse it and **merge-append** the new entry: - If the event key already exists, append the new matcher block to the existing array (do NOT replace existing entries). - If the event key does not exist, add it with the new matcher block as the only element. @@ -120,38 +149,46 @@ Never overwrite the entire file. Never delete existing hooks. ### 5. Verify the write -After writing, re-read the file and JSON-parse it to confirm validity: +After writing, re-read the file and JSON-parse it to confirm validity. If the +write produced invalid JSON, repair the file while preserving existing entries, +then re-read and parse again. Do not leave your own malformed write for the +user to repair manually. If pre-existing corruption prevents a safe merge, +describe the exact issue and preserve the existing content. - If the parse succeeds, report success. -- If the parse fails, report the error and ask the user to check the file manually. +- If the parse fails after repair, do not report success; explain what pre-existing or external corruption prevented a safe repair. ### 6. Report hot-reload semantics - **Project scope**: "The hook will hot-reload within ~2 seconds — it will be active on the next session turn." -- **Global scope**: "The global hooks.json is polled every ~2 seconds too — the hook becomes active shortly after the write, no restart needed." +- **Global scope**: "The global hooks.json is polled every ~2 seconds too — the hook becomes active shortly after the write, no restart needed." Resolve it from `${Global.Path.config}`, which follows the XDG config directory. +- **Worktree scope**: the worktree file is polled too and becomes active shortly after the write. -Point the user at the **Active Hooks** block in the system prompt (visible on the next turn for project-scoped hooks) to confirm the hook is live. +After the final write, allow at least the 2-second poll interval plus the +500-millisecond debounce before ending this command flow. Then, when available, +inspect the **Active Hooks** block to confirm the configuration was loaded. +That block does not prove the hook's behavior works; trigger it and inspect its +input/output when behavior verification is requested. ## Example session ``` User: /create-hook -Agent: Which event should this hook fire on? (see the configure-hooks skill for all 26 events) +Agent: Which event should this hook fire on? (asked only because it was not specified) User: PreToolUse Agent: What type of hook? command / mcp / http / prompt / agent User: command Agent: What shell command should run? User: ./scripts/check.sh -Agent: Matcher (which tools should trigger this)? Empty = all tools. +Agent: Which tools should trigger this? (asked only if relevant and not specified) User: Bash -Agent: Project or global scope? (project hot-reloads in ~2s; global hot-reloads in ~2s too) +Agent: Project or global scope? (asked only because scope was not specified) User: project Here is the entry I will write to .opencode/hooks.json: { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./scripts/check.sh" }] }] } -Proceed? (y/n) -User: y +The write proceeds under the user's existing authorization. ✓ Written to .opencode/hooks.json (merge-appended alongside existing hooks). -The hook will hot-reload within ~2 seconds — it will be active on the next session turn. +It will hot-reload shortly; the Active Hooks block confirms loaded configuration, not that behavior has been verified. ``` diff --git a/packages/opencode/src/command/template/import-claude-hooks.txt b/packages/opencode/src/command/template/import-claude-hooks.txt index 0a60521a7..d1c907345 100644 --- a/packages/opencode/src/command/template/import-claude-hooks.txt +++ b/packages/opencode/src/command/template/import-claude-hooks.txt @@ -6,6 +6,10 @@ description: Import hooks from Claude Code config to OpenCode hooks.json Migrate hooks from Claude Code's `.claude/settings.json` files to OpenCode's `hooks.json` format. Hooks are not loaded directly from `.claude/settings*.json`; use this command to migrate them. OpenCode can still load `.claude/skills` by default unless that compatibility loading is disabled. +Load `configure-hooks` first for the current event, handler, input/output, and +compatibility contracts. Infer approval already given by the user; present +unapproved candidates for review without re-asking about approved ones. + ## Process ### 1. Scan for Claude hooks @@ -49,11 +53,19 @@ Ask the user for each hook: ### 3. Handle path migration -When importing, fix any hardcoded `.claude/` paths: +Resolve each script and its dependencies before rewriting paths. Writing a +hook definition does not copy a Claude plugin or make an OpenCode destination +exist. Keep a working source path, or copy the required assets into the chosen +OpenCode location under the user's migration authorization and verify them. -- Replace `${CLAUDE_PLUGIN_ROOT}` references that assume `.claude/` with `.opencode/` -- Replace absolute paths like `~/.claude/plugins/...` with `~/.config/opencode/plugins/...` -- Update `__sourceDir` stamps from `.claude/` to `.opencode/` +`${CLAUDE_PLUGIN_ROOT}` is the directory containing the declaring hooks file. +`${CLAUDE_PLUGIN_DATA}` is a data path under `${Global.Path.data}/hooks/`, with +an identifier derived from that directory; it is not inside the source folder. +The script may need to create its data directory. Preserve the placeholder +syntax; verify script paths and dependency assets before rewriting them. +Do not author `__sourceDir`: OpenCode stamps that metadata when loading the +file. Never blindly replace `.claude/` with `.opencode/` and assume the script +has moved. Example migration: ```json @@ -79,7 +91,7 @@ Example migration: ] } ``` -`${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder. OpenCode expands it from the hook's location. After migration to `.opencode/`, it points there. +`${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder. OpenCode expands it from the hook's location. After migration to `.opencode/`, it points there; this example therefore requires `validate.sh` and its dependencies to exist there. #### Command-envelope compatibility (`inputFormat`) @@ -95,7 +107,7 @@ Other hook types receive no translation. For each imported `mcp` / `http` / `pro Approved hooks go to: -- **Global scope** (from `~/.claude/settings.json`): `~/.config/opencode/hooks.json` +- **Global scope** (from `~/.claude/settings.json`): `${Global.Path.config}/hooks.json` (the XDG config directory, commonly `$XDG_CONFIG_HOME/opencode`, usually `~/.config/opencode`) - **Project scope** (from `.claude/settings.json` or `.claude/settings.local.json`): `.opencode/hooks.json` Use top-level event keys. Do not wrap them in `{"hooks": {...}}`. Each event maps to matcher blocks with an optional `matcher` and a `hooks` array: @@ -126,14 +138,14 @@ If the target file already exists, merge (append) the imported hooks rather than After migration, report: -- Number of hooks imported (by scope: global/project) +- Number of hooks imported (by destination scope: global/project) - Number of hooks skipped - Any hooks that were edited during migration - Number of command hooks tagged `inputFormat: "claude-code"` - For every imported non-command hook: a warning that stdin-envelope compatibility was not verified and needs manual evaluation - Deprecation warnings (if hooks were found in .opencode/settings.json) -- Keep `.claude/settings*.json`, scripts, and skills in place during migration. After a successful migration and validation, you may remove specific confirmed-unused Claude hook configuration if you no longer need it. Do not recommend deleting the entire `.claude/` directory. -- Imported hooks appear in the system prompt's **Active Hooks** block. It renders from live `hooks.json` state each turn, so no `AGENTS.md` update is needed. Visibility does not prove behavior. Trigger each hook and confirm its stdin and output before calling migration done. +- Keep `.claude/settings*.json`, scripts, and skills in place during migration. Remove specific confirmed-unused Claude hook configuration only when the user explicitly requests removal. Do not recommend deleting the entire `.claude/` directory. +- After the final write, wait at least the 2-second poll interval plus the 500-millisecond debounce before checking reload. The system prompt's **Active Hooks** block confirms loaded configuration only; it does not prove behavior. Trigger each hook and inspect its input/output before calling migration done when behavior validation is requested. ## Hooks.json Format Reference @@ -154,7 +166,7 @@ Note: Claude Code's `TeammateIdle` event is not supported (no teammate concept i Each event maps to an array of **matcher blocks**. Each matcher block has: -- `matcher`: optional — `"*"` (default, all tools), exact name (`"Bash"`), pipe list (`"Bash|Edit"`), or regex +- `matcher`: optional — `"*"`, exact name, pipe list, or regex. It matches `tool_name` on tool/permission events and supported lifecycle targets (`SessionStart.source`, `SessionEnd.reason`, `Setup`/compaction `trigger`, `Notification.notification_type`, subagent `agent_type`, or `FileChanged` path basename). On events with no target, including `Stop`, a matcher is ignored and the group runs regardless; omit it. - `hooks`: array of hook entries Each hook entry has: @@ -163,6 +175,14 @@ Each hook entry has: - `command` / `url` / `prompt`: the hook action (field depends on type — `command` for command/mcp, `url` for http, `prompt` for prompt/agent) - `timeout`: optional seconds (default 60) - `inputFormat`: optional, command hooks only — `"opencode"` (default) or `"claude-code"`. The latter maps envelope `tool_name`/`tool_input` toward Claude Code naming; see `configure-hooks` for the contract. +- `async`: optional background execution flag. Claude Code has an analogous command-hook field; preserve it only after checking the source hook type and intended behavior against OpenCode's handler and timeout semantics. +- `asyncRewake`: Claude Code also has this field, but its semantics differ. In Claude Code it implies asynchronous execution and wakes on exit code 2. OpenCode requires both `async: true` and `asyncRewake: true`; its completion callback can wake for exit-0 JSON `systemMessage` and other documented results. Do not blindly copy the field or assume implicit async behavior. During migration, inspect and explicitly adapt each source hook. If adding it during an edit, explain OpenCode's same-session, Stop-loop, and timeout limits in `configure-hooks`. + +Other supported OpenCode fields (such as `shell`, `statusMessage`, `once`, +`if`, HTTP `headers` / `allowedEnvVars`, command `options`, and top-level +`requireTrust` / `allowUntrusted`) are not implied by migration. See +`configure-hooks` before adding them; respect their type, scope, and trust +limits. Merge semantics: concat-append (hooks accumulate across layers and within a file, not replace). @@ -172,3 +192,4 @@ Merge semantics: concat-append (hooks accumulate across layers and within a file - The `hooks` field in `.opencode/settings.json` is deprecated and ignored (deprecation warning logged). - All three `hooks.json` layers (global, project, worktree) are polled every ~2 seconds; edits take effect within a few seconds without a restart. - Hooks are merge-append, not override. Importing duplicate events will add to the list. +- For every field and event's actual stdin fields, matcher targets, output effects, and `asyncRewake` limits, use the self-contained `configure-hooks` skill. Do not assume that accepted schema fields have the same behavior in OpenCode as in Claude Code. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 27836a21b..93f0268e1 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -172,6 +172,7 @@ export const layer = Layer.effect( const shown = hooks.slice(0, MAX) const lines = [ "## Active Hooks", + "For lifecycle-hook configuration, output protocols and async continuation, load the configure-hooks skill.", ...shown.map((h) => `- ${h.event} [${h.scope}/${h.type}] ${h.descriptor}`), ] if (hooks.length > MAX) lines.push(`… and ${hooks.length - MAX} more (see hooks.json)`) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 66bf6f88f..b996e2b75 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -152,15 +152,14 @@ describe("session.system", () => { { event: "PreToolUse", scope: "project", type: "command", descriptor: "echo hi", matcher: "Bash" }, { event: "Stop", scope: "global", type: "http", descriptor: "https://example.com/stop" }, ] - const output = yield* prompt.hooks().pipe( - Effect.provide( - Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), - ), - ) + const output = yield* prompt + .hooks() + .pipe(Effect.provide(Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }))) expect(output).toEqual([ [ "## Active Hooks", + "For lifecycle-hook configuration, output protocols and async continuation, load the configure-hooks skill.", "- PreToolUse [project/command] echo hi", "- Stop [global/http] https://example.com/stop", ].join("\n"), @@ -171,11 +170,9 @@ describe("session.system", () => { it.effect("hooks block is empty when SettingsHook list is empty", () => Effect.gen(function* () { const prompt = yield* SystemPrompt.Service - const output = yield* prompt.hooks().pipe( - Effect.provide( - Layer.mock(SettingsHook.Service, { list: () => Effect.succeed([]) }), - ), - ) + const output = yield* prompt + .hooks() + .pipe(Effect.provide(Layer.mock(SettingsHook.Service, { list: () => Effect.succeed([]) }))) expect(output).toEqual([]) }), @@ -200,11 +197,9 @@ describe("session.system", () => { type: "command" as const, descriptor: `cmd-${i}`, })) - const output = yield* prompt.hooks().pipe( - Effect.provide( - Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), - ), - ) + const output = yield* prompt + .hooks() + .pipe(Effect.provide(Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }))) const block = output[0] expect(block).toContain("## Active Hooks") From 7e8b871570c9cc222e30db3ed69df4b12aadc9b1 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 05:12:45 +0800 Subject: [PATCH 05/11] fix(dag): preserve pending input and commit report delivery atomically --- .github/releases/v1.0.69.md | 33 +- packages/core/src/dag/messages.ts | 15 +- packages/opencode/src/cli/cmd/run/dag-hold.ts | 66 +- packages/opencode/src/dag/dag.ts | 31 +- packages/opencode/src/dag/runtime/loop.ts | 1 + packages/opencode/src/dag/runtime/recovery.ts | 31 +- .../src/dag/runtime/report-delivery.ts | 285 +++++---- packages/opencode/src/dag/runtime/spawn.ts | 10 +- .../opencode/test/cli/run/dag-hold.test.ts | 123 ++++ .../test/dag/dag-delivery-mutation.test.ts | 213 +++++++ .../test/dag/dag-final-delivery.test.ts | 582 +++++++++++++----- .../dag-final-response-stale-failure.test.ts | 313 ++++++++++ 12 files changed, 1401 insertions(+), 302 deletions(-) create mode 100644 packages/opencode/test/dag/dag-delivery-mutation.test.ts create mode 100644 packages/opencode/test/dag/dag-final-response-stale-failure.test.ts diff --git a/.github/releases/v1.0.69.md b/.github/releases/v1.0.69.md index 5a9fcb2c4..f84b7cfcc 100644 --- a/.github/releases/v1.0.69.md +++ b/.github/releases/v1.0.69.md @@ -1,6 +1,6 @@ ## opencode {VERSION} -{Prerelease/Stable} release from `{branch}` branch. DAG workers now return their final answer directly and deliver the selected report once to the parent conversation. +{Prerelease/Stable} release from `{branch}` branch. DAG workers deliver complete final answers once, while hook guidance now explains how async hooks can resume an idle session. --- @@ -9,27 +9,40 @@ - **Capture the worker's complete final answer**: New workflows use the persisted `final_response` protocol instead of requiring a separate `submit_result` report. The runtime collects all ordinary text parts of the final assistant answer, or validates one final JSON value when an output schema is present. Valid `null`, `false`, and zero results retain their original shape. Historical workflows without a protocol field continue using `submit_result` (#735). - **Deliver the final report once**: An explicit delivery node or a unique terminal synthesis node supplies the report shown verbatim in the parent conversation. Durable receipts and wake markers commit together, preventing duplicate delivery after restart. Normal completed reports no longer start a parent model turn to repeat the answer; pause, failure, timeout, and clarification still use the existing decision path. Ambiguous outputs produce a deterministic result index (#735). - **Wait for CLI report rendering before exit**: The CLI checks both the durable delivery receipt and the answer event consumed by the current process. Regressions cover multiple workflows, old idle events, equal-time message ordering, resumed sessions, and mixed report delivery with pending parent decisions (#735). +- **Retry CLI receipt baselines without masking network errors**: Transient failures while reading the initial receipt state now retry within a bound. A network error is no longer treated as proof that a receipt is absent, so the CLI cannot start from an unknown delivery baseline (#736). +- **Commit EventV2 reports, receipts, and wakes together**: Report events, durable receipts, and wake markers are persisted in the same transaction; listeners are notified only after the transaction commits (#736). +- **Continue after stale final-response validation**: When validation of a saved `final_response` fails after new parent input arrives, the run becomes a continuation instead of a terminal failure (#736). +- **Settle pending user input only with its complete answer**: A real pending user message now requires its corresponding successful, complete assistant answer before delivery can be considered complete (#736). +- **Preserve structured JSON results**: Final JSON output retains its complete structured value through validation and delivery, including values used by downstream conditions and mappings (#736). +- **Invalidate stale extend and replan selections**: Extend/replan invalidates selections when their chosen point is no longer valid, and recovery remaps the selection to the current workflow state (#736). +- **Explain async hook continuation in built-in guidance**: Eight hook guidance and prompt files now describe `asyncRewake`, completion output, timeout and lifecycle constraints, one-shot behavior, and anti-loop handling. This is a guidance update; the asyncRewake runtime mechanism is unchanged (#737). --- ### 🧪 Test Summary ```text -DAG gate: core 153, runtime 909, schema 3, TUI 63 passed -DAG gate opt-in tests: 2 skipped; existing coverage floors passed -workspace typecheck: 31/31 tasks passed -lint: 4736 warnings, 0 errors (cap 4750) -installed CLI regression: 10 passed, 46 assertions -QWEN live acceptance: 1 passed, 39 assertions -GLM live acceptance: 1 passed, 39 assertions -DeepSeek live acceptance: 1 passed, 39 assertions +DAG final delivery regressions: 19 passed, 73 assertions +CLI receipt regressions: 18 passed, 81 assertions +DAG mutation and stale-input tests: 16 passed, 49 assertions (2 test files) +Expanded DAG regression set: 101 passed, 451 assertions +Core mailbox regressions: 30 passed, 296 assertions +Hook focused regressions: 59 passed (core 10, opencode 49) +Package typechecks: core and opencode passed +Full DAG gate: core 153, runtime 933, schema 3, TUI 63 passed +DAG gate opt-in tests: 2 skipped; all coverage floors passed +Workspace typecheck: 31/31 tasks passed +Lint: 4739 warnings, 0 errors (cap 4750) +App Chromium / TUI report rendering: 1 passed each ``` --- ### 🔍 Verification -ASTRA approved implementation commit `cff1423773e52b40b56c8bd53f4cae235c028c41` after reviewing the final CLI event-consumption fix. Local source and installed CLI regressions passed, as did app Chromium and TUI rendering checks. SDK generation had no drift. Each configured QWEN, GLM, and DeepSeek acceptance run used an isolated process and temporary database with schema and plain-text nodes: no `submit_result` calls, one parent assistant answer, and one answer receipt and source receipt, preserved after restart. These live checks used the local test build `0.0.0-dag-final-output.2`; they are separate from the stable release artifact verification. +The DAG delivery, CLI receipt, stale-input and mutation, expanded regression, and core mailbox results above were observed on the current source changes. Both affected package typechecks passed. The full DAG gate passed with existing coverage floors intact, and workspace typechecking, CLI process regressions, app Chromium and TUI report rendering passed. ASTRA reviewed the fixes before push. + +Hook self-authoring acceptance ran in the hook source worktree before this integration. An isolated Qwen session received one natural-language user prompt and loaded the built-in hook skill; the test driver registered no hooks, and the model authored the hook configuration and script. After 2.280080 seconds observably idle, a fresh nonce resumed the same session and appeared in its completed reply; no repeat was observed over the next 6 seconds. This was source-worktree acceptance; the installed binary was not replaced. The GLM self-authoring attempt timed out before reaching idle and is not counted as a pass. --- diff --git a/packages/core/src/dag/messages.ts b/packages/core/src/dag/messages.ts index 052504174..6093db5eb 100644 --- a/packages/core/src/dag/messages.ts +++ b/packages/core/src/dag/messages.ts @@ -81,7 +81,9 @@ export interface Guard { close?: boolean /** Conditional failure applies only to this accepted revision, checked in the settlement transaction. */ expectedAcceptedRevision?: number - /** Failure/cancellation outranks pending input unless expectedAcceptedRevision makes it conditional. */ + /** Response-derived failure must still own its original associated input snapshot. */ + requireCurrentInput?: boolean + /** Failure/cancellation outranks pending input unless explicitly made conditional. */ failureReason?: string } export interface Metadata { @@ -673,10 +675,15 @@ export const layer = Layer.effect( sql`SELECT * FROM agent_input_snapshot WHERE id = ${input.snapshotID} AND mailbox_id = ${box.id}`, ) : undefined - if (!input.failureReason && box.revision > 0 && (!frozen || frozen.revision !== box.revision)) + const checkSnapshot = !input.failureReason || input.requireCurrentInput === true + if ( + checkSnapshot && + ((!frozen && (box.revision > 0 || input.snapshotID !== undefined)) || + (frozen && frozen.revision !== box.revision)) + ) return reject("stale_input") - if (!input.failureReason && frozen && !frozen.associated) return reject("unassociated") - if (!input.failureReason && frozen?.stop_reason && frozen.stop_reason !== "budget_exhausted") + if (checkSnapshot && frozen && !frozen.associated) return reject("unassociated") + if (checkSnapshot && frozen?.stop_reason && frozen.stop_reason !== "budget_exhausted") return reject("stopped") const claimed = yield* tx.get( sql`UPDATE agent_mailbox SET closed_reason = ${input.close === false ? null : (input.failureReason ?? "completed")} WHERE id = ${box.id} AND revision = ${box.revision} AND closed_reason IS NULL RETURNING id`, diff --git a/packages/opencode/src/cli/cmd/run/dag-hold.ts b/packages/opencode/src/cli/cmd/run/dag-hold.ts index bbff91c5b..5b4837fb1 100644 --- a/packages/opencode/src/cli/cmd/run/dag-hold.ts +++ b/packages/opencode/src/cli/cmd/run/dag-hold.ts @@ -55,6 +55,8 @@ export type DagWorkflowSnapshot = { readonly status: string /** The completion episode still lacks its parent answer receipt. */ readonly awaitingReport?: boolean + /** The receipt state could not be established and must be retried at startup. */ + readonly receiptUnknown?: boolean } export type DagHoldDecision = "hold" | "break" @@ -183,6 +185,17 @@ function answerWasEmitted(part: Parameters[0]): string | unde return answerKey(part.sessionID, part.messageID, part.id, delivery.workflow_id, delivery.completion_seq) } +function isNotFoundError(error: unknown): boolean { + return ( + isRecord(error) && error.name === "NotFoundError" && isRecord(error.data) && typeof error.data.message === "string" + ) +} + +function responseStatus(response: unknown): number | undefined { + if (!isRecord(response) || typeof response.status !== "number") return undefined + return response.status +} + /** Poll implementation over the in-process SDK client (GET /dag/session/:id — * session-scoped, project-ownership enforced server-side). A completed final * response remains pending until its exact answer receipt is visible. */ @@ -191,21 +204,22 @@ export async function dagWorkflowsBySession( sessionID: string, verifiedReceipts: Set = new Set(), observation?: ReportObservation, + signal?: AbortSignal, ): Promise { - const response = await client.dag.bySession({ sessionID }) + const response = await client.dag.bySession({ sessionID }, signal ? { signal } : undefined) if (response.error) return undefined return Promise.all( (response.data ?? []).map(async (workflow): Promise => { const basic = { id: workflow.id, status: workflow.status } if (workflow.status !== "completed" || !finalResponseConfig(workflow.config)) return basic if (typeof workflow.seq !== "number" || !Number.isSafeInteger(workflow.seq)) - return { ...basic, awaitingReport: true } + return { ...basic, awaitingReport: true, receiptUnknown: true } const seq = workflow.seq if (typeof workflow.time_updated !== "number" || !Number.isFinite(workflow.time_updated)) - return { ...basic, awaitingReport: true } + return { ...basic, awaitingReport: true, receiptUnknown: true } const completedAt = workflow.completed_at if (completedAt !== undefined && (typeof completedAt !== "number" || !Number.isFinite(completedAt))) - return { ...basic, awaitingReport: true } + return { ...basic, awaitingReport: true, receiptUnknown: true } const identity = reportIdentity({ sessionId: sessionID, id: workflow.id, @@ -220,7 +234,10 @@ export async function dagWorkflowsBySession( !observation.emitted.has(answerKey(sessionID, identity.messageID, identity.answerID, workflow.id, seq)) if (verifiedReceipts.has(episode)) return { ...basic, awaitingReport: awaitingEmission() } try { - const receipt = await client.session.message({ sessionID, messageID: identity.messageID }) + const receipt = await client.session.message( + { sessionID, messageID: identity.messageID }, + signal ? { signal } : undefined, + ) const found = !receipt.error && receipt.data?.info.id === identity.messageID && @@ -235,9 +252,10 @@ export async function dagWorkflowsBySession( ) }) if (found) verifiedReceipts.add(episode) - return { ...basic, awaitingReport: !found || awaitingEmission() } + const missing = isNotFoundError(receipt.error) || responseStatus(receipt.response) === 404 + return { ...basic, awaitingReport: !found || awaitingEmission(), receiptUnknown: !!receipt.error && !missing } } catch { - return { ...basic, awaitingReport: true } + return { ...basic, awaitingReport: true, receiptUnknown: true } } }), ) @@ -250,6 +268,37 @@ export function makeDagWorkflowPoll(client: OpencodeClient, sessionID: string): return () => dagWorkflowsBySession(client, sessionID, verifiedReceipts) } +const STARTUP_BASELINE_ATTEMPTS = 5 +const STARTUP_BASELINE_TIMEOUT_MS = 3_000 +const STARTUP_BASELINE_RETRY_DELAYS_MS = [100, 200, 400, 800] as const + +async function startupBaseline(client: OpencodeClient, sessionID: string): Promise> { + let lastFailure: unknown + for (let attempt = 0; attempt < STARTUP_BASELINE_ATTEMPTS; attempt++) { + const verifiedReceipts = new Set() + try { + const snapshot = await dagWorkflowsBySession( + client, + sessionID, + verifiedReceipts, + undefined, + AbortSignal.timeout(STARTUP_BASELINE_TIMEOUT_MS), + ) + if (snapshot && !snapshot.some((workflow) => workflow.receiptUnknown)) return verifiedReceipts + lastFailure = new Error( + snapshot ? "receipt state is temporarily unknown" : "workflow list is temporarily unavailable", + ) + } catch (error) { + lastFailure = error + } + const delay = STARTUP_BASELINE_RETRY_DELAYS_MS[attempt] + if (delay !== undefined) await new Promise((resolve) => setTimeout(resolve, delay)) + } + throw new Error(`Unable to establish the DAG startup baseline after ${STARTUP_BASELINE_ATTEMPTS} attempts`, { + cause: lastFailure, + }) +} + /** Subscribe first, then await this baseline before starting the prompt. A * historic answer already present at startup need not be replayed by the * current stream; every later completion must both persist and be emitted. */ @@ -258,8 +307,7 @@ export async function createDagRunHold( sessionID: string, requestedMessageID?: string, ): Promise { - const verifiedReceipts = new Set() - await dagWorkflowsBySession(client, sessionID, verifiedReceipts).catch(() => undefined) + const verifiedReceipts = await startupBaseline(client, sessionID) const baseline = new Set(verifiedReceipts) const emitted = new Set() const poll = () => dagWorkflowsBySession(client, sessionID, verifiedReceipts, { baseline, emitted }) diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index 80d775fde..5cc708cb3 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -292,7 +292,23 @@ export function computeMergedConfig( .filter((n) => !cancelSet.has(n.id)) .map((n) => (restartSet.has(n.id) || replaceSet.has(n.id) ? (fragmentById.get(n.id) ?? n) : n)) const added = plan.add.map((id) => fragmentById.get(id)).filter((n): n is NodeConfig => n !== undefined) - return { ...current, nodes: [...surviving, ...added] } + return reconcileDeliveryNode({ ...current, nodes: [...surviving, ...added] }, current) +} + +/** A prior final report stops being final when a mutation adds work after it. + * Existing explicit selections of non-leaf nodes retain their accepted meaning. + */ +function reconcileDeliveryNode(config: WorkflowConfig, previous: WorkflowConfig): WorkflowConfig { + const selected = config.delivery_node + const previouslyLeaf = !previous.nodes.some((node) => node.depends_on.includes(previous.delivery_node ?? "")) + if ( + selected === undefined || + (config.nodes.some((node) => node.id === selected) && + (!previouslyLeaf || !config.nodes.some((node) => node.depends_on.includes(selected)))) + ) + return config + const { delivery_node: _deliveryNode, ...rest } = config + return rest } const parseJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) @@ -1150,6 +1166,12 @@ export const layer = Layer.effect( ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) + const selected = config.delivery_node + const replacement = plan.replacements.find((node) => node.previous === selected) + plan.config = reconcileDeliveryNode( + { ...plan.config, ...(replacement ? { delivery_node: replacement.current } : {}) }, + config, + ) const addedIDs = new Set(plan.replacements.map((replacement) => replacement.current)) const added = plan.config.nodes.filter((node) => addedIDs.has(node.id)) const diagnostics = DagValidation.replanStructuralDiagnostics({ @@ -1372,12 +1394,17 @@ export const layer = Layer.effect( nodeID, attemptID: DagMessages.nodeAttemptID(node.childSessionId, node.replanAttempts), failureReason: trigger, + snapshotID: attempt?.inputSnapshotID, + requireCurrentInput: + trigger === "verdict_fail" && + (attempt?.inputSnapshotID !== undefined || + parseWorkflowConfig(workflow.config)?.result_protocol === "final_response"), expectedAcceptedRevision: attempt?.expectedAcceptedRevision, }, commit, ) if (!guarded.ok) { - if (guarded.reason === "stale_input") + if (guarded.reason === "stale_input" || guarded.reason === "unassociated") yield* Effect.fail(new StaleMessageInputError({ dagID, nodeID, reason: guarded.reason })) yield* Effect.fail(new TerminalViolationError(nodeID, node.status, "failed")) } diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 1a2fb5381..7b5fa02b3 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -1807,6 +1807,7 @@ const serviceLayer = Layer.effect( batch: reportBatch, store, sessions: sessionSvc, + events, directory: ctx.directory, source, }).pipe( diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index 5e7f0a9c0..0a390b300 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -198,6 +198,10 @@ export function reconcileWorkflow( ? yield* messages.value.snapshotForTurn(caller, message.info.id) : undefined const snapshotID = frozen?.ok ? frozen.value?.id : undefined + if (message && Option.isSome(messages) && caller && !snapshotID) { + continuations.add(node.id) + continue + } let captured: FinalCaptureResult if ( message && @@ -241,7 +245,10 @@ export function reconcileWorkflow( ...attempt, inputSnapshotID: captured.ok ? captured.snapshotID : undefined, }) - : dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", attempt), + : dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", { + ...attempt, + inputSnapshotID: snapshotID, + }), ) if (completed) reconciled++ } else if (nodeConfig?.output_schema) { @@ -266,7 +273,7 @@ export function reconcileWorkflow( ? { projectID: workflow.projectId, directory: workflow.directory, sessionID: node.childSessionId } : undefined const frozen = - final?.ok && Option.isSome(messages) && caller && finalMessage + Option.isSome(messages) && caller && finalMessage ? yield* messages.value.snapshotForTurn(caller, finalMessage.info.id) : undefined const snapshotID = frozen?.ok ? frozen.value?.id : undefined @@ -357,7 +364,15 @@ export function reconcileWorkflow( : undefined, ) if (settlement.kind === "fail") { - if (yield* settle(node.id, dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", attempt))) + if ( + yield* settle( + node.id, + dag.nodeFailed(dagID, node.id, settlement.reason, "verdict_fail", { + ...attempt, + ...(workflowConfig?.result_protocol === "final_response" ? { inputSnapshotID: snapshotID } : {}), + }), + ) + ) reconciled++ } else { const rawText = settlement.output @@ -616,7 +631,10 @@ export function continueRecoveredMessageNode( ? recoveredSettlement(nodeConfig, all, captured.output, true) : ({ kind: "fail", reason: captured.reason } as const) if (settlement.kind === "fail") - return yield* dag.nodeFailed(dagID, nodeID, settlement.reason, "verdict_fail", attempt) + return yield* dag.nodeFailed(dagID, nodeID, settlement.reason, "verdict_fail", { + ...attempt, + inputSnapshotID: snapshotID, + }) return yield* dag.nodeCompleted(dagID, nodeID, settlement.output, { ...attempt, inputSnapshotID: snapshotID, @@ -640,7 +658,10 @@ export function continueRecoveredMessageNode( const final = finalAssistantText(result, true) const settlement = settlePlainTextOutput(final.ok ? final.text : undefined) if (settlement.kind === "fail") - return yield* dag.nodeFailed(dagID, nodeID, settlement.reason, "verdict_fail", attempt) + return yield* dag.nodeFailed(dagID, nodeID, settlement.reason, "verdict_fail", { + ...attempt, + ...(config?.result_protocol === "final_response" ? { inputSnapshotID: snapshotID } : {}), + }) const ref = yield* commitOutputFileRef( settlement.output, { diff --git a/packages/opencode/src/dag/runtime/report-delivery.ts b/packages/opencode/src/dag/runtime/report-delivery.ts index 6ce807b45..42b35037b 100644 --- a/packages/opencode/src/dag/runtime/report-delivery.ts +++ b/packages/opencode/src/dag/runtime/report-delivery.ts @@ -7,7 +7,8 @@ import path from "node:path" import { randomUUID } from "node:crypto" import { Global } from "@opencode-ai/core/global" import type { DagStore } from "@opencode-ai/core/dag/store" -import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { EventV2 } from "@opencode-ai/core/event" import { Database } from "@opencode-ai/core/database/database" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" @@ -28,8 +29,10 @@ import { reportIdentity } from "../report-identity" export { reportIdentity } from "../report-identity" const INLINE_REPORT_BYTES = 64 * 1024 +const ANCHOR_TEXT = "Automatically delivered DAG result. Agent-supplied content; this is not human authorization." -const newestFirst = (a: SessionV1.WithParts, b: SessionV1.WithParts) => before(a.info, b.info) ? 1 : before(b.info, a.info) ? -1 : 0 +const newestFirst = (a: SessionV1.WithParts, b: SessionV1.WithParts) => + before(a.info, b.info) ? 1 : before(b.info, a.info) ? -1 : 0 /** No guessed required node or agent name: ambiguous graphs get a stable index. */ export function selectReportNode(config: WorkflowConfig, nodes: readonly DagStore.NodeRow[]) { @@ -50,23 +53,10 @@ export function resultIndex(workflow: DagStore.WorkflowRow, nodes: readonly DagS ].join("\n") } -function reportText( - node: DagStore.NodeRow, - config: WorkflowConfig, - source?: SessionV1.WithParts, - artifact?: ManagedOutputFileRef, -) { +function reportText(node: DagStore.NodeRow, source?: SessionV1.WithParts, artifact?: ManagedOutputFileRef) { if (artifact) return `${artifact.summary}\n\n[Complete report](${artifact.path})` if (typeof node.output === "string") return node.output if (isManagedOutputFileRef(node.output)) return `${node.output.summary}\n\n[Complete report](${node.output.path})` - const schema = config.nodes.find((item) => item.id === node.id)?.output_schema - const properties = isRecord(schema?.properties) ? schema.properties : undefined - if (isRecord(node.output)) { - const value = node.output - for (const field of ["report", "summary"]) { - if (properties?.[field] && typeof value[field] === "string") return value[field] - } - } const final = finalAssistantText(source) if (final.ok) { const parsed = parseFinalResponse(final.text) @@ -75,6 +65,36 @@ function reportText( return JSON.stringify(node.output) ?? "(no output)" } +function latestUser(history: readonly SessionV1.WithParts[]) { + return history + .filter( + (item) => + item.info.role === "user" && + !item.parts.some((part) => part.type === "text" && part.synthetic && part.text === ANCHOR_TEXT), + ) + .toSorted(newestFirst)[0] +} + +function userTurnAnswered(history: readonly SessionV1.WithParts[]) { + const user = latestUser(history) + if (!user) return true + const assistant = history + .filter( + (item) => + item.info.role === "assistant" && + !item.parts.some((part) => part.type === "text" && isRecord(part.metadata?.dag_delivery)), + ) + .toSorted(newestFirst)[0] + return ( + assistant?.info.role === "assistant" && + assistant.info.parentID === user.info.id && + !before(assistant.info, user.info) && + assistant.info.finish === "stop" && + assistant.info.error === undefined && + assistant.info.time.completed !== undefined + ) +} + /** Large generated text uses the existing integrity-checked artifact storage. */ function boundReport(text: string, workflow: DagStore.WorkflowRow, node?: DagStore.NodeRow) { if (Buffer.byteLength(text) <= INLINE_REPORT_BYTES) @@ -105,6 +125,7 @@ export function deliverReport(input: { batch: DagStore.WakeBatch store: DagStore.Interface sessions: Session.Interface + events: EventV2.Interface directory: string source?: SessionV1.WithParts }): Effect.Effect { @@ -138,7 +159,7 @@ export function deliverReport(input: { artifact = node.capturedOutput } const body = yield* boundReport( - node ? reportText(node, config, input.source, artifact) : resultIndex(workflow, nodes), + node ? reportText(node, input.source, artifact) : resultIndex(workflow, nodes), workflow, node, ).pipe(Effect.orDie) @@ -152,114 +173,126 @@ export function deliverReport(input: { ...((artifact ?? body.artifact) ? { artifact: artifact ?? body.artifact } : {}), } const database = yield* Database.Service - return yield* database.db - .transaction( - () => - Effect.gen(function* () { - // A reopen/replan or deletion while building the report invalidates this delivery. - const current = yield* store.getWorkflow(workflow.id) - if ( - !current || - current.status !== "completed" || - current.seq !== workflow.seq || - current.graphRev !== workflow.graphRev - ) - return false - const history = yield* sessions.messages({ sessionID, limit: 20 }).pipe(Effect.orDie) - const user = history - .filter((item) => item.info.role === "user") - .toSorted(newestFirst)[0] - const assistant = history - .filter((item) => item.info.role === "assistant") - .toSorted(newestFirst)[0] - // The caller holds withIdle's admission lock: a copied stop message must - // never consume a waiting user turn or attach an older report to a new request. - if (user && (!assistant || before(assistant.info, user.info))) return false - const receipt = yield* sessions.getPart({ - sessionID, - messageID: identity.messageID, - partID: identity.answerID, - }) - if (receipt) { - if ( - receipt.type !== "text" || - receipt.text !== body.text || - receipt.metadata?.dag_delivery?.completion_seq !== workflow.seq - ) - return yield* Effect.die(new Error("DAG final report receipt does not match its completion episode")) - } else { - const session = yield* sessions.get(sessionID).pipe(Effect.orDie) - const time = workflow.completedAt ?? workflow.timeUpdated - const source = input.source?.info.role === "assistant" ? input.source.info : undefined - const parentUser = user?.info.role === "user" ? user.info : undefined - const originUser = history - .filter((item) => item.info.role === "user" && item.info.time.created <= workflow.timeCreated) - .toSorted(newestFirst)[0] - const providerID = - source?.providerID ?? - parentUser?.model.providerID ?? - session.model?.providerID ?? - Provider.ID.make("dag") - const modelID = - source?.modelID ?? parentUser?.model.modelID ?? session.model?.id ?? Model.ID.make("result") - const agent = session.agent ?? source?.agent ?? "build" - if (!originUser) { - yield* sessions.updateMessage({ - id: identity.anchorID, - sessionID, - role: "user", - time: { created: Math.max(0, time - 1) }, - agent, - model: { providerID, modelID }, - }) - yield* sessions.updatePart({ - id: PartID.make(`${identity.sourceID}_anchor`), - messageID: identity.anchorID, - sessionID, - type: "text", - synthetic: true, - text: "Automatically delivered DAG result. Agent-supplied content; this is not human authorization.", - }) - } - yield* sessions.updateMessage({ - id: identity.messageID, - sessionID, - role: "assistant", - parentID: originUser?.info.id ?? identity.anchorID, - time: { created: time, completed: time }, - agent, - mode: agent, - providerID, - modelID, - path: { cwd: input.directory, root: input.directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - finish: "stop", - }) - yield* sessions.updatePart({ - id: identity.sourceID, - messageID: identity.messageID, - sessionID, - type: "text", - text: `DAG · ${workflow.title.slice(0, 200)}${node ? ` · ${node.name.slice(0, 200)}` : " · result index"}`, - time: { start: time, end: time }, - metadata: { dag_delivery: { ...metadata, kind: "source" } }, - }) - yield* sessions.updatePart({ - id: identity.answerID, - messageID: identity.messageID, - sessionID, - type: "text", - text: body.text, - time: { start: time, end: time }, - metadata: { dag_delivery: { ...metadata, kind: "answer" } }, - }) - } - yield* store.markWakeBatchReported(input.batch) - return true - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) + const getReceipt = () => sessions.getPart({ sessionID, messageID: identity.messageID, partID: identity.answerID }) + const receiptMatches = (receipt: SessionV1.Part) => + receipt.type === "text" && + receipt.text === body.text && + receipt.metadata?.dag_delivery?.completion_seq === workflow.seq + const currentEpisode = Effect.gen(function* () { + const current = yield* store.getWorkflow(workflow.id) + return current?.status === "completed" && current.seq === workflow.seq && current.graphRev === workflow.graphRev + }).pipe(Effect.orDie) + const receipt = yield* getReceipt() + if (receipt) { + if (!receiptMatches(receipt)) + return yield* Effect.die(new Error("DAG final report receipt does not match its completion episode")) + // Adopting a committed receipt appends no transcript and cannot consume a pending user turn. + return yield* database.db + .transaction( + () => + Effect.gen(function* () { + if (!(yield* currentEpisode)) return false + const current = yield* getReceipt() + if (!current || !receiptMatches(current)) return false + yield* store.markWakeBatchReported(input.batch) + return true + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + } + const history = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) + if (!userTurnAnswered(history)) return false + const user = latestUser(history) + const entries: EventV2.BatchEvent[] = [] + const appendMessage = (info: SessionV1.Info) => { + entries.push({ definition: SessionV1.Event.MessageUpdated, data: { sessionID, info } }) + } + const appendPart = (part: SessionV1.Part) => { + entries.push({ + definition: SessionV1.Event.PartUpdated, + data: { sessionID, part: structuredClone(part), time: Date.now() }, + }) + } + const session = yield* sessions.get(sessionID).pipe(Effect.orDie) + const time = workflow.completedAt ?? workflow.timeUpdated + const source = input.source?.info.role === "assistant" ? input.source.info : undefined + const parentUser = user?.info.role === "user" ? user.info : undefined + const originUser = history + .filter((item) => item.info.role === "user" && item.info.time.created <= workflow.timeCreated) + .toSorted(newestFirst)[0] + const providerID = + source?.providerID ?? parentUser?.model.providerID ?? session.model?.providerID ?? Provider.ID.make("dag") + const modelID = source?.modelID ?? parentUser?.model.modelID ?? session.model?.id ?? Model.ID.make("result") + const agent = session.agent ?? source?.agent ?? "build" + if (!originUser) { + appendMessage({ + id: identity.anchorID, + sessionID, + role: "user", + time: { created: Math.max(0, time - 1) }, + agent, + model: { providerID, modelID }, + }) + appendPart({ + id: PartID.make(`${identity.sourceID}_anchor`), + messageID: identity.anchorID, + sessionID, + type: "text", + synthetic: true, + text: ANCHOR_TEXT, + }) + } + appendMessage({ + id: identity.messageID, + sessionID, + role: "assistant", + parentID: originUser?.info.id ?? identity.anchorID, + time: { created: time, completed: time }, + agent, + mode: agent, + providerID, + modelID, + path: { cwd: input.directory, root: input.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }) + appendPart({ + id: identity.sourceID, + messageID: identity.messageID, + sessionID, + type: "text", + text: `DAG · ${workflow.title.slice(0, 200)}${node ? ` · ${node.name.slice(0, 200)}` : " · result index"}`, + time: { start: time, end: time }, + metadata: { dag_delivery: { ...metadata, kind: "source" } }, + }) + appendPart({ + id: identity.answerID, + messageID: identity.messageID, + sessionID, + type: "text", + text: body.text, + time: { start: time, end: time }, + metadata: { dag_delivery: { ...metadata, kind: "answer" } }, + }) + // The final local commit hook joins the transcript projections and wake marks in the + // top-level event batch transaction. Notifications are emitted only after it commits. + entries[entries.length - 1] = { + ...entries[entries.length - 1], + options: { commit: () => store.markWakeBatchReported(input.batch).pipe(Effect.orDie) }, + } + const declined = new Error("DAG report admission changed") + const validate = Effect.gen(function* () { + if (!(yield* currentEpisode) || (yield* getReceipt())) return yield* Effect.die(declined) + const currentHistory = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) + if (latestUser(currentHistory)?.info.id !== user?.info.id || !userTurnAnswered(currentHistory)) + return yield* Effect.die(declined) + }) + return yield* input.events.publishMany(entries, { validate }).pipe( + Effect.uninterruptible, + Effect.as(true), + Effect.catchDefect((defect) => (defect === declined ? Effect.succeed(false) : Effect.die(defect))), + ) }) } diff --git a/packages/opencode/src/dag/runtime/spawn.ts b/packages/opencode/src/dag/runtime/spawn.ts index 0db8dc0a4..81a591cfd 100644 --- a/packages/opencode/src/dag/runtime/spawn.ts +++ b/packages/opencode/src/dag/runtime/spawn.ts @@ -704,7 +704,10 @@ export function spawnNode( ...settlementAttempt, inputSnapshotID: snapshotID, }) - : dag.nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail", settlementAttempt) + : dag.nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail", { + ...settlementAttempt, + inputSnapshotID: snapshotID, + }) ).pipe( Effect.catchIf(isTransitionRejection, () => Effect.logWarning("final-response settlement guard rejected — node already terminal"), @@ -789,7 +792,10 @@ export function spawnNode( const settlement = settlePlainTextOutput(final.ok ? final.text : undefined) if (settlement.kind === "fail") { yield* dag - .nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail", settlementAttempt) + .nodeFailed(input.dagID, input.nodeID, settlement.reason, "verdict_fail", { + ...settlementAttempt, + ...(input.resultProtocol === "final_response" ? { inputSnapshotID: snapshotID } : {}), + }) .pipe( Effect.catchIf(isTransitionRejection, () => Effect.logWarning("nodeFailed (empty output) guard rejected — node already terminal"), diff --git a/packages/opencode/test/cli/run/dag-hold.test.ts b/packages/opencode/test/cli/run/dag-hold.test.ts index 77057ec48..6421439dc 100644 --- a/packages/opencode/test/cli/run/dag-hold.test.ts +++ b/packages/opencode/test/cli/run/dag-hold.test.ts @@ -398,6 +398,129 @@ describe("final-response answer receipt polling", () => { expect(reads).toBe(3) }) + test("startup retries a failed workflow list before freezing the receipt baseline", async () => { + const old = completed("old-list", 13) + let listReads = 0 + const client = { + dag: { + bySession: async () => { + listReads++ + if (listReads === 1) return { error: { name: "InternalServerError", data: { message: "temporary" } } } + return { data: [old] } + }, + }, + session: { message: async () => answer(old) }, + } as unknown as OpencodeClient + + const hold = await DagHold.createDagRunHold(client, "parent") + expect(listReads).toBe(2) + await hold.onBusy() + expect(await hold.onIdle()).toBe("break") + }) + + test("startup retries an unknown receipt read and does not require the historical answer event", async () => { + const old = completed("old-receipt", 14) + let listReads = 0 + let receiptReads = 0 + const client = { + dag: { + bySession: async () => { + listReads++ + return { data: [old] } + }, + }, + session: { + message: async () => { + receiptReads++ + if (receiptReads === 1) throw new Error("temporary receipt read failure") + return answer(old) + }, + }, + } as unknown as OpencodeClient + + const hold = await DagHold.createDagRunHold(client, "parent") + expect(listReads).toBe(2) + expect(receiptReads).toBe(2) + await hold.onBusy() + expect(await hold.onIdle()).toBe("break") + }) + + for (const missing of [ + { + name: "NotFoundError", + response: () => ({ + error: { name: "NotFoundError", data: { message: "Message not found" } }, + response: { status: 404 }, + }), + }, + { + name: "successful lookup without a matching answer part", + response: (row: Row) => ({ data: { info: { id: identity(row).messageID }, parts: [] } }), + }, + ]) { + test(`startup accepts a confirmed missing receipt from ${missing.name} and still requires a later emission`, async () => { + const old = completed("old-missing", 15) + const next = completed("new-after-missing", 16) + let row = old + const reads: string[] = [] + const client = { + dag: { bySession: async () => ({ data: [row] }) }, + session: { + message: async ({ messageID }: { messageID: string }) => { + reads.push(messageID) + return messageID === identity(old).messageID ? missing.response(old) : answer(next) + }, + }, + } as unknown as OpencodeClient + + const hold = await DagHold.createDagRunHold(client, "parent") + expect(reads).toEqual([identity(old).messageID]) + row = next + await hold.onBusy() + expect(await hold.onIdle()).toBe("hold") + hold.onText(answer(next).data.parts[0]) + expect(await hold.onIdle()).toBe("break") + expect(reads).toEqual([identity(old).messageID, identity(next).messageID]) + }) + } + + test("startup fails clearly after bounded retries when the workflow list stays unavailable", async () => { + let listReads = 0 + const client = { + dag: { + bySession: async () => { + listReads++ + return { error: { name: "InternalServerError", data: { message: "still unavailable" } } } + }, + }, + session: { message: async () => ({ data: {} }) }, + } as unknown as OpencodeClient + + await expect(DagHold.createDagRunHold(client, "parent")).rejects.toThrow( + "Unable to establish the DAG startup baseline after 5 attempts", + ) + expect(listReads).toBe(5) + }) + + test("startup fails clearly after bounded retries when a receipt state stays unknown", async () => { + const old = completed("unknown-receipt", 17) + let receiptReads = 0 + const client = { + dag: { bySession: async () => ({ data: [old] }) }, + session: { + message: async () => { + receiptReads++ + throw new Error("receipt endpoint unavailable") + }, + }, + } as unknown as OpencodeClient + + await expect(DagHold.createDagRunHold(client, "parent")).rejects.toThrow( + "Unable to establish the DAG startup baseline after 5 attempts", + ) + expect(receiptReads).toBe(5) + }) + test("an idle poll cannot exit before an already-persisted second answer is emitted", async () => { const old = completed("old", 20) const next = completed("next", 21) diff --git a/packages/opencode/test/dag/dag-delivery-mutation.test.ts b/packages/opencode/test/dag/dag-delivery-mutation.test.ts new file mode 100644 index 000000000..da83113ac --- /dev/null +++ b/packages/opencode/test/dag/dag-delivery-mutation.test.ts @@ -0,0 +1,213 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- Narrow session mocks exercise actual graph mutation and report selection with an isolated database. +import { describe, expect, test } from "bun:test" +import { eq, sql } from "drizzle-orm" +import { DateTime, Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { MessageTable, PartTable } from "@opencode-ai/core/session/sql" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { DagEvent } from "@opencode-ai/schema/dag-event" +import { Dag, computeMergedConfig, parseWorkflowConfig, type NodeConfig, type WorkflowConfig } from "@/dag/dag" +import { deliverReport, selectReportNode } from "@/dag/runtime/report-delivery" +import { EventV2Bridge } from "@/event-v2-bridge" +import type { Session } from "@/session/session" +import { testEffect } from "../lib/effect" + +const workflowID = "dag_delivery_mutation" +function node(id: string, depends_on: string[] = []): NodeConfig { + return { id, name: id, worker_type: "build", depends_on, prompt_template: { inline: "work" }, report_to_parent: true } +} +const config = (nodes: NodeConfig[], delivery_node = "old"): WorkflowConfig => ({ + name: "mutation", + result_protocol: "final_response", + delivery_node, + nodes, +}) +const plan = { cancel: [], restart: [], replace: [], add: [] } + +describe("delivery selection after graph mutation", () => { + test("a former leaf gains a dependent and falls back to the result index", () => { + const merged = computeMergedConfig( + config([node("old")]), + { nodes: [node("new", ["old"])] }, + { + ...plan, + add: ["new"], + }, + ) + expect(merged.delivery_node).toBeUndefined() + expect(parseWorkflowConfig(JSON.stringify(merged))?.delivery_node).toBeUndefined() + }) + test("replacing a pending node with a downstream definition invalidates the prior leaf report", () => { + const merged = computeMergedConfig( + config([node("old"), node("pending")]), + { + nodes: [node("pending", ["old"])], + }, + { ...plan, replace: ["pending"] }, + ) + expect(merged.delivery_node).toBeUndefined() + }) + test("removing a selected node clears the dangling selection", () => { + expect( + computeMergedConfig(config([node("old")]), { nodes: [] }, { ...plan, cancel: ["old"] }).delivery_node, + ).toBeUndefined() + }) + test("an unchanged leaf remains selected", () => { + expect( + computeMergedConfig(config([node("old")]), { nodes: [node("parallel")] }, { ...plan, add: ["parallel"] }) + .delivery_node, + ).toBe("old") + }) + test("a previously accepted explicit non-leaf selection retains its meaning", () => { + const current = config([node("old"), node("downstream", ["old"])]) + const merged = computeMergedConfig(current, { nodes: [node("parallel")] }, { ...plan, add: ["parallel"] }) + expect(merged.delivery_node).toBe("old") + }) +}) + +function services() { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(Layer.merge(database, events))) + const sessionProjector = SessionProjector.layer.pipe(Layer.provide(Layer.merge(database, events))) + const dependencies = Layer.mergeAll(database, events, bridge, store, projector, sessionProjector) + return Layer.merge(dependencies, Dag.layer.pipe(Layer.provide(dependencies))) +} +const it = testEffect(services()) +function seed(workflowStatus: "running" | "completed" | "failed", nodeStatus: "running" | "completed" | "failed") { + return Effect.gen(function* () { + const { db } = yield* Database.Service + const directory = process.cwd() + yield* db.run( + sql`INSERT INTO project (id,worktree,sandboxes,time_created,time_updated) VALUES ('p',${directory},'[]',1,1)`, + ) + yield* db.run( + sql`INSERT INTO session (id,project_id,slug,directory,title,version,time_created,time_updated) VALUES ('ses_parent','p','parent',${directory},'S','test',1,1)`, + ) + yield* db.run( + sql`INSERT INTO workflow (id,project_id,session_id,directory,title,status,config,seq,graph_rev,completed_at,time_created,time_updated) VALUES (${workflowID},'p','ses_parent',${directory},'Mutation',${workflowStatus},${JSON.stringify(config([node("old")]))},0,1,${workflowStatus === "completed" ? 10 : null},1,10)`, + ) + yield* db.run( + sql`INSERT INTO workflow_node (id,workflow_id,name,worker_type,status,depends_on,child_session_id,output,wake_eligible,seq,time_created,time_updated) VALUES ('old',${workflowID},'Old','build',${nodeStatus},'[]','ses_old',${JSON.stringify("old report")},1,0,1,10)`, + ) + }).pipe(Effect.orDie) +} +const completeNode = (dag: Dag.Interface, id: string, output: string) => + Effect.gen(function* () { + yield* dag.nodeQueued(workflowID, id) + yield* dag.nodeStarted(workflowID, id, `ses_${id}`, undefined, true) + yield* dag.nodeCompleted(workflowID, id, output) + }) + +it.live( + "additive reopen delivers a new result index containing the new episode instead of copying the old report", + () => + Effect.gen(function* () { + yield* seed("completed", "completed") + const dag = yield* Dag.Service + const bridge = yield* EventV2Bridge.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const readParts = () => + db + .select() + .from(PartTable) + .pipe( + Effect.map((rows) => + rows.map( + (row) => + ({ + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + }) as SessionV1.Part, + ), + ), + ) + const sessions = { + get: () => Effect.succeed({ id: "ses_parent", agent: "build" }), + messages: () => + Effect.gen(function* () { + const rows = yield* db.select().from(MessageTable) + const parts = yield* readParts() + return rows.map((row) => ({ + info: { ...row.data, id: row.id, sessionID: row.session_id } as SessionV1.Info, + parts: parts.filter((part) => part.messageID === row.id), + })) + }), + getPart: (input: { partID: string }) => + db + .select() + .from(PartTable) + .where(eq(PartTable.id, input.partID as never)) + .pipe( + Effect.map((rows) => + rows[0] + ? ({ + ...rows[0].data, + id: rows[0].id, + sessionID: rows[0].session_id, + messageID: rows[0].message_id, + } as SessionV1.Part) + : undefined, + ), + ), + } as unknown as Session.Interface + const deliver = () => + Effect.gen(function* () { + const workflow = (yield* dag.store.getWorkflow(workflowID))! + const nodes = yield* dag.store.getCurrentNodes(workflowID) + return yield* deliverReport({ + workflow, + store: dag.store, + sessions, + events, + directory: process.cwd(), + batch: { nodes, workflows: [workflow] }, + }) + }) + expect(yield* deliver()).toBe(true) + yield* dag.extend(workflowID, [node("new", ["old"])]) + const reopened = (yield* dag.store.getWorkflow(workflowID))! + expect(reopened.status).toBe("running") + expect(parseWorkflowConfig(reopened.config)?.delivery_node).toBeUndefined() + yield* completeNode(dag, "new", "new report") + yield* bridge.publish(DagEvent.WorkflowCompleted, { + dagID: Dag.ID.make(workflowID), + durationMs: 0, + timestamp: yield* DateTime.now, + }) + expect(yield* deliver()).toBe(true) + const answers = (yield* readParts()).filter( + (part): part is SessionV1.TextPart => part.type === "text" && part.metadata?.dag_delivery?.kind === "answer", + ) + expect(answers).toHaveLength(2) + expect(answers[0].text).toBe("old report") + expect(answers[1].text).toContain(`node_id="new"`) + expect(answers[1].text).not.toBe("old report") + expect(answers[1].metadata?.dag_delivery?.graph_rev).toBe(reopened.graphRev) + }), +) + +it.live("recovery remaps a selected report to its exact replacement attempt", () => + Effect.gen(function* () { + yield* seed("failed", "failed") + const dag = yield* Dag.Service + const workflow = (yield* dag.store.getWorkflow(workflowID))! + const recovered = yield* dag.recover(workflowID, { nodeIDs: ["old"], expectedGraphRev: workflow.graphRev }) + const replacement = recovered.replacements.find((node) => node.previous === "old")!.current + const currentConfig = parseWorkflowConfig((yield* dag.store.getWorkflow(workflowID))!.config)! + expect(currentConfig.delivery_node).toBe(replacement) + yield* completeNode(dag, replacement, "recovered report") + const report = selectReportNode(currentConfig, yield* dag.store.getCurrentNodes(workflowID)) + expect(report).toMatchObject({ id: replacement, status: "completed", output: "recovered report" }) + expect((yield* dag.store.getNode(workflowID, "old"))?.superseded).toBe(true) + }), +) diff --git a/packages/opencode/test/dag/dag-final-delivery.test.ts b/packages/opencode/test/dag/dag-final-delivery.test.ts index eece07028..fcaa68ed7 100644 --- a/packages/opencode/test/dag/dag-final-delivery.test.ts +++ b/packages/opencode/test/dag/dag-final-delivery.test.ts @@ -1,6 +1,6 @@ /* oxlint-disable typescript-eslint/no-unsafe-type-assertion -- Synthetic branded DB rows and Session service mocks exercise transaction rollback without starting a model. */ import { expect, it } from "bun:test" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Layer, Stream } from "effect" import { eq } from "drizzle-orm" import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises" import os from "node:os" @@ -10,7 +10,9 @@ import { DagStore } from "@opencode-ai/core/dag/store" import { WorkflowNodeTable, WorkflowTable } from "@opencode-ai/core/dag/sql" import { ProjectTable } from "@opencode-ai/core/project/sql" import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" -import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { EventV2 } from "@opencode-ai/core/event" +import { SessionProjector } from "@opencode-ai/core/session/projector" import type { Session } from "@/session/session" import { deliverReport, reportIdentity, selectReportNode } from "@/dag/runtime/report-delivery" import { commitOutputFileRef, verifyOutputFileRef } from "@/dag/runtime/output-ref" @@ -19,6 +21,8 @@ import { WorkflowAuthoring } from "@/dag/authoring" import { parseWorkflowConfig, resultProtocol } from "@/dag/dag" import { before } from "@/session/message-v2" +const Barrier = EventV2.define({ type: "test.dag.delivery.barrier", schema: {} }) + function fixture( work: (input: { database: Database.Interface @@ -27,136 +31,190 @@ function fixture( workflow: DagStore.WorkflowRow deliver: () => Effect.Effect crash: (value: boolean) => void + wakeCrash: (value: boolean) => void + visibleReports: EventV2.Payload[] + flush: () => Effect.Effect }) => Effect.Effect, + options?: { output: unknown; schema: Record; source?: SessionV1.WithParts }, ) { const database = Database.layerFromPath(":memory:") - const layer = Layer.merge(database, DagStore.layer.pipe(Layer.provide(database))) - return Effect.gen(function* () { - const database = yield* Database.Service - const store = yield* DagStore.Service - const db = database.db - const text = " Exact final report\n\nwith **formatting** and trailing space. " - const config = { - name: "delivery", - result_protocol: "final_response", - delivery_node: "final", - nodes: [{ id: "final", depends_on: [] }], - } - yield* db - .insert(ProjectTable) - .values({ id: "proj-1" as never, worktree: "/tmp" as never, sandboxes: [], time_created: 1, time_updated: 1 }) - .run() - yield* db - .insert(SessionTable) - .values({ - id: "ses_parent" as never, - project_id: "proj-1" as never, - directory: "/tmp", - title: "parent", - slug: "parent", - version: "test", - time_created: 1, - time_updated: 1, - }) - .run() - yield* db - .insert(WorkflowTable) - .values({ - id: "wf-1", - project_id: "proj-1" as never, - session_id: "ses_parent" as never, - title: "delivery", - status: "completed", - config: JSON.stringify(config), - graph_rev: 2, - seq: 7, - completed_at: 100, - time_created: 1, - time_updated: 100, - }) - .run() - yield* db - .insert(WorkflowNodeTable) - .values({ - workflow_id: "wf-1", - id: "final", - name: "Final", - worker_type: "general", - status: "completed", - depends_on: [], - output: text, - seq: 6, - child_session_id: "ses_child", - time_created: 1, - time_updated: 100, - }) - .run() - const workflow = (yield* store.getWorkflow("wf-1"))! - let fail = false - const sessions = { - get: () => Effect.succeed({ id: "ses_parent", agent: "build", model: { providerID: "test", id: "test-model" } }), - messages: () => - db - .select() - .from(MessageTable) - .pipe( - Effect.map((rows) => - rows.map((row) => ({ info: { ...row.data, id: row.id, sessionID: row.session_id }, parts: [] })), - ), - ), - getPart: (input: { partID: string }) => - db - .select() - .from(PartTable) - .where(eq(PartTable.id, input.partID as never)) - .pipe( - Effect.map((rows) => - rows[0] - ? { ...rows[0].data, id: rows[0].id, sessionID: rows[0].session_id, messageID: rows[0].message_id } - : undefined, - ), - ), - updateMessage: (info: SessionV1.Info) => { - const { id, sessionID, ...data } = info - return db - .insert(MessageTable) - .values({ id, session_id: sessionID, data, time_created: info.time.created, time_updated: info.time.created }) - .run() - .pipe(Effect.as(info)) - }, - updatePart: (part: SessionV1.Part) => { - if (fail && part.type === "text" && part.metadata?.dag_delivery?.kind === "answer") - return Effect.die(new Error("simulated process failure before receipt")) - const { id, sessionID, messageID, ...data } = part - return db - .insert(PartTable) - .values({ id, session_id: sessionID, message_id: messageID, data, time_created: 100, time_updated: 100 }) - .run() - .pipe(Effect.as(part)) - }, - } as unknown as Session.Interface - const deliver = () => - Effect.gen(function* () { - const nodes = yield* store.getCurrentNodes("wf-1") - return yield* deliverReport({ - workflow, - store, - sessions, + const eventsLayer = EventV2.layer.pipe(Layer.provide(database)) + const projector = SessionProjector.layer.pipe(Layer.provide(eventsLayer), Layer.provide(database)) + const layer = Layer.mergeAll(database, DagStore.layer.pipe(Layer.provide(database)), eventsLayer, projector) + return Effect.scoped( + Effect.gen(function* () { + const database = yield* Database.Service + const rawStore = yield* DagStore.Service + const events = yield* EventV2.Service + let failWake = false + const store: DagStore.Interface = { + ...rawStore, + markWakeBatchReported: (batch) => + failWake ? Effect.die(new Error("simulated wake write failure")) : rawStore.markWakeBatchReported(batch), + } + const visibleReports: EventV2.Payload[] = [] + let barrier: Deferred.Deferred | undefined + yield* events.all().pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + if (event.type === Barrier.type && barrier) { + yield* Deferred.succeed(barrier, undefined) + return + } + if ( + event.type === SessionV1.Event.PartUpdated.type && + typeof event.data === "object" && + event.data !== null && + "part" in event.data + ) { + const part = event.data.part as SessionV1.Part + if (part.type === "text" && part.metadata?.dag_delivery) visibleReports.push(event) + } + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + const flush = () => + Effect.gen(function* () { + barrier = yield* Deferred.make() + yield* events.publish(Barrier, {}) + yield* Deferred.await(barrier) + }) + const db = database.db + const text = " Exact final report\n\nwith **formatting** and trailing space. " + const config = { + name: "delivery", + result_protocol: "final_response", + delivery_node: "final", + nodes: [{ id: "final", depends_on: [], ...(options ? { output_schema: options.schema } : {}) }], + } + yield* db + .insert(ProjectTable) + .values({ id: "proj-1" as never, worktree: "/tmp" as never, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: "ses_parent" as never, + project_id: "proj-1" as never, directory: "/tmp", - batch: { nodes, workflows: [workflow] }, + title: "parent", + slug: "parent", + version: "test", + time_created: 1, + time_updated: 1, }) - }).pipe(Effect.orDie) - yield* work({ - database, - store, - sessions, - workflow, - deliver, - crash: (value) => { - fail = value - }, - }) - }).pipe(Effect.provide(layer), Effect.runPromise) + .run() + yield* db + .insert(WorkflowTable) + .values({ + id: "wf-1", + project_id: "proj-1" as never, + session_id: "ses_parent" as never, + title: "delivery", + status: "completed", + config: JSON.stringify(config), + graph_rev: 2, + seq: 7, + completed_at: 100, + time_created: 1, + time_updated: 100, + }) + .run() + yield* db + .insert(WorkflowNodeTable) + .values({ + workflow_id: "wf-1", + id: "final", + name: "Final", + worker_type: "general", + status: "completed", + depends_on: [], + output: options ? options.output : text, + seq: 6, + child_session_id: "ses_child", + time_created: 1, + time_updated: 100, + }) + .run() + const workflow = (yield* store.getWorkflow("wf-1"))! + let fail = false + yield* events.project(SessionV1.Event.PartUpdated, (event) => + fail && event.data.part.type === "text" && event.data.part.metadata?.dag_delivery?.kind === "answer" + ? Effect.die(new Error("simulated process failure before receipt")) + : Effect.void, + ) + const sessions = { + get: () => + Effect.succeed({ id: "ses_parent", agent: "build", model: { providerID: "test", id: "test-model" } }), + messages: (input: { limit?: number }) => + Effect.gen(function* () { + const rows = yield* db.select().from(MessageTable) + const parts = yield* db.select().from(PartTable) + const history = rows + .map((row) => ({ + info: { ...row.data, id: row.id, sessionID: row.session_id }, + parts: parts + .filter((part) => part.message_id === row.id) + .map((part) => ({ + ...part.data, + id: part.id, + sessionID: part.session_id, + messageID: part.message_id, + })), + })) + .toSorted((left, right) => (before(left.info, right.info) ? -1 : 1)) + return input.limit === undefined ? history : history.slice(-input.limit) + }), + getPart: (input: { partID: string }) => + db + .select() + .from(PartTable) + .where(eq(PartTable.id, input.partID as never)) + .pipe( + Effect.map((rows) => + rows[0] + ? { ...rows[0].data, id: rows[0].id, sessionID: rows[0].session_id, messageID: rows[0].message_id } + : undefined, + ), + ), + updateMessage: (info: SessionV1.Info) => + events.publish(SessionV1.Event.MessageUpdated, { sessionID: info.sessionID, info }).pipe(Effect.as(info)), + updatePart: (part: SessionV1.Part) => + events + .publish(SessionV1.Event.PartUpdated, { sessionID: part.sessionID, part, time: 100 }) + .pipe(Effect.as(part)), + } as unknown as Session.Interface + const deliver = () => + Effect.gen(function* () { + const nodes = yield* store.getCurrentNodes("wf-1") + return yield* deliverReport({ + workflow, + events, + source: options?.source, + store, + sessions, + directory: "/tmp", + batch: { nodes, workflows: [workflow] }, + }) + }).pipe(Effect.orDie) + yield* work({ + database, + store, + sessions, + workflow, + deliver, + crash: (value) => { + fail = value + }, + wakeCrash: (value) => { + failWake = value + }, + visibleReports, + flush, + }) + }), + ).pipe(Effect.provide(layer), Effect.runPromise) } it("copies the answer verbatim with source metadata and adopts the same receipt after restart", () => @@ -201,6 +259,165 @@ it("rolls back the entire transcript and wake mark when receipt persistence fail }), )) +it("publishes no report events when the final wake write rolls back, then emits once on retry", () => + fixture(({ database, store, deliver, wakeCrash, visibleReports, flush }) => + Effect.gen(function* () { + wakeCrash(true) + expect((yield* deliver().pipe(Effect.exit))._tag).toBe("Failure") + yield* flush() + expect(visibleReports).toHaveLength(0) + expect(yield* database.db.select().from(MessageTable)).toHaveLength(0) + expect(yield* database.db.select().from(PartTable)).toHaveLength(0) + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(false) + wakeCrash(false) + expect(yield* deliver()).toBe(true) + yield* flush() + expect(visibleReports).toHaveLength(2) + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(true) + yield* database.db.update(WorkflowTable).set({ wake_reported: false }).where(eq(WorkflowTable.id, "wf-1")).run() + expect(yield* deliver()).toBe(true) + yield* flush() + expect(visibleReports).toHaveLength(2) + }), + )) + +function parentAnswer(id: string, parentID: string): SessionV1.Assistant { + return { + id: id as never, + sessionID: "ses_parent" as never, + parentID: parentID as never, + role: "assistant", + time: { created: 60, completed: 60 }, + agent: "build", + mode: "build", + path: { cwd: "/tmp", root: "/tmp" }, + providerID: "test" as never, + modelID: "test" as never, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + } +} + +for (const field of ["report", "summary"] as const) { + it(`preserves all structured fields alongside ${field}`, () => { + const output = { [field]: "Readable report", metrics: { count: 3 }, details: ["a", "b"] } + const raw = JSON.stringify(output, null, 2) + const source: SessionV1.WithParts = { + info: parentAnswer("msg_child_final", "msg_child_input"), + parts: [ + { + id: "prt_child" as never, + sessionID: "ses_parent" as never, + messageID: "msg_child_final" as never, + type: "text", + text: raw, + }, + ], + } + return fixture( + ({ database, deliver }) => + Effect.gen(function* () { + expect(yield* deliver()).toBe(true) + const answer = (yield* database.db.select().from(PartTable)) + .map((row) => row.data as SessionV1.TextPart) + .find((part) => part.metadata?.dag_delivery?.kind === "answer")! + expect(JSON.parse(answer.text)).toEqual(output) + expect(answer.text).toBe(field === "report" ? raw : JSON.stringify(output)) + }), + { + output, + schema: { type: "object", properties: { [field]: { type: "string" } } }, + ...(field === "report" ? { source } : {}), + }, + ) + }) +} + +for (const state of ["error", "unfinished", "wrong-parent", "dag-report"] as const) { + it(`keeps a pending user request behind a newer ${state} assistant`, () => + fixture(({ database, sessions, deliver }) => + Effect.gen(function* () { + yield* sessions.updateMessage({ + id: "msg_pending" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + const reply = parentAnswer("msg_failed_attempt", state === "wrong-parent" ? "msg_previous" : "msg_pending") + if (state === "error") reply.error = { name: "UnknownError", data: { message: "provider failed" } } as never + if (state === "unfinished") { + reply.finish = undefined + reply.time.completed = undefined + } + yield* sessions.updateMessage(reply) + if (state === "dag-report") + yield* sessions.updatePart({ + id: "prt_other_dag" as never, + messageID: reply.id, + sessionID: reply.sessionID, + type: "text", + text: "older workflow result", + metadata: { dag_delivery: { workflow_id: "other", completion_seq: 1, kind: "answer" } }, + }) + expect(yield* deliver()).toBe(false) + expect( + (yield* database.db.select().from(PartTable)).some( + (row) => + row.id === + reportIdentity( + makeWorkflowRow({ + id: "wf-1", + sessionId: "ses_parent", + seq: 7, + completedAt: 100, + timeUpdated: 100, + }), + ).answerID, + ), + ).toBe(false) + const answered = parentAnswer("msg_real_answer", "msg_pending") + answered.time = { created: 70, completed: 70 } + yield* sessions.updateMessage(answered) + expect(yield* deliver()).toBe(true) + }), + )) +} + +it("keeps an unanswered user behind more than twenty assistant tool turns", () => + fixture(({ database, sessions, deliver }) => + Effect.gen(function* () { + yield* sessions.updateMessage({ + id: "msg_pending" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + for (let turn = 0; turn < 25; turn++) { + const reply = parentAnswer(`msg_tool_${turn}`, "msg_pending") + reply.time = { created: 60 + turn, completed: 60 + turn } + reply.finish = "tool-calls" + if (turn === 24) reply.error = { name: "UnknownError", data: { message: "provider failed" } } as never + yield* sessions.updateMessage(reply) + } + expect( + (yield* sessions.messages({ sessionID: "ses_parent" as never, limit: 20 })).every( + (item) => item.info.role === "assistant", + ), + ).toBe(true) + expect(yield* deliver()).toBe(false) + expect(yield* database.db.select().from(PartTable)).toHaveLength(0) + const answer = parentAnswer("msg_real_answer", "msg_pending") + answer.time = { created: 90, completed: 90 } + yield* sessions.updateMessage(answer) + expect(yield* deliver()).toBe(true) + }), + )) + it("does not copy a completion superseded by a reopen or a newer graph revision", () => fixture(({ database, deliver }) => Effect.gen(function* () { @@ -243,12 +460,60 @@ it("uses explicit delivery selection and never guesses from required nodes or ol it("persists the unique leaf synthesis selection and preserves the legacy protocol default", async () => { const authoring = WorkflowAuthoring.make() - const result = await Effect.runPromise(authoring.prepare({ action: "start", profile: "portable", source: { kind: "inline", value: { config: { name: "synthesis", objective: "Produce the final report", blocks: [{ id: "final", kind: "synthesize" }] } } } })) + const result = await Effect.runPromise( + authoring.prepare({ + action: "start", + profile: "portable", + source: { + kind: "inline", + value: { + config: { + name: "synthesis", + objective: "Produce the final report", + blocks: [{ id: "final", kind: "synthesize" }], + }, + }, + }, + }), + ) expect(result.valid).toBe(true) expect(result.prepared?.action === "start" && result.prepared.config.delivery_node).toBe("final") - const ambiguous = await Effect.runPromise(authoring.prepare({ action: "start", profile: "portable", source: { kind: "inline", value: { config: { name: "ambiguous", objective: "Produce reports", blocks: [{ id: "a", kind: "synthesize" }, { id: "b", kind: "synthesize" }] } } } })) + const ambiguous = await Effect.runPromise( + authoring.prepare({ + action: "start", + profile: "portable", + source: { + kind: "inline", + value: { + config: { + name: "ambiguous", + objective: "Produce reports", + blocks: [ + { id: "a", kind: "synthesize" }, + { id: "b", kind: "synthesize" }, + ], + }, + }, + }, + }), + ) expect(ambiguous.prepared?.action === "start" && ambiguous.prepared.config.delivery_node).toBeUndefined() - const invalid = await Effect.runPromise(authoring.prepare({ action: "start", profile: "portable", source: { kind: "inline", value: { config: { name: "invalid", delivery_node: "missing", nodes: [{ id: "a", name: "A", worker_type: "general", depends_on: [], prompt_template: { inline: "A" } }] } } } })) + const invalid = await Effect.runPromise( + authoring.prepare({ + action: "start", + profile: "portable", + source: { + kind: "inline", + value: { + config: { + name: "invalid", + delivery_node: "missing", + nodes: [{ id: "a", name: "A", worker_type: "general", depends_on: [], prompt_template: { inline: "A" } }], + }, + }, + }, + }), + ) expect(invalid.valid).toBe(false) expect(resultProtocol(parseWorkflowConfig(JSON.stringify({ name: "legacy", nodes: [] })))).toBe("submit_result") expect(parseWorkflowConfig(JSON.stringify({ name: "bad", nodes: [], result_protocol: "unknown" }))).toBeUndefined() @@ -340,18 +605,47 @@ it("uses the captured file receipt and refuses to acknowledge a missing managed }), )) -it("keeps the fallback synthetic anchor before its assistant using canonical ordering", () => fixture(({ database, deliver }) => Effect.gen(function* () { - yield* deliver() - const messages = yield* database.db.select().from(MessageTable) - const user = messages.find((row) => row.data.role === "user")! - const answer = messages.find((row) => row.data.role === "assistant")! - expect(before({ id: user.id, time: user.data.time }, { id: answer.id, time: answer.data.time })).toBe(true) - const zero = reportIdentity(makeWorkflowRow({ completedAt: 0, timeUpdated: 0 })) - expect(before({ id: zero.anchorID, time: { created: 0 } }, { id: zero.messageID, time: { created: 0 } })).toBe(true) -}))) +it("keeps the fallback synthetic anchor before its assistant using canonical ordering", () => + fixture(({ database, deliver }) => + Effect.gen(function* () { + yield* deliver() + const messages = yield* database.db.select().from(MessageTable) + const user = messages.find((row) => row.data.role === "user")! + const answer = messages.find((row) => row.data.role === "assistant")! + expect(before({ id: user.id, time: user.data.time }, { id: answer.id, time: answer.data.time })).toBe(true) + const zero = reportIdentity(makeWorkflowRow({ completedAt: 0, timeUpdated: 0 })) + expect(before({ id: zero.anchorID, time: { created: 0 } }, { id: zero.messageID, time: { created: 0 } })).toBe( + true, + ) + }), + )) -it("does not consume a same-millisecond user that sorts after the previous assistant", () => fixture(({ sessions, deliver }) => Effect.gen(function* () { - yield* sessions.updateMessage({ id: "msg_z_pending" as never, sessionID: "ses_parent" as never, role: "user", time: { created: 50 }, agent: "build", model: { providerID: "test" as never, modelID: "test" as never } }) - yield* sessions.updateMessage({ id: "msg_a_previous" as never, sessionID: "ses_parent" as never, parentID: "msg_previous" as never, role: "assistant", time: { created: 50, completed: 50 }, agent: "build", mode: "build", path: { cwd: "/tmp", root: "/tmp" }, providerID: "test" as never, modelID: "test" as never, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "stop" }) - expect(yield* deliver()).toBe(false) -}))) +it("does not consume a same-millisecond user that sorts after the previous assistant", () => + fixture(({ sessions, deliver }) => + Effect.gen(function* () { + yield* sessions.updateMessage({ + id: "msg_z_pending" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + yield* sessions.updateMessage({ + id: "msg_a_previous" as never, + sessionID: "ses_parent" as never, + parentID: "msg_previous" as never, + role: "assistant", + time: { created: 50, completed: 50 }, + agent: "build", + mode: "build", + path: { cwd: "/tmp", root: "/tmp" }, + providerID: "test" as never, + modelID: "test" as never, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }) + expect(yield* deliver()).toBe(false) + }), + )) diff --git a/packages/opencode/test/dag/dag-final-response-stale-failure.test.ts b/packages/opencode/test/dag/dag-final-response-stale-failure.test.ts new file mode 100644 index 000000000..629a85c01 --- /dev/null +++ b/packages/opencode/test/dag/dag-final-response-stale-failure.test.ts @@ -0,0 +1,313 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- Narrow model service fixtures retain real DAG events and durable mailbox guards. +import { describe, expect } from "bun:test" +import { sql } from "drizzle-orm" +import { Effect, Exit, Fiber, Layer, Semaphore } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { DagMessages } from "@opencode-ai/core/dag/messages" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { Agent } from "@/agent/agent" +import { Dag } from "@/dag/dag" +import { continueRecoveredMessageNode, reconcileWorkflow } from "@/dag/runtime/recovery" +import { spawnNode } from "@/dag/runtime/spawn" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +const parent = { projectID: "p", directory: process.cwd(), sessionID: "ses_parent" } +const child = { ...parent, sessionID: "ses_child" } +const attempt = { replanAttempts: 0, childSessionID: child.sessionID } +const workflowID = "dag_final_failure" + +function services() { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const messages = DagMessages.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(Layer.merge(database, events))) + const dependencies = Layer.mergeAll(database, events, bridge, store, messages, projector) + return Layer.merge(dependencies, Dag.layer.pipe(Layer.provide(dependencies))) +} +const it = testEffect(services()) +const value = (result: DagMessages.Result) => { + if (!result.ok) throw new Error(result.reason) + return result.value +} +function seed(status: "pending" | "running" = "running") { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.run( + sql`INSERT INTO project (id,worktree,sandboxes,time_created,time_updated) VALUES ('p',${parent.directory},'[]',1,1)`, + ) + for (const s of [parent, child]) + yield* db.run( + sql`INSERT INTO session (id,project_id,parent_id,slug,directory,title,version,time_created,time_updated) VALUES (${s.sessionID},'p',${s === parent ? null : parent.sessionID},${s.sessionID},${parent.directory},'S','test',1,1)`, + ) + const config = JSON.stringify({ + name: "freshness", + result_protocol: "final_response", + nodes: [ + { + id: "n", + name: "N", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], + }) + yield* db.run( + sql`INSERT INTO workflow (id,project_id,session_id,directory,title,status,config,seq,time_created,time_updated) VALUES (${workflowID},'p',${parent.sessionID},${parent.directory},'W','running',${config},0,1,1)`, + ) + yield* db.run( + sql`INSERT INTO workflow_node (id,workflow_id,name,worker_type,status,depends_on,child_session_id,seq,time_created,time_updated) VALUES ('n',${workflowID},'N','build',${status},'[]',${status === "pending" ? null : child.sessionID},0,1,1)`, + ) + }).pipe(Effect.orDie) +} +function accept(sender = parent, key = "new-input") { + return Effect.gen(function* () { + const messages = yield* DagMessages.Service + return value( + yield* messages.send(sender, { + workflowID, + nodeID: "n", + attemptID: DagMessages.nodeAttemptID(child.sessionID, 0), + idempotencyKey: key, + content: key, + }), + ) + }) +} +function associate(turn: string) { + return Effect.gen(function* () { + const messages = yield* DagMessages.Service + const { db } = yield* Database.Service + const snapshot = value(yield* messages.freeze(child, turn)) + for (const m of snapshot.messages) { + yield* db.run( + sql`INSERT INTO message (id,session_id,time_created,time_updated,data) VALUES (${m.transcriptID},${child.sessionID},1,1,'{}') ON CONFLICT(id) DO NOTHING`, + ) + yield* db.run( + sql`INSERT INTO part (id,message_id,session_id,time_created,time_updated,data) VALUES (${m.partID},${m.transcriptID},${child.sessionID},1,1,${JSON.stringify({ type: "text", text: DagMessages.renderMessage(m) })}) ON CONFLICT(id) DO NOTHING`, + ) + } + value(yield* messages.associate(child, snapshot.id)) + return snapshot + }).pipe(Effect.orDie) +} +function reply(id: string, text: string): SessionV1.WithParts { + const messageID = MessageID.make(id) + const sessionID = SessionID.make(child.sessionID) + return { + info: { + id: messageID, + sessionID, + parentID: MessageID.ascending(), + role: "assistant", + mode: "build", + agent: "build", + cost: 0, + path: { cwd: parent.directory, root: parent.directory }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test" as never, + providerID: "test" as never, + time: { created: Date.now() }, + finish: "stop", + }, + parts: [{ id: PartID.ascending(), sessionID, messageID, type: "text", text }], + } +} + +describe("final-response failure freshness", () => { + for (const mode of ["spawn", "continue", "reconcile"] as const) + it.live(`${mode} continues when parent input arrives during failed schema validation`, () => + Effect.gen(function* () { + yield* seed(mode === "spawn" ? "pending" : "running") + const dag = yield* Dag.Service + const messages = yield* DagMessages.Service + const database = yield* Database.Service + const oldTurn = MessageID.ascending() + const entered = Promise.withResolvers() + // Worker.postMessage reads this getter when admitting schema validation. + // The parent accepts input while that asynchronous worker is pending. + const schema: Record = { + get type() { + entered.resolve() + return "boolean" + }, + } + const config = { result_protocol: "final_response" as const, nodes: [{ id: "n", output_schema: schema }] } + let prompts = 0 + let loops = 0 + const modelTurn = (text: string) => + Effect.gen(function* () { + const turn = MessageID.ascending() + yield* associate(turn) + return reply(turn, text) + }).pipe( + Effect.provideService(Database.Service, database), + Effect.provideService(DagMessages.Service, messages), + ) + const prompt = Layer.mock(SessionPrompt.Service, { + prompt: () => { + prompts++ + return modelTurn('"invalid old answer"') + }, + loop: () => { + loops++ + return modelTurn(mode === "continue" && loops === 1 ? '"invalid old answer"' : "true") + }, + cancel: () => Effect.void, + }) + const injector = yield* Effect.forkChild(Effect.promise(() => entered.promise).pipe(Effect.andThen(accept()))) + if (mode === "spawn") { + const agent = Layer.mock(Agent.Service, { + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + tools: {}, + hooks: {}, + model: { providerID: "test", modelID: "test" }, + } as never), + }) + const sessions = Layer.mock(Session.Service, { + get: () => + Effect.succeed({ id: SessionID.make(parent.sessionID), permission: [], agent: "build" } as never), + create: () => Effect.succeed({ id: SessionID.make(child.sessionID) } as never), + }) + const node = (yield* dag.store.getNode(workflowID, "n"))! + const spawned = yield* spawnNode(Semaphore.makeUnsafe(1), { + dagID: workflowID, + nodeID: "n", + node, + parentSessionID: parent.sessionID, + promptParts: [{ type: "text", text: "work" }], + resultProtocol: "final_response", + outputSchema: schema, + }).pipe(Effect.provide(Layer.mergeAll(agent, sessions, prompt))) + expect(Exit.isSuccess(yield* Fiber.await(spawned.fiber))).toBe(true) + expect({ prompts, loops }).toEqual({ prompts: 1, loops: 1 }) + } else if (mode === "continue") { + yield* continueRecoveredMessageNode(workflowID, "n", config).pipe(Effect.provide(prompt)) + expect({ prompts, loops }).toEqual({ prompts: 0, loops: 2 }) + } else { + yield* associate(oldTurn) + const result = yield* reconcileWorkflow( + workflowID, + (sessionID) => Effect.succeed(sessionID === child.sessionID ? "completed" : "active"), + undefined, + config, + undefined, + undefined, + undefined, + () => Effect.succeed(reply(oldTurn, '"invalid old answer"')), + ) + expect(result).toMatchObject({ reconciled: 0, continuations: ["n"] }) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("running") + expect(value(yield* messages.receive(child))[0].state).toBe("queued") + yield* continueRecoveredMessageNode(workflowID, "n", config).pipe(Effect.provide(prompt)) + expect(loops).toBe(1) + } + yield* Fiber.join(injector) + expect(yield* dag.store.getNode(workflowID, "n")).toMatchObject({ + status: "completed", + output: true, + childSessionId: child.sessionID, + replanAttempts: 0, + }) + expect(value(yield* messages.receive(child)).map((m) => m.state)).toEqual(["delivered"]) + }), + ) + + it.live("an invalid current response still fails its associated snapshot", () => + Effect.gen(function* () { + yield* seed() + const dag = yield* Dag.Service + const messages = yield* DagMessages.Service + yield* accept() + const snapshot = yield* associate(MessageID.ascending()) + yield* dag.nodeFailed(workflowID, "n", "invalid response", "verdict_fail", { + ...attempt, + inputSnapshotID: snapshot.id, + }) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("failed") + expect(value(yield* messages.metadata(parent, workflowID, "n"))).toMatchObject({ closedReason: "verdict_fail" }) + }), + ) + + it.live("execution failures still close newer pending input without a response snapshot", () => + Effect.gen(function* () { + yield* seed() + const dag = yield* Dag.Service + const messages = yield* DagMessages.Service + yield* associate(MessageID.ascending()) + yield* accept() + yield* dag.nodeFailed(workflowID, "n", "provider crashed", "exec_failed", attempt) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("failed") + expect(value(yield* messages.receive(child))[0]).toMatchObject({ state: "undeliverable", reason: "exec_failed" }) + }), + ) + + for (const snapshotState of ["unassociated", "missing"] as const) + it.live(`a response failure rejects its ${snapshotState} original snapshot before closing the mailbox`, () => + Effect.gen(function* () { + yield* seed() + const dag = yield* Dag.Service + const messages = yield* DagMessages.Service + const snapshotID = + snapshotState === "unassociated" + ? value(yield* messages.freeze(child, MessageID.ascending())).id + : "snapshot-that-does-not-exist" + const result = yield* dag + .nodeFailed(workflowID, "n", "invalid response", "verdict_fail", { + ...attempt, + inputSnapshotID: snapshotID, + }) + .pipe(Effect.result) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") expect(Dag.isStaleMessageInput(result.failure)).toBe(true) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("running") + expect(value(yield* messages.metadata(parent, workflowID, "n")).closedReason).toBeUndefined() + }), + ) + + it.live("a forced execution failure ignores an old response snapshot when newer input exists", () => + Effect.gen(function* () { + yield* seed() + const dag = yield* Dag.Service + const messages = yield* DagMessages.Service + const snapshot = yield* associate(MessageID.ascending()) + yield* accept() + yield* dag.nodeFailed(workflowID, "n", "provider crashed", "exec_failed", { + ...attempt, + inputSnapshotID: snapshot.id, + }) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("failed") + expect(value(yield* messages.receive(child))[0]).toMatchObject({ state: "undeliverable", reason: "exec_failed" }) + }), + ) + + it.live("a final-response failure without a known snapshot cannot discard accepted input", () => + Effect.gen(function* () { + yield* seed() + const dag = yield* Dag.Service + const messages = yield* DagMessages.Service + yield* accept() + const result = yield* dag + .nodeFailed(workflowID, "n", "final message unavailable", "verdict_fail", attempt) + .pipe(Effect.result) + expect(result._tag).toBe("Failure") + if (result._tag === "Failure") expect(Dag.isStaleMessageInput(result.failure)).toBe(true) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("running") + expect(value(yield* messages.receive(child))[0].state).toBe("queued") + }), + ) +}) From 1e4ed4fa4034428f8414db9507a21abcfd07db2f Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 05:33:56 +0800 Subject: [PATCH 06/11] docs: correct hook trust, matching and async boundaries --- .../core/src/plugin/skill/configure-hooks.md | 29 ++++++++++++------- .../src/plugin/skill/customize-opencode.md | 7 +++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md index ca8f27dd4..bbf3860dd 100644 --- a/packages/core/src/plugin/skill/configure-hooks.md +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -32,7 +32,8 @@ Optional top-level trust controls: untrusted working directory. - `allowUntrusted: true` opts out of that gate only when declared in the global hooks file. Project/worktree values are ignored; a repository cannot grant - itself trust. With neither setting, there is no trust gate. + itself trust. With neither setting, there is no trust gate unless + `OPENCODE_HOOKS_REQUIRE_TRUST=1` enables it. Example: @@ -78,14 +79,16 @@ tool names/argument keys for command hooks; see below. | `ElicitationResult` | After that form flow resolves; `result` on accept or `cancelled` on decline/dismiss/timeout | none | | `Notification` | For current internal permission-ask and MCP-elicitation attention notifications; `message`, `title?`, `notification_type?` | `notification_type` | | `ConfigChange` | When a watched `hooks.json` layer reloads (not arbitrary configuration changes); `config_path`, empty `session_id` | none | -| `WorktreeCreate` | After a worktree boots successfully; `path?`, `branch?` | none | +| `WorktreeCreate` | After `createFromInfo` returns; bootstrap is asynchronous and may still be running. A failed worktree reset can also return and fire this event; it does not certify readiness or success; `path?`, `branch?` | none | | `WorktreeRemove` | Before registered-worktree cleanup, including a stale or missing path; it does not certify successful removal; `path?`, `branch?` | none | | `InstructionsLoaded` | When instruction content is loaded; `path?`, `content?` | none | | `CwdChanged` | When OpenCode switches into a newly booted worktree, not an arbitrary shell directory change; `old_cwd`, `new_cwd`, empty `session_id` | none | | `FileChanged` | After successful internal `write`, `edit`, `apply_patch`, `multiedit`, or `patch` tool operations; `path` (absolute), `change_type` (`add`, `change`, `delete`). It is not an external filesystem watcher. | basename of `path` | -`matcher` supports case-insensitive exact names, pipe-separated names, `*`, or -regex. Its target is event-specific: the five tool/permission events use the +`matcher` supports `*`, case-insensitive exact names or pipe-separated lists +containing only letters, digits, underscores, and `|`. Other strings are +case-insensitive regexes; anchor them for an exact match (for example +`^(foo-bar|baz)$`). Its target is event-specific: the five tool/permission events use the tool name; `SessionStart`, `SessionEnd`, `Setup`, `PreCompact`/`PostCompact`, `Notification`, `SubagentStart`/`SubagentStop`, and `FileChanged` use the target shown above. For `FileChanged`, match a basename such as `package.json`, @@ -151,11 +154,13 @@ ignored. Plain-text exit-0 stdout is injected as context only for `UserPromptSubmit` and `SessionStart`. `inputFormat: "claude-code"` adapts command stdin and PreToolUse `updatedInput` -for six known built-ins only: `bash`/`read`/`write`/`edit`/`grep`/`glob` become +by tool ID: `bash`/`read`/`write`/`edit`/`grep`/`glob` become `Bash`/`Read`/`Write`/`Edit`/`Grep`/`Glob`; known argument keys such as `filePath`→`file_path`, edit's `oldString`→`old_string`, and grep's -`include`→`glob` are translated. Other tools, including MCP/custom tools, pass -through unchanged; emit native keys when rewriting those tools. This is naming +`include`→`glob` are translated. Custom tools shadowing these IDs receive the +same translation regardless of their origin. Tools outside this set, including +MCP/custom tools with other IDs, pass through unchanged; emit native keys when +rewriting those tools. This is naming compatibility, not full Claude Code behavior or output compatibility. `${CLAUDE_PLUGIN_ROOT}` expands to the directory containing the declaring @@ -190,8 +195,11 @@ Supported `hookSpecificOutput` fields: override the actual triggering event. - `permissionDecision`: `allow`, `deny`, or `ask`; consumed by `PreToolUse`/`PermissionRequest`. Most restrictive wins (`deny` > `ask` > - `allow`). `ask` always asks the user; `permissionDecisionReason` supplies - its explanation. On `PreToolUse`, `ask` shows the proposed input, including + `allow`). `ask` forces a user dialog where available; the task-tool path + degrades it to deny. Headless `opencode run` automatically rejects requests, + or approves them with `--dangerously-skip-permissions`; neither obtains human + confirmation. `permissionDecisionReason` supplies its explanation. + On interactive `PreToolUse`, `ask` shows the proposed input, including any `updatedInput`. On a forced ask handled by `PermissionRequest`, `deny` may reject the request; `allow` is ignored and logged. - `updatedInput`: changes arguments before a `PreToolUse` tool runs. It cannot @@ -282,7 +290,8 @@ callback stale. For task-tool `SubagentStop`, `session_id` is the parent and `agent_id` is the stopped child; an async rewake targets the parent session and does not resume that child. -Completion feedback that can trigger a rewake is: exit code 2 stderr, +Completion feedback that can trigger a rewake is: an `exitBlock` (command exit +code 2 stderr or its fallback reason, or an HTTP non-2xx status failure), `systemMessage`, a `decision: "block"` with a `reason`, `hookSpecificOutput.additionalContext`, or non-JSON plain text on exit 0 for `UserPromptSubmit` / `SessionStart`. Permission decisions, `updatedInput`, diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 4db536ef1..cffeb57d8 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -348,14 +348,17 @@ that resumes an idle conversation, load the `configure-hooks` skill. Its event names, output protocol and `async` / `asyncRewake` fields belong to that system; they are not fields to add to the plugin callback object below. -Legacy JavaScript plugin callbacks are awaited sequentially. Callbacks with an +Legacy JavaScript plugin callbacks dispatched through `Plugin.trigger` are +awaited sequentially. The `event({ event })` observer is fire-and-forget: +bus delivery does not await it, and callbacks or later events can overlap. +Callbacks with an `output` argument mutate that object in place and resolve to `void`; returning a replacement object does not apply it. Their inputs use JavaScript field names such as `sessionID`, unlike lifecycle command stdin. | Callback | Capability and output | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `event({ event })` | Observe published bus events; no decision output. | +| `event({ event })` | Observe published bus events asynchronously without delaying bus delivery; no decision output or sequential-await guarantee. | | `config(cfg)` | Mutate the merged configuration during plugin initialization. | | `dispose()` | Release plugin-owned resources when the plugin instance is disposed; this is not the end of every assistant turn. | | `chat.message` | Receive session/agent/model/message identity and mutate `output.message` / `output.parts` for an incoming message. | From 5a47380a7a452c7347663d10e7e563017cf135db Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 05:42:34 +0800 Subject: [PATCH 07/11] fix: keep hook guidance event coverage assertions in sync --- packages/core/src/plugin/skill.ts | 2 +- packages/core/src/plugin/skill/configure-hooks.md | 2 +- packages/opencode/test/hook/event-trigger-coverage.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index ab20cb822..fb4f6f263 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -18,7 +18,7 @@ export const CustomizeOpencodeDescription = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." export const ConfigureHooksDescription = - "Use when the user wants opencode to react to a tool or lifecycle event, run a background check, or poll for a result and resume the same idle conversation. Also use for questions about hooks, hooks.json, or migrating Claude Code hooks. Explains the 26 events, 5 handler types, matching, configuration scopes, trust, input/output protocols, synchronous decisions, async/asyncRewake continuation, and how to verify execution. Load this skill before creating or changing a lifecycle hook." + "Use when the user wants opencode to react to a tool or lifecycle event, run a background check, or poll for a result and resume the same idle conversation. Also use for questions about hooks, hooks.json, or migrating Claude Code hooks. Explains the 26 supported events, 5 handler types, matching, configuration scopes, trust, input/output protocols, synchronous decisions, async/asyncRewake continuation, and how to verify execution. Load this skill before creating or changing a lifecycle hook." export const CreateDagWorkflowDescription = "Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers project/global scopes, composable blocks, low-level nodes, and verification. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those." diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md index bbf3860dd..ff3c59f01 100644 --- a/packages/core/src/plugin/skill/configure-hooks.md +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -46,7 +46,7 @@ Example: } ``` -## Events, input, and matcher targets +## Events (26 total), input, and matcher targets Every handler receives a JSON envelope with `hook_event_name`, `session_id`, `transcript_path`, and `cwd`; `transcript_path` is currently an empty string. diff --git a/packages/opencode/test/hook/event-trigger-coverage.test.ts b/packages/opencode/test/hook/event-trigger-coverage.test.ts index c2e6160b2..cf2f6ea4a 100644 --- a/packages/opencode/test/hook/event-trigger-coverage.test.ts +++ b/packages/opencode/test/hook/event-trigger-coverage.test.ts @@ -38,7 +38,7 @@ describe("hook event trigger coverage", () => { expect(skill).toContain(`Events (${VALID_HOOK_EVENTS.size} total)`) expect(ConfigureHooksDescription).toContain(`the ${VALID_HOOK_EVENTS.size} supported events`) expect(create).toContain("Names using letters, digits or underscores — exact match (case-insensitive)") - expect(skill).toContain("names using letters, digits or underscores — exact match (case-insensitive)") + expect(skill).toContain("containing only letters, digits, underscores, and `|`") expect(migrate).toContain("Entries naming it are silently skipped") expect(skill).toContain("silently skipped") }) From 002a67dc96b750168dce3ef8717e42892832f94e Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 05:45:09 +0800 Subject: [PATCH 08/11] docs: record final hook review corrections and regression evidence --- .github/releases/v1.0.69.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/releases/v1.0.69.md b/.github/releases/v1.0.69.md index f84b7cfcc..24dee03ef 100644 --- a/.github/releases/v1.0.69.md +++ b/.github/releases/v1.0.69.md @@ -15,7 +15,7 @@ - **Settle pending user input only with its complete answer**: A real pending user message now requires its corresponding successful, complete assistant answer before delivery can be considered complete (#736). - **Preserve structured JSON results**: Final JSON output retains its complete structured value through validation and delivery, including values used by downstream conditions and mappings (#736). - **Invalidate stale extend and replan selections**: Extend/replan invalidates selections when their chosen point is no longer valid, and recovery remaps the selection to the current workflow state (#736). -- **Explain async hook continuation in built-in guidance**: Eight hook guidance and prompt files now describe `asyncRewake`, completion output, timeout and lifecycle constraints, one-shot behavior, and anti-loop handling. This is a guidance update; the asyncRewake runtime mechanism is unchanged (#737). +- **Explain async hook continuation in built-in guidance**: Built-in hook guidance and prompts now describe `asyncRewake`, completion output, timeout and lifecycle constraints, one-shot behavior, and anti-loop handling. Review corrections cover environment-forced trust, HTTP failure feedback, worktree bootstrap timing, headless permission decisions, matcher restrictions, custom tool translation, and asynchronous plugin event observers. This is a guidance update; the asyncRewake runtime mechanism is unchanged (#737). --- @@ -27,7 +27,7 @@ CLI receipt regressions: 18 passed, 81 assertions DAG mutation and stale-input tests: 16 passed, 49 assertions (2 test files) Expanded DAG regression set: 101 passed, 451 assertions Core mailbox regressions: 30 passed, 296 assertions -Hook focused regressions: 59 passed (core 10, opencode 49) +Hook and related regressions: 372 passed (core 12, opencode 360) Package typechecks: core and opencode passed Full DAG gate: core 153, runtime 933, schema 3, TUI 63 passed DAG gate opt-in tests: 2 skipped; all coverage floors passed From a158e6b7e8096a0ab0c5336f7946ab80b8337e36 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 06:21:50 +0800 Subject: [PATCH 09/11] fix(dag): preserve schema JSON during final report delivery --- .github/releases/v1.0.69.md | 10 ++-- .../src/dag/runtime/report-delivery.ts | 20 +++++-- .../test/dag/dag-final-delivery.test.ts | 57 +++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/.github/releases/v1.0.69.md b/.github/releases/v1.0.69.md index 24dee03ef..02e97c90a 100644 --- a/.github/releases/v1.0.69.md +++ b/.github/releases/v1.0.69.md @@ -13,7 +13,7 @@ - **Commit EventV2 reports, receipts, and wakes together**: Report events, durable receipts, and wake markers are persisted in the same transaction; listeners are notified only after the transaction commits (#736). - **Continue after stale final-response validation**: When validation of a saved `final_response` fails after new parent input arrives, the run becomes a continuation instead of a terminal failure (#736). - **Settle pending user input only with its complete answer**: A real pending user message now requires its corresponding successful, complete assistant answer before delivery can be considered complete (#736). -- **Preserve structured JSON results**: Final JSON output retains its complete structured value through validation and delivery, including values used by downstream conditions and mappings (#736). +- **Preserve structured JSON results**: Final JSON output retains its complete structured value through validation and delivery, including values used by downstream conditions and mappings. JSON strings keep their quotes, empty strings remain valid JSON, and objects shaped like file references retain every field (#736). - **Invalidate stale extend and replan selections**: Extend/replan invalidates selections when their chosen point is no longer valid, and recovery remaps the selection to the current workflow state (#736). - **Explain async hook continuation in built-in guidance**: Built-in hook guidance and prompts now describe `asyncRewake`, completion output, timeout and lifecycle constraints, one-shot behavior, and anti-loop handling. Review corrections cover environment-forced trust, HTTP failure feedback, worktree bootstrap timing, headless permission decisions, matcher restrictions, custom tool translation, and asynchronous plugin event observers. This is a guidance update; the asyncRewake runtime mechanism is unchanged (#737). @@ -22,14 +22,14 @@ ### 🧪 Test Summary ```text -DAG final delivery regressions: 19 passed, 73 assertions +DAG final delivery regressions: 31 passed, 109 assertions CLI receipt regressions: 18 passed, 81 assertions DAG mutation and stale-input tests: 16 passed, 49 assertions (2 test files) Expanded DAG regression set: 101 passed, 451 assertions Core mailbox regressions: 30 passed, 296 assertions Hook and related regressions: 372 passed (core 12, opencode 360) Package typechecks: core and opencode passed -Full DAG gate: core 153, runtime 933, schema 3, TUI 63 passed +Full DAG gate: core 153, runtime 945, schema 3, TUI 63 passed DAG gate opt-in tests: 2 skipped; all coverage floors passed Workspace typecheck: 31/31 tasks passed Lint: 4739 warnings, 0 errors (cap 4750) @@ -42,7 +42,9 @@ App Chromium / TUI report rendering: 1 passed each The DAG delivery, CLI receipt, stale-input and mutation, expanded regression, and core mailbox results above were observed on the current source changes. Both affected package typechecks passed. The full DAG gate passed with existing coverage floors intact, and workspace typechecking, CLI process regressions, app Chromium and TUI report rendering passed. ASTRA reviewed the fixes before push. -Hook self-authoring acceptance ran in the hook source worktree before this integration. An isolated Qwen session received one natural-language user prompt and loaded the built-in hook skill; the test driver registered no hooks, and the model authored the hook configuration and script. After 2.280080 seconds observably idle, a fresh nonce resumed the same session and appeared in its completed reply; no repeat was observed over the next 6 seconds. This was source-worktree acceptance; the installed binary was not replaced. The GLM self-authoring attempt timed out before reaching idle and is not counted as a pass. +Final integrated-source DAG acceptance passed independently with configured Qwen, GLM and DeepSeek: 39 assertions per model, two completed nodes, zero `submit_result` calls, one parent answer, and unchanged durable receipts after restart. A local test binary built and passed its version smoke test. Installation over `/usr/local/bin/opencode` was blocked by local administrator permissions, so these final live runs used development source and are not claimed as installed-binary acceptance. The release pipeline independently verifies stable artifacts. + +Hook self-authoring acceptance was repeated on the final integrated source. An isolated Qwen session received one natural-language user prompt and loaded the built-in hook skill; the test driver registered no hooks, and the model authored the hook configuration and script. After 2.266907 seconds observably idle, a fresh nonce resumed the same session and appeared in its completed reply; no repeat was observed over the next 6 seconds. Cleanup confirmed the user's configuration was unchanged and the temporary credentials and task-owned processes were removed. The earlier GLM self-authoring attempt timed out before reaching idle and is not counted as a pass. --- diff --git a/packages/opencode/src/dag/runtime/report-delivery.ts b/packages/opencode/src/dag/runtime/report-delivery.ts index 42b35037b..486bd3770 100644 --- a/packages/opencode/src/dag/runtime/report-delivery.ts +++ b/packages/opencode/src/dag/runtime/report-delivery.ts @@ -53,10 +53,17 @@ export function resultIndex(workflow: DagStore.WorkflowRow, nodes: readonly DagS ].join("\n") } -function reportText(node: DagStore.NodeRow, source?: SessionV1.WithParts, artifact?: ManagedOutputFileRef) { - if (artifact) return `${artifact.summary}\n\n[Complete report](${artifact.path})` - if (typeof node.output === "string") return node.output - if (isManagedOutputFileRef(node.output)) return `${node.output.summary}\n\n[Complete report](${node.output.path})` +function reportText( + node: DagStore.NodeRow, + structured: boolean, + source?: SessionV1.WithParts, + artifact?: ManagedOutputFileRef, +) { + if (!structured) { + if (artifact) return `${artifact.summary}\n\n[Complete report](${artifact.path})` + if (typeof node.output === "string") return node.output + if (isManagedOutputFileRef(node.output)) return `${node.output.summary}\n\n[Complete report](${node.output.path})` + } const final = finalAssistantText(source) if (final.ok) { const parsed = parseFinalResponse(final.text) @@ -137,10 +144,11 @@ export function deliverReport(input: { const sessionID = SessionID.make(workflow.sessionId) const nodes = yield* store.getCurrentNodes(workflow.id).pipe(Effect.orDie) const node = selectReportNode(config, nodes) + const structured = !!config.nodes.find((item) => item.id === node?.id)?.output_schema let artifact: ManagedOutputFileRef | undefined if ( node && - !config.nodes.find((item) => item.id === node.id)?.output_schema && + !structured && node.capturedOutput && typeof node.capturedOutput === "object" && "storage" in node.capturedOutput && @@ -159,7 +167,7 @@ export function deliverReport(input: { artifact = node.capturedOutput } const body = yield* boundReport( - node ? reportText(node, input.source, artifact) : resultIndex(workflow, nodes), + node ? reportText(node, structured, input.source, artifact) : resultIndex(workflow, nodes), workflow, node, ).pipe(Effect.orDie) diff --git a/packages/opencode/test/dag/dag-final-delivery.test.ts b/packages/opencode/test/dag/dag-final-delivery.test.ts index fcaa68ed7..cefaf9c7f 100644 --- a/packages/opencode/test/dag/dag-final-delivery.test.ts +++ b/packages/opencode/test/dag/dag-final-delivery.test.ts @@ -334,6 +334,63 @@ for (const field of ["report", "summary"] as const) { }) } +for (const output of [ + "false", + "", + false, + null, + 0, + { + kind: "file_ref", + content_ref: "report", + path: "/tmp/schema-report.json", + size: 10, + sha256: "schema-value", + summary: "Not an artifact", + storage: "managed-v1", + source_path: "/tmp/source.json", + provenance: { workflow_id: "wf-1", node_id: "final", child_session_id: "ses_child", replan_attempt: 0 }, + details: ["Preserve this field"], + }, +]) { + for (const withSource of [true, false]) { + it(`delivers complete schema JSON ${JSON.stringify(output)} ${withSource ? "with" : "without"} source text`, () => { + const raw = ` ${JSON.stringify(output, null, 2)}\n` + return fixture( + ({ database, deliver }) => + Effect.gen(function* () { + expect(yield* deliver()).toBe(true) + const answer = (yield* database.db.select().from(PartTable)) + .map((row) => row.data as SessionV1.TextPart) + .find((part) => part.metadata?.dag_delivery?.kind === "answer")! + expect(answer.text).toBe(withSource ? raw : JSON.stringify(output)) + expect(JSON.parse(answer.text)).toEqual(output) + }), + { + output, + schema: { type: output === null ? "null" : typeof output === "number" ? "integer" : typeof output }, + ...(withSource + ? { + source: { + info: parentAnswer("msg_child_final", "msg_child_input"), + parts: [ + { + id: "prt_child" as never, + sessionID: "ses_parent" as never, + messageID: "msg_child_final" as never, + type: "text" as const, + text: raw, + }, + ], + }, + } + : {}), + }, + ) + }) + } +} + for (const state of ["error", "unfinished", "wrong-parent", "dag-report"] as const) { it(`keeps a pending user request behind a newer ${state} assistant`, () => fixture(({ database, sessions, deliver }) => From b0d563ab62341e0ae4046a245a298b2cb90808f0 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 07:29:46 +0800 Subject: [PATCH 10/11] fix(dag): guard recovery input and align report admission --- .github/releases/v1.0.69.md | 14 ++- packages/opencode/src/cli/cmd/run/dag-hold.ts | 9 +- packages/opencode/src/dag/authoring.ts | 18 +++ packages/opencode/src/dag/runtime/recovery.ts | 7 +- .../src/dag/runtime/report-delivery.ts | 16 +++ .../opencode/test/cli/run/dag-hold.test.ts | 38 +++++- .../test/dag/dag-final-delivery.test.ts | 71 +++++++++++ .../dag-final-response-stale-failure.test.ts | 62 ++++++++++ .../test/dag/workflow-authoring.test.ts | 112 ++++++++++++++++++ 9 files changed, 335 insertions(+), 12 deletions(-) diff --git a/.github/releases/v1.0.69.md b/.github/releases/v1.0.69.md index 02e97c90a..698a08e63 100644 --- a/.github/releases/v1.0.69.md +++ b/.github/releases/v1.0.69.md @@ -15,6 +15,10 @@ - **Settle pending user input only with its complete answer**: A real pending user message now requires its corresponding successful, complete assistant answer before delivery can be considered complete (#736). - **Preserve structured JSON results**: Final JSON output retains its complete structured value through validation and delivery, including values used by downstream conditions and mappings. JSON strings keep their quotes, empty strings remain valid JSON, and objects shaped like file references retain every field (#736). - **Invalidate stale extend and replan selections**: Extend/replan invalidates selections when their chosen point is no longer valid, and recovery remaps the selection to the current workflow state (#736). +- **Preserve fresh input during recovery**: A recovered answer without an associated snapshot rechecks the mailbox and guards failure by its accepted revision, so a new message continues the node instead of being discarded (#736). +- **Reject start-only fields on replans**: Replan fragments now reject `result_protocol` and `delivery_node` with field-specific diagnostics instead of silently ignoring them (#736). +- **Recognize completed local commands**: Completed `/memory`, `/trust`, `/goal`, and `/subgoal` responses unblock final report delivery without requiring a model-generated assistant message (#736). +- **Align CLI receipt detection with the runtime**: Invalid persisted workflow configurations use the same legacy fallback in the CLI and delivery runtime, avoiding a wait for a receipt the runtime cannot produce (#736). - **Explain async hook continuation in built-in guidance**: Built-in hook guidance and prompts now describe `asyncRewake`, completion output, timeout and lifecycle constraints, one-shot behavior, and anti-loop handling. Review corrections cover environment-forced trust, HTTP failure feedback, worktree bootstrap timing, headless permission decisions, matcher restrictions, custom tool translation, and asynchronous plugin event observers. This is a guidance update; the asyncRewake runtime mechanism is unchanged (#737). --- @@ -22,14 +26,16 @@ ### 🧪 Test Summary ```text -DAG final delivery regressions: 31 passed, 109 assertions -CLI receipt regressions: 18 passed, 81 assertions +DAG final delivery regressions: 39 passed +CLI receipt regressions: 19 passed +Final four-file review regressions: 89 passed, 334 assertions +CLI process and hook guidance tests: 45 passed, 98 assertions DAG mutation and stale-input tests: 16 passed, 49 assertions (2 test files) Expanded DAG regression set: 101 passed, 451 assertions Core mailbox regressions: 30 passed, 296 assertions Hook and related regressions: 372 passed (core 12, opencode 360) Package typechecks: core and opencode passed -Full DAG gate: core 153, runtime 945, schema 3, TUI 63 passed +Full DAG gate: core 153, runtime 961, schema 3, TUI 63 passed DAG gate opt-in tests: 2 skipped; all coverage floors passed Workspace typecheck: 31/31 tasks passed Lint: 4739 warnings, 0 errors (cap 4750) @@ -40,7 +46,7 @@ App Chromium / TUI report rendering: 1 passed each ### 🔍 Verification -The DAG delivery, CLI receipt, stale-input and mutation, expanded regression, and core mailbox results above were observed on the current source changes. Both affected package typechecks passed. The full DAG gate passed with existing coverage floors intact, and workspace typechecking, CLI process regressions, app Chromium and TUI report rendering passed. ASTRA reviewed the fixes before push. +Final review regressions, DAG delivery, CLI receipt, CLI process, hook guidance, runtime package typechecking, lint, and the complete DAG gate were repeated after the four latest review fixes. The earlier mutation, expanded regression, core mailbox, workspace typecheck, and app Chromium results preceded those fixes; the repeated DAG gate includes TUI rendering and preserves all coverage floors. Both affected package typechecks passed during integration. ASTRA reviewed the fixes before push. Final integrated-source DAG acceptance passed independently with configured Qwen, GLM and DeepSeek: 39 assertions per model, two completed nodes, zero `submit_result` calls, one parent answer, and unchanged durable receipts after restart. A local test binary built and passed its version smoke test. Installation over `/usr/local/bin/opencode` was blocked by local administrator permissions, so these final live runs used development source and are not claimed as installed-binary acceptance. The release pipeline independently verifies stable artifacts. diff --git a/packages/opencode/src/cli/cmd/run/dag-hold.ts b/packages/opencode/src/cli/cmd/run/dag-hold.ts index 5b4837fb1..984761942 100644 --- a/packages/opencode/src/cli/cmd/run/dag-hold.ts +++ b/packages/opencode/src/cli/cmd/run/dag-hold.ts @@ -47,6 +47,7 @@ // engine watchdog, and a visible hang is accepted over silent wake loss. import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import { parseWorkflowConfig } from "@/dag/dag" import { reportIdentity } from "@/dag/report-identity" import { isRecord } from "@/util/record" @@ -155,13 +156,9 @@ export function createDagHold( } } +// Match the runtime gate; invalid persisted configs cannot produce direct receipts. function finalResponseConfig(raw: string): boolean { - try { - const parsed: unknown = JSON.parse(raw) - return isRecord(parsed) && parsed.result_protocol === "final_response" - } catch { - return false - } + return parseWorkflowConfig(raw)?.result_protocol === "final_response" } type ReportObservation = { diff --git a/packages/opencode/src/dag/authoring.ts b/packages/opencode/src/dag/authoring.ts index 8d2e30dec..067684b3f 100644 --- a/packages/opencode/src/dag/authoring.ts +++ b/packages/opencode/src/dag/authoring.ts @@ -89,6 +89,24 @@ export function make(options: Options = {}) { if (profile === "portable") cache.set(key, result) return result } + if (decoded.value.action === "replan") { + const fragment = decoded.value.spec.fragment + const rejected = (["result_protocol", "delivery_node"] as const) + .filter((field) => fragment[field] !== undefined) + .map((field) => + DagValidation.diagnostic({ + code: DagValidation.DIAGNOSTIC_CODES.schemaInvalid, + path: `fragment.${field}`, + message: `${field} only applies to start; replans preserve the existing capture and delivery configuration`, + hint: `Remove "${field}" from the fragment. Delivery selection is maintained by the runtime across replans.`, + }), + ) + if (rejected.length > 0) { + const result = { ...invalidResult(sourceName, profile, rejected), document: decoded.value.spec } + if (profile === "portable") cache.set(key, result) + return result + } + } const compiled = compileAction(decoded.value, input.known_dependencies) if (!compiled.nodes) { const result = { ...invalidResult(sourceName, profile, compiled.diagnostics), document: decoded.value.spec } diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index 0a390b300..67bbdcd22 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -278,6 +278,11 @@ export function reconcileWorkflow( : undefined const snapshotID = frozen?.ok ? frozen.value?.id : undefined if (final?.ok && Option.isSome(messages) && !snapshotID) { + const currentInput = caller ? yield* messages.value.revisions(caller) : undefined + if (!currentInput?.ok || currentInput.value.queued > 0) { + continuations.add(node.id) + continue + } if ( yield* settle( node.id, @@ -286,7 +291,7 @@ export function reconcileWorkflow( node.id, "recovered final assistant response has no associated input snapshot", "exec_failed", - attempt, + { ...attempt, expectedAcceptedRevision: currentInput.value.accepted }, ), ) ) diff --git a/packages/opencode/src/dag/runtime/report-delivery.ts b/packages/opencode/src/dag/runtime/report-delivery.ts index 486bd3770..09ad5d327 100644 --- a/packages/opencode/src/dag/runtime/report-delivery.ts +++ b/packages/opencode/src/dag/runtime/report-delivery.ts @@ -85,6 +85,22 @@ function latestUser(history: readonly SessionV1.WithParts[]) { function userTurnAnswered(history: readonly SessionV1.WithParts[]) { const user = latestUser(history) if (!user) return true + // Local commands store their ended response on the user message, without an assistant. + const command = user.parts.find( + (part) => part.type === "text" && ["memory", "trust", "goal", "subgoal"].includes(String(part.metadata?.command)), + ) + if ( + command && + user.parts.some( + (part) => + part.type === "text" && + part.id !== command.id && + !part.synthetic && + part.metadata?.command === undefined && + part.time?.end !== undefined, + ) + ) + return true const assistant = history .filter( (item) => diff --git a/packages/opencode/test/cli/run/dag-hold.test.ts b/packages/opencode/test/cli/run/dag-hold.test.ts index 6421439dc..3ca07acf1 100644 --- a/packages/opencode/test/cli/run/dag-hold.test.ts +++ b/packages/opencode/test/cli/run/dag-hold.test.ts @@ -287,10 +287,21 @@ describe("dag hold decision rule (issue 614)", () => { }) describe("final-response answer receipt polling", () => { + // Same shape a real final_response workflow persists (see the YAML specs in + // run-dag-hold-process.test.ts): parseWorkflowConfig must accept it. + const finalResponseNodes = [{ id: "final", name: "final", worker_type: "general", depends_on: [] }] + const finalResponseConfigJson = () => + JSON.stringify({ + name: "cli-final-response", + result_protocol: "final_response", + delivery_node: "final", + max_concurrency: 1, + nodes: finalResponseNodes, + }) const completed = (id: string, seq: number) => ({ id, status: "completed", - config: JSON.stringify({ result_protocol: "final_response" }), + config: finalResponseConfigJson(), seq, completed_at: 1000 + seq, time_updated: 1000 + seq, @@ -398,6 +409,31 @@ describe("final-response answer receipt polling", () => { expect(reads).toBe(3) }) + test("malformed and non-final_response configs are legacy rows that never wait for receipts", async () => { + const rows = [ + "not json at all", + JSON.stringify({ result_protocol: "final_response" }), // no nodes + JSON.stringify({ result_protocol: "final_response", nodes: "nope" }), // invalid nodes + JSON.stringify({ result_protocol: "final_response", delivery_node: 7, nodes: finalResponseNodes }), // invalid delivery_node + JSON.stringify({ result_protocol: "bogus", nodes: finalResponseNodes }), // invalid protocol + JSON.stringify({ result_protocol: "submit_result", nodes: finalResponseNodes }), // submit_result stays legacy + JSON.stringify({ nodes: finalResponseNodes }), // missing protocol stays legacy + ].map((config, index) => ({ ...completed(`legacy-${index}`, index + 1), config })) + let reads = 0 + const client = { + dag: { bySession: async () => ({ data: rows }) }, + session: { + message: async () => { + reads++ + return { error: { name: "NotFoundError", data: { message: "Message not found" } } } + }, + }, + } as unknown as OpencodeClient + const poll = DagHold.makeDagWorkflowPoll(client, "parent") + expect((await poll())?.every((row) => row.awaitingReport === undefined)).toBe(true) + expect(reads).toBe(0) + }) + test("startup retries a failed workflow list before freezing the receipt baseline", async () => { const old = completed("old-list", 13) let listReads = 0 diff --git a/packages/opencode/test/dag/dag-final-delivery.test.ts b/packages/opencode/test/dag/dag-final-delivery.test.ts index cefaf9c7f..679e67452 100644 --- a/packages/opencode/test/dag/dag-final-delivery.test.ts +++ b/packages/opencode/test/dag/dag-final-delivery.test.ts @@ -443,6 +443,77 @@ for (const state of ["error", "unfinished", "wrong-parent", "dag-report"] as con )) } +for (const command of ["memory", "trust", "goal", "subgoal"]) + it(`delivers after a completed local /${command} without an assistant response`, () => + fixture(({ sessions, store, deliver }) => + Effect.gen(function* () { + const user: SessionV1.User = { + id: "msg_command" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + } + yield* sessions.updateMessage(user) + yield* sessions.updatePart({ + id: "prt_command" as never, + messageID: user.id, + sessionID: user.sessionID, + type: "text", + text: `/${command} status`, + metadata: { command }, + }) + expect(yield* deliver()).toBe(false) + yield* sessions.updatePart({ + id: "prt_response" as never, + messageID: user.id, + sessionID: user.sessionID, + type: "text", + text: "Command completed", + ignored: command === "goal" || command === "subgoal", + time: { start: 60, end: 60 }, + }) + expect(yield* deliver()).toBe(true) + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(true) + }), + )) + +for (const state of ["unfinished", "synthetic", "same-part", "model-command"] as const) + it(`does not treat a ${state} command response as an answered user turn`, () => + fixture(({ sessions, deliver }) => + Effect.gen(function* () { + yield* sessions.updateMessage({ + id: "msg_command" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + yield* sessions.updatePart({ + id: "prt_command" as never, + messageID: "msg_command" as never, + sessionID: "ses_parent" as never, + type: "text", + text: "/command", + metadata: { command: state === "model-command" ? "review" : "memory" }, + ...(state === "same-part" ? { time: { start: 50, end: 50 } } : {}), + }) + if (state !== "same-part") + yield* sessions.updatePart({ + id: "prt_response" as never, + messageID: "msg_command" as never, + sessionID: "ses_parent" as never, + type: "text", + text: "Response text", + synthetic: state === "synthetic", + time: { start: 60, ...(state === "unfinished" ? {} : { end: 60 }) }, + }) + expect(yield* deliver()).toBe(false) + }), + )) + it("keeps an unanswered user behind more than twenty assistant tool turns", () => fixture(({ database, sessions, deliver }) => Effect.gen(function* () { diff --git a/packages/opencode/test/dag/dag-final-response-stale-failure.test.ts b/packages/opencode/test/dag/dag-final-response-stale-failure.test.ts index 629a85c01..e9415a942 100644 --- a/packages/opencode/test/dag/dag-final-response-stale-failure.test.ts +++ b/packages/opencode/test/dag/dag-final-response-stale-failure.test.ts @@ -310,4 +310,66 @@ describe("final-response failure freshness", () => { expect(value(yield* messages.receive(child))[0].state).toBe("queued") }), ) + + for (const arrival of ["before-check", "after-check", "none"] as const) + it.live(`recovery without a response snapshot preserves input arriving ${arrival}`, () => + Effect.gen(function* () { + yield* seed() + const dag = yield* Dag.Service + const messages = yield* DagMessages.Service + let checks = 0 + const database = yield* Database.Service + const observed = { + ...messages, + revisions: (caller: DagMessages.Caller) => + messages.revisions(caller).pipe( + Effect.tap(() => { + checks++ + return arrival === "after-check" && checks === 2 + ? accept().pipe(Effect.provideService(DagMessages.Service, messages), Effect.asVoid) + : Effect.void + }), + ), + } + const config = { result_protocol: "final_response" as const, nodes: [{ id: "n" }] } + const result = yield* reconcileWorkflow( + workflowID, + () => Effect.succeed("completed"), + undefined, + config, + undefined, + undefined, + undefined, + () => + Effect.gen(function* () { + if (arrival === "before-check") yield* accept() + return reply(MessageID.ascending(), "unassociated old result") + }).pipe(Effect.provideService(DagMessages.Service, messages)), + ).pipe(Effect.provideService(DagMessages.Service, observed)) + if (arrival === "none") { + expect(result).toMatchObject({ reconciled: 1 }) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("failed") + expect(value(yield* messages.metadata(parent, workflowID, "n")).closedReason).toBe("exec_failed") + return + } + expect(result).toMatchObject({ reconciled: 0, continuations: ["n"] }) + expect((yield* dag.store.getNode(workflowID, "n"))?.status).toBe("running") + expect(value(yield* messages.receive(child))[0].state).toBe("queued") + expect(value(yield* messages.metadata(parent, workflowID, "n")).closedReason).toBeUndefined() + const prompt = Layer.mock(SessionPrompt.Service, { + loop: () => + Effect.gen(function* () { + const turn = MessageID.ascending() + yield* associate(turn) + return reply(turn, "fresh result") + }).pipe( + Effect.provideService(Database.Service, database), + Effect.provideService(DagMessages.Service, messages), + ), + }) + yield* continueRecoveredMessageNode(workflowID, "n", config).pipe(Effect.provide(prompt)) + expect(yield* dag.store.getNode(workflowID, "n")).toMatchObject({ status: "completed", output: "fresh result" }) + expect(value(yield* messages.receive(child))[0].state).toBe("delivered") + }), + ) }) diff --git a/packages/opencode/test/dag/workflow-authoring.test.ts b/packages/opencode/test/dag/workflow-authoring.test.ts index e79b3a3ed..2317684e5 100644 --- a/packages/opencode/test/dag/workflow-authoring.test.ts +++ b/packages/opencode/test/dag/workflow-authoring.test.ts @@ -368,3 +368,115 @@ describe("WorkflowAuthoring replan envelope (P0-B)", () => { }), ) }) + +describe("WorkflowAuthoring replan start-only capture fields", () => { + const replanPrepare = (content: string[]) => + WorkflowAuthoring.make().prepare({ + action: "replan", + source: { kind: "yaml", source: "replan.yaml", content: content.join("\n") }, + profile: "portable", + }) + + const nodeLines = [ + " - id: work", + " name: work", + " worker_type: general", + " depends_on: []", + " prompt_template: { inline: Do the work }", + ] + + it.effect("rejects a nodes replan fragment that specifies delivery_node with a field-specific diagnostic", () => + Effect.gen(function* () { + const result = yield* replanPrepare([ + "fragment:", + " name: rebuild", + " delivery_node: work", + " nodes:", + ...nodeLines, + ]) + expect(result.valid).toBe(false) + expect(result.prepared).toBeUndefined() + const error = result.errors.find((diagnostic) => diagnostic.path === "fragment.delivery_node") + expect(error).toBeDefined() + expect(error?.code).toBe(DagValidation.DIAGNOSTIC_CODES.schemaInvalid) + expect(error?.message).toContain("delivery_node only applies to start") + expect(error?.hint).toContain('Remove "delivery_node" from the fragment.') + }), + ) + + it.effect("rejects a blocks replan fragment that specifies delivery_node", () => + Effect.gen(function* () { + const result = yield* replanPrepare([ + "fragment:", + " name: rebuild", + " objective: Rebuild the thing.", + " delivery_node: diagnose", + " blocks:", + " - id: diagnose", + " kind: debug", + " instruction: Reproduce the failure.", + ]) + expect(result.valid).toBe(false) + const error = result.errors.find((diagnostic) => diagnostic.path === "fragment.delivery_node") + expect(error).toBeDefined() + expect(error?.code).toBe(DagValidation.DIAGNOSTIC_CODES.schemaInvalid) + }), + ) + + it.effect("rejects result_protocol on replans", () => + Effect.gen(function* () { + const result = yield* replanPrepare([ + "fragment:", + " name: rebuild", + " result_protocol: final_response", + " nodes:", + ...nodeLines, + ]) + expect(result.valid).toBe(false) + const error = result.errors.find((diagnostic) => diagnostic.path === "fragment.result_protocol") + expect(error).toBeDefined() + expect(error?.code).toBe(DagValidation.DIAGNOSTIC_CODES.schemaInvalid) + }), + ) + + it.effect("accepts the same config for start", () => + Effect.gen(function* () { + const result = yield* WorkflowAuthoring.make().prepare({ + action: "start", + source: { + kind: "yaml", + source: "start.yaml", + content: [ + "config:", + " name: capture", + " delivery_node: work", + " result_protocol: final_response", + " nodes:", + ...nodeLines, + ].join("\n"), + }, + profile: "portable", + }) + expect(result.errors).toEqual([]) + expect(result.valid).toBe(true) + expect(result.prepared?.action).toBe("start") + }), + ) + + it.effect("keeps ordinary replans and historical config fields working", () => + Effect.gen(function* () { + const result = yield* replanPrepare([ + "fragment:", + " name: rebuild", + " max_total_nodes: 40", + " node_defaults:", + " required: true", + " nodes:", + ...nodeLines, + ]) + expect(result.errors).toEqual([]) + expect(result.valid).toBe(true) + expect(result.prepared?.nodes).toHaveLength(1) + }), + ) +}) From 2c3a4b8471cc1a8fe503a96dbf44f1b62115e0f1 Mon Sep 17 00:00:00 2001 From: Lex Date: Sun, 11 Oct 2026 09:35:10 +0800 Subject: [PATCH 11/11] fix(dag): render durable reports without local events --- .github/releases/v1.0.69.md | 19 +- packages/opencode/src/cli/cmd/run.ts | 241 +++++++++--------- packages/opencode/src/cli/cmd/run/dag-hold.ts | 145 +++++++++-- .../src/dag/runtime/report-delivery.ts | 29 ++- .../opencode/test/cli/run/dag-hold.test.ts | 111 +++++++- .../test/dag/dag-final-delivery.test.ts | 170 +++++++++++- 6 files changed, 550 insertions(+), 165 deletions(-) diff --git a/.github/releases/v1.0.69.md b/.github/releases/v1.0.69.md index 698a08e63..93d63dd30 100644 --- a/.github/releases/v1.0.69.md +++ b/.github/releases/v1.0.69.md @@ -8,7 +8,7 @@ - **Capture the worker's complete final answer**: New workflows use the persisted `final_response` protocol instead of requiring a separate `submit_result` report. The runtime collects all ordinary text parts of the final assistant answer, or validates one final JSON value when an output schema is present. Valid `null`, `false`, and zero results retain their original shape. Historical workflows without a protocol field continue using `submit_result` (#735). - **Deliver the final report once**: An explicit delivery node or a unique terminal synthesis node supplies the report shown verbatim in the parent conversation. Durable receipts and wake markers commit together, preventing duplicate delivery after restart. Normal completed reports no longer start a parent model turn to repeat the answer; pause, failure, timeout, and clarification still use the existing decision path. Ambiguous outputs produce a deterministic result index (#735). -- **Wait for CLI report rendering before exit**: The CLI checks both the durable delivery receipt and the answer event consumed by the current process. Regressions cover multiple workflows, old idle events, equal-time message ordering, resumed sessions, and mixed report delivery with pending parent decisions (#735). +- **Wait for CLI report rendering before exit**: The CLI checks the durable delivery receipt and renders each new answer before exiting. Idle polling also renders reports committed by another process when no local answer event arrives, while source and answer identities prevent duplicate output from later events. Regressions cover multiple workflows, old idle events, equal-time message ordering, resumed sessions, and mixed report delivery with pending parent decisions (#735, #736). - **Retry CLI receipt baselines without masking network errors**: Transient failures while reading the initial receipt state now retry within a bound. A network error is no longer treated as proof that a receipt is absent, so the CLI cannot start from an unknown delivery baseline (#736). - **Commit EventV2 reports, receipts, and wakes together**: Report events, durable receipts, and wake markers are persisted in the same transaction; listeners are notified only after the transaction commits (#736). - **Continue after stale final-response validation**: When validation of a saved `final_response` fails after new parent input arrives, the run becomes a continuation instead of a terminal failure (#736). @@ -19,6 +19,8 @@ - **Reject start-only fields on replans**: Replan fragments now reject `result_protocol` and `delivery_node` with field-specific diagnostics instead of silently ignoring them (#736). - **Recognize completed local commands**: Completed `/memory`, `/trust`, `/goal`, and `/subgoal` responses unblock final report delivery without requiring a model-generated assistant message (#736). - **Align CLI receipt detection with the runtime**: Invalid persisted workflow configurations use the same legacy fallback in the CLI and delivery runtime, avoiding a wait for a receipt the runtime cannot produce (#736). +- **Recognize failed local command responses**: Historical `/goal` and `/subgoal` failures stored as ended synthetic, ignored responses count as completed local turns. Ordinary or unfinished lookalike text still leaves the user request pending (#736). +- **Defer large report artifacts until admission**: Pending user requests and stale completion episodes are checked before large reports create managed files, preventing artifacts from delivery attempts that are already ineligible (#736). - **Explain async hook continuation in built-in guidance**: Built-in hook guidance and prompts now describe `asyncRewake`, completion output, timeout and lifecycle constraints, one-shot behavior, and anti-loop handling. Review corrections cover environment-forced trust, HTTP failure feedback, worktree bootstrap timing, headless permission decisions, matcher restrictions, custom tool translation, and asynchronous plugin event observers. This is a guidance update; the asyncRewake runtime mechanism is unchanged (#737). --- @@ -26,16 +28,17 @@ ### 🧪 Test Summary ```text -DAG final delivery regressions: 39 passed -CLI receipt regressions: 19 passed -Final four-file review regressions: 89 passed, 334 assertions +DAG final delivery regressions: 47 passed +CLI receipt regressions: 23 passed +Latest delivery / CLI regressions: 80 passed, 298 assertions +Earlier four-file review regressions: 89 passed, 334 assertions CLI process and hook guidance tests: 45 passed, 98 assertions DAG mutation and stale-input tests: 16 passed, 49 assertions (2 test files) Expanded DAG regression set: 101 passed, 451 assertions Core mailbox regressions: 30 passed, 296 assertions Hook and related regressions: 372 passed (core 12, opencode 360) Package typechecks: core and opencode passed -Full DAG gate: core 153, runtime 961, schema 3, TUI 63 passed +Full DAG gate: core 153, runtime 969, schema 3, TUI 63 passed DAG gate opt-in tests: 2 skipped; all coverage floors passed Workspace typecheck: 31/31 tasks passed Lint: 4739 warnings, 0 errors (cap 4750) @@ -46,11 +49,11 @@ App Chromium / TUI report rendering: 1 passed each ### 🔍 Verification -Final review regressions, DAG delivery, CLI receipt, CLI process, hook guidance, runtime package typechecking, lint, and the complete DAG gate were repeated after the four latest review fixes. The earlier mutation, expanded regression, core mailbox, workspace typecheck, and app Chromium results preceded those fixes; the repeated DAG gate includes TUI rendering and preserves all coverage floors. Both affected package typechecks passed during integration. ASTRA reviewed the fixes before push. +The latest delivery, CLI receipt, and CLI process regressions, workspace typecheck, lint, and complete DAG gate passed after the final three delivery fixes. The earlier four-file review, mutation, expanded regression, core mailbox, hook guidance, and app Chromium results preceded these fixes; the repeated DAG gate includes TUI rendering and preserves all coverage floors. Both affected package typechecks passed during integration. Final review results are recorded in the pull request before merge. -Final integrated-source DAG acceptance passed independently with configured Qwen, GLM and DeepSeek: 39 assertions per model, two completed nodes, zero `submit_result` calls, one parent answer, and unchanged durable receipts after restart. A local test binary built and passed its version smoke test. Installation over `/usr/local/bin/opencode` was blocked by local administrator permissions, so these final live runs used development source and are not claimed as installed-binary acceptance. The release pipeline independently verifies stable artifacts. +Before the final three delivery fixes, integrated-source DAG acceptance passed independently with configured Qwen, GLM and DeepSeek: 39 assertions per model, two completed nodes, zero `submit_result` calls, one parent answer, and unchanged durable receipts after restart. A local test binary built and passed its version smoke test at that stage. Installation over `/usr/local/bin/opencode` was blocked by local administrator permissions, so these live runs used development source and are not claimed as installed-binary acceptance. The release pipeline independently verifies stable artifacts. -Hook self-authoring acceptance was repeated on the final integrated source. An isolated Qwen session received one natural-language user prompt and loaded the built-in hook skill; the test driver registered no hooks, and the model authored the hook configuration and script. After 2.266907 seconds observably idle, a fresh nonce resumed the same session and appeared in its completed reply; no repeat was observed over the next 6 seconds. Cleanup confirmed the user's configuration was unchanged and the temporary credentials and task-owned processes were removed. The earlier GLM self-authoring attempt timed out before reaching idle and is not counted as a pass. +Hook self-authoring acceptance was repeated on the integrated source before the final three delivery fixes. An isolated Qwen session received one natural-language user prompt and loaded the built-in hook skill; the test driver registered no hooks, and the model authored the hook configuration and script. After 2.266907 seconds observably idle, a fresh nonce resumed the same session and appeared in its completed reply; no repeat was observed over the next 6 seconds. Cleanup confirmed the user's configuration was unchanged and the temporary credentials and task-owned processes were removed. The earlier GLM self-authoring attempt timed out before reaching idle and is not counted as a pass. --- diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2d5b82852..be52ccb3c 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -22,7 +22,7 @@ import { UI } from "../ui" import { effectCmd } from "../effect-cmd" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" +import { createOpencodeClient, type OpencodeClient, type TextPart, type ToolPart } from "@opencode-ai/sdk/v2" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" import { DagHold } from "./run/dag-hold" @@ -659,6 +659,7 @@ export const RunCommand = effectCmd({ process.exit(1) } const sessionID = sess.id + let dagHold: DagHold.DagHold | undefined function emit(type: string, data: Record) { if (args.format === "json") { @@ -675,6 +676,22 @@ export const RunCommand = effectCmd({ return false } + function renderText(part: TextPart) { + if (dagHold?.hasText(part)) return + if (!emit("text", { part })) { + const text = part.text.trim() + if (text) { + if (!process.stdout.isTTY) process.stdout.write(text + EOL) + else { + UI.empty() + UI.println(text) + UI.empty() + } + } + } + dagHold?.onText(part) + } + // Consume one subscribed event stream for the active session and mirror it // to stdout/UI. `client` is passed explicitly because attach mode may // rebind the SDK to the session's directory after the subscription is @@ -682,7 +699,7 @@ export const RunCommand = effectCmd({ async function loop( client: OpencodeClient, events: Awaited>, - dagHold?: DagHold.DagHold, + stop: () => void, ) { const toggles = new Map() let error: string | undefined @@ -691,138 +708,123 @@ export const RunCommand = effectCmd({ // events while a session workflow may still owe a wake turn. Attach // mode keeps today's break-on-first-idle exit. - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.sessionID === sessionID && - event.properties.info.role === "user" && - dagHold - ) - dagHold.onUserMessage(event.properties.info.id) - if ( - event.type === "message.updated" && - event.properties.sessionID === sessionID && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) - } - - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue - - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - await tool(part) - continue - } - await toolError(part) - UI.error(part.state.error) - } - + try { + for await (const event of dagHold ? DagHold.pollEvents(events.stream, sessionID, stop) : events.stream) { if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" + event.type === "message.updated" && + event.properties.sessionID === sessionID && + event.properties.info.role === "user" && + dagHold + ) + dagHold.onUserMessage(event.properties.info.id) + if ( + event.type === "message.updated" && + event.properties.sessionID === sessionID && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true ) { - if (toggles.get(part.id) === true) continue - await tool(part) - toggles.set(part.id, true) + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + await tool(part) + continue + } + await toolError(part) + UI.error(part.state.error) + } - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + await tool(part) + toggles.set(part.id, true) + } - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) { - dagHold?.onText(part) - continue + if (part.type === "step-start") { + if (emit("step_start", { part })) continue } - const text = part.text.trim() - if (!text) { - // Empty final answers are intentionally not printed, but - // their event still crossed this consumer boundary. - dagHold?.onText(part) - continue + + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue } - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - dagHold?.onText(part) - continue + + if (part.type === "text" && part.time?.end) { + renderText(part) } - UI.empty() - UI.println(text) - UI.empty() - dagHold?.onText(part) - } - if (part.type === "reasoning" && part.time?.end && thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue + if (part.type === "reasoning" && part.time?.end && thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) } - process.stdout.write(line + EOL) } - } - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue + UI.error(err) } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - if (event.type === "session.status" && event.properties.sessionID === sessionID) { - if (event.properties.status.type === "busy") { - await dagHold?.onBusy() + if (event.type === "session.status" && event.properties.sessionID === sessionID) { + if (event.properties.status.type === "busy") { + await dagHold?.onBusy() + } + if (event.properties.status.type === "idle" && (await dagHold?.onIdle()) !== "hold") break } - if (event.properties.status.type === "idle" && (await dagHold?.onIdle()) !== "hold") break - } - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (args["dangerously-skip-permissions"]) { - await client.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await client.permission.reply({ - requestID: permission.id, - reply: "reject", - }) + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue + + if (args["dangerously-skip-permissions"]) { + await client.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await client.permission.reply({ + requestID: permission.id, + reply: "reject", + }) + } } } + } finally { + stop() } return error } @@ -835,12 +837,13 @@ export const RunCommand = effectCmd({ await share(client, sessionID) if (!interactive) { - const events = await client.event.subscribe() + const subscription = new AbortController() + const events = await client.event.subscribe(undefined, { signal: subscription.signal }) const requestedMessageID = args.attach ? undefined : MessageID.ascending() - const dagHold = args.attach + dagHold = args.attach ? undefined - : await DagHold.createDagRunHold(client, sessionID, requestedMessageID) - const completed = loop(client, events, dagHold).catch((e) => { + : await DagHold.createDagRunHold(client, sessionID, requestedMessageID, renderText) + const completed = loop(client, events, () => subscription.abort()).catch((e) => { console.error(e) process.exitCode = 1 }) diff --git a/packages/opencode/src/cli/cmd/run/dag-hold.ts b/packages/opencode/src/cli/cmd/run/dag-hold.ts index 984761942..16380e38b 100644 --- a/packages/opencode/src/cli/cmd/run/dag-hold.ts +++ b/packages/opencode/src/cli/cmd/run/dag-hold.ts @@ -26,8 +26,8 @@ // // Break on idle iff ALL hold: // 1. poll(now) shows no workflow in {pending, running} -// 2. busyActive === false (active work seen this turn means a wake is owed -// even if the poll is already quiescent) +// 2. busyActive === false, or every workflow has a delivered final report +// (legacy workflows still require their wake turn) // 3. every id in poll(now) was already known (a first-seen workflow id means // a wake for it may still be in flight — covers workflows that // terminalize within turn one and chained DAGs started during a wake turn) @@ -43,10 +43,10 @@ // completed workflow's answer from the parent transcript. // Paused and stepping workflows are not active statuses, so they never hold the // loop open by themselves — the CLI exits as it does today once quiescent and -// known. There is no timer and no reconnect: an engine stall is bounded by the -// engine watchdog, and a visible hang is accepted over silent wake loss. +// known. Idle polling also covers receipts committed by another process whose +// events do not reach this subscription. Engine stalls remain watchdog-bounded. -import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { Event, OpencodeClient, TextPart } from "@opencode-ai/sdk/v2" import { parseWorkflowConfig } from "@/dag/dag" import { reportIdentity } from "@/dag/report-identity" import { isRecord } from "@/util/record" @@ -105,7 +105,9 @@ export function applyDagHoldSnapshot(state: DagHoldState, snapshot: readonly Dag const active = snapshot.some((workflow) => isDagActiveStatus(workflow.status)) const awaitingReport = snapshot.some((workflow) => workflow.awaitingReport === true) const known = snapshot.every((workflow) => state.busyIds.has(workflow.id)) - const decision: DagHoldDecision = !active && !state.busyActive && known && !awaitingReport ? "break" : "hold" + const delivered = snapshot.length > 0 && snapshot.every((workflow) => workflow.awaitingReport === false) + const decision: DagHoldDecision = + !active && (!state.busyActive || delivered) && known && !awaitingReport ? "break" : "hold" state.inTurn = false foldSnapshot(state, snapshot) return decision @@ -113,6 +115,45 @@ export function applyDagHoldSnapshot(state: DagHoldState, snapshot: readonly Dag export type DagHoldPoll = () => Promise +/** Keep one event read in flight while idle polls observe cross-process delivery. */ +export async function* pollEvents( + events: AsyncIterable, + sessionID: string, + stop: () => void, + interval = 1_000, +): AsyncGenerator { + const iterator = events[Symbol.asyncIterator]() + let pending: ReturnType | undefined + let idle = false + try { + while (true) { + pending ??= iterator.next() + let timer: ReturnType | undefined + const next: IteratorResult | undefined = idle + ? await Promise.race([ + pending, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), interval) + }), + ]).finally(() => clearTimeout(timer)) + : await pending + if (!next) { + yield { id: "dag-hold-poll", type: "session.status", properties: { sessionID, status: { type: "idle" } } } + continue + } + pending = undefined + if (next.done) break + const event: Event = next.value + if (event.type === "session.status" && event.properties.sessionID === sessionID) + idle = event.properties.status.type === "idle" + yield event + } + } finally { + stop() + await iterator.return?.() + } +} + export type DagHold = { /** session.status=busy for the target session: sample the workflow snapshot * (failures ignored) and latch per-turn active state. */ @@ -128,6 +169,7 @@ export type DagHold = { text?: string metadata?: Record }) => void + readonly hasText: (part: Parameters[0]) => boolean /** The current command/prompt user message entered this event stream. */ readonly onUserMessage: (messageID: string) => void } @@ -136,6 +178,7 @@ export function createDagHold( poll: DagHoldPoll, onText: DagHold["onText"] = () => {}, requestedMessageID?: string, + hasText: DagHold["hasText"] = () => false, ): DagHold { const state = initialDagHoldState() let requestObserved = requestedMessageID === undefined @@ -150,6 +193,7 @@ export function createDagHold( return requestObserved ? decision : "hold" }, onText, + hasText, onUserMessage: (messageID) => { if (messageID === requestedMessageID) requestObserved = true }, @@ -164,22 +208,37 @@ function finalResponseConfig(raw: string): boolean { type ReportObservation = { readonly baseline: ReadonlySet readonly emitted: ReadonlySet + readonly render?: (part: TextPart) => void } function episodeKey(sessionID: string, workflowID: string, seq: number): string { return JSON.stringify([sessionID, workflowID, seq]) } -function answerKey(sessionID: string, messageID: string, partID: string, workflowID: string, seq: number): string { - return JSON.stringify([sessionID, messageID, partID, workflowID, seq]) +function answerKey( + sessionID: string, + messageID: string, + partID: string, + workflowID: string, + seq: number, + kind = "answer", +): string { + return JSON.stringify([sessionID, messageID, partID, workflowID, seq, kind]) } -function answerWasEmitted(part: Parameters[0]): string | undefined { +function reportWasEmitted(part: Parameters[0]): string | undefined { if (!isRecord(part.metadata)) return undefined const delivery = part.metadata.dag_delivery - if (!isRecord(delivery) || delivery.kind !== "answer") return undefined + if (!isRecord(delivery) || (delivery.kind !== "answer" && delivery.kind !== "source")) return undefined if (typeof delivery.workflow_id !== "string" || typeof delivery.completion_seq !== "number") return undefined - return answerKey(part.sessionID, part.messageID, part.id, delivery.workflow_id, delivery.completion_seq) + return answerKey( + part.sessionID, + part.messageID, + part.id, + delivery.workflow_id, + delivery.completion_seq, + delivery.kind, + ) } function isNotFoundError(error: unknown): boolean { @@ -229,26 +288,41 @@ export async function dagWorkflowsBySession( observation !== undefined && !observation.baseline.has(episode) && !observation.emitted.has(answerKey(sessionID, identity.messageID, identity.answerID, workflow.id, seq)) - if (verifiedReceipts.has(episode)) return { ...basic, awaitingReport: awaitingEmission() } + if (verifiedReceipts.has(episode) && (!awaitingEmission() || !observation?.render)) + return { ...basic, awaitingReport: awaitingEmission() } try { const receipt = await client.session.message( { sessionID, messageID: identity.messageID }, signal ? { signal } : undefined, ) - const found = - !receipt.error && - receipt.data?.info.id === identity.messageID && - receipt.data.parts.some((part) => { - if (part.type !== "text" || part.id !== identity.answerID || !isRecord(part.metadata)) return false - const delivery = part.metadata.dag_delivery - return ( - isRecord(delivery) && - delivery.workflow_id === workflow.id && - delivery.completion_seq === seq && - delivery.kind === "answer" - ) - }) + const answer = + !receipt.error && receipt.data?.info.id === identity.messageID + ? receipt.data.parts.find((part): part is TextPart => { + if (part.type !== "text" || part.id !== identity.answerID || !isRecord(part.metadata)) return false + const delivery = part.metadata.dag_delivery + return ( + isRecord(delivery) && + delivery.workflow_id === workflow.id && + delivery.completion_seq === seq && + delivery.kind === "answer" + ) + }) + : undefined + const found = answer !== undefined if (found) verifiedReceipts.add(episode) + if (answer && awaitingEmission() && observation?.render) { + const source = receipt.data?.parts.find( + (part): part is TextPart => + part.type === "text" && + part.id === identity.sourceID && + isRecord(part.metadata?.dag_delivery) && + part.metadata.dag_delivery.workflow_id === workflow.id && + part.metadata.dag_delivery.completion_seq === seq && + part.metadata.dag_delivery.kind === "source", + ) + if (source) observation.render(source) + observation.render(answer) + } const missing = isNotFoundError(receipt.error) || responseStatus(receipt.response) === 404 return { ...basic, awaitingReport: !found || awaitingEmission(), receiptUnknown: !!receipt.error && !missing } } catch { @@ -303,18 +377,35 @@ export async function createDagRunHold( client: OpencodeClient, sessionID: string, requestedMessageID?: string, + render?: (part: TextPart) => void, ): Promise { const verifiedReceipts = await startupBaseline(client, sessionID) const baseline = new Set(verifiedReceipts) const emitted = new Set() - const poll = () => dagWorkflowsBySession(client, sessionID, verifiedReceipts, { baseline, emitted }) + const poll = () => + dagWorkflowsBySession(client, sessionID, verifiedReceipts, { + baseline, + emitted, + render: render + ? (part) => { + const key = reportWasEmitted(part) + if (!key || emitted.has(key)) return + render(part) + emitted.add(key) + } + : undefined, + }) return createDagHold( poll, (part) => { - const key = answerWasEmitted(part) + const key = reportWasEmitted(part) if (key) emitted.add(key) }, requestedMessageID, + (part) => { + const key = reportWasEmitted(part) + return key !== undefined && emitted.has(key) + }, ) } diff --git a/packages/opencode/src/dag/runtime/report-delivery.ts b/packages/opencode/src/dag/runtime/report-delivery.ts index 09ad5d327..ed23f2f4f 100644 --- a/packages/opencode/src/dag/runtime/report-delivery.ts +++ b/packages/opencode/src/dag/runtime/report-delivery.ts @@ -85,6 +85,18 @@ function latestUser(history: readonly SessionV1.WithParts[]) { function userTurnAnswered(history: readonly SessionV1.WithParts[]) { const user = latestUser(history) if (!user) return true + // Historical goal dispatch failures have no command metadata or assistant. + if ( + user.parts.some( + (part) => + part.type === "text" && + part.synthetic && + part.ignored && + part.time?.end !== undefined && + ["goal", "subgoal"].some((command) => part.text === `⚠️ /${command} 执行失败,请检查日志。`), + ) + ) + return true // Local commands store their ended response on the user message, without an assistant. const command = user.parts.find( (part) => part.type === "text" && ["memory", "trust", "goal", "subgoal"].includes(String(part.metadata?.command)), @@ -158,6 +170,15 @@ export function deliverReport(input: { if (workflow.status !== "completed" || config?.result_protocol !== "final_response") return false const identity = reportIdentity(workflow) const sessionID = SessionID.make(workflow.sessionId) + const getReceipt = () => sessions.getPart({ sessionID, messageID: identity.messageID, partID: identity.answerID }) + const currentEpisode = Effect.gen(function* () { + const current = yield* store.getWorkflow(workflow.id) + return current?.status === "completed" && current.seq === workflow.seq && current.graphRev === workflow.graphRev + }).pipe(Effect.orDie) + if (!(yield* currentEpisode)) return false + const receipt = yield* getReceipt() + const history = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) + if (!receipt && !userTurnAnswered(history)) return false const nodes = yield* store.getCurrentNodes(workflow.id).pipe(Effect.orDie) const node = selectReportNode(config, nodes) const structured = !!config.nodes.find((item) => item.id === node?.id)?.output_schema @@ -197,16 +218,10 @@ export function deliverReport(input: { ...((artifact ?? body.artifact) ? { artifact: artifact ?? body.artifact } : {}), } const database = yield* Database.Service - const getReceipt = () => sessions.getPart({ sessionID, messageID: identity.messageID, partID: identity.answerID }) const receiptMatches = (receipt: SessionV1.Part) => receipt.type === "text" && receipt.text === body.text && receipt.metadata?.dag_delivery?.completion_seq === workflow.seq - const currentEpisode = Effect.gen(function* () { - const current = yield* store.getWorkflow(workflow.id) - return current?.status === "completed" && current.seq === workflow.seq && current.graphRev === workflow.graphRev - }).pipe(Effect.orDie) - const receipt = yield* getReceipt() if (receipt) { if (!receiptMatches(receipt)) return yield* Effect.die(new Error("DAG final report receipt does not match its completion episode")) @@ -225,8 +240,6 @@ export function deliverReport(input: { ) .pipe(Effect.orDie) } - const history = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) - if (!userTurnAnswered(history)) return false const user = latestUser(history) const entries: EventV2.BatchEvent[] = [] const appendMessage = (info: SessionV1.Info) => { diff --git a/packages/opencode/test/cli/run/dag-hold.test.ts b/packages/opencode/test/cli/run/dag-hold.test.ts index 3ca07acf1..7ce1df9f4 100644 --- a/packages/opencode/test/cli/run/dag-hold.test.ts +++ b/packages/opencode/test/cli/run/dag-hold.test.ts @@ -4,7 +4,7 @@ // wrapper: each row is one CLI run's poll sequence with the expected idle // decisions. Real-CLI lifecycle coverage lives in run-dag-hold-process.test.ts. import { describe, expect, test } from "bun:test" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import { reportIdentity } from "@/dag/report-identity" import { DagHold } from "../../../src/cli/cmd/run/dag-hold" @@ -42,7 +42,7 @@ function drive(steps: readonly Step[]): Promise { })() } -const wf = (id: string, status: string, awaitingReport = false): DagHold.DagWorkflowSnapshot => ({ +const wf = (id: string, status: string, awaitingReport?: boolean): DagHold.DagWorkflowSnapshot => ({ id, status, awaitingReport, @@ -632,4 +632,111 @@ describe("final-response answer receipt polling", () => { fails = false expect(await hold.onIdle()).toBe("break") }) + + test("renders a cross-process receipt without an answer event and suppresses later duplicate events", async () => { + const row = completed("external", 41) + let current = { ...row, status: "running" } + const receipt = answer(row) + receipt.data.parts.unshift({ + ...receipt.data.parts[0], + id: identity(row).sourceID, + text: "report source", + metadata: { dag_delivery: { workflow_id: row.id, completion_seq: row.seq, kind: "source" } }, + }) + const client = { + dag: { bySession: async () => ({ data: [current] }) }, + session: { message: async () => receipt }, + } as unknown as OpencodeClient + const rendered: string[] = [] + const hold = await DagHold.createDagRunHold(client, "parent", undefined, (part) => rendered.push(part.text)) + await hold.onBusy() + expect(await hold.onIdle()).toBe("hold") + current = row + // No onText call or busy event: only the shared database changes. + expect(await hold.onIdle()).toBe("break") + expect(rendered).toEqual(["report source", "report"]) + const lateEvent = answer(row).data.parts[0] + expect(hold.hasText(lateEvent)).toBe(true) + expect(hold.hasText(receipt.data.parts[0])).toBe(true) + if (!hold.hasText(lateEvent)) rendered.push(lateEvent.text) + expect(await hold.onIdle()).toBe("break") + expect(rendered).toEqual(["report source", "report"]) + }) + + test("retries a receipt whose renderer failed instead of treating it as printed", async () => { + const row = completed("retry-render", 42) + let rows: Row[] = [] + let attempts = 0 + const client = { + dag: { bySession: async () => ({ data: rows }) }, + session: { message: async () => answer(row) }, + } as unknown as OpencodeClient + const hold = await DagHold.createDagRunHold(client, "parent", undefined, () => { + if (++attempts === 1) throw new Error("stdout temporarily unavailable") + }) + rows = [row] + await hold.onBusy() + expect(hold.hasText(answer(row).data.parts[0])).toBe(false) + expect(await hold.onIdle()).toBe("break") + expect(attempts).toBe(2) + expect(hold.hasText(answer(row).data.parts[0])).toBe(true) + }) + + test("does not replay baseline receipts or an answer already consumed from the stream", async () => { + const old = completed("history", 43) + const next = completed("stream-first", 44) + let rows = [old] + const client = { + dag: { bySession: async () => ({ data: rows }) }, + session: { + message: async ({ messageID }: { messageID: string }) => + answer(messageID === identity(old).messageID ? old : next), + }, + } as unknown as OpencodeClient + const rendered: string[] = [] + const hold = await DagHold.createDagRunHold(client, "parent", undefined, (part) => rendered.push(part.text)) + rows = [old, next] + hold.onText(answer(next).data.parts[0]) + await hold.onBusy() + expect(await hold.onIdle()).toBe("break") + expect(rendered).toEqual([]) + }) +}) + +test("idle polling keeps one event read pending and closes it on exit", async () => { + const waiting = Promise.withResolvers>() + let reads = 0 + let closed = 0 + const events: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: async () => { + if (++reads === 1) + return { + done: false, + value: { + id: "evt_idle", + type: "session.status", + properties: { sessionID: "parent", status: { type: "idle" } }, + }, + } + return waiting.promise + }, + return: async () => { + closed++ + return { done: true, value: undefined } + }, + }), + } + let polls = 0 + for await (const event of DagHold.pollEvents( + events, + "parent", + () => waiting.resolve({ done: true, value: undefined }), + 5, + )) { + expect(event.type).toBe("session.status") + if (++polls === 3) break + } + expect(reads).toBe(2) + expect(closed).toBe(1) }) diff --git a/packages/opencode/test/dag/dag-final-delivery.test.ts b/packages/opencode/test/dag/dag-final-delivery.test.ts index 679e67452..e00eec51c 100644 --- a/packages/opencode/test/dag/dag-final-delivery.test.ts +++ b/packages/opencode/test/dag/dag-final-delivery.test.ts @@ -2,7 +2,8 @@ import { expect, it } from "bun:test" import { Deferred, Effect, Layer, Stream } from "effect" import { eq } from "drizzle-orm" -import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { readFile, writeFile, mkdtemp, rm, readdir } from "node:fs/promises" import os from "node:os" import path from "node:path" import { Database } from "@opencode-ai/core/database/database" @@ -12,7 +13,9 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2 } from "@opencode-ai/core/event" +import { Global } from "@opencode-ai/core/global" import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Hash } from "@opencode-ai/core/util/hash" import type { Session } from "@/session/session" import { deliverReport, reportIdentity, selectReportNode } from "@/dag/runtime/report-delivery" import { commitOutputFileRef, verifyOutputFileRef } from "@/dag/runtime/output-ref" @@ -479,6 +482,92 @@ for (const command of ["memory", "trust", "goal", "subgoal"]) }), )) +for (const command of ["goal", "subgoal"]) + it(`delivers behind a historical /${command} dispatch failure persisted without metadata or assistant`, () => + fixture(({ database, store, sessions, deliver }) => + Effect.gen(function* () { + yield* sessions.updateMessage({ + id: "msg_historic" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + yield* sessions.updatePart({ + id: "prt_historic" as never, + messageID: "msg_historic" as never, + sessionID: "ses_parent" as never, + type: "text", + text: `⚠️ /${command} 执行失败,请检查日志。`, + synthetic: true, + ignored: true, + time: { start: 50, end: 50 }, + }) + expect(yield* deliver()).toBe(true) + expect((yield* store.getWorkflow("wf-1"))?.wakeReported).toBe(true) + expect( + (yield* database.db.select().from(PartTable)).filter( + (part) => (part.data as SessionV1.TextPart).metadata?.dag_delivery?.kind === "answer", + ), + ).toHaveLength(1) + }), + )) + +for (const variant of [ + { name: "lookalike ordinary", text: "⚠️ /goal 执行失败", ignored: true, synthetic: true, ended: true }, + { + name: "lookalike unfinished", + text: "⚠️ /goal 执行失败,请检查日志。", + ignored: true, + synthetic: true, + ended: false, + }, + { + name: "lookalike nonignored", + text: "⚠️ /goal 执行失败,请检查日志。", + ignored: false, + synthetic: true, + ended: true, + }, + { + name: "lookalike nonsynthetic", + text: "⚠️ /goal 执行失败,请检查日志。", + ignored: true, + synthetic: false, + ended: true, + }, +]) + it(`keeps a pending user request behind a ${variant.name} failure part`, () => + fixture(({ database, sessions, deliver }) => + Effect.gen(function* () { + yield* sessions.updateMessage({ + id: "msg_lookalike" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + yield* sessions.updatePart({ + id: "prt_lookalike" as never, + messageID: "msg_lookalike" as never, + sessionID: "ses_parent" as never, + type: "text", + text: variant.text, + ...(variant.synthetic ? { synthetic: true } : {}), + ...(variant.ignored ? { ignored: true } : {}), + time: variant.ended ? { start: 50, end: 50 } : { start: 50 }, + }) + expect(yield* deliver()).toBe(false) + expect(yield* database.db.select().from(PartTable)).toHaveLength(1) + const answer = parentAnswer("msg_real_answer", "msg_lookalike") + answer.time = { created: 70, completed: 70 } + yield* sessions.updateMessage(answer) + expect(yield* deliver()).toBe(true) + }), + )) + for (const state of ["unfinished", "synthetic", "same-part", "model-command"] as const) it(`does not treat a ${state} command response as an answered user turn`, () => fixture(({ sessions, deliver }) => @@ -701,6 +790,85 @@ it("stores an oversized answer as a verified artifact with bounded parent text", }), )) +it("writes no filesystem artifact for oversized output while a user request is pending, then delivers it after an answer", () => + fixture(({ database, sessions, deliver }) => + Effect.gen(function* () { + const text = `${randomUUID()} unique oversized report\n${"large report\n".repeat(6000)}` + const digest = Hash.sha256(Buffer.from(text)) + yield* database.db + .update(WorkflowNodeTable) + .set({ output: text }) + .where(eq(WorkflowNodeTable.workflow_id, "wf-1")) + .run() + yield* sessions.updateMessage({ + id: "msg_pending" as never, + sessionID: "ses_parent" as never, + role: "user", + time: { created: 50 }, + agent: "build", + model: { providerID: "test" as never, modelID: "test" as never }, + }) + const deliveryDirectory = path.join(Global.Path.data, "workflow-artifacts", "delivery") + const objectDirectory = path.join(Global.Path.data, "workflow-artifacts", "objects", digest.slice(0, 2), digest) + const listDelivery = Effect.promise(() => + readdir(deliveryDirectory).then( + () => true, + () => false, + ), + ) + const objectExists = Effect.promise(() => + readdir(objectDirectory).then( + () => true, + () => false, + ), + ) + const before = yield* listDelivery + expect(yield* deliver()).toBe(false) + expect(yield* listDelivery).toBe(before) + expect(yield* objectExists).toBe(false) + const answer = parentAnswer("msg_real_answer", "msg_pending") + answer.time = { created: 70, completed: 70 } + yield* sessions.updateMessage(answer) + expect(yield* deliver()).toBe(true) + const receipt = (yield* database.db.select().from(PartTable)) + .map((row) => row.data as SessionV1.TextPart) + .find((part) => part.metadata?.dag_delivery?.kind === "answer")! + expect(receipt.text.length).toBeLessThan(2000) + const artifact = receipt.metadata!.dag_delivery.artifact + yield* verifyOutputFileRef(artifact) + expect(yield* Effect.promise(() => readFile(artifact.path, "utf8"))).toBe(text) + yield* Effect.promise(() => rm(path.dirname(artifact.path), { recursive: true, force: true })) + }), + )) + +it("writes no filesystem artifact for oversized output behind a stale completion episode", () => + fixture(({ database, deliver }) => + Effect.gen(function* () { + const text = `${randomUUID()} unique stale report\n${"large report\n".repeat(6000)}` + const digest = Hash.sha256(Buffer.from(text)) + yield* database.db + .update(WorkflowNodeTable) + .set({ output: text }) + .where(eq(WorkflowNodeTable.workflow_id, "wf-1")) + .run() + yield* database.db + .update(WorkflowTable) + .set({ status: "running", seq: 8, graph_rev: 3 }) + .where(eq(WorkflowTable.id, "wf-1")) + .run() + const objectDirectory = path.join(Global.Path.data, "workflow-artifacts", "objects", digest.slice(0, 2), digest) + expect(yield* deliver()).toBe(false) + expect( + yield* Effect.promise(() => + readdir(objectDirectory).then( + () => true, + () => false, + ), + ), + ).toBe(false) + }), + )) + it("uses the captured file receipt and refuses to acknowledge a missing managed object", () => fixture(({ database, deliver }) => Effect.gen(function* () {