@tduncan / Cortana / commits / 963de0c

feat: implement sequential LLM inference to prevent collision during concurrent generation requests

abdulmominsakib committed Aug 15, 2026 at 10:31 UTC 963de0c4fd0df97dac003a4fa79b2e88b3e51872
6 files changed +519 -119
lib/core/utils/safe_file_picker.dart
+3 -3
@@ -73,7 +73,7 @@ class SafeFilePicker {
73 bool withReadStream = false,
74 String? dialogTitle,
75 String? initialDirectory,
76 - dynamic lockParentWindow,
76 + bool lockParentWindow = false,
77 Function(FilePickerStatus)? onFileLoading,
78 BuildContext? context,
79 void Function(String message)? onError,
@@ -115,7 +115,7 @@ class SafeFilePicker {
115 FileType type = FileType.any,
116 List<String>? allowedExtensions,
117 Uint8List? bytes,
118 - dynamic lockParentWindow,
118 + bool lockParentWindow = false,
119 BuildContext? context,
120 void Function(String message)? onError,
121 }) async {
@@ -150,7 +150,7 @@ class SafeFilePicker {
150 static Future<String?> getDirectoryPath({
151 String? dialogTitle,
152 String? initialDirectory,
153 - dynamic lockParentWindow,
153 + bool lockParentWindow = false,
154 BuildContext? context,
155 void Function(String message)? onError,
156 }) async {
lib/features/chat/providers/chat_notifier.dart
+50 -18
@@ -123,6 +123,8 @@ class ChatNotifier extends Notifier<ChatState> {
123 MessageSaveService? _saveService;
124 PendingToolApproval? _pendingToolApproval;
125 final Set<String> _titleGenerationsInFlight = <String>{};
126 + final Map<String, Future<void>> _titleGenerationFutures =
127 + <String, Future<void>>{};
128 bool _pendingTemporaryChat = false;
129 String? _ephemeralConversationId;
130 bool _attemptedResume = false;
@@ -1216,6 +1218,7 @@ class ChatNotifier extends Notifier<ChatState> {
1218 if (isCurrentContext) {
1219 _replaceMessageInState(finalMessage, clearStreaming: true);
1220 state = state.copyWith(isStreaming: false);
1221 + _maybeAutoGenerateTitleAfterFirstReply();
1222 }
1223 ref.read(isStreamingProvider.notifier).setStreaming(false);
1224 _latestStreamingMessage = null;
@@ -1530,36 +1533,64 @@ class ChatNotifier extends Notifier<ChatState> {
1533 return result;
1534 }
1535
1533 - void _maybeAutoGenerateTitleAfterFirstReply() {
1534 - if (_currentConversationId == null || _isInMemoryChat) return;
1536 + /// Waits for any in-flight AI title generation on [conversationId] to complete.
1537 + /// Used by downstream post-generation tasks (like smart reply generation)
1538 + /// so LLM inference calls execute sequentially without colliding on local models.
1539 + Future<void> waitForTitleGeneration(String conversationId) async {
1540 + final future = _titleGenerationFutures[conversationId];
1541 + if (future != null) {
1542 + try {
1543 + await future;
1544 + } catch (_) {
1545 + // Title generation errors should not fail downstream consumers.
1546 + }
1547 + }
1548 + }
1549 +
1550 + /// Returns true if AI title generation is currently in flight for [conversationId].
1551 + bool isTitleGenerationInFlight(String conversationId) {
1552 + return _titleGenerationsInFlight.contains(conversationId) ||
1553 + _titleGenerationFutures.containsKey(conversationId);
1554 + }
1555 +
1556 + Future<void>? _maybeAutoGenerateTitleAfterFirstReply() {
1557 + if (_currentConversationId == null || _isInMemoryChat) return null;
1558
1559 final settings = ref.read(settingsProvider);
1537 - if (!settings.autoGenerateTitle) return;
1560 + if (!settings.autoGenerateTitle) return null;
1561
1562 final activeConv = ref.read(conv.activeConversationProvider);
1540 - if (activeConv == null || activeConv.title != 'New Chat') return;
1563 + if (activeConv == null || activeConv.title != 'New Chat') return null;
1564
1565 final assistantCount = state.messages
1566 .where((m) => m.role == MessageRole.assistant)
1567 .length;
1545 - if (assistantCount != 1) return;
1568 + if (assistantCount != 1) return null;
1569
1547 - unawaited(_generateAndApplyTitle(_currentConversationId!));
1570 + final convId = _currentConversationId!;
1571 + return _generateAndApplyTitle(convId);
1572 }
1573
1550 - Future<void> _generateAndApplyTitle(String conversationId) async {
1551 - if (!_titleGenerationsInFlight.add(conversationId)) return;
1552 - try {
1553 - final title = await generateTitleWithAi(conversationId);
1554 - if (!ref.mounted) return;
1555 - if (title == null || title.isEmpty || title == 'New Chat') return;
1556 -
1557 - await ref
1558 - .read(conv.conversationsProvider.notifier)
1559 - .renameConversation(conversationId, title);
1560 - } finally {
1561 - _titleGenerationsInFlight.remove(conversationId);
1574 + Future<void> _generateAndApplyTitle(String conversationId) {
1575 + if (!_titleGenerationsInFlight.add(conversationId)) {
1576 + return _titleGenerationFutures[conversationId] ?? Future<void>.value();
1577 }
1578 + final future = () async {
1579 + try {
1580 + final title = await generateTitleWithAi(conversationId);
1581 + if (!ref.mounted) return;
1582 + if (title == null || title.isEmpty || title == 'New Chat') return;
1583 +
1584 + await ref
1585 + .read(conv.conversationsProvider.notifier)
1586 + .renameConversation(conversationId, title);
1587 + } finally {
1588 + _titleGenerationsInFlight.remove(conversationId);
1589 + _titleGenerationFutures.remove(conversationId);
1590 + }
1591 + }();
1592 + _titleGenerationFutures[conversationId] = future;
1593 + return future;
1594 }
1595
1596 Future<String?> generateTitleWithAi(String conversationId) async {
@@ -2151,6 +2182,7 @@ class ChatNotifier extends Notifier<ChatState> {
2182 isStreaming: false,
2183 clearStreaming: true,
2184 );
2185 + _maybeAutoGenerateTitleAfterFirstReply();
2186 }
2187 ref.read(isStreamingProvider.notifier).setStreaming(false);
2188 ref.read(chatBackgroundServiceProvider).stop();
lib/features/chat/providers/smart_reply_providers.dart
+29 -8
@@ -28,14 +28,34 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
28 return [];
29 }
30
31 - final activeConv = ref.watch(conv.activeConversationProvider);
32 - if (activeConv != null &&
33 - activeConv.smartRepliesLastMessageId == lastMessage.id &&
34 - activeConv.smartReplies != null &&
35 - activeConv.smartReplies!.isNotEmpty) {
36 - return activeConv.smartReplies!;
31 + // Only watch the smart replies cache fields of the active conversation to avoid
32 + // invalidating and restarting this provider when conversation title or stats change.
33 + final cachedData = ref.watch(
34 + conv.activeConversationProvider.select((c) {
35 + if (c == null) return null;
36 + return (
37 + id: c.id,
38 + smartRepliesLastMessageId: c.smartRepliesLastMessageId,
39 + smartReplies: c.smartReplies,
40 + );
41 + }),
42 + );
43 +
44 + if (cachedData != null &&
45 + cachedData.smartRepliesLastMessageId == lastMessage.id &&
46 + cachedData.smartReplies != null &&
47 + cachedData.smartReplies!.isNotEmpty) {
48 + return cachedData.smartReplies!;
49 }
50
51 + // If AI title generation is running for this conversation, wait for it first
52 + // so LLM inference executes sequentially without colliding on local models.
53 + final chatNotifier = ref.read(chatProvider.notifier);
54 + await chatNotifier.waitForTitleGeneration(lastMessage.conversationId);
55 +
56 + if (!ref.mounted) return [];
57 + if (ref.read(chatProvider.select((s) => s.isStreaming))) return [];
58 +
59 List<String> suggestions = [];
60
61 if (settings.smartReplyEnabled) {
@@ -66,12 +86,13 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
86 suggestions = service.getFallbackReplies(lastMessage.content);
87 }
88
69 - if (activeConv != null && suggestions.isNotEmpty) {
89 + final convId = cachedData?.id ?? lastMessage.conversationId;
90 + if (suggestions.isNotEmpty) {
91 Future.microtask(() {
92 if (ref.mounted) {
93 ref
94 .read(conv.conversationsProvider.notifier)
74 - .updateSmartReplies(activeConv.id, suggestions, lastMessage.id);
95 + .updateSmartReplies(convId, suggestions, lastMessage.id);
96 }
97 });
98 }
lib/features/on_device/data/on_device_llama_service.dart
+120 -90
@@ -24,6 +24,7 @@ class OnDeviceLlamaService {
24 /// out-of-band edits or regenerations and rebuild the chat history.
25 String? _lastUserMessageId;
26 int _userMessageCount = 0;
27 + Future<void>? _activeGenerationFuture;
28
29 String? get currentModelId => _currentModelId;
30 bool get isLoaded => _engine != null && !_isDisposed;
@@ -88,104 +89,132 @@ class OnDeviceLlamaService {
89 required List<Message> messages,
90 required ChatParameters params,
91 }) async* {
91 - final engine = _engine;
92 - if (engine == null || _currentModelId != modelId) {
93 - yield const ChatResponse(
94 - type: ChatResponseType.error,
95 - content: 'GGUF model not loaded',
96 - );
97 - yield const ChatResponse(type: ChatResponseType.done);
98 - return;
99 - }
92 + final prev = _activeGenerationFuture;
93 + final generationCompleter = Completer<void>();
94 + _activeGenerationFuture = generationCompleter.future;
95
101 - final userMessages = messages
102 - .where((m) => m.role == MessageRole.user)
103 - .toList(growable: false);
104 - if (userMessages.isEmpty) {
105 - yield const ChatResponse(
106 - type: ChatResponseType.error,
107 - content: 'GGUF chat requires a user message.',
108 - );
109 - yield const ChatResponse(type: ChatResponseType.done);
110 - return;
96 + if (prev != null) {
97 + try {
98 + await prev;
99 + } catch (_) {}
100 }
101
113 - final latestUserMessage = userMessages.last;
114 - final systemPrompt = params.systemPrompt?.isNotEmpty == true
115 - ? params.systemPrompt
116 - : null;
117 - final sessionContextChanged =
118 - _session != null && _currentContextLength != params.contextLength;
102 + try {
103 + if (_isDisposed) {
104 + yield const ChatResponse(
105 + type: ChatResponseType.error,
106 + content: 'GGUF engine disposed',
107 + );
108 + yield const ChatResponse(type: ChatResponseType.done);
109 + return;
110 + }
111
120 - if (_session == null ||
121 - _currentSystemPrompt != systemPrompt ||
122 - sessionContextChanged) {
123 - _session = llama.ChatSession(
124 - engine,
125 - maxContextTokens: params.contextLength,
126 - systemPrompt: systemPrompt,
127 - );
128 - _currentSystemPrompt = systemPrompt;
129 - _currentContextLength = params.contextLength;
130 - _userMessageCount = 0;
131 - _lastUserMessageId = null;
132 - } else if (userMessages.length < _userMessageCount ||
133 - (_lastUserMessageId != null &&
134 - latestUserMessage.id != _lastUserMessageId)) {
135 - // Upstream history diverged from our internal tracking (edit, retry,
136 - // regeneration). Discard the cached history and rebuild it from the
137 - // caller-supplied messages on the next create() call.
138 - _session!.reset(keepSystemPrompt: true);
139 - _userMessageCount = 0;
140 - _lastUserMessageId = null;
141 - }
112 + final engine = _engine;
113 + if (engine == null || _currentModelId != modelId) {
114 + yield const ChatResponse(
115 + type: ChatResponseType.error,
116 + content: 'GGUF model not loaded',
117 + );
118 + yield const ChatResponse(type: ChatResponseType.done);
119 + return;
120 + }
121
143 - final text = latestUserMessage.content.trim();
144 - if (text.isEmpty) {
145 - yield const ChatResponse(
146 - type: ChatResponseType.error,
147 - content: 'GGUF chat requires a text prompt.',
148 - );
149 - yield const ChatResponse(type: ChatResponseType.done);
150 - return;
151 - }
122 + final userMessages = messages
123 + .where((m) => m.role == MessageRole.user)
124 + .toList(growable: false);
125 + if (userMessages.isEmpty) {
126 + yield const ChatResponse(
127 + type: ChatResponseType.error,
128 + content: 'GGUF chat requires a user message.',
129 + );
130 + yield const ChatResponse(type: ChatResponseType.done);
131 + return;
132 + }
133
153 - try {
154 - await for (final chunk in _session!.create(
155 - [llama.LlamaTextContent(text)],
156 - params: llama.GenerationParams(
157 - maxTokens: params.maxTokens,
158 - temp: params.temperature,
159 - topP: params.topP,
160 - topK: params.topK ?? 40,
161 - minP: params.minP ?? 0.0,
162 - penalty: params.repeatPenalty ?? 1.1,
163 - ),
164 - )) {
165 - if (chunk.choices.isEmpty) continue;
166 - final delta = chunk.choices.first.delta;
167 - final reasoning = delta.thinking;
168 - if (reasoning != null && reasoning.isNotEmpty) {
169 - yield ChatResponse(
170 - type: ChatResponseType.reasoning,
171 - reasoningContent: reasoning,
172 - );
173 - }
174 - final content = delta.content;
175 - if (content != null && content.isNotEmpty) {
176 - yield ChatResponse(type: ChatResponseType.message, content: content);
134 + final latestUserMessage = userMessages.last;
135 + final systemPrompt = params.systemPrompt?.isNotEmpty == true
136 + ? params.systemPrompt
137 + : null;
138 + final sessionContextChanged =
139 + _session != null && _currentContextLength != params.contextLength;
140 +
141 + if (_session == null ||
142 + _currentSystemPrompt != systemPrompt ||
143 + sessionContextChanged) {
144 + _session = llama.ChatSession(
145 + engine,
146 + maxContextTokens: params.contextLength,
147 + systemPrompt: systemPrompt,
148 + );
149 + _currentSystemPrompt = systemPrompt;
150 + _currentContextLength = params.contextLength;
151 + _userMessageCount = 0;
152 + _lastUserMessageId = null;
153 + } else if (userMessages.length < _userMessageCount ||
154 + (_lastUserMessageId != null &&
155 + latestUserMessage.id != _lastUserMessageId)) {
156 + // Upstream history diverged from our internal tracking (edit, retry,
157 + // regeneration). Discard the cached history and rebuild it from the
158 + // caller-supplied messages on the next create() call.
159 + _session!.reset(keepSystemPrompt: true);
160 + _userMessageCount = 0;
161 + _lastUserMessageId = null;
162 + }
163 +
164 + final text = latestUserMessage.content.trim();
165 + if (text.isEmpty) {
166 + yield const ChatResponse(
167 + type: ChatResponseType.error,
168 + content: 'GGUF chat requires a text prompt.',
169 + );
170 + yield const ChatResponse(type: ChatResponseType.done);
171 + return;
172 + }
173 +
174 + try {
175 + await for (final chunk in _session!.create(
176 + [llama.LlamaTextContent(text)],
177 + params: llama.GenerationParams(
178 + maxTokens: params.maxTokens,
179 + temp: params.temperature,
180 + topP: params.topP,
181 + topK: params.topK ?? 40,
182 + minP: params.minP ?? 0.0,
183 + penalty: params.repeatPenalty ?? 1.1,
184 + ),
185 + )) {
186 + if (chunk.choices.isEmpty) continue;
187 + final delta = chunk.choices.first.delta;
188 + final reasoning = delta.thinking;
189 + if (reasoning != null && reasoning.isNotEmpty) {
190 + yield ChatResponse(
191 + type: ChatResponseType.reasoning,
192 + reasoningContent: reasoning,
193 + );
194 + }
195 + final content = delta.content;
196 + if (content != null && content.isNotEmpty) {
197 + yield ChatResponse(type: ChatResponseType.message, content: content);
198 + }
199 }
200 + _userMessageCount = userMessages.length;
201 + _lastUserMessageId = latestUserMessage.id;
202 + yield const ChatResponse(type: ChatResponseType.done);
203 + } catch (e) {
204 + Log.error('GGUF inference error: $e');
205 + yield ChatResponse(
206 + type: ChatResponseType.error,
207 + content: 'GGUF inference error: ${e.toString()}',
208 + );
209 + yield const ChatResponse(type: ChatResponseType.done);
210 + }
211 + } finally {
212 + if (!generationCompleter.isCompleted) {
213 + generationCompleter.complete();
214 + }
215 + if (identical(_activeGenerationFuture, generationCompleter.future)) {
216 + _activeGenerationFuture = null;
217 }
179 - _userMessageCount = userMessages.length;
180 - _lastUserMessageId = latestUserMessage.id;
181 - yield const ChatResponse(type: ChatResponseType.done);
182 - } catch (e) {
183 - Log.error('GGUF inference error: $e');
184 - yield ChatResponse(
185 - type: ChatResponseType.error,
186 - content: 'GGUF inference error: ${e.toString()}',
187 - );
188 - yield const ChatResponse(type: ChatResponseType.done);
218 }
219 }
220
@@ -203,6 +232,7 @@ class OnDeviceLlamaService {
232
233 Future<void> unloadModel() async {
234 cancelGeneration();
235 + _activeGenerationFuture = null;
236 final engine = _engine;
237 _engine = null;
238 _session = null;
test/core/utils/safe_file_picker_test.dart
+23
@@ -136,5 +136,28 @@ void main() {
136 final result = await SafeFilePicker.getDirectoryPath();
137 expect(result, isNull);
138 });
139 +
140 + test('pickFiles returns result when platform succeeds', () async {
141 + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
142 + .setMockMethodCallHandler(
143 + const MethodChannel('miguelruivo.flutter.plugins.filepicker'),
144 + (MethodCall methodCall) async {
145 + return [
146 + {
147 + 'path': '/path/to/file.txt',
148 + 'name': 'file.txt',
149 + 'size': 123,
150 + 'bytes': null,
151 + 'identifier': null,
152 + }
153 + ];
154 + },
155 + );
156 +
157 + final result = await SafeFilePicker.pickFiles();
158 + expect(result, isNotNull);
159 + expect(result!.files.first.name, 'file.txt');
160 + });
161 });
162 }
163 +
test/features/chat/sequential_post_generation_test.dart new
+294
@@ -0,0 +1,294 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/core/providers/app_providers.dart';
7 +import 'package:localmind/features/chat/data/chat_service.dart';
8 +import 'package:localmind/features/chat/data/models/chat_parameters.dart';
9 +import 'package:localmind/features/chat/data/models/mcp_integration.dart';
10 +import 'package:localmind/features/chat/data/models/message.dart';
11 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
12 +import 'package:localmind/features/chat/providers/chat_notifier.dart';
13 +import 'package:localmind/features/chat/providers/chat_service_providers.dart';
14 +import 'package:localmind/features/chat/providers/model_selection_providers.dart';
15 +import 'package:localmind/features/chat/providers/smart_reply_providers.dart';
16 +import 'package:localmind/features/conversations/data/models/conversation.dart';
17 +import 'package:localmind/features/conversations/providers/conversation_providers.dart';
18 +import 'package:localmind/features/models/data/models/model_info.dart';
19 +import 'package:localmind/features/servers/data/models/server.dart';
20 +import 'package:localmind/features/servers/providers/server_providers.dart';
21 +import 'package:localmind/features/settings/data/models/app_settings.dart';
22 +import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
23 +
24 +void main() {
25 + test(
26 + 'smartRepliesProvider waits for title generation on the conversation to complete before generating replies',
27 + () async {
28 + final titleCompleter = Completer<void>();
29 + final executionOrder = <String>[];
30 +
31 + final testChatNotifier = _MockChatNotifier(
32 + initialState: ChatState(
33 + isStreaming: false,
34 + messages: [
35 + Message(
36 + id: 'user-1',
37 + conversationId: 'conv-1',
38 + role: MessageRole.user,
39 + content: 'Tell me about stars',
40 + createdAt: DateTime.utc(2026, 8, 15),
41 + ),
42 + Message(
43 + id: 'assistant-1',
44 + conversationId: 'conv-1',
45 + role: MessageRole.assistant,
46 + content: 'Stars are massive celestial bodies made of plasma.',
47 + status: MessageStatus.complete,
48 + createdAt: DateTime.utc(2026, 8, 15),
49 + ),
50 + ],
51 + ),
52 + onWaitForTitle: (convId) async {
53 + executionOrder.add('waitForTitle:start');
54 + await titleCompleter.future;
55 + executionOrder.add('waitForTitle:done');
56 + },
57 + );
58 +
59 + final fakeChatService = _SequentialTrackingChatService(
60 + onSend: (prompt) {
61 + executionOrder.add('llm:smartReply');
62 + },
63 + );
64 +
65 + final conversation = Conversation(
66 + id: 'conv-1',
67 + title: 'New Chat',
68 + serverId: 'server-1',
69 + createdAt: DateTime.utc(2026, 8, 15),
70 + updatedAt: DateTime.utc(2026, 8, 15),
71 + );
72 +
73 + final container = ProviderContainer(
74 + overrides: [
75 + conversationsProvider.overrideWith(_TestConversationsNotifier.new),
76 + activeConversationProvider.overrideWith(
77 + () => _TestActiveConversationNotifier(conversation),
78 + ),
79 + activeConversationIdProvider.overrideWith(
80 + () => _TestActiveConversationIdNotifier('conv-1'),
81 + ),
82 + chatProvider.overrideWith(() => testChatNotifier),
83 + chatServiceProvider.overrideWithValue(fakeChatService),
84 + activeServerProvider.overrideWith(_TestServerNotifier.new),
85 + selectedModelProvider.overrideWith(_TestModelNotifier.new),
86 + voiceModeProvider.overrideWith(_TestVoiceModeNotifier.new),
87 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
88 + ],
89 + );
90 + addTearDown(container.dispose);
91 +
92 + // Read smartRepliesProvider
93 + final repliesFuture = container.read(smartRepliesProvider.future);
94 +
95 + // Allow microtasks to execute
96 + await Future<void>.delayed(const Duration(milliseconds: 10));
97 +
98 + // waitForTitle should have been called, but LLM smart reply should NOT have run yet
99 + expect(executionOrder, contains('waitForTitle:start'));
100 + expect(executionOrder, isNot(contains('llm:smartReply')));
101 +
102 + // Now complete the title generation
103 + titleCompleter.complete();
104 +
105 + final replies = await repliesFuture;
106 + expect(executionOrder, [
107 + 'waitForTitle:start',
108 + 'waitForTitle:done',
109 + 'llm:smartReply',
110 + ]);
111 + expect(replies, ['Tell me more', 'How do they form?']);
112 + },
113 + );
114 +
115 + test(
116 + 'smartRepliesProvider does not delay if no title generation is pending',
117 + () async {
118 + final executionOrder = <String>[];
119 +
120 + final testChatNotifier = _MockChatNotifier(
121 + initialState: ChatState(
122 + isStreaming: false,
123 + messages: [
124 + Message(
125 + id: 'user-2',
126 + conversationId: 'conv-1',
127 + role: MessageRole.user,
128 + content: 'How big are they?',
129 + createdAt: DateTime.utc(2026, 8, 15),
130 + ),
131 + Message(
132 + id: 'assistant-2',
133 + conversationId: 'conv-1',
134 + role: MessageRole.assistant,
135 + content: 'They range from small dwarfs to giant supergiants.',
136 + status: MessageStatus.complete,
137 + createdAt: DateTime.utc(2026, 8, 15),
138 + ),
139 + ],
140 + ),
141 + onWaitForTitle: (convId) async {
142 + executionOrder.add('waitForTitle:instant');
143 + },
144 + );
145 +
146 + final fakeChatService = _SequentialTrackingChatService(
147 + onSend: (prompt) {
148 + executionOrder.add('llm:smartReply');
149 + },
150 + );
151 +
152 + final conversation = Conversation(
153 + id: 'conv-1',
154 + title: 'Stars and Galaxies',
155 + serverId: 'server-1',
156 + createdAt: DateTime.utc(2026, 8, 15),
157 + updatedAt: DateTime.utc(2026, 8, 15),
158 + );
159 +
160 + final container = ProviderContainer(
161 + overrides: [
162 + conversationsProvider.overrideWith(_TestConversationsNotifier.new),
163 + activeConversationProvider.overrideWith(
164 + () => _TestActiveConversationNotifier(conversation),
165 + ),
166 + activeConversationIdProvider.overrideWith(
167 + () => _TestActiveConversationIdNotifier('conv-1'),
168 + ),
169 + chatProvider.overrideWith(() => testChatNotifier),
170 + chatServiceProvider.overrideWithValue(fakeChatService),
171 + activeServerProvider.overrideWith(_TestServerNotifier.new),
172 + selectedModelProvider.overrideWith(_TestModelNotifier.new),
173 + voiceModeProvider.overrideWith(_TestVoiceModeNotifier.new),
174 + settingsProvider.overrideWith(_TestSettingsNotifier.new),
175 + ],
176 + );
177 + addTearDown(container.dispose);
178 +
179 + final replies = await container.read(smartRepliesProvider.future);
180 + expect(executionOrder, ['waitForTitle:instant', 'llm:smartReply']);
181 + expect(replies, ['Tell me more', 'How do they form?']);
182 + },
183 + );
184 +}
185 +
186 +class _MockChatNotifier extends ChatNotifier {
187 + _MockChatNotifier({this.initialState = const ChatState(), this.onWaitForTitle});
188 +
189 + final ChatState initialState;
190 + final Future<void> Function(String convId)? onWaitForTitle;
191 +
192 + @override
193 + ChatState build() => initialState;
194 +
195 + @override
196 + Future<void> waitForTitleGeneration(String conversationId) async {
197 + if (onWaitForTitle != null) {
198 + await onWaitForTitle!(conversationId);
199 + }
200 + }
201 +}
202 +
203 +class _SequentialTrackingChatService implements ChatService {
204 + _SequentialTrackingChatService({required this.onSend});
205 +
206 + final void Function(String prompt) onSend;
207 +
208 + @override
209 + Stream<ChatResponse> sendMessage({
210 + required Server server,
211 + required String modelId,
212 + required List<Message> messages,
213 + required ChatParameters params,
214 + List<McpIntegration>? integrations,
215 + List<ToolDefinition>? tools,
216 + bool continueGeneration = false,
217 + }) async* {
218 + onSend(messages.last.content);
219 + yield const ChatResponse(
220 + type: ChatResponseType.message,
221 + content: '["Tell me more", "How do they form?"]',
222 + );
223 + yield const ChatResponse(type: ChatResponseType.done);
224 + }
225 +
226 + @override
227 + void cancelStream() {}
228 +}
229 +
230 +class _TestVoiceModeNotifier extends VoiceModeNotifier {
231 + @override
232 + VoiceModeState build() => const VoiceModeState();
233 +}
234 +
235 +class _TestConversationsNotifier extends ConversationsNotifier {
236 + @override
237 + Future<List<Conversation>> build() async => [];
238 +
239 + @override
240 + Future<void> updateSmartReplies(
241 + String id,
242 + List<String> replies,
243 + String lastMessageId,
244 + ) async {}
245 +}
246 +
247 +class _TestActiveConversationNotifier extends ActiveConversationNotifier {
248 + _TestActiveConversationNotifier(this._initial);
249 + final Conversation? _initial;
250 +
251 + @override
252 + Conversation? build() => _initial;
253 +}
254 +
255 +class _TestActiveConversationIdNotifier extends ActiveConversationIdNotifier {
256 + _TestActiveConversationIdNotifier(this._initial);
257 + final String? _initial;
258 +
259 + @override
260 + String? build() => _initial;
261 +}
262 +
263 +class _TestServerNotifier extends ActiveServerNotifier {
264 + @override
265 + Server? build() => Server(
266 + id: 'server-1',
267 + name: 'On-Device',
268 + type: ServerType.onDevice,
269 + host: '',
270 + port: 0,
271 + createdAt: DateTime.utc(2026, 8, 15),
272 + lastConnectedAt: DateTime.utc(2026, 8, 15),
273 + status: ConnectionStatus.connected,
274 + );
275 +}
276 +
277 +class _TestModelNotifier extends SelectedModelNotifier {
278 + @override
279 + ModelInfo? build() => ModelInfo(
280 + id: 'smollm2-135m-instruct',
281 + name: 'SmolLM2 135M',
282 + serverType: ServerType.onDevice,
283 + serverId: 'server-1',
284 + );
285 +}
286 +
287 +class _TestSettingsNotifier extends SettingsNotifier {
288 + @override
289 + AppSettings build() => AppSettings(
290 + smartReplyEnabled: true,
291 + smartRepliesUsePersona: false,
292 + autoGenerateTitle: true,
293 + );
294 +}