Show agent tool steps in chat responses

This commit is contained in:
Hamza Ayed
2026-10-03 12:00:45 +03:00
parent 4345e43e29
commit e77f9fa672
3 changed files with 111 additions and 34 deletions
@@ -12,10 +12,80 @@ import '../../features/chat/presentation/cubit/chat_state.dart';
const _uuid = Uuid();
class AgentRunResult {
const AgentRunResult({required this.answer, this.proposal});
const AgentRunResult({
required this.answer,
this.proposal,
this.steps = const [],
});
final String answer;
final FileChangeProposal? proposal;
final List<AgentToolStep> steps;
factory AgentRunResult.fromApiData(Map<String, dynamic> data) {
final steps = (data['steps'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(AgentToolStep.fromJson)
.toList(growable: false);
final tool = data['tool'] as String?;
final skill = data['skill'] as String?;
final files = (data['files'] as List<dynamic>? ?? const []).cast<String>();
final details = <String>[
if (steps.isNotEmpty)
'خطوات الوكيل:\n${steps.indexed.map((entry) => '${entry.$1 + 1}. ${entry.$2.label} — ${entry.$2.statusLabel}.').join('\n')}'
else if (tool != null)
'الأداة المستخدمة: ${AgentToolStep.labelFor(tool)}.',
if (skill != null)
'المهارة المستخدمة: ${switch (skill) {
'code_explain' => 'شرح الكود',
'code_review' => 'مراجعة الكود',
'test_plan' => 'خطة اختبارات',
_ => skill,
}}.',
if (files.isNotEmpty) 'الملفات المقروءة: ${files.join('، ')}.',
];
final proposalData = data['proposal'];
final proposal =
proposalData is Map<String, dynamic>
? FileChangeProposal.fromJson(proposalData)
: null;
final result = data['result']?.toString() ?? '';
return AgentRunResult(
answer: details.isEmpty ? result : '$result\n\n${details.join('\n')}',
proposal: proposal,
steps: steps,
);
}
}
class AgentToolStep {
const AgentToolStep({required this.tool, required this.status});
final String tool;
final String status;
factory AgentToolStep.fromJson(Map<String, dynamic> json) => AgentToolStep(
tool: json['tool']?.toString() ?? 'unknown',
status: json['status']?.toString() ?? 'unknown',
);
String get label => labelFor(tool);
String get statusLabel => switch (status) {
'completed' => 'اكتملت',
'failed' => 'فشلت',
'running' => 'قيد التنفيذ',
'pending' => 'بانتظار التنفيذ',
_ => status,
};
static String labelFor(String tool) => switch (tool) {
'calculator' => 'الآلة الحاسبة المحلية',
'search_workspace' => 'البحث في مساحة العمل',
'search_knowledge' => 'البحث في فهرس المعرفة',
'propose_file_change' => 'اقتراح تعديل ملف',
'local-llm' => 'النموذج المحلي',
_ => tool,
};
}
class ApiRepository {
@@ -488,37 +558,7 @@ class ApiRepository {
if (streamError != null) throw Exception(streamError);
final data = resultData;
if (data == null) throw Exception('انقطع اتصال البث قبل اكتمال الوكيل.');
final result = data['result']?.toString() ?? '';
final files =
(data['files'] as List<dynamic>? ?? const []).cast<String>();
final tool = data['tool'] as String?;
final skill = data['skill'] as String?;
final toolLabels = {
'calculator': 'الآلة الحاسبة المحلية',
'search_workspace': 'البحث في مساحة العمل (قراءة فقط)',
'local-llm': 'النموذج المحلي دون أداة',
};
final usedTool = tool == null ? null : toolLabels[tool] ?? tool;
final details = <String>[
if (usedTool != null) 'الأداة المستخدمة: $usedTool.',
if (skill != null)
'المهارة المستخدمة: ${switch (skill) {
'code_explain' => 'شرح الكود',
'code_review' => 'مراجعة الكود',
'test_plan' => 'خطة اختبارات',
_ => skill,
}}.',
if (files.isNotEmpty) 'الملفات المقروءة: ${files.join('، ')}.',
];
final proposalData = data['proposal'];
final proposal =
proposalData is Map<String, dynamic>
? FileChangeProposal.fromJson(proposalData)
: null;
return AgentRunResult(
answer: details.isEmpty ? result : '$result\n\n${details.join('\n')}',
proposal: proposal,
);
return AgentRunResult.fromApiData(data);
} finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
client.close();
@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/network/api_repository.dart';
void main() {
group('AgentRunResult.fromApiData', () {
test('preserves and labels every completed tool step', () {
final result = AgentRunResult.fromApiData({
'result': 'الإجابة النهائية.',
'tool': 'search_knowledge',
'steps': [
{'tool': 'search_workspace', 'status': 'completed'},
{'tool': 'search_knowledge', 'status': 'completed'},
],
'files': ['lib/main.dart'],
});
expect(result.steps, hasLength(2));
expect(result.steps.first.label, 'البحث في مساحة العمل');
expect(result.steps.last.label, 'البحث في فهرس المعرفة');
expect(result.steps.last.statusLabel, 'اكتملت');
expect(result.answer, contains('1. البحث في مساحة العمل — اكتملت.'));
expect(result.answer, contains('2. البحث في فهرس المعرفة — اكتملت.'));
expect(result.answer, contains('الملفات المقروءة: lib/main.dart.'));
});
test('uses the legacy tool field when the API returns no steps', () {
final result = AgentRunResult.fromApiData({
'result': '391',
'tool': 'calculator',
});
expect(result.steps, isEmpty);
expect(result.answer, '391\n\nالأداة المستخدمة: الآلة الحاسبة المحلية.');
});
});
}