From 4fa1549182d3e463a3e65587d892cc31c7e1572e Mon Sep 17 00:00:00 2001 From: sunsunsun Date: Tue, 25 Aug 2026 10:53:36 +0800 Subject: [PATCH 1/5] fix(workflows): remove background launch alias --- .../OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md | 36 +++++------ extensions/workflows/coordinator.test.ts | 14 +---- extensions/workflows/coordinator.ts | 16 +---- extensions/workflows/execute.e2e.test.ts | 48 ++++++++++++++- extensions/workflows/index.ts | 50 ++++++++------- extensions/workflows/prompt.ts | 2 - extensions/workflows/rendering.test.ts | 61 +++++++++++++++++++ .../workflows/target-resolution.test.ts | 2 +- skills/workflows/REFERENCE.md | 2 +- 9 files changed, 153 insertions(+), 78 deletions(-) diff --git a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md index eb5080be..50313b56 100644 --- a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md +++ b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md @@ -5,6 +5,8 @@ > 状态:已在 `codex/workflow-v2` 实施;最终验证与真实模型 smoke 见文末实施记录。 > > 依据:当前 OpenPI 源码、Issues #71/#74/#75/#90、Claude Code `2.1.241` 运行时合同访谈,以及三份相互独立的 interface 设计评审。 +> +> 后续变更:Issue #132 已将调用侧策略收敛为 `wait`;下文的当前合同已同步为 wait-only,明确标注的改造前记录仍保留历史原貌。 ## 结论 @@ -33,14 +35,14 @@ workflow_stop 本轮没有把整个 2200 行 Workflow extension 塞进一个新的大类,而是按不变量拆成三个深 seam: -- `coordinator.ts`:宿主默认值、`wait/background` 兼容解析,以及 wait/terminal 仲裁; +- `coordinator.ts`:宿主默认值、wait policy 解析,以及 wait/terminal 仲裁; - `result-delivery.ts`:逐 run delivery identity、pending/receipt/retry/restore; - `shared/result-budget.ts` 与 `shared/text-projection.ts`:Subagent/Workflow 共用的公平预算与 head/tail 投影。 已经落地: - TUI 默认 detached,print/无可靠投递宿主默认 wait; -- `wait:true` 是唯一正向同步选择,`background` 仅为 deprecated inverse alias; +- `wait:true` 是唯一显式同步选择,调用 schema 不再接受 `background`; - 中断 wait 不取消 run,stop/shutdown 才拥有取消权; - terminal execution state 与 delivery state 正交持久化; - send failure 以同一 per-run id 重试,成功 sibling 不重发; @@ -53,11 +55,11 @@ workflow_stop 明确没有增加:`workflow_wait`、size planner、budget planner、通用 Execution Fabric、daemon、全局 scheduler、递归 Workflow 或第二 provider stack。 -## 1. 当前问题 +## 1. 改造前问题 ### 1.1 默认阻塞 -当前 `workflow` 使用: +改造前 `workflow` 使用: ```ts const background = (params.background ?? false) && ctx.hasUI @@ -116,12 +118,7 @@ workflow({ script: string, args?: string, resume_from_run_id?: string, - - // 新的正向语义 wait?: boolean, - - // 旧兼容参数,逐步 deprecated - background?: boolean, }) ``` @@ -131,15 +128,12 @@ workflow({ 显式 wait -> 严格服从 -只有 background - -> wait = !background - -两者同时出现且语义冲突 - -> fail closed - -两者都省略 +省略 wait -> adapter.canDeliverLater = true:wait = false -> adapter.canDeliverLater = false:wait = true + +未知字段(包括旧 background alias) + -> schema validation fail closed ``` 为什么推荐 `wait`: @@ -149,7 +143,7 @@ workflow({ - `wait` 被中断只结束等待,不能隐式取消 run; - 只有 `workflow_stop` 取消运行。 -兼容期保留 `background`,避免旧模型调用和历史脚本立即失效。 +Issue #132 以 breaking change 删除了调用侧 `background` alias。Persisted artifact 和 tool details 中记录实际 detached 状态的 `background` 仍是运行时事实,并继续兼容读取。 ### 3.2 `workflow_status` @@ -463,7 +457,7 @@ Runtime 不自动插入 Report Agent。当前已提供: ### Phase 0:固定基线 -- 当前 blocking/background 行为; +- 改造前 blocking/background 行为; - tool schema hash; - sendMessage、final write、busy/idle、quick-completion 故障注入; - 3/16/40 ref handoff 覆盖基线。 @@ -491,7 +485,7 @@ Runtime 不自动插入 Report Agent。当前已提供: ### Phase 4:`wait` 与宿主默认值 - 新增 `wait`; -- `background` 兼容映射并 deprecated; +- `background` 曾在迁移期兼容映射并 deprecated,随后由 Issue #132 从调用 schema 与 coordinator input 删除; - interactive/Web/RPC adapter 有可靠 completion channel 时默认 `wait=false`; - print/automation 默认 `wait=true`; - wait interrupt 不取消 run; @@ -643,11 +637,11 @@ bun run test Vitest: 30/30 pass ``` -专项覆盖包括:interactive/print 默认值、legacy alias 冲突、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 +专项覆盖包括:interactive/print 默认值、wait-only schema validation、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy artifact restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 ### 14.2 真实模型 smoke -运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 `wait/background`,因此按合同 inline 等待。 +运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 `wait`,因此按合同 inline 等待。 真实 run `wf_6664b8e3427d`: diff --git a/extensions/workflows/coordinator.test.ts b/extensions/workflows/coordinator.test.ts index 0832c2da..86ba0489 100644 --- a/extensions/workflows/coordinator.test.ts +++ b/extensions/workflows/coordinator.test.ts @@ -16,26 +16,18 @@ test("interactive launch defaults detached while non-delivery hosts wait", () => }); }); -test("wait is authoritative and legacy background maps to its inverse", () => { +test("explicit wait selects inline or detached launch policy", () => { assert.deepEqual(resolveWorkflowLaunchPolicy({ wait: true }, true), { wait: true, detached: false, }); - assert.deepEqual(resolveWorkflowLaunchPolicy({ background: true }, true), { + assert.deepEqual(resolveWorkflowLaunchPolicy({ wait: false }, true), { wait: false, detached: true, }); - assert.deepEqual(resolveWorkflowLaunchPolicy({ background: false }, true), { - wait: true, - detached: false, - }); }); -test("conflicting aliases and unsupported detached delivery fail closed", () => { - assert.throws( - () => resolveWorkflowLaunchPolicy({ wait: true, background: true }, true), - /conflict/, - ); +test("unsupported detached delivery fails closed", () => { assert.throws( () => resolveWorkflowLaunchPolicy({ wait: false }, false), /cannot deliver/, diff --git a/extensions/workflows/coordinator.ts b/extensions/workflows/coordinator.ts index 3be2041c..8c263680 100644 --- a/extensions/workflows/coordinator.ts +++ b/extensions/workflows/coordinator.ts @@ -1,6 +1,5 @@ export interface WorkflowLaunchPolicyInput { wait?: boolean; - background?: boolean; } export interface WorkflowLaunchPolicy { @@ -8,23 +7,12 @@ export interface WorkflowLaunchPolicy { detached: boolean; } -/** Resolve legacy/background and host capability without silently changing semantics. */ +/** Resolve the caller's wait preference against the host delivery capability. */ export function resolveWorkflowLaunchPolicy( input: WorkflowLaunchPolicyInput, canDeliverLater: boolean, ): WorkflowLaunchPolicy { - if ( - input.wait !== undefined && - input.background !== undefined && - input.wait === input.background - ) { - throw new Error( - "wait and background conflict: background is the deprecated inverse of wait", - ); - } - const wait = - input.wait ?? - (input.background !== undefined ? !input.background : !canDeliverLater); + const wait = input.wait ?? !canDeliverLater; if (!wait && !canDeliverLater) { throw new Error( "This host cannot deliver a workflow result later; use wait: true", diff --git a/extensions/workflows/execute.e2e.test.ts b/extensions/workflows/execute.e2e.test.ts index 8217bdb8..bb961ac9 100644 --- a/extensions/workflows/execute.e2e.test.ts +++ b/extensions/workflows/execute.e2e.test.ts @@ -11,6 +11,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -346,7 +347,7 @@ test("oversized workflow args fail before child sessions or journals are created for (const rawArgs of args) { const launch = (await workflow.execute( "e2e-oversized-workflow-args", - { script, args: rawArgs, background: true }, + { script, args: rawArgs, wait: false }, undefined, undefined, ctx, @@ -374,6 +375,49 @@ test("oversized workflow args fail before child sessions or journals are created assert.equal(sessionCreations, 0); }); +test("print hosts wait by default and reject detached delivery", async () => { + const printCtx = { + ...ctx, + mode: "print", + hasUI: false, + } as unknown as ExtensionContext; + const inline = (await workflow.execute( + "e2e-print-default", + { + script: + 'export const meta = { name: "print-default" };\nreturn { inline: true };', + }, + undefined, + undefined, + printCtx, + )) as AgentToolResult; + + assert.equal(inline.details.status, "completed"); + assert.equal(inline.details.background, false); + assert.equal(inline.details.delivery?.state, "consumed-inline"); + + const workflowsDir = join(agentDir, "workflows"); + const runDirsBefore = readdirSync(workflowsDir).sort(); + const messagesBefore = sentMessages.length; + await assert.rejects( + Promise.resolve().then(() => + workflow.execute( + "e2e-print-detached", + { + script: "return { detached: true };", + wait: false, + }, + undefined, + undefined, + printCtx, + ), + ), + /cannot deliver.*wait: true/i, + ); + assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); + assert.equal(sentMessages.length, messagesBefore); +}); + test("background runs deliver a follow-up that triggers a turn only when idle", async () => { sentMessages.length = 0; @@ -412,7 +456,7 @@ test("background runs deliver a follow-up that triggers a turn only when idle", "e2e-bg-busy", { script: 'export const meta = { name: "bg-busy" };\nreturn 8;', - background: true, + wait: false, }, undefined, undefined, diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 03a4656c..6738b47a 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -532,31 +532,29 @@ interface AgentCallOptions { inputs?: unknown; } -const WorkflowParams = Type.Object({ - script: Type.String({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.script, - }), - args: Type.Optional( - Type.String({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.args, +const WorkflowParams = Type.Object( + { + script: Type.String({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.script, }), - ), - background: Type.Optional( - Type.Boolean({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.background, - }), - ), - wait: Type.Optional( - Type.Boolean({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait, - }), - ), - resume_from_run_id: Type.Optional( - Type.String({ - description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId, - }), - ), -}); + args: Type.Optional( + Type.String({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.args, + }), + ), + wait: Type.Optional( + Type.Boolean({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait, + }), + ), + resume_from_run_id: Type.Optional( + Type.String({ + description: WORKFLOW_PARAMETER_DESCRIPTIONS.resumeFromRunId, + }), + ), + }, + { additionalProperties: false }, +); type WorkflowInput = Static; @@ -1150,7 +1148,7 @@ export default function workflows(pi: ExtensionAPI) { const runDir = path.join(getAgentDir(), "workflows", runId); const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; const launchPolicy = resolveWorkflowLaunchPolicy( - { wait: params.wait, background: params.background }, + { wait: params.wait }, canDeliverLater, ); const background = launchPolicy.detached; @@ -2193,7 +2191,7 @@ export default function workflows(pi: ExtensionAPI) { let text = theme.fg("toolTitle", theme.bold("workflow ")) + theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)"); - if (args.background) text += theme.fg("dim", " (background)"); + if (args.wait === true) text += theme.fg("dim", " (wait)"); const description = (meta as WorkflowMeta).description; if (description) text += `\n ${theme.fg("dim", description)}`; for (const phase of meta.phases.slice(0, 8)) { diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index 96629ffb..7ceeba45 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -17,8 +17,6 @@ export const WORKFLOW_PARAMETER_DESCRIPTIONS = { script: "JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.", args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).", - background: - "Deprecated compatibility alias: true means wait=false; false means wait=true. Do not provide both background and wait.", wait: "Wait for the final result in this tool call. Interactive sessions default to false and deliver completion later; print/automation defaults to true. Interrupting the wait does not cancel the workflow.", resumeFromRunId: "Optional prior run id or unique suffix for safe read-only replay. See the workflows Skill for matching rules.", diff --git a/extensions/workflows/rendering.test.ts b/extensions/workflows/rendering.test.ts index 617af37e..947434f7 100644 --- a/extensions/workflows/rendering.test.ts +++ b/extensions/workflows/rendering.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { validateToolArguments } from "@earendil-works/pi-ai"; import { initTheme, type AgentToolResult, @@ -61,6 +62,66 @@ function captureRenderers() { return { workflow, message }; } +test("workflow launch schema accepts only the positive wait policy", () => { + const { workflow } = captureRenderers(); + const parameters = workflow.parameters as unknown as { + properties?: Record; + additionalProperties?: boolean; + }; + + assert.ok(parameters.properties?.wait); + assert.equal(parameters.properties?.background, undefined); + assert.equal(parameters.additionalProperties, false); + + const toolCall = (args: Record) => ({ + type: "toolCall" as const, + id: "call-schema", + name: "workflow", + arguments: args, + }); + const script = "return 1;"; + + assert.deepEqual( + validateToolArguments(workflow, toolCall({ script, wait: false })), + { script, wait: false }, + ); + assert.throws( + () => + validateToolArguments(workflow, toolCall({ script, background: true })), + /Validation failed.*background/s, + ); + assert.throws( + () => validateToolArguments(workflow, toolCall({ script, detached: true })), + /Validation failed.*detached/s, + ); +}); + +test("workflow call rendering labels an explicit inline wait", () => { + const { workflow } = captureRenderers(); + assert.ok(workflow.renderCall); + const args = { + script: 'export const meta = { name: "inline" }; return 1;', + wait: true, + }; + + const component = workflow.renderCall(args, theme, { + args, + toolCallId: "call-inline-wait", + invalidate() {}, + lastComponent: undefined, + state: {}, + cwd: process.cwd(), + executionStarted: true, + argsComplete: true, + isPartial: false, + expanded: false, + showImages: false, + isError: false, + }); + + assert.match(component.render(100).join("\n"), /workflow inline \(wait\)/); +}); + test("workflow tool errors with malformed details fall back to plain text", (t) => { t.mock.timers.enable({ apis: ["setInterval", "Date"], now: 0 }); const { workflow } = captureRenderers(); diff --git a/extensions/workflows/target-resolution.test.ts b/extensions/workflows/target-resolution.test.ts index eae57690..2e08b522 100644 --- a/extensions/workflows/target-resolution.test.ts +++ b/extensions/workflows/target-resolution.test.ts @@ -121,7 +121,7 @@ test("an ambiguous short suffix cannot stop or inspect either active run", async { script: 'export const meta = { name: "pending", phases: [] };\nawait new Promise(() => {});', - background: true, + wait: false, }, undefined, undefined, diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index 8f5f48db..45d0a884 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -40,7 +40,7 @@ Each call persists intent, admission, and execution state. Interrupted nontermin ## Lifecycle and replay -Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. The deprecated `background` parameter remains an inverse compatibility alias and cannot be combined with `wait`. +Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. Loading the Workflow capability exposes `workflow`, `workflow_status`, and `workflow_stop` as one stable group; starting or settling a run does not mutate the model tool Schema. `workflow_status` returns a bounded state/coverage summary and artifact path without consuming or repeating the full completion. `workflow_stop` is idempotent and preserves partial artifacts. A failed completion send remains pending with the same per-run delivery identity and is retried when the parent settles or the Session is restored. From 615f37af1d4b91356023a467ea135794db84ac59 Mon Sep 17 00:00:00 2001 From: sunsunsun Date: Tue, 25 Aug 2026 19:15:15 +0800 Subject: [PATCH 2/5] fix(workflows): warn legacy background callers --- .../OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md | 31 +++++-- extensions/workflows/coordinator.test.ts | 42 ++++++++- extensions/workflows/coordinator.ts | 33 ++++++- extensions/workflows/execute.e2e.test.ts | 93 +++++++++++++++++++ extensions/workflows/index.ts | 69 ++++++++++---- extensions/workflows/prompt.ts | 2 + extensions/workflows/rendering.test.ts | 46 +++++++-- skills/workflows/REFERENCE.md | 2 + 8 files changed, 279 insertions(+), 39 deletions(-) diff --git a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md index 50313b56..558a0f45 100644 --- a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md +++ b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md @@ -6,7 +6,7 @@ > > 依据:当前 OpenPI 源码、Issues #71/#74/#75/#90、Claude Code `2.1.241` 运行时合同访谈,以及三份相互独立的 interface 设计评审。 > -> 后续变更:Issue #132 已将调用侧策略收敛为 `wait`;下文的当前合同已同步为 wait-only,明确标注的改造前记录仍保留历史原貌。 +> 后续变更:Issue #132 已将新调用收敛为 `wait`。当前 deprecation window 仍接受旧 `background` alias,并对每次旧调用返回可操作的迁移警告;下一 breaking release 才从 schema 与 coordinator input 删除 alias。明确标注的改造前记录仍保留历史原貌。 ## 结论 @@ -35,14 +35,14 @@ workflow_stop 本轮没有把整个 2200 行 Workflow extension 塞进一个新的大类,而是按不变量拆成三个深 seam: -- `coordinator.ts`:宿主默认值、wait policy 解析,以及 wait/terminal 仲裁; +- `coordinator.ts`:宿主默认值、wait policy、deprecated alias 警告,以及 wait/terminal 仲裁; - `result-delivery.ts`:逐 run delivery identity、pending/receipt/retry/restore; - `shared/result-budget.ts` 与 `shared/text-projection.ts`:Subagent/Workflow 共用的公平预算与 head/tail 投影。 已经落地: - TUI 默认 detached,print/无可靠投递宿主默认 wait; -- `wait:true` 是唯一显式同步选择,调用 schema 不再接受 `background`; +- `wait:true` 是唯一推荐的显式同步选择;旧 `background` alias 仅在兼容期接受,并返回 `true → wait:false`、`false → wait:true` 的迁移警告; - 中断 wait 不取消 run,stop/shutdown 才拥有取消权; - terminal execution state 与 delivery state 正交持久化; - send failure 以同一 per-run id 重试,成功 sibling 不重发; @@ -119,6 +119,9 @@ workflow({ args?: string, resume_from_run_id?: string, wait?: boolean, + + // Deprecated compatibility alias; removed in the next breaking release. + background?: boolean, }) ``` @@ -128,11 +131,21 @@ workflow({ 显式 wait -> 严格服从 -省略 wait +只有 background + -> wait = !background + -> 返回模型可见的精确迁移警告 + +两者同时出现且语义一致 + -> 严格服从 wait,并警告删除 background + +两者同时出现且语义冲突 + -> fail closed,错误包含迁移映射 + +两者都省略 -> adapter.canDeliverLater = true:wait = false -> adapter.canDeliverLater = false:wait = true -未知字段(包括旧 background alias) +其他未知字段 -> schema validation fail closed ``` @@ -143,7 +156,7 @@ workflow({ - `wait` 被中断只结束等待,不能隐式取消 run; - 只有 `workflow_stop` 取消运行。 -Issue #132 以 breaking change 删除了调用侧 `background` alias。Persisted artifact 和 tool details 中记录实际 detached 状态的 `background` 仍是运行时事实,并继续兼容读取。 +Issue #132 的当前阶段只保留有警告的调用兼容,不允许新文档、Skill、fixture 或示例继续推荐 alias。警告属于调用级迁移信息,不写入 artifact/details。下一 breaking release 删除调用侧 alias 时,persisted artifact 和 tool details 中记录实际 detached 状态的 `background` 仍是运行时事实,并继续兼容读取。 ### 3.2 `workflow_status` @@ -485,7 +498,7 @@ Runtime 不自动插入 Report Agent。当前已提供: ### Phase 4:`wait` 与宿主默认值 - 新增 `wait`; -- `background` 曾在迁移期兼容映射并 deprecated,随后由 Issue #132 从调用 schema 与 coordinator input 删除; +- `background` 在迁移期兼容映射并对每次旧调用返回 actionable warning;下一 breaking release 由 Issue #132 从调用 schema 与 coordinator input 删除; - interactive/Web/RPC adapter 有可靠 completion channel 时默认 `wait=false`; - print/automation 默认 `wait=true`; - wait interrupt 不取消 run; @@ -637,11 +650,11 @@ bun run test Vitest: 30/30 pass ``` -专项覆盖包括:interactive/print 默认值、wait-only schema validation、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy artifact restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 +专项覆盖包括:interactive/print 默认值、deprecated alias 迁移警告、unknown-field schema validation、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy artifact restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 ### 14.2 真实模型 smoke -运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 `wait`,因此按合同 inline 等待。 +运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 launch-policy 字段,因此按合同 inline 等待。 真实 run `wf_6664b8e3427d`: diff --git a/extensions/workflows/coordinator.test.ts b/extensions/workflows/coordinator.test.ts index 86ba0489..6e63a37d 100644 --- a/extensions/workflows/coordinator.test.ts +++ b/extensions/workflows/coordinator.test.ts @@ -27,11 +27,51 @@ test("explicit wait selects inline or detached launch policy", () => { }); }); -test("unsupported detached delivery fails closed", () => { +test("legacy background maps to wait and returns an actionable warning", () => { + const detached = resolveWorkflowLaunchPolicy({ background: true }, true); + assert.deepEqual( + { wait: detached.wait, detached: detached.detached }, + { wait: false, detached: true }, + ); + assert.match( + detached.migrationWarning ?? "", + /replace background: true with wait: false.*next breaking release/i, + ); + + const inline = resolveWorkflowLaunchPolicy({ background: false }, true); + assert.deepEqual( + { wait: inline.wait, detached: inline.detached }, + { wait: true, detached: false }, + ); + assert.match( + inline.migrationWarning ?? "", + /replace background: false with wait: true.*next breaking release/i, + ); +}); + +test("consistent aliases warn while conflicts fail with migration guidance", () => { + const consistent = resolveWorkflowLaunchPolicy( + { wait: true, background: false }, + true, + ); + assert.equal(consistent.wait, true); + assert.match(consistent.migrationWarning ?? "", /remove|replace/i); + + assert.throws( + () => resolveWorkflowLaunchPolicy({ wait: true, background: true }, true), + /replace background: true with wait: false.*conflict.*remove background/is, + ); +}); + +test("unsupported detached delivery fails closed with alias guidance", () => { assert.throws( () => resolveWorkflowLaunchPolicy({ wait: false }, false), /cannot deliver/, ); + assert.throws( + () => resolveWorkflowLaunchPolicy({ background: true }, false), + /replace background: true with wait: false.*cannot deliver.*wait: true/is, + ); }); test("wait cancellation does not cancel the underlying completion", async () => { diff --git a/extensions/workflows/coordinator.ts b/extensions/workflows/coordinator.ts index 8c263680..d1dafc76 100644 --- a/extensions/workflows/coordinator.ts +++ b/extensions/workflows/coordinator.ts @@ -1,24 +1,49 @@ export interface WorkflowLaunchPolicyInput { wait?: boolean; + background?: boolean; } export interface WorkflowLaunchPolicy { wait: boolean; detached: boolean; + migrationWarning?: string; } -/** Resolve the caller's wait preference against the host delivery capability. */ +function backgroundMigrationWarning(background: boolean) { + return `Deprecated Workflow parameter "background": replace background: ${background} with wait: ${!background}. "background" will be removed in the next breaking release.`; +} + +/** Resolve the caller's launch policy and warn on the legacy inverse alias. */ export function resolveWorkflowLaunchPolicy( input: WorkflowLaunchPolicyInput, canDeliverLater: boolean, ): WorkflowLaunchPolicy { - const wait = input.wait ?? !canDeliverLater; + const migrationWarning = + input.background === undefined + ? undefined + : backgroundMigrationWarning(input.background); + if ( + input.wait !== undefined && + input.background !== undefined && + input.wait === input.background + ) { + throw new Error( + `${migrationWarning} wait and background conflict because background is the inverse of wait; remove background and provide only wait.`, + ); + } + const wait = + input.wait ?? + (input.background !== undefined ? !input.background : !canDeliverLater); if (!wait && !canDeliverLater) { throw new Error( - "This host cannot deliver a workflow result later; use wait: true", + `${migrationWarning ? `${migrationWarning} ` : ""}This host cannot deliver a workflow result later; use wait: true.`, ); } - return { wait, detached: !wait }; + return { + wait, + detached: !wait, + ...(migrationWarning ? { migrationWarning } : {}), + }; } /** diff --git a/extensions/workflows/execute.e2e.test.ts b/extensions/workflows/execute.e2e.test.ts index bb961ac9..bb99b17e 100644 --- a/extensions/workflows/execute.e2e.test.ts +++ b/extensions/workflows/execute.e2e.test.ts @@ -396,9 +396,49 @@ test("print hosts wait by default and reject detached delivery", async () => { assert.equal(inline.details.background, false); assert.equal(inline.details.delivery?.state, "consumed-inline"); + const legacyInline = (await workflow.execute( + "e2e-print-legacy-inline", + { + script: "return { legacyInline: true };", + background: false, + }, + undefined, + undefined, + printCtx, + )) as AgentToolResult; + const legacyInlineContent = legacyInline.content[0]; + assert.equal(legacyInlineContent?.type, "text"); + if (legacyInlineContent?.type !== "text") assert.fail("expected text result"); + assert.match( + legacyInlineContent.text, + /replace background: false with wait: true.*next breaking release/is, + ); + assert.equal(legacyInline.details.background, false); + assert.equal( + readWorkflowJson(legacyInline.details.runId).migrationWarning, + undefined, + ); + const workflowsDir = join(agentDir, "workflows"); const runDirsBefore = readdirSync(workflowsDir).sort(); const messagesBefore = sentMessages.length; + await assert.rejects( + Promise.resolve().then(() => + workflow.execute( + "e2e-print-legacy-parse-error", + { + script: "return {", + background: false, + }, + undefined, + undefined, + printCtx, + ), + ), + /replace background: false with wait: true.*failed to parse/is, + ); + assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); + await assert.rejects( Promise.resolve().then(() => workflow.execute( @@ -416,6 +456,59 @@ test("print hosts wait by default and reject detached delivery", async () => { ); assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); assert.equal(sentMessages.length, messagesBefore); + + await assert.rejects( + Promise.resolve().then(() => + workflow.execute( + "e2e-print-legacy-detached", + { + script: "return { detached: true };", + background: true, + }, + undefined, + undefined, + printCtx, + ), + ), + /replace background: true with wait: false.*cannot deliver.*wait: true/is, + ); + assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); + assert.equal(sentMessages.length, messagesBefore); +}); + +test("legacy detached alias warns without persisting migration text", async () => { + sentMessages.length = 0; + modelIdle = true; + const launch = (await workflow.execute( + "e2e-legacy-detached", + { + script: 'export const meta = { name: "legacy-detached" };\nreturn 9;', + background: true, + }, + undefined, + undefined, + ctx, + )) as AgentToolResult; + + assert.equal(launch.details.background, true); + const launchContent = launch.content[0]; + assert.equal(launchContent?.type, "text"); + if (launchContent?.type !== "text") assert.fail("expected text result"); + assert.match( + launchContent.text, + /replace background: true with wait: false.*next breaking release/is, + ); + const persisted = readWorkflowJson(launch.details.runId); + assert.equal(persisted.background, true); + assert.equal(persisted.migrationWarning, undefined); + + await waitFor( + () => + sentMessages.some( + (sent) => sent.message.details?.runId === launch.details.runId, + ), + "legacy detached completion", + ); }); test("background runs deliver a follow-up that triggers a turn only when idle", async () => { diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 6738b47a..7f211f08 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -542,6 +542,12 @@ const WorkflowParams = Type.Object( description: WORKFLOW_PARAMETER_DESCRIPTIONS.args, }), ), + background: Type.Optional( + Type.Boolean({ + deprecated: true, + description: WORKFLOW_PARAMETER_DESCRIPTIONS.background, + }), + ), wait: Type.Optional( Type.Boolean({ description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait, @@ -592,6 +598,10 @@ function errorText(error: unknown): string { ); } +function withWorkflowMigrationWarning(text: string, warning?: string) { + return warning ? `${warning}\n\n${text}` : text; +} + function isWorkflowRenderDetails(value: unknown): value is WorkflowDetails { if (!value || typeof value !== "object") return false; const details = value as Partial; @@ -1127,11 +1137,21 @@ export default function workflows(pi: ExtensionAPI) { parameters: WorkflowParams, async execute(_toolCallId, params, signal, onUpdate, ctx) { + const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; + const launchPolicy = resolveWorkflowLaunchPolicy( + { wait: params.wait, background: params.background }, + canDeliverLater, + ); let prepared: ReturnType; try { prepared = prepareWorkflowScript(params.script); } catch (error) { - throw new Error(`Workflow script failed to parse: ${errorText(error)}`); + throw new Error( + withWorkflowMigrationWarning( + `Workflow script failed to parse: ${errorText(error)}`, + launchPolicy.migrationWarning, + ), + ); } let args: unknown; @@ -1146,11 +1166,6 @@ export default function workflows(pi: ExtensionAPI) { const meta = prepared.meta; const runId = `wf_${randomBytes(6).toString("hex")}`; const runDir = path.join(getAgentDir(), "workflows", runId); - const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; - const launchPolicy = resolveWorkflowLaunchPolicy( - { wait: params.wait }, - canDeliverLater, - ); const background = launchPolicy.detached; const now = Date.now(); @@ -2143,11 +2158,14 @@ export default function workflows(pi: ExtensionAPI) { content: [ { type: "text", - text: buildBackgroundWorkflowLaunchResult({ - runId, - name: details.name, - runDir, - }), + text: withWorkflowMigrationWarning( + buildBackgroundWorkflowLaunchResult({ + runId, + name: details.name, + runDir, + }), + launchPolicy.migrationWarning, + ), }, ], details: compactToolDetails(details), @@ -2158,7 +2176,10 @@ export default function workflows(pi: ExtensionAPI) { if (waitOutcome === "aborted") { void settleForLaterDelivery(true); throw new Error( - `Workflow wait interrupted; run ${runId} continues in the background.`, + withWorkflowMigrationWarning( + `Workflow wait interrupted; run ${runId} continues in the background.`, + launchPolicy.migrationWarning, + ), ); } recordTerminalRun(); @@ -2166,16 +2187,24 @@ export default function workflows(pi: ExtensionAPI) { if (details.status !== "completed") { // Pi marks tool failures only when execute throws; returning isError is // ignored by the extension API. - throw new Error(buildWorkflowResultMessage(details, runDir)); + throw new Error( + withWorkflowMigrationWarning( + buildWorkflowResultMessage(details, runDir), + launchPolicy.migrationWarning, + ), + ); } return { content: [ { type: "text", - text: buildProjectedWorkflowResultMessage( - details, - runDir, - ctx.getContextUsage?.(), + text: withWorkflowMigrationWarning( + buildProjectedWorkflowResultMessage( + details, + runDir, + ctx.getContextUsage?.(), + ), + launchPolicy.migrationWarning, ), }, ], @@ -2191,7 +2220,11 @@ export default function workflows(pi: ExtensionAPI) { let text = theme.fg("toolTitle", theme.bold("workflow ")) + theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)"); - if (args.wait === true) text += theme.fg("dim", " (wait)"); + if (args.background !== undefined) { + text += theme.fg("dim", ` (deprecated: use wait: ${!args.background})`); + } else if (args.wait === true) { + text += theme.fg("dim", " (wait)"); + } const description = (meta as WorkflowMeta).description; if (description) text += `\n ${theme.fg("dim", description)}`; for (const phase of meta.phases.slice(0, 8)) { diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index 7ceeba45..6729e276 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -17,6 +17,8 @@ export const WORKFLOW_PARAMETER_DESCRIPTIONS = { script: "JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.", args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).", + background: + "Deprecated compatibility alias accepted during the current migration window. Replace true with wait=false and false with wait=true. It will be removed in the next breaking release; do not provide both fields.", wait: "Wait for the final result in this tool call. Interactive sessions default to false and deliver completion later; print/automation defaults to true. Interrupting the wait does not cancel the workflow.", resumeFromRunId: "Optional prior run id or unique suffix for safe read-only replay. See the workflows Skill for matching rules.", diff --git a/extensions/workflows/rendering.test.ts b/extensions/workflows/rendering.test.ts index 947434f7..e2460662 100644 --- a/extensions/workflows/rendering.test.ts +++ b/extensions/workflows/rendering.test.ts @@ -62,15 +62,19 @@ function captureRenderers() { return { workflow, message }; } -test("workflow launch schema accepts only the positive wait policy", () => { +test("workflow launch schema deprecates background and rejects unknown fields", () => { const { workflow } = captureRenderers(); const parameters = workflow.parameters as unknown as { - properties?: Record; + properties?: Record; additionalProperties?: boolean; }; assert.ok(parameters.properties?.wait); - assert.equal(parameters.properties?.background, undefined); + assert.equal(parameters.properties?.background?.deprecated, true); + assert.match( + parameters.properties?.background?.description ?? "", + /true with wait=false.*false with wait=true.*next breaking release/i, + ); assert.equal(parameters.additionalProperties, false); const toolCall = (args: Record) => ({ @@ -85,10 +89,9 @@ test("workflow launch schema accepts only the positive wait policy", () => { validateToolArguments(workflow, toolCall({ script, wait: false })), { script, wait: false }, ); - assert.throws( - () => - validateToolArguments(workflow, toolCall({ script, background: true })), - /Validation failed.*background/s, + assert.deepEqual( + validateToolArguments(workflow, toolCall({ script, background: true })), + { script, background: true }, ); assert.throws( () => validateToolArguments(workflow, toolCall({ script, detached: true })), @@ -122,6 +125,35 @@ test("workflow call rendering labels an explicit inline wait", () => { assert.match(component.render(100).join("\n"), /workflow inline \(wait\)/); }); +test("workflow call rendering gives legacy background migration guidance", () => { + const { workflow } = captureRenderers(); + assert.ok(workflow.renderCall); + const args = { + script: 'export const meta = { name: "legacy" }; return 1;', + background: true, + }; + + const component = workflow.renderCall(args, theme, { + args, + toolCallId: "call-legacy-background", + invalidate() {}, + lastComponent: undefined, + state: {}, + cwd: process.cwd(), + executionStarted: true, + argsComplete: true, + isPartial: false, + expanded: false, + showImages: false, + isError: false, + }); + + assert.match( + component.render(100).join("\n"), + /workflow legacy \(deprecated: use wait: false\)/, + ); +}); + test("workflow tool errors with malformed details fall back to plain text", (t) => { t.mock.timers.enable({ apis: ["setInterval", "Date"], now: 0 }); const { workflow } = captureRenderers(); diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index 45d0a884..e8510c58 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -42,6 +42,8 @@ Each call persists intent, admission, and execution state. Interrupted nontermin Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. +New calls must use `wait`. During the current deprecation window, legacy `background` remains accepted and returns an actionable warning on every use: replace `background: true` with `wait: false`, or `background: false` with `wait: true`. The alias will be removed in the next breaking release; persisted artifact/details fields named `background` remain runtime facts and are not part of that removal. + Loading the Workflow capability exposes `workflow`, `workflow_status`, and `workflow_stop` as one stable group; starting or settling a run does not mutate the model tool Schema. `workflow_status` returns a bounded state/coverage summary and artifact path without consuming or repeating the full completion. `workflow_stop` is idempotent and preserves partial artifacts. A failed completion send remains pending with the same per-run delivery identity and is retried when the parent settles or the Session is restored. `resume_from_run_id` accepts a previous run id or unique suffix. Replay is content-based and order-independent. It requires an unchanged prompt, resolved role/schema/model/provider/effort, canonical cwd, repository state, resources, and trust context. Only provably read-only non-operator calls replay. Failed, unrestricted, unknown-tool, writable, worktree, operator, or un-fingerprintable calls run for real. Missing or old journals safely degrade to a full run. From ff4f3ee7d0f876c8f6c535ad5ac2c834867d513e Mon Sep 17 00:00:00 2001 From: sunsunsun Date: Wed, 26 Aug 2026 20:33:27 +0800 Subject: [PATCH 3/5] Revert "fix(workflows): warn legacy background callers" This reverts commit 615f37af1d4b91356023a467ea135794db84ac59. --- .../OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md | 31 ++----- extensions/workflows/coordinator.test.ts | 42 +-------- extensions/workflows/coordinator.ts | 33 +------ extensions/workflows/execute.e2e.test.ts | 93 ------------------- extensions/workflows/index.ts | 69 ++++---------- extensions/workflows/prompt.ts | 2 - extensions/workflows/rendering.test.ts | 46 ++------- skills/workflows/REFERENCE.md | 2 - 8 files changed, 39 insertions(+), 279 deletions(-) diff --git a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md index 558a0f45..50313b56 100644 --- a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md +++ b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md @@ -6,7 +6,7 @@ > > 依据:当前 OpenPI 源码、Issues #71/#74/#75/#90、Claude Code `2.1.241` 运行时合同访谈,以及三份相互独立的 interface 设计评审。 > -> 后续变更:Issue #132 已将新调用收敛为 `wait`。当前 deprecation window 仍接受旧 `background` alias,并对每次旧调用返回可操作的迁移警告;下一 breaking release 才从 schema 与 coordinator input 删除 alias。明确标注的改造前记录仍保留历史原貌。 +> 后续变更:Issue #132 已将调用侧策略收敛为 `wait`;下文的当前合同已同步为 wait-only,明确标注的改造前记录仍保留历史原貌。 ## 结论 @@ -35,14 +35,14 @@ workflow_stop 本轮没有把整个 2200 行 Workflow extension 塞进一个新的大类,而是按不变量拆成三个深 seam: -- `coordinator.ts`:宿主默认值、wait policy、deprecated alias 警告,以及 wait/terminal 仲裁; +- `coordinator.ts`:宿主默认值、wait policy 解析,以及 wait/terminal 仲裁; - `result-delivery.ts`:逐 run delivery identity、pending/receipt/retry/restore; - `shared/result-budget.ts` 与 `shared/text-projection.ts`:Subagent/Workflow 共用的公平预算与 head/tail 投影。 已经落地: - TUI 默认 detached,print/无可靠投递宿主默认 wait; -- `wait:true` 是唯一推荐的显式同步选择;旧 `background` alias 仅在兼容期接受,并返回 `true → wait:false`、`false → wait:true` 的迁移警告; +- `wait:true` 是唯一显式同步选择,调用 schema 不再接受 `background`; - 中断 wait 不取消 run,stop/shutdown 才拥有取消权; - terminal execution state 与 delivery state 正交持久化; - send failure 以同一 per-run id 重试,成功 sibling 不重发; @@ -119,9 +119,6 @@ workflow({ args?: string, resume_from_run_id?: string, wait?: boolean, - - // Deprecated compatibility alias; removed in the next breaking release. - background?: boolean, }) ``` @@ -131,21 +128,11 @@ workflow({ 显式 wait -> 严格服从 -只有 background - -> wait = !background - -> 返回模型可见的精确迁移警告 - -两者同时出现且语义一致 - -> 严格服从 wait,并警告删除 background - -两者同时出现且语义冲突 - -> fail closed,错误包含迁移映射 - -两者都省略 +省略 wait -> adapter.canDeliverLater = true:wait = false -> adapter.canDeliverLater = false:wait = true -其他未知字段 +未知字段(包括旧 background alias) -> schema validation fail closed ``` @@ -156,7 +143,7 @@ workflow({ - `wait` 被中断只结束等待,不能隐式取消 run; - 只有 `workflow_stop` 取消运行。 -Issue #132 的当前阶段只保留有警告的调用兼容,不允许新文档、Skill、fixture 或示例继续推荐 alias。警告属于调用级迁移信息,不写入 artifact/details。下一 breaking release 删除调用侧 alias 时,persisted artifact 和 tool details 中记录实际 detached 状态的 `background` 仍是运行时事实,并继续兼容读取。 +Issue #132 以 breaking change 删除了调用侧 `background` alias。Persisted artifact 和 tool details 中记录实际 detached 状态的 `background` 仍是运行时事实,并继续兼容读取。 ### 3.2 `workflow_status` @@ -498,7 +485,7 @@ Runtime 不自动插入 Report Agent。当前已提供: ### Phase 4:`wait` 与宿主默认值 - 新增 `wait`; -- `background` 在迁移期兼容映射并对每次旧调用返回 actionable warning;下一 breaking release 由 Issue #132 从调用 schema 与 coordinator input 删除; +- `background` 曾在迁移期兼容映射并 deprecated,随后由 Issue #132 从调用 schema 与 coordinator input 删除; - interactive/Web/RPC adapter 有可靠 completion channel 时默认 `wait=false`; - print/automation 默认 `wait=true`; - wait interrupt 不取消 run; @@ -650,11 +637,11 @@ bun run test Vitest: 30/30 pass ``` -专项覆盖包括:interactive/print 默认值、deprecated alias 迁移警告、unknown-field schema validation、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy artifact restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 +专项覆盖包括:interactive/print 默认值、wait-only schema validation、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy artifact restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 ### 14.2 真实模型 smoke -运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 launch-policy 字段,因此按合同 inline 等待。 +运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 `wait`,因此按合同 inline 等待。 真实 run `wf_6664b8e3427d`: diff --git a/extensions/workflows/coordinator.test.ts b/extensions/workflows/coordinator.test.ts index 6e63a37d..86ba0489 100644 --- a/extensions/workflows/coordinator.test.ts +++ b/extensions/workflows/coordinator.test.ts @@ -27,51 +27,11 @@ test("explicit wait selects inline or detached launch policy", () => { }); }); -test("legacy background maps to wait and returns an actionable warning", () => { - const detached = resolveWorkflowLaunchPolicy({ background: true }, true); - assert.deepEqual( - { wait: detached.wait, detached: detached.detached }, - { wait: false, detached: true }, - ); - assert.match( - detached.migrationWarning ?? "", - /replace background: true with wait: false.*next breaking release/i, - ); - - const inline = resolveWorkflowLaunchPolicy({ background: false }, true); - assert.deepEqual( - { wait: inline.wait, detached: inline.detached }, - { wait: true, detached: false }, - ); - assert.match( - inline.migrationWarning ?? "", - /replace background: false with wait: true.*next breaking release/i, - ); -}); - -test("consistent aliases warn while conflicts fail with migration guidance", () => { - const consistent = resolveWorkflowLaunchPolicy( - { wait: true, background: false }, - true, - ); - assert.equal(consistent.wait, true); - assert.match(consistent.migrationWarning ?? "", /remove|replace/i); - - assert.throws( - () => resolveWorkflowLaunchPolicy({ wait: true, background: true }, true), - /replace background: true with wait: false.*conflict.*remove background/is, - ); -}); - -test("unsupported detached delivery fails closed with alias guidance", () => { +test("unsupported detached delivery fails closed", () => { assert.throws( () => resolveWorkflowLaunchPolicy({ wait: false }, false), /cannot deliver/, ); - assert.throws( - () => resolveWorkflowLaunchPolicy({ background: true }, false), - /replace background: true with wait: false.*cannot deliver.*wait: true/is, - ); }); test("wait cancellation does not cancel the underlying completion", async () => { diff --git a/extensions/workflows/coordinator.ts b/extensions/workflows/coordinator.ts index d1dafc76..8c263680 100644 --- a/extensions/workflows/coordinator.ts +++ b/extensions/workflows/coordinator.ts @@ -1,49 +1,24 @@ export interface WorkflowLaunchPolicyInput { wait?: boolean; - background?: boolean; } export interface WorkflowLaunchPolicy { wait: boolean; detached: boolean; - migrationWarning?: string; } -function backgroundMigrationWarning(background: boolean) { - return `Deprecated Workflow parameter "background": replace background: ${background} with wait: ${!background}. "background" will be removed in the next breaking release.`; -} - -/** Resolve the caller's launch policy and warn on the legacy inverse alias. */ +/** Resolve the caller's wait preference against the host delivery capability. */ export function resolveWorkflowLaunchPolicy( input: WorkflowLaunchPolicyInput, canDeliverLater: boolean, ): WorkflowLaunchPolicy { - const migrationWarning = - input.background === undefined - ? undefined - : backgroundMigrationWarning(input.background); - if ( - input.wait !== undefined && - input.background !== undefined && - input.wait === input.background - ) { - throw new Error( - `${migrationWarning} wait and background conflict because background is the inverse of wait; remove background and provide only wait.`, - ); - } - const wait = - input.wait ?? - (input.background !== undefined ? !input.background : !canDeliverLater); + const wait = input.wait ?? !canDeliverLater; if (!wait && !canDeliverLater) { throw new Error( - `${migrationWarning ? `${migrationWarning} ` : ""}This host cannot deliver a workflow result later; use wait: true.`, + "This host cannot deliver a workflow result later; use wait: true", ); } - return { - wait, - detached: !wait, - ...(migrationWarning ? { migrationWarning } : {}), - }; + return { wait, detached: !wait }; } /** diff --git a/extensions/workflows/execute.e2e.test.ts b/extensions/workflows/execute.e2e.test.ts index bb99b17e..bb961ac9 100644 --- a/extensions/workflows/execute.e2e.test.ts +++ b/extensions/workflows/execute.e2e.test.ts @@ -396,49 +396,9 @@ test("print hosts wait by default and reject detached delivery", async () => { assert.equal(inline.details.background, false); assert.equal(inline.details.delivery?.state, "consumed-inline"); - const legacyInline = (await workflow.execute( - "e2e-print-legacy-inline", - { - script: "return { legacyInline: true };", - background: false, - }, - undefined, - undefined, - printCtx, - )) as AgentToolResult; - const legacyInlineContent = legacyInline.content[0]; - assert.equal(legacyInlineContent?.type, "text"); - if (legacyInlineContent?.type !== "text") assert.fail("expected text result"); - assert.match( - legacyInlineContent.text, - /replace background: false with wait: true.*next breaking release/is, - ); - assert.equal(legacyInline.details.background, false); - assert.equal( - readWorkflowJson(legacyInline.details.runId).migrationWarning, - undefined, - ); - const workflowsDir = join(agentDir, "workflows"); const runDirsBefore = readdirSync(workflowsDir).sort(); const messagesBefore = sentMessages.length; - await assert.rejects( - Promise.resolve().then(() => - workflow.execute( - "e2e-print-legacy-parse-error", - { - script: "return {", - background: false, - }, - undefined, - undefined, - printCtx, - ), - ), - /replace background: false with wait: true.*failed to parse/is, - ); - assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); - await assert.rejects( Promise.resolve().then(() => workflow.execute( @@ -456,59 +416,6 @@ test("print hosts wait by default and reject detached delivery", async () => { ); assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); assert.equal(sentMessages.length, messagesBefore); - - await assert.rejects( - Promise.resolve().then(() => - workflow.execute( - "e2e-print-legacy-detached", - { - script: "return { detached: true };", - background: true, - }, - undefined, - undefined, - printCtx, - ), - ), - /replace background: true with wait: false.*cannot deliver.*wait: true/is, - ); - assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); - assert.equal(sentMessages.length, messagesBefore); -}); - -test("legacy detached alias warns without persisting migration text", async () => { - sentMessages.length = 0; - modelIdle = true; - const launch = (await workflow.execute( - "e2e-legacy-detached", - { - script: 'export const meta = { name: "legacy-detached" };\nreturn 9;', - background: true, - }, - undefined, - undefined, - ctx, - )) as AgentToolResult; - - assert.equal(launch.details.background, true); - const launchContent = launch.content[0]; - assert.equal(launchContent?.type, "text"); - if (launchContent?.type !== "text") assert.fail("expected text result"); - assert.match( - launchContent.text, - /replace background: true with wait: false.*next breaking release/is, - ); - const persisted = readWorkflowJson(launch.details.runId); - assert.equal(persisted.background, true); - assert.equal(persisted.migrationWarning, undefined); - - await waitFor( - () => - sentMessages.some( - (sent) => sent.message.details?.runId === launch.details.runId, - ), - "legacy detached completion", - ); }); test("background runs deliver a follow-up that triggers a turn only when idle", async () => { diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 7f211f08..6738b47a 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -542,12 +542,6 @@ const WorkflowParams = Type.Object( description: WORKFLOW_PARAMETER_DESCRIPTIONS.args, }), ), - background: Type.Optional( - Type.Boolean({ - deprecated: true, - description: WORKFLOW_PARAMETER_DESCRIPTIONS.background, - }), - ), wait: Type.Optional( Type.Boolean({ description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait, @@ -598,10 +592,6 @@ function errorText(error: unknown): string { ); } -function withWorkflowMigrationWarning(text: string, warning?: string) { - return warning ? `${warning}\n\n${text}` : text; -} - function isWorkflowRenderDetails(value: unknown): value is WorkflowDetails { if (!value || typeof value !== "object") return false; const details = value as Partial; @@ -1137,21 +1127,11 @@ export default function workflows(pi: ExtensionAPI) { parameters: WorkflowParams, async execute(_toolCallId, params, signal, onUpdate, ctx) { - const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; - const launchPolicy = resolveWorkflowLaunchPolicy( - { wait: params.wait, background: params.background }, - canDeliverLater, - ); let prepared: ReturnType; try { prepared = prepareWorkflowScript(params.script); } catch (error) { - throw new Error( - withWorkflowMigrationWarning( - `Workflow script failed to parse: ${errorText(error)}`, - launchPolicy.migrationWarning, - ), - ); + throw new Error(`Workflow script failed to parse: ${errorText(error)}`); } let args: unknown; @@ -1166,6 +1146,11 @@ export default function workflows(pi: ExtensionAPI) { const meta = prepared.meta; const runId = `wf_${randomBytes(6).toString("hex")}`; const runDir = path.join(getAgentDir(), "workflows", runId); + const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; + const launchPolicy = resolveWorkflowLaunchPolicy( + { wait: params.wait }, + canDeliverLater, + ); const background = launchPolicy.detached; const now = Date.now(); @@ -2158,14 +2143,11 @@ export default function workflows(pi: ExtensionAPI) { content: [ { type: "text", - text: withWorkflowMigrationWarning( - buildBackgroundWorkflowLaunchResult({ - runId, - name: details.name, - runDir, - }), - launchPolicy.migrationWarning, - ), + text: buildBackgroundWorkflowLaunchResult({ + runId, + name: details.name, + runDir, + }), }, ], details: compactToolDetails(details), @@ -2176,10 +2158,7 @@ export default function workflows(pi: ExtensionAPI) { if (waitOutcome === "aborted") { void settleForLaterDelivery(true); throw new Error( - withWorkflowMigrationWarning( - `Workflow wait interrupted; run ${runId} continues in the background.`, - launchPolicy.migrationWarning, - ), + `Workflow wait interrupted; run ${runId} continues in the background.`, ); } recordTerminalRun(); @@ -2187,24 +2166,16 @@ export default function workflows(pi: ExtensionAPI) { if (details.status !== "completed") { // Pi marks tool failures only when execute throws; returning isError is // ignored by the extension API. - throw new Error( - withWorkflowMigrationWarning( - buildWorkflowResultMessage(details, runDir), - launchPolicy.migrationWarning, - ), - ); + throw new Error(buildWorkflowResultMessage(details, runDir)); } return { content: [ { type: "text", - text: withWorkflowMigrationWarning( - buildProjectedWorkflowResultMessage( - details, - runDir, - ctx.getContextUsage?.(), - ), - launchPolicy.migrationWarning, + text: buildProjectedWorkflowResultMessage( + details, + runDir, + ctx.getContextUsage?.(), ), }, ], @@ -2220,11 +2191,7 @@ export default function workflows(pi: ExtensionAPI) { let text = theme.fg("toolTitle", theme.bold("workflow ")) + theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)"); - if (args.background !== undefined) { - text += theme.fg("dim", ` (deprecated: use wait: ${!args.background})`); - } else if (args.wait === true) { - text += theme.fg("dim", " (wait)"); - } + if (args.wait === true) text += theme.fg("dim", " (wait)"); const description = (meta as WorkflowMeta).description; if (description) text += `\n ${theme.fg("dim", description)}`; for (const phase of meta.phases.slice(0, 8)) { diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index 6729e276..7ceeba45 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -17,8 +17,6 @@ export const WORKFLOW_PARAMETER_DESCRIPTIONS = { script: "JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.", args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).", - background: - "Deprecated compatibility alias accepted during the current migration window. Replace true with wait=false and false with wait=true. It will be removed in the next breaking release; do not provide both fields.", wait: "Wait for the final result in this tool call. Interactive sessions default to false and deliver completion later; print/automation defaults to true. Interrupting the wait does not cancel the workflow.", resumeFromRunId: "Optional prior run id or unique suffix for safe read-only replay. See the workflows Skill for matching rules.", diff --git a/extensions/workflows/rendering.test.ts b/extensions/workflows/rendering.test.ts index e2460662..947434f7 100644 --- a/extensions/workflows/rendering.test.ts +++ b/extensions/workflows/rendering.test.ts @@ -62,19 +62,15 @@ function captureRenderers() { return { workflow, message }; } -test("workflow launch schema deprecates background and rejects unknown fields", () => { +test("workflow launch schema accepts only the positive wait policy", () => { const { workflow } = captureRenderers(); const parameters = workflow.parameters as unknown as { - properties?: Record; + properties?: Record; additionalProperties?: boolean; }; assert.ok(parameters.properties?.wait); - assert.equal(parameters.properties?.background?.deprecated, true); - assert.match( - parameters.properties?.background?.description ?? "", - /true with wait=false.*false with wait=true.*next breaking release/i, - ); + assert.equal(parameters.properties?.background, undefined); assert.equal(parameters.additionalProperties, false); const toolCall = (args: Record) => ({ @@ -89,9 +85,10 @@ test("workflow launch schema deprecates background and rejects unknown fields", validateToolArguments(workflow, toolCall({ script, wait: false })), { script, wait: false }, ); - assert.deepEqual( - validateToolArguments(workflow, toolCall({ script, background: true })), - { script, background: true }, + assert.throws( + () => + validateToolArguments(workflow, toolCall({ script, background: true })), + /Validation failed.*background/s, ); assert.throws( () => validateToolArguments(workflow, toolCall({ script, detached: true })), @@ -125,35 +122,6 @@ test("workflow call rendering labels an explicit inline wait", () => { assert.match(component.render(100).join("\n"), /workflow inline \(wait\)/); }); -test("workflow call rendering gives legacy background migration guidance", () => { - const { workflow } = captureRenderers(); - assert.ok(workflow.renderCall); - const args = { - script: 'export const meta = { name: "legacy" }; return 1;', - background: true, - }; - - const component = workflow.renderCall(args, theme, { - args, - toolCallId: "call-legacy-background", - invalidate() {}, - lastComponent: undefined, - state: {}, - cwd: process.cwd(), - executionStarted: true, - argsComplete: true, - isPartial: false, - expanded: false, - showImages: false, - isError: false, - }); - - assert.match( - component.render(100).join("\n"), - /workflow legacy \(deprecated: use wait: false\)/, - ); -}); - test("workflow tool errors with malformed details fall back to plain text", (t) => { t.mock.timers.enable({ apis: ["setInterval", "Date"], now: 0 }); const { workflow } = captureRenderers(); diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index e8510c58..45d0a884 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -42,8 +42,6 @@ Each call persists intent, admission, and execution state. Interrupted nontermin Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. -New calls must use `wait`. During the current deprecation window, legacy `background` remains accepted and returns an actionable warning on every use: replace `background: true` with `wait: false`, or `background: false` with `wait: true`. The alias will be removed in the next breaking release; persisted artifact/details fields named `background` remain runtime facts and are not part of that removal. - Loading the Workflow capability exposes `workflow`, `workflow_status`, and `workflow_stop` as one stable group; starting or settling a run does not mutate the model tool Schema. `workflow_status` returns a bounded state/coverage summary and artifact path without consuming or repeating the full completion. `workflow_stop` is idempotent and preserves partial artifacts. A failed completion send remains pending with the same per-run delivery identity and is retried when the parent settles or the Session is restored. `resume_from_run_id` accepts a previous run id or unique suffix. Replay is content-based and order-independent. It requires an unchanged prompt, resolved role/schema/model/provider/effort, canonical cwd, repository state, resources, and trust context. Only provably read-only non-operator calls replay. Failed, unrestricted, unknown-tool, writable, worktree, operator, or un-fingerprintable calls run for real. Missing or old journals safely degrade to a full run. From b894d2f2d09040c7f651aec41b221f71d6aaf0c5 Mon Sep 17 00:00:00 2001 From: sunsunsun Date: Wed, 26 Aug 2026 20:57:10 +0800 Subject: [PATCH 4/5] refactor(workflows): simplify wait-only lifecycle --- .../OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md | 47 +++++++++++++------ extensions/workflows/coordinator.test.ts | 24 +++------- extensions/workflows/coordinator.ts | 19 ++------ extensions/workflows/index.ts | 10 ++-- extensions/workflows/prompt.test.ts | 10 +--- extensions/workflows/prompt.ts | 4 +- 6 files changed, 53 insertions(+), 61 deletions(-) diff --git a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md index 50313b56..57f7ff48 100644 --- a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md +++ b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md @@ -6,7 +6,7 @@ > > 依据:当前 OpenPI 源码、Issues #71/#74/#75/#90、Claude Code `2.1.241` 运行时合同访谈,以及三份相互独立的 interface 设计评审。 > -> 后续变更:Issue #132 已将调用侧策略收敛为 `wait`;下文的当前合同已同步为 wait-only,明确标注的改造前记录仍保留历史原貌。 +> 后续决定(2026-08-26):Issue #132 / PR #139 将调用侧策略收敛为 `wait`。本文件保留 Workflow V2 落地时的历史合同与验证证据;当前行为以代码和当前用户文档为准,后续结果见文末 addendum。 ## 结论 @@ -35,14 +35,14 @@ workflow_stop 本轮没有把整个 2200 行 Workflow extension 塞进一个新的大类,而是按不变量拆成三个深 seam: -- `coordinator.ts`:宿主默认值、wait policy 解析,以及 wait/terminal 仲裁; +- `coordinator.ts`:宿主默认值、`wait/background` 兼容解析,以及 wait/terminal 仲裁; - `result-delivery.ts`:逐 run delivery identity、pending/receipt/retry/restore; - `shared/result-budget.ts` 与 `shared/text-projection.ts`:Subagent/Workflow 共用的公平预算与 head/tail 投影。 已经落地: - TUI 默认 detached,print/无可靠投递宿主默认 wait; -- `wait:true` 是唯一显式同步选择,调用 schema 不再接受 `background`; +- `wait:true` 是唯一正向同步选择,`background` 仅为 deprecated inverse alias; - 中断 wait 不取消 run,stop/shutdown 才拥有取消权; - terminal execution state 与 delivery state 正交持久化; - send failure 以同一 per-run id 重试,成功 sibling 不重发; @@ -55,11 +55,11 @@ workflow_stop 明确没有增加:`workflow_wait`、size planner、budget planner、通用 Execution Fabric、daemon、全局 scheduler、递归 Workflow 或第二 provider stack。 -## 1. 改造前问题 +## 1. 当前问题 ### 1.1 默认阻塞 -改造前 `workflow` 使用: +当前 `workflow` 使用: ```ts const background = (params.background ?? false) && ctx.hasUI @@ -118,7 +118,12 @@ workflow({ script: string, args?: string, resume_from_run_id?: string, + + // 新的正向语义 wait?: boolean, + + // 旧兼容参数,逐步 deprecated + background?: boolean, }) ``` @@ -128,12 +133,15 @@ workflow({ 显式 wait -> 严格服从 -省略 wait +只有 background + -> wait = !background + +两者同时出现且语义冲突 + -> fail closed + +两者都省略 -> adapter.canDeliverLater = true:wait = false -> adapter.canDeliverLater = false:wait = true - -未知字段(包括旧 background alias) - -> schema validation fail closed ``` 为什么推荐 `wait`: @@ -143,7 +151,7 @@ workflow({ - `wait` 被中断只结束等待,不能隐式取消 run; - 只有 `workflow_stop` 取消运行。 -Issue #132 以 breaking change 删除了调用侧 `background` alias。Persisted artifact 和 tool details 中记录实际 detached 状态的 `background` 仍是运行时事实,并继续兼容读取。 +兼容期保留 `background`,避免旧模型调用和历史脚本立即失效。 ### 3.2 `workflow_status` @@ -457,7 +465,7 @@ Runtime 不自动插入 Report Agent。当前已提供: ### Phase 0:固定基线 -- 改造前 blocking/background 行为; +- 当前 blocking/background 行为; - tool schema hash; - sendMessage、final write、busy/idle、quick-completion 故障注入; - 3/16/40 ref handoff 覆盖基线。 @@ -485,7 +493,7 @@ Runtime 不自动插入 Report Agent。当前已提供: ### Phase 4:`wait` 与宿主默认值 - 新增 `wait`; -- `background` 曾在迁移期兼容映射并 deprecated,随后由 Issue #132 从调用 schema 与 coordinator input 删除; +- `background` 兼容映射并 deprecated; - interactive/Web/RPC adapter 有可靠 completion channel 时默认 `wait=false`; - print/automation 默认 `wait=true`; - wait interrupt 不取消 run; @@ -637,11 +645,11 @@ bun run test Vitest: 30/30 pass ``` -专项覆盖包括:interactive/print 默认值、wait-only schema validation、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy artifact restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 +专项覆盖包括:interactive/print 默认值、legacy alias 冲突、wait 中断、terminal/abort 仲裁、busy/idle delivery、首次 transport failure、逐 run receipt、legacy restore、`uncertain` 独立统计、稳定工具组、64-ref 公平 head/tail handoff、64-run completion 批次预算、动态父上下文投影和逐 Agent artifact。 ### 14.2 真实模型 smoke -运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 `wait`,因此按合同 inline 等待。 +运行资产与 npm 安装隔离,直接从本 checkout 显式加载 `extensions/workflows/index.ts`、`extensions/capabilities/index.ts` 和 Workflow Skill。父模型为 `seal/deepseek-v4-flash-0731-baidu`;三个 Workflow children 按本地角色配置使用 `gpt-5.6-luna`。print 宿主省略 `wait/background`,因此按合同 inline 等待。 真实 run `wf_6664b8e3427d`: @@ -657,3 +665,14 @@ bun run test ### 14.3 当前结论 Lifecycle、delivery、Schema stability、dynamic capacity、fair projection 和 artifact 证据链已经实现并有确定性或真实模型证据。尚未把通用 Execution Fabric 暴露给模型,也没有自动插入 Report Agent;这两项是刻意不做,而非未完成缺口。真正的大规模质量仍应通过后续冻结配置的 2×2 benchmark 决定,不用单次 smoke 冒充跑分提升。 + +## 15. 后续合同变更(2026-08-26) + +Issue #132 / PR #139 按维护者最终决定直接删除模型输入侧的 `background` alias:tool schema 与 coordinator input 只保留正向 `wait`,未知字段继续 fail closed。`WorkflowDetails.background` 与 persisted artifact 中的同名字段仍记录实际 detached 状态,并保留历史读取兼容。 + +该后续变更 rebase 至 `upstream/main@dd82010` 后通过: + +- Workflow 专项测试:34/34; +- `bun run check`; +- `bun run test`:Node 876/876,Vitest 30/30; +- GitHub CI:Node 22.19.0 与 Node 24。 diff --git a/extensions/workflows/coordinator.test.ts b/extensions/workflows/coordinator.test.ts index 86ba0489..43281f2a 100644 --- a/extensions/workflows/coordinator.test.ts +++ b/extensions/workflows/coordinator.test.ts @@ -1,35 +1,23 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - resolveWorkflowLaunchPolicy, + resolveWorkflowLaunchMode, waitForWorkflowCompletion, } from "./coordinator.ts"; test("interactive launch defaults detached while non-delivery hosts wait", () => { - assert.deepEqual(resolveWorkflowLaunchPolicy({}, true), { - wait: false, - detached: true, - }); - assert.deepEqual(resolveWorkflowLaunchPolicy({}, false), { - wait: true, - detached: false, - }); + assert.equal(resolveWorkflowLaunchMode(undefined, true), "detached"); + assert.equal(resolveWorkflowLaunchMode(undefined, false), "inline"); }); test("explicit wait selects inline or detached launch policy", () => { - assert.deepEqual(resolveWorkflowLaunchPolicy({ wait: true }, true), { - wait: true, - detached: false, - }); - assert.deepEqual(resolveWorkflowLaunchPolicy({ wait: false }, true), { - wait: false, - detached: true, - }); + assert.equal(resolveWorkflowLaunchMode(true, true), "inline"); + assert.equal(resolveWorkflowLaunchMode(false, true), "detached"); }); test("unsupported detached delivery fails closed", () => { assert.throws( - () => resolveWorkflowLaunchPolicy({ wait: false }, false), + () => resolveWorkflowLaunchMode(false, false), /cannot deliver/, ); }); diff --git a/extensions/workflows/coordinator.ts b/extensions/workflows/coordinator.ts index 8c263680..39e867f6 100644 --- a/extensions/workflows/coordinator.ts +++ b/extensions/workflows/coordinator.ts @@ -1,24 +1,15 @@ -export interface WorkflowLaunchPolicyInput { - wait?: boolean; -} - -export interface WorkflowLaunchPolicy { - wait: boolean; - detached: boolean; -} - /** Resolve the caller's wait preference against the host delivery capability. */ -export function resolveWorkflowLaunchPolicy( - input: WorkflowLaunchPolicyInput, +export function resolveWorkflowLaunchMode( + requestedWait: boolean | undefined, canDeliverLater: boolean, -): WorkflowLaunchPolicy { - const wait = input.wait ?? !canDeliverLater; +) { + const wait = requestedWait ?? !canDeliverLater; if (!wait && !canDeliverLater) { throw new Error( "This host cannot deliver a workflow result later; use wait: true", ); } - return { wait, detached: !wait }; + return wait ? "inline" : "detached"; } /** diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 6738b47a..04c5be21 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -89,7 +89,7 @@ import { } from "./artifacts.ts"; import { RunController } from "./controller.ts"; import { - resolveWorkflowLaunchPolicy, + resolveWorkflowLaunchMode, waitForWorkflowCompletion, } from "./coordinator.ts"; import { @@ -1147,11 +1147,11 @@ export default function workflows(pi: ExtensionAPI) { const runId = `wf_${randomBytes(6).toString("hex")}`; const runDir = path.join(getAgentDir(), "workflows", runId); const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; - const launchPolicy = resolveWorkflowLaunchPolicy( - { wait: params.wait }, + const launchMode = resolveWorkflowLaunchMode( + params.wait, canDeliverLater, ); - const background = launchPolicy.detached; + const background = launchMode === "detached"; const now = Date.now(); const details: WorkflowDetails = { @@ -1166,7 +1166,7 @@ export default function workflows(pi: ExtensionAPI) { agents: [], delivery: { id: `workflow:${runId}:terminal`, - state: launchPolicy.wait ? "held-for-inline" : "none", + state: launchMode === "inline" ? "held-for-inline" : "none", attempts: 0, updatedAt: now, }, diff --git a/extensions/workflows/prompt.test.ts b/extensions/workflows/prompt.test.ts index ebbb96bf..fa7daab0 100644 --- a/extensions/workflows/prompt.test.ts +++ b/extensions/workflows/prompt.test.ts @@ -112,14 +112,8 @@ test("launch result advertises the model-facing lifecycle tools", () => { }); test("lifecycle tool descriptions state their scope and non-blocking nature", () => { - assert.match( - WORKFLOW_STOP_TOOL_DESCRIPTION, - /Cancel a running background workflow/, - ); - assert.match( - WORKFLOW_STOP_TOOL_DESCRIPTION, - /Only background runs need this/, - ); + assert.match(WORKFLOW_STOP_TOOL_DESCRIPTION, /Cancel a running workflow/); + assert.doesNotMatch(WORKFLOW_STOP_TOOL_DESCRIPTION, /interrupting the turn/); assert.match(WORKFLOW_STATUS_TOOL_DESCRIPTION, /without blocking/); assert.match(WORKFLOW_STATUS_TOOL_DESCRIPTION, /Does not wait/); }); diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index 7ceeba45..052c9e11 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -22,9 +22,9 @@ export const WORKFLOW_PARAMETER_DESCRIPTIONS = { "Optional prior run id or unique suffix for safe read-only replay. See the workflows Skill for matching rules.", }; -/** Describes stopping a running background workflow, mirroring subagent_cancel/bg_kill. */ +/** Describes stopping a running workflow, mirroring subagent_cancel/bg_kill. */ export const WORKFLOW_STOP_TOOL_DESCRIPTION = - "Cancel a running background workflow by its run id (from the workflow launch result). This aborts its remaining agents and settles the run; partial results and artifacts are preserved. Only background runs need this — a blocking workflow is already cancelled by interrupting the turn."; + "Cancel a running workflow by its run id (from the workflow launch result). This aborts its remaining agents and settles the run; partial results and artifacts are preserved."; /** Model-facing schema description for the workflow run id to stop. */ export const WORKFLOW_STOP_PARAMETER_DESCRIPTIONS = { From 2390d0406ab792388cf2f17eac07423a21433dc5 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sun, 30 Aug 2026 14:03:09 +0800 Subject: [PATCH 5/5] fix(workflows): preserve background migration window --- .../OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md | 16 +-- extensions/workflows/coordinator.ts | 25 +++- extensions/workflows/index.ts | 14 ++- extensions/workflows/prompt.ts | 2 + skills/workflows/REFERENCE.md | 2 + .../extensions/workflows/coordinator.test.ts | 42 +++++-- .../extensions/workflows/execute.e2e.test.ts | 107 ++++++++++++++++-- tests/extensions/workflows/rendering.test.ts | 43 ++++++- 8 files changed, 214 insertions(+), 37 deletions(-) diff --git a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md index ad7a9a75..61ed7f67 100644 --- a/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md +++ b/docs/design/OPENPI_WORKFLOW_V2_DESIGN_2026-08-23.md @@ -6,7 +6,7 @@ > > 依据:当前 OpenPI 源码、Issues #71/#74/#75/#90、Claude Code `2.1.241` 运行时合同访谈,以及三份相互独立的 interface 设计评审。 > -> 后续决定(2026-08-26):Issue #132 / PR #139 将调用侧策略收敛为 `wait`。本文件保留 Workflow V2 落地时的历史合同与验证证据;当前行为以代码和当前用户文档为准,后续结果见文末 addendum。 +> 后续决定(2026-08-30):Issue #132 / PR #139 将新调用策略收敛为 `wait`,同时为已发布的 `background` alias 保留迁移窗口。本文件保留 Workflow V2 落地时的历史合同与验证证据;当前行为以代码和当前用户文档为准,后续结果见文末 addendum。 ## 结论 @@ -666,13 +666,15 @@ bun run test Lifecycle、delivery、Schema stability、dynamic capacity、fair projection 和 artifact 证据链已经实现并有确定性或真实模型证据。尚未把通用 Execution Fabric 暴露给模型,也没有自动插入 Report Agent;这两项是刻意不做,而非未完成缺口。真正的大规模质量仍应通过后续冻结配置的 2×2 benchmark 决定,不用单次 smoke 冒充跑分提升。 -## 15. 后续合同变更(2026-08-26) +## 15. 后续合同变更(2026-08-30) -Issue #132 / PR #139 按维护者最终决定直接删除模型输入侧的 `background` alias:tool schema 与 coordinator input 只保留正向 `wait`,未知字段继续 fail closed。`WorkflowDetails.background` 与 persisted artifact 中的同名字段仍记录实际 detached 状态,并保留历史读取兼容。 +Issue #132 / PR #139 将 `wait` 作为唯一推荐的新调用策略。由于 `background` 从 OpenPI v0.2.0 起就是已发布输入,本次继续把它作为 deprecated inverse alias 接受:`background: true` 对应 `wait: false`,`background: false` 对应 `wait: true`;真正删除只在另行公告的 breaking release 进行。除这一已发布兼容字段外,未知输入继续 fail closed。 -该后续变更同步至 `upstream/main@813f58e` 后通过: +Coordinator 在单一输入边界完成 legacy 映射,内部仍只产生 `inline | detached` 运行模式。`WorkflowDetails.background` 与 persisted artifact 中的同名字段继续记录实际 detached 状态,不记录调用时使用的是 `wait` 还是兼容 alias,也不改写历史 artifact。 -- Workflow 专项测试:34/34; +该后续变更的最终验证以 PR #139 exact-head review 为准,至少包括: + +- `wait`、legacy `background`、冲突输入、host delivery 能力和 wait interruption 的专项测试; - `bun run check`; -- `bun run test`:Node 878/878,Vitest 30/30; -- GitHub CI:Node 22.19.0 与 Node 24。 +- `bun run test`; +- GitHub CI:Node 22.19.0、Node 24 与 Windows background-terminal suite。 diff --git a/extensions/workflows/coordinator.ts b/extensions/workflows/coordinator.ts index 39e867f6..b6ab4500 100644 --- a/extensions/workflows/coordinator.ts +++ b/extensions/workflows/coordinator.ts @@ -1,9 +1,28 @@ -/** Resolve the caller's wait preference against the host delivery capability. */ +export interface WorkflowLaunchPolicyInput { + wait?: boolean; + background?: boolean; +} + +/** + * Resolve the caller's launch preference to one positive runtime mode. + * `background` remains only as the published inverse compatibility alias. + */ export function resolveWorkflowLaunchMode( - requestedWait: boolean | undefined, + input: WorkflowLaunchPolicyInput, canDeliverLater: boolean, ) { - const wait = requestedWait ?? !canDeliverLater; + if ( + input.wait !== undefined && + input.background !== undefined && + input.wait === input.background + ) { + throw new Error( + "wait and background conflict: background is the deprecated inverse of wait; remove background and provide only wait", + ); + } + const wait = + input.wait ?? + (input.background !== undefined ? !input.background : !canDeliverLater); if (!wait && !canDeliverLater) { throw new Error( "This host cannot deliver a workflow result later; use wait: true", diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index d14e2e55..370003ff 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -562,6 +562,12 @@ const WorkflowParams = Type.Object( description: WORKFLOW_PARAMETER_DESCRIPTIONS.args, }), ), + background: Type.Optional( + Type.Boolean({ + deprecated: true, + description: WORKFLOW_PARAMETER_DESCRIPTIONS.background, + }), + ), wait: Type.Optional( Type.Boolean({ description: WORKFLOW_PARAMETER_DESCRIPTIONS.wait, @@ -1228,7 +1234,7 @@ export default function workflows( const runDir = path.join(getAgentDir(), "workflows", runId); const canDeliverLater = ctx.hasUI && ctx.mode === "tui"; const launchMode = resolveWorkflowLaunchMode( - params.wait, + { wait: params.wait, background: params.background }, canDeliverLater, ); const background = launchMode === "detached"; @@ -2318,7 +2324,11 @@ export default function workflows( let text = theme.fg("toolTitle", theme.bold("workflow ")) + theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)"); - if (args.wait === true) text += theme.fg("dim", " (wait)"); + if (args.background !== undefined) { + text += theme.fg("dim", ` (deprecated: use wait: ${!args.background})`); + } else if (args.wait === true) { + text += theme.fg("dim", " (wait)"); + } const description = (meta as WorkflowMeta).description; if (description) text += `\n ${theme.fg("dim", description)}`; for (const phase of meta.phases.slice(0, 8)) { diff --git a/extensions/workflows/prompt.ts b/extensions/workflows/prompt.ts index daacbbce..a56afddc 100644 --- a/extensions/workflows/prompt.ts +++ b/extensions/workflows/prompt.ts @@ -17,6 +17,8 @@ export const WORKFLOW_PARAMETER_DESCRIPTIONS = { script: "JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.", args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).", + background: + "Deprecated compatibility alias for published callers only; new calls must use wait. Replace true with wait=false and false with wait=true. Do not provide both fields. The alias will be removed in the next announced breaking release.", wait: "Wait for the final result in this tool call. Interactive sessions default to false and deliver completion later; print/automation defaults to true. Interrupting the wait does not cancel the workflow.", resumeFromRunId: "Optional prior run id or unique suffix for safe read-only replay. See the workflows Skill for matching rules.", diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index b26f0d07..96979a88 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -42,6 +42,8 @@ Each call persists intent, admission, and execution state. Interrupted nontermin Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. +New calls must use `wait`. For compatibility with released OpenPI versions, the deprecated `background` alias remains accepted during the current migration window: replace `background: true` with `wait: false`, or `background: false` with `wait: true`, and do not provide both fields. The alias will be removed only in an announced breaking release. Persisted artifact/details fields named `background` remain actual detached-state facts and are not part of that removal. + Loading the Workflow capability exposes `workflow`, `workflow_status`, and `workflow_stop` as one stable group; starting or settling a run does not mutate the model tool Schema. `workflow_status` returns a bounded state/coverage summary and artifact path without consuming or repeating the full completion. `workflow_stop` is idempotent and preserves partial artifacts. A failed completion send remains pending with the same per-run delivery identity and is retried when the parent settles or the Session is restored. `resume_from_run_id` accepts a previous run id or unique suffix. Replay is content-based and order-independent. It requires an unchanged prompt, resolved role/schema/model/provider/effort, canonical cwd, repository state, resources, and trust context. Only provably read-only non-operator calls replay. Failed, unrestricted, unknown-tool, writable, worktree, operator, or un-fingerprintable calls run for real. Missing or old journals safely degrade to a full run. diff --git a/tests/extensions/workflows/coordinator.test.ts b/tests/extensions/workflows/coordinator.test.ts index ecd15a55..0e446c2c 100644 --- a/tests/extensions/workflows/coordinator.test.ts +++ b/tests/extensions/workflows/coordinator.test.ts @@ -6,20 +6,48 @@ import { } from "../../../extensions/workflows/coordinator.ts"; test("interactive launch defaults detached while non-delivery hosts wait", () => { - assert.equal(resolveWorkflowLaunchMode(undefined, true), "detached"); - assert.equal(resolveWorkflowLaunchMode(undefined, false), "inline"); + assert.equal(resolveWorkflowLaunchMode({}, true), "detached"); + assert.equal(resolveWorkflowLaunchMode({}, false), "inline"); }); -test("explicit wait selects inline or detached launch policy", () => { - assert.equal(resolveWorkflowLaunchMode(true, true), "inline"); - assert.equal(resolveWorkflowLaunchMode(false, true), "detached"); +test("wait is authoritative and legacy background maps to its inverse", () => { + assert.equal(resolveWorkflowLaunchMode({ wait: true }, true), "inline"); + assert.equal(resolveWorkflowLaunchMode({ wait: false }, true), "detached"); + assert.equal( + resolveWorkflowLaunchMode({ background: true }, true), + "detached", + ); + assert.equal( + resolveWorkflowLaunchMode({ background: false }, true), + "inline", + ); + assert.equal( + resolveWorkflowLaunchMode({ wait: true, background: false }, true), + "inline", + ); + assert.equal( + resolveWorkflowLaunchMode({ wait: false, background: true }, true), + "detached", + ); }); -test("unsupported detached delivery fails closed", () => { +test("conflicting aliases and unsupported detached delivery fail closed", () => { + assert.throws( + () => resolveWorkflowLaunchMode({ wait: true, background: true }, true), + /conflict.*background is the deprecated inverse of wait/i, + ); + assert.throws( + () => resolveWorkflowLaunchMode({ wait: false, background: false }, true), + /conflict.*background is the deprecated inverse of wait/i, + ); assert.throws( - () => resolveWorkflowLaunchMode(false, false), + () => resolveWorkflowLaunchMode({ wait: false }, false), /cannot deliver/, ); + assert.throws( + () => resolveWorkflowLaunchMode({ background: true }, false), + /cannot deliver.*wait: true/i, + ); }); test("wait cancellation does not cancel the underlying completion", async () => { diff --git a/tests/extensions/workflows/execute.e2e.test.ts b/tests/extensions/workflows/execute.e2e.test.ts index 508223f5..7db782a3 100644 --- a/tests/extensions/workflows/execute.e2e.test.ts +++ b/tests/extensions/workflows/execute.e2e.test.ts @@ -413,26 +413,109 @@ test("print hosts wait by default and reject detached delivery", async () => { assert.equal(inline.details.background, false); assert.equal(inline.details.delivery?.state, "consumed-inline"); + const legacyInline = (await workflow.execute( + "e2e-print-legacy-inline", + { + script: + 'export const meta = { name: "print-legacy-inline" };\nreturn { inline: true };', + background: false, + }, + undefined, + undefined, + printCtx, + )) as AgentToolResult; + assert.equal(legacyInline.details.background, false); + assert.equal(legacyInline.details.delivery?.state, "consumed-inline"); + assert.doesNotMatch( + legacyInline.content + .map((entry) => (entry.type === "text" ? entry.text : "")) + .join("\n"), + /deprecated|migration/i, + ); + const workflowsDir = join(agentDir, "workflows"); const runDirsBefore = readdirSync(workflowsDir).sort(); const messagesBefore = sentMessages.length; - await assert.rejects( - Promise.resolve().then(() => + for (const input of [{ wait: false }, { background: true }]) { + await assert.rejects( + Promise.resolve().then(() => + workflow.execute( + "e2e-print-detached", + { script: "return { detached: true };", ...input }, + undefined, + undefined, + printCtx, + ), + ), + /cannot deliver.*wait: true/i, + ); + } + assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); + assert.equal(sentMessages.length, messagesBefore); +}); + +test("interrupting an inline wait leaves the run stoppable and delivers one terminal result", async () => { + sentMessages.length = 0; + modelIdle = true; + let sessionCreated = false; + let releasePrompt = () => {}; + const promptGate = new Promise((resolve) => { + releasePrompt = resolve; + }); + __setWorkflowTestAgentSessionFactory(async () => { + sessionCreated = true; + return { session: fakeAgentSession("interrupted output", promptGate) }; + }); + + try { + const controller = new AbortController(); + let interruptedMessage = ""; + const execution = Promise.resolve( workflow.execute( - "e2e-print-detached", + "e2e-interrupted-inline-wait", { - script: "return { detached: true };", - wait: false, + script: + 'export const meta = { name: "interrupted-inline-wait" };\n' + + 'return await agent("wait for interruption", { agent_type: "reviewer" });', + wait: true, }, + controller.signal, undefined, - undefined, - printCtx, + ctx, ), - ), - /cannot deliver.*wait: true/i, - ); - assert.deepEqual(readdirSync(workflowsDir).sort(), runDirsBefore); - assert.equal(sentMessages.length, messagesBefore); + ).then( + () => assert.fail("interrupted inline wait unexpectedly resolved"), + (error: unknown) => { + interruptedMessage = String( + error instanceof Error ? error.message : error, + ); + }, + ); + + await waitFor(() => sessionCreated, "inline workflow before interruption"); + controller.abort(); + await execution; + const runId = interruptedMessage.match(/run (wf_[0-9a-f]+)/)?.[1]; + assert.ok(runId); + assert.match(interruptedMessage, /continues in the background/); + assert.equal(readWorkflowJson(runId).status, "running"); + + await workflowStop.execute("e2e-interrupted-inline-stop", { runId }); + releasePrompt(); + await waitFor( + () => readWorkflowJson(runId).status === "aborted", + "interrupted inline workflow cancellation", + ); + await waitFor( + () => + sentMessages.filter((sent) => sent.message.details?.runId === runId) + .length === 1, + "interrupted inline terminal delivery", + ); + } finally { + releasePrompt(); + __setWorkflowTestAgentSessionFactory(undefined); + } }); test("background runs deliver a follow-up that triggers a turn only when idle", async () => { diff --git a/tests/extensions/workflows/rendering.test.ts b/tests/extensions/workflows/rendering.test.ts index d7dc39fe..4674722d 100644 --- a/tests/extensions/workflows/rendering.test.ts +++ b/tests/extensions/workflows/rendering.test.ts @@ -99,7 +99,7 @@ function captureRenderers() { return { workflow, message }; } -test("workflow launch schema accepts only the positive wait policy", () => { +test("workflow launch schema recommends wait while preserving only the published alias", () => { const { workflow } = captureRenderers(); const parameters = workflow.parameters as unknown as { properties?: Record; @@ -107,7 +107,10 @@ test("workflow launch schema accepts only the positive wait policy", () => { }; assert.ok(parameters.properties?.wait); - assert.equal(parameters.properties?.background, undefined); + assert.deepEqual( + (parameters.properties?.background as { deprecated?: unknown })?.deprecated, + true, + ); assert.equal(parameters.additionalProperties, false); const toolCall = (args: Record) => ({ @@ -122,10 +125,9 @@ test("workflow launch schema accepts only the positive wait policy", () => { validateToolArguments(workflow, toolCall({ script, wait: false })), { script, wait: false }, ); - assert.throws( - () => - validateToolArguments(workflow, toolCall({ script, background: true })), - /Validation failed.*background/s, + assert.deepEqual( + validateToolArguments(workflow, toolCall({ script, background: true })), + { script, background: true }, ); assert.throws( () => validateToolArguments(workflow, toolCall({ script, detached: true })), @@ -159,6 +161,35 @@ test("workflow call rendering labels an explicit inline wait", () => { assert.match(component.render(100).join("\n"), /workflow inline \(wait\)/); }); +test("workflow call rendering gives legacy callers an actionable migration", () => { + const { workflow } = captureRenderers(); + assert.ok(workflow.renderCall); + const args = { + script: 'export const meta = { name: "legacy" }; return 1;', + background: true, + }; + + const component = workflow.renderCall(args, theme, { + args, + toolCallId: "call-legacy-background", + invalidate() {}, + lastComponent: undefined, + state: {}, + cwd: process.cwd(), + executionStarted: true, + argsComplete: true, + isPartial: false, + expanded: false, + showImages: false, + isError: false, + }); + + assert.match( + component.render(100).join("\n"), + /workflow legacy \(deprecated: use wait: false\)/, + ); +}); + test("workflow tool errors with malformed details fall back to plain text", (t) => { t.mock.timers.enable({ apis: ["setInterval", "Date"], now: 0 }); const { workflow } = captureRenderers();