@tduncan / Cortana / commits / 1fc9c54

fix #76, fix #77: guard async ref usage and resend MCP tool results

Issue #76 — Riverpod "ref used on a deactivated widget" crashes - chat_screen.dart: guard _approvalDialog `.then` against unmount so the post-await ref.read(chatProvider.notifier).approveTool() never runs on a disposed State. - chat_input_bar.dart: convert async ref.listen callback to synchronous + Future.microtask so the auto-TTS restart branch can safely touch ref/state after the await window. - message_action_bar.dart, message_area.dart, notification_permission_banner.dart, screen_app_bar.dart, cloud_sync_lifecycle_host.dart, conversation_list.dart, lm_studio_model_browser_screen.dart, model_card.dart, model_manager_screen.dart, onboarding_model_download_screen.dart, save_message_sheet.dart, saved_messages_screen.dart, add_server_screen.dart, data_backup_actions.dart, tts_model_manager_screen.dart, voice_mode_overlay.dart: add `if (!mounted) return;` / `if (!context.mounted) return;` between awaits and any subsequent ref.read / setState / context.pop calls so we never touch ref on a deactivated widget. Issue #77 — MCP tool result never sent back to the model - chat_notifier.dart: when ToolExecutionLoop returns completed events, the streaming onDone callback now invokes _sendFollowupWithToolResults instead of ending the turn. The new helper: * mints a stable `call_<eventId>_completed` id and reuses it for both the assistant message's toolCalls[].id and the tool-role message's tool_call_id (OpenAI-compatible pairing invariant); * updates the assistant message with toolCalls + stopReason='tool_use' and persists it; * emits one MessageRole.tool message per completed event with the matching toolCallId, parentMessageId, variantGroupId, and thread order, then persists them and merges into state.allMessages so the next _buildMessagesForApi call includes the tool history; * creates a continuation assistant message sharing the variant group, flips isStreamingProvider on, starts chatBackgroundServiceProvider, persists it, and re-invokes _runAssistantStream to issue the fresh chat completion. If the model emits another tool call the same onDone path runs again — recursion is bounded by ToolExecutionLoop.maxIterations. - test/features/chat/mcp_tool_result_followup_test.dart: regression test that mirrors the production message-construction logic and asserts: tool_call_id ↔ toolCalls[].id pairing, unique IDs across multiple tools, failed-tool exclusion, and stopReason='tool_use'. Co-Authored-By: puku-ai-2.8 <noreply@puku.sh>

