From b399acedbf28f0aef195dc2371b54a9e350b8538 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 21:44:41 +0200 Subject: [PATCH] surface Claude Code Workflow runs in convo + sidebar (T-416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `Workflow` tool-use launches its multi-agent run in the background and returns immediately; the real fan-out arrives out-of-band on stream-json `type:"system"` task_* events (task_started / task_progress / task_updated / task_notification) keyed by the launching tool-use id — which clide was dropping. (Wire shape captured by two live stream-json probes; recorded on the ticket.) - workflow_run.dart: a pure, Flutter-free WorkflowRun/WorkflowAgent model that folds those events (phases, per-agent start→progress→done deltas, usage) into a snapshot. - StreamJsonSession recognises the events, accumulates a Map, and exposes `workflows` + `workflowsStream`. - A `Workflow` tool-use with a live run renders a dedicated run card — phase groups, per-agent rows with spinner/check status, usage, and the script — falling back to the generic tool card pre-progress or on reload. The run breaks the activity cluster so it's always first-class (like T-342). - The sidebar Activity tab adds a WORKFLOWS section: one row per run with its done/total agent count, tinted by running/done state. Closes T-416 and the T-410 epic (all children done). Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 18 ++ .pql/changelog/tickets/2026-06.sql | 49 ++++ CHANGELOG.md | 5 + lib/builtin/claude/src/activity_cluster.dart | 3 + .../claude/src/claude_meta_sidebar.dart | 27 +- lib/builtin/claude/src/claude_pane.dart | 16 ++ lib/builtin/claude/src/conversation_view.dart | 118 +++++++++ .../claude/src/meta_sidebar/activity_tab.dart | 34 ++- .../claude/src/stream_json_session.dart | 34 +++ lib/builtin/claude/src/workflow_run.dart | 234 ++++++++++++++++++ .../builtin/claude/activity_cluster_test.dart | 10 + test/builtin/claude/activity_tab_test.dart | 25 ++ .../claude/claude_meta_sidebar_test.dart | 31 +++ test/builtin/claude/claude_pane_test.dart | 38 +++ .../claude/conversation_view_test.dart | 42 ++++ .../claude/stream_json_session_test.dart | 54 ++++ test/builtin/claude/workflow_run_test.dart | 137 ++++++++++ 17 files changed, 871 insertions(+), 4 deletions(-) create mode 100644 lib/builtin/claude/src/workflow_run.dart create mode 100644 test/builtin/claude/workflow_run_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index a05d9f1e..8d61376d 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4367,3 +4367,21 @@ answered headless in stream-json (free, num_turns 0) with parseable text (session %, week % all-models, week % Sonnet). The Activity tab now renders it via parseUsageText + a user-initiated refresh control. Remaining scope for this ticket would be per-member/team budget split, if still wanted.', NULL, '2026-06-12 12:11:28', '2026-06-12 12:11:28', '2026-06-12 12:11:28', NULL, '5e45a2e89f2234f8b642e1b9968d7ef1', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'description', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_ runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line. + +SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12.', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_ runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line. + +SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12. + +--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) --- +ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: ''. The run is async — today clide shows only the generic tool card + that result, no fan-out. +PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id: + - task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source) + - task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index) + - task_updated: patch{status,end_time} + - task_notification: terminal status:''completed'', output_file, summary, usage +LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) — they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) — live progress shows during the session; on reload only the tool card + result summary survive. +PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', NULL, '2026-06-12 14:22:42', '2026-06-12 14:22:42', '2026-06-12 14:22:42', NULL, '0b91f092dbd940481e385ccef6e45e6c', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'ready', 'in_progress', NULL, '2026-06-12 14:22:45', '2026-06-12 14:22:45', '2026-06-12 14:22:45', NULL, 'bff47e0f2b4ebfbf5a4ca6b9c7b45825', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'in_progress', 'done', NULL, '2026-06-12 14:52:31', '2026-06-12 14:52:31', '2026-06-12 14:52:31', NULL, 'd05d502773832fcfb5c8baf19a606b1c', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'status', 'ready', 'done', NULL, '2026-06-12 19:44:20', '2026-06-12 19:44:20', '2026-06-12 19:44:20', NULL, '3637ac53f5fc94698f604db89fdcb7e0', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index ae6308e2..b7cb8b4b 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4793,3 +4793,52 @@ answered headless in stream-json (free, num_turns 0) with parseable text (session %, week % all-models, week % Sonnet). The Activity tab now renders it via parseUsageText + a user-initiated refresh control. Remaining scope for this ticket would be per-member/team budget split, if still wanted.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-12 12:11:28', NULL, '98e11d00c142b8377584e35a45b39cdd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_ runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line. + +SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12. + +--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) --- +ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: ''. The run is async — today clide shows only the generic tool card + that result, no fan-out. +PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id: + - task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source) + - task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index) + - task_updated: patch{status,end_time} + - task_notification: terminal status:''completed'', output_file, summary, usage +LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) — they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) — live progress shows during the session; on reload only the tool card + result summary survive. +PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:22:42', NULL, '41ff693db584a23f2e5938f873384c2b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_ runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line. + +SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12. + +--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) --- +ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: ''. The run is async — today clide shows only the generic tool card + that result, no fan-out. +PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id: + - task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source) + - task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index) + - task_updated: patch{status,end_time} + - task_notification: terminal status:''completed'', output_file, summary, usage +LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) — they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) — live progress shows during the session; on reload only the tool card + result summary survive. +PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:22:45', NULL, 'fad55deb0db50c438acb9efe72c6e76c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'story', '06FBP3EZC7AJANXZVF3D91QYWM', 'Surface Claude Code Workflow runs in the convo + status surfaces', 'Claude Code''s Workflow mechanism (multi-agent orchestration: wf_ runs, phases, agent fan-outs, live progress) currently renders in clide as ordinary tool-use cards at best. Integrate it properly: (1) CONVO PANEL — recognise a Workflow tool-use and render a dedicated run card: phase groups, per-agent rows with live status, and the run''s result summary; reuse the collapser/agent-card machinery (T-305/T-342) rather than inventing new chrome. (2) STATUS/SIDEBAR — while a workflow runs, show an aggregate indicator (run id, phase, agents active/done) in the Claude sidebar Activity tab and/or status line. + +SPIKE FIRST: capture what the stream-json wire actually emits during a Workflow run (tool_use input shape, progress/notification events, sidechain attribution for workflow-spawned agents) — same probe method as T-410''s. Scope the rendering to what the wire really carries; if progress only exists in the harness UI and not on the wire, document that limit and render what''s available (start/end + result). Filed from user request 2026-06-12. + +--- SPIKE FINDINGS (2026-06-12, claude 2.1.175 stream-json probe) --- +ANCHOR: Workflow run is a normal assistant tool_use {name:''Workflow'', input:{script}}. Its tool_result returns IMMEDIATELY: ''Workflow launched in background. Task ID: ''. The run is async — today clide shows only the generic tool card + that result, no fan-out. +PROGRESS: carried on type:''system'' events (clide currently drops these), keyed by tool_use_id + task_id: + - task_started: task_id, tool_use_id, description, task_type:''local_workflow'', workflow_name, prompt(script source) + - task_progress (repeated): usage{total_tokens,tool_uses,duration_ms}, summary, workflow_progress[] = per-agent {index,label,model,state:start->progress->done,agentId} DELTAS (partial; merge by index) + - task_updated: patch{status,end_time} + - task_notification: terminal status:''completed'', output_file, summary, usage +LIMITS: (1) workflow agents are NOT sidechain messages (no separate assistant/user wire events, unlike Task) — they exist only as workflow_progress telemetry; existing parentToolUseId nesting does not apply. (2) system events are ephemeral (not in resumed transcript JSONL) — live progress shows during the session; on reload only the tool card + result summary survive. +PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in StreamJsonSession; upgrade the Workflow tool_use card to render agent rows + status; add an aggregate active-workflow indicator to the Activity tab. Raw probe wire: /tmp/wf-probe/wire.jsonl.', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 10:25:00', '2026-06-12 14:52:31', NULL, 'b810aebc9678105a6e4e3628810172cb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'epic', NULL, 'Claude sidebar — power control panel + full harness-command capture', 'PROBLEM. clide runs the Claude CLI headless (stream-json). The CLI''s interactive TUI commands don''t exist there: a typed /effort (observed live, 2026-06-12) is forwarded and the CLI answers with a raw system/local_command error — ''/effort isn''t available in this environment.'' Today clide owns only {clear, resume, fork, model} (slash_commands.dart kClideOwnedCommands); every other TUI-only builtin (/permissions /status /cost /help /config /memory /mcp /agents /usage-adjacent…) falls through to that raw error. Meanwhile the sidebar''s Config/Activity tabs (T-182/T-183) are mostly read-only rows — the lone interactions are the permission badge and T-408''s model picker (which lives in the pane''s interaction zone, not the sidebar). + +EVIDENCE / GROUNDING. clide already caches the CLI initialize handshake per version (~/.config/clide/claude/init-.json, T-151). The advertised slash_commands list = skills + a small headless-capable builtin set (clear, compact, context, init, review, security-review, usage, …). TUI-only builtins are NOT advertised — that''s the authoritative signal for what forwards safely. Current CLI 2.1.175. + +DESIGN — three layers: +1. ROUTING TABLE (one source of truth, replaces kClideOwnedCommands): every command token → forward (advertised by the live probe: skills, compact, context, …) | owned (clide implements natively: clear/resume/fork/model today; effort, permissions, config, memory, mcp, agents, todos, status, help as they land) | unavailable (known TUI-only with no equivalent: doctor, login, logout, terminal-setup, ide, exit → friendly explanation card, never the CLI''s raw error). Probe-driven so new CLI versions stay current without code changes. +2. REACTIVE CATCH-ALL: any system/local_command response whose stdout matches "isn''t available in this environment" renders as a muted hint card (pointing at the matching sidebar control when one exists) instead of raw harness output. This captures FUTURE unknown commands automatically — the guarantee ''nothing surfaces as a raw harness error'', not just a hand-maintained list. +3. SIDEBAR = POWER CONTROL PANEL (D-6 parity): every owned command gets a sidebar interaction and every sidebar control is reachable as a slash command. Config tab gains inline pickers (model T-408, permission T-275, effort new); Activity tab gains session controls (clear/compact/fork/resume) and a usage/cost block (/usage IS advertised in stream-json per the probes — revisit T-158''s upstream blocker). + +Child stories carry the implementation slices. Refs: slash_commands.dart, claude_pane._send, claude_meta_sidebar.dart (+T-395 split), claude_config.dart probe, T-408 set_model spike pattern.', 'done', 'high', NULL, NULL, NULL, '2026-06-12 08:58:29', '2026-06-12 19:44:20', NULL, 'ab48816b1f3007884c4b29ac3acd7104', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index f99d9fce..143ddaee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Claude Code Workflow runs surface in the conversation and sidebar.** A + `Workflow` tool-use renders a dedicated run card — phase groups, per-agent + rows with live spinner/check status, usage, and the script — driven by the + harness's out-of-band progress events. The Activity tab adds a WORKFLOWS + section showing each run's done/total agent count. (T-416) - **Session controls and live usage in the Claude sidebar Activity tab.** A SESSION strip offers clear/compact/fork/resume buttons (same code path as the typed commands), and a refresh control fetches `/usage` — plan usage renders diff --git a/lib/builtin/claude/src/activity_cluster.dart b/lib/builtin/claude/src/activity_cluster.dart index 9d319157..248fde18 100644 --- a/lib/builtin/claude/src/activity_cluster.dart +++ b/lib/builtin/claude/src/activity_cluster.dart @@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map too // breaks the cluster at every level, including L3, so parallel agents // never merge into one Activity card. if (isAgentTool(name)) return false; + // A Workflow run is a first-class orchestration card too (T-416): it owns + // the live agent fan-out, so it never folds into a generic Activity card. + if (name == 'Workflow') return false; // The Edit/Write call stays first-class with its diff at L1/L2. if (level == FoldLevel.everything) return true; return !isDiffTool(name); diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index d2bec2bc..ffb098f4 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -39,6 +39,7 @@ import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, Team import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, parseUsageText; import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation; import 'package:clide/builtin/claude/src/transcript_reader.dart' show AssistantTextMessage, ConversationItem, SessionStatus; +import 'package:clide/builtin/claude/src/workflow_run.dart' show WorkflowRun; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/widgets.dart'; @@ -87,7 +88,9 @@ class _ClaudeMetaSidebarState extends State { StreamSubscription? _tabSub; StreamSubscription? _primarySub; StreamSubscription? _primaryItemsSub; + StreamSubscription>? _primaryWorkflowsSub; ClaudeUsage? _usage; + Map _workflows = const {}; StreamSubscription? _brokerChangeSub; Timer? _timer; late final Future Function() _load; @@ -204,15 +207,32 @@ class _ClaudeMetaSidebarState extends State { _primarySub = null; _primaryItemsSub?.cancel(); _primaryItemsSub = null; + _primaryWorkflowsSub?.cancel(); + _primaryWorkflowsSub = null; if (session == null) { - if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null); + if (mounted && (_primaryStatus != null || _workflows.isNotEmpty)) { + setState(() { + _primaryStatus = null; + _workflows = const {}; + }); + } return; } final seed = session.status; - if (mounted) setState(() => _primaryStatus = seed); + if (mounted) { + setState(() { + _primaryStatus = seed; + _workflows = session.workflows; + }); + } _primarySub = session.statusStream.listen((s) { if (mounted) setState(() => _primaryStatus = s); }); + // The Activity tab's WORKFLOWS section tracks the primary session's live + // workflow runs (T-416). + _primaryWorkflowsSub = session.workflowsStream.listen((w) { + if (mounted) setState(() => _workflows = w); + }); // Watch for /usage responses: CLI-local output arrives as synthetic // assistant text; when it parses as usage, the Activity block updates // (T-415). Driven by the refresh control publishing '/usage'. @@ -271,6 +291,7 @@ class _ClaudeMetaSidebarState extends State { _tabSub?.cancel(); _primarySub?.cancel(); _primaryItemsSub?.cancel(); + _primaryWorkflowsSub?.cancel(); _brokerChangeSub?.cancel(); _injectCtl.dispose(); _config?.removeListener(_onConfigChange); @@ -286,7 +307,7 @@ class _ClaudeMetaSidebarState extends State { SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)), Expanded( child: switch (_tab) { - SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage), + SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage, workflows: _workflows), SidebarTab.team => TeamTabView( members: _members, memberStatus: _memberStatus, diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 97cc3b25..f5203e34 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -25,6 +25,7 @@ import 'slash_commands.dart'; import 'stream_json_session.dart'; import 'task_list.dart'; import 'transcript_reader.dart'; +import 'workflow_run.dart'; /// The Claude conversation pane. Drives `claude` over the stream-json control /// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events @@ -76,6 +77,7 @@ class _ClaudePaneState extends State { StreamSubscription? _projectSub; StreamSubscription? _commandSub; StreamSubscription? _modelErrorSub; + StreamSubscription>? _workflowsSub; ConversationController? _conversation; StreamJsonSession? _session; SessionStatus _status = const SessionStatus(); @@ -213,6 +215,8 @@ class _ClaudePaneState extends State { _endSub = null; _modelErrorSub?.cancel(); _modelErrorSub = null; + _workflowsSub?.cancel(); + _workflowsSub = null; // The orchestrator owns the session, so disposing this pane does NOT kill // it — that's what lets a hidden/kept-alive pane keep its session (T-169). // A secondary tab being *closed* is a real teardown, so close its session; @@ -268,6 +272,8 @@ class _ClaudePaneState extends State { _endSub = null; _modelErrorSub?.cancel(); _modelErrorSub = null; + _workflowsSub?.cancel(); + _workflowsSub = null; _modelPickerOpen = false; _effortPickerOpen = false; _permissionPickerOpen = false; @@ -385,6 +391,13 @@ class _ClaudePaneState extends State { if (!mounted) return; setState(() => _status = s); }); + // Workflow runs arrive on out-of-band system events that add no + // conversation item, so the view won't rebuild on its own — drive a + // rebuild as the run map changes so the workflow card updates live (T-416). + _workflowsSub = managed.session.workflowsStream.listen((_) { + if (!mounted) return; + setState(() {}); + }); // A rejected /model change (unknown name) rolls back silently in the // status — say why out loud (T-408). _modelErrorSub = managed.session.modelErrors.listen((msg) { @@ -671,6 +684,8 @@ class _ClaudePaneState extends State { _endSub = null; _modelErrorSub?.cancel(); _modelErrorSub = null; + _workflowsSub?.cancel(); + _workflowsSub = null; _modelPickerOpen = false; _effortPickerOpen = false; _permissionPickerOpen = false; @@ -729,6 +744,7 @@ class _ClaudePaneState extends State { hiddenToolUseIds: _session?.promptedToolUseIds ?? const {}, toolUseOutcomes: _session?.toolUseOutcomes ?? const {}, quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const {}, + workflows: _session?.workflows ?? const {}, emptyState: ClaudeBanner( role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', workspace: _repoRoot, diff --git a/lib/builtin/claude/src/conversation_view.dart b/lib/builtin/claude/src/conversation_view.dart index 6137cdf5..df30bea9 100644 --- a/lib/builtin/claude/src/conversation_view.dart +++ b/lib/builtin/claude/src/conversation_view.dart @@ -15,12 +15,14 @@ import 'dart:io'; import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/bash_tail_source.dart'; +import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel; import 'package:clide/builtin/claude/src/conversation_card.dart'; import 'package:clide/builtin/claude/src/conversation_controller.dart'; import 'package:clide/builtin/claude/src/file_tail_follower.dart'; import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/prompt_card.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:clide/kernel/src/facade.dart'; import 'package:clide/kernel/src/syntax/language_map.dart'; import 'package:clide/kernel/src/theme/controller.dart'; @@ -38,11 +40,18 @@ class ConversationView extends StatefulWidget { this.hiddenToolUseIds = const {}, this.toolUseOutcomes = const {}, this.quietErrorToolUseIds = const {}, + this.workflows = const {}, this.foldLevel = FoldLevel.tools, }); final ConversationController controller; + /// Live Workflow runs keyed by their launching `Workflow` tool-use id + /// (T-416). A `Workflow` tool-use card with a matching run renders the + /// dedicated run card (phases, agent rows, status) instead of the generic + /// tool card; absent (pre-progress, or on reload) it falls back to generic. + final Map workflows; + /// How aggressively consecutive meta items (tool calls/results, thinking) /// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]). final FoldLevel foldLevel; @@ -332,6 +341,7 @@ class _ConversationViewState extends State { resultByToolUseId: resultByToolUseId, promptsByToolUseId: fold.promptsByToolUseId, runByToolUseId: fold.runByToolUseId, + workflows: widget.workflows, ), FoldedCluster(:final items) => _ActivityCard( key: ValueKey('cluster.${items.first.uuid}'), @@ -343,6 +353,7 @@ class _ConversationViewState extends State { resultByToolUseId: resultByToolUseId, promptsByToolUseId: fold.promptsByToolUseId, runByToolUseId: fold.runByToolUseId, + workflows: widget.workflows, ), EditRun(:final edits) => _EditRunCard( key: ValueKey('edits.${edits.first.uuid}'), @@ -503,6 +514,7 @@ class _ConversationTurn extends StatelessWidget { this.resultByToolUseId = const {}, this.promptsByToolUseId = const >{}, this.runByToolUseId = const >{}, + this.workflows = const {}, }); final ConversationItem item; @@ -538,6 +550,9 @@ class _ConversationTurn extends StatelessWidget { /// thinking, tool cards) nested under the Agent card in a holder (T-264). final Map> runByToolUseId; + /// Live Workflow runs keyed by launching tool-use id (T-416). + final Map workflows; + @override Widget build(BuildContext context) { final i = item; @@ -697,6 +712,13 @@ class _ConversationTurn extends StatelessWidget { /// and its own per-item mark. An Agent/Task call also nests its visible /// sub-agent run in a second collapser below (T-264). Widget _toolUseCollapser(AssistantToolUse t) { + // A Workflow tool-use with a live run (T-416) renders the dedicated run + // card — phases, agent rows, status — instead of the generic tool card. No + // run yet (pre-progress, or on reload where the system events are gone) + // falls through to the generic collapser below. + if (t.name == 'Workflow' && workflows[t.toolUseId] != null) { + return _workflowCard(t, workflows[t.toolUseId]!); + } final outcome = toolUseOutcomes[t.toolUseId]; final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError); final collapser = ClideCollapserCard( @@ -740,6 +762,99 @@ class _ConversationTurn extends StatelessWidget { ); } + /// A dedicated card for a Workflow run (T-416): the harness's multi-agent + /// orchestration. The collapser header carries the run's live status (spinner + /// while running, check when done) and a `done/total agents` counter; the body + /// lists each fanned-out agent — grouped under phase headers when the workflow + /// declared phases — plus the run's usage and the orchestration script. + Widget _workflowCard(AssistantToolUse t, WorkflowRun run) { + final title = run.name ?? 'workflow'; + final color = run.done ? tokens.statusSuccess : tokens.globalFocus; + final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents'; + final detail = run.done ? (run.summary ?? run.description) : run.description; + final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail'; + return ClideCollapserCard( + label: 'workflow', + color: color, + collapsedSummary: collapsedSummary, + counter: counter, + status: run.done ? ClideRunStatus.success : ClideRunStatus.running, + children: [_workflowBody(t, run)], + ); + } + + Widget _workflowBody(AssistantToolUse t, WorkflowRun run) { + final agents = run.orderedAgents; + final phases = run.orderedPhases; + final rows = []; + if (phases.isEmpty) { + rows.addAll(agents.map(_workflowAgentRow)); + } else { + for (final p in phases) { + rows.add( + Padding( + padding: const EdgeInsets.only(top: 6, bottom: 2), + child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600), + ), + ); + rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow)); + } + // Agents the deltas never tagged with a phase still render, after the + // phased groups, so nothing fanned out is silently dropped. + rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow)); + } + if (rows.isEmpty) { + rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta)); + } + + final script = t.input['script']; + return ConversationCard( + variant: ConversationCardVariant.bordered, + accent: run.done ? tokens.statusSuccess : tokens.globalFocus, + label: run.name ?? 'workflow', + copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input), + body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows), + extraSegments: [ + if (run.totalTokens != null && run.totalTokens! > 0) + CardSegment( + label: 'usage', + child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta), + ), + if (script is String) + CardSegment( + label: 'script', + child: ClideCodeBlock(source: script, language: 'javascript'), + ), + ], + margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH), + ); + } + + /// One agent row in a workflow card: a state glyph (spinner while running, a + /// muted check once done), the agent's label, and its model (T-416). + Widget _workflowAgentRow(WorkflowAgent a) { + final done = a.state == WorkflowAgentState.done; + final Widget glyph = done + ? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess) + : ClideSpinner(size: 12, color: tokens.globalTextMuted); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox(width: 16, child: Center(child: glyph)), + const SizedBox(width: 6), + Expanded( + child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + if (a.model != null && a.model!.isNotEmpty) ...[ + const SizedBox(width: 8), + ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1), + ], + ], + ), + ); + } + /// The inner content card for a tool use (T-305): the call body + folded /// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own /// collapse caret — the enclosing collapser owns collapse. Used both as a @@ -907,6 +1022,7 @@ class _ActivityCard extends StatelessWidget { required this.resultByToolUseId, required this.promptsByToolUseId, required this.runByToolUseId, + this.workflows = const {}, }); final List items; @@ -917,6 +1033,7 @@ class _ActivityCard extends StatelessWidget { final Map resultByToolUseId; final Map> promptsByToolUseId; final Map> runByToolUseId; + final Map workflows; @override Widget build(BuildContext context) { @@ -938,6 +1055,7 @@ class _ActivityCard extends StatelessWidget { resultByToolUseId: resultByToolUseId, promptsByToolUseId: promptsByToolUseId, runByToolUseId: runByToolUseId, + workflows: workflows, ), ], ); diff --git a/lib/builtin/claude/src/meta_sidebar/activity_tab.dart b/lib/builtin/claude/src/meta_sidebar/activity_tab.dart index 8e5950f3..2724b171 100644 --- a/lib/builtin/claude/src/meta_sidebar/activity_tab.dart +++ b/lib/builtin/claude/src/meta_sidebar/activity_tab.dart @@ -9,12 +9,20 @@ import 'package:clide/builtin/claude/src/claude_stats.dart'; import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus; +import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; class ActivityTabView extends StatelessWidget { - const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config, this.usage}); + const ActivityTabView({ + super.key, + required this.stats, + required this.primaryStatus, + required this.config, + this.usage, + this.workflows = const {}, + }); final ClaudeStats stats; final SessionStatus? primaryStatus; @@ -24,6 +32,11 @@ class ActivityTabView extends StatelessWidget { /// control (T-415). Null until the first refresh. final ClaudeUsage? usage; + /// Live Workflow runs in the primary session, keyed by launching tool-use id + /// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with + /// its done/total agent count and running/done state. + final Map workflows; + /// Publish a slash command for the primary pane to execute — the session /// controls are the same code path as typing the command (D-6). void _command(BuildContext context, String text) { @@ -36,6 +49,7 @@ class ActivityTabView extends StatelessWidget { final latest = stats.latest; final u = usage; final sections = [ + ..._workflowSection(tokens), if (u != null) MetaSection('USAGE', [ if (u.session != null) MetaRow('session', u.session!), @@ -94,6 +108,24 @@ class ActivityTabView extends StatelessWidget { ); } + /// An aggregate WORKFLOWS section while one or more workflow runs exist this + /// session (T-416): a row per run — its name and `done/total agents`, tinted + /// focus while running and success once complete. + List _workflowSection(SurfaceTokens tokens) { + final runs = workflows.values.toList(); + if (runs.isEmpty) return const []; + return [ + MetaSection('WORKFLOWS', [ + for (final r in runs) + MetaRow( + r.name ?? r.taskId ?? 'workflow', + r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? ' ✓' : ''}', + valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus, + ), + ]), + ]; + } + List _runtimeSection(SurfaceTokens tokens) { final st = primaryStatus; final skills = config?.skills.length; diff --git a/lib/builtin/claude/src/stream_json_session.dart b/lib/builtin/claude/src/stream_json_session.dart index ee3f71dd..39cecc5b 100644 --- a/lib/builtin/claude/src/stream_json_session.dart +++ b/lib/builtin/claude/src/stream_json_session.dart @@ -19,6 +19,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:clide/src/util/value_stream.dart'; /// The claude subprocess, abstracted so tests drive it without spawning. @@ -377,6 +378,20 @@ class StreamJsonSession { Map get toolUseOutcomes => _toolUseOutcome; Set get quietErrorToolUseIds => _quietErrorToolUses; + /// Live Workflow runs, keyed by their launching `Workflow` tool-use id + /// (T-416). Accumulated from the out-of-band `system` task_* events the + /// harness emits while a workflow runs in the background; the conversation + /// card and the sidebar indicator both read this snapshot. Ephemeral — the + /// events aren't in the resumed transcript, so this is empty on reload. + final _workflows = {}; + final _workflowsCtl = ValueStream>.seeded(const {}); + + /// The current workflow runs, keyed by launching tool-use id. + Map get workflows => Map.unmodifiable(_workflows); + + /// Emits the workflow-run map whenever a `system` task event updates it. + Stream> get workflowsStream => _workflowsCtl.stream; + /// Whether a turn is in flight (between a send and claude's `result`). Drives /// the composer's Stop affordance. bool _busy = false; @@ -501,6 +516,15 @@ class StreamJsonSession { return; } + // Workflow run progress (T-416): the harness reports a backgrounded Workflow + // tool's fan-out on out-of-band `system` task_* events keyed by the + // launching tool-use id. Fold them into the run snapshot and notify; they + // carry no conversation item, so don't fall through to the parser. + if (isWorkflowSystemEvent(ev)) { + _onWorkflowEvent(ev); + return; + } + // Finalise a streamed reply: when the real text `assistant` event for a // message we streamed arrives, reuse the placeholder's `partial-` uuid // so the controller replaces the placeholder in place rather than appending @@ -574,6 +598,15 @@ class StreamJsonSession { } } + /// Fold one workflow `system` task event into its run snapshot, keyed by the + /// launching tool-use id, and publish the updated map (T-416). + void _onWorkflowEvent(Map ev) { + final id = ev['tool_use_id'] as String; + final prior = _workflows[id] ?? WorkflowRun(toolUseId: id); + _workflows[id] = prior.foldEvent(ev); + _workflowsCtl.add(Map.unmodifiable(_workflows)); + } + /// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt] /// item the UI resolves; every other subtype is answered with an error so /// the turn never hangs waiting on us (D-78). @@ -938,6 +971,7 @@ class StreamJsonSession { await _proc.kill(); await _items.close(); await _statusCtl.close(); + await _workflowsCtl.close(); await _sessionIdCtl.close(); await _pendingCtl.close(); await _busyCtl.close(); diff --git a/lib/builtin/claude/src/workflow_run.dart b/lib/builtin/claude/src/workflow_run.dart new file mode 100644 index 00000000..0cad38f6 --- /dev/null +++ b/lib/builtin/claude/src/workflow_run.dart @@ -0,0 +1,234 @@ +/// Live state of a Claude Code Workflow run (T-416). +/// +/// A Workflow is the harness's multi-agent orchestration tool. The model calls +/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the +/// tool returns immediately ("launched in background") and the run's real +/// progress arrives out-of-band on stream-json `type: "system"` events keyed by +/// the launching tool-use id. This file is the pure, Flutter-free model that +/// folds those events into a snapshot the conversation/sidebar surfaces render. +/// +/// Wire shape (captured by the T-416 spike, claude 2.1.175): +/// - `task_started` — task_id, tool_use_id, description, workflow_name, +/// prompt (script source) +/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary, +/// and `workflow_progress[]`, a DELTA list mixing +/// `{type:"workflow_phase", index, title}` and +/// `{type:"workflow_agent", index, label, phaseIndex?, +/// phaseTitle?, model, state(start|progress|done), +/// agentId?}` — partial, merged by index. +/// - `task_updated` — patch{status, end_time} +/// - `task_notification` — terminal status:"completed", summary, usage +/// +/// Limit: these events are ephemeral (not persisted to the resumed transcript +/// JSONL), so live progress shows during the session; on reload only the tool +/// card + its "launched in background" result survive. +library; + +/// Lifecycle of a single workflow agent, from its `state` field. +enum WorkflowAgentState { start, progress, done, unknown } + +WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) { + 'start' || 'queued' || 'running' => WorkflowAgentState.start, + 'progress' => WorkflowAgentState.progress, + 'done' || 'complete' || 'completed' => WorkflowAgentState.done, + _ => WorkflowAgentState.unknown, +}; + +/// One phase declared by `meta.phases` / a `phase()` call. +class WorkflowPhase { + const WorkflowPhase({required this.index, required this.title}); + + final int index; + final String title; +} + +/// One agent fanned out by the workflow. Fields accrete across `task_progress` +/// deltas — a later delta fills in `agentId` / upgrades `model` / advances +/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot. +class WorkflowAgent { + const WorkflowAgent({ + required this.index, + required this.label, + this.model, + this.state = WorkflowAgentState.start, + this.agentId, + this.phaseIndex, + this.phaseTitle, + }); + + final int index; + final String label; + final String? model; + final WorkflowAgentState state; + final String? agentId; + final int? phaseIndex; + final String? phaseTitle; + + /// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior + /// values where the delta omits a field. + WorkflowAgent mergeDelta(Map e) => WorkflowAgent( + index: index, + label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label, + model: (e['model'] as String?) ?? model, + state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state, + agentId: (e['agentId'] as String?) ?? agentId, + phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex, + phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle, + ); + + static WorkflowAgent fromDelta(Map e) => WorkflowAgent( + index: (e['index'] as num).toInt(), + label: (e['label'] as String?) ?? '', + model: e['model'] as String?, + state: parseWorkflowAgentState(e['state']), + agentId: e['agentId'] as String?, + phaseIndex: (e['phaseIndex'] as num?)?.toInt(), + phaseTitle: e['phaseTitle'] as String?, + ); +} + +/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot +/// with a single `system` task event applied (the session keeps one per +/// launching tool-use id and replaces it as events arrive). +class WorkflowRun { + const WorkflowRun({ + required this.toolUseId, + this.taskId, + this.name, + this.description, + this.summary, + this.done = false, + this.totalTokens, + this.toolUses, + this.durationMs, + this.phases = const {}, + this.agents = const {}, + }); + + /// The launching `Workflow` tool-use id — the join key to the conversation + /// card and across all of this run's system events. + final String toolUseId; + + /// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`. + final String? taskId; + + /// `workflow_name` from `meta.name`. + final String? name; + final String? description; + final String? summary; + + /// True once a `task_updated{status:completed}` or `task_notification` + /// terminal event lands. + final bool done; + + final int? totalTokens; + final int? toolUses; + final int? durationMs; + + /// Phase index → phase. Empty for a phase-less workflow. + final Map phases; + + /// Agent index → agent snapshot. + final Map agents; + + bool get running => !done; + int get agentCount => agents.length; + int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length; + + /// Agents in index order — the order the script fanned them out. + List get orderedAgents { + final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index)); + return list; + } + + /// Phases in index order. + List get orderedPhases { + final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index)); + return list; + } + + WorkflowRun _copyWith({ + String? taskId, + String? name, + String? description, + String? summary, + bool? done, + int? totalTokens, + int? toolUses, + int? durationMs, + Map? phases, + Map? agents, + }) => WorkflowRun( + toolUseId: toolUseId, + taskId: taskId ?? this.taskId, + name: name ?? this.name, + description: description ?? this.description, + summary: summary ?? this.summary, + done: done ?? this.done, + totalTokens: totalTokens ?? this.totalTokens, + toolUses: toolUses ?? this.toolUses, + durationMs: durationMs ?? this.durationMs, + phases: phases ?? this.phases, + agents: agents ?? this.agents, + ); + + /// Apply one `system` task event ([ev]) and return the updated snapshot. + /// [ev] must already be the decoded envelope; unknown subtypes return `this`. + WorkflowRun foldEvent(Map ev) { + switch (ev['subtype']) { + case 'task_started': + return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?); + case 'task_progress': + return _foldProgress(ev); + case 'task_updated': + final patch = ev['patch']; + final status = patch is Map ? patch['status'] as String? : null; + return _copyWith(done: status == 'completed' || status == 'failed' ? true : null); + case 'task_notification': + final status = ev['status'] as String?; + return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']); + default: + return this; + } + } + + WorkflowRun _foldProgress(Map ev) { + final phases = Map.from(this.phases); + final agents = Map.from(this.agents); + final progress = ev['workflow_progress']; + if (progress is List) { + for (final raw in progress) { + if (raw is! Map) continue; + final e = raw.cast(); + final idx = (e['index'] as num?)?.toInt(); + if (idx == null) continue; + switch (e['type']) { + case 'workflow_phase': + phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx'); + case 'workflow_agent': + final prior = agents[idx]; + agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e); + } + } + } + return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']); + } + + WorkflowRun _foldUsage(Object? usage) { + if (usage is! Map) return this; + return _copyWith( + totalTokens: (usage['total_tokens'] as num?)?.toInt(), + toolUses: (usage['tool_uses'] as num?)?.toInt(), + durationMs: (usage['duration_ms'] as num?)?.toInt(), + ); + } +} + +/// The `system` subtypes that carry workflow run progress (T-416). Other system +/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone. +const Set kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'}; + +/// True when [ev] is a `system` event carrying workflow run progress that names +/// a launching tool-use id we can key on. +bool isWorkflowSystemEvent(Map ev) => + ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true; diff --git a/test/builtin/claude/activity_cluster_test.dart b/test/builtin/claude/activity_cluster_test.dart index cfa7b0cd..4ea633e8 100644 --- a/test/builtin/claude/activity_cluster_test.dart +++ b/test/builtin/claude/activity_cluster_test.dart @@ -76,6 +76,16 @@ void main() { expect(groupConversation(const [], FoldLevel.tools), isEmpty); }); + test('a Workflow run stays first-class even at L3, never folded (T-416)', () { + // At every fold level the Workflow tool-use owns its own card so the live + // run card can render — it must not fold into a generic Activity cluster. + for (final level in FoldLevel.values) { + final groups = groupConversation([_tool('1', 'Workflow'), _result('1')], level); + expect(groups.first, isA(), reason: '$level'); + expect((groups.first as StickyItem).item, isA(), reason: '$level'); + } + }); + test('an image card stays first-class even at L3 (everything)', () { final img = ImageMessage(uuid: 'i${_n++}', timestamp: _ts, isSidechain: false, path: '/abs/shot.png'); final groups = groupConversation([_tool('1', 'Bash'), _result('1'), img], FoldLevel.everything); diff --git a/test/builtin/claude/activity_tab_test.dart b/test/builtin/claude/activity_tab_test.dart index 63571f38..a09264d7 100644 --- a/test/builtin/claude/activity_tab_test.dart +++ b/test/builtin/claude/activity_tab_test.dart @@ -5,6 +5,7 @@ library; import 'package:clide/builtin/claude/src/claude_stats.dart'; import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage; import 'package:clide/builtin/claude/src/meta_sidebar/activity_tab.dart'; +import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/kernel_fixture.dart'; @@ -34,4 +35,28 @@ void main() { expect(find.text('No activity recorded yet.'), findsOneWidget); expect(find.text('USAGE'), findsNothing); }); + + testWidgets('renders a WORKFLOWS row per live run with its done/total count (T-416)', (tester) async { + var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words'); + run = run.foldEvent({ + 'subtype': 'task_progress', + 'tool_use_id': 'x1', + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'done'}, + {'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'}, + ], + }); + await tester.pumpWidget(harness(f, ActivityTabView(stats: const ClaudeStats(), primaryStatus: null, config: null, workflows: {'x1': run}))); + await tester.pump(); + + expect(find.text('WORKFLOWS'), findsOneWidget); + expect(find.text('parallel-words'), findsOneWidget); + expect(find.text('1/2 agents'), findsOneWidget); + }); + + testWidgets('no workflows → no WORKFLOWS section', (tester) async { + await tester.pumpWidget(harness(f, const ActivityTabView(stats: ClaudeStats(), primaryStatus: null, config: null))); + await tester.pump(); + expect(find.text('WORKFLOWS'), findsNothing); + }); } diff --git a/test/builtin/claude/claude_meta_sidebar_test.dart b/test/builtin/claude/claude_meta_sidebar_test.dart index a8962e38..c0215a6e 100644 --- a/test/builtin/claude/claude_meta_sidebar_test.dart +++ b/test/builtin/claude/claude_meta_sidebar_test.dart @@ -1109,4 +1109,35 @@ void main() { expect(find.text('MCP SERVERS · 0'), findsOneWidget); }); }); + + group('T-416 workflow runs in the Activity tab', () { + testWidgets('a primary workflow run surfaces as a WORKFLOWS row', (tester) async { + _FakeProc? proc; + final orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async => proc = _FakeProc()); + await orch.spawn(const SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo')); + await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.activity))); + await tester.pump(); + + // The harness emits the workflow progress on the primary session's wire. + proc!._ctl.add( + jsonEncode({ + 'type': 'system', + 'subtype': 'task_progress', + 'tool_use_id': 'toolu_wf', + 'summary': 'orchestrating', + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'done'}, + {'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'}, + ], + }), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('WORKFLOWS'), findsOneWidget); + expect(find.text('1/2 agents'), findsOneWidget); + + orch.dispose(); + }); + }); } diff --git a/test/builtin/claude/claude_pane_test.dart b/test/builtin/claude/claude_pane_test.dart index f86d7186..db2c3f27 100644 --- a/test/builtin/claude/claude_pane_test.dart +++ b/test/builtin/claude/claude_pane_test.dart @@ -167,6 +167,44 @@ void main() { expect(proc.writes.any((w) => w.contains('hello there')), isTrue); }); + testWidgets('a Workflow run renders its dedicated card in the conversation (T-416)', (tester) async { + await mount(tester, const ClaudePane(showChrome: false)); + final proc = created.single; + final semantics = tester.ensureSemantics(); + + await act(tester, () { + proc.feed({ + 'type': 'assistant', + 'uuid': 'a-wf', + 'message': { + 'role': 'assistant', + 'content': [ + { + 'type': 'tool_use', + 'id': 'toolu_wf', + 'name': 'Workflow', + 'input': {'script': 'await parallel([])'}, + }, + ], + }, + }); + proc.feed({ + 'type': 'system', + 'subtype': 'task_progress', + 'tool_use_id': 'toolu_wf', + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 1, 'label': 'first agent', 'state': 'done'}, + {'type': 'workflow_agent', 'index': 2, 'label': 'second agent', 'state': 'start'}, + ], + }); + }); + + // The dedicated workflow collapser, with its live done/total agent counter. + expect(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'), findsOneWidget); + + semantics.dispose(); + }); + testWidgets('/clear empties the deterministic session in place', (tester) async { await mount(tester, const ClaudePane(showChrome: false)); final firstProc = created.single; diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 41db3b34..b10bc8d4 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -13,6 +13,7 @@ import 'package:clide/builtin/claude/src/conversation_view.dart'; import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/transcript_publisher.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:clide/kernel/src/events/message_bus.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart'; @@ -154,6 +155,7 @@ void main() { Set hiddenToolUseIds = const {}, Map toolUseOutcomes = const {}, Set quietErrorToolUseIds = const {}, + Map workflows = const {}, FoldLevel foldLevel = FoldLevel.none, }) async { tester.view.physicalSize = const Size(900, 700); @@ -178,6 +180,7 @@ void main() { hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, quietErrorToolUseIds: quietErrorToolUseIds, + workflows: workflows, foldLevel: foldLevel, ), ), @@ -196,6 +199,45 @@ void main() { expect(find.text('Waiting for Claude…'), findsOneWidget); }); + testWidgets('a Workflow tool-use with a live run renders the workflow card (T-416)', (tester) async { + var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words'); + run = run.foldEvent({ + 'subtype': 'task_progress', + 'tool_use_id': 'x1', + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 1, 'label': 'do alpha', 'model': 'haiku', 'state': 'done'}, + {'type': 'workflow_agent', 'index': 2, 'label': 'do beta', 'model': 'haiku', 'state': 'start'}, + ], + }); + await pumpWith( + tester, + [ + _tool('Workflow', const {'script': 'await parallel([])'}), + ], + workflows: {'x1': run}, + ); + + // Collapsed: the dedicated workflow collapser with its done/total counter + // and the workflow name as the summary (no description set → no duplicate). + expect(find.bySemanticsLabel('workflow, 1/2 agents, collapsed'), findsOneWidget); + expect(find.text('parallel-words'), findsOneWidget); + expect(find.text('do alpha'), findsNothing); // folded while collapsed + + // Expand → the per-agent rows show. + await tester.tap(find.bySemanticsLabel('workflow, 1/2 agents, collapsed')); + await tester.pumpAndSettle(); + expect(find.text('do alpha'), findsOneWidget); + expect(find.text('do beta'), findsOneWidget); + }); + + testWidgets('a Workflow tool-use with no run yet falls back to the generic tool card (T-416)', (tester) async { + await pumpWith(tester, [ + _tool('Workflow', const {'script': 'await parallel([])'}), + ]); + // No run snapshot → the generic tool collapser labeled by the tool name. + expect(find.bySemanticsLabel('Workflow, 1 step, collapsed'), findsOneWidget); + }); + testWidgets('unfolded conversation cards carry stable per-item identity keys (T-285)', (tester) async { tester.view.physicalSize = const Size(900, 800); tester.view.devicePixelRatio = 1.0; diff --git a/test/builtin/claude/stream_json_session_test.dart b/test/builtin/claude/stream_json_session_test.dart index 49a0742d..5ff5b500 100644 --- a/test/builtin/claude/stream_json_session_test.dart +++ b/test/builtin/claude/stream_json_session_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:clide/builtin/claude/src/stream_json_session.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:clide/builtin/claude/src/workflow_run.dart'; import 'package:test/test.dart'; class _FakeProc extends StreamJsonProcess { @@ -945,4 +946,57 @@ void main() { expect(b.lines, ['line 2', 'line 3', 'line 4']); }); }); + + group('workflow runs (T-416)', () { + test('accumulates a run from system task_* events keyed by tool_use_id', () async { + final p = _FakeProc(); + final session = StreamJsonSession(p)..start(); + final snapshots = >[]; + session.workflowsStream.listen(snapshots.add); + + p.emit( + jsonEncode({ + 'type': 'system', + 'subtype': 'task_started', + 'task_id': 'wy01fihjt', + 'tool_use_id': 'toolu_wf', + 'description': 'Two agents', + 'workflow_name': 'parallel-words', + }), + ); + p.emit( + jsonEncode({ + 'type': 'system', + 'subtype': 'task_progress', + 'tool_use_id': 'toolu_wf', + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 1, 'label': 'alpha', 'state': 'start'}, + {'type': 'workflow_agent', 'index': 2, 'label': 'beta', 'state': 'done'}, + ], + }), + ); + p.emit(jsonEncode({'type': 'system', 'subtype': 'task_notification', 'tool_use_id': 'toolu_wf', 'status': 'completed', 'summary': 'done'})); + await Future.delayed(Duration.zero); + + final run = session.workflows['toolu_wf']; + expect(run, isNotNull); + expect(run!.name, 'parallel-words'); + expect(run.agentCount, 2); + expect(run.doneCount, 1); + expect(run.done, isTrue); + expect(run.summary, 'done'); + expect(snapshots, isNotEmpty); + }); + + test('a workflow system event produces no conversation item', () async { + final p = _FakeProc(); + final session = StreamJsonSession(p)..start(); + final items = []; + session.items.listen(items.add); + p.emit(jsonEncode({'type': 'system', 'subtype': 'task_progress', 'tool_use_id': 'toolu_wf', 'workflow_progress': const []})); + await Future.delayed(Duration.zero); + expect(items, isEmpty); + expect(session.workflows.containsKey('toolu_wf'), isTrue); + }); + }); } diff --git a/test/builtin/claude/workflow_run_test.dart b/test/builtin/claude/workflow_run_test.dart new file mode 100644 index 00000000..9d2a4ab8 --- /dev/null +++ b/test/builtin/claude/workflow_run_test.dart @@ -0,0 +1,137 @@ +/// Tests for the WorkflowRun model (T-416): folding stream-json `system` +/// task_* events — the exact shapes captured by the spike — into a snapshot. +library; + +import 'package:clide/builtin/claude/src/workflow_run.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isWorkflowSystemEvent', () { + test('accepts task_* system events that name a tool_use_id', () { + for (final s in kWorkflowSystemSubtypes) { + expect(isWorkflowSystemEvent({'type': 'system', 'subtype': s, 'tool_use_id': 'toolu_1'}), isTrue, reason: s); + } + }); + + test('rejects unrelated system subtypes and non-system events', () { + expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'init', 'tool_use_id': 'x'}), isFalse); + expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'thinking_tokens'}), isFalse); + expect(isWorkflowSystemEvent({'type': 'assistant', 'subtype': 'task_progress', 'tool_use_id': 'x'}), isFalse); + expect(isWorkflowSystemEvent({'type': 'system', 'subtype': 'task_progress'}), isFalse); // no tool_use_id + }); + }); + + group('foldEvent — phase-less run', () { + test('task_started seeds name/description/taskId', () { + const run = WorkflowRun(toolUseId: 'toolu_1'); + final r = run.foldEvent({ + 'type': 'system', + 'subtype': 'task_started', + 'task_id': 'wy01fihjt', + 'tool_use_id': 'toolu_1', + 'description': 'Two agents return one word each', + 'workflow_name': 'parallel-words', + 'prompt': 'export const meta = ...', + }); + expect(r.taskId, 'wy01fihjt'); + expect(r.name, 'parallel-words'); + expect(r.description, 'Two agents return one word each'); + expect(r.running, isTrue); + expect(r.agentCount, 0); + }); + + test('task_progress merges workflow_agent deltas by index', () { + var run = const WorkflowRun(toolUseId: 'toolu_1'); + // First delta: two agents start, then get their agentIds + real model. + run = run.foldEvent({ + 'type': 'system', + 'subtype': 'task_progress', + 'tool_use_id': 'toolu_1', + 'summary': 'Two agents return one word each', + 'usage': {'total_tokens': 0, 'tool_uses': 0, 'duration_ms': 28}, + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 1, 'label': 'alpha', 'model': 'haiku', 'state': 'start'}, + {'type': 'workflow_agent', 'index': 2, 'label': 'beta', 'model': 'haiku', 'state': 'start'}, + {'type': 'workflow_agent', 'index': 1, 'agentId': 'ae51341336dd3a4a0', 'model': 'claude-haiku-4-5-20251001', 'state': 'start'}, + ], + }); + expect(run.agentCount, 2); + expect(run.agents[1]!.label, 'alpha'); // kept from earlier delta + expect(run.agents[1]!.agentId, 'ae51341336dd3a4a0'); // filled by later delta + expect(run.agents[1]!.model, 'claude-haiku-4-5-20251001'); // upgraded + expect(run.summary, 'Two agents return one word each'); + + // Second delta: agent 2 advances to done. + run = run.foldEvent({ + 'type': 'system', + 'subtype': 'task_progress', + 'tool_use_id': 'toolu_1', + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 2, 'agentId': 'a210f9290a5f5d089', 'state': 'done'}, + ], + }); + expect(run.agents[2]!.state, WorkflowAgentState.done); + expect(run.agents[1]!.state, WorkflowAgentState.start); // untouched + expect(run.doneCount, 1); + }); + + test('task_updated and task_notification mark the run done', () { + var run = const WorkflowRun(toolUseId: 'toolu_1'); + run = run.foldEvent({ + 'type': 'system', + 'subtype': 'task_updated', + 'tool_use_id': 'toolu_1', + 'patch': {'status': 'completed', 'end_time': 1}, + }); + expect(run.done, isTrue); + + var run2 = const WorkflowRun(toolUseId: 'toolu_1'); + run2 = run2.foldEvent({ + 'type': 'system', + 'subtype': 'task_notification', + 'tool_use_id': 'toolu_1', + 'status': 'completed', + 'summary': 'Dynamic workflow completed', + 'usage': {'total_tokens': 19306, 'tool_uses': 0, 'duration_ms': 1009}, + }); + expect(run2.done, isTrue); + expect(run2.summary, 'Dynamic workflow completed'); + expect(run2.totalTokens, 19306); + expect(run2.durationMs, 1009); + }); + }); + + group('foldEvent — phased run', () { + test('workflow_phase entries register phases; agents carry phase tags', () { + var run = const WorkflowRun(toolUseId: 'toolu_1'); + run = run.foldEvent({ + 'type': 'system', + 'subtype': 'task_progress', + 'tool_use_id': 'toolu_1', + 'workflow_progress': [ + {'type': 'workflow_phase', 'index': 1, 'title': 'Scan'}, + {'type': 'workflow_phase', 'index': 2, 'title': 'Fix'}, + {'type': 'workflow_agent', 'index': 1, 'label': 'scan it', 'phaseIndex': 1, 'phaseTitle': 'Scan', 'model': 'haiku', 'state': 'start'}, + ], + }); + expect(run.orderedPhases.map((p) => p.title), ['Scan', 'Fix']); + expect(run.agents[1]!.phaseIndex, 1); + expect(run.agents[1]!.phaseTitle, 'Scan'); + }); + }); + + test('orderedAgents sorts by fan-out index', () { + var run = const WorkflowRun(toolUseId: 'toolu_1'); + run = run.foldEvent({ + 'type': 'system', + 'subtype': 'task_progress', + 'tool_use_id': 'toolu_1', + 'workflow_progress': [ + {'type': 'workflow_agent', 'index': 3, 'label': 'c', 'state': 'start'}, + {'type': 'workflow_agent', 'index': 1, 'label': 'a', 'state': 'start'}, + {'type': 'workflow_agent', 'index': 2, 'label': 'b', 'state': 'start'}, + ], + }); + expect(run.orderedAgents.map((a) => a.label), ['a', 'b', 'c']); + }); +}