Files
clide/test/builtin/claude/prompt_card_test.dart
T
jpmschweitzerandClaude Opus 4.7 de264e7141 flesh out the Claude prompt UX: options, stepper, collapse
Builds on the in-composer prompt surface (D-78):

- Permission prompts (T-175): Allow / Allow-and-don't-ask-again / Deny.
  "Don't ask again" appears only when the request carries a
  permission_suggestion and echoes it back as updatedPermissions. An
  optional note rides Deny as the message, or Allow as a follow-up user
  message (the protocol has no allow-with-message).
- AskUserQuestion picker (T-176): a single question renders bare; 2-4
  questions step one at a time (nav shows "N · Header", ✓ when answered)
  then a review/confirm screen. Each question offers an "Other" free-text
  choice and a per-choice note; multi-select joins labels. A "chat
  instead" escape denies the prompt so the user can type freely. On
  submit the answer is echoed into the log, since the card is ephemeral.
- Collapsed tool cards (T-177): multi-line tool_use / tool_result start
  collapsed behind a one-line summary; one-line output renders inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:30:50 +02:00

275 lines
9.6 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: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: 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>());
});
}