29 files changed
+1509
-46
lib/core/storage/entities.dart
+15
@@ -2,6 +2,7 @@ import 'dart:convert';
2
import 'package:objectbox/objectbox.dart';
3
import '../models/enums.dart';
4
import '../../features/chat/data/models/message.dart';
5
+import '../../features/chat/data/tools/tool_event.dart';
6
import '../../features/conversations/data/models/conversation.dart';
7
import '../../features/personas/data/models/persona.dart';
8
import '../../features/servers/data/models/server.dart';
@@ -293,6 +294,8 @@ class MessageEntity {
294
String? toolCallsJson;
295
String? toolCallId;
296
bool isProcessing;
297
+ String? toolSessionId;
298
+ String? toolEventsJson;
299
300
MessageEntity({
301
this.internalId = 0,
@@ -311,6 +314,8 @@ class MessageEntity {
314
this.toolCallsJson,
315
this.toolCallId,
316
this.isProcessing = false,
317
+ this.toolSessionId,
318
+ this.toolEventsJson,
319
});
320
321
factory MessageEntity.fromDomain(Message message) {
@@ -334,6 +339,10 @@ class MessageEntity {
339
: null,
340
toolCallId: message.toolCallId,
341
isProcessing: message.isProcessing,
342
+ toolSessionId: message.toolSessionId,
343
+ toolEventsJson: message.toolEvents != null
344
+ ? jsonEncode(message.toolEvents!.map((e) => e.toMap()).toList())
345
+ : null,
346
);
347
}
348
@@ -360,6 +369,12 @@ class MessageEntity {
369
: null,
370
toolCallId: toolCallId,
371
isProcessing: isProcessing,
372
+ toolSessionId: toolSessionId,
373
+ toolEvents: toolEventsJson != null
374
+ ? (jsonDecode(toolEventsJson!) as List)
375
+ .map((e) => ToolEvent.fromMap(Map<String, dynamic>.from(e)))
376
+ .toList()
377
+ : null,
378
);
379
}
380
}
lib/features/chat/data/chat_service.dart
+51
@@ -10,6 +10,10 @@ import '../../../core/models/enums.dart';
10
import '../../../core/logger/app_logger.dart';
11
import '../../on_device/data/on_device_gemma_service.dart';
12
import '../../on_device/data/on_device_chat_service.dart';
13
+import 'tools/adapters/tool_transport_adapter.dart';
14
+import 'tools/adapters/openai_tool_adapter.dart';
15
+import 'tools/adapters/openrouter_tool_adapter.dart';
16
+import 'tools/adapters/ollama_tool_adapter.dart';
17
18
abstract class ChatService {
19
Stream<ChatResponse> sendMessage({
@@ -449,6 +453,7 @@ class OpenAICompatibleChatService implements ChatService {
453
String? previousResponseId,
454
}) async* {
455
_cancelToken = CancelToken();
456
+ final toolAdapter = OllamaToolAdapter();
457
458
final apiMessages = messages
459
.map((m) => {'role': _roleToString(m.role), 'content': m.content})
@@ -533,11 +538,21 @@ class OpenAICompatibleChatService implements ChatService {
538
if (data == '[DONE]') {
539
_timeoutTimer?.cancel();
540
Log.debug('OpenAICompatible: Received [DONE]');
541
+ for (final call in toolAdapter.takeCompletedCalls()) {
542
+ yield ChatResponse(
543
+ type: ChatResponseType.toolCall,
544
+ toolCall: ToolCallData(
545
+ tool: call.name,
546
+ arguments: call.arguments,
547
+ ),
548
+ );
549
+ }
550
yield const ChatResponse(type: ChatResponseType.done);
551
return;
552
}
553
try {
554
final json = jsonDecode(data) as Map<String, dynamic>;
555
+ toolAdapter.consumeDynamicChunk(json);
556
557
// Check for errors in the response
558
if (json['error'] != null) {
@@ -642,6 +657,7 @@ class OllamaChatService implements ChatService {
657
String? previousResponseId,
658
}) async* {
659
_cancelToken = CancelToken();
660
+ final toolAdapter = OpenAiToolAdapter();
661
662
final body = {
663
'model': modelId,
@@ -678,6 +694,7 @@ class OllamaChatService implements ChatService {
694
if (line.isNotEmpty) {
695
try {
696
final json = jsonDecode(line) as Map<String, dynamic>;
697
+ toolAdapter.consumeDynamicChunk(json);
698
final message = json['message'];
699
if (message != null && message is Map<String, dynamic>) {
700
final content = message['content'] as String?;
@@ -697,6 +714,15 @@ class OllamaChatService implements ChatService {
714
}
715
}
716
if (json['done'] == true) {
717
+ for (final call in toolAdapter.takeCompletedCalls()) {
718
+ yield ChatResponse(
719
+ type: ChatResponseType.toolCall,
720
+ toolCall: ToolCallData(
721
+ tool: call.name,
722
+ arguments: call.arguments,
723
+ ),
724
+ );
725
+ }
726
yield const ChatResponse(type: ChatResponseType.done);
727
return;
728
}
@@ -738,6 +764,7 @@ class OpenRouterChatService implements ChatService {
764
String? previousResponseId,
765
}) async* {
766
_cancelToken = CancelToken();
767
+ final toolAdapter = OpenRouterToolAdapter();
768
769
final body = {
770
'model': modelId,
@@ -813,11 +840,21 @@ class OpenRouterChatService implements ChatService {
840
if (data == '[DONE]') {
841
_timeoutTimer?.cancel();
842
Log.debug('OpenRouter: Received [DONE]');
843
+ for (final call in toolAdapter.takeCompletedCalls()) {
844
+ yield ChatResponse(
845
+ type: ChatResponseType.toolCall,
846
+ toolCall: ToolCallData(
847
+ tool: call.name,
848
+ arguments: call.arguments,
849
+ ),
850
+ );
851
+ }
852
yield const ChatResponse(type: ChatResponseType.done);
853
return;
854
}
855
try {
856
final json = jsonDecode(data) as Map<String, dynamic>;
857
+ toolAdapter.consumeDynamicChunk(json);
858
859
// Check for errors in the response
860
if (json['error'] != null) {
@@ -969,3 +1006,17 @@ String _roleToString(MessageRole role) {
1006
return 'tool';
1007
}
1008
}
1009
+
1010
+/// Returns the streaming-parsing adapter for a server type.
1011
+///
1012
+/// OpenAI-compatible and Ollama servers use adapter-based tool call parsing
1013
+/// from streaming chunks. LM Studio and on-device servers collect tool calls
1014
+/// directly from [ChatResponseType.toolCall] events emitted during streaming
1015
+/// and feed them to the loop via [ToolExecutionLoop.run]'s [preParsedCalls].
1016
+ToolTransportAdapter createAdapterForServerType(ServerType type) => switch (type) {
1017
+ ServerType.openAICompatible => OpenAiToolAdapter(),
1018
+ ServerType.openRouter => OpenRouterToolAdapter(),
1019
+ ServerType.lmStudio => OpenAiToolAdapter(),
1020
+ ServerType.onDevice => OpenAiToolAdapter(),
1021
+ ServerType.ollama => OllamaToolAdapter(),
1022
+};
lib/features/chat/data/models/message.dart
+9
@@ -1,6 +1,7 @@
1
import 'dart:convert';
2
3
import '../../../../core/models/enums.dart';
4
+import '../tools/tool_event.dart';
5
6
class Message {
7
final String id;
@@ -18,6 +19,8 @@ class Message {
19
final List<ToolCallData>? toolCalls;
20
final String? toolCallId;
21
final bool isProcessing;
22
+ final String? toolSessionId;
23
+ final List<ToolEvent>? toolEvents;
24
25
Message({
26
required this.id,
@@ -35,6 +38,8 @@ class Message {
38
this.toolCalls,
39
this.toolCallId,
40
this.isProcessing = false,
41
+ this.toolSessionId,
42
+ this.toolEvents,
43
});
44
45
Message copyWith({
@@ -53,6 +58,8 @@ class Message {
58
List<ToolCallData>? toolCalls,
59
String? toolCallId,
60
bool? isProcessing,
61
+ String? toolSessionId,
62
+ List<ToolEvent>? toolEvents,
63
}) {
64
return Message(
65
id: id ?? this.id,
@@ -70,6 +77,8 @@ class Message {
77
toolCalls: toolCalls ?? this.toolCalls,
78
toolCallId: toolCallId ?? this.toolCallId,
79
isProcessing: isProcessing ?? this.isProcessing,
80
+ toolSessionId: toolSessionId ?? this.toolSessionId,
81
+ toolEvents: toolEvents ?? this.toolEvents,
82
);
83
}
84
}
lib/features/chat/data/tools/adapters/lm_studio_tool_adapter.dart
new
+61
@@ -0,0 +1,61 @@
1
+class ServerExecutedToolCall {
2
+ final String name;
3
+ final Map<String, dynamic> arguments;
4
+ final String output;
5
+ const ServerExecutedToolCall({required this.name, required this.arguments, required this.output});
6
+}
7
+
8
+class LmStudioToolAdapter {
9
+ final List<ServerExecutedToolCall> _completedCalls = [];
10
+ String? _currentTool;
11
+ Map<String, dynamic> _currentArgs = {};
12
+
13
+ void consumeEvent(String eventType, Map<String, dynamic> data) {
14
+ switch (eventType) {
15
+ case 'tool_call.start':
16
+ _currentTool = data['tool'] as String?;
17
+ _currentArgs = {};
18
+ break;
19
+ case 'tool_call.arguments':
20
+ final args = data['arguments'] as Map<String, dynamic>?;
21
+ if (args != null) {
22
+ _currentArgs = args;
23
+ }
24
+ break;
25
+ case 'tool_call.success':
26
+ final tool = data['tool'] as String? ?? _currentTool;
27
+ final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
28
+ final output = data['output'] as String? ?? '';
29
+ if (tool != null) {
30
+ _completedCalls.add(ServerExecutedToolCall(
31
+ name: tool,
32
+ arguments: args,
33
+ output: output,
34
+ ));
35
+ }
36
+ _currentTool = null;
37
+ _currentArgs = {};
38
+ break;
39
+ case 'tool_call.failure':
40
+ final tool = data['tool'] as String? ?? _currentTool;
41
+ final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
42
+ final reason = data['reason'] as String? ?? 'Tool execution failed';
43
+ if (tool != null) {
44
+ _completedCalls.add(ServerExecutedToolCall(
45
+ name: tool,
46
+ arguments: args,
47
+ output: reason,
48
+ ));
49
+ }
50
+ _currentTool = null;
51
+ _currentArgs = {};
52
+ break;
53
+ }
54
+ }
55
+
56
+ List<ServerExecutedToolCall> takeServerExecutedCalls() {
57
+ final calls = List<ServerExecutedToolCall>.from(_completedCalls);
58
+ _completedCalls.clear();
59
+ return calls;
60
+ }
61
+}
lib/features/chat/data/tools/adapters/ollama_tool_adapter.dart
new
+60
@@ -0,0 +1,60 @@
1
+import 'dart:convert';
2
+
3
+import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
5
+
6
+class OllamaToolAdapter implements ToolTransportAdapter {
7
+ final List<ParsedToolCall> _completedCalls = [];
8
+
9
+ @override
10
+ Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
11
+ return {
12
+ 'tools': tools.map((t) => {
13
+ 'type': 'function',
14
+ 'function': {
15
+ 'name': t.name,
16
+ 'description': t.description,
17
+ 'parameters': t.inputSchema,
18
+ },
19
+ }).toList(),
20
+ };
21
+ }
22
+
23
+ @override
24
+ void consumeDynamicChunk(Map<String, dynamic> chunk) {
25
+ final message = chunk['message'] as Map<String, dynamic>?;
26
+ if (message == null) return;
27
+ final toolCalls = message['tool_calls'] as List?;
28
+ if (toolCalls == null) return;
29
+ for (final tc in toolCalls) {
30
+ final func = tc['function'] as Map<String, dynamic>?;
31
+ if (func == null) continue;
32
+ _completedCalls.add(ParsedToolCall(
33
+ id: tc['id'] ?? '',
34
+ name: func['name'] as String? ?? '',
35
+ arguments: Map<String, dynamic>.from(
36
+ jsonDecode(func['arguments'] as String? ?? '{}') as Map,
37
+ ),
38
+ ));
39
+ }
40
+ }
41
+
42
+ @override
43
+ List<ParsedToolCall> takeCompletedCalls() {
44
+ final calls = List<ParsedToolCall>.from(_completedCalls);
45
+ _completedCalls.clear();
46
+ return calls;
47
+ }
48
+
49
+ @override
50
+ Map<String, dynamic> buildToolResultMessage({
51
+ required ParsedToolCall call,
52
+ required String output,
53
+ }) {
54
+ return {
55
+ 'role': 'tool',
56
+ 'tool_name': call.name,
57
+ 'content': output,
58
+ };
59
+ }
60
+}
lib/features/chat/data/tools/adapters/on_device_tool_adapter.dart
new
+19
@@ -0,0 +1,19 @@
1
+import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
2
+
3
+class OnDeviceToolAdapter {
4
+ final List<ParsedToolCall> _completedCalls = [];
5
+
6
+ void consumeFunctionCall(String name, Map<String, dynamic> args) {
7
+ _completedCalls.add(ParsedToolCall(
8
+ id: 'ondevice_${_completedCalls.length}',
9
+ name: name,
10
+ arguments: Map<String, dynamic>.from(args),
11
+ ));
12
+ }
13
+
14
+ List<ParsedToolCall> takeCompletedCalls() {
15
+ final calls = List<ParsedToolCall>.from(_completedCalls);
16
+ _completedCalls.clear();
17
+ return calls;
18
+ }
19
+}
lib/features/chat/data/tools/adapters/openai_tool_adapter.dart
new
+85
@@ -0,0 +1,85 @@
1
+import 'dart:convert';
2
+
3
+import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
5
+
6
+class _AccumulatingChunk {
7
+ String? id;
8
+ String? name;
9
+ final StringBuffer argumentsBuffer = StringBuffer();
10
+}
11
+
12
+class OpenAiToolAdapter implements ToolTransportAdapter {
13
+ final Map<int, _AccumulatingChunk> _accumulators = {};
14
+
15
+ @override
16
+ Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
17
+ return {
18
+ 'tools': tools.map((t) => {
19
+ 'type': 'function',
20
+ 'function': {
21
+ 'name': t.name,
22
+ 'description': t.description,
23
+ 'parameters': t.inputSchema,
24
+ },
25
+ }).toList(),
26
+ };
27
+ }
28
+
29
+ @override
30
+ void consumeDynamicChunk(Map<String, dynamic> chunk) {
31
+ final choices = chunk['choices'] as List?;
32
+ if (choices == null || choices.isEmpty) return;
33
+ final delta = choices[0]['delta'] as Map<String, dynamic>?;
34
+ if (delta == null) return;
35
+ final toolCalls = delta['tool_calls'] as List?;
36
+ if (toolCalls == null) return;
37
+ for (final tc in toolCalls) {
38
+ final index = tc['index'] as int;
39
+ final acc = _accumulators.putIfAbsent(index, () => _AccumulatingChunk());
40
+ final func = tc['function'] as Map<String, dynamic>?;
41
+ if (tc['id'] != null) acc.id = tc['id'] as String;
42
+ if (func != null) {
43
+ if (func['name'] != null) acc.name = func['name'] as String;
44
+ if (func['arguments'] != null) {
45
+ acc.argumentsBuffer.write(func['arguments'] as String);
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ @override
52
+ List<ParsedToolCall> takeCompletedCalls() {
53
+ if (_accumulators.isEmpty) return const [];
54
+ final calls = _accumulators.entries.map((e) {
55
+ final acc = e.value;
56
+ Map<String, dynamic> parsedArgs;
57
+ try {
58
+ parsedArgs = Map<String, dynamic>.from(
59
+ jsonDecode(acc.argumentsBuffer.toString()) as Map,
60
+ );
61
+ } catch (_) {
62
+ parsedArgs = {};
63
+ }
64
+ return ParsedToolCall(
65
+ id: acc.id ?? '',
66
+ name: acc.name ?? '',
67
+ arguments: parsedArgs,
68
+ );
69
+ }).toList();
70
+ _accumulators.clear();
71
+ return calls;
72
+ }
73
+
74
+ @override
75
+ Map<String, dynamic> buildToolResultMessage({
76
+ required ParsedToolCall call,
77
+ required String output,
78
+ }) {
79
+ return {
80
+ 'role': 'tool',
81
+ 'tool_call_id': call.id,
82
+ 'content': output,
83
+ };
84
+ }
85
+}
lib/features/chat/data/tools/adapters/openrouter_tool_adapter.dart
new
+6
@@ -0,0 +1,6 @@
1
+import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
2
+
3
+class OpenRouterToolAdapter extends OpenAiToolAdapter {
4
+ // OpenRouter uses identical function calling protocol to OpenAI
5
+ // No override needed — inherits all behavior
6
+}
lib/features/chat/data/tools/adapters/tool_transport_adapter.dart
new
+23
@@ -0,0 +1,23 @@
1
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
2
+
3
+class ParsedToolCall {
4
+ final String id;
5
+ final String name;
6
+ final Map<String, dynamic> arguments;
7
+
8
+ const ParsedToolCall({
9
+ required this.id,
10
+ required this.name,
11
+ required this.arguments,
12
+ });
13
+}
14
+
15
+abstract class ToolTransportAdapter {
16
+ Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools);
17
+ void consumeDynamicChunk(Map<String, dynamic> chunk);
18
+ List<ParsedToolCall> takeCompletedCalls();
19
+ Map<String, dynamic> buildToolResultMessage({
20
+ required ParsedToolCall call,
21
+ required String output,
22
+ });
23
+}
lib/features/chat/data/tools/builtin_tool_provider.dart
new
+51
@@ -0,0 +1,51 @@
1
+import 'tool_definition.dart';
2
+import 'tool_registry.dart';
3
+
4
+class BuiltInToolProvider implements ToolProvider {
5
+ @override
6
+ Future<List<ToolDefinition>> listTools() async => const [
7
+ ToolDefinition(
8
+ name: 'calc.add',
9
+ description: 'Add two numbers',
10
+ inputSchema: {
11
+ 'type': 'object',
12
+ 'properties': {
13
+ 'a': {'type': 'number'},
14
+ 'b': {'type': 'number'},
15
+ },
16
+ 'required': ['a', 'b'],
17
+ },
18
+ providerType: ToolProviderType.builtIn,
19
+ ),
20
+ ToolDefinition(
21
+ name: 'calc.multiply',
22
+ description: 'Multiply two numbers',
23
+ inputSchema: {
24
+ 'type': 'object',
25
+ 'properties': {
26
+ 'a': {'type': 'number'},
27
+ 'b': {'type': 'number'},
28
+ },
29
+ 'required': ['a', 'b'],
30
+ },
31
+ providerType: ToolProviderType.builtIn,
32
+ ),
33
+ ];
34
+
35
+ @override
36
+ Future<ToolExecutionResult> execute(
37
+ String name,
38
+ Map<String, dynamic> args,
39
+ ) async {
40
+ switch (name) {
41
+ case 'calc.add':
42
+ final sum = (args['a'] as num) + (args['b'] as num);
43
+ return ToolExecutionResult.success(sum.toString());
44
+ case 'calc.multiply':
45
+ final product = (args['a'] as num) * (args['b'] as num);
46
+ return ToolExecutionResult.success(product.toString());
47
+ default:
48
+ return const ToolExecutionResult.failure('Unknown built-in tool');
49
+ }
50
+ }
51
+}
lib/features/chat/data/tools/mcp_tool_provider.dart
new
+47
@@ -0,0 +1,47 @@
1
+import 'package:localmind/features/chat/data/mcp_server_manager.dart';
2
+
3
+import 'tool_definition.dart';
4
+import 'tool_registry.dart';
5
+
6
+class McpToolProvider implements ToolProvider {
7
+ final McpServerManager serverManager;
8
+
9
+ McpToolProvider({required this.serverManager});
10
+
11
+ @override
12
+ Future<List<ToolDefinition>> listTools() async {
13
+ final tools = <ToolDefinition>[];
14
+ for (final label in serverManager.serverLabels) {
15
+ final serverTools = serverManager.getTools(label);
16
+ for (final tool in serverTools) {
17
+ tools.add(ToolDefinition(
18
+ name: tool.name,
19
+ description: tool.description ?? '',
20
+ inputSchema: tool.inputSchema,
21
+ providerType: ToolProviderType.mcp,
22
+ providerRef: label,
23
+ ));
24
+ }
25
+ }
26
+ return tools;
27
+ }
28
+
29
+ @override
30
+ Future<ToolExecutionResult> execute(
31
+ String name,
32
+ Map<String, dynamic> args,
33
+ ) async {
34
+ for (final label in serverManager.serverLabels) {
35
+ final serverTools = serverManager.getTools(label);
36
+ if (serverTools.any((t) => t.name == name)) {
37
+ try {
38
+ final result = await serverManager.callTool(label, name, args);
39
+ return ToolExecutionResult.success(result);
40
+ } catch (e) {
41
+ return ToolExecutionResult.failure(e.toString());
42
+ }
43
+ }
44
+ }
45
+ return const ToolExecutionResult.failure('MCP tool not found');
46
+ }
47
+}
lib/features/chat/data/tools/tool_definition.dart
new
+31
@@ -0,0 +1,31 @@
1
+enum ToolProviderType { builtIn, mcp, lmStudioServer }
2
+
3
+class ToolDefinition {
4
+ final String name;
5
+ final String description;
6
+ final Map<String, dynamic> inputSchema;
7
+ final ToolProviderType providerType;
8
+ final String? providerRef;
9
+
10
+ const ToolDefinition({
11
+ required this.name,
12
+ required this.description,
13
+ required this.inputSchema,
14
+ required this.providerType,
15
+ this.providerRef,
16
+ });
17
+}
18
+
19
+class ToolExecutionResult {
20
+ final bool success;
21
+ final String output;
22
+ final String? error;
23
+
24
+ const ToolExecutionResult.success(this.output)
25
+ : success = true,
26
+ error = null;
27
+
28
+ const ToolExecutionResult.failure(this.error)
29
+ : success = false,
30
+ output = '';
31
+}
lib/features/chat/data/tools/tool_event.dart
new
+78
@@ -0,0 +1,78 @@
1
+import 'tool_definition.dart';
2
+
3
+enum ToolEventStatus { requested, approved, rejected, running, completed, failed }
4
+
5
+class ToolEvent {
6
+ final String eventId;
7
+ final DateTime timestamp;
8
+ final ToolEventStatus status;
9
+ final String toolName;
10
+ final ToolProviderType providerType;
11
+ final String? providerRef;
12
+ final Map<String, dynamic>? arguments;
13
+ final String? result;
14
+ final String? error;
15
+ final int? durationMs;
16
+
17
+ const ToolEvent({
18
+ required this.eventId,
19
+ required this.timestamp,
20
+ required this.status,
21
+ required this.toolName,
22
+ required this.providerType,
23
+ this.providerRef,
24
+ this.arguments,
25
+ this.result,
26
+ this.error,
27
+ this.durationMs,
28
+ });
29
+
30
+ factory ToolEvent.requested({
31
+ required String eventId,
32
+ required String toolName,
33
+ required ToolProviderType providerType,
34
+ String? providerRef,
35
+ Map<String, dynamic>? arguments,
36
+ }) =>
37
+ ToolEvent(
38
+ eventId: eventId,
39
+ timestamp: DateTime.now(),
40
+ status: ToolEventStatus.requested,
41
+ toolName: toolName,
42
+ providerType: providerType,
43
+ providerRef: providerRef,
44
+ arguments: arguments,
45
+ );
46
+
47
+ Map<String, dynamic> toMap() {
48
+ return {
49
+ 'eventId': eventId,
50
+ 'timestamp': timestamp.toIso8601String(),
51
+ 'status': status.name,
52
+ 'toolName': toolName,
53
+ 'providerType': providerType.name,
54
+ 'providerRef': providerRef,
55
+ 'arguments': arguments,
56
+ 'result': result,
57
+ 'error': error,
58
+ 'durationMs': durationMs,
59
+ };
60
+ }
61
+
62
+ factory ToolEvent.fromMap(Map<String, dynamic> map) {
63
+ return ToolEvent(
64
+ eventId: map['eventId'],
65
+ timestamp: DateTime.parse(map['timestamp']),
66
+ status: ToolEventStatus.values.byName(map['status']),
67
+ toolName: map['toolName'],
68
+ providerType: ToolProviderType.values.byName(map['providerType']),
69
+ providerRef: map['providerRef'],
70
+ arguments: map['arguments'] != null
71
+ ? Map<String, dynamic>.from(map['arguments'])
72
+ : null,
73
+ result: map['result'],
74
+ error: map['error'],
75
+ durationMs: map['durationMs'],
76
+ );
77
+ }
78
+}
lib/features/chat/data/tools/tool_execution_loop.dart
new
+197
@@ -0,0 +1,197 @@
1
+import 'dart:async';
2
+import 'tool_definition.dart';
3
+import 'tool_event.dart';
4
+import 'tool_registry.dart';
5
+import 'adapters/tool_transport_adapter.dart';
6
+
7
+class ToolLoopResult {
8
+ final String finalAssistantContent;
9
+ final List<ToolEvent> events;
10
+ final bool completedWithinLimit;
11
+ final int toolCallCount;
12
+
13
+ const ToolLoopResult({
14
+ required this.finalAssistantContent,
15
+ required this.events,
16
+ this.completedWithinLimit = true,
17
+ this.toolCallCount = 0,
18
+ });
19
+}
20
+
21
+typedef ToolApprovalCallback = Future<bool> Function(ParsedToolCall call);
22
+
23
+class ToolExecutionLoop {
24
+ final ToolTransportAdapter adapter;
25
+ final ToolRegistry registry;
26
+ final int maxIterations;
27
+ final Duration toolTimeout;
28
+ final Duration approvalTimeout;
29
+ final ToolApprovalCallback? onRequestApproval;
30
+
31
+ String _sessionId = '';
32
+ final List<ToolEvent> _events = [];
33
+
34
+ ToolExecutionLoop({
35
+ required this.adapter,
36
+ required this.registry,
37
+ this.maxIterations = 6,
38
+ this.toolTimeout = const Duration(seconds: 30),
39
+ this.approvalTimeout = const Duration(seconds: 60),
40
+ this.onRequestApproval,
41
+ });
42
+
43
+ String get sessionId => _sessionId;
44
+
45
+ Future<ToolLoopResult> run({
46
+ required String initialUserMessage,
47
+ List<Map<String, dynamic>> conversationHistory = const [],
48
+ String? assistantContent,
49
+ List<ParsedToolCall>? preParsedCalls,
50
+ }) async {
51
+ _sessionId = DateTime.now().millisecondsSinceEpoch.toString();
52
+ _events.clear();
53
+
54
+ int iteration = 0;
55
+ int toolCallCount = 0;
56
+ final toolDefinitions = {
57
+ for (final tool in await registry.listTools()) tool.name: tool,
58
+ };
59
+
60
+ final callsToProcess = preParsedCalls ?? adapter.takeCompletedCalls();
61
+
62
+ if (callsToProcess.isEmpty) {
63
+ return ToolLoopResult(
64
+ finalAssistantContent: assistantContent ?? '',
65
+ events: const [],
66
+ completedWithinLimit: true,
67
+ toolCallCount: 0,
68
+ );
69
+ }
70
+
71
+ iteration++;
72
+ for (final call in callsToProcess) {
73
+ toolCallCount++;
74
+ final baseId = '${_sessionId}_${iteration}_${call.id}';
75
+ final toolDefinition = toolDefinitions[call.name];
76
+ final providerType = toolDefinition?.providerType ?? ToolProviderType.builtIn;
77
+ final providerRef = toolDefinition?.providerRef;
78
+
79
+ if (onRequestApproval != null) {
80
+ _events.add(ToolEvent(
81
+ eventId: '$baseId.requested',
82
+ timestamp: DateTime.now(),
83
+ status: ToolEventStatus.requested,
84
+ toolName: call.name,
85
+ providerType: providerType,
86
+ providerRef: providerRef,
87
+ arguments: call.arguments,
88
+ ));
89
+
90
+ try {
91
+ final approved = await onRequestApproval!(call)
92
+ .timeout(approvalTimeout);
93
+ if (!approved) {
94
+ _events.add(ToolEvent(
95
+ eventId: '$baseId.rejected',
96
+ timestamp: DateTime.now(),
97
+ status: ToolEventStatus.rejected,
98
+ toolName: call.name,
99
+ providerType: providerType,
100
+ providerRef: providerRef,
101
+ arguments: call.arguments,
102
+ error: 'Rejected by user',
103
+ ));
104
+ continue;
105
+ }
106
+ } on TimeoutException {
107
+ _events.add(ToolEvent(
108
+ eventId: '$baseId.rejected',
109
+ timestamp: DateTime.now(),
110
+ status: ToolEventStatus.rejected,
111
+ toolName: call.name,
112
+ providerType: providerType,
113
+ providerRef: providerRef,
114
+ arguments: call.arguments,
115
+ error: 'Approval timed out',
116
+ ));
117
+ continue;
118
+ } catch (_) {
119
+ _events.add(ToolEvent(
120
+ eventId: '$baseId.failed',
121
+ timestamp: DateTime.now(),
122
+ status: ToolEventStatus.failed,
123
+ toolName: call.name,
124
+ providerType: providerType,
125
+ providerRef: providerRef,
126
+ arguments: call.arguments,
127
+ error: 'Approval callback error',
128
+ ));
129
+ continue;
130
+ }
131
+
132
+ _events.add(ToolEvent(
133
+ eventId: '$baseId.approved',
134
+ timestamp: DateTime.now(),
135
+ status: ToolEventStatus.approved,
136
+ toolName: call.name,
137
+ providerType: providerType,
138
+ providerRef: providerRef,
139
+ arguments: call.arguments,
140
+ ));
141
+ }
142
+
143
+ _events.add(ToolEvent(
144
+ eventId: '$baseId.running',
145
+ timestamp: DateTime.now(),
146
+ status: ToolEventStatus.running,
147
+ toolName: call.name,
148
+ providerType: providerType,
149
+ providerRef: providerRef,
150
+ arguments: call.arguments,
151
+ ));
152
+
153
+ final stopwatch = Stopwatch()..start();
154
+ try {
155
+ final result = await registry
156
+ .execute(call.name, call.arguments)
157
+ .timeout(toolTimeout);
158
+
159
+ stopwatch.stop();
160
+
161
+ _events.add(ToolEvent(
162
+ eventId: '$baseId.${result.success ? "completed" : "failed"}',
163
+ timestamp: DateTime.now(),
164
+ status: result.success ? ToolEventStatus.completed : ToolEventStatus.failed,
165
+ toolName: call.name,
166
+ providerType: providerType,
167
+ providerRef: providerRef,
168
+ arguments: call.arguments,
169
+ result: result.success ? result.output : null,
170
+ error: result.success ? null : result.error,
171
+ durationMs: stopwatch.elapsedMilliseconds,
172
+ ));
173
+ } catch (e) {
174
+ stopwatch.stop();
175
+
176
+ _events.add(ToolEvent(
177
+ eventId: '$baseId.failed',
178
+ timestamp: DateTime.now(),
179
+ status: ToolEventStatus.failed,
180
+ toolName: call.name,
181
+ providerType: providerType,
182
+ providerRef: providerRef,
183
+ arguments: call.arguments,
184
+ error: 'Timeout or error: $e',
185
+ durationMs: stopwatch.elapsedMilliseconds,
186
+ ));
187
+ }
188
+ }
189
+
190
+ return ToolLoopResult(
191
+ finalAssistantContent: assistantContent ?? '',
192
+ events: List<ToolEvent>.from(_events),
193
+ completedWithinLimit: iteration < maxIterations,
194
+ toolCallCount: toolCallCount,
195
+ );
196
+ }
197
+}
lib/features/chat/data/tools/tool_registry.dart
new
+34
@@ -0,0 +1,34 @@
1
+import 'tool_definition.dart';
2
+
3
+abstract class ToolProvider {
4
+ Future<List<ToolDefinition>> listTools();
5
+ Future<ToolExecutionResult> execute(String name, Map<String, dynamic> args);
6
+}
7
+
8
+class ToolRegistry {
9
+ final List<ToolProvider> providers;
10
+
11
+ ToolRegistry({required this.providers});
12
+
13
+ Future<List<ToolDefinition>> listTools({Set<String>? allowedTools}) async {
14
+ final all = <ToolDefinition>[];
15
+ for (final provider in providers) {
16
+ all.addAll(await provider.listTools());
17
+ }
18
+ if (allowedTools == null || allowedTools.isEmpty) return all;
19
+ return all.where((t) => allowedTools.contains(t.name)).toList();
20
+ }
21
+
22
+ Future<ToolExecutionResult> execute(
23
+ String name,
24
+ Map<String, dynamic> args,
25
+ ) async {
26
+ for (final provider in providers) {
27
+ final tools = await provider.listTools();
28
+ if (tools.any((t) => t.name == name)) {
29
+ return provider.execute(name, args);
30
+ }
31
+ }
32
+ return const ToolExecutionResult.failure('Tool not found');
33
+ }
34
+}
lib/features/chat/providers/chat_providers.dart
+61
-2
@@ -23,9 +23,12 @@ import '../../../objectbox.g.dart';
23
import '../../conversations/data/models/conversation.dart';
24
import '../data/chat_service.dart';
25
import '../data/models/chat_parameters.dart';
26
-import '../data/models/message.dart';
26
+import '../data/models/message.dart' hide ToolCallData;
27
import '../data/smart_reply_service.dart';
28
+import '../data/tools/tool_execution_loop.dart';
29
+import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
30
import '../../../core/providers/chat_background_service_provider.dart';
31
+import './tooling_providers.dart';
32
33
final selectedModelProvider =
34
NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
@@ -622,12 +625,20 @@ class ChatNotifier extends Notifier<ChatState> {
625
var streamingAssistantMessage = assistantMessage;
626
627
if (chatService != null) {
628
+ final mcpConfig = ref.read(chatMcpConfigProvider);
629
+ final integrations = mcpConfig.enabled && mcpConfig.integrations.isNotEmpty
630
+ ? mcpConfig.integrations
631
+ : null;
632
+
633
+ final collectedToolCalls = <ToolCallData>[];
634
+
635
_streamSubscription = chatService
636
.sendMessage(
637
server: server,
638
modelId: selectedModel?.id ?? 'default',
639
messages: messagesForApi,
640
params: chatParams,
641
+ integrations: integrations,
642
)
643
.listen(
644
(response) async {
@@ -765,6 +776,10 @@ class ChatNotifier extends Notifier<ChatState> {
776
await _saveMessage(streamingAssistantMessage);
777
break;
778
case ChatResponseType.toolCall:
779
+ if (response.toolCall != null) {
780
+ collectedToolCalls.add(response.toolCall!);
781
+ }
782
+ break;
783
case ChatResponseType.invalidToolCall:
784
case ChatResponseType.done:
785
break;
@@ -812,10 +827,54 @@ class ChatNotifier extends Notifier<ChatState> {
827
}
828
} else {
829
// Normal completion with content
815
- final finalMessage = streamingMessage.copyWith(
830
+ var finalMessage = streamingMessage.copyWith(
831
status: MessageStatus.complete,
832
isProcessing: false,
833
);
834
+
835
+ // ---- TOOL LOOP INTEGRATION ----
836
+ if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
837
+ try {
838
+ final registry = ref.read(toolRegistryProvider);
839
+ final adapter = createAdapterForServerType(server.type);
840
+
841
+ final preParsedCalls = collectedToolCalls
842
+ .map((tc) => ParsedToolCall(
843
+ id: tc.tool,
844
+ name: tc.tool,
845
+ arguments: tc.arguments,
846
+ ))
847
+ .toList();
848
+
849
+ final loop = ToolExecutionLoop(
850
+ adapter: adapter,
851
+ registry: registry,
852
+ onRequestApproval: mcpConfig.autoExecuteTools
853
+ ? null
854
+ : (call) async {
855
+ // TODO: wire real approval UI dialog
856
+ return false;
857
+ },
858
+ );
859
+
860
+ final loopResult = await loop.run(
861
+ initialUserMessage: content,
862
+ assistantContent: streamingAssistantMessage.content,
863
+ preParsedCalls: preParsedCalls,
864
+ );
865
+
866
+ if (loopResult.events.isNotEmpty) {
867
+ finalMessage = finalMessage.copyWith(
868
+ toolSessionId: loop.sessionId,
869
+ toolEvents: loopResult.events,
870
+ );
871
+ }
872
+ } catch (e) {
873
+ Log.error('Tool execution loop failed: $e');
874
+ }
875
+ }
876
+ // ---- END TOOL LOOP INTEGRATION ----
877
+
878
await _saveMessage(finalMessage);
879
if (isCurrentContext) {
880
final messageIndex = state.messages.indexWhere(
lib/features/chat/providers/tooling_providers.dart
new
+24
@@ -0,0 +1,24 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import '../data/tools/tool_registry.dart';
3
+import '../data/tools/builtin_tool_provider.dart';
4
+import '../data/tools/mcp_tool_provider.dart';
5
+import '../data/mcp_server_manager.dart';
6
+
7
+final mcpServerManagerProvider = Provider<McpServerManager>((ref) {
8
+ return McpServerManager();
9
+});
10
+
11
+final builtInToolProviderProvider = Provider<BuiltInToolProvider>((ref) {
12
+ return BuiltInToolProvider();
13
+});
14
+
15
+final toolRegistryProvider = Provider<ToolRegistry>((ref) {
16
+ final mcpServerManager = ref.watch(mcpServerManagerProvider);
17
+ return ToolRegistry(
18
+ providers: [
19
+ ref.watch(builtInToolProviderProvider),
20
+ if (mcpServerManager.serverCount > 0)
21
+ McpToolProvider(serverManager: mcpServerManager),
22
+ ],
23
+ );
24
+});
lib/features/chat/views/components/chat_bubble.dart
+167
@@ -9,6 +9,7 @@ import 'message_action_bar.dart';
9
import 'processing_indicator.dart';
10
import 'typing_indicator.dart';
11
import 'reasoning_widget.dart';
12
+import '../../data/tools/tool_event.dart';
13
14
class ChatBubble extends StatelessWidget {
15
const ChatBubble({
@@ -261,6 +262,11 @@ class _AssistantBubble extends StatelessWidget {
262
),
263
),
264
),
265
+ if (message.toolEvents != null && message.toolEvents!.isNotEmpty)
266
+ Padding(
267
+ padding: const EdgeInsets.only(top: 8),
268
+ child: _ToolTimeline(events: message.toolEvents!),
269
+ ),
270
if (isStreaming && message.content.isNotEmpty)
271
const Padding(
272
padding: EdgeInsets.only(top: 8),
@@ -509,6 +515,167 @@ class _ToolBubble extends StatelessWidget {
515
}
516
}
517
518
+class _ToolTimeline extends StatelessWidget {
519
+ const _ToolTimeline({required this.events});
520
+ final List<ToolEvent> events;
521
+
522
+ @override
523
+ Widget build(BuildContext context) {
524
+ final theme = Theme.of(context);
525
+ final isDark = theme.brightness == Brightness.dark;
526
+
527
+ return Container(
528
+ decoration: BoxDecoration(
529
+ color: isDark ? const Color(0xFF1E293B) : const Color(0xFFF8FAFC),
530
+ borderRadius: BorderRadius.circular(8),
531
+ border: Border.all(
532
+ color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
533
+ ),
534
+ ),
535
+ child: Column(
536
+ crossAxisAlignment: CrossAxisAlignment.start,
537
+ children: [
538
+ Padding(
539
+ padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
540
+ child: Row(
541
+ children: [
542
+ Icon(Icons.troubleshoot, size: 14, color: isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B)),
543
+ const SizedBox(width: 4),
544
+ Text('Tools', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B))),
545
+ ],
546
+ ),
547
+ ),
548
+ ...events.map((event) => _buildEventRow(event, isDark)),
549
+ ],
550
+ ),
551
+ );
552
+ }
553
+
554
+ Widget _buildEventRow(ToolEvent event, bool isDark) {
555
+ final iconData = switch (event.status) {
556
+ ToolEventStatus.requested => Icons.hourglass_empty,
557
+ ToolEventStatus.approved => Icons.check_circle_outline,
558
+ ToolEventStatus.rejected => Icons.cancel_outlined,
559
+ ToolEventStatus.running => Icons.play_circle_outline,
560
+ ToolEventStatus.completed => Icons.check_circle,
561
+ ToolEventStatus.failed => Icons.error_outline,
562
+ };
563
+ final iconColor = switch (event.status) {
564
+ ToolEventStatus.completed => const Color(0xFF22C55E),
565
+ ToolEventStatus.failed || ToolEventStatus.rejected => const Color(0xFFEF4444),
566
+ ToolEventStatus.running || ToolEventStatus.requested => const Color(0xFFF59E0B),
567
+ ToolEventStatus.approved => const Color(0xFF3B82F6),
568
+ };
569
+ final label = switch (event.status) {
570
+ ToolEventStatus.requested => 'Requested',
571
+ ToolEventStatus.approved => 'Approved',
572
+ ToolEventStatus.rejected => 'Rejected',
573
+ ToolEventStatus.running => 'Running',
574
+ ToolEventStatus.completed => 'Done',
575
+ ToolEventStatus.failed => 'Failed',
576
+ };
577
+
578
+ return Padding(
579
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
580
+ child: Row(
581
+ crossAxisAlignment: CrossAxisAlignment.start,
582
+ children: [
583
+ Padding(
584
+ padding: const EdgeInsets.only(top: 2),
585
+ child: Icon(iconData, size: 14, color: iconColor),
586
+ ),
587
+ const SizedBox(width: 6),
588
+ Expanded(
589
+ child: Column(
590
+ crossAxisAlignment: CrossAxisAlignment.start,
591
+ children: [
592
+ Row(
593
+ children: [
594
+ Flexible(
595
+ child: Text(
596
+ event.toolName,
597
+ style: TextStyle(
598
+ fontSize: 12,
599
+ fontWeight: FontWeight.w600,
600
+ color: isDark ? Colors.white : Colors.black87,
601
+ fontFamily: 'monospace',
602
+ ),
603
+ overflow: TextOverflow.ellipsis,
604
+ ),
605
+ ),
606
+ const SizedBox(width: 4),
607
+ Text(
608
+ label,
609
+ style: TextStyle(fontSize: 10, color: iconColor, fontWeight: FontWeight.w500),
610
+ ),
611
+ ],
612
+ ),
613
+ if (event.arguments != null && event.arguments!.isNotEmpty)
614
+ Padding(
615
+ padding: const EdgeInsets.only(top: 2),
616
+ child: Text(
617
+ _formatArgs(event.arguments!),
618
+ style: TextStyle(
619
+ fontSize: 10,
620
+ color: isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B),
621
+ fontFamily: 'monospace',
622
+ ),
623
+ maxLines: 3,
624
+ overflow: TextOverflow.ellipsis,
625
+ ),
626
+ ),
627
+ if (event.result != null && event.result!.isNotEmpty)
628
+ Padding(
629
+ padding: const EdgeInsets.only(top: 2),
630
+ child: Text(
631
+ '→ ${event.result}',
632
+ style: TextStyle(
633
+ fontSize: 10,
634
+ color: isDark ? const Color(0xFF86EFAC) : const Color(0xFF166534),
635
+ fontFamily: 'monospace',
636
+ ),
637
+ maxLines: 2,
638
+ overflow: TextOverflow.ellipsis,
639
+ ),
640
+ ),
641
+ if (event.error != null && event.error!.isNotEmpty)
642
+ Padding(
643
+ padding: const EdgeInsets.only(top: 2),
644
+ child: Text(
645
+ '✕ ${event.error}',
646
+ style: TextStyle(
647
+ fontSize: 10,
648
+ color: const Color(0xFFEF4444),
649
+ fontFamily: 'monospace',
650
+ ),
651
+ maxLines: 2,
652
+ overflow: TextOverflow.ellipsis,
653
+ ),
654
+ ),
655
+ if (event.durationMs != null)
656
+ Padding(
657
+ padding: const EdgeInsets.only(top: 1),
658
+ child: Text(
659
+ '${event.durationMs}ms',
660
+ style: TextStyle(
661
+ fontSize: 9,
662
+ color: isDark ? const Color(0xFF64748B) : const Color(0xFF94A3B8),
663
+ ),
664
+ ),
665
+ ),
666
+ ],
667
+ ),
668
+ ),
669
+ ],
670
+ ),
671
+ );
672
+ }
673
+
674
+ String _formatArgs(Map<String, dynamic> args) {
675
+ return args.entries.map((e) => '${e.key}: ${e.value}').join(', ');
676
+ }
677
+}
678
+
679
class _AttachmentList extends StatelessWidget {
680
const _AttachmentList({required this.paths, required this.isUser});
681
lib/features/on_device/data/on_device_chat_service.dart
+1
@@ -63,6 +63,7 @@ class OnDeviceChatService implements ChatService {
63
64
final chat = await _gemmaService.createChat(
65
systemInstruction: systemInstruction,
66
+ tools: const [], // will be populated in Task 6
67
);
68
69
if (chat == null) {
lib/features/on_device/data/on_device_gemma_service.dart
+6
-1
@@ -137,13 +137,18 @@ class OnDeviceGemmaService {
137
Log.info('Model $modelId loaded successfully');
138
}
139
140
- Future<InferenceChat?> createChat({String? systemInstruction}) async {
140
+ Future<InferenceChat?> createChat({
141
+ String? systemInstruction,
142
+ List<Tool> tools = const [],
143
+ }) async {
144
if (_model == null) {
145
throw StateError('Model not loaded. Call loadModel first.');
146
}
147
148
return _model!.createChat(
149
systemInstruction: systemInstruction,
150
+ tools: tools,
151
+ supportsFunctionCalls: tools.isNotEmpty,
152
);
153
}
154
lib/features/tts/providers/tts_providers.dart
+57
-35
@@ -1,4 +1,5 @@
1
import 'dart:async';
2
+import 'dart:io';
3
import 'dart:isolate';
4
import 'dart:typed_data';
5
@@ -76,26 +77,33 @@ class TtsNotifier extends Notifier<TtsState> {
77
bool _isWorkerInitializing = false;
78
int _currentSessionId = 0;
79
80
+ StreamSubscription<void>? _playerCompleteSubscription;
81
+ bool _isProcessingChunk = false;
82
+ int _playbackSessionId = 0;
83
+ Completer<SendPort>? _workerPortCompleter;
84
+
85
@override
86
TtsState build() {
87
ref.keepAlive();
88
83
- // Listen for engine changes to update the state without rebuilding the whole notifier
89
ref.listen(settingsProvider, (previous, next) {
90
if (previous?.ttsEngine != next.ttsEngine) {
91
state = state.copyWith(activeEngine: next.ttsEngine);
92
}
93
});
94
90
- _player.onPlayerComplete.listen((_) {
95
+ _playerCompleteSubscription = _player.onPlayerComplete.listen((_) {
96
_onChunkFinished();
97
});
98
99
_setupWorkerListener();
100
101
ref.onDispose(() {
102
+ _workerPortCompleter?.completeError(StateError('Disposed'));
103
+ _workerPortCompleter = null;
104
_workerIsolate?.kill();
105
_mainReceivePort.close();
106
+ _playerCompleteSubscription?.cancel();
107
if (!_isPlayerDisposed) {
108
_isPlayerDisposed = true;
109
try {
@@ -112,6 +120,8 @@ class TtsNotifier extends Notifier<TtsState> {
120
_mainReceivePort.listen((message) {
121
if (message is SendPort) {
122
_workerSendPort = message;
123
+ _workerPortCompleter?.complete(message);
124
+ _workerPortCompleter = null;
125
} else if (message is TtsWorkerResponse) {
126
if (message.isInitResponse) {
127
_initCompleter?.complete();
@@ -123,7 +133,6 @@ class TtsNotifier extends Notifier<TtsState> {
133
}
134
if (message.wavBytes != null) {
135
_synthesizedChunks[message.chunkIndex!] = message.wavBytes!;
126
- // If this was the chunk we were waiting for, play it
136
if (message.chunkIndex == _currentChunkIndex && state.isSpeaking) {
137
_playNextChunk();
138
}
@@ -148,25 +157,20 @@ class TtsNotifier extends Notifier<TtsState> {
157
if (_workerIsolate != null) return;
158
159
Log.info('Spawning TTS worker isolate...');
160
+ _workerPortCompleter = Completer<SendPort>();
161
_workerIsolate = await Isolate.spawn(
162
ttsWorkerEntry,
163
_mainReceivePort.sendPort,
164
);
165
156
- // Wait for worker to send its SendPort
157
- int attempts = 0;
158
- while (_workerSendPort == null && attempts < 200) {
159
- await Future.delayed(const Duration(milliseconds: 50));
160
- attempts++;
161
- }
162
-
163
- if (_workerSendPort == null) {
164
- throw StateError('Failed to establish communication with TTS worker');
165
- }
166
+ await _workerPortCompleter!.future.timeout(
167
+ const Duration(seconds: 10),
168
+ );
169
}
170
171
void _onPlaybackFinished() {
172
if (_isStopping) return;
173
+ if (_currentSessionId != _playbackSessionId) return;
174
state = state.copyWith(
175
isSpeaking: false,
176
isInitializing: false,
@@ -175,19 +179,23 @@ class TtsNotifier extends Notifier<TtsState> {
179
}
180
181
void _onChunkFinished() {
178
- if (_isStopping) return;
179
- _currentChunkIndex++;
180
- if (_currentChunkIndex < _chunks.length) {
181
- _playNextChunk();
182
- } else {
183
- _onPlaybackFinished();
182
+ if (_isStopping || _isProcessingChunk) return;
183
+ _isProcessingChunk = true;
184
+ try {
185
+ _currentChunkIndex++;
186
+ if (_currentChunkIndex < _chunks.length) {
187
+ _playNextChunk();
188
+ } else {
189
+ _onPlaybackFinished();
190
+ }
191
+ } finally {
192
+ _isProcessingChunk = false;
193
}
194
}
195
196
List<String> _splitText(String text) {
197
if (text.length <= 250) return [text];
198
190
- // Split by common sentence terminators but keep them
199
final regex = RegExp(r'(?<=[.!?])\s+');
200
final sentences = text.split(regex);
201
@@ -202,7 +210,6 @@ class TtsNotifier extends Notifier<TtsState> {
210
}
211
212
if (sentence.length > 250) {
205
- // If a single sentence is too long, split it by commas or spaces
213
final subRegex = RegExp(r'(?<=[,;])\s+');
214
final parts = sentence.split(subRegex);
215
for (final part in parts) {
@@ -229,7 +236,9 @@ class TtsNotifier extends Notifier<TtsState> {
236
await _ensureWorker();
237
238
if (_isWorkerInitializing && _initCompleter != null) {
232
- await _initCompleter!.future;
239
+ await _initCompleter!.future.timeout(
240
+ const Duration(seconds: 30),
241
+ );
242
}
243
244
String modelPath = '';
@@ -313,7 +322,9 @@ class TtsNotifier extends Notifier<TtsState> {
322
),
323
);
324
316
- await _initCompleter!.future;
325
+ await _initCompleter!.future.timeout(
326
+ const Duration(seconds: 30),
327
+ );
328
_currentEngine = engineId;
329
_currentModelPath = modelPath;
330
_currentVoicesPath = voicesPath;
@@ -331,7 +342,6 @@ class TtsNotifier extends Notifier<TtsState> {
342
_isStopping = false;
343
if (state.isSpeaking) {
344
await stop();
334
- // Wait a bit for the player to fully stop
345
await Future.delayed(const Duration(milliseconds: 100));
346
_isStopping = false;
347
}
@@ -359,6 +369,11 @@ class TtsNotifier extends Notifier<TtsState> {
369
await _flutterTts!.setSpeechRate(
370
(settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
371
);
372
+ _flutterTts!.setCompletionHandler(() {
373
+ if (!_isStopping) {
374
+ state = state.copyWith(isSpeaking: false, playingContent: null);
375
+ }
376
+ });
377
await _flutterTts!.awaitSpeakCompletion(true);
378
await _flutterTts!.speak(text);
379
} catch (e) {
@@ -368,8 +383,6 @@ class TtsNotifier extends Notifier<TtsState> {
383
playingContent: null,
384
);
385
rethrow;
371
- } finally {
372
- state = state.copyWith(isSpeaking: false, playingContent: null);
386
}
387
return;
388
}
@@ -380,6 +393,7 @@ class TtsNotifier extends Notifier<TtsState> {
393
_synthesizedChunks.clear();
394
_lastEnqueuedChunk = -1;
395
_currentSessionId++;
396
+ _playbackSessionId = _currentSessionId;
397
398
state = state.copyWith(
399
isSpeaking: true,
@@ -408,17 +422,15 @@ class TtsNotifier extends Notifier<TtsState> {
422
Future<void> _playNextChunk() async {
423
if (_isStopping || _currentChunkIndex >= _chunks.length) return;
424
411
- // Start synthesizing more chunks
425
_enqueueNextChunks();
426
414
- // Check if current chunk is synthesized
427
if (!_synthesizedChunks.containsKey(_currentChunkIndex)) {
428
Log.debug('Waiting for chunk $_currentChunkIndex to be synthesized...');
429
return;
430
}
431
432
final wavBytes = _synthesizedChunks[_currentChunkIndex]!;
421
- _synthesizedChunks.remove(_currentChunkIndex); // Clear memory
433
+ _synthesizedChunks.remove(_currentChunkIndex);
434
435
try {
436
Log.debug(
@@ -426,11 +438,22 @@ class TtsNotifier extends Notifier<TtsState> {
438
);
439
440
await _player.stop();
429
- await _player.setSource(BytesSource(wavBytes));
430
- await _player.resume();
441
+
442
+ final tempDir = Directory.systemTemp;
443
+ final tempFile = File(
444
+ '${tempDir.path}/tts_chunk_${_currentSessionId}_$_currentChunkIndex.wav',
445
+ );
446
+ await tempFile.writeAsBytes(wavBytes);
447
+
448
+ await _player.play(DeviceFileSource(tempFile.path));
449
} catch (e) {
450
Log.error('Error playing chunk $_currentChunkIndex: $e');
433
- _onChunkFinished();
451
+ _currentChunkIndex++;
452
+ if (_currentChunkIndex < _chunks.length) {
453
+ _playNextChunk();
454
+ } else {
455
+ _onPlaybackFinished();
456
+ }
457
}
458
}
459
@@ -442,7 +465,7 @@ class TtsNotifier extends Notifier<TtsState> {
465
final voiceId =
466
_currentVoice ??
467
settings.ttsVoiceId ??
445
- (availableVoices.isNotEmpty ? availableVoices.first.id : '');
468
+ (availableVoices.isNotEmpty ? availableVoices.first.id : null);
469
470
while (_lastEnqueuedChunk < _chunks.length - 1 &&
471
_lastEnqueuedChunk < _currentChunkIndex + _lookAheadCount) {
@@ -454,7 +477,7 @@ class TtsNotifier extends Notifier<TtsState> {
477
_workerSendPort!.send(
478
TtsSynthesizeRequest(
479
text: text,
457
- voiceId: voiceId,
480
+ voiceId: voiceId ?? '',
481
speed: settings.ttsSpeed,
482
chunkIndex: chunkIndex,
483
sessionId: _currentSessionId,
@@ -468,7 +491,6 @@ class TtsNotifier extends Notifier<TtsState> {
491
if (_isPlayerDisposed) return;
492
try {
493
await _player.stop();
471
- // Don't release here, as it makes restarting slower and can cause state issues
494
} catch (_) {}
495
await _flutterTts?.stop();
496
state = state.copyWith(
pubspec.lock
+8
-8
@@ -801,10 +801,10 @@ packages:
801
dependency: transitive
802
description:
803
name: matcher
804
- sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
804
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
805
url: "https://pub.dev"
806
source: hosted
807
- version: "0.12.18"
807
+ version: "0.12.19"
808
material_color_utilities:
809
dependency: transitive
810
description:
@@ -1351,26 +1351,26 @@ packages:
1351
dependency: transitive
1352
description:
1353
name: test
1354
- sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
1354
+ sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
1355
url: "https://pub.dev"
1356
source: hosted
1357
- version: "1.29.0"
1357
+ version: "1.30.0"
1358
test_api:
1359
dependency: transitive
1360
description:
1361
name: test_api
1362
- sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
1362
+ sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
1363
url: "https://pub.dev"
1364
source: hosted
1365
- version: "0.7.9"
1365
+ version: "0.7.10"
1366
test_core:
1367
dependency: transitive
1368
description:
1369
name: test_core
1370
- sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
1370
+ sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
1371
url: "https://pub.dev"
1372
source: hosted
1373
- version: "0.6.15"
1373
+ version: "0.6.16"
1374
theme_extensions_builder_annotation:
1375
dependency: transitive
1376
description:
test/features/chat/tools/chat_service_adapter_selection_test.dart
new
+25
@@ -0,0 +1,25 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/features/chat/data/chat_service.dart';
4
+import 'package:localmind/features/chat/data/tools/adapters/ollama_tool_adapter.dart';
5
+import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
6
+import 'package:localmind/features/chat/data/tools/adapters/openrouter_tool_adapter.dart';
7
+
8
+void main() {
9
+ group('createAdapterForServerType', () {
10
+ test('uses OpenAI adapter for OpenAI-compatible servers', () {
11
+ final adapter = createAdapterForServerType(ServerType.openAICompatible);
12
+ expect(adapter, isA<OpenAiToolAdapter>());
13
+ });
14
+
15
+ test('uses OpenRouter adapter for OpenRouter servers', () {
16
+ final adapter = createAdapterForServerType(ServerType.openRouter);
17
+ expect(adapter, isA<OpenRouterToolAdapter>());
18
+ });
19
+
20
+ test('uses Ollama adapter for Ollama servers', () {
21
+ final adapter = createAdapterForServerType(ServerType.ollama);
22
+ expect(adapter, isA<OllamaToolAdapter>());
23
+ });
24
+ });
25
+}
test/features/chat/tools/lm_studio_tool_adapter_test.dart
new
+40
@@ -0,0 +1,40 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/data/tools/adapters/lm_studio_tool_adapter.dart';
3
+
4
+void main() {
5
+ group('LmStudioToolAdapter', () {
6
+ test('maps tool_call.success event to completed call', () {
7
+ final adapter = LmStudioToolAdapter();
8
+ adapter.consumeEvent('tool_call.success', {
9
+ 'tool': 'calc.add',
10
+ 'arguments': {'a': 1, 'b': 2},
11
+ 'output': '3',
12
+ });
13
+ final calls = adapter.takeServerExecutedCalls();
14
+ expect(calls.length, 1);
15
+ expect(calls.single.name, 'calc.add');
16
+ expect(calls.single.output, '3');
17
+ });
18
+
19
+ test('tracks lifecycle across start-arguments-success events', () {
20
+ final adapter = LmStudioToolAdapter();
21
+ adapter.consumeEvent('tool_call.start', {'tool': 'calc.add'});
22
+ adapter.consumeEvent('tool_call.arguments', {'arguments': {'a': 1, 'b': 2}});
23
+ adapter.consumeEvent('tool_call.success', {'output': '3'});
24
+ final calls = adapter.takeServerExecutedCalls();
25
+ expect(calls.length, 1);
26
+ expect(calls.single.output, '3');
27
+ });
28
+
29
+ test('handles tool_call.failure events', () {
30
+ final adapter = LmStudioToolAdapter();
31
+ adapter.consumeEvent('tool_call.failure', {
32
+ 'tool': 'calc.add',
33
+ 'reason': 'Division by zero',
34
+ });
35
+ final calls = adapter.takeServerExecutedCalls();
36
+ expect(calls.length, 1);
37
+ expect(calls.single.output, 'Division by zero');
38
+ });
39
+ });
40
+}
test/features/chat/tools/ollama_tool_adapter_test.dart
new
+42
@@ -0,0 +1,42 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/data/tools/adapters/ollama_tool_adapter.dart';
3
+import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4
+
5
+void main() {
6
+ group('OllamaToolAdapter', () {
7
+ test('parses tool calls from final message', () {
8
+ final adapter = OllamaToolAdapter();
9
+ adapter.consumeDynamicChunk({
10
+ 'message': {
11
+ 'role': 'assistant',
12
+ 'content': '',
13
+ 'tool_calls': [
14
+ {
15
+ 'id': 'tc_1',
16
+ 'function': {'name': 'calc.add', 'arguments': '{"a":1,"b":2}'},
17
+ },
18
+ ],
19
+ },
20
+ });
21
+
22
+ final calls = adapter.takeCompletedCalls();
23
+ expect(calls.length, 1);
24
+ expect(calls.single.name, 'calc.add');
25
+ expect(calls.single.arguments['a'], 1);
26
+ });
27
+
28
+ test('builds tool result message', () {
29
+ final adapter = OllamaToolAdapter();
30
+ final msg = adapter.buildToolResultMessage(
31
+ call: const ParsedToolCall(
32
+ id: 'tc_1',
33
+ name: 'calc.add',
34
+ arguments: {'a': 1},
35
+ ),
36
+ output: '1',
37
+ );
38
+ expect(msg['role'], 'tool');
39
+ expect(msg['tool_name'], 'calc.add');
40
+ });
41
+ });
42
+}
test/features/chat/tools/on_device_tool_adapter_test.dart
new
+22
@@ -0,0 +1,22 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/data/tools/adapters/on_device_tool_adapter.dart';
3
+
4
+void main() {
5
+ group('OnDeviceToolAdapter', () {
6
+ test('collects function calls and returns them', () {
7
+ final adapter = OnDeviceToolAdapter();
8
+ adapter.consumeFunctionCall('calc.add', {'a': 1, 'b': 2});
9
+ final calls = adapter.takeCompletedCalls();
10
+ expect(calls.length, 1);
11
+ expect(calls.single.name, 'calc.add');
12
+ expect(calls.single.arguments['a'], 1);
13
+ });
14
+
15
+ test('clears state after takeCompletedCalls', () {
16
+ final adapter = OnDeviceToolAdapter();
17
+ adapter.consumeFunctionCall('calc.add', {'a': 1, 'b': 2});
18
+ adapter.takeCompletedCalls();
19
+ expect(adapter.takeCompletedCalls(), isEmpty);
20
+ });
21
+ });
22
+}
test/features/chat/tools/openai_tool_adapter_test.dart
new
+73
@@ -0,0 +1,73 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
3
+import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
5
+
6
+void main() {
7
+ group('OpenAiToolAdapter', () {
8
+ test('reconstructs tool call arguments from delta chunks', () {
9
+ final adapter = OpenAiToolAdapter();
10
+ adapter.consumeDynamicChunk({
11
+ 'choices': [
12
+ {
13
+ 'delta': {
14
+ 'tool_calls': [
15
+ {
16
+ 'index': 0,
17
+ 'id': 'call_1',
18
+ 'type': 'function',
19
+ 'function': {'name': 'calc.add', 'arguments': ''},
20
+ },
21
+ ],
22
+ },
23
+ },
24
+ ],
25
+ });
26
+ adapter.consumeDynamicChunk({
27
+ 'choices': [
28
+ {
29
+ 'delta': {
30
+ 'tool_calls': [
31
+ {'index': 0, 'function': {'arguments': '{"a":1,"b":2}'}},
32
+ ],
33
+ },
34
+ },
35
+ ],
36
+ });
37
+
38
+ final calls = adapter.takeCompletedCalls();
39
+ expect(calls.length, 1);
40
+ expect(calls.single.name, 'calc.add');
41
+ expect(calls.single.arguments['a'], 1);
42
+ });
43
+
44
+ test('builds tool definition payload', () {
45
+ final adapter = OpenAiToolAdapter();
46
+ final payload = adapter.buildToolDefinitionPayload([
47
+ const ToolDefinition(
48
+ name: 'calc.add',
49
+ description: 'Add two numbers',
50
+ inputSchema: {'type': 'object'},
51
+ providerType: ToolProviderType.builtIn,
52
+ ),
53
+ ]);
54
+ expect(payload['tools'], isA<List>());
55
+ expect(payload['tools'][0]['function']['name'], 'calc.add');
56
+ });
57
+
58
+ test('builds tool result message', () {
59
+ final adapter = OpenAiToolAdapter();
60
+ final msg = adapter.buildToolResultMessage(
61
+ call: const ParsedToolCall(
62
+ id: 'call_1',
63
+ name: 'calc.add',
64
+ arguments: {'a': 1, 'b': 2},
65
+ ),
66
+ output: '3',
67
+ );
68
+ expect(msg['role'], 'tool');
69
+ expect(msg['tool_call_id'], 'call_1');
70
+ expect(msg['content'], '3');
71
+ });
72
+ });
73
+}
test/features/chat/tools/tool_execution_loop_test.dart
new
+163
@@ -0,0 +1,163 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
3
+import 'package:localmind/features/chat/data/tools/tool_execution_loop.dart';
4
+import 'package:localmind/features/chat/data/tools/tool_registry.dart';
5
+import 'package:localmind/features/chat/data/tools/builtin_tool_provider.dart';
6
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
7
+import 'package:localmind/features/chat/data/tools/tool_event.dart';
8
+
9
+class FakeToolAdapter implements ToolTransportAdapter {
10
+ final List<ParsedToolCall> callsToReturn;
11
+ int callCount = 0;
12
+
13
+ FakeToolAdapter.singleToolCall(String name, Map<String, dynamic> args)
14
+ : callsToReturn = [ParsedToolCall(id: 'call_1', name: name, arguments: args)];
15
+
16
+ @override
17
+ Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
18
+ return {'tools': []};
19
+ }
20
+
21
+ @override
22
+ void consumeDynamicChunk(Map<String, dynamic> chunk) {}
23
+
24
+ @override
25
+ List<ParsedToolCall> takeCompletedCalls() {
26
+ if (callCount < callsToReturn.length) {
27
+ callCount++;
28
+ return [callsToReturn[callCount - 1]];
29
+ }
30
+ return const [];
31
+ }
32
+
33
+ @override
34
+ Map<String, dynamic> buildToolResultMessage({
35
+ required ParsedToolCall call,
36
+ required String output,
37
+ }) {
38
+ return {'role': 'tool', 'tool_call_id': call.id, 'content': output};
39
+ }
40
+}
41
+
42
+void main() {
43
+ group('ToolExecutionLoop', () {
44
+ test('executes requested tool and returns completed event', () async {
45
+ final loop = ToolExecutionLoop(
46
+ adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
47
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
48
+ );
49
+
50
+ final result = await loop.run(
51
+ initialUserMessage: 'add 1 and 2',
52
+ assistantContent: 'The result is 3',
53
+ );
54
+ expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
55
+ expect(result.finalAssistantContent, 'The result is 3');
56
+ });
57
+
58
+ test('handles tool that returns error', () async {
59
+ final loop = ToolExecutionLoop(
60
+ adapter: FakeToolAdapter.singleToolCall('unknown.tool', {}),
61
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
62
+ );
63
+
64
+ final result = await loop.run(initialUserMessage: 'do something');
65
+ expect(result.events.any((e) => e.status == ToolEventStatus.failed), true);
66
+ });
67
+
68
+ test('respects maxIterations', () async {
69
+ final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
70
+ final loop = ToolExecutionLoop(
71
+ adapter: adapter,
72
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
73
+ maxIterations: 1,
74
+ );
75
+
76
+ final result = await loop.run(initialUserMessage: 'add');
77
+ expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
78
+ expect(result.toolCallCount, greaterThan(0));
79
+ });
80
+
81
+ test('creates timeline with event IDs', () async {
82
+ final loop = ToolExecutionLoop(
83
+ adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
84
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
85
+ );
86
+
87
+ final result = await loop.run(initialUserMessage: 'add');
88
+ expect(result.events.first.eventId, isNotEmpty);
89
+ });
90
+
91
+ test('emits approved event when approval callback returns true', () async {
92
+ final loop = ToolExecutionLoop(
93
+ adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
94
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
95
+ onRequestApproval: (call) async => true,
96
+ );
97
+
98
+ final result = await loop.run(initialUserMessage: 'add');
99
+ expect(result.events.any((e) => e.status == ToolEventStatus.approved), true);
100
+ expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
101
+ });
102
+
103
+ test('rejects tool when approval callback returns false', () async {
104
+ final loop = ToolExecutionLoop(
105
+ adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
106
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
107
+ onRequestApproval: (call) async => false,
108
+ );
109
+
110
+ final result = await loop.run(initialUserMessage: 'add');
111
+ expect(result.events.any((e) => e.status == ToolEventStatus.rejected), true);
112
+ expect(result.events.any((e) => e.status == ToolEventStatus.completed), false);
113
+ });
114
+
115
+ test('loop runs with tool calls and returns events with assistant content', () async {
116
+ final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
117
+ final loop = ToolExecutionLoop(
118
+ adapter: adapter,
119
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
120
+ );
121
+
122
+ final result = await loop.run(
123
+ initialUserMessage: 'hello',
124
+ assistantContent: 'Hi there!',
125
+ );
126
+ expect(result.finalAssistantContent, 'Hi there!');
127
+ expect(result.events, isNotEmpty);
128
+ });
129
+
130
+ test('loop processes preParsedCalls when provided', () async {
131
+ final loop = ToolExecutionLoop(
132
+ adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
133
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
134
+ );
135
+
136
+ final result = await loop.run(
137
+ initialUserMessage: 'add',
138
+ preParsedCalls: [
139
+ const ParsedToolCall(id: 'pc_1', name: 'calc.add', arguments: {'a': 1, 'b': 2}),
140
+ ],
141
+ );
142
+ expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
143
+ expect(result.toolCallCount, 1);
144
+ });
145
+
146
+ test('loop returns empty result when no tool calls', () async {
147
+ final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
148
+ final loop = ToolExecutionLoop(
149
+ adapter: adapter,
150
+ registry: ToolRegistry(providers: [BuiltInToolProvider()]),
151
+ );
152
+
153
+ final result = await loop.run(
154
+ initialUserMessage: 'hello',
155
+ assistantContent: 'No tools needed',
156
+ preParsedCalls: const [],
157
+ );
158
+ expect(result.events, isEmpty);
159
+ expect(result.finalAssistantContent, 'No tools needed');
160
+ expect(result.toolCallCount, 0);
161
+ });
162
+ });
163
+}
test/features/chat/tools/tool_registry_test.dart
new
+53
@@ -0,0 +1,53 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/features/chat/data/models/message.dart';
4
+import 'package:localmind/features/chat/data/tools/builtin_tool_provider.dart';
5
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
6
+import 'package:localmind/features/chat/data/tools/tool_event.dart';
7
+import 'package:localmind/features/chat/data/tools/tool_registry.dart';
8
+
9
+void main() {
10
+ test('Message preserves toolEvents in copyWith', () {
11
+ final msg = Message(
12
+ id: 'm1',
13
+ conversationId: 'c1',
14
+ role: MessageRole.assistant,
15
+ content: 'hi',
16
+ createdAt: DateTime(2026, 1, 1),
17
+ toolEvents: [
18
+ ToolEvent.requested(
19
+ eventId: 'e1',
20
+ toolName: 'calc.add',
21
+ providerType: ToolProviderType.builtIn,
22
+ ),
23
+ ],
24
+ );
25
+
26
+ expect(msg.copyWith().toolEvents!.length, 1);
27
+ });
28
+
29
+ test('registry resolves built-in tools with allow-list filtering', () async {
30
+ final registry = ToolRegistry(
31
+ providers: [BuiltInToolProvider()],
32
+ );
33
+ final tools = await registry.listTools(allowedTools: {'calc.add'});
34
+ expect(tools.map((t) => t.name), ['calc.add']);
35
+ });
36
+
37
+ test('registry executes tool from correct provider', () async {
38
+ final registry = ToolRegistry(
39
+ providers: [BuiltInToolProvider()],
40
+ );
41
+ final result = await registry.execute('calc.add', {'a': 1, 'b': 2});
42
+ expect(result.success, true);
43
+ expect(result.output, '3');
44
+ });
45
+
46
+ test('registry returns failure for unknown tool', () async {
47
+ final registry = ToolRegistry(
48
+ providers: [BuiltInToolProvider()],
49
+ );
50
+ final result = await registry.execute('unknown.tool', {});
51
+ expect(result.success, false);
52
+ });
53
+}