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:
2026-06-12 21:44:41 +02:00
co-authored by Claude Opus 4.8
parent 57978c54c5
commit b399acedbf
17 changed files with 871 additions and 4 deletions
@@ -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();
});
});
}
+38
View File
@@ -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);
});
});
}
+137
View File
@@ -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']);
});
}