abdulmominsakib committed Sep 5, 2026 at 21:01 UTC 1fc9c54e93fcdc7bd195ec21b5ff11f7da13a1c4
20 files changed +554 -32
lib/features/chat/providers/chat_notifier.dart
+187
@@ -25,7 +25,11 @@ import 'package:localmind/features/servers/data/models/server.dart';
25 import 'package:localmind/features/servers/providers/server_providers.dart';
26 import 'package:localmind/objectbox.g.dart';
27 import '../data/chat_service.dart';
28 +import '../data/models/chat_parameters.dart';
29 +import '../data/models/mcp_integration.dart';
30 import '../data/models/message.dart' hide ToolCallData;
31 +import '../data/models/message.dart' as msg_model
32 + show ToolCallData;
33 import '../data/title_generation_service.dart';
34 import '../data/tools/tool_definition.dart';
35 import '../data/tools/tool_event.dart';
@@ -1226,6 +1230,49 @@ class ChatNotifier extends Notifier<ChatState> {
1230 toolEvents: toolEvents,
1231 );
1232 }
1233 +
1234 + // After successful tool execution the chat must
1235 + // continue: the model has emitted a tool call, the
1236 + // client (or server) executed it, and now we need to
1237 + // send a follow-up chat completion containing the
1238 + // tool-role result(s) so the model can produce the
1239 + // final answer. Without this, the conversation just
1240 + // ends as soon as the tool returns — see issue #77.
1241 + final completedResults = toolEvents
1242 + .where((e) => e.status == ToolEventStatus.completed)
1243 + .toList();
1244 + if (ref.mounted &&
1245 + completedResults.isNotEmpty &&
1246 + chatService != null) {
1247 + await _sendFollowupWithToolResults(
1248 + previousAssistant: finalMessage,
1249 + toolEvents: toolEvents,
1250 + server: server,
1251 + selectedModel: selectedModel,
1252 + effectiveModelId: effectiveModelId,
1253 + chatService: chatService,
1254 + chatParams: chatParams,
1255 + tools: tools,
1256 + integrations: integrations,
1257 + streamConvId: streamConvId,
1258 + isCurrentContext: isCurrentContext,
1259 + );
1260 + // After the follow-up the original `finalMessage`
1261 + // already contains its `toolEvents` and is on disk;
1262 + // mark it as final and bail out of the rest of the
1263 + // post-stream bookkeeping that was meant for the
1264 + // first turn only.
1265 + if (ref.mounted) {
1266 + await _saveMessage(finalMessage);
1267 + if (isCurrentContext) {
1268 + _replaceMessageInState(
1269 + finalMessage,
1270 + clearStreaming: true,
1271 + );
1272 + }
1273 + }
1274 + return;
1275 + }
1276 } catch (e) {
1277 Log.error('Tool execution loop failed: $e');
1278 }
@@ -2015,6 +2062,146 @@ class ChatNotifier extends Notifier<ChatState> {
2062 await _runAssistantStream(assistantMessage, selectedModel);
2063 }
2064
2065 + /// After successful tool execution (issue #77), build `tool` role messages
2066 + /// for each completed tool event, persist them, and re-issue the chat
2067 + /// completion so the model can produce the final answer.
2068 + ///
2069 + /// The assistant message emitted in the first turn has its `toolCalls`
2070 + /// field populated with the executed results, plus matching `MessageRole.tool`
2071 + /// messages are appended right after it so the OpenAI-compatible protocol
2072 + /// gets the `tool_call_id` ↔ `tool_calls[].id` pairing the model needs to
2073 + /// continue the turn.
2074 + Future<void> _sendFollowupWithToolResults({
2075 + required Message previousAssistant,
2076 + required List<ToolEvent> toolEvents,
2077 + required Server server,
2078 + required ModelInfo? selectedModel,
2079 + required String effectiveModelId,
2080 + required ChatService chatService,
2081 + required ChatParameters chatParams,
2082 + required List<ToolDefinition> tools,
2083 + required List<McpIntegration>? integrations,
2084 + required String streamConvId,
2085 + required bool isCurrentContext,
2086 + }) async {
2087 + if (!ref.mounted) return;
2088 +
2089 + final completed = toolEvents
2090 + .where((e) => e.status == ToolEventStatus.completed)
2091 + .toList();
2092 + if (completed.isEmpty) return;
2093 +
2094 + // Synthesize stable tool-call IDs that match between the assistant
2095 + // message's `tool_calls[].id` and the tool role message's `tool_call_id`.
2096 + // The streaming-side `ToolCallData` doesn't carry an OpenAI `id`, so we
2097 + // mint one here and reuse it on both sides.
2098 + String toolCallIdFor(ToolEvent event) {
2099 + final uniqueSuffix = event.eventId.isNotEmpty
2100 + ? event.eventId
2101 + : '${event.toolName}_${event.timestamp.microsecondsSinceEpoch}';
2102 + return 'call_${uniqueSuffix}_completed';
2103 + }
2104 +
2105 + final toolCallEntries = completed
2106 + .map(
2107 + (e) => msg_model.ToolCallData(
2108 + id: toolCallIdFor(e),
2109 + toolName: e.toolName,
2110 + arguments: e.arguments ?? const {},
2111 + result: e.result,
2112 + ),
2113 + )
2114 + .toList();
2115 +
2116 + final assistantWithToolCalls = previousAssistant.copyWith(
2117 + toolCalls: toolCallEntries,
2118 + stopReason: 'tool_use',
2119 + );
2120 + await _saveMessage(assistantWithToolCalls);
2121 + if (!ref.mounted) return;
2122 + if (isCurrentContext) {
2123 + _replaceMessageInAll(assistantWithToolCalls, clearStreaming: true);
2124 + }
2125 +
2126 + final toolMessages = completed.map((e) {
2127 + final callId = toolCallIdFor(e);
2128 + return Message(
2129 + id: 'tool_${callId}_${e.timestamp.microsecondsSinceEpoch}',
2130 + conversationId: previousAssistant.conversationId,
2131 + role: MessageRole.tool,
2132 + content: e.result ?? e.error ?? '',
2133 + createdAt: e.timestamp,
2134 + status: MessageStatus.complete,
2135 + toolCallId: callId,
2136 + toolSessionId: previousAssistant.toolSessionId,
2137 + parentMessageId: previousAssistant.id,
2138 + threadOrder: previousAssistant.threadOrder + 0,
2139 + variantGroupId: previousAssistant.variantGroupId,
2140 + variantIndex: previousAssistant.variantIndex,
2141 + );
2142 + }).toList();
2143 +
2144 + for (final tm in toolMessages) {
2145 + await _saveMessage(tm);
2146 + }
2147 +
2148 + if (!ref.mounted) return;
2149 +
2150 + // Persist the assistant+tool messages into state.allMessages so future
2151 + // `_buildMessagesForApi(selectedModel)` calls (used by the follow-up
2152 + // stream) include them. Without this the next request would be missing
2153 + // the tool history and the model would re-emit the same tool call.
2154 + final newAll = [...state.allMessages, ...toolMessages];
2155 + final activeTimeline = MessageVariants.resolveActiveTimeline(newAll);
2156 + state = state.copyWith(
2157 + allMessages: newAll,
2158 + messages: activeTimeline,
2159 + );
2160 +
2161 + // Create a new assistant message that will receive the model's final
2162 + // answer. It shares the variant group with the previous assistant turn
2163 + // so users see a single grouped assistant card that contains both the
2164 + // tool-call turn and the follow-up answer.
2165 + final continuationThreadOrder = MessageVariants.nextThreadOrder(
2166 + state.allMessages,
2167 + );
2168 + final continuationMessage = Message(
2169 + id: generateUuid(),
2170 + conversationId: previousAssistant.conversationId,
2171 + role: MessageRole.assistant,
2172 + content: '',
2173 + createdAt: DateTime.now(),
2174 + status: MessageStatus.streaming,
2175 + modelId: effectiveModelId,
2176 + variantGroupId: previousAssistant.variantGroupId,
2177 + variantIndex: previousAssistant.variantIndex + 1,
2178 + threadOrder: continuationThreadOrder,
2179 + isActiveVariant: true,
2180 + parentMessageId: previousAssistant.id,
2181 + );
2182 +
2183 + final newAllWithAssistant = [...newAll, continuationMessage];
2184 + state = state.copyWith(
2185 + allMessages: newAllWithAssistant,
2186 + messages: MessageVariants.resolveActiveTimeline(newAllWithAssistant),
2187 + isStreaming: true,
2188 + streamingMessage: continuationMessage,
2189 + clearStreaming: false,
2190 + );
2191 + ref.read(isStreamingProvider.notifier).setStreaming(true);
2192 + ref.read(chatBackgroundServiceProvider).start();
2193 + await _saveMessage(continuationMessage);
2194 + if (!ref.mounted) return;
2195 +
2196 + // Reuse the streaming logic — `_runAssistantStream` builds the API
2197 + // message list from state, which now contains the tool history, and
2198 + // will issue a fresh chat completion. If the model emits another tool
2199 + // call, the same onDone flow will execute it again until the model
2200 + // yields a final answer or `maxIterations` (enforced inside
2201 + // `ToolExecutionLoop`) is reached.
2202 + await _runAssistantStream(continuationMessage, selectedModel);
2203 + }
2204 +
2205 Future<void> _runAssistantStream(
2206 Message assistantMessage,
2207 ModelInfo? selectedModel, {
lib/features/chat/views/chat_screen.dart
+5 -1
@@ -247,7 +247,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
247 ],
248 ),
249 ).then((value) {
250 - if (mounted) setState(() => _isApprovalDialogOpen = false);
250 + // `mounted` must guard every ref-touching branch here, not just the
251 + // setState — calling `ref.read` on a deactivated State throws
252 + // "Using 'ref' when a widget is about to or has been unmounted is unsafe".
253 + if (!mounted) return;
254 + setState(() => _isApprovalDialogOpen = false);
255 ref.read(chatProvider.notifier).approveTool(value ?? false);
256 });
257 }
lib/features/chat/views/components/chat_input_bar.dart
+26 -13
@@ -854,29 +854,42 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
854 // Voice-to-voice: when the LLM stops producing output and any auto-TTS
855 // playback has finished (or was never scheduled), restart the mic so the
856 // user can speak again without pressing the mic button.
857 - ref.listen<bool>(isStreamingProvider, (previous, next) async {
858 - if (previous == true && next == false && mounted) {
859 - if (!ref.read(settingsProvider).autoSpeakEnabled) return;
860 - final voiceState = ref.read(voiceModeProvider);
861 - final voiceActive =
862 - voiceState.isActive || voiceState.phase != VoiceModePhase.idle;
863 - if (voiceActive) return;
864 - final ttsSpeaking = ref.read(ttsProvider).isSpeaking;
865 - final sttActive = ref.read(sttProvider).isListening;
866 - if (ttsSpeaking || sttActive) return;
857 + //
858 + // NOTE: This callback is intentionally not async. Riverpod's `ref.listen`
859 + // subscribes synchronously and tears the listener down when the widget
860 + // deactivates, but an async listener that touches `ref` after an `await`
861 + // throws
862 + // "Using 'ref' when a widget is about to or has been unmounted is unsafe"
863 + // if the State deactivates between the await and the next ref read.
864 + // Schedule the actual restart on the microtask queue instead.
865 + ref.listen<bool>(isStreamingProvider, (previous, next) {
866 + if (previous != true || next != false || !mounted) return;
867 + final shouldAutoSpeak =
868 + ref.read(settingsProvider).autoSpeakEnabled;
869 + final voiceState = ref.read(voiceModeProvider);
870 + final voiceActive =
871 + voiceState.isActive || voiceState.phase != VoiceModePhase.idle;
872 + final ttsSpeaking = ref.read(ttsProvider).isSpeaking;
873 + final sttActive = ref.read(sttProvider).isListening;
874 + if (!shouldAutoSpeak || voiceActive || ttsSpeaking || sttActive) return;
875
876 + _preSpeechText = _controller.text;
877 + final preSpeechText = _preSpeechText;
878 + Future.microtask(() async {
879 // Small delay so the OS finishes releasing the audio session before
880 // we ask for the microphone again.
881 await Future.delayed(const Duration(milliseconds: 200));
882 if (!mounted) return;
883 if (ref.read(sttProvider).isListening) return;
884 if (ref.read(isStreamingProvider)) return;
874 -
875 - _preSpeechText = _controller.text;
885 await ref
886 .read(sttProvider.notifier)
887 .startListening(onResult: _onSpeechResult);
879 - }
888 + // Ensure _preSpeechText didn't drift while the async chain ran.
889 + if (mounted && _preSpeechText == preSpeechText) {
890 + _preSpeechText = _controller.text;
891 + }
892 + });
893 });
894
895 final canSend =
lib/features/chat/views/components/message_action_bar.dart
+3
@@ -64,6 +64,9 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
64 } else {
65 if (ttsState.isSpeaking || ttsState.isPaused) {
66 await ttsNotifier.stop();
67 + // Guard every ref-touching statement post-await — the user can
68 + // unmount this message's action bar while TTS is stopping.
69 + if (!mounted) return;
70 }
71 try {
72 await ttsNotifier.speak(
lib/features/chat/views/components/message_list/message_area.dart
+4
@@ -119,6 +119,9 @@ class MessageArea extends ConsumerWidget {
119 initialContent: currentContent,
120 );
121 if (result == null || result.content == currentContent) return;
122 + // Guard every ref-touching statement post-await — the user can
123 + // pop this screen or open another conversation mid-edit.
124 + if (!context.mounted) return;
125 if (result.regenerate) {
126 await ref
127 .read(chatProvider.notifier)
@@ -137,6 +140,7 @@ class MessageArea extends ConsumerWidget {
140 description: editL10n.edit_assistant_message_desc,
141 saveLabel: editL10n.save,
142 );
143 + if (!context.mounted) return;
144 if (newContent != null && newContent != currentContent) {
145 await ref
146 .read(chatProvider.notifier)
lib/features/chat/views/components/notification_permission_banner.dart
+5
@@ -41,6 +41,9 @@ class _NotificationPermissionBannerState
41 if (!granted) {
42 setState(() => _isVisible = true);
43 } else {
44 + // Guard every ref access after an await — the State may have been
45 + // deactivated while the permission request was in flight.
46 + if (!mounted) return;
47 await ref
48 .read(settingsProvider.notifier)
49 .updateSettings(settings.copyWith(hasAskedForNotifications: true));
@@ -50,6 +53,7 @@ class _NotificationPermissionBannerState
53 Future<void> _requestPermission() async {
54 final service = ref.read(notificationPermissionServiceProvider);
55 await service.requestPermission();
56 + if (!mounted) return;
57 await _dismiss(true);
58 }
59
@@ -58,6 +62,7 @@ class _NotificationPermissionBannerState
62 await ref
63 .read(settingsProvider.notifier)
64 .updateSettings(settings.copyWith(hasAskedForNotifications: true));
65 + if (!mounted) return;
66 setState(() => _isVisible = false);
67 }
68
lib/features/chat/views/components/top_bar/screen_app_bar.dart
+3
@@ -299,6 +299,9 @@ class ScreenAppBar extends ConsumerWidget {
299 for (final id in selectedIds) {
300 await ref.read(chatProvider.notifier).deleteMessage(id);
301 }
302 + // Guard every ref-touching statement post-await — the
303 + // user can dismiss this screen while the deletions run.
304 + if (!context.mounted) return;
305 ref.read(messageSelectionModeProvider.notifier).disable();
306 },
307 style: TextButton.styleFrom(foregroundColor: Colors.red),
lib/features/cloud_sync/views/cloud_sync_lifecycle_host.dart
+8
@@ -42,6 +42,10 @@ class _CloudSyncLifecycleHostState extends ConsumerState<CloudSyncLifecycleHost>
42 .store
43 .entityChanges
44 .listen((types) {
45 + // The stream may emit one last event between cancel() and full
46 + // shutdown, so guard every ref access even though we cancel in
47 + // dispose.
48 + if (!mounted) return;
49 if (types.any(_syncedTypes.contains)) {
50 ref.read(cloudSyncControllerProvider.notifier).scheduleSync();
51 }
@@ -50,6 +54,10 @@ class _CloudSyncLifecycleHostState extends ConsumerState<CloudSyncLifecycleHost>
54 ref.read(cloudSyncControllerProvider.notifier).scheduleSync();
55 });
56 WidgetsBinding.instance.addPostFrameCallback((_) {
57 + // Same guard — the post-frame callback runs after initState returns,
58 + // and the State may already be deactivated if the host was rebuilt
59 + // and torn down quickly.
60 + if (!mounted) return;
61 ref
62 .read(cloudSyncControllerProvider.notifier)
63 .scheduleSync(delay: Duration.zero);
lib/features/conversations/views/components/conversation_list.dart
+4
@@ -367,9 +367,13 @@ Future<void> runBulkAiRename(
367
368 for (final id in conversationIds) {
369 try {
370 + // Guard every ref-touching statement post-await — the user may pop
371 + // this screen while the AI rename loop is running.
372 + if (!context.mounted) return;
373 final title = await ref
374 .read(chatProvider.notifier)
375 .generateTitleWithAi(id);
376 + if (!context.mounted) return;
377 if (title != null && title.isNotEmpty) {
378 await ref
379 .read(conversationsProvider.notifier)
lib/features/lm_studio_catalog/views/lm_studio_model_browser_screen.dart
+3
@@ -62,6 +62,9 @@ class _LmStudioModelBrowserScreenState
62 }
63
64 void _onListScroll() {
65 + // The scroll listener can fire one last time during dispose, after which
66 + // the State's `ref` is no longer safe to use.
67 + if (!mounted) return;
68 if (_searchQuery.trim().isEmpty) return;
69 if (!_listScrollController.hasClients) return;
70 final pos = _listScrollController.position;
lib/features/on_device/components/model_card.dart
+8
@@ -564,22 +564,30 @@ class _DownloadedActions extends ConsumerWidget {
564 );
565
566 if (confirm == true) {
567 + // Guard every ref-touching statement post-await — the parent widget
568 + // tree can rebuild and unmount this card during the long-running
569 + // delete operations (especially model-file removal on disk).
570 + if (!context.mounted) return;
571 final engineState = ref.read(onDeviceEngineProvider);
572 if (engineState.loadedModelId == model.id) {
573 await ref.read(onDeviceEngineProvider.notifier).unloadModel();
574 }
575 + if (!context.mounted) return;
576
577 if (model.isImported) {
578 await ref
579 .read(importedGgufModelsProvider.notifier)
580 .deleteModel(model.id);
581 } else if (model.runtime == OnDeviceModelRuntime.mlx) {
582 + if (!context.mounted) return;
583 final mlxService = ref.read(onDeviceMlxServiceProvider);
584 await mlxService.deleteModel(model.id);
585 } else {
586 + if (!context.mounted) return;
587 final gemmaService = ref.read(onDeviceGemmaServiceProvider);
588 await gemmaService.deleteModel(model.id);
589 }
590 + if (!context.mounted) return;
591 ref.invalidate(downloadedModelsProvider);
592 }
593 }
lib/features/on_device/views/model_manager_screen.dart
+6 -3
@@ -31,9 +31,12 @@ class _OnDeviceModelManagerScreenState
31 @override
32 void initState() {
33 super.initState();
34 - Future.microtask(
35 - () => ref.read(importedGgufModelsProvider.notifier).pruneMissing(),
36 - );
34 + Future.microtask(() {
35 + // The microtask runs on the next event loop tick — by then the State
36 + // may already be deactivated, so we must guard the ref access.
37 + if (!mounted) return;
38 + ref.read(importedGgufModelsProvider.notifier).pruneMissing();
39 + });
40 }
41
42 @override
lib/features/onboarding/screens/onboarding_model_download_screen.dart
+2
@@ -195,7 +195,9 @@ class _OnboardingModelDownloadScreenState
195 );
196
197 await ref.read(serversProvider.notifier).addServer(server);
198 + if (!mounted) return;
199 await ref.read(serversProvider.notifier).setDefault(server.id);
200 + if (!mounted) return;
201 ref.read(activeServerIdProvider.notifier).setActiveServer(server);
202
203 if (mounted) {
lib/features/saved_messages/views/components/save_message_sheet.dart
+4
@@ -70,6 +70,9 @@ Future<void> showSaveMessageSheet(
70 await ref
71 .read(savedMessagesProvider.notifier)
72 .removeBySourceMessageId(message.id);
73 + // Guard every ref-touching statement post-await — the
74 + // sheet can be dismissed while the provider mutation runs.
75 + if (!context.mounted) return;
76 ref.invalidate(isMessageSavedProvider(message.id));
77 if (context.mounted) Navigator.pop(context);
78 },
@@ -89,6 +92,7 @@ Future<void> showSaveMessageSheet(
92 folderId: selectedFolderId,
93 isTemporaryChat: isTemporaryChat,
94 );
95 + if (!context.mounted) return;
96 ref.invalidate(isMessageSavedProvider(message.id));
97 if (context.mounted) Navigator.pop(context);
98 },
lib/features/saved_messages/views/saved_messages_screen.dart
+3
@@ -223,6 +223,9 @@ class SavedMessagesScreen extends ConsumerWidget {
223 await ref
224 .read(chatProvider.notifier)
225 .loadConversation(conversation);
226 + // Guard every ref access post-await — the user can pop
227 + // this screen while loadConversation is running.
228 + if (!context.mounted) return;
229 ref
230 .read(chatOriginProvider.notifier)
231 .set(ChatOrigin.savedMessages);
lib/features/servers/views/add_server_screen.dart
+4
@@ -226,10 +226,14 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
226 } else {
227 await ref.read(serversProvider.notifier).addServer(server);
228 }
229 + // Guard every ref-touching statement post-await — the user can pop
230 + // this screen after the provider mutation succeeds.
231 + if (!mounted) return;
232 final apiService = ref.read(serverApiServiceProvider);
233 await ref
234 .read(serversProvider.notifier)
235 .testConnection(server.id, apiService);
236 + if (!mounted) return;
237 invalidateAvailableModelsCache(server.id);
238
239 if (mounted) {
lib/features/settings/views/data_backup_actions.dart
+18 -4
@@ -173,8 +173,9 @@ class DataBackupActions extends ConsumerWidget {
173
174 Future<void> _applySettingsPayload(
175 WidgetRef ref,
176 - Map<String, dynamic> decoded,
177 - ) async {
176 + Map<String, dynamic> decoded, {
177 + required BuildContext context,
178 + }) async {
179 final prefs = ref.read(sharedPreferencesProvider);
180 final settingsMap = decoded['settings'];
181 if (settingsMap is Map) {
@@ -184,6 +185,9 @@ class DataBackupActions extends ConsumerWidget {
185 if (metadata is Map) {
186 await prefs.setString('modelMetadata', jsonEncode(metadata));
187 }
188 + // Guard every ref-touching statement post-await — the
189 + // ConsumerWidget may have been deactivated during the writes.
190 + if (!context.mounted) return;
191 ref.invalidate(settingsProvider);
192 ref.invalidate(serversProvider);
193 }
@@ -210,12 +214,17 @@ class DataBackupActions extends ConsumerWidget {
214 final decoded =
215 jsonDecode(utf8.decode(file.content as List<int>))
216 as Map<String, dynamic>;
213 - await _applySettingsPayload(ref, decoded);
217 + await _applySettingsPayload(ref, decoded, context: context);
218 }
219
220 + // Guard every ref-touching statement post-await — the
221 + // ConsumerWidget may have been deactivated during the import.
222 + if (!context.mounted) return;
223 +
224 // Defer invalidations until after the current build frame so widgets that
225 // watch these providers (e.g. SettingsViews) don't flush them during build.
226 WidgetsBinding.instance.addPostFrameCallback((_) {
227 + if (!context.mounted) return;
228 ref.invalidate(conversationsProvider);
229 ref.invalidate(personasNotifierProvider);
230 ref.invalidate(savedMessagesProvider);
@@ -289,6 +298,9 @@ class DataBackupActions extends ConsumerWidget {
298 ),
299 onImport: () => _importJsonFile(context, ref, (json) async {
300 await service.importFromJson(db.store, json);
301 + // Guard every ref-touching statement post-await — the
302 + // ConsumerWidget may have been deactivated during the import.
303 + if (!context.mounted) return;
304 ref.invalidate(conversationsProvider);
305 ref.invalidate(savedMessagesProvider);
306 }, l10n.import_data_confirm),
@@ -304,6 +316,7 @@ class DataBackupActions extends ConsumerWidget {
316 ),
317 onImport: () => _importJsonFile(context, ref, (json) async {
318 await service.importFromJson(db.store, json);
319 + if (!context.mounted) return;
320 ref.invalidate(personasNotifierProvider);
321 }, l10n.import_data_confirm),
322 ),
@@ -323,7 +336,8 @@ class DataBackupActions extends ConsumerWidget {
336 onImport: () => _importJsonFile(context, ref, (json) async {
337 final decoded = jsonDecode(json) as Map<String, dynamic>;
338 await service.importFromJson(db.store, json);
326 - await _applySettingsPayload(ref, decoded);
339 + if (!context.mounted) return;
340 + await _applySettingsPayload(ref, decoded, context: context);
341 }, l10n.import_settings_confirm),
342 ),
343 const SizedBox(height: 8),
lib/features/tts/views/tts_model_manager_screen.dart
+9 -8
@@ -1086,19 +1086,20 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
1086 final l10n = AppLocalizations.of(context)!;
1087 if (_playingVoice == voice) {
1088 await ref.read(tts.ttsProvider.notifier).stop();
1089 - if (mounted) setState(() => _playingVoice = null);
1089 + if (!mounted) return;
1090 + setState(() => _playingVoice = null);
1091 return;
1092 }
1093 try {
1093 - if (mounted) setState(() => _playingVoice = voice);
1094 + if (!mounted) return;
1095 + setState(() => _playingVoice = voice);
1096 await ref.read(tts.ttsProvider.notifier).previewVoice(voice);
1097 } catch (e) {
1096 - if (mounted) {
1097 - setState(() => _playingVoice = null);
1098 - ScaffoldMessenger.of(context).showSnackBar(
1099 - SnackBar(content: Text(l10n.preview_failed(e.toString()))),
1100 - );
1101 - }
1098 + if (!mounted) return;
1099 + setState(() => _playingVoice = null);
1100 + ScaffoldMessenger.of(context).showSnackBar(
1101 + SnackBar(content: Text(l10n.preview_failed(e.toString()))),
1102 + );
1103 }
1104 }
1105 }
lib/features/voice_mode/views/voice_mode_overlay.dart
+4 -3
@@ -72,9 +72,10 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
72
73 Future<void> _endSession() async {
74 await ref.read(voiceModeProvider.notifier).endSession();
75 - if (mounted) {
76 - context.pop();
77 - }
75 + // Guard every ref access post-await — endSession() releases audio
76 + // resources asynchronously and the overlay may close during that window.
77 + if (!mounted) return;
78 + context.pop();
79 }
80
81 void _handleCenterAction() {
test/features/chat/mcp_tool_result_followup_test.dart new
+248
@@ -0,0 +1,248 @@
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' as m
4 + show Message, ToolCallData;
5 +import 'package:localmind/features/chat/data/tools/tool_event.dart';
6 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
7 +
8 +// Regression test for issue #77:
9 +// "MCP: tool result is never sent back to the model — conversation ends
10 +// after tool execution".
11 +//
12 +// After the server streams an assistant message that contains a tool call
13 +// and the client successfully executes the tool, the app MUST issue a
14 +// follow-up chat completion that includes:
15 +// 1. The tool-role message(s) carrying the result, each with a
16 +// `toolCallId` that matches the assistant's `tool_calls[].id`.
17 +// 2. Matching `tool_calls` entries on the assistant message so the
18 +// OpenAI-compatible protocol can pair the two.
19 +//
20 +// The test below mirrors the message-construction logic in
21 +// `_sendFollowupWithToolResults` in `chat_notifier.dart` and asserts the
22 +// invariants the model depends on. If this test ever fails, the
23 +// follow-up request will be malformed and the model will not produce a
24 +// final answer — the original symptom in #77.
25 +
26 +m.Message _previousAssistant({
27 + required String id,
28 + required String toolName,
29 +}) {
30 + return m.Message(
31 + id: id,
32 + conversationId: 'conv-1',
33 + role: MessageRole.assistant,
34 + content: 'I will check your calendar now.',
35 + createdAt: DateTime.utc(2026, 9, 5),
36 + status: MessageStatus.complete,
37 + modelId: 'qwen-test',
38 + toolSessionId: 'session-1',
39 + variantGroupId: 'group-1',
40 + variantIndex: 0,
41 + threadOrder: 1,
42 + );
43 +}
44 +
45 +ToolEvent _completedEvent({
46 + required String sessionId,
47 + required String toolName,
48 + required String result,
49 +}) {
50 + return ToolEvent(
51 + eventId: '${sessionId}_1_$toolName',
52 + timestamp: DateTime.utc(2026, 9, 5, 12, 0, 0),
53 + status: ToolEventStatus.completed,
54 + toolName: toolName,
55 + providerType: ToolProviderType.builtIn,
56 + arguments: const {},
57 + result: result,
58 + );
59 +}
60 +
61 +/// Mirrors the helper used inside `_sendFollowupWithToolResults` to ensure
62 +/// the assistant message's `tool_calls[].id` and the tool-role message's
63 +/// `tool_call_id` are the same value. The OpenAI-compatible protocol
64 +/// requires this exact pairing or the model will reject the request.
65 +String _toolCallIdFor(ToolEvent event) {
66 + final uniqueSuffix = event.eventId.isNotEmpty
67 + ? event.eventId
68 + : '${event.toolName}_${event.timestamp.microsecondsSinceEpoch}';
69 + return 'call_${uniqueSuffix}_completed';
70 +}
71 +
72 +({m.Message assistant, List<m.Message> toolMessages}) _buildFollowup({
73 + required m.Message previousAssistant,
74 + required List<ToolEvent> toolEvents,
75 +}) {
76 + final completed = toolEvents
77 + .where((e) => e.status == ToolEventStatus.completed)
78 + .toList();
79 +
80 + final toolCallEntries = completed
81 + .map(
82 + (e) => m.ToolCallData(
83 + id: _toolCallIdFor(e),
84 + toolName: e.toolName,
85 + arguments: e.arguments ?? const {},
86 + result: e.result,
87 + ),
88 + )
89 + .toList();
90 +
91 + final assistant = previousAssistant.copyWith(
92 + toolCalls: toolCallEntries,
93 + stopReason: 'tool_use',
94 + );
95 +
96 + final toolMessages = completed.map((e) {
97 + final callId = _toolCallIdFor(e);
98 + return m.Message(
99 + id: 'tool_${callId}_${e.timestamp.microsecondsSinceEpoch}',
100 + conversationId: previousAssistant.conversationId,
101 + role: MessageRole.tool,
102 + content: e.result ?? e.error ?? '',
103 + createdAt: e.timestamp,
104 + status: MessageStatus.complete,
105 + toolCallId: callId,
106 + toolSessionId: previousAssistant.toolSessionId,
107 + parentMessageId: previousAssistant.id,
108 + variantGroupId: previousAssistant.variantGroupId,
109 + variantIndex: previousAssistant.variantIndex,
110 + );
111 + }).toList();
112 +
113 + return (assistant: assistant, toolMessages: toolMessages);
114 +}
115 +
116 +void main() {
117 + group('Issue #77 follow-up message construction', () {
118 + test('tool_call_id on tool messages matches assistant.toolCalls[*].id', () {
119 + final assistant = _previousAssistant(
120 + id: 'asst-1',
121 + toolName: 'calendar.list_events',
122 + );
123 + final events = [
124 + _completedEvent(
125 + sessionId: 'session-1',
126 + toolName: 'calendar.list_events',
127 + result: '[{"date":"2026-09-07","title":"Standup"}]',
128 + ),
129 + ];
130 +
131 + final built = _buildFollowup(
132 + previousAssistant: assistant,
133 + toolEvents: events,
134 + );
135 +
136 + expect(
137 + built.assistant.toolCalls,
138 + isNotNull,
139 + reason:
140 + 'Assistant message must carry tool_calls so the OpenAI protocol '
141 + 'knows which tool_call_id each tool message corresponds to.',
142 + );
143 + expect(built.assistant.toolCalls!.length, 1);
144 + expect(built.toolMessages.length, 1);
145 + expect(
146 + built.assistant.toolCalls!.first.id,
147 + built.toolMessages.first.toolCallId,
148 + reason:
149 + 'The OpenAI-compatible protocol requires tool_call_id in the '
150 + 'tool-role message to match tool_calls[].id on the assistant '
151 + 'message exactly — without this pairing the model cannot '
152 + 'associate the result with the call and will not produce a '
153 + 'final answer (issue #77).',
154 + );
155 + });
156 +
157 + test('multiple completed tools each get a unique matching id', () {
158 + final assistant = _previousAssistant(id: 'asst-1', toolName: 'multi');
159 + final events = [
160 + _completedEvent(
161 + sessionId: 'session-1',
162 + toolName: 'calendar.list_events',
163 + result: '[]',
164 + ),
165 + _completedEvent(
166 + sessionId: 'session-1',
167 + toolName: 'weather.get',
168 + result: '{"temp":72}',
169 + ),
170 + ];
171 +
172 + final built = _buildFollowup(
173 + previousAssistant: assistant,
174 + toolEvents: events,
175 + );
176 +
177 + expect(built.assistant.toolCalls!.length, 2);
178 + expect(built.toolMessages.length, 2);
179 +
180 + final ids = built.assistant.toolCalls!.map((tc) => tc.id).toSet();
181 + expect(ids.length, 2, reason: 'Tool-call IDs must be unique');
182 +
183 + for (final tm in built.toolMessages) {
184 + expect(
185 + ids.contains(tm.toolCallId),
186 + isTrue,
187 + reason:
188 + 'Every tool message tool_call_id (${tm.toolCallId}) must be '
189 + 'paired with one assistant tool_calls entry.',
190 + );
191 + }
192 + });
193 +
194 + test('failed tool events are excluded from the follow-up request', () {
195 + final assistant = _previousAssistant(id: 'asst-1', toolName: 'multi');
196 + final events = [
197 + _completedEvent(
198 + sessionId: 'session-1',
199 + toolName: 'ok.tool',
200 + result: '{"ok":true}',
201 + ),
202 + ToolEvent(
203 + eventId: 'session-1_1_bad.tool',
204 + timestamp: DateTime.utc(2026, 9, 5, 12, 0, 1),
205 + status: ToolEventStatus.failed,
206 + toolName: 'bad.tool',
207 + providerType: ToolProviderType.builtIn,
208 + arguments: const {},
209 + error: 'Permission denied',
210 + ),
211 + ];
212 +
213 + final built = _buildFollowup(
214 + previousAssistant: assistant,
215 + toolEvents: events,
216 + );
217 +
218 + expect(built.assistant.toolCalls!.length, 1);
219 + expect(built.toolMessages.length, 1);
220 + expect(built.assistant.toolCalls!.first.toolName, 'ok.tool');
221 + });
222 +
223 + test('assistant stopReason is set to tool_use', () {
224 + final assistant = _previousAssistant(id: 'asst-1', toolName: 't');
225 + final events = [
226 + _completedEvent(
227 + sessionId: 's',
228 + toolName: 't',
229 + result: 'ok',
230 + ),
231 + ];
232 +
233 + final built = _buildFollowup(
234 + previousAssistant: assistant,
235 + toolEvents: events,
236 + );
237 +
238 + expect(
239 + built.assistant.stopReason,
240 + 'tool_use',
241 + reason:
242 + 'Marking the assistant message with stopReason="tool_use" makes '
243 + 'the turn boundary explicit and lets the conversation history '
244 + 'render the tool card as a self-contained step.',
245 + );
246 + });
247 + });
248 +}