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
+}