From 6793d7c3c035a6ce7ce1dc994e6f528ea1f13d76 Mon Sep 17 00:00:00 2001 From: using-system Date: Tue, 21 Apr 2026 13:05:55 +0200 Subject: [PATCH 1/2] fix(chat): guard null check operators to prevent crash on uninitialized chat Replace unsafe `_chat!`, `_pendingAudioPath!`, and `_pendingImagePath!` assertions with local captures and early returns. The crash occurred when all three model init attempts failed in `_createChat()`, leaving `_chat` null, or when a concurrent skill-config reload nullified `_chat` mid-send. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chat/presentation/conversation_page.dart | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index a54991c..310c7b4 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -224,11 +224,13 @@ class _ConversationPageState extends ConsumerState { setState(() => _chatError = null); } + final chat = _chat; + if (chat == null) return; final repo = await ref.read(chatRepositoryProvider.future); final messages = await repo.getMessages(widget.conversationId); for (final msg in messages) { if (msg.content.isEmpty) continue; - await _chat!.addQueryChunk( + await chat.addQueryChunk( gemma.Message.text(text: msg.content, isUser: msg.role == 'user'), ); // Yield to the UI between history messages to keep input responsive. @@ -290,7 +292,9 @@ class _ConversationPageState extends ConsumerState { onError: (e, s) => debugPrint('Persist failed: $e\n$s'), ); - await _chat!.addQueryChunk(gemma.Message.text(text: text, isUser: true)); + final chat = _chat; + if (chat == null) return; + await chat.addQueryChunk(gemma.Message.text(text: text, isUser: true)); await _streamAiResponse(); } catch (e, stack) { @@ -303,7 +307,8 @@ class _ConversationPageState extends ConsumerState { } Future _doSendAudioWithText(String text) async { - final audioPath = _pendingAudioPath!; + final audioPath = _pendingAudioPath; + if (audioPath == null) return; try { final audioBytes = await File(audioPath).readAsBytes(); @@ -336,7 +341,9 @@ class _ConversationPageState extends ConsumerState { final prompt = text.trim().isNotEmpty ? text.trim() : l10n.chatAudioPrompt; - await _chat!.addQueryChunk( + final chat = _chat; + if (chat == null) return; + await chat.addQueryChunk( gemma.Message.withAudio( text: prompt, audioBytes: audioBytes, @@ -380,7 +387,8 @@ class _ConversationPageState extends ConsumerState { } Future _doSendImageWithText(String text) async { - final imagePath = _pendingImagePath!; + final imagePath = _pendingImagePath; + if (imagePath == null) return; try { final imageBytes = await File(imagePath).readAsBytes(); @@ -414,7 +422,9 @@ class _ConversationPageState extends ConsumerState { final prompt = text.trim().isNotEmpty ? text.trim() : l10n.chatImagePrompt; // Send to InferenceChat with image. - await _chat!.addQueryChunk( + final chat = _chat; + if (chat == null) return; + await chat.addQueryChunk( gemma.Message.withImage( text: prompt, imageBytes: imageBytes, @@ -499,8 +509,11 @@ class _ConversationPageState extends ConsumerState { const _maxRepeat = 12; bool _hadToolCall = false; + final chat = _chat; + if (chat == null) return; + try { - await for (final response in _chat!.generateChatResponseAsync()) { + await for (final response in chat.generateChatResponseAsync()) { if (!mounted) break; if (response is ThinkingResponse) { @@ -573,7 +586,7 @@ class _ConversationPageState extends ConsumerState { final toolResult = await toolReg.handle(response, context); if (toolResult != null && _chat != null) { _hadToolCall = true; - await _chat!.addQueryChunk(gemma.Message.toolResponse( + await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); @@ -586,7 +599,7 @@ class _ConversationPageState extends ConsumerState { final toolResult = await toolReg.handle(call, context); if (toolResult != null && _chat != null) { _hadToolCall = true; - await _chat!.addQueryChunk(gemma.Message.toolResponse( + await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); @@ -600,7 +613,7 @@ class _ConversationPageState extends ConsumerState { if (_hadToolCall && mounted && _chat != null) { debugPrint('>>> Re-generating after tool call...'); int tokenCount = 0; - await for (final response in _chat!.generateChatResponseAsync()) { + await for (final response in chat.generateChatResponseAsync()) { if (!mounted) break; debugPrint('>>> Re-gen response: ${response.runtimeType}'); if (response is TextResponse) { From 54568d51e1cc3024880a144cf513b992cdbd00f4 Mon Sep 17 00:00:00 2001 From: using-system Date: Tue, 21 Apr 2026 13:12:23 +0200 Subject: [PATCH 2/2] fix(chat): address review feedback on null guard consistency - Move _streamAiResponse null check before inserting placeholder message - Set _chatError when all model init attempts fail instead of silently returning - Use _chat == chat identity check instead of _chat != null in tool call guards to detect stale instances after concurrent skill-config reloads Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chat/presentation/conversation_page.dart | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index 310c7b4..0a92102 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -220,12 +220,17 @@ class _ConversationPageState extends ConsumerState { } } + final chat = _chat; + if (chat == null) { + if (mounted) { + setState(() => _chatError = 'Model initialization failed'); + } + return; + } + if (mounted) { setState(() => _chatError = null); } - - final chat = _chat; - if (chat == null) return; final repo = await ref.read(chatRepositoryProvider.future); final messages = await repo.getMessages(widget.conversationId); for (final msg in messages) { @@ -484,6 +489,9 @@ class _ConversationPageState extends ConsumerState { } Future _streamAiResponse() async { + final chat = _chat; + if (chat == null) return; + final streamId = _uuid.v4(); final streamMsgId = _uuid.v4(); final now = DateTime.now(); @@ -509,9 +517,6 @@ class _ConversationPageState extends ConsumerState { const _maxRepeat = 12; bool _hadToolCall = false; - final chat = _chat; - if (chat == null) return; - try { await for (final response in chat.generateChatResponseAsync()) { if (!mounted) break; @@ -584,7 +589,7 @@ class _ConversationPageState extends ConsumerState { if (mounted) { final toolReg = ref.read(toolRegistryProvider); final toolResult = await toolReg.handle(response, context); - if (toolResult != null && _chat != null) { + if (toolResult != null && _chat == chat) { _hadToolCall = true; await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, @@ -597,7 +602,7 @@ class _ConversationPageState extends ConsumerState { final toolReg = ref.read(toolRegistryProvider); for (final call in response.calls) { final toolResult = await toolReg.handle(call, context); - if (toolResult != null && _chat != null) { + if (toolResult != null && _chat == chat) { _hadToolCall = true; await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, @@ -610,7 +615,7 @@ class _ConversationPageState extends ConsumerState { // After stream ends, if a tool was called, re-generate so the LLM // produces its final answer using the tool results as context. - if (_hadToolCall && mounted && _chat != null) { + if (_hadToolCall && mounted && _chat == chat) { debugPrint('>>> Re-generating after tool call...'); int tokenCount = 0; await for (final response in chat.generateChatResponseAsync()) {