From 3a539f92d935de1a7baf155ec79fdc303ac572f4 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 4 Nov 2026 19:10:31 +0800 Subject: [PATCH] Show what the agent asked and what the user answered An answered AskUserQuestion keeps the answers on its step and shows each question with the reply under it, standing out of a closed turn. Co-Authored-By: BaoCode --- lib/chat/step_folds.dart | 12 ++- lib/chat/widgets/tool_call_row.dart | 85 ++++++++++++++++++- .../claude_code/claude_code_translator.dart | 32 +++++++ test/chat/question_step_test.dart | 67 +++++++++++++++ test/chat/step_folds_test.dart | 34 ++++++++ test/kernel_test.dart | 34 ++++++++ 6 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 test/chat/question_step_test.dart diff --git a/lib/chat/step_folds.dart b/lib/chat/step_folds.dart index 426ee28b8..90303697f 100644 --- a/lib/chat/step_folds.dart +++ b/lib/chat/step_folds.dart @@ -242,9 +242,9 @@ class StepFolds { /// Whether [item] is a quick step, folded with others like it: a thought, /// or a tool call that only looks around or runs something. Not an edit -/// (shown on its own), a subagent, a message to another agent, a goal -/// proposed, pictures made, a refused call, nor a command left running in -/// the background. +/// (shown on its own), a subagent, a message to another agent, a question +/// to the user (with what they answered), a goal proposed, pictures made, +/// a refused call, nor a command left running in the background. bool isQuickStep(ChatItem item) => switch (item) { ThinkingItem() => true, ToolCallItem(:final kind, :final status) => @@ -253,6 +253,7 @@ bool isQuickStep(ChatItem item) => switch (item) { ToolKind.edit || ToolKind.agent || ToolKind.message || + ToolKind.question || ToolKind.goal || ToolKind.image => false, _ => true, @@ -262,10 +263,13 @@ bool isQuickStep(ChatItem item) => switch (item) { }; /// Whether [item] shows though its turn's work is folded: pictures an -/// image model made, which are what the user asked for. +/// image model made, which are what the user asked for; questions the user +/// answered, and what they said. bool standsOutOfWork(ChatItem item) => switch (item) { ToolCallItem(kind: ToolKind.image, :final status, :final images) => status == ToolStatus.succeeded && images.isNotEmpty, + ToolCallItem(kind: ToolKind.question, :final status) => + status == ToolStatus.succeeded, _ => false, }; diff --git a/lib/chat/widgets/tool_call_row.dart b/lib/chat/widgets/tool_call_row.dart index d56a84f2c..ec5e8e841 100644 --- a/lib/chat/widgets/tool_call_row.dart +++ b/lib/chat/widgets/tool_call_row.dart @@ -82,7 +82,35 @@ class ToolCallRow extends StatelessWidget { _ => null, }; - bool get _opens => results.isNotEmpty || _shown != null || images.isNotEmpty; + bool get _opens => + _answered == null && + (results.isNotEmpty || _shown != null || images.isNotEmpty); + + /// The questions asked and what the user answered, once they have + /// (AskUserQuestion): shown under it as they are, not folded in what it + /// returned. + List<({String question, String answer})>? get _answered { + if (kind != ToolKind.question || status != ToolStatus.succeeded) { + return null; + } + final answers = input['answers']; + if (answers is! Map || answers.isEmpty) return null; + final asked = [ + for (final question in input['questions'] as List? ?? const []) + if (question is Map && question['question'] is String) + question['question'] as String, + ]; + final pairs = [ + for (final question in asked) + if (answers[question] case final answer?) + (question: question, answer: '$answer'), + // Answers to questions not in the list, as they came. + for (final MapEntry(:key, :value) in answers.entries) + if (!asked.contains(key) && value != null) + (question: '$key', answer: '$value'), + ]; + return pairs.isEmpty ? null : pairs; + } /// The list written or read, each item marked by its status (its /// output says it as text). @@ -171,7 +199,9 @@ class ToolCallRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ header, - if (_todos case final todos? when expanded) + if (_answered case final answered?) + StepBody(child: _QuestionAnswers(answered: answered)) + else if (_todos case final todos? when expanded) StepBody(child: _TodoList(todos: todos)) else if (expanded && (results.isNotEmpty || _shown != null)) StepBody( @@ -220,6 +250,57 @@ class ToolCallRow extends StatelessWidget { } } +/// Each question asked, and under it what the user answered. +class _QuestionAnswers extends StatelessWidget { + const _QuestionAnswers({required this.answered}); + + final List<({String question, String answer})> answered; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (i, (:question, :answer)) in answered.indexed) ...[ + if (i > 0) const SizedBox(height: 8), + Text( + question, + style: TextStyle( + color: AppColors.textMuted, + fontSize: 12.5, + height: 1.5, + ), + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 3, right: 6), + child: Icon( + Icons.subdirectory_arrow_right_rounded, + size: 14, + color: AppColors.textFaint, + ), + ), + Expanded( + child: Text( + answer, + style: TextStyle( + color: AppColors.text, + fontSize: 13, + height: 1.5, + ), + ), + ), + ], + ), + ], + ], + ); + } +} + /// A todo list, each item after its mark: done ones faint and struck /// through, as the todo panel had them. class _TodoList extends StatelessWidget { diff --git a/lib/kernel/claude_code/claude_code_translator.dart b/lib/kernel/claude_code/claude_code_translator.dart index d1bcd77cf..849775100 100644 --- a/lib/kernel/claude_code/claude_code_translator.dart +++ b/lib/kernel/claude_code/claude_code_translator.dart @@ -122,6 +122,10 @@ class ClaudeTranslator { /// The subjects of the todos created (TaskCreate), by their ids. final Map _taskSubjects = {}; + /// What the user answered to questions asked (AskUserQuestion), by the + /// call's id: each answer by its question's text. + final Map> _answers = {}; + /// The session's goal (`/goal`), as last reported. KernelGoal? _goal; @@ -1116,6 +1120,8 @@ class ClaudeTranslator { status: status, output: output, ), + // The questions, and once answered, the answers (see [_answers]), + // for the history to show what was asked and said. 'AskUserQuestion' => ToolCallItem( kind: ToolKind.question, target: [ @@ -1124,6 +1130,8 @@ class ClaudeTranslator { ].join(' · '), status: status, output: output, + tool: name, + input: {'questions': input['questions'], 'answers': ?_answers[tool.id]}, ), // "Said …": what it says, on a line; opened, all of it (what sending // it returned only if it failed). @@ -1314,6 +1322,8 @@ class ClaudeTranslator { parent: tool.parent, ); return; + case 'AskUserQuestion' when outcome == _Outcome.done: + _answers[id] = questionAnswers(structured?['answers'], text); case 'ExitPlanMode': if (_string(tool.input['plan']) != null) return; case 'TaskCreate': @@ -2030,3 +2040,25 @@ class _Agent { AgentItem item; final Map children = {}; } + +/// What the user answered to questions asked (AskUserQuestion), each +/// answer by its question's text: from the call's result ([structured]'s +/// `answers`, the CLI's and BaoCode's own core's alike), else read from +/// what it said to the model (`"question"="answer", …`). +Map questionAnswers(Object? structured, String text) { + if (structured is Map && structured.isNotEmpty) { + return { + for (final MapEntry(:key, :value) in structured.entries) + if (key is String && value != null) key: '$value', + }; + } + return { + for (final match in _answered.allMatches(text)) + _unquote(match[1]!): _unquote(match[2]!), + }; +} + +final _answered = RegExp(r'"((?:[^"\\]|\\.)*)"="((?:[^"\\]|\\.)*)"'); + +String _unquote(String text) => + text.replaceAllMapped(RegExp(r'\\(.)'), (match) => match[1]!); diff --git a/test/chat/question_step_test.dart b/test/chat/question_step_test.dart new file mode 100644 index 000000000..c1893baee --- /dev/null +++ b/test/chat/question_step_test.dart @@ -0,0 +1,67 @@ +import 'package:baocode/chat/chat_models.dart'; +import 'package:baocode/chat/widgets/step_header.dart'; +import 'package:baocode/chat/widgets/tool_call_row.dart'; +import 'package:baocode/theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future pump(WidgetTester tester, ToolCallRow row) => tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: row), + ), + ); + + testWidgets('a question answered shows each question and what the user ' + 'said, without being opened; its raw result is not offered', ( + tester, + ) async { + await pump( + tester, + const ToolCallRow( + kind: ToolKind.question, + target: 'Platform · Size', + tool: 'AskUserQuestion', + output: 'User has answered your questions: …', + input: { + 'questions': [ + {'question': 'Which platform?', 'header': 'Platform'}, + {'question': 'What size?', 'header': 'Size'}, + ], + 'answers': {'Which platform?': 'Flutter', 'What size?': 'Large'}, + }, + ), + ); + for (final text in ['Which platform?', 'Flutter', 'What size?', 'Large']) { + expect(find.text(text), findsOneWidget); + } + expect(find.text('User has answered your questions: …'), findsNothing); + expect(tester.widget(find.byType(StepHeader)).onToggle, isNull); + }); + + testWidgets('not answered (refused, failed), it is the step it was', ( + tester, + ) async { + await pump( + tester, + ToolCallRow( + kind: ToolKind.question, + target: 'Platform', + status: ToolStatus.failed, + output: 'The user dismissed the questions.', + onToggle: () {}, + input: const { + 'questions': [ + {'question': 'Which platform?'}, + ], + }, + ), + ); + expect(find.text('Which platform?'), findsNothing); + expect( + tester.widget(find.byType(StepHeader)).onToggle, + isNotNull, + ); + }); +} diff --git a/test/chat/step_folds_test.dart b/test/chat/step_folds_test.dart index 4ca845e42..3622c9290 100644 --- a/test/chat/step_folds_test.dart +++ b/test/chat/step_folds_test.dart @@ -236,6 +236,40 @@ void main() { }); }); + test('a question the user answered breaks a run, and shows out of a ' + 'closed turn, with what they said', () { + const asked = ToolCallItem( + kind: ToolKind.question, + target: 'Color', + input: { + 'questions': [ + {'question': 'Red or blue?', 'header': 'Color'}, + ], + 'answers': {'Red or blue?': 'Red'}, + }, + ); + final turn = [ + _prompt(), + _read, + _grep, + asked, + _read, + _grep, + _answer, + ]; + expect(_folds(turn), ['work 1-6', 'steps 1-3', 'steps 4-6']); + final folds = _fold(turn); + expect(folds.rowAt(3, asked, (_) => false).item, isTrue); + // Still being asked: in the panel over the composer, not out of it. + final asking = [...turn] + ..[3] = const ToolCallItem( + kind: ToolKind.question, + target: 'Color', + status: ToolStatus.running, + ); + expect(_fold(asking).rowAt(3, asking[3], (_) => false).item, isFalse); + }); + group('thoughts one after another', () { const empty = ThinkingItem(text: '', tokens: 0, seconds: 12); const titled = ThinkingItem( diff --git a/test/kernel_test.dart b/test/kernel_test.dart index fa98e26df..5649b5181 100644 --- a/test/kernel_test.dart +++ b/test/kernel_test.dart @@ -395,6 +395,40 @@ void main() { expect(toolVerb(ToolKind.question), 'Asked'); }); + test('a question answered keeps what was asked and what the user said, ' + 'from the result or, without one, its words', () { + final transcript = Transcript(); + var seq = 0; + final translator = ClaudeTranslator( + emit: transcript.apply, + nextSeq: () => ++seq, + ); + for (final message in recorded('question')) { + translator.translate(message); + } + final asked = [ + for (var i = 0; i < transcript.length; i++) transcript.itemAt(i), + ].whereType().single; + expect(asked.kind, ToolKind.question); + expect(asked.status, ToolStatus.succeeded); + expect(asked.input['answers'], {'Do you prefer red or blue?': 'Red'}); + expect( + (asked.input['questions'] as List).single, + containsPair('question', 'Do you prefer red or blue?'), + ); + + // Only in its words (BaoCode's own core's, a result not kept). + expect( + questionAnswers( + null, + 'The user answered: "Which \\"one\\"?"="A, B", "Size?"="Large". ' + 'Carry on with these answers.', + ), + {'Which "one"?': 'A, B', 'Size?': 'Large'}, + ); + expect(questionAnswers({'Q?': 'Yes'}, 'ignored'), {'Q?': 'Yes'}); + }); + test('a message sent to an agent reads on its line, in full opened', () { final transcript = Transcript(); var seq = 0;