chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)

Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:11:53 +02:00
co-authored by Claude Opus 4.8
parent bcea5f15b7
commit 6d0ebab721
444 changed files with 7587 additions and 12849 deletions
@@ -57,12 +57,7 @@ void main() {
});
test('a failed result surfaces (sticky) and breaks the cluster', () {
final groups = groupConversation([
_tool('1', 'Bash'),
_result('1', isError: true),
_tool('2', 'Bash'),
_result('2'),
], FoldLevel.tools);
final groups = groupConversation([_tool('1', 'Bash'), _result('1', isError: true), _tool('2', 'Bash'), _result('2')], FoldLevel.tools);
// [Folded([tool1]), Sticky(errorResult), Folded([tool2,result2])]
expect(groups, hasLength(3));
expect((groups[0] as FoldedCluster).items, hasLength(1));
+5 -29
View File
@@ -37,43 +37,23 @@ void main() {
group('agentEnvDelta (T-215)', () {
test('always exports CLIDE_SOCK + CLIDE_WORKSPACE', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/run/clide/abc.sock',
currentPath: '/usr/bin',
clideCliDir: null,
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/run/clide/abc.sock', currentPath: '/usr/bin', clideCliDir: null);
expect(d['CLIDE_WORKSPACE'], '/repo');
expect(d['CLIDE_SOCK'], '/run/clide/abc.sock');
});
test('leaves PATH untouched when clide is already resolvable (clideCliDir null)', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/usr/bin',
clideCliDir: null,
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin', clideCliDir: null);
expect(d.containsKey('PATH'), isFalse);
});
test('prepends the cli dir to PATH when given', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: '/usr/bin:/bin',
clideCliDir: '/home/dev/.local/bin',
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin:/bin', clideCliDir: '/home/dev/.local/bin');
expect(d['PATH'], '/home/dev/.local/bin:/usr/bin:/bin');
});
test('sets PATH to just the cli dir when there is no current PATH', () {
final d = agentEnvDelta(
workspaceRoot: '/repo',
socketPath: '/s.sock',
currentPath: null,
clideCliDir: '/opt/clide/bin',
);
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: null, clideCliDir: '/opt/clide/bin');
expect(d['PATH'], '/opt/clide/bin');
});
});
@@ -98,11 +78,7 @@ void main() {
});
test('returns null when nothing holds clide', () {
final dir = resolveClideCliDir(
currentPath: '/usr/bin',
candidateDirs: const ['/a', '/b'],
isExecutableFile: (_) => false,
);
final dir = resolveClideCliDir(currentPath: '/usr/bin', candidateDirs: const ['/a', '/b'], isExecutableFile: (_) => false);
expect(dir, isNull);
});
});
+53 -112
View File
@@ -21,10 +21,7 @@ void main() {
});
test('multi-line input is wrapped in bracketed-paste markers', () {
expect(
encodeClaudeInput('line one\nline two'),
'\x1b[200~line one\nline two\x1b[201~\r',
);
expect(encodeClaudeInput('line one\nline two'), '\x1b[200~line one\nline two\x1b[201~\r');
});
});
@@ -35,10 +32,7 @@ void main() {
Future<List<String>> pump(WidgetTester tester, {bool enabled = true}) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(enabled: enabled, onSubmit: submitted.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(enabled: enabled, onSubmit: submitted.add)));
return submitted;
}
@@ -96,15 +90,17 @@ void main() {
// WidgetsApp provides DefaultTextEditingShortcuts in the real app;
// the bare harness doesn't, so wrap explicitly to map Ctrl+V ->
// PasteTextIntent, which the composer's Actions override intercepts.
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: (_) {},
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
await tester.pumpWidget(
harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: (_) {},
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
),
));
);
await pasteAttachment(tester);
expect(find.text('notes.txt'), findsOneWidget);
@@ -113,15 +109,17 @@ void main() {
testWidgets('submit appends attachment @path tokens to the message', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
await tester.pumpWidget(
harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
),
));
);
await tester.enterText(find.byType(EditableText), 'look at this');
await pasteAttachment(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
@@ -132,18 +130,9 @@ void main() {
expect(find.text('notes.txt'), findsNothing);
});
Future<List<String>> pumpWithCommands(
WidgetTester tester,
List<String> commands,
) async {
Future<List<String>> pumpWithCommands(WidgetTester tester, List<String> commands) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: submitted.add,
slashCommandsResolver: () => commands,
),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: submitted.add, slashCommandsResolver: () => commands)));
return submitted;
}
@@ -239,15 +228,17 @@ void main() {
testWidgets('remove × cancels the attachment before send', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
await tester.pumpWidget(
harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
),
));
);
await pasteAttachment(tester);
expect(find.text('notes.txt'), findsOneWidget);
@@ -263,10 +254,7 @@ void main() {
testWidgets('Escape interrupts when the typeahead is closed', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++)));
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
await tester.pump();
@@ -277,14 +265,7 @@ void main() {
testWidgets('Escape closes the typeahead before it interrupts', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
onInterrupt: () => interrupts++,
slashCommandsResolver: () => ['model'],
),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onInterrupt: () => interrupts++, slashCommandsResolver: () => ['model'])));
await tester.enterText(find.byType(EditableText), '/mo');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
@@ -305,10 +286,7 @@ void main() {
testWidgets('the Stop button shows when busy and interrupts on tap', (tester) async {
var interrupts = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () => interrupts++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () => interrupts++)));
expect(find.text('Stop ⎋'), findsOneWidget);
await tester.tap(find.text('Stop ⎋'));
@@ -317,10 +295,7 @@ void main() {
});
testWidgets('no Stop button when idle', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onInterrupt: () {}),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onInterrupt: () {})));
expect(find.text('Stop ⎋'), findsNothing);
});
@@ -353,10 +328,7 @@ void main() {
testWidgets('clide-owned commands are reachable via the default resolver', (tester) async {
// No slashCommandsResolver → default path; kClideOwnedCommands must be included.
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: submitted.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: submitted.add)));
await tester.enterText(find.byType(EditableText), '/fo');
await tester.pump();
await tester.pump(); // ClideTypeahead inserts the popover post-frame
@@ -375,10 +347,7 @@ void main() {
testWidgets('Ctrl+M fires onCycleMode and is consumed', (tester) async {
var cycles = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++)));
await tester.tap(find.byType(EditableText));
await tester.pump();
@@ -394,10 +363,7 @@ void main() {
testWidgets('plain m types normally (no modifier, no cycle)', (tester) async {
var cycles = 0;
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onCycleMode: () => cycles++)));
await tester.enterText(find.byType(EditableText), 'm');
await tester.pump();
expect(cycles, 0);
@@ -425,10 +391,7 @@ void main() {
final submitted = <String>[];
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: submitted.add, focusNode: node),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: submitted.add, focusNode: node)));
await tester.enterText(find.byType(EditableText), 'via external node');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
@@ -444,15 +407,8 @@ void main() {
String text(WidgetTester tester) => tester.widget<EditableText>(find.byType(EditableText)).controller.text;
Future<void> pumpWithHistory(
WidgetTester tester, {
required List<String> history,
ValueChanged<TextEditingValue>? onDraftChanged,
}) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, history: history, onDraftChanged: onDraftChanged),
));
Future<void> pumpWithHistory(WidgetTester tester, {required List<String> history, ValueChanged<TextEditingValue>? onDraftChanged}) async {
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, history: history, onDraftChanged: onDraftChanged)));
await tester.tap(find.byType(EditableText));
await tester.pump();
}
@@ -528,16 +484,15 @@ void main() {
tearDown(() => f.dispose());
testWidgets('seeds the field from initialValue on mount', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
initialValue: const TextEditingValue(
text: 'half-typed',
selection: TextSelection.collapsed(offset: 4),
await tester.pumpWidget(
harness(
f,
ClaudeComposer(
onSubmit: (_) {},
initialValue: const TextEditingValue(text: 'half-typed', selection: TextSelection.collapsed(offset: 4)),
),
),
));
);
final controller = tester.widget<EditableText>(find.byType(EditableText)).controller;
expect(controller.text, 'half-typed');
expect(controller.selection.baseOffset, 4); // caret restored too
@@ -545,10 +500,7 @@ void main() {
testWidgets('reports draft changes (text + caret) via onDraftChanged', (tester) async {
final drafts = <TextEditingValue>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add)));
await tester.enterText(find.byType(EditableText), 'draft text');
await tester.pump();
@@ -557,10 +509,7 @@ void main() {
testWidgets('reports an empty draft when submitting clears the field', (tester) async {
final drafts = <TextEditingValue>[];
await tester.pumpWidget(harness(
f,
ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add),
));
await tester.pumpWidget(harness(f, ClaudeComposer(onSubmit: (_) {}, onDraftChanged: drafts.add)));
await tester.enterText(find.byType(EditableText), 'send me');
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
@@ -589,10 +538,7 @@ void main() {
// Prompt resolved — composer remounts and restores the draft.
showPrompt.value = false;
await tester.pump();
expect(
tester.widget<EditableText>(find.byType(EditableText)).controller.text,
'survived the prompt',
);
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, 'survived the prompt');
});
});
}
@@ -614,13 +560,8 @@ class _DraftSwapHostState extends State<_DraftSwapHost> {
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: widget.showPrompt,
builder: (context, prompt, _) => prompt
? const SizedBox.shrink()
: ClaudeComposer(
onSubmit: (_) {},
initialValue: _draft,
onDraftChanged: (v) => _draft = v,
),
builder: (context, prompt, _) =>
prompt ? const SizedBox.shrink() : ClaudeComposer(onSubmit: (_) {}, initialValue: _draft, onDraftChanged: (v) => _draft = v),
);
}
}
+23 -34
View File
@@ -49,27 +49,10 @@ void main() {
await File('${scope.path}/settings.json').writeAsString(jsonEncode(json));
}
String initLine({
String version = '2.1.150',
List<String> slash = const ['clear', 'pql'],
List<String> skills = const ['pql'],
}) =>
'${jsonEncode({
'type': 'system',
'subtype': 'init',
'claude_code_version': version,
'slash_commands': slash,
'skills': skills,
'model': 'claude-opus-4-7',
'permissionMode': 'default',
})}\n';
String initLine({String version = '2.1.150', List<String> slash = const ['clear', 'pql'], List<String> skills = const ['pql']}) =>
'${jsonEncode({'type': 'system', 'subtype': 'init', 'claude_code_version': version, 'slash_commands': slash, 'skills': skills, 'model': 'claude-opus-4-7', 'permissionMode': 'default'})}\n';
ClaudeConfig build({
ClaudeVersionRunner? versionRunner,
ClaudeInitProbe? initProbe,
ClaudeConfigWatch? watch,
Duration debounce = Duration.zero,
}) =>
ClaudeConfig build({ClaudeVersionRunner? versionRunner, ClaudeInitProbe? initProbe, ClaudeConfigWatch? watch, Duration debounce = Duration.zero}) =>
ClaudeConfig(
globalDir: globalDir,
cacheDir: cacheDir,
@@ -111,14 +94,14 @@ void main() {
'keep': 1,
'permissions': {
'allow': ['Bash'],
'deny': ['Write']
'deny': ['Write'],
},
});
await writeSettings(localDir, {
'model': 'sonnet',
'permissions': {
'allow': ['Edit'],
'ask': ['Read']
'ask': ['Read'],
},
});
@@ -152,10 +135,12 @@ void main() {
test('load stays on the fallback until ensureProbe runs (no eager turn)', () async {
var probeCalls = 0;
final c = build(initProbe: () async {
probeCalls++;
return initLine(slash: ['clear', 'pql', 'whats-next']);
});
final c = build(
initProbe: () async {
probeCalls++;
return initLine(slash: ['clear', 'pql', 'whats-next']);
},
);
await c.load();
expect(probeCalls, 0, reason: 'load must never pay for a model turn');
expect(c.slashCommands, kFallbackSlashCommands);
@@ -192,10 +177,12 @@ void main() {
test('a different claude version misses the cache and re-probes', () async {
var probeCalls = 0;
final c1 = build(initProbe: () async {
probeCalls++;
return initLine();
});
final c1 = build(
initProbe: () async {
probeCalls++;
return initLine();
},
);
await c1.load();
await c1.ensureProbe();
expect(probeCalls, 1);
@@ -281,10 +268,12 @@ void main() {
test('explicit refresh re-reads disk without re-resolving the version', () async {
var versionCalls = 0;
final c = build(versionRunner: () async {
versionCalls++;
return '2.1.150 (Claude Code)\n';
});
final c = build(
versionRunner: () async {
versionCalls++;
return '2.1.150 (Claude Code)\n';
},
);
await c.load();
expect(versionCalls, 1);
+143 -194
View File
@@ -64,14 +64,7 @@ void main() {
ClaudeConfig? config,
SidebarTab initialTab = SidebarTab.activity,
ClaudeSessionOrchestrator? orchestrator,
}) =>
ClaudeMetaSidebar(
statsLoader: () async => stats,
pollInterval: Duration.zero,
config: config,
orchestrator: orchestrator,
initialTab: initialTab,
);
}) => ClaudeMetaSidebar(statsLoader: () async => stats, pollInterval: Duration.zero, config: config, orchestrator: orchestrator, initialTab: initialTab);
testWidgets('Activity tab shows today + lifetime stats on the table', (tester) async {
await tester.pumpWidget(harness(f, sidebar(stats: stats)));
@@ -145,15 +138,9 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('TODAY'), findsOneWidget); // starts on Activity
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'Scout',
agentType: 'explorer',
paneId: '%1',
model: 'claude-opus-4-7',
color: 'blue',
));
f.services.events.emit(
const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', model: 'claude-opus-4-7', color: 'blue'),
);
await tester.pump();
await tester.pump();
@@ -167,14 +154,7 @@ void main() {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'Scout',
agentType: 'explorer',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
expect(find.text('Scout'), findsOneWidget);
@@ -190,24 +170,14 @@ void main() {
await tester.pumpWidget(harness(f, sidebar()));
await tester.pumpAndSettle();
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'Scout',
agentType: 'explorer',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'Scout', agentType: 'explorer', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
f.services.messages.publish(
ClaudeConversation.publisher,
ClaudeConversation.memberStatusChannel,
ClaudeConversation.memberStatusData(
'a1',
const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 21000),
),
ClaudeConversation.memberStatusData('a1', const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'acceptEdits', contextTokens: 21000)),
);
await tester.pump();
await tester.pump();
@@ -226,25 +196,11 @@ void main() {
group('T-171 roster controls', () {
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Scout', String agentId = 'a1'}) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(
id: 'teammate:$name',
role: 'teammate',
sessionId: '$name-uuid',
cwd: '/repo',
team: true,
memberName: name,
));
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(
team: 't',
agentId: agentId,
name: name,
agentType: 'coder',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
return orch;
@@ -345,24 +301,10 @@ void main() {
final orch = _fakeOrchestrator();
// We cannot intercept the proc easily through the public API — verify
// injectMessage wired up by checking the managed session is the right one.
await orch.spawn(SpawnSpec(
id: 'teammate:Alpha',
role: 'teammate',
sessionId: 'alpha-uuid',
cwd: '/repo',
team: true,
memberName: 'Alpha',
));
await orch.spawn(SpawnSpec(id: 'teammate:Alpha', role: 'teammate', sessionId: 'alpha-uuid', cwd: '/repo', team: true, memberName: 'Alpha'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a2',
name: 'Alpha',
agentType: 'coder',
paneId: '%2',
color: 'green',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a2', name: 'Alpha', agentType: 'coder', paneId: '%2', color: 'green'));
await tester.pump();
await tester.pump();
@@ -391,32 +333,11 @@ void main() {
group('T-171 task list', () {
testWidgets('task list renders live from broker on changes stream', (tester) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
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',
));
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', 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'));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(const TeamMemberJoined(
team: 't',
agentId: 'a1',
name: 'lead',
agentType: 'lead',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
@@ -443,10 +364,16 @@ void main() {
// shared canSizeOverlay harness the sidebar's scrollable otherwise gets a
// degenerate viewport and clips the task section out of the semantics
// tree, so the reassign button below the roster isn't findable by label.
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(orchestrator: orch, initialTab: SidebarTab.team)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(orchestrator: orch, initialTab: SidebarTab.team),
),
),
);
f.services.events.emit(const TeamMemberJoined(team: 't', agentId: 'a1', name: 'lead', agentType: 'lead', paneId: '%1', color: 'cyan'));
await tester.pump();
await tester.pump();
@@ -462,9 +389,7 @@ void main() {
// canSizeOverlay test harness gives the sidebar's ListView a degenerate
// viewport that clips the lower task section out of the semantics *tree*
// (the widget is built and tappable; only the semantics node is dropped).
final reassign = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Reassign task',
);
final reassign = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Reassign task');
await tester.tap(reassign.first);
await tester.pump();
await tester.pump();
@@ -493,29 +418,11 @@ void main() {
return (orch, writes);
}
Future<(ClaudeSessionOrchestrator, List<String>)> spawnAndShow(
WidgetTester tester, {
String name = 'Scout',
String agentId = 'b1',
}) async {
Future<(ClaudeSessionOrchestrator, List<String>)> spawnAndShow(WidgetTester tester, {String name = 'Scout', String agentId = 'b1'}) async {
final (orch, writes) = orchCapturing();
await orch.spawn(SpawnSpec(
id: 'teammate:$name',
role: 'teammate',
sessionId: '$name-uuid',
cwd: '/repo',
team: true,
memberName: name,
));
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(
team: 't',
agentId: agentId,
name: name,
agentType: 'coder',
paneId: '%1',
color: 'blue',
));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'blue'));
await tester.pump();
await tester.pump();
return (orch, writes);
@@ -540,10 +447,7 @@ void main() {
f.services.messages.publish(
ClaudeConversation.publisher,
ClaudeConversation.memberStatusChannel,
ClaudeConversation.memberStatusData(
'b1',
const SessionStatus(permissionMode: 'acceptEdits'),
),
ClaudeConversation.memberStatusData('b1', const SessionStatus(permissionMode: 'acceptEdits')),
);
await tester.pump();
await tester.pump();
@@ -647,25 +551,11 @@ void main() {
group('T-172 fork session button', () {
Future<ClaudeSessionOrchestrator> orchWithMember(WidgetTester tester, {String name = 'Forker', String agentId = 'f1'}) async {
final orch = _fakeOrchestrator();
await orch.spawn(SpawnSpec(
id: 'teammate:$name',
role: 'teammate',
sessionId: '$name-uuid',
cwd: '/repo',
team: true,
memberName: name,
));
await orch.spawn(SpawnSpec(id: 'teammate:$name', role: 'teammate', sessionId: '$name-uuid', cwd: '/repo', team: true, memberName: name));
await tester.pumpWidget(harness(f, sidebar(orchestrator: orch, initialTab: SidebarTab.team)));
f.services.events.emit(TeamMemberJoined(
team: 't',
agentId: agentId,
name: name,
agentType: 'coder',
paneId: '%1',
color: 'teal',
));
f.services.events.emit(TeamMemberJoined(team: 't', agentId: agentId, name: name, agentType: 'coder', paneId: '%1', color: 'teal'));
await tester.pump();
await tester.pump();
return orch;
@@ -794,10 +684,7 @@ void main() {
final config = await loadedConfig(
tester,
dir,
skills: [
(name: 'git-commit', dir: 'git-commit'),
(name: 'pql', dir: 'pql'),
],
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql')],
commands: ['deploy', 'test'],
agents: ['planner'],
settings: {
@@ -810,10 +697,16 @@ void main() {
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -831,18 +724,20 @@ void main() {
final config = await loadedConfig(
tester,
dir,
skills: [
(name: 'git-commit', dir: 'git-commit'),
(name: 'pql', dir: 'pql'),
(name: 'deep-research', dir: 'deep-research'),
],
skills: [(name: 'git-commit', dir: 'git-commit'), (name: 'pql', dir: 'pql'), (name: 'deep-research', dir: 'deep-research')],
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -865,10 +760,16 @@ void main() {
final config = await loadedConfig(tester, dir, agents: ['planner', 'coder']);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -885,10 +786,16 @@ void main() {
final config = await loadedConfig(tester, dir, commands: ['deploy', 'test', 'lint']);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -916,10 +823,16 @@ void main() {
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -947,10 +860,16 @@ void main() {
);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -973,10 +892,16 @@ void main() {
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1003,10 +928,16 @@ void main() {
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1031,10 +962,16 @@ void main() {
final sub = f.services.messages.subscribe(publisher: 'builtin.markdown', channel: 'selection').listen(published.add);
addTearDown(sub.cancel);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1055,10 +992,16 @@ void main() {
final config = await loadedConfig(tester, dir, skills: [(name: 'git-commit', dir: 'git-commit')]);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
@@ -1079,10 +1022,16 @@ void main() {
final config = await loadedConfig(tester, dir);
addTearDown(config.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 320, height: 700, child: sidebar(config: config, initialTab: SidebarTab.config)),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 320,
height: 700,
child: sidebar(config: config, initialTab: SidebarTab.config),
),
),
);
await tester.pump();
await tester.pump();
+33 -37
View File
@@ -76,28 +76,28 @@ void main() {
});
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)]),
),
),
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.
@@ -205,14 +205,9 @@ void main() {
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'),
}));
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');
@@ -223,17 +218,18 @@ void main() {
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'},
},
}));
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)');
});
@@ -15,29 +15,29 @@ import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
Widget _host(KernelFixture f, Key key) => 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(
// Bounded size so the reorderable tab strip's Draggable has a
// real width and the Overlay below isn't asked to self-size.
width: 800,
height: 600,
child: Overlay(
initialEntries: [OverlayEntry(builder: (_) => ClaudeSessionHost(key: key))],
),
),
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(
// Bounded size so the reorderable tab strip's Draggable has a
// real width and the Overlay below isn't asked to self-size.
width: 800,
height: 600,
child: Overlay(
initialEntries: [OverlayEntry(builder: (_) => ClaudeSessionHost(key: key))],
),
),
),
),
);
),
),
);
void main() {
late KernelFixture f;
+2 -13
View File
@@ -67,13 +67,7 @@ void main() {
});
test('full status line with all fields (T-168)', () {
const s = SessionStatus(
model: 'claude-opus-4-7',
permissionMode: 'default',
contextTokens: 21000,
contextWindow: 1000000,
cost: 0.05,
);
const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'default', contextTokens: 21000, contextWindow: 1000000, cost: 0.05);
final line = formatStatusLine(s);
expect(line, contains('opus 4.7'));
expect(line, contains('default'));
@@ -98,12 +92,7 @@ void main() {
group('statusSegmentsAroundMode (T-226)', () {
test('splits model (leading) from ctx/cost/rate (trailing), mode excluded', () {
const s = SessionStatus(
model: 'claude-opus-4-7',
permissionMode: 'plan',
contextTokens: 21000,
cost: 0.05,
);
const s = SessionStatus(model: 'claude-opus-4-7', permissionMode: 'plan', contextTokens: 21000, cost: 0.05);
final seg = statusSegmentsAroundMode(s);
expect(seg.leading, 'opus 4.7');
expect(seg.trailing, contains('21k ctx'));
@@ -24,10 +24,15 @@ void main() {
];
Future<void> pump(WidgetTester tester, List<TaskItem> items) async {
await tester.pumpWidget(harness(
f,
Align(alignment: Alignment.topLeft, child: SizedBox(width: 400, child: ClaudeTaskDock(tasks: items))),
));
await tester.pumpWidget(
harness(
f,
Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 400, child: ClaudeTaskDock(tasks: items)),
),
),
);
await tester.pump();
}
@@ -53,9 +53,7 @@ void main() {
});
test('files take precedence over an image', () async {
final result = await resolveClipboardAttachment(
_FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])),
);
final result = await resolveClipboardAttachment(_FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])));
expect(result.map((a) => a.path), ['/x/y']);
});
+167 -134
View File
@@ -39,15 +39,17 @@ void main() {
}
testWidgets('copy button is always in the tree (Opacity 0 before hover)', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
// The text widget is in the tree (always), but the Opacity hides it.
@@ -56,15 +58,17 @@ void main() {
});
testWidgets('copy button (on hover) writes the copyText to the clipboard', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'the raw message',
body: Text('the raw message', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
await hoverCard(tester);
@@ -78,15 +82,17 @@ void main() {
testWidgets('copy button: keyboard focus + ActivateIntent writes to clipboard without hovering', (tester) async {
// T-174: actions must be keyboard-reachable even when the card is not hovered.
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'keyboard written',
body: Text('keyboard written', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'keyboard written',
body: Text('keyboard written', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
// Locate the ClideTappable whose FocusNode we want: the one wrapping the
@@ -96,9 +102,7 @@ void main() {
expect(copyTextFinder, findsOneWidget);
// Walk up to find the Focus that ClideTappable installed, then request focus.
final focusWidget = tester.widget<Focus>(
find.ancestor(of: copyTextFinder, matching: find.byType(Focus)).first,
);
final focusWidget = tester.widget<Focus>(find.ancestor(of: copyTextFinder, matching: find.byType(Focus)).first);
focusWidget.focusNode!.requestFocus();
await tester.pump(); // focus resolves; the focus listener calls setState
await tester.pump(); // the scheduled rebuild paints the lifted opacity
@@ -117,15 +121,17 @@ void main() {
testWidgets('copy button carries a Semantics button label', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'msg',
body: Text('msg', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'you',
copyText: 'msg',
body: Text('msg', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
// Semantics label 'copy' must be present even before hover, so AT can
// discover the button without the user mousing over first.
@@ -134,17 +140,19 @@ void main() {
});
testWidgets('collapsible card hides its body until expanded', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('tool output here'), findsNothing); // collapsed
@@ -155,14 +163,16 @@ void main() {
});
testWidgets('a non-collapsible card always shows its body and no caret', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'claude',
body: Text('always visible', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
accent: Color(0xFFFFFFFF),
label: 'claude',
body: Text('always visible', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('always visible'), findsOneWidget);
expect(find.bySemanticsLabel('Expand'), findsNothing);
@@ -170,15 +180,17 @@ void main() {
});
testWidgets('custom actions are always in the tree (Opacity 0 before hover)', (tester) async {
await tester.pumpWidget(harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () {})],
await tester.pumpWidget(
harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () {})],
),
),
));
);
await tester.pump();
// 'fork' is in the tree (always), but the Opacity hides it.
@@ -188,15 +200,17 @@ void main() {
testWidgets('custom actions appear on hover and invoke', (tester) async {
var forked = false;
await tester.pumpWidget(harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () => forked = true)],
await tester.pumpWidget(
harness(
f,
ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
body: const Text('body', textDirection: TextDirection.ltr),
actions: [MessageAction(label: 'fork', onInvoke: () => forked = true)],
),
),
));
);
await tester.pump();
await hoverCard(tester);
@@ -212,19 +226,21 @@ void main() {
final showExtra = ValueNotifier<bool>(true);
addTearDown(showExtra.dispose);
await tester.pumpWidget(harness(
f,
ValueListenableBuilder<bool>(
valueListenable: showExtra,
builder: (_, show, __) => ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
copyText: show ? 'text' : null,
body: const Text('body', textDirection: TextDirection.ltr),
actions: show ? [MessageAction(label: 'fork', onInvoke: () {})] : [],
await tester.pumpWidget(
harness(
f,
ValueListenableBuilder<bool>(
valueListenable: showExtra,
builder: (_, show, _) => ConversationCard(
accent: const Color(0xFFFFFFFF),
label: 'claude',
copyText: show ? 'text' : null,
body: const Text('body', textDirection: TextDirection.ltr),
actions: show ? [MessageAction(label: 'fork', onInvoke: () {})] : [],
),
),
),
));
);
await tester.pump();
// Initially: copy + fork.
expect(find.text('copy'), findsOneWidget);
@@ -243,17 +259,19 @@ void main() {
testWidgets('collapsible card: Semantics.onTap on caret also toggles collapse', (tester) async {
// The caret Semantics node has its own onTap (for accessibility-tree callers).
// Invoke it via the Semantics.onTap callback directly.
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'result',
collapsible: true,
collapsedByDefault: true,
body: Text('tool output here', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('tool output here'), findsNothing); // collapsed
@@ -269,15 +287,17 @@ void main() {
});
testWidgets('bordered variant renders with a border container', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFF0000),
label: 'result',
body: Text('bordered body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFF0000),
label: 'result',
body: Text('bordered body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('bordered body'), findsOneWidget);
expect(find.text('result'), findsOneWidget);
@@ -285,16 +305,18 @@ void main() {
testWidgets('status: success renders a "succeeded" semantics mark in the header (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.success,
body: Text('body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.success,
body: Text('body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.bySemanticsLabel('succeeded'), findsOneWidget);
expect(find.bySemanticsLabel('failed'), findsNothing);
@@ -303,16 +325,18 @@ void main() {
testWidgets('status: error renders a "failed" semantics mark; none renders nothing (T-262)', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.error,
body: Text('body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Bash',
status: ConversationCardStatus.error,
body: Text('body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.bySemanticsLabel('failed'), findsOneWidget);
expect(find.bySemanticsLabel('succeeded'), findsNothing);
@@ -320,18 +344,25 @@ void main() {
});
testWidgets('extraSegments render below the body with their sub-label when expanded (T-262)', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Read',
collapsible: true,
collapsedByDefault: true,
body: Text('the call', textDirection: TextDirection.ltr),
extraSegments: [CardSegment(label: 'result', child: Text('the output', textDirection: TextDirection.ltr))],
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'Read',
collapsible: true,
collapsedByDefault: true,
body: Text('the call', textDirection: TextDirection.ltr),
extraSegments: [
CardSegment(
label: 'result',
child: Text('the output', textDirection: TextDirection.ltr),
),
],
),
),
));
);
await tester.pump();
// Collapsed: neither the body nor the segment shows.
expect(find.text('the call'), findsNothing);
@@ -346,15 +377,17 @@ void main() {
});
testWidgets('bare variant renders content without frame decoration', (tester) async {
await tester.pumpWidget(harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bare,
accent: Color(0xFF00FF00),
label: 'bare',
body: Text('bare body', textDirection: TextDirection.ltr),
await tester.pumpWidget(
harness(
f,
const ConversationCard(
variant: ConversationCardVariant.bare,
accent: Color(0xFF00FF00),
label: 'bare',
body: Text('bare body', textDirection: TextDirection.ltr),
),
),
));
);
await tester.pump();
expect(find.text('bare body'), findsOneWidget);
});
@@ -34,24 +34,26 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(
width: 600,
height: 600,
child: Column(
children: [
Expanded(child: ConversationView(controller: c)),
// Stand-in for the interaction zone (composer / prompt) whose height
// changes; ValueListenableBuilder rebuilds just the box.
ValueListenableBuilder<double>(
valueListenable: bottomH,
builder: (_, h, __) => SizedBox(height: h, width: 600),
),
],
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 600,
height: 600,
child: Column(
children: [
Expanded(child: ConversationView(controller: c)),
// Stand-in for the interaction zone (composer / prompt) whose height
// changes; ValueListenableBuilder rebuilds just the box.
ValueListenableBuilder<double>(
valueListenable: bottomH,
builder: (_, h, _) => SizedBox(height: h, width: 600),
),
],
),
),
),
));
);
for (var i = 0; i < 40; i++) {
stream.add(_asst('conversation line number $i', i));
}
+80 -102
View File
@@ -148,11 +148,14 @@ void main() {
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
{Set<String> hiddenToolUseIds = const {},
Map<String, bool> toolUseOutcomes = const {},
Set<String> quietErrorToolUseIds = const {},
FoldLevel foldLevel = FoldLevel.none}) async {
Future<ConversationController> pumpWith(
WidgetTester tester,
List<ConversationItem> items, {
Set<String> hiddenToolUseIds = const {},
Map<String, bool> toolUseOutcomes = const {},
Set<String> quietErrorToolUseIds = const {},
FoldLevel foldLevel = FoldLevel.none,
}) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
@@ -164,20 +167,23 @@ void main() {
addTearDown(c.dispose);
// Disable animations so an in-flight run's ClideSpinner (a perpetual
// animation) renders static and pumpAndSettle can settle (T-296).
await tester.pumpWidget(harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(
await tester.pumpWidget(
harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(
controller: c,
hiddenToolUseIds: hiddenToolUseIds,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
foldLevel: foldLevel),
foldLevel: foldLevel,
),
),
),
),
));
);
for (final it in items) {
stream.add(it);
}
@@ -198,17 +204,19 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
Builder(
// The in-flight Bash collapser shows a live spinner (T-305); disable
// animations so it renders static and pumpAndSettle can settle.
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.none),
await tester.pumpWidget(
harness(
f,
Builder(
// The in-flight Bash collapser shows a live spinner (T-305); disable
// animations so it renders static and pumpAndSettle can settle.
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.none),
),
),
),
));
);
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
stream.add(AssistantThinkingMessage(uuid: 'B', timestamp: _t, isSidechain: false, thinking: 'thinking body'));
await tester.pumpAndSettle();
@@ -226,15 +234,17 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.tools),
await tester.pumpWidget(
harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, foldLevel: FoldLevel.tools),
),
),
),
));
);
stream.add(AssistantToolUse(uuid: 'A', timestamp: _t, isSidechain: false, toolUseId: 'A', name: 'Bash', input: const {'command': 'echo a'}));
stream.add(AssistantToolUse(uuid: 'B', timestamp: _t, isSidechain: false, toolUseId: 'B', name: 'Read', input: const {'file_path': '/a'}));
await tester.pumpAndSettle();
@@ -286,15 +296,7 @@ void main() {
AssistantToolUse edit(String id, String path) =>
AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'Edit', input: {'file_path': path});
ToolResultMessage ok(String id) => ToolResultMessage(uuid: 'r$id', timestamp: _t, isSidechain: false, toolUseId: id, content: 'done', isError: false);
await pumpWith(
tester,
[
edit('e1', '/lib/x.dart'),
ok('e1'),
edit('e2', '/lib/x.dart'),
ok('e2'),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [edit('e1', '/lib/x.dart'), ok('e1'), edit('e2', '/lib/x.dart'), ok('e2')], foldLevel: FoldLevel.tools);
// One bundled card labelled "2 edits" with an aggregate status indicator.
expect(find.text('2 edits'), findsOneWidget);
expect(find.byType(ClideStatusIndicator), findsOneWidget);
@@ -309,15 +311,12 @@ void main() {
});
testWidgets('meta items fold into a collapsed activity card; tap expands (T-230)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'echo hi'}),
// A second, distinct in-flight tool call (T-262 folds a success
// result into its call card, so two *calls* are what make 2 steps).
AssistantToolUse(uuid: 'tu2', timestamp: _t, isSidechain: false, toolUseId: 'x2', name: 'Read', input: const {'file_path': '/a'}),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [
_tool('Bash', const {'command': 'echo hi'}),
// A second, distinct in-flight tool call (T-262 folds a success
// result into its call card, so two *calls* are what make 2 steps).
AssistantToolUse(uuid: 'tu2', timestamp: _t, isSidechain: false, toolUseId: 'x2', name: 'Read', input: const {'file_path': '/a'}),
], foldLevel: FoldLevel.tools);
// Collapsed by default: one card with a step count, not the raw rows.
expect(find.text('2 steps'), findsOneWidget);
expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget);
@@ -328,13 +327,10 @@ void main() {
});
testWidgets('a folded success result is not a separate step — merged into its call (T-262 note D)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'echo hi'}),
_result('hi there'),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [
_tool('Bash', const {'command': 'echo hi'}),
_result('hi there'),
], foldLevel: FoldLevel.tools);
// The call + its success result is ONE unit now: 1 step, not 2.
expect(find.text('1 step'), findsOneWidget);
expect(find.text('2 steps'), findsNothing);
@@ -360,13 +356,10 @@ void main() {
});
testWidgets('a failed result surfaces first-class, not folded (T-230)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', const {'command': 'boom'}),
_result('error output', isError: true),
],
foldLevel: FoldLevel.tools);
await pumpWith(tester, [
_tool('Bash', const {'command': 'boom'}),
_result('error output', isError: true),
], foldLevel: FoldLevel.tools);
// The tool call folds (1 step); the error result is sticky → no 2-step card.
expect(find.text('1 step'), findsOneWidget);
expect(find.textContaining('error output'), findsWidgets);
@@ -376,13 +369,7 @@ void main() {
final stream = StreamController<ConversationItem>.broadcast();
final c = ConversationController(stream: stream.stream);
addTearDown(c.dispose);
await tester.pumpWidget(harness(
f,
ConversationView(
controller: c,
emptyState: const ClideText('CUSTOM EMPTY'),
),
));
await tester.pumpWidget(harness(f, ConversationView(controller: c, emptyState: const ClideText('CUSTOM EMPTY'))));
expect(find.text('CUSTOM EMPTY'), findsOneWidget);
expect(find.text('Waiting for Claude…'), findsNothing);
});
@@ -421,7 +408,13 @@ void main() {
testWidgets('a sidechain prompt folds into its Agent card; never labelled "you" (T-263)', (tester) async {
await pumpWith(tester, [
AssistantToolUse(
uuid: 'agt-msg', timestamp: _t, isSidechain: false, toolUseId: 'task1', name: 'Task', input: const {'description': 'explore the codebase'}),
uuid: 'agt-msg',
timestamp: _t,
isSidechain: false,
toolUseId: 'task1',
name: 'Task',
input: const {'description': 'explore the codebase'},
),
UserMessage(uuid: 'p1', timestamp: _t, isSidechain: true, text: 'find all the widgets'),
]);
// Never the blue "you", and no standalone block (folded → suppressed).
@@ -458,9 +451,7 @@ void main() {
testWidgets('an orphan sidechain prompt renders as muted "agent prompt", never "you" (T-263)', (tester) async {
// No Agent tool-use to attach to → stays standalone, but relabelled.
await pumpWith(tester, [
UserMessage(uuid: 'orphan', timestamp: _t, isSidechain: true, text: 'orphaned agent instructions'),
]);
await pumpWith(tester, [UserMessage(uuid: 'orphan', timestamp: _t, isSidechain: true, text: 'orphaned agent instructions')]);
expect(find.text('you'), findsNothing);
expect(find.text('agent prompt'), findsOneWidget);
});
@@ -490,7 +481,14 @@ void main() {
UserMessage(uuid: 'p', timestamp: _t, isSidechain: true, parentUuid: 'mA', text: 'go explore'),
// A sidechain tool call — part of the run, chained off the prompt.
AssistantToolUse(
uuid: 's1', timestamp: _t, isSidechain: true, parentUuid: 'p', toolUseId: 'sb', name: 'Bash', input: const {'command': 'grep widgets'}),
uuid: 's1',
timestamp: _t,
isSidechain: true,
parentUuid: 'p',
toolUseId: 'sb',
name: 'Bash',
input: const {'command': 'grep widgets'},
),
]);
expect(find.text('Task'), findsOneWidget);
// The run is a nested holder titled "agent run", collapsed by default —
@@ -550,17 +548,13 @@ void main() {
testWidgets('sidechain assistant prose is attributed to "agent", not "claude" (T-265)', (tester) async {
// An orphan sidechain prose (no resolvable Agent) renders inline, still
// attributed to the agent — never the main-thread coral "claude".
await pumpWith(tester, [
AssistantTextMessage(uuid: 's', timestamp: _t, isSidechain: true, text: 'sub-agent says hi'),
]);
await pumpWith(tester, [AssistantTextMessage(uuid: 's', timestamp: _t, isSidechain: true, text: 'sub-agent says hi')]);
expect(find.text('agent'), findsOneWidget);
expect(find.text('claude'), findsNothing);
});
testWidgets('sidechain thinking is attributed to "agent thinking" (T-265)', (tester) async {
await pumpWith(tester, [
AssistantThinkingMessage(uuid: 's', timestamp: _t, isSidechain: true, thinking: 'hmm let me think'),
]);
await pumpWith(tester, [AssistantThinkingMessage(uuid: 's', timestamp: _t, isSidechain: true, thinking: 'hmm let me think')]);
expect(find.text('agent thinking'), findsOneWidget);
expect(find.text('thinking'), findsNothing);
});
@@ -578,7 +572,7 @@ void main() {
tester,
[
_tool('Write', {'file_path': '/tmp/x'}),
_result('done')
_result('done'),
],
hiddenToolUseIds: {'x1'}, // _tool + _result both use toolUseId 'x1'
);
@@ -593,7 +587,7 @@ void main() {
tester,
[
_tool('Write', {'file_path': '/tmp/x'}),
_result('done')
_result('done'),
],
hiddenToolUseIds: {'x1'},
toolUseOutcomes: {'x1': true}, // approved
@@ -619,7 +613,7 @@ void main() {
testWidgets('tool-use body: Bash shows the command in the collapsed summary (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Bash', {'command': 'ls -la'})
_tool('Bash', {'command': 'ls -la'}),
]);
// Collapser starts collapsed — the command appears as the echoed summary.
expect(find.text('ls -la'), findsOneWidget);
@@ -632,7 +626,7 @@ void main() {
testWidgets('tool-use body: Read/Grep/LS shows a compact path label (T-168)', (tester) async {
await pumpWith(tester, [
_tool('Read', {'file_path': '/foo/bar.dart'})
_tool('Read', {'file_path': '/foo/bar.dart'}),
]);
// The path label appears (collapsed summary or body).
expect(find.text('/foo/bar.dart'), findsOneWidget);
@@ -665,7 +659,7 @@ void main() {
final handle = tester.ensureSemantics();
// In-flight: a call with no result yet → no check, no folded result.
await pumpWith(tester, [
_tool('Bash', {'command': 'ls'})
_tool('Bash', {'command': 'ls'}),
]);
expect(find.bySemanticsLabel('succeeded'), findsNothing);
expect(find.bySemanticsLabel('failed'), findsNothing);
@@ -734,16 +728,7 @@ void main() {
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
// Orphan result (no matching tool_use in the controller).
await pumpWith(tester, [
ToolResultMessage(
uuid: 'r-orphan',
timestamp: _t,
isSidechain: false,
toolUseId: 'unknown-id',
content: 'ok',
isError: false,
),
]);
await pumpWith(tester, [ToolResultMessage(uuid: 'r-orphan', timestamp: _t, isSidechain: false, toolUseId: 'unknown-id', content: 'ok', isError: false)]);
expect(find.text('result'), findsOneWidget);
});
@@ -811,14 +796,7 @@ void main() {
tearDown(() => f.dispose());
testWidgets('shows role, workspace, status, and a hint', (tester) async {
await tester.pumpWidget(harness(
f,
const ClaudeBanner(
role: 'primary',
workspace: '/work/space',
statusLine: 'tmux · clide-claude-x',
),
));
await tester.pumpWidget(harness(f, const ClaudeBanner(role: 'primary', workspace: '/work/space', statusLine: 'tmux · clide-claude-x')));
await tester.pump();
expect(find.text('Claude'), findsOneWidget);
expect(find.text('primary'), findsOneWidget);
@@ -24,30 +24,30 @@ import '../../helpers/widget_harness.dart';
/// the minimum theme/kernel tree under a tight, [Align]ed [SizedBox] so the
/// `Flexible` gets a real bounded width from the surrounding Row.
Widget _narrowRow(KernelFixture f, double width, Widget child) => 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: width,
height: 24,
child: Row(
children: [
Flexible(flex: 1, fit: FlexFit.loose, child: child),
const SizedBox(width: 20), // simulated right-side items
],
),
),
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: width,
height: 24,
child: Row(
children: [
Flexible(flex: 1, fit: FlexFit.loose, child: child),
const SizedBox(width: 20), // simulated right-side items
],
),
),
),
),
);
),
),
);
void main() {
late KernelFixture f;
@@ -76,11 +76,7 @@ void main() {
testWidgets('no RenderFlex overflow at narrow width — ClideMarquee is bounded', (tester) async {
// A long status line similar to what T-154 added:
// "opus 4.7 · default · 21k ctx · 10 skills" — roughly 280 px of text.
const longStatus = Text(
'opus 4.7 · default · 21k ctx · 10 skills',
textDirection: TextDirection.ltr,
softWrap: false,
);
const longStatus = Text('opus 4.7 · default · 21k ctx · 10 skills', textDirection: TextDirection.ltr, softWrap: false);
// Pump at 200 px wide — narrower than the status content so the marquee
// must receive a bounded viewport < content width and start scrolling.
@@ -18,10 +18,15 @@ void main() {
tearDown(() async => f.dispose());
Future<void> pump(WidgetTester tester, String mode, ValueChanged<String> onSelect) {
return tester.pumpWidget(harness(
f,
Align(alignment: Alignment.center, child: PermissionModeControl(mode: mode, onSelect: onSelect)),
));
return tester.pumpWidget(
harness(
f,
Align(
alignment: Alignment.center,
child: PermissionModeControl(mode: mode, onSelect: onSelect),
),
),
);
}
testWidgets('opens a menu of the safe trio + a bypass row; selecting sets the mode', (tester) async {
@@ -70,16 +75,9 @@ void main() {
tearDown(() async => f.dispose());
testWidgets('the mode control and the Stop row coexist while busy', (tester) async {
await tester.pumpWidget(harness(
f,
ClaudeComposer(
onSubmit: (_) {},
busy: true,
onInterrupt: () {},
permissionMode: 'default',
onSetPermissionMode: (_) {},
),
));
await tester.pumpWidget(
harness(f, ClaudeComposer(onSubmit: (_) {}, busy: true, onInterrupt: () {}, permissionMode: 'default', onSetPermissionMode: (_) {})),
);
await tester.pump();
// Stop affordance (busy row) and the trailing mode control are both present.
expect(find.textContaining('Stop'), findsOneWidget);
+93 -112
View File
@@ -9,60 +9,60 @@ import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
ToolPrompt permissionPrompt({List<dynamic> suggestions = const []}) => ToolPrompt(
promptId: 'req-1',
toolName: 'Write',
displayName: 'Write',
description: 'banana.txt',
input: const {'file_path': '/tmp/banana.txt', 'content': 'banana'},
permissionSuggestions: suggestions,
);
promptId: 'req-1',
toolName: 'Write',
displayName: 'Write',
description: 'banana.txt',
input: const {'file_path': '/tmp/banana.txt', 'content': 'banana'},
permissionSuggestions: suggestions,
);
ToolPrompt questionPrompt({bool multi = false}) => ToolPrompt(
promptId: 'req-q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Do you prefer cats or dogs?',
'header': 'Pet',
'multiSelect': multi,
'options': [
{'label': 'Cats', 'description': 'cat person'},
{'label': 'Dogs', 'description': 'dog person'},
],
},
promptId: 'req-q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Do you prefer cats or dogs?',
'header': 'Pet',
'multiSelect': multi,
'options': [
{'label': 'Cats', 'description': 'cat person'},
{'label': 'Dogs', 'description': 'dog person'},
],
},
);
],
},
);
ToolPrompt twoQuestionPrompt() => const ToolPrompt(
promptId: 'req-2q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Which pet?',
'header': 'Pet',
'multiSelect': false,
'options': [
{'label': 'Cats', 'description': ''},
{'label': 'Dogs', 'description': ''},
],
},
{
'question': 'How eaten?',
'header': 'Eaten',
'multiSelect': false,
'options': [
{'label': 'Fresh', 'description': ''},
{'label': 'Smoothie', 'description': ''},
],
},
promptId: 'req-2q',
toolName: 'AskUserQuestion',
displayName: 'AskUserQuestion',
input: {
'questions': [
{
'question': 'Which pet?',
'header': 'Pet',
'multiSelect': false,
'options': [
{'label': 'Cats', 'description': ''},
{'label': 'Dogs', 'description': ''},
],
},
);
{
'question': 'How eaten?',
'header': 'Eaten',
'multiSelect': false,
'options': [
{'label': 'Fresh', 'description': ''},
{'label': 'Smoothie', 'description': ''},
],
},
],
},
);
void main() {
late KernelFixture f;
@@ -72,16 +72,18 @@ void main() {
testWidgets('permission card: Allow returns AllowTool echoing the input', (tester) async {
ToolDecision? decision;
String? id;
await tester.pumpWidget(harness(
f,
ToolPromptCard(
prompt: permissionPrompt(),
onResolve: (p, d) {
id = p;
decision = d;
},
await tester.pumpWidget(
harness(
f,
ToolPromptCard(
prompt: permissionPrompt(),
onResolve: (p, d) {
id = p;
decision = d;
},
),
),
));
);
await tester.pump();
expect(find.text('permission · Write'), findsOneWidget);
@@ -97,7 +99,7 @@ void main() {
});
testWidgets('permission card shows the command being permitted', (tester) async {
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, _) {})));
await tester.pump();
expect(find.byType(ClideCodeBlock), findsOneWidget);
});
@@ -110,7 +112,7 @@ void main() {
description: 'Read IDE lock files',
input: {'command': 'cat ~/.claude/ide/97632.lock', 'description': 'Read IDE lock files'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
final block = tester.widget<ClideCodeBlock>(find.byType(ClideCodeBlock));
@@ -129,7 +131,7 @@ void main() {
description: 'long task',
input: {'command': 'sleep 30', 'run_in_background': true, 'timeout': 60000},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
expect(find.text('background · timeout 60000ms'), findsOneWidget);
});
@@ -145,7 +147,7 @@ void main() {
description: '/tmp/clide-ux-test.txt',
input: {'file_path': '/tmp/clide-ux-test.txt', 'content': 'hello'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
// The path should appear exactly once (in _pathLine, inside the body).
@@ -160,7 +162,7 @@ void main() {
description: 'banana.txt',
input: {'file_path': '/tmp/banana.txt', 'content': 'banana'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
expect(find.text('banana.txt'), findsOneWidget);
@@ -168,13 +170,8 @@ void main() {
});
testWidgets('permission card: unknown tool falls back to JSON', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-x',
toolName: 'NovelTool',
displayName: 'NovelTool',
input: {'foo': 'bar'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
const prompt = ToolPrompt(promptId: 'req-x', toolName: 'NovelTool', displayName: 'NovelTool', input: {'foo': 'bar'});
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
final block = tester.widget<ClideCodeBlock>(find.byType(ClideCodeBlock));
expect(block.language, 'json');
@@ -183,10 +180,7 @@ void main() {
testWidgets('permission card: Deny returns DenyTool with a message', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.tap(find.text('2. Deny'));
@@ -197,7 +191,7 @@ void main() {
});
testWidgets('permission: no "don\'t ask again" button without a suggestion', (tester) async {
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, _) {})));
await tester.pump();
expect(find.text("2. Allow & don't ask again"), findsNothing);
});
@@ -205,12 +199,17 @@ void main() {
testWidgets('permission: "don\'t ask again" shows with a suggestion and returns updatedPermissions', (tester) async {
ToolDecision? decision;
const sugg = [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'},
];
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: permissionPrompt(suggestions: sugg), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(
harness(
f,
ToolPromptCard(
prompt: permissionPrompt(suggestions: sugg),
onResolve: (_, d) => decision = d,
),
),
);
await tester.pump();
expect(find.text("2. Allow & don't ask again"), findsOneWidget);
@@ -272,10 +271,7 @@ void main() {
testWidgets('question card: Submit is gated until an option is picked, then returns answers', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
expect(find.text('Do you prefer cats or dogs?'), findsOneWidget);
@@ -297,10 +293,7 @@ void main() {
testWidgets('question card: multi-select joins chosen labels comma-separated', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: questionPrompt(multi: true), onResolve: (_, d) => decision = d),
));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(multi: true), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.tap(find.textContaining('Cats'));
@@ -396,13 +389,9 @@ void main() {
promptId: 'req-e',
toolName: 'Edit',
displayName: 'Edit',
input: {
'file_path': '/tmp/foo.dart',
'old_string': 'void main() {}',
'new_string': 'void main() => run();',
},
input: {'file_path': '/tmp/foo.dart', 'old_string': 'void main() {}', 'new_string': 'void main() => run();'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
// Two code blocks: before + after.
expect(find.byType(ClideCodeBlock), findsNWidgets(2));
@@ -413,13 +402,8 @@ void main() {
group('permission card: Read/Grep show compact path via shared helper', () {
testWidgets('Read shows the file path label', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-r',
toolName: 'Read',
displayName: 'Read',
input: {'file_path': '/docs/readme.md'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
const prompt = ToolPrompt(promptId: 'req-r', toolName: 'Read', displayName: 'Read', input: {'file_path': '/docs/readme.md'});
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
expect(find.text('/docs/readme.md'), findsOneWidget);
// No code blocks — just a text label for Read.
@@ -428,13 +412,8 @@ void main() {
testWidgets('Grep shows pattern quoted alongside any path', (tester) async {
// Grep with both path and pattern: the label shows file_path + quoted pattern.
const prompt = ToolPrompt(
promptId: 'req-grep',
toolName: 'Grep',
displayName: 'Grep',
input: {'pattern': 'TODO', 'path': '/src'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
const prompt = ToolPrompt(promptId: 'req-grep', toolName: 'Grep', displayName: 'Grep', input: {'pattern': 'TODO', 'path': '/src'});
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, _) {})));
await tester.pump();
// The combined label contains both the path and the quoted pattern.
expect(find.textContaining('"TODO"'), findsOneWidget);
@@ -449,13 +428,15 @@ void main() {
final notifier = ValueNotifier<ToolPrompt>(questionPrompt());
addTearDown(notifier.dispose);
await tester.pumpWidget(harness(
f,
ValueListenableBuilder<ToolPrompt>(
valueListenable: notifier,
builder: (_, p, __) => ToolPromptCard(prompt: p, onResolve: (_, d) => decision = d),
await tester.pumpWidget(
harness(
f,
ValueListenableBuilder<ToolPrompt>(
valueListenable: notifier,
builder: (_, p, _) => ToolPromptCard(prompt: p, onResolve: (_, d) => decision = d),
),
),
));
);
await tester.pump();
expect(find.text('Do you prefer cats or dogs?'), findsOneWidget);
@@ -530,7 +511,7 @@ void main() {
});
const sugg = [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'},
];
testWidgets('with a remember suggestion: 2 = Allow & remember', (tester) async {
@@ -22,9 +22,9 @@ void main() {
tearDown(() => f.dispose());
Widget wrap({bool reducedMotion = false}) => MediaQuery(
data: MediaQueryData(disableAnimations: reducedMotion),
child: const RunningIndicator(shuffle: false),
);
data: MediaQueryData(disableAnimations: reducedMotion),
child: const RunningIndicator(shuffle: false),
);
testWidgets('animates the ellipsis and rotates the verb', (tester) async {
await tester.pumpWidget(harness(f, wrap()));
+28 -33
View File
@@ -5,39 +5,39 @@ import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:test/test.dart';
String userLine(String text) => jsonEncode({
'type': 'user',
'message': {'role': 'user', 'content': text},
});
'type': 'user',
'message': {'role': 'user', 'content': text},
});
String userBlocksLine(String text) => jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': text},
],
},
});
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': text},
],
},
});
String toolResultLine() => jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'tool_result', 'content': 'output'},
],
},
});
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'tool_result', 'content': 'output'},
],
},
});
String assistantLine(String text) => jsonEncode({
'type': 'assistant',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
'type': 'assistant',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
void main() {
group('userText', () {
@@ -69,12 +69,7 @@ void main() {
});
test('summarises each session with first … last bookends', () async {
await writeSession('aaaa', [
userLine('start the swallow'),
assistantLine('ok'),
toolResultLine(),
userLine('now the peacock'),
]);
await writeSession('aaaa', [userLine('start the swallow'), assistantLine('ok'), toolResultLine(), userLine('now the peacock')]);
final sessions = await listSessions(dir);
expect(sessions, hasLength(1));
expect(sessions.single.id, 'aaaa');
@@ -47,14 +47,8 @@ ClaudeSessionOrchestrator _orch(List<_FakeProc> created) {
);
}
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath, String cwd = '/repo'}) => SpawnSpec(
id: id,
role: id,
sessionId: '$id-uuid',
cwd: cwd,
resume: resume,
transcriptPath: transcriptPath,
);
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath, String cwd = '/repo'}) =>
SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: cwd, resume: resume, transcriptPath: transcriptPath);
// ---------------------------------------------------------------------------
// Tests
@@ -195,11 +189,7 @@ void main() {
'"message":{"role":"user","content":"hello from the past"}}\n',
);
final managed = await orch.spawn(_spec(
'primary',
resume: true,
transcriptPath: file.path,
));
final managed = await orch.spawn(_spec('primary', resume: true, transcriptPath: file.path));
expect(managed.conversation.items, hasLength(1));
await tmp.delete(recursive: true);
@@ -210,13 +200,7 @@ void main() {
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 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));
@@ -278,22 +262,8 @@ void main() {
});
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',
));
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));
// Broker has 2 agent members + 1 virtual 'user' member (T-180).
final agentMembers = orch.broker.members.where((m) => m.id != 'user');
@@ -26,11 +26,13 @@ void main() {
setUp(() {
created = [];
orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
created.add(p);
return p;
});
orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
created.add(p);
return p;
},
);
});
SpawnSpec spec(String id, {bool visible = true}) => SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: '/repo', visible: visible);
@@ -44,21 +46,9 @@ void main() {
});
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',
));
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',
}));
created.last.emit(jsonEncode({'type': 'system', 'subtype': 'init', 'session_id': 'real-fork-id', 'model': 'claude-opus-4-8', 'permissionMode': 'default'}));
await Future<void>.delayed(Duration.zero);
expect(m.sessionId, 'real-fork-id'); // updated to the branch's real id
});
@@ -161,14 +151,9 @@ void main() {
'{"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 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<UserMessage>());
@@ -179,26 +164,23 @@ void main() {
});
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',
));
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',
));
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);
});
});
@@ -218,13 +200,8 @@ void main() {
);
});
SpawnSpec forkSpec(String id, String sourceSessionId) => SpawnSpec(
id: id,
role: 'fork',
sessionId: '$id-placeholder',
cwd: '/repo',
forkSourceSessionId: sourceSessionId,
);
SpawnSpec forkSpec(String id, String sourceSessionId) =>
SpawnSpec(id: id, role: 'fork', sessionId: '$id-placeholder', cwd: '/repo', forkSourceSessionId: sourceSessionId);
test('fork spawn passes --resume <source> --fork-session instead of --session-id', () async {
const sourceId = 'bbbb2222-2222-4222-8222-222222222222';
@@ -288,12 +265,7 @@ void main() {
});
test('ManagedSession.cwd reflects the spec cwd', () async {
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/my/project',
));
final managed = await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'primary-uuid', cwd: '/my/project'));
expect(managed.cwd, '/my/project');
});
});
+7 -19
View File
@@ -27,16 +27,13 @@ void main() {
tearDown(() => f.dispose());
List<SessionSummary> two() => [
SessionSummary(id: 'aaa', modified: DateTime.now(), firstUser: 'first a', lastUser: 'last a'),
SessionSummary(id: 'bbb', modified: DateTime.now(), firstUser: 'first b', lastUser: 'last b'),
];
SessionSummary(id: 'aaa', modified: DateTime.now(), firstUser: 'first a', lastUser: 'last a'),
SessionSummary(id: 'bbb', modified: DateTime.now(), firstUser: 'first b', lastUser: 'last b'),
];
testWidgets('renders first … last labels and picks with arrow + Enter', (tester) async {
String? picked;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {})));
await tester.pump();
expect(find.text('first a … last a'), findsOneWidget);
expect(find.text('first b … last b'), findsOneWidget);
@@ -49,10 +46,7 @@ void main() {
testWidgets('Escape cancels', (tester) async {
var cancelled = false;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (_) {}, onCancel: () => cancelled = true),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: two(), onPick: (_) {}, onCancel: () => cancelled = true)));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(cancelled, isTrue);
@@ -60,20 +54,14 @@ void main() {
testWidgets('tap picks a row', (tester) async {
String? picked;
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {}),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: two(), onPick: (id) => picked = id, onCancel: () {})));
await tester.pump();
await tester.tap(find.text('first b … last b'));
expect(picked, 'bbb');
});
testWidgets('empty list shows a message', (tester) async {
await tester.pumpWidget(harness(
f,
SessionPickerDialog(sessions: const [], onPick: (_) {}, onCancel: () {}),
));
await tester.pumpWidget(harness(f, SessionPickerDialog(sessions: const [], onPick: (_) {}, onCancel: () {})));
await tester.pump();
expect(find.text('No sessions found for this workspace.'), findsOneWidget);
});
+7 -27
View File
@@ -17,15 +17,9 @@ void main() {
testWidgets('shows the total and a two-click delete that calls the deleter', (tester) async {
final deleted = <String>[];
await tester.pumpWidget(harness(
f,
SessionStorageDialog(
dir: Directory.systemTemp,
sessions: [session('aaa', 2048)],
onClose: () {},
deleter: (d, id) async => deleted.add(id),
),
));
await tester.pumpWidget(
harness(f, SessionStorageDialog(dir: Directory.systemTemp, sessions: [session('aaa', 2048)], onClose: () {}, deleter: (d, id) async => deleted.add(id))),
);
await tester.pump();
expect(find.text('Session storage · 2 KB total'), findsOneWidget);
@@ -44,30 +38,16 @@ void main() {
testWidgets('Escape closes', (tester) async {
var closed = false;
await tester.pumpWidget(harness(
f,
SessionStorageDialog(
dir: Directory.systemTemp,
sessions: [session('aaa', 1024)],
onClose: () => closed = true,
deleter: (_, __) async {},
),
));
await tester.pumpWidget(
harness(f, SessionStorageDialog(dir: Directory.systemTemp, sessions: [session('aaa', 1024)], onClose: () => closed = true, deleter: (_, _) async {})),
);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(closed, isTrue);
});
testWidgets('empty list shows a message', (tester) async {
await tester.pumpWidget(harness(
f,
SessionStorageDialog(
dir: Directory.systemTemp,
sessions: const [],
onClose: () {},
deleter: (_, __) async {},
),
));
await tester.pumpWidget(harness(f, SessionStorageDialog(dir: Directory.systemTemp, sessions: const [], onClose: () {}, deleter: (_, _) async {})));
await tester.pump();
expect(find.text('No sessions found for this workspace.'), findsOneWidget);
});
+177 -177
View File
@@ -28,12 +28,12 @@ class _FakeMcpServer implements McpServer {
final List<String> calls = [];
@override
List<Map<String, dynamic>> get tools => [
{
'name': 'ping',
'description': 'p',
'inputSchema': {'type': 'object', 'properties': <String, dynamic>{}},
},
];
{
'name': 'ping',
'description': 'p',
'inputSchema': {'type': 'object', 'properties': <String, dynamic>{}},
},
];
@override
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments) async {
calls.add(name);
@@ -47,114 +47,101 @@ class _FakeMcpServer implements McpServer {
}
String mcpMessage(String rid, Map<String, dynamic> message, {String server = 'clide-team'}) => jsonEncode({
'type': 'control_request',
'request_id': rid,
'request': {'subtype': 'mcp_message', 'server_name': server, 'message': message},
});
'type': 'control_request',
'request_id': rid,
'request': {'subtype': 'mcp_message', 'server_name': server, 'message': message},
});
String assistantText(String text) => jsonEncode({
'type': 'assistant',
'uuid': 'a1',
'message': {
'model': 'claude-opus-4-7',
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
'usage': {'input_tokens': 100, 'cache_read_input_tokens': 50, 'cache_creation_input_tokens': 0},
},
});
'type': 'assistant',
'uuid': 'a1',
'message': {
'model': 'claude-opus-4-7',
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
'usage': {'input_tokens': 100, 'cache_read_input_tokens': 50, 'cache_creation_input_tokens': 0},
},
});
String assistantToolUse() => jsonEncode({
'type': 'assistant',
'uuid': 'a2',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': 't1',
'name': 'Bash',
'input': {'command': 'ls'}
},
],
'type': 'assistant',
'uuid': 'a2',
'message': {
'role': 'assistant',
'content': [
{
'type': 'tool_use',
'id': 't1',
'name': 'Bash',
'input': {'command': 'ls'},
},
});
],
},
});
String initEvent() => jsonEncode({
'type': 'system',
'subtype': 'init',
'model': 'claude-opus-4-7',
'permissionMode': 'default',
});
String initEvent() => jsonEncode({'type': 'system', 'subtype': 'init', 'model': 'claude-opus-4-7', 'permissionMode': 'default'});
String resultEvent({double? cost, Map<String, dynamic>? modelUsage}) => jsonEncode({
'type': 'result',
'result': '',
'usage': <String, dynamic>{},
if (cost != null) 'total_cost_usd': cost,
if (modelUsage != null) 'modelUsage': modelUsage,
});
String resultEvent({double? cost, Map<String, dynamic>? modelUsage}) =>
jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}, 'total_cost_usd': ?cost, 'modelUsage': ?modelUsage});
String rateLimitEvent({String? status, String? resetsAt}) => jsonEncode({
'type': 'rate_limit_event',
'rate_limit_info': <String, dynamic>{
if (status != null) 'status': status,
if (resetsAt != null) 'resetsAt': resetsAt,
},
});
'type': 'rate_limit_event',
'rate_limit_info': <String, dynamic>{'status': ?status, 'resetsAt': ?resetsAt},
});
// Real `--include-partial-messages` wire shape (captured from claude 2.1.150,
// interactive stream-json mode — T-184): partials arrive as `stream_event`
// envelopes wrapping Anthropic streaming deltas, NOT `assistant`+`partial:true`.
String streamMessageStart(String messageId) => jsonEncode({
'type': 'stream_event',
'event': {
'type': 'message_start',
'message': {'id': messageId, 'role': 'assistant', 'content': <dynamic>[]},
},
});
'type': 'stream_event',
'event': {
'type': 'message_start',
'message': {'id': messageId, 'role': 'assistant', 'content': <dynamic>[]},
},
});
String streamTextDelta(String text, {int index = 0}) => jsonEncode({
'type': 'stream_event',
'event': {
'type': 'content_block_delta',
'index': index,
'delta': {'type': 'text_delta', 'text': text},
},
});
'type': 'stream_event',
'event': {
'type': 'content_block_delta',
'index': index,
'delta': {'type': 'text_delta', 'text': text},
},
});
String streamMessageStop() => jsonEncode({
'type': 'stream_event',
'event': {'type': 'message_stop'},
});
'type': 'stream_event',
'event': {'type': 'message_stop'},
});
// The final per-block `assistant` event carrying a message id (so the session
// can pair it with a streamed placeholder).
String assistantTextWithId(String messageId, String text, {String uuid = 'final-uuid'}) => jsonEncode({
'type': 'assistant',
'uuid': uuid,
'message': {
'id': messageId,
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
'type': 'assistant',
'uuid': uuid,
'message': {
'id': messageId,
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
});
String canUseTool(String rid, {String tool = 'Write', Map<String, dynamic>? input}) => jsonEncode({
'type': 'control_request',
'request_id': rid,
'request': {
'subtype': 'can_use_tool',
'tool_name': tool,
'display_name': tool,
'description': 'banana.txt',
'input': input ?? {'file_path': '/tmp/banana.txt', 'content': 'banana'},
'tool_use_id': 'toolu_1',
},
});
'type': 'control_request',
'request_id': rid,
'request': {
'subtype': 'can_use_tool',
'tool_name': tool,
'display_name': tool,
'description': 'banana.txt',
'input': input ?? {'file_path': '/tmp/banana.txt', 'content': 'banana'},
'tool_use_id': 'toolu_1',
},
});
void main() {
late _FakeProc proc;
@@ -209,13 +196,7 @@ void main() {
test('captures the claude session id from the first event carrying it (T-185)', () async {
final ids = <String>[];
session.sessionIdResolved.listen(ids.add);
proc.emit(jsonEncode({
'type': 'system',
'subtype': 'init',
'session_id': 'sess-abc',
'model': 'claude-opus-4-7',
'permissionMode': 'default',
}));
proc.emit(jsonEncode({'type': 'system', 'subtype': 'init', 'session_id': 'sess-abc', 'model': 'claude-opus-4-7', 'permissionMode': 'default'}));
await Future<void>.delayed(Duration.zero);
expect(session.claudeSessionId, 'sess-abc');
expect(ids, ['sess-abc']);
@@ -229,12 +210,14 @@ void main() {
});
test('result event with modelUsage populates contextWindow', () async {
proc.emit(resultEvent(
cost: 0.01,
modelUsage: {
'claude-opus-4-7': {'contextWindow': 1000000, 'maxOutputTokens': 8192},
},
));
proc.emit(
resultEvent(
cost: 0.01,
modelUsage: {
'claude-opus-4-7': {'contextWindow': 1000000, 'maxOutputTokens': 8192},
},
),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.contextWindow, 1000000);
});
@@ -275,10 +258,12 @@ void main() {
test('rate_limit_event with a numeric (epoch) resetsAt does not crash', () async {
// Claude sends resetsAt as a unix-epoch number, not a string — the
// old `as String?` cast threw 'int is not a subtype of String?'.
proc.emit(jsonEncode({
'type': 'rate_limit_event',
'rate_limit_info': {'status': 'rate_limited', 'resetsAt': 1780000000},
}));
proc.emit(
jsonEncode({
'type': 'rate_limit_event',
'rate_limit_info': {'status': 'rate_limited', 'resetsAt': 1780000000},
}),
);
await Future<void>.delayed(Duration.zero);
expect(statuses.last.rateLimitInfo, contains('rate limited'));
expect(statuses.last.rateLimitInfo, contains('resets'));
@@ -361,31 +346,35 @@ void main() {
});
test('a synthetic user message (skill/command inject) is flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'isSynthetic': true,
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'Base directory for this skill: /x'}
],
},
}));
proc.emit(
jsonEncode({
'type': 'user',
'isSynthetic': true,
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'Base directory for this skill: /x'},
],
},
}),
);
await Future<void>.delayed(Duration.zero);
final u = items.whereType<UserMessage>().single;
expect(u.injected, isTrue);
});
test('a plain user text event is not flagged injected', () async {
proc.emit(jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'hello'}
],
},
}));
proc.emit(
jsonEncode({
'type': 'user',
'message': {
'role': 'user',
'content': [
{'type': 'text', 'text': 'hello'},
],
},
}),
);
await Future<void>.delayed(Duration.zero);
expect(items.whereType<UserMessage>().single.injected, isFalse);
});
@@ -428,18 +417,20 @@ void main() {
});
test('a permission request carries its permission_suggestions', () async {
proc.emit(jsonEncode({
'type': 'control_request',
'request_id': 'rs',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'Write',
'input': {'file_path': '/tmp/x'},
'permission_suggestions': [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'}
],
},
}));
proc.emit(
jsonEncode({
'type': 'control_request',
'request_id': 'rs',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'Write',
'input': {'file_path': '/tmp/x'},
'permission_suggestions': [
{'type': 'setMode', 'mode': 'acceptEdits', 'destination': 'session'},
],
},
}),
);
await Future<void>.delayed(Duration.zero);
expect(session.pendingPrompt!.permissionSuggestions, hasLength(1));
});
@@ -448,12 +439,14 @@ void main() {
proc.emit(canUseTool('rp'));
await Future<void>.delayed(Duration.zero);
session.resolvePrompt(
'rp',
AllowTool(const {
'x': 1
}, updatedPermissions: const [
{'type': 'setMode'}
]));
'rp',
AllowTool(
const {'x': 1},
updatedPermissions: const [
{'type': 'setMode'},
],
),
);
final decision = ((jsonDecode(proc.writes.single) as Map)['response'] as Map)['response'] as Map;
expect(decision['behavior'], 'allow');
expect(decision['updatedPermissions'], hasLength(1));
@@ -496,21 +489,24 @@ void main() {
});
test('resolving an AskUserQuestion leaves an answered echo in the log', () async {
proc.emit(jsonEncode({
'type': 'control_request',
'request_id': 'aq',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'AskUserQuestion',
'input': {'questions': <dynamic>[]},
},
}));
proc.emit(
jsonEncode({
'type': 'control_request',
'request_id': 'aq',
'request': {
'subtype': 'can_use_tool',
'tool_name': 'AskUserQuestion',
'input': {'questions': <dynamic>[]},
},
}),
);
await Future<void>.delayed(Duration.zero);
session.resolvePrompt(
'aq',
AllowTool(const {
'answers': {'Pet': 'Dogs'}
}));
'aq',
AllowTool(const {
'answers': {'Pet': 'Dogs'},
}),
);
await Future<void>.delayed(Duration.zero);
final echo = items.whereType<UserMessage>().toList();
@@ -541,11 +537,13 @@ void main() {
});
test('an unsupported control_request is answered with an error (no hang)', () async {
proc.emit(jsonEncode({
'type': 'control_request',
'request_id': 'req-5',
'request': {'subtype': 'mystery_subtype'},
}));
proc.emit(
jsonEncode({
'type': 'control_request',
'request_id': 'req-5',
'request': {'subtype': 'mystery_subtype'},
}),
);
await Future<void>.delayed(Duration.zero);
expect(items, isEmpty);
@@ -646,19 +644,19 @@ void main() {
}
test('declares its sdkMcpServers in the initialize handshake', () {
final init = mproc.writes.map((w) => jsonDecode(w) as Map).firstWhere(
(m) => (m['request'] as Map?)?['subtype'] == 'initialize',
);
final init = mproc.writes.map((w) => jsonDecode(w) as Map).firstWhere((m) => (m['request'] as Map?)?['subtype'] == 'initialize');
expect((init['request'] as Map)['sdkMcpServers'], ['clide-team']);
});
test('answers mcp initialize with our serverInfo', () async {
mproc.emit(mcpMessage('m1', {
'method': 'initialize',
'params': {'protocolVersion': '2025-11-25'},
'jsonrpc': '2.0',
'id': 0,
}));
mproc.emit(
mcpMessage('m1', {
'method': 'initialize',
'params': {'protocolVersion': '2025-11-25'},
'jsonrpc': '2.0',
'id': 0,
}),
);
await Future<void>.delayed(Duration.zero);
final r = mcpResponseOf(mproc.writes.last);
expect((r['result'] as Map)['serverInfo'], {'name': 'clide-team', 'version': '9.9.9'});
@@ -673,12 +671,14 @@ void main() {
});
test('routes tools/call to the server and returns its result', () async {
mproc.emit(mcpMessage('m3', {
'method': 'tools/call',
'params': {'name': 'ping', 'arguments': <String, dynamic>{}},
'jsonrpc': '2.0',
'id': 2,
}));
mproc.emit(
mcpMessage('m3', {
'method': 'tools/call',
'params': {'name': 'ping', 'arguments': <String, dynamic>{}},
'jsonrpc': '2.0',
'id': 2,
}),
);
await Future<void>.delayed(Duration.zero);
expect(server.calls, ['ping']);
final r = mcpResponseOf(mproc.writes.last);
+5 -5
View File
@@ -19,7 +19,7 @@ void main() {
{'content': 'a', 'status': 'pending'},
{'content': 'b', 'status': 'in_progress'},
{'content': 'c', 'status': 'completed'},
])
]),
]);
expect(tasks, const [
TaskItem(text: 'a', status: TaskStatus.pending),
@@ -31,10 +31,10 @@ void main() {
test('the latest TodoWrite wins — a snapshot, not an append log', () {
final tasks = taskListFrom([
_todo([
{'content': 'old', 'status': 'pending'}
{'content': 'old', 'status': 'pending'},
], id: '1'),
_todo([
{'content': 'new', 'status': 'in_progress'}
{'content': 'new', 'status': 'in_progress'},
], id: '2'),
]);
expect(tasks, const [TaskItem(text: 'new', status: TaskStatus.inProgress)]);
@@ -45,7 +45,7 @@ void main() {
_todo([
{'activeForm': 'doing it', 'status': 'in_progress'},
{'status': 'weird'},
])
]),
]);
expect(tasks[0].text, 'doing it');
expect(tasks[1].text, '');
@@ -56,7 +56,7 @@ void main() {
expect(taskListFrom(const []), isEmpty);
expect(
taskListFrom([
AssistantToolUse(uuid: 'b', timestamp: _t, isSidechain: false, toolUseId: 'b', name: 'Bash', input: const {'command': 'ls'})
AssistantToolUse(uuid: 'b', timestamp: _t, isSidechain: false, toolUseId: 'b', name: 'Bash', input: const {'command': 'ls'}),
]),
isEmpty,
);
+62 -122
View File
@@ -18,7 +18,7 @@ void main() {
setUp(() async {
f = await KernelFixture.create();
broker = TeamBroker(deliver: (_, __) {});
broker = TeamBroker(deliver: (_, _) {});
broker.addMember(const TeamMemberRef(id: 'primary', name: 'lead', role: 'lead'));
broker.addMember(const TeamMemberRef(id: 'teammate:tyre', name: 'tyre', role: 'teammate'));
model = TeamChatModel(broker: broker);
@@ -36,17 +36,13 @@ void main() {
group('TeamChatSidebar', () {
Widget sidebar({VoidCallback? onPopOut}) => harness(
f,
SizedBox(
width: 220,
height: 400,
child: TeamChatSidebar(
model: model,
broker: broker,
onPopOut: onPopOut ?? () {},
),
),
);
f,
SizedBox(
width: 220,
height: 400,
child: TeamChatSidebar(model: model, broker: broker, onPopOut: onPopOut ?? () {}),
),
);
testWidgets('renders MESSAGES header', (tester) async {
await tester.pumpWidget(sidebar());
@@ -131,18 +127,16 @@ void main() {
var popped = false;
// Use a tall harness so the MESSAGES header (and its pop-out icon) is
// always in view and tappable.
await tester.pumpWidget(harness(
f,
SizedBox(
width: 300,
height: 800,
child: TeamChatSidebar(
model: model,
broker: broker,
onPopOut: () => popped = true,
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 300,
height: 800,
child: TeamChatSidebar(model: model, broker: broker, onPopOut: () => popped = true),
),
),
));
);
await tester.pump();
// The pop-out icon is wired via Semantics(label: 'Open full chat pane').
@@ -188,18 +182,13 @@ void main() {
await tester.pumpAndSettle();
// Focus the field and set text with a selection so cursor is at end.
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set value with explicit cursor position at end.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
await tester.pump();
@@ -211,18 +200,13 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// No match — _updateSuggestions called with null.
field.controller.value = const TextEditingValue(
text: '@zzz',
selection: TextSelection.collapsed(offset: 4),
);
field.controller.value = const TextEditingValue(text: '@zzz', selection: TextSelection.collapsed(offset: 4));
await tester.pump();
await tester.pump();
@@ -236,25 +220,17 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set @ty (matches tyre) → _showOverlay called.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
// Clear → _removeOverlay called.
field.controller.value = const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
field.controller.value = const TextEditingValue(text: '', selection: TextSelection.collapsed(offset: 0));
await tester.pump();
await tester.pump();
@@ -266,18 +242,13 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set a matching @-prefix to activate the overlay path.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
// Send Escape — _handleKeyEvent should return KeyEventResult.handled.
@@ -294,18 +265,13 @@ void main() {
await tester.pumpWidget(sidebar());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// A value with no selection (baseOffset == -1) triggers the cursor < 0 guard.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: -1),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: -1));
await tester.pump();
await tester.pump();
@@ -337,13 +303,13 @@ void main() {
group('TeamChatPane', () {
Widget pane() => harness(
f,
SizedBox(
width: 400,
height: 600,
child: TeamChatPane(model: model, broker: broker),
),
);
f,
SizedBox(
width: 400,
height: 600,
child: TeamChatPane(model: model, broker: broker),
),
);
testWidgets('renders Team Chat header', (tester) async {
await tester.pumpWidget(pane());
@@ -408,18 +374,13 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// Set @ty to activate overlay path.
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
// Escape dismisses.
@@ -434,18 +395,13 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
// @le → matches lead.
field.controller.value = const TextEditingValue(
text: '@le',
selection: TextSelection.collapsed(offset: 3),
);
field.controller.value = const TextEditingValue(text: '@le', selection: TextSelection.collapsed(offset: 3));
await tester.pump();
await tester.pump();
@@ -457,17 +413,12 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
field.controller.value = const TextEditingValue(
text: '@zzz',
selection: TextSelection.collapsed(offset: 4),
);
field.controller.value = const TextEditingValue(text: '@zzz', selection: TextSelection.collapsed(offset: 4));
await tester.pump();
await tester.pump();
@@ -479,17 +430,12 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
final field = tester.widget<EditableText>(chatField);
field.focusNode.requestFocus();
await tester.pump();
field.controller.value = const TextEditingValue(
text: '@ty',
selection: TextSelection.collapsed(offset: -1),
);
field.controller.value = const TextEditingValue(text: '@ty', selection: TextSelection.collapsed(offset: -1));
await tester.pump();
await tester.pump();
@@ -500,9 +446,7 @@ void main() {
await tester.pumpWidget(pane());
await tester.pumpAndSettle();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
await tester.enterText(chatField, ' ');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -519,9 +463,7 @@ void main() {
await tester.tap(interruptArea);
await tester.pump();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
await tester.enterText(chatField, 'urgent message');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -538,9 +480,7 @@ void main() {
await tester.tap(find.text('Interrupt'));
await tester.pump();
final chatField = find.byWidgetPredicate(
(w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane',
);
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
await tester.enterText(chatField, '@lead do this now');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -549,26 +489,26 @@ void main() {
});
testWidgets('sidebar and pane share the same model (both surfaces update)', (tester) async {
await tester.pumpWidget(harness(
f,
SizedBox(
width: 800,
height: 600,
child: Row(
children: [
SizedBox(
width: 220,
child: TeamChatSidebar(
model: model,
broker: broker,
onPopOut: () {},
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 800,
height: 600,
child: Row(
children: [
SizedBox(
width: 220,
child: TeamChatSidebar(model: model, broker: broker, onPopOut: () {}),
),
),
Expanded(child: TeamChatPane(model: model, broker: broker)),
],
Expanded(
child: TeamChatPane(model: model, broker: broker),
),
],
),
),
),
));
);
await tester.pump();
// Posting from the model shows up in both surfaces.
@@ -16,15 +16,8 @@ import '../../helpers/widget_harness.dart';
Widget _lead() => const Center(child: Text('LEAD', textDirection: TextDirection.ltr));
TeamMemberJoined _joined(String name, String pane, {String? color}) => TeamMemberJoined(
team: 'myteam',
agentId: '$name@myteam',
name: name,
agentType: 'researcher',
paneId: pane,
model: 'sonnet',
color: color,
);
TeamMemberJoined _joined(String name, String pane, {String? color}) =>
TeamMemberJoined(team: 'myteam', agentId: '$name@myteam', name: name, agentType: 'researcher', paneId: pane, model: 'sonnet', color: color);
void main() {
late KernelFixture f;
+12 -2
View File
@@ -81,7 +81,12 @@ void main() {
test('already started: injects but does not move the status backwards (T-339)', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
final accepted = await applyTicketPickUp(payload(status: 'in_progress'), orchestrator: orch, ipc: ipc, messages: messages);
final accepted = await applyTicketPickUp(
payload(status: 'in_progress'),
orchestrator: orch,
ipc: ipc,
messages: messages,
);
await Future<void>.delayed(Duration.zero);
expect(accepted, isTrue); // prompt still delivered
@@ -91,7 +96,12 @@ void main() {
test('a backlog ticket is also startable', () async {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
await applyTicketPickUp(payload(status: 'backlog'), orchestrator: orch, ipc: ipc, messages: messages);
await applyTicketPickUp(
payload(status: 'backlog'),
orchestrator: orch,
ipc: ipc,
messages: messages,
);
expect(statusCalls, hasLength(1));
});
@@ -12,29 +12,29 @@ import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:test/test.dart';
Map<String, dynamic> _userLine(String uuid, String text) => {
'type': 'user',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:06.708Z',
'message': {'role': 'user', 'content': text},
};
'type': 'user',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:06.708Z',
'message': {'role': 'user', 'content': text},
};
Map<String, dynamic> _asstLine(String uuid, String text) => {
'type': 'assistant',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:07.708Z',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text}
],
},
};
'type': 'assistant',
'uuid': uuid,
'parentUuid': '',
'isSidechain': false,
'version': '2.1.143',
'timestamp': '2026-05-16T08:53:07.708Z',
'message': {
'role': 'assistant',
'content': [
{'type': 'text', 'text': text},
],
},
};
void main() {
group('TranscriptPublisher', () {
@@ -49,9 +49,7 @@ void main() {
test('republishes reader items onto the bus (lead channel + item key)', tags: ['serial'], () async {
final dir = Directory('${base.path}/${workspace.replaceAll('/', '-')}');
await dir.create(recursive: true);
File('${dir.path}/session-abc.jsonl').writeAsStringSync(
'${[_userLine('u1', 'hello'), _asstLine('a1', 'hi there')].map(jsonEncode).join('\n')}\n',
);
File('${dir.path}/session-abc.jsonl').writeAsStringSync('${[_userLine('u1', 'hello'), _asstLine('a1', 'hi there')].map(jsonEncode).join('\n')}\n');
final bus = MessageBus();
addTearDown(bus.dispose);
@@ -59,11 +57,7 @@ void main() {
// Subscribe before the publisher starts the reader's first poll.
final sub = bus.subscribe(publisher: ClaudeConversation.publisher, channel: ClaudeConversation.leadChannel).listen(received.add);
final reader = TranscriptReader(
workspace,
projectsBase: base.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: base.path, pollInterval: const Duration(milliseconds: 20));
final pub = TranscriptPublisher(messages: bus, reader: reader);
await Future<void>.delayed(const Duration(milliseconds: 200));
@@ -87,12 +81,7 @@ void main() {
'coder@team-x',
const SessionStatus(model: 'claude-opus-4-7', permissionMode: 'plan', contextTokens: 21000),
);
expect(full, {
'agentId': 'coder@team-x',
'model': 'claude-opus-4-7',
'permissionMode': 'plan',
'contextTokens': 21000,
});
expect(full, {'agentId': 'coder@team-x', 'model': 'claude-opus-4-7', 'permissionMode': 'plan', 'contextTokens': 21000});
// Absent fields are omitted (only agentId is always present).
expect(ClaudeConversation.memberStatusData('a', const SessionStatus()), {'agentId': 'a'});
});
+44 -210
View File
@@ -16,27 +16,18 @@ import 'package:test/test.dart';
/// Write [lines] to [file], each JSON-encoded, newline-terminated.
void writeLines(File file, List<Map<String, dynamic>> lines) {
file.writeAsStringSync(
'${lines.map(jsonEncode).join('\n')}\n',
mode: FileMode.writeOnly,
);
file.writeAsStringSync('${lines.map(jsonEncode).join('\n')}\n', mode: FileMode.writeOnly);
}
/// Append [lines] to [file].
void appendLines(File file, List<Map<String, dynamic>> lines) {
file.writeAsStringSync(
'${lines.map(jsonEncode).join('\n')}\n',
mode: FileMode.append,
);
file.writeAsStringSync('${lines.map(jsonEncode).join('\n')}\n', mode: FileMode.append);
}
/// Poll [ready] until it returns true or [timeout] elapses. Streaming
/// assertions use this instead of a fixed delay so they don't flake under
/// load (the reader polls on a timer and may parse off-isolate).
Future<void> pumpUntil(
bool Function() ready, {
Duration timeout = const Duration(seconds: 5),
}) async {
Future<void> pumpUntil(bool Function() ready, {Duration timeout = const Duration(seconds: 5)}) async {
final deadline = DateTime.now().add(timeout);
while (!ready() && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 10));
@@ -53,24 +44,12 @@ Map<String, dynamic> envelope({
String version = '2.1.143',
String timestamp = '2026-05-16T08:53:06.708Z',
}) {
return {
'type': type,
'uuid': uuid,
'parentUuid': parentUuid,
'isSidechain': isSidechain,
'version': version,
'timestamp': timestamp,
if (message != null) 'message': message,
};
return {'type': type, 'uuid': uuid, 'parentUuid': parentUuid, 'isSidechain': isSidechain, 'version': version, 'timestamp': timestamp, 'message': ?message};
}
/// Build a `user` envelope whose content is a plain string.
Map<String, dynamic> userText(String uuid, String text) {
return envelope(
type: 'user',
uuid: uuid,
message: {'role': 'user', 'content': text},
);
return envelope(type: 'user', uuid: uuid, message: {'role': 'user', 'content': text});
}
/// Build a `user` envelope whose content is an array of text parts.
@@ -81,43 +60,28 @@ Map<String, dynamic> userTextArray(String uuid, List<String> parts) {
message: {
'role': 'user',
'content': [
for (final p in parts) {'type': 'text', 'text': p}
for (final p in parts) {'type': 'text', 'text': p},
],
},
);
}
/// Build a `user` envelope containing a tool_result.
Map<String, dynamic> userToolResult(
String uuid, {
required String toolUseId,
required String content,
bool isError = false,
}) {
Map<String, dynamic> userToolResult(String uuid, {required String toolUseId, required String content, bool isError = false}) {
return envelope(
type: 'user',
uuid: uuid,
message: {
'role': 'user',
'content': [
{
'type': 'tool_result',
'tool_use_id': toolUseId,
'content': content,
'is_error': isError,
}
{'type': 'tool_result', 'tool_use_id': toolUseId, 'content': content, 'is_error': isError},
],
},
);
}
/// Build an `assistant` envelope with a tool_use content block.
Map<String, dynamic> assistantToolUse(
String uuid, {
required String id,
required String name,
required Map<String, dynamic> input,
}) {
Map<String, dynamic> assistantToolUse(String uuid, {required String id, required String name, required Map<String, dynamic> input}) {
return envelope(
type: 'assistant',
uuid: uuid,
@@ -169,10 +133,7 @@ Map<String, dynamic> skipRecord(String type, String uuid) {
/// Parses [lines] synchronously via the real [TranscriptReader.parseLine] and
/// returns every emitted [ConversationItem].
List<ConversationItem> parseAll(
List<Map<String, dynamic>> lines, {
void Function(String)? onWarn,
}) {
List<ConversationItem> parseAll(List<Map<String, dynamic>> lines, {void Function(String)? onWarn}) {
final reader = TranscriptReader('/fake', onWarn: onWarn);
return [for (final l in lines) ...reader.parseLine(jsonEncode(l))];
}
@@ -185,12 +146,7 @@ void main() {
// -------------------------------------------------------------------------
group('ConversationItem model', () {
test('UserMessage holds text and metadata', () {
final item = UserMessage(
uuid: 'u1',
timestamp: _epoch,
isSidechain: false,
text: 'hello',
);
final item = UserMessage(uuid: 'u1', timestamp: _epoch, isSidechain: false, text: 'hello');
expect(item.uuid, 'u1');
expect(item.text, 'hello');
expect(item.isSidechain, isFalse);
@@ -198,48 +154,24 @@ void main() {
});
test('AssistantTextMessage holds text', () {
final item = AssistantTextMessage(
uuid: 'a1',
timestamp: _epoch,
isSidechain: false,
text: 'world',
);
final item = AssistantTextMessage(uuid: 'a1', timestamp: _epoch, isSidechain: false, text: 'world');
expect(item.text, 'world');
});
test('AssistantThinkingMessage holds thinking', () {
final item = AssistantThinkingMessage(
uuid: 't1',
timestamp: _epoch,
isSidechain: false,
thinking: 'pondering',
);
final item = AssistantThinkingMessage(uuid: 't1', timestamp: _epoch, isSidechain: false, thinking: 'pondering');
expect(item.thinking, 'pondering');
});
test('AssistantToolUse holds name and input', () {
final item = AssistantToolUse(
uuid: 'tu1',
timestamp: _epoch,
isSidechain: false,
toolUseId: 'toolu_001',
name: 'Bash',
input: const {'command': 'ls'},
);
final item = AssistantToolUse(uuid: 'tu1', timestamp: _epoch, isSidechain: false, toolUseId: 'toolu_001', name: 'Bash', input: const {'command': 'ls'});
expect(item.name, 'Bash');
expect(item.input['command'], 'ls');
expect(item.toString(), contains('Bash'));
});
test('ToolResultMessage holds content and error flag', () {
final item = ToolResultMessage(
uuid: 'r1',
timestamp: _epoch,
isSidechain: false,
toolUseId: 'toolu_001',
content: 'ok',
isError: false,
);
final item = ToolResultMessage(uuid: 'r1', timestamp: _epoch, isSidechain: false, toolUseId: 'toolu_001', content: 'ok', isError: false);
expect(item.content, 'ok');
expect(item.isError, isFalse);
});
@@ -264,14 +196,7 @@ void main() {
// -------------------------------------------------------------------------
group('TranscriptReader — parse: skip types', () {
test('skips attachment, system, last-prompt, permission-mode, file-history-snapshot, queue-operation', () {
final skipTypes = [
'attachment',
'system',
'last-prompt',
'permission-mode',
'file-history-snapshot',
'queue-operation',
];
final skipTypes = ['attachment', 'system', 'last-prompt', 'permission-mode', 'file-history-snapshot', 'queue-operation'];
for (final t in skipTypes) {
final items = parseAll([skipRecord(t, 'skip-$t')]);
expect(items, isEmpty, reason: 'type "$t" should be skipped');
@@ -290,7 +215,7 @@ void main() {
test('array content with text parts emits joined UserMessage', () {
final items = parseAll([
userTextArray('u2', ['foo', 'bar'])
userTextArray('u2', ['foo', 'bar']),
]);
expect(items, hasLength(1));
final msg = items.first as UserMessage;
@@ -298,9 +223,7 @@ void main() {
});
test('array content with tool_result emits ToolResultMessage', () {
final items = parseAll([
userToolResult('u3', toolUseId: 'toolu_abc', content: '{"ok":true}'),
]);
final items = parseAll([userToolResult('u3', toolUseId: 'toolu_abc', content: '{"ok":true}')]);
expect(items, hasLength(1));
final res = items.first as ToolResultMessage;
expect(res.toolUseId, 'toolu_abc');
@@ -309,9 +232,7 @@ void main() {
});
test('tool_result with is_error=true sets isError', () {
final items = parseAll([
userToolResult('u4', toolUseId: 'toolu_xyz', content: 'boom', isError: true),
]);
final items = parseAll([userToolResult('u4', toolUseId: 'toolu_xyz', content: 'boom', isError: true)]);
final res = items.first as ToolResultMessage;
expect(res.isError, isTrue);
});
@@ -324,12 +245,7 @@ void main() {
'role': 'user',
'content': [
{'type': 'text', 'text': 'see result'},
{
'type': 'tool_result',
'tool_use_id': 'toolu_mixed',
'content': 'done',
'is_error': false,
},
{'type': 'tool_result', 'tool_use_id': 'toolu_mixed', 'content': 'done', 'is_error': false},
],
},
);
@@ -352,23 +268,13 @@ void main() {
});
test('isSidechain flag is preserved', () {
final raw = envelope(
type: 'user',
uuid: 'u7',
isSidechain: true,
message: {'role': 'user', 'content': 'side'},
);
final raw = envelope(type: 'user', uuid: 'u7', isSidechain: true, message: {'role': 'user', 'content': 'side'});
final items = parseAll([raw]);
expect(items.first.isSidechain, isTrue);
});
test('parentUuid is parsed; empty parentUuid normalises to null (T-263)', () {
final withParent = envelope(
type: 'user',
uuid: 'u8',
parentUuid: 'msg-A',
message: {'role': 'user', 'content': 'a sidechain prompt'},
);
final withParent = envelope(type: 'user', uuid: 'u8', parentUuid: 'msg-A', message: {'role': 'user', 'content': 'a sidechain prompt'});
final withoutParent = envelope(
type: 'user',
uuid: 'u9',
@@ -382,22 +288,14 @@ void main() {
test('stream-json parent_tool_use_id marks a sidechain message (T-338)', () {
// The stream-json wire tags sub-agent messages with parent_tool_use_id and
// NO isSidechain flag — we must treat it as a sidechain item anyway.
final raw = envelope(
type: 'user',
uuid: 'sp',
message: {'role': 'user', 'content': 'go explore the codebase'},
)..['parent_tool_use_id'] = 'toolu_task1';
final raw = envelope(type: 'user', uuid: 'sp', message: {'role': 'user', 'content': 'go explore the codebase'})..['parent_tool_use_id'] = 'toolu_task1';
final items = parseAll([raw]);
expect(items.first.isSidechain, isTrue);
expect(items.first.parentToolUseId, 'toolu_task1');
});
test('empty parent_tool_use_id normalises to null and stays main-thread (T-338)', () {
final raw = envelope(
type: 'user',
uuid: 'mt',
message: {'role': 'user', 'content': 'a normal turn'},
)..['parent_tool_use_id'] = '';
final raw = envelope(type: 'user', uuid: 'mt', message: {'role': 'user', 'content': 'a normal turn'})..['parent_tool_use_id'] = '';
final items = parseAll([raw]);
expect(items.first.isSidechain, isFalse);
expect(items.first.parentToolUseId, isNull);
@@ -436,12 +334,7 @@ void main() {
test('tool_use block emits AssistantToolUse', () {
final items = parseAll([
assistantToolUse(
'a4',
id: 'toolu_001',
name: 'Bash',
input: {'command': 'ls -la', 'description': 'List files'},
),
assistantToolUse('a4', id: 'toolu_001', name: 'Bash', input: {'command': 'ls -la', 'description': 'List files'}),
]);
expect(items, hasLength(1));
final tu = items.first as AssistantToolUse;
@@ -463,7 +356,7 @@ void main() {
'type': 'tool_use',
'id': 'toolu_002',
'name': 'Read',
'input': {'file_path': '/foo'}
'input': {'file_path': '/foo'},
},
],
},
@@ -519,21 +412,13 @@ void main() {
group('TranscriptReader — version drift-guard', () {
test('known versions (1.x, 2.x) produce no warning', () {
final warnings = <String>[];
parseAll(
[userText('u1', 'hello'), assistantText('a1', 'world')],
onWarn: warnings.add,
);
parseAll([userText('u1', 'hello'), assistantText('a1', 'world')], onWarn: warnings.add);
expect(warnings, isEmpty);
});
test('unknown major version warns and still emits parseable items', () {
final warnings = <String>[];
final raw = envelope(
type: 'user',
uuid: 'u1',
version: '99.0.1',
message: {'role': 'user', 'content': 'future format'},
);
final raw = envelope(type: 'user', uuid: 'u1', version: '99.0.1', message: {'role': 'user', 'content': 'future format'});
final items = parseAll([raw], onWarn: warnings.add);
expect(warnings, hasLength(1));
expect(warnings.first, contains('99'));
@@ -567,10 +452,7 @@ void main() {
});
test('malformed JSON line is skipped without throwing', () {
expect(
TranscriptReader('/fake').parseLine('not json!!!'),
isEmpty,
);
expect(TranscriptReader('/fake').parseLine('not json!!!'), isEmpty);
});
});
@@ -588,14 +470,9 @@ void main() {
'role': 'assistant',
'model': 'claude-opus-4-7',
'content': [
{'type': 'text', 'text': 'hi'}
{'type': 'text', 'text': 'hi'},
],
'usage': {
'input_tokens': 2,
'cache_read_input_tokens': 1000,
'cache_creation_input_tokens': 500,
'output_tokens': 99,
},
'usage': {'input_tokens': 2, 'cache_read_input_tokens': 1000, 'cache_creation_input_tokens': 500, 'output_tokens': 99},
},
}),
].join('\n');
@@ -672,16 +549,9 @@ void main() {
await projectDir.create(recursive: true);
final sessionFile = File('${projectDir.path}/session-abc.jsonl');
writeLines(sessionFile, [
userText('u1', 'first message'),
assistantText('a1', 'first reply'),
]);
writeLines(sessionFile, [userText('u1', 'first message'), assistantText('a1', 'first reply')]);
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -701,11 +571,7 @@ void main() {
writeLines(sessionFile, [userText('u1', 'initial')]);
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -740,11 +606,7 @@ void main() {
writeLines(sessionFile, [userText('u1', 'old session')]);
await sessionFile.setLastModified(DateTime.utc(2020));
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -778,11 +640,7 @@ void main() {
skipRecord('system', 'skip3'),
]);
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20));
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -802,18 +660,11 @@ void main() {
// A long pre-existing transcript — the kind that froze the UI when
// parsed in full on attach.
writeLines(sessionFile, [
for (var i = 0; i < 200; i++) assistantText('a$i', 'reply number $i'),
]);
writeLines(sessionFile, [for (var i = 0; i < 200; i++) assistantText('a$i', 'reply number $i')]);
// Cap the initial read well below the file size so only the last
// records are within the tail window.
final reader = TranscriptReader(
workspace,
projectsBase: tempBase.path,
pollInterval: const Duration(milliseconds: 20),
initialTailBytes: 256,
);
final reader = TranscriptReader(workspace, projectsBase: tempBase.path, pollInterval: const Duration(milliseconds: 20), initialTailBytes: 256);
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -826,10 +677,7 @@ void main() {
expect(collected, isNotEmpty);
expect(collected.length, lessThan(200));
// The most recent record is always intact at the end of the file.
expect(
collected.whereType<AssistantTextMessage>().last.text,
'reply number 199',
);
expect(collected.whereType<AssistantTextMessage>().last.text, 'reply number 199');
});
test('explicit file: tails that exact file, ignoring newest-discovery', () async {
@@ -840,12 +688,7 @@ void main() {
final target = File('${dir.path}/agent-abc.jsonl');
writeLines(target, [assistantText('a1', 'from the explicit file')]);
final reader = TranscriptReader(
'/unused/workspace',
projectsBase: '/nonexistent',
pollInterval: const Duration(milliseconds: 20),
file: target.path,
);
final reader = TranscriptReader('/unused/workspace', projectsBase: '/nonexistent', pollInterval: const Duration(milliseconds: 20), file: target.path);
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -864,12 +707,7 @@ void main() {
addTearDown(() => dir.delete(recursive: true));
final target = File('${dir.path}/agent-late.jsonl');
final reader = TranscriptReader(
'/unused',
projectsBase: '/nonexistent',
pollInterval: const Duration(milliseconds: 20),
file: target.path,
);
final reader = TranscriptReader('/unused', projectsBase: '/nonexistent', pollInterval: const Duration(milliseconds: 20), file: target.path);
final collected = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
@@ -901,7 +739,7 @@ void main() {
'role': 'assistant',
'model': 'claude-sonnet-4-6',
'content': [
{'type': 'text', 'text': 'hi'}
{'type': 'text', 'text': 'hi'},
],
'usage': {'input_tokens': 5, 'cache_read_input_tokens': 200, 'cache_creation_input_tokens': 0, 'output_tokens': 10},
},
@@ -955,18 +793,14 @@ List<Map<String, dynamic>> _snapshotFixture() {
'type': 'tool_use',
'id': toolUseId,
'name': 'Bash',
'input': {'command': 'ls -la'}
'input': {'command': 'ls -la'},
},
],
},
),
// tool result (user turn)
userToolResult(
'u-002',
toolUseId: toolUseId,
content: 'total 4\n-rw-r--r-- file.dart',
),
userToolResult('u-002', toolUseId: toolUseId, content: 'total 4\n-rw-r--r-- file.dart'),
// assistant text reply
assistantText('a-002', 'The directory contains file.dart.'),