From 8c45a7c7c312357e9507bb0922b61e77703d93d6 Mon Sep 17 00:00:00 2001 From: evilpsycho42 <202909006+evilpsycho42@users.noreply.github.com> Date: Sun, 31 May 2026 11:24:26 +0800 Subject: [PATCH 1/6] Support dynamic workflow phases --- README.md | 4 +- src/display.ts | 19 +++++-- src/workflow-tool.ts | 21 ++++++-- src/workflow.ts | 2 +- tests/workflow-display.test.ts | 91 ++++++++++++++++++++++++++++++++++ tests/workflow-runtime.test.ts | 58 ++++++++++++++++++++++ tests/workflow-tool.test.ts | 12 +++++ 7 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 tests/workflow-display.test.ts create mode 100644 tests/workflow-runtime.test.ts create mode 100644 tests/workflow-tool.test.ts diff --git a/README.md b/README.md index 06be3f2..b984f3c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Press `Esc` to cancel a running workflow. Active subagents are aborted and surfa ## Workflow script shape -A workflow is plain JavaScript. The first statement must export literal metadata: +A workflow is plain JavaScript. The first statement must export literal metadata. `name` and `description` are required; `phases` is optional metadata for a stable outline, not a complete list of everything that might happen: ```js export const meta = { @@ -73,6 +73,8 @@ const summary = await agent( return { inventory, summary } ``` +Phases are discovered as the script runs, so conditional and loop-created phases work naturally. If a branch is skipped, its phase does not show up as an empty progress row. + ### Available globals | Global | Description | diff --git a/src/display.ts b/src/display.ts index fba21b6..654a856 100644 --- a/src/display.ts +++ b/src/display.ts @@ -17,6 +17,7 @@ export interface WorkflowSnapshot { name: string; description?: string; phases: string[]; + dynamicPhases?: string[]; currentPhase?: string; logs: string[]; agents: WorkflowAgentSnapshot[]; @@ -47,7 +48,8 @@ export function createWorkflowSnapshot(meta: WorkflowMeta): WorkflowSnapshot { return { name: meta.name, description: meta.description, - phases: meta.phases?.map((phase) => phase.title) ?? [], + phases: [], + dynamicPhases: [], logs: [], agents: [], agentCount: 0, @@ -137,14 +139,21 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo : ""; const lines = [`◆ Workflow: ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state})`]; - const phaseNames = snapshot.phases.length - ? snapshot.phases - : unique(snapshot.agents.map((agent) => agent.phase).filter(Boolean) as string[]); + const agentPhaseNames = snapshot.agents + .map((agent) => agent.phase) + .filter((phase): phase is string => Boolean(phase)); + const phaseNames = unique([ + ...snapshot.phases, + ...(snapshot.currentPhase ? [snapshot.currentPhase] : []), + ...agentPhaseNames, + ]); const rendered = new Set(); for (const phase of phaseNames) { const agents = snapshot.agents.filter((agent) => agent.phase === phase); + if (agents.length === 0 && snapshot.currentPhase !== phase) continue; for (const agent of agents) rendered.add(agent); + const phaseLabel = snapshot.dynamicPhases?.includes(phase) ? `✦ ${phase}` : phase; const done = agents.filter((agent) => agent.status === "done").length; const running = agents.filter((agent) => agent.status === "running").length; const errors = agents.filter((agent) => agent.status === "error").length; @@ -152,7 +161,7 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo const complete = agents.length > 0 && done + errors + skipped === agents.length; const marker = running > 0 || (!complete && snapshot.currentPhase === phase) ? "▶" : complete ? "✓" : " "; lines.push( - ` ${marker} ${phase} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${skipped ? ` · ${skipped} skipped` : ""}`, + ` ${marker} ${phaseLabel} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${skipped ? ` · ${skipped} skipped` : ""}`, ); const visibleAgents = agents.slice(-maxAgents); diff --git a/src/workflow-tool.ts b/src/workflow-tool.ts index 087c269..45be7b2 100644 --- a/src/workflow-tool.ts +++ b/src/workflow-tool.ts @@ -15,7 +15,7 @@ const workflowToolSchema = Type.Object({ script: Type.String({ description: [ "Required raw JavaScript workflow script, with no Markdown fences.", - "First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description', phases: [{ title: 'Phase' }] }", + "First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. Add phases only when a stable upfront outline helps.", "Use phase('Name'), agent(prompt, opts), parallel(arrayOfFunctions), pipeline(items, ...stages), log(message), args, and budget. The workflow must call agent() at least once.", "parallel() requires functions, not promises: await parallel(items.map(item => () => agent(...))).", ].join(" "), @@ -41,16 +41,17 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin label: "Workflow", description: [ "Execute a deterministic JavaScript workflow that orchestrates multiple subagents with agent(), parallel(), and pipeline().", - "script is required raw JavaScript. It must start with export const meta = { name, description, phases? } and must call agent() at least once.", + "script is required raw JavaScript. It must start with export const meta = { name, description } and must call agent() at least once; phases are optional metadata.", ].join(" "), promptSnippet: - "Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description', phases: [{ title: 'Phase' }] }.", + "Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. Add phases only when they help explain the planned shape.", promptGuidelines: [ "Use workflow only when the user explicitly asks for a workflow, workflows, fan-out, or multi-agent orchestration.", "For workflow, always pass one raw JavaScript string in the required script parameter; do not include Markdown fences or prose around the script.", - "For workflow, the script's first statement must be `export const meta = { name: 'short_snake_case', description: 'non-empty human description', phases: [{ title: 'Phase name' }] }`; meta.name and meta.description are required non-empty strings.", + "For workflow, the script's first statement must be `export const meta = { name: 'short_snake_case', description: 'non-empty human description' }`; meta.name and meta.description are required non-empty strings, and meta.phases is optional metadata for a stable upfront outline.", "For workflow, write plain JavaScript after the meta export. Do not use TypeScript syntax, imports, require(), fs, Date.now(), Math.random(), or new Date().", "For workflow, available globals are agent(prompt, opts), parallel(thunks), pipeline(items, ...stages), phase(title), log(message), args, cwd, process.cwd(), and budget. Every workflow must call agent() at least once; do not use workflow only to declare phases or return a static object.", + "For workflow, call phase(title) when a new group of work starts. Phase names may be conditional or built in a loop; do not predeclare speculative phases just in case.", "For workflow, prefer it for decomposable work: repository inspection, independent research/checks, multi-perspective review, or fan-out/fan-in synthesis. Do not use it for a single quick file read/edit or when ordinary tools are enough.", "For workflow, parallel() takes functions, not promises: use `await parallel(items.map(item => () => agent('...', { label: '...' })))`, never `await parallel(items.map(item => agent(...)))`. Results are returned in input order.", "For workflow, pipeline(items, ...stages) runs each item through stages sequentially, while different items may run concurrently. Each stage receives (previousValue, originalItem, index).", @@ -67,6 +68,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin async execute(_toolCallId, params, signal, onUpdate, ctx) { const script = normalizeWorkflowScript(params.script); const parsed = parseWorkflowScript(script); + const declaredPhaseTitles = new Set(parsed.meta.phases?.map((phase) => phase.title) ?? []); let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta); const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, { key: "workflow", @@ -81,6 +83,14 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin display.update(snapshot); }; + const recordPhase = (title: string | undefined) => { + if (!title) return; + if (!snapshot.phases.includes(title)) snapshot.phases.push(title); + if (declaredPhaseTitles.has(title)) return; + snapshot.dynamicPhases ??= []; + if (!snapshot.dynamicPhases.includes(title)) snapshot.dynamicPhases.push(title); + }; + let result: WorkflowRunResult; try { result = await runWorkflow(script, { @@ -98,11 +108,12 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin }, onPhase(title) { snapshot.currentPhase = title; - if (!snapshot.phases.includes(title)) snapshot.phases.push(title); + recordPhase(title); update(); }, onAgentStart(event) { if (signal?.aborted) throw new Error("Workflow was aborted"); + recordPhase(event.phase); snapshot.agents.push({ id: snapshot.agents.length + 1, label: event.label, diff --git a/src/workflow.ts b/src/workflow.ts index 0e174f1..2b7bd8d 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -227,7 +227,7 @@ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body: const first = ast.body?.[0] as AnyNode | undefined; if (first?.type !== "ExportNamedDeclaration") { - throw new Error("`export const meta = { name, description, phases }` must be the first statement in the script"); + throw new Error("`export const meta = { name, description }` must be the first statement in the script"); } const declaration = first.declaration as AnyNode | null; diff --git a/tests/workflow-display.test.ts b/tests/workflow-display.test.ts new file mode 100644 index 0000000..6ba3394 --- /dev/null +++ b/tests/workflow-display.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createWorkflowSnapshot, + recomputeWorkflowSnapshot, + renderWorkflowLines, + type WorkflowAgentSnapshot, + type WorkflowSnapshot, +} from "../src/display.js"; + +function snapshot(overrides: Partial = {}): WorkflowSnapshot { + return recomputeWorkflowSnapshot({ + name: "demo_workflow", + phases: [], + logs: [], + agents: [], + agentCount: 0, + runningCount: 0, + doneCount: 0, + errorCount: 0, + ...overrides, + }); +} + +function agent(overrides: Partial = {}): WorkflowAgentSnapshot { + return { + id: 1, + label: "scan repo", + phase: "Scan", + prompt: "Scan the repo", + status: "done", + ...overrides, + }; +} + +test("createWorkflowSnapshot does not pre-render declared phases", () => { + const value = createWorkflowSnapshot({ + name: "demo_workflow", + description: "A useful workflow", + phases: [{ title: "Scan" }, { title: "Review" }], + }); + + assert.deepEqual(value.phases, []); +}); + +test("renderWorkflowLines hides empty phase rows", () => { + const lines = renderWorkflowLines( + snapshot({ + phases: ["Scan", "Review"], + agents: [agent()], + }), + ); + + assert.ok(lines.some((line) => line.includes("Scan 1/1"))); + assert.ok(!lines.some((line) => line.includes("Review 0/0"))); +}); + +test("renderWorkflowLines keeps the current empty phase visible", () => { + const lines = renderWorkflowLines( + snapshot({ + phases: ["Scan"], + currentPhase: "Scan", + }), + ); + + assert.ok(lines.some((line) => line.includes("▶ Scan 0/0"))); +}); + +test("renderWorkflowLines groups agents by phase even when the phase was not pre-recorded", () => { + const lines = renderWorkflowLines( + snapshot({ + phases: ["Scan"], + agents: [agent({ id: 2, label: "review diff", phase: "Review" })], + }), + ); + + assert.ok(lines.some((line) => line.includes("Review 1/1"))); + assert.ok(!lines.some((line) => line.trim() === "Unphased")); +}); + +test("renderWorkflowLines marks runtime-created phases", () => { + const lines = renderWorkflowLines( + snapshot({ + phases: ["Inspect API"], + dynamicPhases: ["Inspect API"], + agents: [agent({ label: "inspect api", phase: "Inspect API" })], + }), + ); + + assert.ok(lines.some((line) => line.includes("✦ Inspect API 1/1"))); +}); diff --git a/tests/workflow-runtime.test.ts b/tests/workflow-runtime.test.ts new file mode 100644 index 0000000..d8677b9 --- /dev/null +++ b/tests/workflow-runtime.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { runWorkflow } from "../src/workflow.js"; + +const fakeAgent = { + async run(prompt: string): Promise { + return `result:${prompt}`; + }, +}; + +test("runWorkflow accepts metadata without phases and records runtime phases", async () => { + const result = await runWorkflow( + `export const meta = { + name: 'dynamic_demo', + description: 'Use runtime phases' +} + +phase('Scan') +const scan = await agent('scan', { label: 'scan' }) +return { scan } +`, + { agent: fakeAgent }, + ); + + assert.deepEqual(result.phases, ["Scan"]); + assert.equal(result.agentCount, 1); + assert.equal((result.result as { scan: string }).scan, "result:scan"); +}); + +test("runWorkflow records loop-created phases without skipped conditional phases", async () => { + const result = await runWorkflow( + `export const meta = { + name: 'loop_demo', + description: 'Create phases from work items', + phases: [{ title: 'Review' }] +} + +if (args.needsReview) { + phase('Review') + await agent('review', { label: 'review' }) +} + +for (const area of args.areas) { + phase('Inspect ' + area) + await agent('inspect ' + area, { label: 'inspect ' + area }) +} + +return { ok: true } +`, + { + args: { needsReview: false, areas: ["API", "UI"] }, + agent: fakeAgent, + }, + ); + + assert.deepEqual(result.phases, ["Inspect API", "Inspect UI"]); + assert.equal(result.agentCount, 2); +}); diff --git a/tests/workflow-tool.test.ts b/tests/workflow-tool.test.ts new file mode 100644 index 0000000..ab1c39c --- /dev/null +++ b/tests/workflow-tool.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWorkflowTool } from "../src/workflow-tool.js"; + +test("createWorkflowTool describes phases as optional and dynamic", () => { + const tool = createWorkflowTool(); + + assert.match(tool.promptSnippet ?? "", /export const meta = \{ name: 'short_snake_case', description:/); + assert.doesNotMatch(tool.promptSnippet ?? "", /phases: \[/); + assert.ok(tool.promptGuidelines?.some((line) => line.includes("meta.phases is optional metadata"))); + assert.ok(tool.promptGuidelines?.some((line) => line.includes("Phase names may be conditional or built in a loop"))); +}); From 0a30057b87065754a8707fd4eb8d4b5b2e223e78 Mon Sep 17 00:00:00 2001 From: evilpsycho42 <202909006+evilpsycho42@users.noreply.github.com> Date: Sun, 31 May 2026 11:52:21 +0800 Subject: [PATCH 2/6] Simplify workflow phase snapshots --- src/display.ts | 5 +---- src/workflow-tool.ts | 4 ---- tests/workflow-display.test.ts | 5 ++--- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/display.ts b/src/display.ts index 654a856..19f7333 100644 --- a/src/display.ts +++ b/src/display.ts @@ -17,7 +17,6 @@ export interface WorkflowSnapshot { name: string; description?: string; phases: string[]; - dynamicPhases?: string[]; currentPhase?: string; logs: string[]; agents: WorkflowAgentSnapshot[]; @@ -49,7 +48,6 @@ export function createWorkflowSnapshot(meta: WorkflowMeta): WorkflowSnapshot { name: meta.name, description: meta.description, phases: [], - dynamicPhases: [], logs: [], agents: [], agentCount: 0, @@ -153,7 +151,6 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo const agents = snapshot.agents.filter((agent) => agent.phase === phase); if (agents.length === 0 && snapshot.currentPhase !== phase) continue; for (const agent of agents) rendered.add(agent); - const phaseLabel = snapshot.dynamicPhases?.includes(phase) ? `✦ ${phase}` : phase; const done = agents.filter((agent) => agent.status === "done").length; const running = agents.filter((agent) => agent.status === "running").length; const errors = agents.filter((agent) => agent.status === "error").length; @@ -161,7 +158,7 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo const complete = agents.length > 0 && done + errors + skipped === agents.length; const marker = running > 0 || (!complete && snapshot.currentPhase === phase) ? "▶" : complete ? "✓" : " "; lines.push( - ` ${marker} ${phaseLabel} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${skipped ? ` · ${skipped} skipped` : ""}`, + ` ${marker} ${phase} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${skipped ? ` · ${skipped} skipped` : ""}`, ); const visibleAgents = agents.slice(-maxAgents); diff --git a/src/workflow-tool.ts b/src/workflow-tool.ts index 45be7b2..55d520e 100644 --- a/src/workflow-tool.ts +++ b/src/workflow-tool.ts @@ -68,7 +68,6 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin async execute(_toolCallId, params, signal, onUpdate, ctx) { const script = normalizeWorkflowScript(params.script); const parsed = parseWorkflowScript(script); - const declaredPhaseTitles = new Set(parsed.meta.phases?.map((phase) => phase.title) ?? []); let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta); const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, { key: "workflow", @@ -86,9 +85,6 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin const recordPhase = (title: string | undefined) => { if (!title) return; if (!snapshot.phases.includes(title)) snapshot.phases.push(title); - if (declaredPhaseTitles.has(title)) return; - snapshot.dynamicPhases ??= []; - if (!snapshot.dynamicPhases.includes(title)) snapshot.dynamicPhases.push(title); }; let result: WorkflowRunResult; diff --git a/tests/workflow-display.test.ts b/tests/workflow-display.test.ts index 6ba3394..3d32696 100644 --- a/tests/workflow-display.test.ts +++ b/tests/workflow-display.test.ts @@ -78,14 +78,13 @@ test("renderWorkflowLines groups agents by phase even when the phase was not pre assert.ok(!lines.some((line) => line.trim() === "Unphased")); }); -test("renderWorkflowLines marks runtime-created phases", () => { +test("renderWorkflowLines renders runtime-created phases from the phase list", () => { const lines = renderWorkflowLines( snapshot({ phases: ["Inspect API"], - dynamicPhases: ["Inspect API"], agents: [agent({ label: "inspect api", phase: "Inspect API" })], }), ); - assert.ok(lines.some((line) => line.includes("✦ Inspect API 1/1"))); + assert.ok(lines.some((line) => line.includes("Inspect API 1/1"))); }); From 7fb02ba2ca8e63cabdfe33470fda2aef759a126f Mon Sep 17 00:00:00 2001 From: Michael Livshits Date: Sun, 31 May 2026 09:28:03 +0300 Subject: [PATCH 3/6] Respect workflow display log limits --- src/display.ts | 10 +++++++--- src/workflow-tool.ts | 11 ++++++++--- tests/workflow-display.test.ts | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/display.ts b/src/display.ts index 19f7333..d911791 100644 --- a/src/display.ts +++ b/src/display.ts @@ -104,7 +104,7 @@ export function createToolUpdateWorkflowDisplay( const emit = (snapshot: WorkflowSnapshot, completed = false) => { if (streamToolUpdates) { onUpdate?.({ - content: [{ type: "text", text: renderWorkflowText(snapshot, completed) }], + content: [{ type: "text", text: renderWorkflowText(snapshot, completed, options) }], details: snapshot, }); } @@ -184,9 +184,13 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo return lines; } -export function renderWorkflowText(snapshot: WorkflowSnapshot, completed = false): string { +export function renderWorkflowText( + snapshot: WorkflowSnapshot, + completed = false, + options: WorkflowDisplayOptions = {}, +): string { const header = completed ? "Workflow completed" : "Workflow running"; - return [header, ...renderWorkflowLines(snapshot)].join("\n"); + return [header, ...renderWorkflowLines(snapshot, options)].join("\n"); } function statusLine(snapshot: WorkflowSnapshot, completed: boolean): string { diff --git a/src/workflow-tool.ts b/src/workflow-tool.ts index 55d520e..8261f30 100644 --- a/src/workflow-tool.ts +++ b/src/workflow-tool.ts @@ -69,13 +69,14 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin const script = normalizeWorkflowScript(params.script); const parsed = parseWorkflowScript(script); let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta); - const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, { + const displayOptions = { key: "workflow", streamToolUpdates: true, maxAgents: 4, maxLogs: 1, showResultPreviews: false, - }); + }; + const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, displayOptions); const update = () => { snapshot = recomputeWorkflowSnapshot(snapshot); @@ -179,7 +180,11 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin renderResult(result, { isPartial }, theme) { const snapshot = result.details as WorkflowSnapshot | undefined; if (snapshot?.name) { - return new Text(renderWorkflowText(snapshot, !isPartial), 0, 0); + return new Text( + renderWorkflowText(snapshot, !isPartial, { maxAgents: 4, maxLogs: 1, showResultPreviews: false }), + 0, + 0, + ); } const text = result.content?.[0]; return new Text(text?.type === "text" ? text.text : theme.fg("muted", "workflow"), 0, 0); diff --git a/tests/workflow-display.test.ts b/tests/workflow-display.test.ts index 3d32696..3fa6309 100644 --- a/tests/workflow-display.test.ts +++ b/tests/workflow-display.test.ts @@ -4,6 +4,7 @@ import { createWorkflowSnapshot, recomputeWorkflowSnapshot, renderWorkflowLines, + renderWorkflowText, type WorkflowAgentSnapshot, type WorkflowSnapshot, } from "../src/display.js"; @@ -88,3 +89,17 @@ test("renderWorkflowLines renders runtime-created phases from the phase list", ( assert.ok(lines.some((line) => line.includes("Inspect API 1/1"))); }); + +test("renderWorkflowText respects log limits", () => { + const text = renderWorkflowText( + snapshot({ + logs: ["first", "second", "third"], + }), + true, + { maxLogs: 1 }, + ); + + assert.doesNotMatch(text, /log: first/); + assert.doesNotMatch(text, /log: second/); + assert.match(text, /log: third/); +}); From 64abfb00a10e9d8531221051d60b0cb918cde513 Mon Sep 17 00:00:00 2001 From: Michael Livshits Date: Sun, 31 May 2026 09:28:58 +0300 Subject: [PATCH 4/6] Separate workflow logs from progress --- src/display.ts | 6 +++++- tests/workflow-display.test.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/display.ts b/src/display.ts index d911791..0335758 100644 --- a/src/display.ts +++ b/src/display.ts @@ -180,7 +180,11 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo } } - for (const log of snapshot.logs.slice(-maxLogs)) lines.push(` log: ${log}`); + const visibleLogs = snapshot.logs.slice(-maxLogs); + if (visibleLogs.length) { + if (lines.length > 1) lines.push(""); + for (const log of visibleLogs) lines.push(` log: ${log}`); + } return lines; } diff --git a/tests/workflow-display.test.ts b/tests/workflow-display.test.ts index 3fa6309..db0eab9 100644 --- a/tests/workflow-display.test.ts +++ b/tests/workflow-display.test.ts @@ -103,3 +103,16 @@ test("renderWorkflowText respects log limits", () => { assert.doesNotMatch(text, /log: second/); assert.match(text, /log: third/); }); + +test("renderWorkflowLines separates logs from progress", () => { + const lines = renderWorkflowLines( + snapshot({ + agents: [agent()], + logs: ["finished scan"], + }), + ); + + const logIndex = lines.findIndex((line) => line.includes("log: finished scan")); + assert.ok(logIndex > 0); + assert.equal(lines[logIndex - 1], ""); +}); From 58ee18cf156e21d0ce2dd77c43c74ed4ce4a44cf Mon Sep 17 00:00:00 2001 From: Michael Livshits Date: Sun, 31 May 2026 09:30:12 +0300 Subject: [PATCH 5/6] Reuse workflow display options --- src/workflow-tool.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/workflow-tool.ts b/src/workflow-tool.ts index 8261f30..2f543b7 100644 --- a/src/workflow-tool.ts +++ b/src/workflow-tool.ts @@ -30,6 +30,14 @@ export type WorkflowToolInput = { args?: unknown; }; +const workflowDisplayOptions = { + key: "workflow", + streamToolUpdates: true, + maxAgents: 4, + maxLogs: 1, + showResultPreviews: false, +} as const; + export interface WorkflowToolOptions { cwd?: string; concurrency?: number; @@ -69,14 +77,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin const script = normalizeWorkflowScript(params.script); const parsed = parseWorkflowScript(script); let snapshot: WorkflowSnapshot = createWorkflowSnapshot(parsed.meta); - const displayOptions = { - key: "workflow", - streamToolUpdates: true, - maxAgents: 4, - maxLogs: 1, - showResultPreviews: false, - }; - const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, displayOptions); + const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, workflowDisplayOptions); const update = () => { snapshot = recomputeWorkflowSnapshot(snapshot); @@ -180,11 +181,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin renderResult(result, { isPartial }, theme) { const snapshot = result.details as WorkflowSnapshot | undefined; if (snapshot?.name) { - return new Text( - renderWorkflowText(snapshot, !isPartial, { maxAgents: 4, maxLogs: 1, showResultPreviews: false }), - 0, - 0, - ); + return new Text(renderWorkflowText(snapshot, !isPartial, workflowDisplayOptions), 0, 0); } const text = result.content?.[0]; return new Text(text?.type === "text" ? text.text : theme.fg("muted", "workflow"), 0, 0); From b382836652acf84c2f63e2fe16fca8ce417df8bc Mon Sep 17 00:00:00 2001 From: Michael Livshits Date: Sun, 31 May 2026 09:31:46 +0300 Subject: [PATCH 6/6] Clarify runtime workflow phases --- README.md | 2 +- src/workflow-tool.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b984f3c..87f7727 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Press `Esc` to cancel a running workflow. Active subagents are aborted and surfa ## Workflow script shape -A workflow is plain JavaScript. The first statement must export literal metadata. `name` and `description` are required; `phases` is optional metadata for a stable outline, not a complete list of everything that might happen: +A workflow is plain JavaScript. The first statement must export literal metadata. `name` and `description` are required; `phases` is optional documentation for an expected outline. The live progress view is driven by `phase(...)` calls at runtime: ```js export const meta = { diff --git a/src/workflow-tool.ts b/src/workflow-tool.ts index 2f543b7..f5c3162 100644 --- a/src/workflow-tool.ts +++ b/src/workflow-tool.ts @@ -15,7 +15,7 @@ const workflowToolSchema = Type.Object({ script: Type.String({ description: [ "Required raw JavaScript workflow script, with no Markdown fences.", - "First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. Add phases only when a stable upfront outline helps.", + "First statement: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. meta.phases is optional documentation; live progress is driven by phase(title).", "Use phase('Name'), agent(prompt, opts), parallel(arrayOfFunctions), pipeline(items, ...stages), log(message), args, and budget. The workflow must call agent() at least once.", "parallel() requires functions, not promises: await parallel(items.map(item => () => agent(...))).", ].join(" "), @@ -52,7 +52,7 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin "script is required raw JavaScript. It must start with export const meta = { name, description } and must call agent() at least once; phases are optional metadata.", ].join(" "), promptSnippet: - "Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. Add phases only when they help explain the planned shape.", + "Run a deterministic JavaScript workflow. Required script header: export const meta = { name: 'short_snake_case', description: 'non-empty description' }. Use phase(title) at runtime to create progress groups.", promptGuidelines: [ "Use workflow only when the user explicitly asks for a workflow, workflows, fan-out, or multi-agent orchestration.", "For workflow, always pass one raw JavaScript string in the required script parameter; do not include Markdown fences or prose around the script.",