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>
470 lines
19 KiB
Dart
470 lines
19 KiB
Dart
/// Widget tests for [ClaudePane] — the spawn/rebind lifecycle (T-269) plus the
|
|
/// composer-driven command handling (/clear, /fork, send, mode cycle) and the
|
|
/// status / prompt render paths.
|
|
///
|
|
/// Harness: a fake [ClaudeSessionOrchestrator] (no real `claude` process) and a
|
|
/// connected fake IPC that answers `files.root`. The pane awaits a real
|
|
/// File(...).exists() transcript probe during spawn, so the spawn/respawn
|
|
/// phases run inside tester.runAsync (fake-async would trap that I/O).
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:clide/builtin/claude/src/claude_composer.dart';
|
|
import 'package:clide/builtin/claude/src/claude_pane.dart';
|
|
import 'package:clide/builtin/claude/src/conversation_view.dart';
|
|
import 'package:clide/builtin/claude/src/model_picker_card.dart';
|
|
import 'package:clide/builtin/claude/src/session_naming.dart';
|
|
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
|
import 'package:clide/builtin/claude/src/session_picker.dart';
|
|
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
|
import 'package:clide/clide.dart';
|
|
import 'package:clide/kernel/kernel.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../../helpers/kernel_fixture.dart';
|
|
|
|
class _FakeProc extends StreamJsonProcess {
|
|
final _ctl = StreamController<String>.broadcast();
|
|
final List<String> writes = [];
|
|
bool killed = false;
|
|
|
|
@override
|
|
Stream<String> get lines => _ctl.stream;
|
|
|
|
@override
|
|
void writeLine(String line) => writes.add(line);
|
|
|
|
@override
|
|
Future<void> kill() async {
|
|
killed = true;
|
|
if (!_ctl.isClosed) await _ctl.close();
|
|
}
|
|
|
|
void feed(Map<String, Object?> event) {
|
|
if (!_ctl.isClosed) _ctl.add(jsonEncode(event));
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
late KernelFixture f;
|
|
late ClaudeSessionOrchestrator orch;
|
|
late String root;
|
|
final created = <_FakeProc>[];
|
|
final spawnArgs = <List<String>>[];
|
|
|
|
setUp(() async {
|
|
f = await KernelFixture.create();
|
|
created.clear();
|
|
spawnArgs.clear();
|
|
root = '/repo-a';
|
|
orch = ClaudeSessionOrchestrator(
|
|
processFactory: ({required sessionArgs, required cwd, env}) async {
|
|
final p = _FakeProc();
|
|
created.add(p);
|
|
spawnArgs.add(sessionArgs);
|
|
return p;
|
|
},
|
|
);
|
|
activeSessionOrchestrator = orch;
|
|
f.ipc.setConnected(true);
|
|
f.ipc.stub('files.root', (_) async => IpcResponse.ok(id: '', data: {'path': root}));
|
|
});
|
|
|
|
tearDown(() async {
|
|
activeSessionOrchestrator = null;
|
|
orch.dispose();
|
|
await f.dispose();
|
|
});
|
|
|
|
Widget tree(ClaudePane pane) => Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: ClideKernel(
|
|
services: f.services,
|
|
child: ClideTheme(
|
|
controller: f.services.theme,
|
|
child: MediaQuery(
|
|
data: const MediaQueryData(),
|
|
child: Align(
|
|
alignment: Alignment.topLeft,
|
|
child: SizedBox(
|
|
width: 900,
|
|
height: 700,
|
|
child: DialogHost(
|
|
router: f.services.dialog,
|
|
child: Overlay(initialEntries: [OverlayEntry(builder: (_) => pane)]),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// Pump the pane and release its project-wait gate so _spawn runs. The whole
|
|
// chain (incl. the real transcript-probe I/O) runs in the real zone.
|
|
Future<void> mount(WidgetTester tester, ClaudePane pane, {String? openPath}) async {
|
|
await tester.runAsync(() async {
|
|
await tester.pumpWidget(tree(pane));
|
|
f.services.events.emit(ProjectOpened(path: openPath ?? root));
|
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
|
});
|
|
await tester.pump();
|
|
}
|
|
|
|
// Run [fn] (a stream feed or an async command) in the real zone, then settle.
|
|
Future<void> act(WidgetTester tester, FutureOr<void> Function() fn) async {
|
|
await tester.runAsync(() async {
|
|
await fn();
|
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
|
});
|
|
await tester.pump();
|
|
}
|
|
|
|
ClaudeComposer composer(WidgetTester tester) => tester.widget<ClaudeComposer>(find.byType(ClaudeComposer));
|
|
|
|
testWidgets('spawns a session bound to the active repo and renders', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
|
|
final primary = orch.byId('primary');
|
|
expect(primary, isNotNull);
|
|
expect(primary!.cwd, '/repo-a');
|
|
expect(primary.sessionId, primarySessionId('/repo-a'));
|
|
expect(find.byType(ConversationView), findsOneWidget);
|
|
expect(find.byType(ClaudeComposer), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('switching the workspace in place rebinds to the new repo (T-269)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
expect(orch.byId('primary')!.cwd, '/repo-a');
|
|
final firstProc = created.single;
|
|
|
|
root = '/repo-b';
|
|
await act(tester, () => f.services.events.emit(const ProjectOpened(path: '/repo-b')));
|
|
|
|
final primary = orch.byId('primary')!;
|
|
expect(primary.cwd, '/repo-b');
|
|
expect(primary.sessionId, primarySessionId('/repo-b'));
|
|
expect(firstProc.killed, isTrue);
|
|
expect(created, hasLength(2));
|
|
});
|
|
|
|
testWidgets('a re-open of the same repo does not respawn', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
expect(created, hasLength(1));
|
|
await act(tester, () => f.services.events.emit(ProjectOpened(path: root)));
|
|
expect(created, hasLength(1), reason: 'same path → no rebind');
|
|
});
|
|
|
|
testWidgets('sending a message writes to the session', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
composer(tester).onSubmit('hello there');
|
|
await tester.pump();
|
|
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;
|
|
final id = orch.byId('primary')!.sessionId;
|
|
|
|
await act(tester, () => composer(tester).onSubmit('/clear'));
|
|
|
|
expect(firstProc.killed, isTrue);
|
|
expect(created, hasLength(2));
|
|
// Re-bound to the SAME deterministic id (cleared in place, not a random id).
|
|
expect(orch.byId('primary')!.sessionId, id);
|
|
});
|
|
|
|
testWidgets('/clear in a fork pane clears instead of re-forking (T-375)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false, isPrimary: false, secondaryIndex: 1, forkSourceId: 'source-session-uuid'));
|
|
// First bind forks from the source.
|
|
expect(spawnArgs.single, containsAll(['--fork-session', 'source-session-uuid']));
|
|
|
|
await act(tester, () => composer(tester).onSubmit('/clear'));
|
|
|
|
// The respawn must NOT fork the original again — the fork source is a
|
|
// one-shot spawn parameter consumed by the first bind.
|
|
expect(spawnArgs, hasLength(2));
|
|
expect(spawnArgs.last, isNot(contains('--fork-session')));
|
|
expect(spawnArgs.last, isNot(contains('source-session-uuid')));
|
|
});
|
|
|
|
testWidgets('/fork delegates to the onFork callback with the session id', (tester) async {
|
|
String? forkedWith;
|
|
await mount(tester, ClaudePane(showChrome: false, onFork: (sid) => forkedWith = sid));
|
|
composer(tester).onSubmit('/fork');
|
|
await tester.pump();
|
|
expect(forkedWith, primarySessionId('/repo-a'));
|
|
});
|
|
|
|
testWidgets('/model with an argument sends set_model, never a user message (T-408)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
|
|
await act(tester, () => composer(tester).onSubmit('/model sonnet'));
|
|
|
|
final sent = proc.writes.map((w) => jsonDecode(w) as Map).toList();
|
|
final setModel = sent.where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList();
|
|
expect(setModel, hasLength(1));
|
|
expect((setModel.single['request'] as Map)['model'], 'sonnet');
|
|
expect(sent.any((m) => m['type'] == 'user'), isFalse, reason: '/model must not be forwarded as message text');
|
|
expect(find.byType(ModelPickerCard), findsNothing);
|
|
});
|
|
|
|
testWidgets('bare /model swaps the picker in; picking sends set_model and restores the composer (T-408)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
|
|
await act(tester, () => composer(tester).onSubmit('/model'));
|
|
expect(find.byType(ModelPickerCard), findsOneWidget);
|
|
expect(find.byType(ClaudeComposer), findsNothing, reason: 'the picker takes the interaction zone (D-78)');
|
|
|
|
tester.widget<ModelPickerCard>(find.byType(ModelPickerCard)).onPick('opus');
|
|
await tester.pump();
|
|
|
|
expect(find.byType(ModelPickerCard), findsNothing);
|
|
expect(find.byType(ClaudeComposer), findsOneWidget);
|
|
final setModel = proc.writes.map((w) => jsonDecode(w) as Map).where((m) => (m['request'] as Map?)?['subtype'] == 'set_model').toList();
|
|
expect((setModel.single['request'] as Map)['model'], 'opus');
|
|
});
|
|
|
|
testWidgets('Esc cancels the /model picker without sending (T-408)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
|
|
await act(tester, () => composer(tester).onSubmit('/model'));
|
|
expect(find.byType(ModelPickerCard), findsOneWidget);
|
|
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(ModelPickerCard), findsNothing);
|
|
expect(find.byType(ClaudeComposer), findsOneWidget);
|
|
expect(proc.writes.any((w) => w.contains('set_model')), isFalse);
|
|
});
|
|
|
|
testWidgets('cycling permission mode sends a control message', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
composer(tester).onCycleMode!();
|
|
await tester.pump();
|
|
expect(proc.writes.any((w) => w.contains('permission')), isTrue);
|
|
});
|
|
|
|
testWidgets('composer draft is retained then cleared', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final c = composer(tester);
|
|
c.onDraftChanged!(const TextEditingValue(text: 'a draft'));
|
|
c.onDraftChanged!(TextEditingValue.empty);
|
|
await tester.pump();
|
|
// No throw / no crash; draft round-trips through the pane's per-session map.
|
|
expect(find.byType(ClaudeComposer), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('an init event populates the status line', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
await act(
|
|
tester,
|
|
() => proc.feed({'type': 'system', 'subtype': 'init', 'model': 'claude-opus-4-8', 'permissionMode': 'plan', 'session_id': primarySessionId('/repo-a')}),
|
|
);
|
|
// The init event flows through the session into the pane's status path
|
|
// (the rendered slot lives in the status bar, absent from this harness).
|
|
expect(orch.byId('primary')!.session.status.permissionMode, 'plan');
|
|
expect(orch.byId('primary')!.session.status.model, 'claude-opus-4-8');
|
|
});
|
|
|
|
testWidgets('a can_use_tool request renders a prompt card in place of the composer', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
await act(
|
|
tester,
|
|
() => proc.feed({
|
|
'type': 'control_request',
|
|
'request_id': 'req-1',
|
|
'request': {
|
|
'subtype': 'can_use_tool',
|
|
'tool_name': 'Bash',
|
|
'tool_use_id': 'tu-1',
|
|
'input': {'command': 'ls'},
|
|
},
|
|
}),
|
|
);
|
|
expect(find.byType(ClaudeComposer), findsNothing, reason: 'prompt takes the composer slot (D-78)');
|
|
});
|
|
|
|
testWidgets('a disconnected daemon surfaces an error instead of spawning', (tester) async {
|
|
f.ipc.setConnected(false);
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
expect(orch.byId('primary'), isNull);
|
|
expect(find.textContaining('not connected'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a secondary pane spawns a fresh session under its own key', (tester) async {
|
|
await mount(tester, const ClaudePane(isPrimary: false, secondaryIndex: 1, showChrome: false));
|
|
final secondary = orch.byId('secondary-1');
|
|
expect(secondary, isNotNull);
|
|
expect(secondary!.cwd, '/repo-a');
|
|
// Secondaries get a fresh random id, not the deterministic primary one.
|
|
expect(secondary.sessionId, isNot(primarySessionId('/repo-a')));
|
|
// Disposing the secondary closes its session (the !isPrimary branch).
|
|
// Pump a different widget type so the pane's State is torn down, not reused.
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.pump();
|
|
expect(orch.byId('secondary-1'), isNull);
|
|
});
|
|
|
|
testWidgets('tapping the conversation area focuses the composer', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await tester.tap(find.byType(ConversationView));
|
|
await tester.pump();
|
|
expect(find.byType(ClaudeComposer), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('/resume opens the session picker; cancelling leaves the session', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final proc = created.single;
|
|
|
|
await act(tester, () => composer(tester).onSubmit('/resume'));
|
|
expect(find.byType(SessionPickerDialog), findsOneWidget);
|
|
|
|
// Cancel the picker → no rebind, original session untouched.
|
|
await act(tester, () => f.services.dialog.dismiss());
|
|
expect(find.byType(SessionPickerDialog), findsNothing);
|
|
expect(created, hasLength(1));
|
|
expect(proc.killed, isFalse);
|
|
});
|
|
|
|
// ---- T-410 epic: bus-driven owned commands (T-412/T-413/T-414) ----------
|
|
// The sidebar controls publish slash-command text on builtin.claude/command;
|
|
// the primary pane executes it through _send — the same path as typing.
|
|
group('command bus → owned slash commands', () {
|
|
Future<void> command(WidgetTester tester, String text) => act(tester, () => f.services.messages.publish('builtin.claude', 'command', {'text': text}));
|
|
|
|
testWidgets('/effort <level> respawns the session carrying --effort (T-412)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
expect(created, hasLength(1));
|
|
|
|
await command(tester, '/effort xhigh');
|
|
|
|
expect(created, hasLength(2), reason: 'effort change = respawn');
|
|
final args = spawnArgs.last;
|
|
final i = args.indexOf('--effort');
|
|
expect(i, isNonNegative, reason: 'args: $args');
|
|
expect(args[i + 1], 'xhigh');
|
|
// The pane records the level on the session status (the wire never
|
|
// reports effort).
|
|
expect(orch.byId('primary')!.session.status.effort, 'xhigh');
|
|
});
|
|
|
|
testWidgets('/effort with an unknown level notices and does NOT respawn (T-412)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await command(tester, '/effort warp9');
|
|
expect(created, hasLength(1));
|
|
expect(find.textContaining('unknown effort "warp9"'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('bare /effort opens the effort picker in the interaction zone (T-412)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await command(tester, '/effort');
|
|
expect(find.byType(ModelPickerCard), findsOneWidget);
|
|
expect(find.text('effort'), findsOneWidget); // the picker title
|
|
});
|
|
|
|
testWidgets('/permissions <mode> sends set_permission_mode (T-413)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await command(tester, '/permissions plan');
|
|
final proc = created.single;
|
|
expect(proc.writes.any((w) => w.contains('set_permission_mode') && w.contains('"plan"')), isTrue, reason: proc.writes.join('\n'));
|
|
});
|
|
|
|
testWidgets('bare /permissions opens the mode picker (T-413)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await command(tester, '/permissions');
|
|
expect(find.byType(ModelPickerCard), findsOneWidget);
|
|
expect(find.text('permissions'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('/status and /config navigate the Claude sidebar (T-413)', (tester) async {
|
|
final tabs = <String?>[];
|
|
final sub = f.services.messages.subscribe(publisher: 'builtin.claude', channel: 'meta.tab').listen((m) => tabs.add(m.data['tab'] as String?));
|
|
addTearDown(sub.cancel);
|
|
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await command(tester, '/status');
|
|
await command(tester, '/config');
|
|
await command(tester, '/mcp');
|
|
expect(tabs, ['activity', 'config', 'config']);
|
|
});
|
|
|
|
testWidgets('/memory opens CLAUDE.md in the editor (T-413)', (tester) async {
|
|
final opened = <String?>[];
|
|
f.ipc.stub('editor.open', (args) async {
|
|
opened.add(args['path'] as String?);
|
|
return IpcResponse.ok(id: '', data: const {});
|
|
});
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await command(tester, '/memory');
|
|
expect(opened, ['/repo-a/CLAUDE.md']);
|
|
});
|
|
|
|
testWidgets('/help renders a local clide summary card (T-413)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
await command(tester, '/help');
|
|
expect(find.textContaining('clide commands:'), findsOneWidget);
|
|
expect(find.text('clide'), findsOneWidget); // synthetic card attribution
|
|
});
|
|
|
|
testWidgets('a TUI-only command becomes a notice card, never reaching the session (T-411)', (tester) async {
|
|
await mount(tester, const ClaudePane(showChrome: false));
|
|
final before = created.single.writes.length;
|
|
await command(tester, '/doctor');
|
|
expect(find.textContaining('/doctor is a Claude Code TUI command'), findsOneWidget);
|
|
expect(created.single.writes.length, before, reason: 'nothing forwarded');
|
|
});
|
|
});
|
|
}
|