diff --git a/README.md b/README.md index 06be3f2..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: +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 = { @@ -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..0335758 100644 --- a/src/display.ts +++ b/src/display.ts @@ -47,7 +47,7 @@ export function createWorkflowSnapshot(meta: WorkflowMeta): WorkflowSnapshot { return { name: meta.name, description: meta.description, - phases: meta.phases?.map((phase) => phase.title) ?? [], + phases: [], logs: [], agents: [], agentCount: 0, @@ -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, }); } @@ -137,13 +137,19 @@ 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 done = agents.filter((agent) => agent.status === "done").length; const running = agents.filter((agent) => agent.status === "running").length; @@ -174,13 +180,21 @@ 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; } -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 087c269..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', phases: [{ title: 'Phase' }] }", + "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(" "), @@ -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; @@ -41,16 +49,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' }. 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.", - "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).", @@ -68,19 +77,18 @@ 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, { - key: "workflow", - streamToolUpdates: true, - maxAgents: 4, - maxLogs: 1, - showResultPreviews: false, - }); + const display = createToolUpdateWorkflowDisplay(onUpdate, undefined, workflowDisplayOptions); const update = () => { snapshot = recomputeWorkflowSnapshot(snapshot); display.update(snapshot); }; + const recordPhase = (title: string | undefined) => { + if (!title) return; + if (!snapshot.phases.includes(title)) snapshot.phases.push(title); + }; + let result: WorkflowRunResult; try { result = await runWorkflow(script, { @@ -98,11 +106,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, @@ -172,7 +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), 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); 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..db0eab9 --- /dev/null +++ b/tests/workflow-display.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createWorkflowSnapshot, + recomputeWorkflowSnapshot, + renderWorkflowLines, + renderWorkflowText, + 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 renders runtime-created phases from the phase list", () => { + const lines = renderWorkflowLines( + snapshot({ + phases: ["Inspect API"], + agents: [agent({ label: "inspect api", phase: "Inspect API" })], + }), + ); + + 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/); +}); + +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], ""); +}); 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"))); +});