retire tmux for Claude sessions
Session lifecycle now runs entirely on the stream-json model: argv selection picks --resume <id> for an existing transcript and --session-id <uuid> for a fresh one, and the managed-session orchestrator owns spawn/close. With the transport off tmux, remove the tmux session lifecycle (reaping, kill-all-for-repo) and the tmux-polling team observer; kill-all-sessions now closes sessions through the orchestrator. Team membership is orchestrator-driven since the coordination broker landed. Amends D-41 (tmux persistence -> --resume). T-167. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
/// Session lifecycle tests for T-167 (--resume model, /clear, /resume,
|
||||
/// kill-all-sessions via orchestrator).
|
||||
///
|
||||
/// Pure Dart (no Flutter): exercises the session argv selection, the
|
||||
/// spawn-spec logic for clear vs resume, and the kill-all-sessions command
|
||||
/// behaviour — all without spawning a real `claude` process.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fake process — same as session_orchestrator_test.dart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeProc implements 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ClaudeSessionOrchestrator _orch(List<_FakeProc> created) {
|
||||
return ClaudeSessionOrchestrator(
|
||||
processFactory: ({required sessionArgs, required cwd, env}) async {
|
||||
final p = _FakeProc();
|
||||
created.add(p);
|
||||
return p;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath}) => SpawnSpec(
|
||||
id: id,
|
||||
role: id,
|
||||
sessionId: '$id-uuid',
|
||||
cwd: '/repo',
|
||||
resume: resume,
|
||||
transcriptPath: transcriptPath,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
// ---- argv selection (D-77 / T-161) -------------------------------------
|
||||
|
||||
group('claudeLaunchArgs — argv selection', () {
|
||||
// These mirror session_naming_test.dart but put the semantics in context.
|
||||
|
||||
test('/clear → fresh session — uses --session-id', () {
|
||||
// /clear spawns a brand-new session: transcript does not exist yet.
|
||||
final args = claudeLaunchArgs('new-uuid', resume: false);
|
||||
expect(args, ['--session-id', 'new-uuid']);
|
||||
});
|
||||
|
||||
test('/resume → existing session — uses --resume', () {
|
||||
// /resume picks a past session whose transcript is already on disk.
|
||||
final args = claudeLaunchArgs('past-uuid', resume: true);
|
||||
expect(args, ['--resume', 'past-uuid']);
|
||||
});
|
||||
|
||||
test('restart after transcript exists → --resume preserves continuity', () {
|
||||
// On restart, the primary's transcript is on disk → resume:true.
|
||||
const id = 'stable-uuid';
|
||||
expect(claudeLaunchArgs(id, resume: true).first, '--resume');
|
||||
});
|
||||
|
||||
test('secondary spawn → always --session-id (fresh)', () {
|
||||
// Secondaries always start clean: freshSessionId() + resume:false.
|
||||
final id = freshSessionId();
|
||||
expect(claudeLaunchArgs(id, resume: false).first, '--session-id');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- primarySessionId stability (migration safety) ----------------------
|
||||
|
||||
group('primarySessionId — stable across restart', () {
|
||||
test('same repo root always yields the same UUID', () {
|
||||
const root = '/home/user/projects/myapp';
|
||||
expect(primarySessionId(root), primarySessionId(root));
|
||||
});
|
||||
|
||||
test('different repos yield different UUIDs', () {
|
||||
expect(primarySessionId('/home/user/a'), isNot(primarySessionId('/home/user/b')));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- /clear — spawn a fresh session via orchestrator --------------------
|
||||
|
||||
group('/clear — fresh session via orchestrator', () {
|
||||
late List<_FakeProc> created;
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
|
||||
setUp(() {
|
||||
created = [];
|
||||
orch = _orch(created);
|
||||
});
|
||||
|
||||
tearDown(() => orch.dispose());
|
||||
|
||||
test('spawns a new session with resume:false (empty conversation)', () async {
|
||||
final managed = await orch.spawn(_spec('primary', resume: false));
|
||||
expect(managed.id, 'primary');
|
||||
expect(managed.conversation.items, isEmpty);
|
||||
expect(created, hasLength(1));
|
||||
// The process receives --session-id (not --resume) in its init args.
|
||||
// The factory was given the right spec; verify the session is new.
|
||||
expect(managed.sessionId, 'primary-uuid');
|
||||
});
|
||||
|
||||
test('close old + spawn new resets the session (clear flow)', () async {
|
||||
await orch.spawn(_spec('primary', resume: false));
|
||||
// /clear: close the current session and spawn a fresh one.
|
||||
await orch.close('primary');
|
||||
expect(orch.byId('primary'), isNull);
|
||||
expect(created.first.killed, isTrue);
|
||||
|
||||
await orch.spawn(_spec('primary', resume: false));
|
||||
expect(orch.sessions, hasLength(1));
|
||||
expect(created, hasLength(2)); // a second process was created
|
||||
});
|
||||
});
|
||||
|
||||
// ---- /resume — bind to an existing session via orchestrator -------------
|
||||
|
||||
group('/resume — resume an existing session via orchestrator', () {
|
||||
late List<_FakeProc> created;
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
|
||||
setUp(() {
|
||||
created = [];
|
||||
orch = _orch(created);
|
||||
});
|
||||
|
||||
tearDown(() => orch.dispose());
|
||||
|
||||
test('spawns with resume:true and seeds conversation from 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-01T00:00:00Z","isSidechain":false,'
|
||||
'"message":{"role":"user","content":"hello from the past"}}\n',
|
||||
);
|
||||
|
||||
final managed = await orch.spawn(_spec(
|
||||
'primary',
|
||||
resume: true,
|
||||
transcriptPath: file.path,
|
||||
));
|
||||
|
||||
expect(managed.conversation.items, hasLength(1));
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('close old + spawn resumed resets to picked session (resume flow)', () async {
|
||||
await orch.spawn(_spec('primary', resume: false));
|
||||
await orch.close('primary');
|
||||
|
||||
// /resume picked a past session id; re-spawn with resume:true.
|
||||
final picked = SpawnSpec(
|
||||
id: 'primary',
|
||||
role: 'primary',
|
||||
sessionId: 'picked-past-uuid',
|
||||
cwd: '/repo',
|
||||
resume: true,
|
||||
);
|
||||
final managed = await orch.spawn(picked);
|
||||
expect(managed.sessionId, 'picked-past-uuid');
|
||||
expect(created, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- claude.kill-all-sessions via orchestrator --------------------------
|
||||
|
||||
group('claude.kill-all-sessions via orchestrator (T-167)', () {
|
||||
late List<_FakeProc> created;
|
||||
late ClaudeSessionOrchestrator orch;
|
||||
|
||||
setUp(() {
|
||||
created = [];
|
||||
orch = _orch(created);
|
||||
});
|
||||
|
||||
tearDown(() => orch.dispose());
|
||||
|
||||
test('kills the primary session through the orchestrator', () async {
|
||||
await orch.spawn(_spec('primary'));
|
||||
expect(orch.sessions, hasLength(1));
|
||||
|
||||
final ids = orch.sessions.map((m) => m.id).toList();
|
||||
for (final id in ids) {
|
||||
await orch.close(id);
|
||||
}
|
||||
|
||||
expect(orch.sessions, isEmpty);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(created.single.killed, isTrue);
|
||||
});
|
||||
|
||||
test('kills primary + all secondaries and leaves orchestrator empty', () async {
|
||||
await orch.spawn(_spec('primary'));
|
||||
await orch.spawn(_spec('secondary-1'));
|
||||
await orch.spawn(_spec('secondary-2'));
|
||||
expect(orch.sessions, hasLength(3));
|
||||
|
||||
// Simulate what _killAllSessions does in extension.dart.
|
||||
final ids = orch.sessions.map((m) => m.id).toList();
|
||||
for (final id in ids) {
|
||||
await orch.close(id);
|
||||
}
|
||||
|
||||
expect(orch.sessions, isEmpty);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(created.every((p) => p.killed), isTrue);
|
||||
});
|
||||
|
||||
test('kill-all on empty orchestrator is a no-op', () async {
|
||||
// No sessions — the loop is a no-op, no crash.
|
||||
final ids = orch.sessions.map((m) => m.id).toList();
|
||||
for (final id in ids) {
|
||||
await orch.close(id);
|
||||
}
|
||||
expect(orch.sessions, isEmpty);
|
||||
expect(created, isEmpty);
|
||||
});
|
||||
|
||||
test('kill-all includes team sessions', () async {
|
||||
await orch.spawn(SpawnSpec(
|
||||
id: 'primary',
|
||||
role: 'lead',
|
||||
sessionId: 'primary-uuid',
|
||||
cwd: '/repo',
|
||||
team: true,
|
||||
memberName: 'lead',
|
||||
));
|
||||
await orch.spawn(SpawnSpec(
|
||||
id: 'teammate:tyre',
|
||||
role: 'teammate',
|
||||
sessionId: 'tyre-uuid',
|
||||
cwd: '/repo',
|
||||
team: true,
|
||||
memberName: 'tyre',
|
||||
));
|
||||
expect(orch.sessions, hasLength(2));
|
||||
expect(orch.broker.members, hasLength(2));
|
||||
|
||||
final ids = orch.sessions.map((m) => m.id).toList();
|
||||
for (final id in ids) {
|
||||
await orch.close(id);
|
||||
}
|
||||
|
||||
expect(orch.sessions, isEmpty);
|
||||
expect(orch.broker.members, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,56 +2,10 @@ import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('claude session naming', () {
|
||||
test('primary name is deterministic per repo path', () {
|
||||
final a = primarySessionName('/home/me/clide');
|
||||
final b = primarySessionName('/home/me/clide');
|
||||
expect(a, b);
|
||||
expect(a, startsWith('clide-claude-'));
|
||||
});
|
||||
|
||||
test('different repos yield different primaries', () {
|
||||
final a = primarySessionName('/home/me/clide');
|
||||
final b = primarySessionName('/home/me/other');
|
||||
expect(a, isNot(b));
|
||||
});
|
||||
|
||||
test('secondary names carry the N suffix', () {
|
||||
final p = primarySessionName('/home/me/clide');
|
||||
final s1 = secondarySessionName('/home/me/clide', 1);
|
||||
final s2 = secondarySessionName('/home/me/clide', 2);
|
||||
expect(s1, '$p-1');
|
||||
expect(s2, '$p-2');
|
||||
});
|
||||
|
||||
test('a HOME-relative path collapses the HOME prefix in the slug', () {
|
||||
// Forces the `p.startsWith(home)` branch.
|
||||
final home = const String.fromEnvironment('HOME');
|
||||
// Use a path we know lives under the platform HOME so the branch fires.
|
||||
// In test environments HOME is set; the path /tmp may or may not be
|
||||
// under it. Use a synthesized HOME path so the assert holds regardless.
|
||||
final fake = '${home.isEmpty ? '/home/test' : home}/projects/clide';
|
||||
final name = primarySessionName(fake);
|
||||
expect(name, contains('projects-clide'));
|
||||
});
|
||||
|
||||
test('path of only "/" slugifies to "root"', () {
|
||||
// Exercises the "strip leading/trailing '-' then fall back" branch.
|
||||
expect(primarySessionName('/'), 'clide-claude-root');
|
||||
});
|
||||
|
||||
test('path longer than the slug cap hashes to 8 hex chars', () {
|
||||
final long = '/${'segment/' * 30}leaf';
|
||||
final name = primarySessionName(long);
|
||||
// Hash form: clide-claude-<8 hex>.
|
||||
expect(name, matches(RegExp(r'^clide-claude-[0-9a-f]{8}$')));
|
||||
});
|
||||
|
||||
test('the same long path produces a stable hash', () {
|
||||
final long = '/${'a/' * 200}';
|
||||
expect(primarySessionName(long), primarySessionName(long));
|
||||
});
|
||||
});
|
||||
// The tmux-slug functions (primarySessionName / secondarySessionName) are
|
||||
// retired as public API (D-77 / T-167). The UUID derivation is kept because
|
||||
// `primarySessionId` still deterministically derives its UUID from the old
|
||||
// slug (private) so existing transcripts survive the migration.
|
||||
|
||||
group('claude session ids (T-146)', () {
|
||||
final uuidRe = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
|
||||
@@ -77,15 +31,25 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('claudeLaunchArgs (T-161)', () {
|
||||
group('claudeLaunchArgs (T-161 / T-167)', () {
|
||||
test('resumes an existing session with --resume, not --session-id', () {
|
||||
// --session-id refuses an existing id ("already in use"), so resuming
|
||||
// (transcript on disk) must use --resume.
|
||||
// (transcript on disk) must use --resume (D-77).
|
||||
expect(claudeLaunchArgs('abc', resume: true), ['--resume', 'abc']);
|
||||
});
|
||||
|
||||
test('creates a new session with --session-id', () {
|
||||
expect(claudeLaunchArgs('abc', resume: false), ['--session-id', 'abc']);
|
||||
});
|
||||
|
||||
test('resume flag controls the verb — same id, different verb', () {
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
final fresh = claudeLaunchArgs(id, resume: false);
|
||||
final resumed = claudeLaunchArgs(id, resume: true);
|
||||
expect(fresh.first, '--session-id');
|
||||
expect(resumed.first, '--resume');
|
||||
expect(fresh.last, id);
|
||||
expect(resumed.last, id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
/// Tests for the tmux team observer (T-139). Pure Dart (no Flutter):
|
||||
/// config parsing/discovery, the config-driven joined/left lifecycle, and
|
||||
/// the best-effort subagent-transcript join — all exercised against
|
||||
/// on-disk fixtures, mirroring how the T-134 spike validated CC's
|
||||
/// undocumented team artifacts.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/team_observer.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _ws = '/work/space';
|
||||
|
||||
String _configJson({
|
||||
String team = 'myteam',
|
||||
int createdAt = 1000,
|
||||
String leadSessionId = 'sid-1',
|
||||
String cwd = _ws,
|
||||
List<Map<String, dynamic>> teammates = const [],
|
||||
}) {
|
||||
return jsonEncode({
|
||||
'name': team,
|
||||
'createdAt': createdAt,
|
||||
'leadSessionId': leadSessionId,
|
||||
'members': [
|
||||
{'agentId': 'team-lead@$team', 'name': 'team-lead', 'agentType': 'team-lead', 'tmuxPaneId': '', 'cwd': cwd, 'joinedAt': 1},
|
||||
...teammates,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _member(String name, String pane, {String? type, int joinedAt = 2, String cwd = _ws}) => {
|
||||
'agentId': '$name@myteam',
|
||||
'name': name,
|
||||
'agentType': type ?? name,
|
||||
'tmuxPaneId': pane,
|
||||
'model': 'sonnet',
|
||||
'color': 'blue',
|
||||
'cwd': cwd,
|
||||
'joinedAt': joinedAt,
|
||||
};
|
||||
|
||||
Future<Directory> _writeTeam(Directory teamsBase, String team, String json) async {
|
||||
final dir = Directory('${teamsBase.path}/$team');
|
||||
await dir.create(recursive: true);
|
||||
await File('${dir.path}/config.json').writeAsString(json);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('team events', () {
|
||||
test('TeamMemberJoined payload carries identity + optional fields', () {
|
||||
const e = TeamMemberJoined(
|
||||
team: 'myteam',
|
||||
agentId: 'alice@myteam',
|
||||
name: 'alice',
|
||||
agentType: 'researcher',
|
||||
paneId: '%5',
|
||||
model: 'sonnet',
|
||||
color: 'blue',
|
||||
cwd: '/work/space',
|
||||
transcriptPath: '/t/agent-a.jsonl',
|
||||
);
|
||||
expect(e.subsystem, 'team');
|
||||
expect(e.kind, 'member-joined');
|
||||
expect(e.payload(), {
|
||||
'team': 'myteam',
|
||||
'agentId': 'alice@myteam',
|
||||
'name': 'alice',
|
||||
'agentType': 'researcher',
|
||||
'paneId': '%5',
|
||||
'model': 'sonnet',
|
||||
'color': 'blue',
|
||||
'cwd': '/work/space',
|
||||
'transcriptPath': '/t/agent-a.jsonl',
|
||||
});
|
||||
});
|
||||
|
||||
test('TeamMemberJoined omits null optional fields', () {
|
||||
const e = TeamMemberJoined(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1');
|
||||
expect(e.payload().keys, ['team', 'agentId', 'name', 'agentType', 'paneId']);
|
||||
});
|
||||
|
||||
test('TeamMemberLeft payload', () {
|
||||
const e = TeamMemberLeft(team: 't', agentId: 'a@t', paneId: '%1');
|
||||
expect(e.subsystem, 'team');
|
||||
expect(e.kind, 'member-left');
|
||||
expect(e.payload(), {'team': 't', 'agentId': 'a@t', 'paneId': '%1'});
|
||||
});
|
||||
});
|
||||
|
||||
group('TeamConfig.parse', () {
|
||||
test('parses members and detects the lead', () {
|
||||
final cfg = TeamConfig.parse('myteam', _configJson(teammates: [_member('alice', '%5')]))!;
|
||||
expect(cfg.team, 'myteam');
|
||||
expect(cfg.leadSessionId, 'sid-1');
|
||||
expect(cfg.members, hasLength(2));
|
||||
expect(cfg.teammates.map((m) => m.name), ['alice']);
|
||||
final lead = cfg.members.firstWhere((m) => m.isLead);
|
||||
expect(lead.name, 'team-lead');
|
||||
final alice = cfg.teammates.single;
|
||||
expect(alice.tmuxPaneId, '%5');
|
||||
expect(alice.model, 'sonnet');
|
||||
expect(alice.isLead, isFalse);
|
||||
});
|
||||
|
||||
test('returns null on malformed JSON', () {
|
||||
expect(TeamConfig.parse('x', 'not json'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('discoverTeam', () {
|
||||
late Directory teamsBase;
|
||||
setUp(() async => teamsBase = await Directory.systemTemp.createTemp('teams_'));
|
||||
tearDown(() async => teamsBase.delete(recursive: true));
|
||||
|
||||
test('finds the team whose member cwd matches the workspace', () async {
|
||||
await _writeTeam(teamsBase, 'other', _configJson(team: 'other', cwd: '/elsewhere', teammates: [_member('bob', '%9', cwd: '/elsewhere')]));
|
||||
await _writeTeam(teamsBase, 'mine', _configJson(team: 'mine', teammates: [_member('alice', '%5')]));
|
||||
final cfg = await discoverTeam(_ws, teamsBase: teamsBase.path);
|
||||
expect(cfg, isNotNull);
|
||||
expect(cfg!.team, 'mine');
|
||||
});
|
||||
|
||||
test('prefers the newest createdAt when several match', () async {
|
||||
await _writeTeam(teamsBase, 'old', _configJson(team: 'old', createdAt: 100, teammates: [_member('a', '%1')]));
|
||||
await _writeTeam(teamsBase, 'new', _configJson(team: 'new', createdAt: 999, teammates: [_member('b', '%2')]));
|
||||
final cfg = await discoverTeam(_ws, teamsBase: teamsBase.path);
|
||||
expect(cfg!.team, 'new');
|
||||
});
|
||||
|
||||
test('returns null when nothing matches', () async {
|
||||
await _writeTeam(teamsBase, 'other', _configJson(team: 'other', cwd: '/elsewhere', teammates: [_member('bob', '%9', cwd: '/elsewhere')]));
|
||||
expect(await discoverTeam(_ws, teamsBase: teamsBase.path), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('TeamObserver lifecycle', () {
|
||||
late Directory teamsBase;
|
||||
late Directory projectsBase;
|
||||
late DaemonBus events;
|
||||
late MessageBus messages;
|
||||
late List<TeamMemberJoined> joined;
|
||||
late List<TeamMemberLeft> left;
|
||||
|
||||
setUp(() async {
|
||||
teamsBase = await Directory.systemTemp.createTemp('teams_');
|
||||
projectsBase = await Directory.systemTemp.createTemp('projects_');
|
||||
events = DaemonBus();
|
||||
messages = MessageBus();
|
||||
joined = [];
|
||||
left = [];
|
||||
events.on<TeamMemberJoined>().listen(joined.add);
|
||||
events.on<TeamMemberLeft>().listen(left.add);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await teamsBase.delete(recursive: true);
|
||||
await projectsBase.delete(recursive: true);
|
||||
await events.dispose();
|
||||
messages.dispose();
|
||||
});
|
||||
|
||||
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
test('emits joined when a teammate pane is live, left when it goes', () async {
|
||||
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')]));
|
||||
var panes = {'%5'};
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => panes,
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined.map((b) => b.name), ['alice']);
|
||||
expect(joined.single.paneId, '%5');
|
||||
expect(joined.single.agentId, 'alice@myteam');
|
||||
expect(left, isEmpty);
|
||||
|
||||
// Same pane still live -> no duplicate joined.
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined, hasLength(1));
|
||||
|
||||
// Pane gone -> left.
|
||||
panes = {};
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(left.map((d) => d.agentId), ['alice@myteam']);
|
||||
});
|
||||
|
||||
test('start() polls on a timer and dispose() stops it', () async {
|
||||
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')]));
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5'},
|
||||
pollInterval: const Duration(milliseconds: 20),
|
||||
);
|
||||
obs.start();
|
||||
// Poll until the timer-driven tick emits joined (or time out).
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 2));
|
||||
while (joined.isEmpty && DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
expect(joined.map((b) => b.name), ['alice']);
|
||||
await obs.dispose();
|
||||
// dispose emits left for the tracked member.
|
||||
await settle();
|
||||
expect(left.map((d) => d.agentId), ['alice@myteam']);
|
||||
});
|
||||
|
||||
test('constructs with default base dirs / pane lister', () {
|
||||
// Exercises the default resolvers; not started, so nothing shells out.
|
||||
final obs = TeamObserver(workspacePath: _ws, events: events, messages: messages);
|
||||
expect(obs.workspacePath, _ws);
|
||||
});
|
||||
|
||||
test('no team config -> no events', () async {
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5'},
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined, isEmpty);
|
||||
expect(left, isEmpty);
|
||||
});
|
||||
|
||||
test('joins the teammate transcript via a matching .meta.json', () async {
|
||||
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5', type: 'researcher')]));
|
||||
// <projectsBase>/<munged cwd>/<leadSessionId>/subagents/agent-*.jsonl
|
||||
final sub = Directory('${projectsBase.path}/${_ws.replaceAll('/', '-')}/sid-1/subagents');
|
||||
await sub.create(recursive: true);
|
||||
await File('${sub.path}/agent-aaa111.jsonl').writeAsString('');
|
||||
await File('${sub.path}/agent-aaa111.meta.json').writeAsString(jsonEncode({'agentType': 'researcher'}));
|
||||
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5'},
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
|
||||
await obs.tick();
|
||||
await settle();
|
||||
expect(joined.single.transcriptPath, endsWith('agent-aaa111.jsonl'));
|
||||
});
|
||||
|
||||
test('falls back to joinedAt<->mtime order when no .meta.json', () async {
|
||||
await _writeTeam(
|
||||
teamsBase,
|
||||
'myteam',
|
||||
_configJson(teammates: [
|
||||
_member('first', '%5', joinedAt: 10),
|
||||
_member('second', '%6', joinedAt: 20),
|
||||
]),
|
||||
);
|
||||
final sub = Directory('${projectsBase.path}/${_ws.replaceAll('/', '-')}/sid-1/subagents');
|
||||
await sub.create(recursive: true);
|
||||
// Older file first (earlier mtime) -> maps to the earlier-joined member.
|
||||
final older = File('${sub.path}/agent-older.jsonl');
|
||||
await older.writeAsString('');
|
||||
await older.setLastModified(DateTime(2026, 1, 1));
|
||||
final newer = File('${sub.path}/agent-newer.jsonl');
|
||||
await newer.writeAsString('');
|
||||
await newer.setLastModified(DateTime(2026, 2, 1));
|
||||
|
||||
final obs = TeamObserver(
|
||||
workspacePath: _ws,
|
||||
events: events,
|
||||
messages: messages,
|
||||
teamsBase: teamsBase.path,
|
||||
projectsBase: projectsBase.path,
|
||||
paneLister: () async => {'%5', '%6'},
|
||||
);
|
||||
addTearDown(obs.dispose);
|
||||
|
||||
await obs.tick();
|
||||
await settle();
|
||||
final byName = {for (final b in joined) b.name: b.transcriptPath};
|
||||
expect(byName['first'], endsWith('agent-older.jsonl'));
|
||||
expect(byName['second'], endsWith('agent-newer.jsonl'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/// Widget tests for the teammate tile grid (T-140): tiles appear on
|
||||
/// TeamMemberJoined, disappear on TeamMemberLeft, and the lead shows
|
||||
/// alone when there's no team. Events are emitted directly into the
|
||||
/// fixture's event bus (the observer is exercised separately in
|
||||
/// team_observer_test.dart).
|
||||
/// fixture's event bus (team membership is orchestrator-driven since
|
||||
/// D-77 / T-167 — the tmux observer was retired).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart';
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _RecordingRunner {
|
||||
final List<List<String>> calls = [];
|
||||
String stdout = '';
|
||||
int exitCode = 0;
|
||||
|
||||
Future<ProcessResult> call(List<String> args) async {
|
||||
calls.add(List.of(args));
|
||||
return ProcessResult(0, exitCode, stdout, '');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _RecordingRunner runner;
|
||||
|
||||
setUp(() {
|
||||
runner = _RecordingRunner();
|
||||
tmux.tmuxRunner = runner.call;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
// Restore the default runner so other tests aren't affected.
|
||||
tmux.tmuxRunner = (args) => Process.run('tmux', args);
|
||||
});
|
||||
|
||||
group('killSession', () {
|
||||
test('invokes tmux kill-session on the clide socket', () async {
|
||||
await tmux.killSession('clide-claude-foo');
|
||||
expect(runner.calls, [
|
||||
['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not surface non-zero exit (session already gone)', () async {
|
||||
runner.exitCode = 1;
|
||||
await tmux.killSession('clide-claude-foo');
|
||||
expect(runner.calls, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('listClideSessions', () {
|
||||
test('parses session names from tmux output', () async {
|
||||
runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-foo-2\n';
|
||||
final names = await tmux.listClideSessions();
|
||||
expect(names, ['clide-claude-foo', 'clide-claude-foo-1', 'clide-claude-foo-2']);
|
||||
});
|
||||
|
||||
test('returns empty list when server is not running', () async {
|
||||
runner.exitCode = 1;
|
||||
final names = await tmux.listClideSessions();
|
||||
expect(names, isEmpty);
|
||||
});
|
||||
|
||||
test('strips blank lines and whitespace', () async {
|
||||
runner.stdout = '\nclide-claude-foo\n\n clide-claude-foo-1 \n';
|
||||
final names = await tmux.listClideSessions();
|
||||
expect(names, ['clide-claude-foo', 'clide-claude-foo-1']);
|
||||
});
|
||||
});
|
||||
|
||||
group('reapSecondaries', () {
|
||||
test('kills only -<digits>-suffixed sessions, leaves primary alive', () async {
|
||||
runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-foo-2\n';
|
||||
await tmux.reapSecondaries('clide-claude-foo');
|
||||
|
||||
// First call lists, then one kill per secondary.
|
||||
expect(runner.calls.first, ['-L', 'clide', 'list-sessions', '-F', '#{session_name}']);
|
||||
final kills = runner.calls.skip(1).toList();
|
||||
expect(kills, [
|
||||
['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'],
|
||||
['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores sessions for other repos', () async {
|
||||
runner.stdout = 'clide-claude-foo\nclide-claude-bar-1\nclide-claude-foo-1\n';
|
||||
await tmux.reapSecondaries('clide-claude-foo');
|
||||
final kills = runner.calls.skip(1).toList();
|
||||
expect(kills, [
|
||||
['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('no-op when there are no secondaries', () async {
|
||||
runner.stdout = 'clide-claude-foo\n';
|
||||
await tmux.reapSecondaries('clide-claude-foo');
|
||||
expect(runner.calls, hasLength(1)); // just the list call
|
||||
});
|
||||
});
|
||||
|
||||
group('killAllForRepo', () {
|
||||
test('kills primary and every secondary for the repo', () async {
|
||||
runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-bar\n';
|
||||
await tmux.killAllForRepo('clide-claude-foo');
|
||||
final kills = runner.calls.skip(1).toList();
|
||||
expect(kills, [
|
||||
['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo'],
|
||||
['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('hasSession', () {
|
||||
test('true on exit 0, false otherwise', () async {
|
||||
runner.exitCode = 0;
|
||||
expect(await tmux.hasSession('clide-claude-foo'), isTrue);
|
||||
expect(runner.calls.last, ['-L', 'clide', 'has-session', '-t', 'clide-claude-foo']);
|
||||
runner.exitCode = 1;
|
||||
expect(await tmux.hasSession('clide-claude-foo'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('sendMessage', () {
|
||||
test('loads a bracketed paste buffer then submits with Enter', () async {
|
||||
await tmux.sendMessage('clide-claude-foo', 'hello\nworld');
|
||||
expect(runner.calls, [
|
||||
['-L', 'clide', 'set-buffer', '-b', 'clide-compose', '--', 'hello\nworld'],
|
||||
['-L', 'clide', 'paste-buffer', '-p', '-d', '-b', 'clide-compose', '-t', 'clide-claude-foo'],
|
||||
['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', 'Enter'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('sendCommand', () {
|
||||
test('types the text literally (no bracketed paste) then submits Enter', () async {
|
||||
await tmux.sendCommand('clide-claude-foo', '/whats-next');
|
||||
expect(runner.calls, [
|
||||
['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', '-l', '--', '/whats-next'],
|
||||
['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', 'Enter'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user