refine the prompt/log UX: show commands, de-emphasize injects
Two spot-check fixes (T-178, T-179), both grounded in a boundary test of the stream-json wire (findings folded into the spike doc): - Harness-injected user messages (skill loads, slash-command expansions, system reminders) carry isSynthetic on the wire (isMeta in the transcript). They were rendering as blue "you" cards though the user never typed them; now UserMessage.injected flags them and the view shows a muted, collapsed "context" card instead. - Permission prompts now show the command/input being permitted (a capped, scrollable code block) so you can see what you approve. Instead of fully hiding a prompted tool-use, once resolved it collapses to a one-line summary with a green (approved) or red (denied) border; the session tracks per-tool_use_id outcome and the view colours it. The result is kept. Corrects an earlier wrong assumption: the Skill tool is auto-allowed (no permission prompt); the inject only appears once the Skill tool is actually invoked, which is why deny-captures missed it. T-178, T-179, D-78. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,7 +115,8 @@ void main() {
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items, {Set<String> hiddenToolUseIds = const {}}) async {
|
||||
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
|
||||
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}}) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() {
|
||||
@@ -125,7 +126,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, hiddenToolUseIds: hiddenToolUseIds)));
|
||||
await tester.pumpWidget(harness(f, ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes)));
|
||||
for (final it in items) {
|
||||
stream.add(it);
|
||||
}
|
||||
@@ -196,6 +197,29 @@ void main() {
|
||||
expect(find.text('result'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a resolved permission tool-use is shown collapsed, not hidden', (tester) async {
|
||||
await pumpWith(
|
||||
tester,
|
||||
[
|
||||
_tool('Write', {'file_path': '/tmp/x'}),
|
||||
_result('done')
|
||||
],
|
||||
hiddenToolUseIds: {'x1'},
|
||||
toolUseOutcomes: {'x1': true}, // approved
|
||||
);
|
||||
expect(find.text('Write'), findsOneWidget); // shown (resolved)
|
||||
expect(find.byType(ClideIcon), findsOneWidget); // collapsed caret
|
||||
});
|
||||
|
||||
testWidgets('an injected user message renders as a muted "context" card, not "you"', (tester) async {
|
||||
await pumpWith(tester, [
|
||||
UserMessage(uuid: 'i', timestamp: _t, isSidechain: false, text: 'Base directory for this skill: /x\n\n# pql', injected: true),
|
||||
_user('a real question'),
|
||||
]);
|
||||
expect(find.text('context'), findsOneWidget);
|
||||
expect(find.text('you'), findsOneWidget); // the real one
|
||||
});
|
||||
|
||||
testWidgets('a one-line tool result renders inline (no collapse caret)', (tester) async {
|
||||
await pumpWith(tester, [_result('hello-from-spike')]);
|
||||
expect(find.text('hello-from-spike'), findsOneWidget);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -94,6 +95,12 @@ void main() {
|
||||
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: Deny returns DenyTool with a message', (tester) async {
|
||||
ToolDecision? decision;
|
||||
await tester.pumpWidget(harness(
|
||||
|
||||
@@ -142,6 +142,36 @@ void main() {
|
||||
expect(echoed.single.text, 'do the thing');
|
||||
});
|
||||
|
||||
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'}
|
||||
],
|
||||
},
|
||||
}));
|
||||
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'}
|
||||
],
|
||||
},
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(items.whereType<UserMessage>().single.injected, isFalse);
|
||||
});
|
||||
|
||||
test('a can_use_tool control_request becomes a pending prompt (not a conversation item)', () async {
|
||||
final emitted = <ToolPrompt?>[];
|
||||
session.pendingPromptStream.listen(emitted.add);
|
||||
@@ -223,6 +253,20 @@ void main() {
|
||||
expect((follow['message'] as Map)['content'], 'use docs/ instead');
|
||||
});
|
||||
|
||||
test('resolvePrompt records the tool outcome — allow', () async {
|
||||
proc.emit(canUseTool('o1'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
session.resolvePrompt('o1', AllowTool(const {}));
|
||||
expect(session.toolUseOutcomes['toolu_1'], isTrue);
|
||||
});
|
||||
|
||||
test('resolvePrompt records the tool outcome — deny', () async {
|
||||
proc.emit(canUseTool('o2'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
session.resolvePrompt('o2', const DenyTool('no'));
|
||||
expect(session.toolUseOutcomes['toolu_1'], isFalse);
|
||||
});
|
||||
|
||||
test('resolvePrompt(deny) writes a deny decision with a message', () async {
|
||||
proc.emit(canUseTool('req-3'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
Reference in New Issue
Block a user