import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:clide/builtin/claude/src/session_orchestrator.dart'; import 'package:clide/builtin/claude/src/stream_json_session.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:flutter_test/flutter_test.dart'; class _FakeProc extends StreamJsonProcess { final _ctl = StreamController.broadcast(); final List writes = []; bool killed = false; @override Stream get lines => _ctl.stream; void emit(String line) => _ctl.add(line); @override void writeLine(String line) => writes.add(line); @override Future kill() async => killed = true; } void main() { late List<_FakeProc> created; late List> spawnedArgs; late ClaudeSessionOrchestrator orch; setUp(() { created = []; spawnedArgs = []; orch = ClaudeSessionOrchestrator( processFactory: ({required sessionArgs, required cwd, env}) async { final p = _FakeProc(); created.add(p); spawnedArgs.add(sessionArgs); return p; }, ); }); SpawnSpec spec(String id, {bool visible = true}) => SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: '/repo', visible: visible); test('a spec with effort spawns claude with --effort (T-412)', () async { await orch.spawn(SpawnSpec(id: 'e1', role: 'primary', sessionId: 'e1-uuid', cwd: '/repo', effort: 'xhigh')); final args = spawnedArgs.single; final i = args.indexOf('--effort'); expect(i, isNonNegative, reason: 'sessionArgs: $args'); expect(args[i + 1], 'xhigh'); }); test('a spec without effort spawns without the flag (CLI default applies)', () async { await orch.spawn(spec('primary')); expect(spawnedArgs.single, isNot(contains('--effort'))); }); test('spawns multiple concurrent sessions, each with its own process', () async { await orch.spawn(spec('primary')); await orch.spawn(spec('teammate:tyre')); expect(orch.sessions, hasLength(2)); expect(created, hasLength(2)); expect(orch.byId('teammate:tyre')!.role, 'teammate:tyre'); }); test('folds the real claude session id from the init event into the session (T-185)', () async { final m = await orch.spawn(SpawnSpec(id: 'fork-x', role: 'teammate', sessionId: 'placeholder-uuid', cwd: '/repo', forkSourceSessionId: 'source-uuid')); expect(m.sessionId, 'placeholder-uuid'); // starts as the placeholder created.last.emit(jsonEncode({'type': 'system', 'subtype': 'init', 'session_id': 'real-fork-id', 'model': 'claude-opus-4-8', 'permissionMode': 'default'})); await pumpEventQueue(); expect(m.sessionId, 'real-fork-id'); // updated to the branch's real id }); test('spawn is idempotent on id — no second process', () async { final a = await orch.spawn(spec('primary')); final b = await orch.spawn(spec('primary')); expect(identical(a, b), isTrue); expect(created, hasLength(1)); }); // T-374: spawn() check-then-acts across awaits; without the in-flight // map, two CONCURRENT spawns both passed the registry check and the // loser's live claude process was orphaned. test('two concurrent spawns for one id share one session and one process (T-374)', () async { final (a, b) = await (orch.spawn(spec('primary')), orch.spawn(spec('primary'))).wait; expect(identical(a, b), isTrue); expect(created, hasLength(1)); }); test('a failed spawn clears the in-flight entry so a retry can proceed (T-374)', () async { var calls = 0; final flaky = ClaudeSessionOrchestrator( processFactory: ({required sessionArgs, required cwd, env}) async { calls++; if (calls == 1) throw StateError('spawn blew up'); final p = _FakeProc(); created.add(p); return p; }, ); await expectLater(flaky.spawn(spec('primary')), throwsStateError); final m = await flaky.spawn(spec('primary')); expect(m.id, 'primary'); expect(calls, 2); }); test('hide keeps the process alive and in the registry; show restores it', () async { await orch.spawn(spec('primary')); orch.hide('primary'); expect(orch.byId('primary')!.visible, isFalse); expect(orch.visibleSessions, isEmpty); expect(orch.sessions, hasLength(1)); // still registered expect(created.single.killed, isFalse); // NOT torn down orch.show('primary'); expect(orch.byId('primary')!.visible, isTrue); expect(orch.visibleSessions, hasLength(1)); }); test('close kills the process and removes the session', () async { await orch.spawn(spec('primary')); await orch.close('primary'); expect(orch.byId('primary'), isNull); expect(orch.sessions, isEmpty); await pumpEventQueue(); // session.dispose is async expect(created.single.killed, isTrue); }); test('visibleSessions filters hidden ones', () async { await orch.spawn(spec('a')); await orch.spawn(spec('b', visible: false)); expect(orch.visibleSessions.map((m) => m.id), ['a']); }); test('notifies on spawn / hide / close', () async { var n = 0; orch.addListener(() => n++); await orch.spawn(spec('primary')); orch.hide('primary'); await orch.close('primary'); expect(n, 3); }); test('dispose kills every session', () async { await orch.spawn(spec('a')); await orch.spawn(spec('b')); orch.dispose(); await pumpEventQueue(); expect(created.every((p) => p.killed), isTrue); }); group('team broker wiring (T-170)', () { SpawnSpec teamSpec(String id, String name, String role) => SpawnSpec(id: id, role: role, sessionId: '$id-uuid', cwd: '/repo', team: true, memberName: name); test('team sessions register in the broker; solo sessions do not', () async { await orch.spawn(spec('solo')); // The 'user' virtual member is always registered in the broker (T-180). final agentNames = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name); expect(agentNames, isEmpty); await orch.spawn(teamSpec('primary', 'lead', 'lead')); final agentNamesAfter = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name); expect(agentNamesAfter, ['lead']); }); test('a message between team members is delivered into the target session stdin', () async { await orch.spawn(teamSpec('primary', 'lead', 'lead')); await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate')); orch.broker.sendMessage('primary', 'tyre', 'pick up T-9'); await pumpEventQueue(); final tyreProc = created[1]; expect(tyreProc.writes.any((w) => w.contains('[team] lead: pick up T-9')), isTrue); }); test('a team session declares the clide-team MCP server in its init handshake', () async { await orch.spawn(teamSpec('primary', 'lead', 'lead')); expect(created.single.writes.any((w) => w.contains('"sdkMcpServers":["clide-team"]')), isTrue); }); test('closing a team member removes it from the broker roster', () async { await orch.spawn(teamSpec('primary', 'lead', 'lead')); await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate')); await orch.close('teammate:tyre'); // 'user' is always in the broker (T-180); only agent members checked here. final agentNames = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name); expect(agentNames, ['lead']); }); }); group('resume hydration', () { test('seeds the controller with prior items from the transcript', () async { final tmp = await Directory.systemTemp.createTemp('clide-resume-'); final file = File('${tmp.path}/session.jsonl'); await file.writeAsString( '{"type":"user","uuid":"u1","timestamp":"2026-05-26T00:00:00Z","isSidechain":false,"message":{"role":"user","content":"hello"}}\n' '{"type":"assistant","uuid":"a1","timestamp":"2026-05-26T00:00:01Z","isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"hi back"}]}}\n', ); final managed = await orch.spawn( SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', resume: true, transcriptPath: file.path), ); final items = managed.conversation.items; expect(items, hasLength(2)); expect(items.first, isA()); expect((items.first as UserMessage).text, 'hello'); expect(items.last, isA()); await tmp.delete(recursive: true); }); test('non-resume spawn does not read the transcript', () async { final managed = await orch.spawn( SpawnSpec( id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', // resume:false → transcriptPath ignored even if set transcriptPath: '/does/not/exist.jsonl', ), ); expect(managed.conversation.items, isEmpty); }); test('missing transcript file is tolerated (best-effort hydration)', () async { final managed = await orch.spawn( SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/repo', resume: true, transcriptPath: '/does/not/exist.jsonl'), ); expect(managed.conversation.items, isEmpty); }); }); // T-172: fork-into-a-pane -------------------------------------------------- group('fork session (T-172)', () { List? capturedArgs; setUp(() { capturedArgs = null; orch = ClaudeSessionOrchestrator( processFactory: ({required sessionArgs, required cwd, env}) async { capturedArgs = sessionArgs; return _FakeProc(); }, ); }); SpawnSpec forkSpec(String id, String sourceSessionId) => SpawnSpec(id: id, role: 'fork', sessionId: '$id-placeholder', cwd: '/repo', forkSourceSessionId: sourceSessionId); test('fork spawn passes --resume --fork-session instead of --session-id', () async { const sourceId = 'bbbb2222-2222-4222-8222-222222222222'; await orch.spawn(forkSpec('fork-1', sourceId)); expect(capturedArgs, isNotNull); // The Epic B bootstrap (T-215..T-217) prepends --append-system-prompt // + --allowedTools, so --resume is no longer at index 0 — but // they stay adjacent and --fork-session is present, no --session-id. final resumeAt = capturedArgs!.indexOf('--resume'); expect(resumeAt, greaterThanOrEqualTo(0)); expect(capturedArgs![resumeAt + 1], sourceId); expect(capturedArgs!, contains('--fork-session')); expect(capturedArgs!, isNot(contains('--session-id'))); }); test('fork SpawnSpec.isFork is true when forkSourceSessionId is set', () { const sourceId = 'cccc3333-3333-4333-8333-333333333333'; final s = forkSpec('fork-1', sourceId); expect(s.isFork, isTrue); }); test('non-fork SpawnSpec.isFork is false', () { final s = spec('primary'); expect(s.isFork, isFalse); }); test('fork registers in the orchestrator under its own id', () async { const sourceId = 'dddd4444-4444-4444-8444-444444444444'; final managed = await orch.spawn(forkSpec('fork-1', sourceId)); expect(orch.byId('fork-1'), same(managed)); expect(managed.isFork, isTrue); expect(managed.forkSourceSessionId, sourceId); }); test('fork is independent from the source — source session is unaffected', () async { // Spawn the source session first. final source = await orch.spawn(spec('primary')); expect(orch.sessions, hasLength(1)); // Fork it. await orch.spawn(forkSpec('fork-1', source.sessionId)); // Both sessions exist; source is unchanged. expect(orch.sessions, hasLength(2)); expect(orch.byId('primary'), same(source)); }); test('fork session appears in sessions list and is visible by default', () async { const sourceId = 'eeee5555-5555-4555-8555-555555555555'; final managed = await orch.spawn(forkSpec('fork-1', sourceId)); expect(orch.sessions.contains(managed), isTrue); expect(managed.visible, isTrue); expect(orch.visibleSessions.contains(managed), isTrue); }); test('fork notifies listeners when spawned', () async { const sourceId = 'ffff6666-6666-4666-8666-666666666666'; var notified = false; orch.addListener(() => notified = true); await orch.spawn(forkSpec('fork-1', sourceId)); expect(notified, isTrue); }); test('ManagedSession.cwd reflects the spec cwd', () async { final managed = await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/my/project')); expect(managed.cwd, '/my/project'); }); }); }