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:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'});
|
||||
});
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
Reference in New Issue
Block a user