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:
@@ -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