fold the Deny & simplify denial instead of a red error (T-340)

A user-initiated denial (Deny & simplify) came back as an isError
tool_result and rendered as a prominent expanded-red "Bash · error"
block — pure noise, since the user chose it. It now folds to a muted,
collapsed "denied" card.

Built as a reusable filter rather than string-matching the note: DenyTool
carries a `quiet` flag, the session collects quiet denials' tool_use_ids,
and ConversationView renders any error whose id is in that set folded +
muted. Genuine tool failures (ids not in the set) keep the expanded-red
treatment. Adding future "expected error" cases is just adding ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:29:30 +02:00
co-authored by Claude Opus 4.8
parent a948bc8a1f
commit 3b23da052e
8 changed files with 117 additions and 12 deletions
@@ -149,7 +149,10 @@ void main() {
tearDown(() => f.dispose());
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}, FoldLevel foldLevel = FoldLevel.none}) async {
{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(() {
@@ -166,7 +169,12 @@ void main() {
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel),
child: ConversationView(
controller: c,
hiddenToolUseIds: hiddenToolUseIds,
toolUseOutcomes: toolUseOutcomes,
quietErrorToolUseIds: quietErrorToolUseIds,
foldLevel: foldLevel),
),
),
));
@@ -697,6 +705,33 @@ void main() {
expect(find.text('Bash · error'), findsOneWidget);
});
testWidgets('a quiet (user-initiated) denial folds to a muted "denied" card (T-340)', (tester) async {
await pumpWith(
tester,
[
_tool('Bash', {'command': 'rm -rf x'}),
_result('Denied — too complex\nretry simpler', isError: true),
],
quietErrorToolUseIds: {'x1'}, // the toolUseId shared by _tool/_result
);
// Labelled "denied", not the loud "error", and folded by default so the
// body (its second line) is hidden behind a collapsed summary.
expect(find.text('Bash · denied'), findsOneWidget);
expect(find.text('Bash · error'), findsNothing);
expect(find.textContaining('retry simpler'), findsNothing);
});
testWidgets('a genuine error (not in the quiet set) stays expanded red (T-340)', (tester) async {
await pumpWith(tester, [
_tool('Bash', {'command': 'rm -rf x'}),
_result('Denied — too complex\nretry simpler', isError: true),
]);
// Same content, but not flagged quiet → the normal expanded error path.
expect(find.text('Bash · error'), findsOneWidget);
expect(find.text('Bash · denied'), findsNothing);
expect(find.textContaining('retry simpler'), findsOneWidget);
});
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, [