Files
clide/test/builtin/claude/prompt_card_test.dart
T
jpmschweitzerandClaude c5e58733a2 render typed tool cards and live session status from stream-json
The conversation pane now exploits the structured stream instead of
dumping tool input as JSON. ConversationController indexes tool_use by id
so a tool_result pairs back to its call and renders the Edit/Write diff or
is_error failure in place; per-tool bodies (Bash command+output, Read/Grep
file/query) reuse the shared renderers factored out of the permission
card. SessionStatus gains cost + contextWindow + rate-limit, read straight
off the init/result/rate_limit_event events, so the in-pane status line
reflects live state without the config probe.

Partial-message streaming is wired behind --include-partial-messages but
its event shape is unverified against the live binary and degrades to a
no-op if it differs — see T-184.

T-168.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-30 13:44:19 +02:00

400 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
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,
);
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'},
],
},
],
},
);
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': ''},
],
},
],
},
);
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
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.pump();
expect(find.text('permission · Write'), findsOneWidget);
expect(find.text('Allow'), findsOneWidget);
expect(find.text('Deny'), findsOneWidget);
await tester.tap(find.text('Allow'));
await tester.pump();
expect(id, 'req-1');
expect(decision, isA<AllowTool>());
expect((decision as AllowTool).updatedInput['content'], 'banana');
});
testWidgets('permission card shows the command being permitted', (tester) async {
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
await tester.pump();
expect(find.byType(ClideCodeBlock), findsOneWidget);
});
testWidgets('permission card: Bash renders the command as a shell code block, not JSON', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-b',
toolName: 'Bash',
displayName: 'Bash',
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.pump();
final block = tester.widget<ClideCodeBlock>(find.byType(ClideCodeBlock));
expect(block.language, 'bash');
expect(block.source, 'cat ~/.claude/ide/97632.lock');
// The raw `description` key shouldn't bleed into the body — it's already
// shown above as the prompt's description line.
expect(find.textContaining('"description"'), findsNothing);
});
testWidgets('permission card: Bash shows background/timeout footer when set', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-b2',
toolName: 'Bash',
displayName: 'Bash',
description: 'long task',
input: {'command': 'sleep 30', 'run_in_background': true, 'timeout': 60000},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pump();
expect(find.text('background · timeout 60000ms'), findsOneWidget);
});
testWidgets('permission card: Write suppresses the description line when it duplicates file_path', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-w',
toolName: 'Write',
displayName: 'Write',
// Claude often sends the path itself as the description for Write —
// the body already shows it via _pathLine, so the line above should
// be suppressed to avoid printing the same path twice.
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.pump();
// The path should appear exactly once (in _pathLine, inside the body).
expect(find.text('/tmp/clide-ux-test.txt'), findsOneWidget);
});
testWidgets('permission card: Write keeps the description line when it adds info', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-w2',
toolName: 'Write',
displayName: 'Write',
description: 'banana.txt',
input: {'file_path': '/tmp/banana.txt', 'content': 'banana'},
);
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: prompt, onResolve: (_, __) {})));
await tester.pump();
expect(find.text('banana.txt'), findsOneWidget);
expect(find.text('/tmp/banana.txt'), findsOneWidget);
});
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: (_, __) {})));
await tester.pump();
final block = tester.widget<ClideCodeBlock>(find.byType(ClideCodeBlock));
expect(block.language, 'json');
expect(block.source, contains('"foo"'));
});
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.pump();
await tester.tap(find.text('Deny'));
await tester.pump();
expect(decision, isA<DenyTool>());
expect((decision as DenyTool).message, isNotEmpty);
});
testWidgets('permission: no "don\'t ask again" button without a suggestion', (tester) async {
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, __) {})));
await tester.pump();
expect(find.text("Allow & don't ask again"), findsNothing);
});
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'}
];
await tester.pumpWidget(harness(
f,
ToolPromptCard(prompt: permissionPrompt(suggestions: sugg), onResolve: (_, d) => decision = d),
));
await tester.pump();
expect(find.text("Allow & don't ask again"), findsOneWidget);
await tester.tap(find.text("Allow & don't ask again"));
await tester.pump();
expect((decision as AllowTool).updatedPermissions, hasLength(1));
});
testWidgets('permission: a typed note rides Deny as the message', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.enterText(find.byType(EditableText), 'write it under docs/ instead');
await tester.pump();
await tester.tap(find.text('Deny'));
await tester.pump();
expect((decision as DenyTool).message, 'write it under docs/ instead');
});
testWidgets('permission: a typed note rides Allow as a follow-up note', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: permissionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.enterText(find.byType(EditableText), 'fyi: sandbox only');
await tester.pump();
await tester.tap(find.text('Allow'));
await tester.pump();
expect((decision as AllowTool).followUpNote, 'fyi: sandbox only');
});
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.pump();
expect(find.text('Do you prefer cats or dogs?'), findsOneWidget);
// Submit before choosing → no-op (disabled).
await tester.tap(find.text('Submit'));
await tester.pump();
expect(decision, isNull);
await tester.tap(find.textContaining('Dogs'));
await tester.pump();
await tester.tap(find.text('Submit'));
await tester.pump();
expect(decision, isA<AllowTool>());
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
expect(answers['Do you prefer cats or dogs?'], 'Dogs');
});
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.pump();
await tester.tap(find.textContaining('Cats'));
await tester.pump();
await tester.tap(find.textContaining('Dogs'));
await tester.pump();
await tester.tap(find.text('Submit'));
await tester.pump();
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
expect(answers['Do you prefer cats or dogs?'], 'Cats, Dogs');
});
testWidgets('question card: "Other…" free-text becomes the answer value', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.tap(find.text('○ Other…'));
await tester.pump();
// Two fields now: [0] = the Other free-text, [1] = the per-choice note.
await tester.enterText(find.byType(EditableText).first, 'Kiwi');
await tester.pump();
await tester.tap(find.text('Submit'));
await tester.pump();
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
expect(answers['Do you prefer cats or dogs?'], 'Kiwi'); // not the word "Other"
});
testWidgets('question card: a per-choice note is appended to the label', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.tap(find.textContaining('Dogs'));
await tester.pump();
await tester.enterText(find.byType(EditableText), 'only big ones'); // the note field
await tester.pump();
await tester.tap(find.text('Submit'));
await tester.pump();
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
expect(answers['Do you prefer cats or dogs?'], 'Dogs — only big ones');
});
testWidgets('multi-question: steps through to review, then submits both answers', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
// Stepper nav shows numbered headers; only question 1 is visible.
expect(find.textContaining('1 · Pet'), findsOneWidget);
expect(find.text('Which pet?'), findsOneWidget);
expect(find.text('How eaten?'), findsNothing);
await tester.tap(find.textContaining('Dogs'));
await tester.pump();
await tester.tap(find.text('Next '));
await tester.pump();
expect(find.text('How eaten?'), findsOneWidget);
await tester.tap(find.textContaining('Fresh'));
await tester.pump();
await tester.tap(find.text('Review '));
await tester.pump();
// Review screen lists both answers; submit delivers them.
expect(find.text('Review your answers'), findsOneWidget);
await tester.tap(find.text('Submit answers'));
await tester.pump();
final answers = (decision as AllowTool).updatedInput['answers'] as Map;
expect(answers['Which pet?'], 'Dogs');
expect(answers['How eaten?'], 'Fresh');
});
testWidgets('question card: "chat instead" denies the prompt', (tester) async {
ToolDecision? decision;
await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, d) => decision = d)));
await tester.pump();
await tester.tap(find.text('chat instead'));
await tester.pump();
expect(decision, isA<DenyTool>());
});
// -- shared tool-input rendering helpers (T-168) ----------------------------
group('permission card: Edit shows before/after diff via shared helper', () {
testWidgets('Edit card has two code blocks (before/after)', (tester) async {
const prompt = ToolPrompt(
promptId: 'req-e',
toolName: 'Edit',
displayName: 'Edit',
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.pump();
// Two code blocks: before + after.
expect(find.byType(ClideCodeBlock), findsNWidgets(2));
expect(find.text('— before'), findsOneWidget);
expect(find.text('+ after'), findsOneWidget);
});
});
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: (_, __) {})));
await tester.pump();
expect(find.text('/docs/readme.md'), findsOneWidget);
// No code blocks — just a text label for Read.
expect(find.byType(ClideCodeBlock), findsNothing);
});
});
}