surface Claude Code Workflow runs in convo + sidebar (T-416)
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<toolUseId, WorkflowRun>, 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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_<id> 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_<id> 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: <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;
|
||||
|
||||
@@ -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_<id> 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: <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_<id> 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: <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_<id> 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: <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-<version>.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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> 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);
|
||||
|
||||
@@ -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<ClaudeMetaSidebar> {
|
||||
StreamSubscription<Message>? _tabSub;
|
||||
StreamSubscription<SessionStatus>? _primarySub;
|
||||
StreamSubscription<ConversationItem>? _primaryItemsSub;
|
||||
StreamSubscription<Map<String, WorkflowRun>>? _primaryWorkflowsSub;
|
||||
ClaudeUsage? _usage;
|
||||
Map<String, WorkflowRun> _workflows = const {};
|
||||
StreamSubscription<void>? _brokerChangeSub;
|
||||
Timer? _timer;
|
||||
late final Future<ClaudeStats> Function() _load;
|
||||
@@ -204,15 +207,32 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
_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<ClaudeMetaSidebar> {
|
||||
_tabSub?.cancel();
|
||||
_primarySub?.cancel();
|
||||
_primaryItemsSub?.cancel();
|
||||
_primaryWorkflowsSub?.cancel();
|
||||
_brokerChangeSub?.cancel();
|
||||
_injectCtl.dispose();
|
||||
_config?.removeListener(_onConfigChange);
|
||||
@@ -286,7 +307,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
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,
|
||||
|
||||
@@ -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<ClaudePane> {
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
StreamSubscription<Message>? _commandSub;
|
||||
StreamSubscription<String>? _modelErrorSub;
|
||||
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
|
||||
ConversationController? _conversation;
|
||||
StreamJsonSession? _session;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
@@ -213,6 +215,8 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_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<ClaudePane> {
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
@@ -385,6 +391,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
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<ClaudePane> {
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
@@ -729,6 +744,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
|
||||
emptyState: ClaudeBanner(
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
workspace: _repoRoot,
|
||||
|
||||
@@ -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 <String>{},
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
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<String, WorkflowRun> 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<ConversationView> {
|
||||
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<ConversationView> {
|
||||
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 <String, ToolResultMessage>{},
|
||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||
this.runByToolUseId = const <String, List<ConversationItem>>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
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<String, List<ConversationItem>> runByToolUseId;
|
||||
|
||||
/// Live Workflow runs keyed by launching tool-use id (T-416).
|
||||
final Map<String, WorkflowRun> 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 = <Widget>[];
|
||||
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 <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final List<ConversationItem> items;
|
||||
@@ -917,6 +1033,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -938,6 +1055,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
runByToolUseId: runByToolUseId,
|
||||
workflows: workflows,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
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<String, WorkflowRun> 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 = <MetaSection>[
|
||||
..._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<MetaSection> _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<MetaSection> _runtimeSection(SurfaceTokens tokens) {
|
||||
final st = primaryStatus;
|
||||
final skills = config?.skills.length;
|
||||
|
||||
@@ -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<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
||||
Set<String> 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 = <String, WorkflowRun>{};
|
||||
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
|
||||
|
||||
/// The current workflow runs, keyed by launching tool-use id.
|
||||
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
|
||||
|
||||
/// Emits the workflow-run map whenever a `system` task event updates it.
|
||||
Stream<Map<String, WorkflowRun>> 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-<id>` 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<String, dynamic> 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();
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<int, WorkflowPhase> phases;
|
||||
|
||||
/// Agent index → agent snapshot.
|
||||
final Map<int, WorkflowAgent> 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<WorkflowAgent> get orderedAgents {
|
||||
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Phases in index order.
|
||||
List<WorkflowPhase> 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<int, WorkflowPhase>? phases,
|
||||
Map<int, WorkflowAgent>? 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<String, dynamic> 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<String, dynamic> ev) {
|
||||
final phases = Map<int, WorkflowPhase>.from(this.phases);
|
||||
final agents = Map<int, WorkflowAgent>.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<String, dynamic>();
|
||||
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<String> 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<String, dynamic> ev) =>
|
||||
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;
|
||||
@@ -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<StickyItem>(), reason: '$level');
|
||||
expect((groups.first as StickyItem).item, isA<AssistantToolUse>(), 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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> hiddenToolUseIds = const {},
|
||||
Map<String, bool> toolUseOutcomes = const {},
|
||||
Set<String> quietErrorToolUseIds = const {},
|
||||
Map<String, WorkflowRun> 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;
|
||||
|
||||
@@ -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 = <Map<String, WorkflowRun>>[];
|
||||
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<void>.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 = <ConversationItem>[];
|
||||
session.items.listen(items.add);
|
||||
p.emit(jsonEncode({'type': 'system', 'subtype': 'task_progress', 'tool_use_id': 'toolu_wf', 'workflow_progress': const []}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(items, isEmpty);
|
||||
expect(session.workflows.containsKey('toolu_wf'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user