diff --git a/.gitmodules b/.gitmodules index 2f2c8c3bc..bca376837 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,7 @@ [submodule "third_party/katex"] path = third_party/katex url = https://github.com/orestesgaolin/katex.git +[submodule "hermes-src"] + path = hermes-src + url = https://github.com/NousResearch/hermes-agent + branch = main diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 59281da4f..d51de7317 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -33,7 +33,7 @@ git clone --recursive https://github.com/cogwheel0/conduit.git cd conduit ``` -`--recursive` matters. Conduit vendors three submodules: +`--recursive` matters. Conduit vendors four submodules: - `third_party/mermaid`: the native Mermaid renderer packages (`mermaid_core`, `mermaid_flutter`), referenced by path from `pubspec.yaml`. @@ -41,6 +41,10 @@ cd conduit - `third_party/katex`: KaTeX assets for math rendering. - `openwebui-src`: a vendored Open WebUI checkout used **only** as an API reference. It is not built or shipped. +- `hermes-src`: a vendored Hermes Agent checkout (NousResearch/hermes-agent) + used **only** as the reference for the Hermes gateway RPC and REST contracts + (`tui_gateway/`, `gateway/platforms/api_server.py`). It is not built or + shipped. For an existing clone: diff --git a/hermes-src b/hermes-src new file mode 160000 index 000000000..939e45c91 --- /dev/null +++ b/hermes-src @@ -0,0 +1 @@ +Subproject commit 939e45c91d751fadd94dcd1b873ac3cb44846213 diff --git a/lib/core/persistence/persistence_keys.dart b/lib/core/persistence/persistence_keys.dart index 25bb6905d..1b52f8519 100644 --- a/lib/core/persistence/persistence_keys.dart +++ b/lib/core/persistence/persistence_keys.dart @@ -28,6 +28,11 @@ final class PreferenceKeys { /// It prevents bearer/credential restoration and proxy-cookie attachment /// after a process death or incomplete secure-storage cleanup. static const String incompleteLogoutFence = 'incomplete_logout_fence_v1'; + + /// Set when a user-requested full app-data clear failed part-way. Keeps + /// surviving Direct profiles hidden and unusable across restarts until the + /// logout fence is cleared. + static const String incompleteAppDataClear = 'incomplete_app_data_clear_v1'; static const String appIntentInvocationLedger = 'app_intent_invocation_ledger_v1'; static const String themeMode = 'theme_mode'; @@ -39,6 +44,8 @@ final class PreferenceKeys { 'release_notes_existing_install_at_bootstrap_v1'; static const String releaseNotesBannerPreviousVersion = 'release_notes_banner_previous_version_v1'; + static const String serverVersionWarningDismissed = + 'server_version_warning_dismissed_v1'; static const String ttsVoice = 'tts_voice'; static const String ttsVoiceName = 'tts_voice_name'; static const String ttsSpeechRate = 'tts_speech_rate'; diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 8da2b5750..17632f70c 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -169,6 +169,10 @@ final class SignOutCoordinator { directMcpServers.blockMutationsForAppDataClear(), hermesConfig.blockMutationsForAppDataClear(), ]); + // Armed before anything is wiped: a process death mid-clear must not + // bring surviving Direct profiles back on restart. Failing to arm it + // aborts the clear through the catch below. + await armIncompleteAppDataClearMarker(); _ref.invalidate(directProviderAdapterRegistryProvider); _ref.invalidate(directModelDiscoveryProvider); _ref.invalidate(directHttpClientPoolProvider); @@ -203,6 +207,7 @@ final class SignOutCoordinator { // admission barrier and may still lose auth ownership. await _ref.read(directLocalDatabasePurgeProvider)(); directLocalPurgeCompleted = true; + await disarmIncompleteAppDataClearMarker(); _resetProvidersAfterFullAppDataClear(_ref); case FullAppDataClearOutcome.incomplete: directRuns.commitAppDataClear(); @@ -211,10 +216,13 @@ final class SignOutCoordinator { directMcpServers.blockMutationsForAppDataClear(), hermesConfig.blockMutationsForAppDataClear(), ]); - directProfiles.revokeRuntimeAfterIncompleteAppDataClear(); directMcpServers.revokeRuntimeAfterIncompleteAppDataClear(); hermesConfig.revokeRuntimeAfterIncompleteAppDataClear(); + // Awaited last: it persists the restart marker that keeps surviving + // Direct profiles hidden, and must be durable before returning. + await directProfiles.revokeRuntimeAfterIncompleteAppDataClear(); case FullAppDataClearOutcome.ownershipYielded: + await disarmIncompleteAppDataClearMarker(); resumeGlobalAdmission(); directProfiles.resumeMutationsAfterAppDataClearAbort(); directMcpServers.resumeMutationsAfterAppDataClearAbort(); diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index e3cfc7a20..77f6de532 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -7333,6 +7333,7 @@ class ApiService { List>? toolServers, Map? backgroundTasks, Map? userSettings, + String? reasoningEffort, String? parentId, Map? userMessage, Map? variables, @@ -7474,6 +7475,15 @@ class ApiService { // Non-critical: proceed without user params } + // The user's per-model pick is the chat-level `params` the web client + // spreads after `$settings.params`. Sent explicitly, it reaches the + // server as a top-level form field, so the model's configured + // `reasoning_effort` default is skipped (apply_model_params_to_body only + // fills keys absent from the body). + if (reasoningEffort != null) { + params['reasoning_effort'] = reasoningEffort; + } + final modelInfo = modelItem?['info']; final openAiModel = modelItem?['openai']; final effort = params['reasoning_effort']; @@ -7656,6 +7666,7 @@ class ApiService { Map? backgroundTasks, String? responseMessageId, Map? userSettings, + String? reasoningEffort, String? parentId, Map? userMessage, Map? variables, @@ -7728,6 +7739,7 @@ class ApiService { toolServers: toolServers, backgroundTasks: backgroundTasks, userSettings: userSettings, + reasoningEffort: reasoningEffort, parentId: parentId, userMessage: userMessage, variables: variables, @@ -8197,6 +8209,7 @@ class ApiService { List>? toolServers, Map? backgroundTasks, Map? userSettings, + String? reasoningEffort, String? parentId, Map? userMessage, Map? variables, @@ -8218,6 +8231,7 @@ class ApiService { toolServers: toolServers, backgroundTasks: backgroundTasks, userSettings: userSettings, + reasoningEffort: reasoningEffort, parentId: parentId, userMessage: userMessage, variables: variables, diff --git a/lib/core/services/native_sheet_hydration_service.dart b/lib/core/services/native_sheet_hydration_service.dart index 75ce896cd..adc65ca11 100644 --- a/lib/core/services/native_sheet_hydration_service.dart +++ b/lib/core/services/native_sheet_hydration_service.dart @@ -74,6 +74,7 @@ nativeHydratedServerReasoningEffort({ required Model model, required ServerModelReasoningEffort detail, String? personalizationEffort, + String? localEffort, }) { final modelEffort = detail.value ?? modelConfiguredReasoningEffort(model); final policy = model.supportsReasoningEffort || modelEffort != null @@ -83,7 +84,7 @@ nativeHydratedServerReasoningEffort({ policy: policy, value: policy.effectiveConfiguredEffort( - modelEffort ?? personalizationEffort, + localEffort ?? modelEffort ?? personalizationEffort, ) ?? kAutomaticReasoningEffort, ); @@ -303,6 +304,10 @@ class NativeSheetHydrationService { .asData ?.value .reasoningEffort, + localEffort: localReasoningEffortForModel( + _ref.read, + lateEffortModel, + ), ); await bridge.updateModelSelectorReasoningEffort( presentationId: presentationId, diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index d4cd3c2bf..e52fff706 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -420,7 +420,10 @@ class _AssistantServerPatch { Future _handleReconnectRecovery({ required bool Function() hasFinished, required List Function() getMessages, - required Future<_ServerMessageSnapshot?> Function() pollServerForMessage, + required Future<_ServerMessageSnapshot?> Function({ + bool inferDoneFromMissingStreaming, + }) + pollServerForMessage, required bool Function( String, List, { @@ -442,7 +445,13 @@ Future _handleReconnectRecovery({ return; } - final result = await pollServerForMessage(); + // Open WebUI persists the in-progress assistant with `done: false` and no + // `isStreaming` key, so a missing flag says nothing about completion. + // Inferring "done" from it here finished a live stream the moment the app + // came back to the foreground; only an explicit done/error is terminal. + final result = await pollServerForMessage( + inferDoneFromMissingStreaming: false, + ); if (hasFinished()) return; if (result != null) { @@ -1430,32 +1439,32 @@ ActiveChatStream attachUnifiedChunkedStreaming({ }) { final eventType = event['type']?.toString() ?? ''; if (!openWebUIResponseStreamEventTouchesOutput(eventType)) return; - if (eventType == 'response.failed' || eventType == 'response.incomplete') { - // Terminal failure or cut-off: keep whatever output landed, then - // surface the state so a trailing [DONE] cannot finish the turn as a - // clean success. + if (eventType == 'response.failed') { + // Terminal failure: keep whatever output landed, then surface the state + // so a trailing [DONE] cannot finish the turn as a clean success. final response = event['response']; - final String? message; - if (eventType == 'response.failed') { - final error = response is Map ? response['error'] : null; - final text = error is Map - ? error['message']?.toString() - : error?.toString(); - message = text == null || text.trim().isEmpty - ? 'The response failed.' - : text; - } else { - final details = response is Map ? response['incomplete_details'] : null; - final reason = details is Map ? details['reason']?.toString() : null; - message = reason == null || reason.trim().isEmpty - ? 'The response stopped before it was complete.' - : 'The response stopped before it was complete ($reason).'; - } + final error = response is Map ? response['error'] : null; + final text = error is Map + ? error['message']?.toString() + : error?.toString(); + final message = text == null || text.trim().isEmpty + ? 'The response failed.' + : text; applyAssistantServerPatch( targetId: targetId, buildPatch: (_) => _AssistantServerPatch(error: ChatMessageError(content: message)), ); + } else if (eventType == 'response.incomplete') { + // Upstream contract (middleware.handle_responses_streaming_event): a + // cut-off response is not an error. The server keeps the output, runs + // the turn to its normal `done`, and the web client shows no banner. + // Raising an error here painted "stopped before it was complete" under + // the answer until that `done` cleared it again. + DebugLogger.log( + 'response.incomplete: keeping output without an error', + scope: 'streaming/helper', + ); } latestResponseOutputItems = applyOpenWebUIResponseStreamEvent( latestResponseOutputItems, diff --git a/lib/core/utils/persisted_message_content.dart b/lib/core/utils/persisted_message_content.dart new file mode 100644 index 000000000..74fc6531f --- /dev/null +++ b/lib/core/utils/persisted_message_content.dart @@ -0,0 +1,67 @@ +import '../models/chat_message.dart'; +import '../services/direct_replay_output.dart'; +import 'semantic_details.dart'; + +/// The `content` string Conduit persists for [message] in Open WebUI chat +/// payloads and the local message rows that feed them. +/// +/// Open WebUI's own web client stores `content = getOutputText(output)` (the +/// joined text of `type == 'message'` output items) and rebuilds the +/// reasoning / tool-call `
` presentation from `output` on load +/// (`Chat.svelte`, `structuredOutput.ts`). Conduit renders those wrappers +/// into `ChatMessage.content` locally, so persisting it verbatim leaks the +/// markup into shares, exports, and anything else that reads `content` +/// (issue #703). `conversation_parsing.dart` re-synthesizes the wrappers +/// from `output` on load, so a clean `content` plus an intact `output` +/// round-trips for display. +/// +/// The content is returned untouched when it is the only representation the +/// message has: +/// +/// * `output` is null or empty (older servers, Hermes, legacy shapes); +/// * `output` is Conduit's direct replay mirror, because on load the mirror +/// short-circuits the structured-output re-synthesis and the rendered +/// `content` is what the UI shows; +/// * `output` yields no message text and the content has no prose outside +/// its `
` wrappers. +String persistedMessageContent(ChatMessage message) { + final content = message.content; + if (message.role != 'assistant') return content; + final output = message.output; + if (output == null || output.isEmpty) return content; + if (parseConduitDirectReplayOutput(output) != null) return content; + + final outputText = outputItemsMessageText(output); + if (outputText.trim().isNotEmpty) return outputText; + + final stripped = stripRenderedSemanticDetails(content); + if (stripped.trim().isNotEmpty) return stripped; + return content; +} + +/// Mirrors Open WebUI's `getOutputText`: the text of every `type == 'message'` +/// item (its `content[].text` parts concatenated), skipping blank items and +/// joining the rest with a single newline. +String outputItemsMessageText(List output) { + final texts = []; + for (final item in output) { + if (item is! Map || item['type'] != 'message') continue; + final text = _messageItemText(item); + if (text.trim().isEmpty) continue; + texts.add(text); + } + return texts.join('\n'); +} + +String _messageItemText(Map item) { + final content = item['content']; + if (content is! List) return ''; + final buffer = StringBuffer(); + for (final part in content) { + if (part is! Map) continue; + final text = part['text']; + if (text == null) continue; + buffer.write(text is String ? text : text.toString()); + } + return buffer.toString(); +} diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 6011e0a8b..3d1648861 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -57,6 +57,7 @@ import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/json_normalization.dart'; import '../../../core/utils/message_tree_utils.dart' as message_tree; import '../../../core/utils/openwebui_message_payload.dart'; +import '../../../core/utils/persisted_message_content.dart'; import '../../../core/utils/semantic_details.dart'; import '../../auth/providers/unified_auth_providers.dart'; import '../utils/follow_ups_socket_event.dart'; @@ -6575,6 +6576,11 @@ class ChatMessagesNotifier extends Notifier> /// server's merge replaces each message object wholesale, so any field /// omitted here (`output`, `sources`, `usage`, …) would be wiped from the /// server copy on the next push. +/// +/// `content` is stored the way the Open WebUI web client stores it: the plain +/// output text when `output` carries the turn (see +/// [persistedMessageContent]); the rendered `
` presentation is +/// re-synthesized from `output` on load. MessageRowData localEchoRowForMessage(String chatId, ChatMessage message) { final timestamp = message.timestamp.millisecondsSinceEpoch ~/ 1000; final resolvedParentId = message_tree.chatMessageParentId(message); @@ -6587,7 +6593,7 @@ MessageRowData localEchoRowForMessage(String chatId, ChatMessage message) { chatId: chatId, parentId: resolvedParentId, role: message.role, - content: message.content, + content: persistedMessageContent(message), model: message.model, createdAt: timestamp, // Recomputed by upsertLocalEcho for new rows. @@ -6597,7 +6603,7 @@ MessageRowData localEchoRowForMessage(String chatId, ChatMessage message) { 'parentId': resolvedParentId, 'childrenIds': childrenIds, 'role': message.role, - 'content': message.content, + 'content': persistedMessageContent(message), 'timestamp': timestamp, 'isStreaming': message.isStreaming, if (message.role == 'assistant' && !message.isStreaming) 'done': true, @@ -9916,6 +9922,7 @@ Future regenerateMessage( backgroundTasks: bgTasks, responseMessageId: assistantMessageId, userSettings: userSettingsData, + reasoningEffort: reasoningEffortForModel(ref.read, selectedModel), parentId: parentMsgMap?['parentId']?.toString(), userMessage: parentMsgMap, variables: promptVars2, @@ -10403,6 +10410,10 @@ Future runQueuedCompletion( backgroundTasks: bgTasks, responseMessageId: assistantMessageId, userSettings: userSettingsData, + reasoningEffort: + selectedModel != null && selectedModel.id == effectiveModelId + ? reasoningEffortForModel(ref.read, selectedModel) + : null, parentId: parentMsgMap?['parentId']?.toString(), userMessage: parentMsgMap, variables: promptVars2, @@ -10639,6 +10650,10 @@ Future runHeadlessCompletion( backgroundTasks: bgTasks, responseMessageId: assistantMessageId, userSettings: userSettingsData, + reasoningEffort: + selectedModel != null && selectedModel.id == effectiveModelId + ? reasoningEffortForModel(ref.read, selectedModel) + : null, parentId: parentMsgMap?['parentId']?.toString(), userMessage: parentMsgMap, variables: promptVars, @@ -15132,7 +15147,7 @@ Map _directPersistedMessagePayload( 'parentId': parentId, 'childrenIds': childrenIds, 'role': message.role, - 'content': message.content, + 'content': persistedMessageContent(message), 'isStreaming': message.isStreaming, if (message.role == 'assistant' && !message.isStreaming) 'done': true, if (message.model != null) 'model': message.model, @@ -15198,7 +15213,7 @@ MessageRowData _directMessageRow({ chatId: chatId, parentId: parentId, role: message.role, - content: message.content, + content: persistedMessageContent(message), model: message.model, createdAt: message.timestamp.millisecondsSinceEpoch ~/ 1000, orderIndex: orderIndex, @@ -18309,6 +18324,7 @@ Future _sendMessageInternal( backgroundTasks: bgTasks, responseMessageId: assistantMessageId, userSettings: userSettingsData, + reasoningEffort: reasoningEffortForModel(ref.read, selectedModel), parentId: userMessageMap?['parentId']?.toString(), userMessage: userMessageMap, variables: promptVariables, diff --git a/lib/features/chat/providers/reasoning_effort_provider.dart b/lib/features/chat/providers/reasoning_effort_provider.dart index b4083a44e..42f6dea14 100644 --- a/lib/features/chat/providers/reasoning_effort_provider.dart +++ b/lib/features/chat/providers/reasoning_effort_provider.dart @@ -193,15 +193,45 @@ class LocalReasoningEfforts extends _$LocalReasoningEfforts { } } -String? _localEffortKey(Ref ref) { - final model = ref.watch(selectedModelProvider); - if (model == null) return null; +String? _openWebUiEffortKey(ReasoningEffortReader read, Model model) { + final serverId = read(apiServiceProvider)?.serverConfig.id; + if (serverId == null || serverId.isEmpty) return null; + return 'openwebui:$serverId:${model.id}'; +} + +/// Storage key in [LocalReasoningEfforts] for the user's own pick on [model]. +/// +/// Open WebUI server models are namespaced by the active server id so the +/// same model id on two servers never shares a preference. +String? localReasoningEffortKeyForModel( + ReasoningEffortReader read, + Model model, +) { if (isHermesModel(model)) return 'hermes:${model.id}'; - final binding = ref.watch(directModelRegistryProvider).resolve(model); + final binding = read(directModelRegistryProvider).resolve(model); if (binding != null) { return 'direct:${binding.profileId}:${binding.remoteModelId}'; } - return null; + return _openWebUiEffortKey(read, model); +} + +/// The raw per-model pick stored locally for [model], or null when the user +/// has not chosen one. Open WebUI models may store an explicit +/// [kAutomaticReasoningEffort] to mean "let the server decide". +String? localReasoningEffortForModel(ReasoningEffortReader read, Model model) { + final key = localReasoningEffortKeyForModel(read, model); + if (key == null) return null; + return read(localReasoningEffortsProvider)[key]; +} + +bool _isOpenWebUiServerModel(ReasoningEffortReader read, Model model) => + !isHermesModel(model) && + read(directModelRegistryProvider).resolve(model) == null; + +String? _localEffortKey(Ref ref) { + final model = ref.watch(selectedModelProvider); + if (model == null) return null; + return localReasoningEffortKeyForModel(ref.watch, model); } final configuredReasoningEffortProvider = Provider((ref) { @@ -220,12 +250,16 @@ final configuredReasoningEffortProvider = Provider((ref) { } final localKey = _localEffortKey(ref); - if (localKey != null) { + final localEffort = localKey == null + ? null + : ref.watch(localReasoningEffortsProvider)[localKey]; + // A user's own pick always wins. Hermes and Direct models have no + // server-side default, so they resolve entirely from the local record. + if (localEffort != null || + (localKey != null && !_isOpenWebUiServerModel(ref.watch, model))) { return ref .watch(reasoningEffortPolicyProvider) - .effectiveConfiguredEffort( - ref.watch(localReasoningEffortsProvider)[localKey], - ); + .effectiveConfiguredEffort(localEffort); } String? detailedModelEffort; @@ -299,6 +333,14 @@ String reasoningEffortForModel(ReasoningEffortReader read, Model? model) { return read(localReasoningEffortsProvider)['hermes:${model.id}'] ?? kAutomaticReasoningEffort; } + final localEffort = localReasoningEffortForModel(read, model); + if (localEffort != null) { + return reasoningEffortPolicyForModel( + read, + model, + ).effectiveConfiguredEffort(localEffort) ?? + kAutomaticReasoningEffort; + } String? detailedModelEffort; if (_isOpenWebUiWorkspaceModel(model)) { final detailedEffort = read(serverModelReasoningEffortProvider(model)); @@ -451,9 +493,15 @@ Future setReasoningEffortForModel( .set('hermes:${model.id}', configured); return; } - if (read(apiServiceProvider) != null) { - await read(personalizationSettingsProvider.notifier) - .setReasoningEffort(configured); + // Open WebUI server models: keep the pick per model and per server, like the + // web client's chat-level Controls, and send it explicitly with each + // completion so it wins over the model's configured default. An explicit + // "automatic" is stored too, so the pick can override a model default back + // to "let the server decide". + final serverKey = _openWebUiEffortKey(read, model); + if (serverKey != null) { + await read(localReasoningEffortsProvider.notifier) + .set(serverKey, normalized); } } diff --git a/lib/features/chat/services/voice_input_service.dart b/lib/features/chat/services/voice_input_service.dart index dac582cc5..4a21a93ac 100644 --- a/lib/features/chat/services/voice_input_service.dart +++ b/lib/features/chat/services/voice_input_service.dart @@ -82,7 +82,11 @@ class VoiceInputService { ? AudioManagerMode.modeInCommunication : AudioManagerMode.modeNormal, speakerphone: false, - manageBluetooth: true, + // During voice calls the audio session coordinator owns SCO and + // communication-device selection. Letting the record plugin manage + // Bluetooth makes every recorder stop clear the communication device + // (issue #716: the loudspeaker route is wiped right before TTS speaks). + manageBluetooth: !voiceCallSession, useLegacy: false, ); } diff --git a/lib/features/chat/voice_mode/chat_voice_audio_session_coordinator.dart b/lib/features/chat/voice_mode/chat_voice_audio_session_coordinator.dart index fea32054d..0a1cb31d2 100644 --- a/lib/features/chat/voice_mode/chat_voice_audio_session_coordinator.dart +++ b/lib/features/chat/voice_mode/chat_voice_audio_session_coordinator.dart @@ -96,6 +96,13 @@ class ChatVoiceAudioSessionCoordinator { @visibleForTesting bool debugRefuseRouteChanges = false; + /// Runs the Android route calls on a test host, where the audio manager + /// channel is mocked. The real platform check still wins on a device. + @visibleForTesting + bool debugTreatAsAndroid = false; + + bool get _isAndroid => Platform.isAndroid || debugTreatAsAndroid; + /// The phases teardown has run for, in order. A test host has no audio route /// to watch come back, so this is the only trace teardown leaves behind. /// Only filled in when asserts are on. @@ -398,7 +405,7 @@ class ChatVoiceAudioSessionCoordinator { /// it has. Call this before the first pass of a call, not after a manual /// toggle, or it would overwrite the user's choice. Future applyDefaultSpeakerphoneRoute() async { - if (!Platform.isAndroid && !Platform.isIOS) { + if (!_isAndroid && !Platform.isIOS) { return; } if (_routeChangesStopped) { @@ -659,7 +666,7 @@ class ChatVoiceAudioSessionCoordinator { // be set before the platform calls and put back if they refuse. _speakerphoneEnabled = enabled; var applied = true; - if (Platform.isAndroid) { + if (_isAndroid) { final manager = _androidAudioManager ??= AndroidAudioManager(); // Release the currently selected communication device before re-routing: // setCommunicationDevice replaces the route, but leaving SCO running @@ -694,7 +701,7 @@ class ChatVoiceAudioSessionCoordinator { /// Points the Android call at the route [_speakerphoneEnabled] asks for, and /// reports whether it landed. Future _configureAndroidVoiceRoute({required String phase}) async { - if (!Platform.isAndroid) { + if (!_isAndroid) { return true; } @@ -717,23 +724,11 @@ class ChatVoiceAudioSessionCoordinator { phase: phase, ); if (_speakerphoneEnabled) { - // setSpeakerphoneOn is deprecated and is a no-op on Android 12+ once a - // communication device is selected, so route to the built-in speaker - // explicitly and keep the legacy call only as the pre-31 fallback. - final routed = await _selectAndroidCommunicationDevice( + final loudspeaker = await _routeAndroidToLoudspeaker( manager, - AndroidAudioDeviceType.builtInSpeaker, phase: phase, ); - if (routed) { - return modeSet; - } - final legacyRouted = await _safeAndroidRouteAction( - () => manager.setSpeakerphoneOn(true), - operation: 'configure-speakerphone', - phase: phase, - ); - return modeSet && legacyRouted; + return modeSet && loudspeaker; } await _safeAndroidRouteCall( @@ -766,6 +761,91 @@ class ChatVoiceAudioSessionCoordinator { return modeSet; } + /// Puts the call on the built-in speaker and reports whether it is there. + /// + /// Neither route call answers honestly on its own: `setSpeakerphoneOn` is + /// void, and `setCommunicationDevice` can say yes and still be overridden + /// (issue #716). The route is read back after each and only counts as + /// applied when the read-back shows the loudspeaker. A read-back that fails + /// outright says nothing, so that case trusts the set call as before. + Future _routeAndroidToLoudspeaker( + AndroidAudioManager manager, { + required String phase, + }) async { + // setSpeakerphoneOn is deprecated and is a no-op on Android 12+ once a + // communication device is selected, so route to the built-in speaker + // explicitly and keep the legacy call as the pre-31 fallback. + final routed = await _selectAndroidCommunicationDevice( + manager, + AndroidAudioDeviceType.builtInSpeaker, + phase: phase, + ); + var loudspeaker = false; + String? deviceReadBack; + bool? speakerphoneReadBack; + if (routed) { + final selected = await _safeAndroidRouteCall( + () async => (device: await manager.getCommunicationDevice()), + operation: 'get-communication-device', + phase: phase, + ); + if (selected == null) { + loudspeaker = true; + } else { + loudspeaker = + selected.device?.type == AndroidAudioDeviceType.builtInSpeaker; + deviceReadBack = selected.device?.type.name ?? 'none'; + } + } + if (!loudspeaker) { + if (routed) { + // The platform accepted the speaker but the read-back disagrees. + // Release that selection first: setSpeakerphoneOn is ignored while a + // communication device stays selected, so the fallback would not + // move the route either. + await _safeAndroidRouteCall( + () => manager.clearCommunicationDevice(), + operation: 'clear-rejected-communication-device', + phase: phase, + ); + } + final legacyRouted = await _safeAndroidRouteAction( + () => manager.setSpeakerphoneOn(true), + operation: 'configure-speakerphone', + phase: phase, + ); + if (legacyRouted) { + final speakerphoneOn = await _safeAndroidRouteCall( + () => manager.isSpeakerphoneOn(), + operation: 'get-speakerphone', + phase: phase, + ); + loudspeaker = speakerphoneOn ?? true; + speakerphoneReadBack = speakerphoneOn; + } + } + final data = { + 'phase': phase, + 'communicationDevice': routed, + 'deviceReadBack': deviceReadBack, + 'speakerphoneReadBack': speakerphoneReadBack, + }; + if (loudspeaker) { + DebugLogger.info( + 'android-loudspeaker-route-applied', + scope: 'voice/audio-route', + data: data, + ); + } else { + DebugLogger.warning( + 'android-loudspeaker-route-refused', + scope: 'voice/audio-route', + data: data, + ); + } + return loudspeaker; + } + Future _selectAndroidCommunicationDevice( AndroidAudioManager manager, AndroidAudioDeviceType type, { @@ -799,7 +879,7 @@ class ChatVoiceAudioSessionCoordinator { } Future _restoreAndroidVoiceRoute() async { - if (!Platform.isAndroid) { + if (!_isAndroid) { return; } diff --git a/lib/features/chat/voice_mode/chat_voice_mode_controller.dart b/lib/features/chat/voice_mode/chat_voice_mode_controller.dart index a8e73c350..28dbcde1b 100644 --- a/lib/features/chat/voice_mode/chat_voice_mode_controller.dart +++ b/lib/features/chat/voice_mode/chat_voice_mode_controller.dart @@ -1550,7 +1550,10 @@ class ChatVoiceModeController extends Notifier { return; } _handleAssistantContentChanged(messages); - if (active.isStreaming) { + // The transport marks `responseDone` on a terminal finish reason while + // `isStreaming` can stay true until a later done event. Finalize on either + // signal so the trailing TTS chunk is not held until transport close. + if (!assistantMessageResponseCompleted(active)) { return; } @@ -1710,7 +1713,16 @@ class ChatVoiceModeController extends Notifier { String? _visibleAssistantText([List? messages]) { final message = _activeAssistantMessage(messages); if (message == null) return null; - if (message.isStreaming && _isLastStreamingAssistant(message, messages)) { + // Once the transport marks the response done it has flushed the buffered + // text into the message, while `streamingContentProvider` may still hold + // an earlier frame. The message is authoritative from that point. + final settled = + message.isStreaming && + assistantMessageResponseCompleted(message) && + message.content.isNotEmpty; + if (!settled && + message.isStreaming && + _isLastStreamingAssistant(message, messages)) { final visible = ref.read(streamingContentProvider); if (visible != null && visible.isNotEmpty) { return visible; diff --git a/lib/features/direct_connections/controllers/direct_connection_editor_workflow.dart b/lib/features/direct_connections/controllers/direct_connection_editor_workflow.dart index b7ee6752a..44951df8d 100644 --- a/lib/features/direct_connections/controllers/direct_connection_editor_workflow.dart +++ b/lib/features/direct_connections/controllers/direct_connection_editor_workflow.dart @@ -1,8 +1,10 @@ import 'package:flutter/foundation.dart'; +import '../../../core/utils/debug_logger.dart'; import '../../../shared/models/connection_attempt.dart'; import '../models/direct_connection_profile.dart'; import '../models/direct_remote_model.dart'; +import '../services/direct_adapter_helpers.dart'; import 'direct_connection_editor_draft.dart'; import 'direct_connection_editor_form.dart'; @@ -448,6 +450,7 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier { return _unavailable(messages); } catch (error) { if (!_canContinue()) return _unavailable(messages); + _logOperationFailure('Direct profile save failed', error, draft); _finishOperation(error: messages.saveFailed); return DirectEditorActionResult( DirectEditorActionOutcome.failed, @@ -456,6 +459,24 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier { } } + /// Logs a failed editor operation without leaking the draft's credentials + /// or endpoint. The message is redacted against every sensitive profile + /// value and stripped of URLs before it is emitted. + void _logOperationFailure( + String message, + Object error, + DirectConnectionProfile draft, + ) { + DebugLogger.error( + message, + scope: 'direct/editor', + data: { + 'errorType': error.runtimeType.toString(), + 'message': _redactedFailureMessage(error, draft), + }, + ); + } + Future testConnection({ required DirectEditorMessages messages, required DirectCredentialTransferConfirmation confirmCredentialTransfer, @@ -508,6 +529,7 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier { ); } catch (error) { if (!_canContinue()) return _unavailable(messages); + _logOperationFailure('Direct connection probe threw', error, draft); _finishOperation( attempt: ConnectionAttemptState.failed(messages.reachFailed), ); @@ -666,3 +688,17 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier { super.dispose(); } } + +final RegExp _urlPattern = RegExp(r'[A-Za-z][A-Za-z0-9+.-]*://\S+'); + +String _redactedFailureMessage(Object error, DirectConnectionProfile draft) { + final sanitized = sanitizeDirectProviderErrorMessage( + error.toString(), + sensitiveValues: [ + ...directProfileSensitiveValues(draft), + draft.baseUrl.trim(), + ], + maxCharacters: 200, + ); + return sanitized.replaceAll(_urlPattern, '[URL]'); +} diff --git a/lib/features/direct_connections/providers/direct_connection_providers.dart b/lib/features/direct_connections/providers/direct_connection_providers.dart index 077f20261..efe384a90 100644 --- a/lib/features/direct_connections/providers/direct_connection_providers.dart +++ b/lib/features/direct_connections/providers/direct_connection_providers.dart @@ -451,21 +451,43 @@ class DirectConnectionProfilesController extends AsyncNotifier> { Future _mutationQueue = Future.value(); bool _appDataClearBlocked = false; - bool _durableLogoutFenceBlocked = false; + bool _incompleteAppDataClearBlocked = false; List? _profilesBeforeAppDataClear; bool get _mutationsBlocked => - _appDataClearBlocked || _durableLogoutFenceBlocked; + _appDataClearBlocked || _incompleteAppDataClearBlocked; DirectConnectionProfileStore get _store => ref.read(directConnectionProfileStoreProvider); + /// Direct profiles live in their own secure-storage document with their own + /// credentials. The Open WebUI incomplete-logout fence + /// ([incompleteLogoutFenceProvider]) guards Open WebUI token/cookie + /// restoration, so an ordinary failed Open WebUI sign-out must not hide + /// Direct profiles or block Direct setup. + /// + /// A full app-data clear that the user requested is different: when it + /// fails part-way ([revokeRuntimeAfterIncompleteAppDataClear]), profiles the + /// wipe could not remove stay hidden and unusable, in memory and across + /// restarts ([PreferenceKeys.incompleteAppDataClear]), until the durable + /// logout fence is cleared by a completed cleanup or a new authenticated + /// session. @override Future> build() { - _durableLogoutFenceBlocked = ref.watch(incompleteLogoutFenceProvider); - if (_durableLogoutFenceBlocked) { - ref.read(directRunRegistryProvider).blockAdmissionForAppDataClear(); - return Future.value(const []); + final logoutFenceActive = ref.watch(incompleteLogoutFenceProvider); + final incompleteClearPersisted = + PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear) ?? + false; + if (_incompleteAppDataClearBlocked || incompleteClearPersisted) { + if (logoutFenceActive) { + _incompleteAppDataClearBlocked = true; + ref.read(directRunRegistryProvider).blockAdmissionForAppDataClear(); + return Future.value(const []); + } + _incompleteAppDataClearBlocked = false; + if (incompleteClearPersisted) { + unawaited(_clearIncompleteAppDataClearMarker()); + } } if (_appDataClearBlocked) { ref.read(directRunRegistryProvider).blockAdmissionForAppDataClear(); @@ -647,35 +669,98 @@ class DirectConnectionProfilesController }); } + /// Probes [profile] and always resolves to a [DirectConnectionProbe]. + /// + /// Pre-flight failures (validation, missing adapter, an in-flight app-data + /// clear) are reported as `reachable: false` with a specific message rather + /// than thrown, so the editor can show the real cause instead of a generic + /// "could not save" fallback. Future probe(DirectConnectionProfile profile) async { - _ensureMounted(); - if (_mutationsBlocked) { - throw StateError( - 'Direct connection probes are blocked while app data is being cleared.', - ); - } - profile.validate(); - final adapter = ref - .read(directProviderAdapterRegistryProvider) - .require(profile.adapterKey); try { + _ensureMounted(); + if (_mutationsBlocked) { + return const DirectConnectionProbe( + reachable: false, + message: + 'Direct connection probes are unavailable while app data is ' + 'being cleared.', + ); + } + final validationError = profile.validateOrNull(); + if (validationError != null) { + return DirectConnectionProbe( + reachable: false, + message: _sanitizeRuntimeAdapterMessage(profile, validationError), + ); + } + final adapter = ref + .read(directProviderAdapterRegistryProvider) + .lookup(profile.adapterKey); + if (adapter == null) { + return const DirectConnectionProbe( + reachable: false, + message: 'This provider type is not available on this device.', + ); + } final result = await adapter.probe(profile); final message = result.message; if (message == null) return result; return DirectConnectionProbe( reachable: result.reachable, modelCount: result.modelCount, - message: _sanitizeRuntimeAdapterMessage(profile, message), + message: result.reachable + ? _sanitizeRuntimeAdapterMessage(profile, message) + : _probeFailureMessage(profile, message), ); } catch (error) { final normalized = normalizeDirectProviderError(error); + DebugLogger.warning( + 'Direct connection probe failed', + scope: 'direct/profiles', + data: {'errorType': error.runtimeType.toString()}, + ); return DirectConnectionProbe( reachable: false, - message: _sanitizeRuntimeAdapterMessage(profile, normalized.message), + message: _probeFailureMessage( + profile, + normalized.message, + statusCode: normalized.statusCode, + ), ); } } + /// Sanitizes an untrusted provider failure and, for HTTP 401/403 under the + /// Azure API-key header mode, appends the trusted Bearer hint after + /// redaction so the combined text stays within the sanitizer budget. + String _probeFailureMessage( + DirectConnectionProfile profile, + String message, { + int? statusCode, + }) { + final hinted = directAuthModeHintApplies( + message, + profile: profile, + statusCode: statusCode, + ); + final sanitized = sanitizeDirectProviderErrorMessage( + message, + sensitiveValues: directProfileSensitiveValues(profile), + maxCharacters: hinted + ? kMaxDirectProviderErrorCharacters - + kDirectBearerAuthModeHint.length - + 2 + : kMaxDirectProviderErrorCharacters, + ); + return hinted + ? appendDirectAuthModeHint( + sanitized, + profile: profile, + statusCode: statusCode, + ) + : sanitized; + } + Future reload() async { final resources = _captureMutationResources(); await _serializeMutation(() => _reload(resources)); @@ -769,12 +854,9 @@ class DirectConnectionProfilesController void resumeMutationsAfterAppDataClearAbort() { if (ref.mounted) { _appDataClearBlocked = false; - final runRegistry = ref.read(directRunRegistryProvider); - if (_durableLogoutFenceBlocked) { - runRegistry.blockAdmissionForAppDataClear(); - } else { - runRegistry.resumeAdmissionAfterAppDataClearAbort(); - } + ref + .read(directRunRegistryProvider) + .resumeAdmissionAfterAppDataClearAbort(); final previous = _profilesBeforeAppDataClear; if (previous != null) { state = AsyncValue.data(previous); @@ -783,32 +865,47 @@ class DirectConnectionProfilesController } } - /// Drops every in-memory transport authority after a partial wipe while the - /// durable incomplete-logout fence keeps the controller blocked. - void revokeRuntimeAfterIncompleteAppDataClear() { + /// Drops every in-memory transport authority after a partial wipe and keeps + /// the controller blocked until the durable logout fence is cleared. + /// + /// The user asked for these credentials to be erased. Profiles the wipe + /// failed to remove must not come back for new completions, so the block + /// outlives the transient preparation flag and is persisted for restarts; + /// [build] releases it once the fence is cleared by a completed cleanup or + /// a new authenticated session. The returned future completes once the + /// restart marker is durable, or has failed and been logged; the in-memory + /// block holds either way for this process. + Future revokeRuntimeAfterIncompleteAppDataClear() async { if (!ref.mounted) return; - // The durable logout fence now owns the long-lived block. Releasing the - // transient preparation flag lets a later successful login resume this - // controller when that durable fence is cleared. _appDataClearBlocked = false; + _incompleteAppDataClearBlocked = true; _profilesBeforeAppDataClear = null; final current = state.value ?? const []; final clientPool = ref.read(directHttpClientPoolProvider); final modelRegistry = ref.read(directModelRegistryProvider); final runRegistry = ref.read(directRunRegistryProvider); + runRegistry.blockAdmissionForAppDataClear(); for (final profile in current) { _invalidateDirectProfileTransportBestEffort(clientPool, profile.id); _removeProfileModelsBestEffort(modelRegistry, profile.id); _cancelProfileRunsBestEffort(runRegistry, profile.id); } state = const AsyncValue.data([]); + // Re-assert the marker armed before the wipe: the wipe itself may have + // removed it. A failure propagates so the clear is not reported as + // handled while the restart fence is missing. + await armIncompleteAppDataClearMarker(); } + Future _clearIncompleteAppDataClearMarker() => + disarmIncompleteAppDataClearMarker(); + _DirectProfileMutationResources _captureMutationResources() { _ensureMounted(); if (_mutationsBlocked) { throw StateError( - 'Direct connection changes are unavailable while signing out.', + 'Direct connection changes are unavailable while app data is being ' + 'cleared.', ); } return ( @@ -2572,3 +2669,48 @@ void _cancelDirectProfileRunsBestEffort( ); } } + +/// Durably marks that a full app-data clear is in progress or ended +/// incomplete, so [DirectConnectionProfilesController.build] keeps surviving +/// Direct profiles hidden after a restart until the logout fence is cleared. +/// +/// Written through the app-data-clear write barrier with a checked write and +/// awaited by the caller. A failure throws: the clear must not proceed, or be +/// reported as handled, without the restart fence in place. +Future armIncompleteAppDataClearMarker() async { + try { + await PreferencesStore.putCheckedIf( + PreferenceKeys.incompleteAppDataClear, + true, + canWrite: () => true, + bypassAppDataClearBarrier: true, + ); + } catch (error) { + DebugLogger.error( + 'Failed to persist the incomplete app-data-clear marker', + scope: 'direct/profiles', + data: {'errorType': error.runtimeType.toString()}, + ); + rethrow; + } +} + +/// Removes the restart marker after a clear completed or was abandoned before +/// touching any data. Best effort: a stale marker only keeps Direct profiles +/// hidden until the logout fence is observed clear. +Future disarmIncompleteAppDataClearMarker() async { + try { + await PreferencesStore.putCheckedIf( + PreferenceKeys.incompleteAppDataClear, + null, + canWrite: () => true, + bypassAppDataClearBarrier: true, + ); + } catch (error) { + DebugLogger.warning( + 'Failed to clear the incomplete app-data-clear marker', + scope: 'direct/profiles', + data: {'errorType': error.runtimeType.toString()}, + ); + } +} diff --git a/lib/features/direct_connections/services/direct_adapter_helpers.dart b/lib/features/direct_connections/services/direct_adapter_helpers.dart index 15329bad8..4da1f89e5 100644 --- a/lib/features/direct_connections/services/direct_adapter_helpers.dart +++ b/lib/features/direct_connections/services/direct_adapter_helpers.dart @@ -634,6 +634,56 @@ DirectProviderException normalizeDirectProviderError(Object error) { return DirectProviderException('The provider request failed.', cause: error); } +/// Hint appended to an authentication failure when the profile sends its +/// credential in the Azure `api-key` header. Most OpenAI-compatible servers +/// (llama.cpp, vLLM, LM Studio, Ollama) only honor `Authorization: Bearer`. +const String kDirectBearerAuthModeHint = + 'This provider may expect the Bearer authentication mode.'; + +final RegExp _directAuthFailureStatusPattern = RegExp(r'\bHTTP (401|403)\b'); + +/// Whether [message] (or [statusCode]) reports an HTTP 401/403 while +/// [profile] sends its credential in the Azure API-key header. +/// +/// Adapters may surface the failure either as a thrown +/// [DirectProviderException] (carrying [statusCode]) or as a returned probe +/// whose message came from [normalizeDirectProviderError]; the message text is +/// the common denominator, so both shapes are recognized. +bool directAuthModeHintApplies( + String message, { + required DirectConnectionProfile profile, + int? statusCode, +}) { + if (profile.apiKeyAuthMode != DirectApiKeyAuthMode.apiKeyHeader) { + return false; + } + if (message.contains(kDirectBearerAuthModeHint)) return false; + return statusCode == 401 || + statusCode == 403 || + _directAuthFailureStatusPattern.hasMatch(message); +} + +/// Appends [kDirectBearerAuthModeHint] to an already sanitized [message] when +/// [directAuthModeHintApplies]. Call this after +/// [sanitizeDirectProviderErrorMessage]: the hint is trusted app text, and the +/// sanitizer's Authorization-header redaction would otherwise mangle it. +String appendDirectAuthModeHint( + String message, { + required DirectConnectionProfile profile, + int? statusCode, +}) { + if (!directAuthModeHintApplies( + message, + profile: profile, + statusCode: statusCode, + )) { + return message; + } + final trimmed = message.trimRight(); + final separator = trimmed.isEmpty ? '' : (trimmed.endsWith('.') ? ' ' : '. '); + return '$trimmed$separator$kDirectBearerAuthModeHint'; +} + const int kMaxDirectProviderErrorCharacters = 512; const String _redactedProviderValue = '[REDACTED]'; const int _maxProviderErrorSecretPatterns = 128; diff --git a/lib/features/direct_connections/services/openai_compatible_adapter.dart b/lib/features/direct_connections/services/openai_compatible_adapter.dart index 1d5dee435..cd10a2188 100644 --- a/lib/features/direct_connections/services/openai_compatible_adapter.dart +++ b/lib/features/direct_connections/services/openai_compatible_adapter.dart @@ -149,6 +149,9 @@ final class OpenAiCompatibleAdapter implements DirectProviderAdapter { ?.toString() .trim(); if (id == null || id.isEmpty || !seen.add(id)) continue; + // OpenRouter lists `:batch` variants that only accept the Batch API + // and return 404 on chat completions, so they are not usable here. + if (profile.isOpenRouter && id.endsWith(':batch')) continue; // Compatible providers frequently omit OpenAI's otherwise-required // object field. Normalize only that protocol detail, then let the SDK diff --git a/lib/features/direct_connections/views/direct_connection_editor_sections.dart b/lib/features/direct_connections/views/direct_connection_editor_sections.dart index b7d94558f..80c8b1a16 100644 --- a/lib/features/direct_connections/views/direct_connection_editor_sections.dart +++ b/lib/features/direct_connections/views/direct_connection_editor_sections.dart @@ -403,8 +403,11 @@ final class DirectConnectionDetailsSection extends StatelessWidget { return InsetGroupedList( footer: [ baseUrlDescription, + if (form.authentication == DirectAuthenticationMode.bearer && + form.canSelectApiKeyHeader) + l10n.directBearerDescription, if (form.authentication == DirectAuthenticationMode.apiKeyHeader) - l10n.directApiKeyHeaderDescription, + l10n.directApiKeyHeaderAzureOnlyDescription, if (form.authentication == DirectAuthenticationMode.unsupported) l10n.openWebUiDirectConnectionUnsupportedAuth, ].join('\n\n'), @@ -446,10 +449,20 @@ final class DirectConnectionDetailsSection extends StatelessWidget { ), const SizedBox(height: Spacing.sm), authenticationField, + if (form.authentication == DirectAuthenticationMode.bearer && + form.canSelectApiKeyHeader) ...[ + const SizedBox(height: Spacing.sm), + Text( + l10n.directBearerDescription, + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textSecondary, + ), + ), + ], if (form.authentication == DirectAuthenticationMode.apiKeyHeader) ...[ const SizedBox(height: Spacing.sm), Text( - l10n.directApiKeyHeaderDescription, + l10n.directApiKeyHeaderAzureOnlyDescription, style: AppTypography.bodySmallStyle.copyWith( color: theme.textSecondary, ), diff --git a/lib/features/hermes/services/hermes_api_service.dart b/lib/features/hermes/services/hermes_api_service.dart index 80424225a..dc7688346 100644 --- a/lib/features/hermes/services/hermes_api_service.dart +++ b/lib/features/hermes/services/hermes_api_service.dart @@ -1414,29 +1414,54 @@ class HermesApiService implements HermesBackendService, HermesTurnService { maxDuration: timeout, clock: _recoveryClock, ); - final resp = await _getRecoveryResponse( - '$_root/api/sessions/$encodedId/messages', - cancelToken: requestCancelToken, - recoveryBudget: budget, - limits: limits, - ); - final data = await _decodeHermesBoundedJsonValue( - resp.data, - cancelToken: requestCancelToken, - limits: limits, - recoveryBudget: budget, - ); + final Object? data; + try { + final resp = await _getRecoveryResponse( + '$_root/api/sessions/$encodedId/messages', + cancelToken: requestCancelToken, + recoveryBudget: budget, + limits: limits, + ); + data = await _decodeHermesBoundedJsonValue( + resp.data, + cancelToken: requestCancelToken, + limits: limits, + recoveryBudget: budget, + ); + } on HermesStreamGuardException catch (error, stackTrace) { + // Size-class guards become the shared "too large" type so the session + // list can tell "this conversation is too big" from a connection fault. + if (_isHermesSizeGuard(error)) { + Error.throwWithStackTrace( + HermesResponseTooLargeException(error.message), + stackTrace, + ); + } + rethrow; + } final list = data is Map ? (data['messages'] ?? data['data']) : data; if (list is! List) return const []; if (list.length > kMaxHermesSessionHistoryMessages) { _signalHermesStreamCancellation(requestCancelToken); - throw const HermesStreamGuardException( + throw const HermesResponseTooLargeException( 'The Hermes session history exceeded Conduit\'s message limit.', ); } return list.whereType().map((m) => m.cast()).toList(); } + /// Whether a recovery guard tripped on payload size rather than time. + static bool _isHermesSizeGuard(HermesStreamGuardException error) { + const sizeLimits = [ + 'transfer limit', + 'size limit', + 'nesting limit', + 'value limit', + 'token limit', + ]; + return sizeLimits.any(error.message.contains); + } + /// Starts one turn through Hermes's existing Responses SSE endpoint. /// /// Unlike `/v1/runs`, Responses accepts multimodal content on current Hermes diff --git a/lib/features/hermes/services/hermes_desktop_api_service.dart b/lib/features/hermes/services/hermes_desktop_api_service.dart index 70ab62157..fe60f4021 100644 --- a/lib/features/hermes/services/hermes_desktop_api_service.dart +++ b/lib/features/hermes/services/hermes_desktop_api_service.dart @@ -113,16 +113,43 @@ List> parseHermesDesktopConfiguredModels( return models; } +/// Default and smallest page sizes for `GET /api/sessions/{id}/messages`. +/// +/// A single 500-message page of a long tool-heavy conversation can exceed the +/// REST transport's character cap, so the pager starts smaller and shrinks +/// further whenever a page is rejected as too large. +const int kHermesDesktopTranscriptPageSize = 200; +const int kHermesDesktopTranscriptMinimumPageSize = 25; + @visibleForTesting Future>> loadHermesDesktopTranscriptPages( Future>> Function(int offset, int limit) loadPage, { int maximum = 10000, + int pageSize = kHermesDesktopTranscriptPageSize, + int minimumPageSize = kHermesDesktopTranscriptMinimumPageSize, }) async { - const pageSize = 500; + assert(pageSize > 0 && minimumPageSize > 0); final messages = >[]; + var currentPageSize = pageSize; while (messages.length < maximum) { - final limit = min(pageSize, maximum - messages.length); - final page = await loadPage(messages.length, limit); + final limit = min(currentPageSize, maximum - messages.length); + final List> page; + try { + page = await loadPage(messages.length, limit); + } on HermesResponseTooLargeException { + if (limit <= minimumPageSize) rethrow; + currentPageSize = max(minimumPageSize, limit ~/ 2); + DebugLogger.warning( + 'transcript-page-too-large', + scope: 'hermes/desktop', + data: { + 'offset': messages.length, + 'limit': limit, + 'retryLimit': currentPageSize, + }, + ); + continue; + } messages.addAll(page.take(limit)); if (page.length < limit) break; } diff --git a/lib/features/hermes/services/hermes_desktop_auth_rest.dart b/lib/features/hermes/services/hermes_desktop_auth_rest.dart index ed0d14886..38a644c40 100644 --- a/lib/features/hermes/services/hermes_desktop_auth_rest.dart +++ b/lib/features/hermes/services/hermes_desktop_auth_rest.dart @@ -260,12 +260,12 @@ extension _HermesDesktopAuthRest on HermesDesktopApiService { ); final bytes = response.data ?? const []; if (bytes.length > kMaxHermesDesktopFrameBytes) { - throw const FormatException('Hermes response is too large.'); + throw const HermesResponseTooLargeException(); } if (bytes.isEmpty) return null; final source = utf8.decode(bytes); if (source.length > kMaxHermesDesktopFrameCharacters) { - throw const FormatException('Hermes response is too large.'); + throw const HermesResponseTooLargeException(); } validateHermesJsonSource(source); return jsonDecode(source); diff --git a/lib/features/hermes/services/hermes_desktop_live_runtime.dart b/lib/features/hermes/services/hermes_desktop_live_runtime.dart index b5b258acd..50ac31051 100644 --- a/lib/features/hermes/services/hermes_desktop_live_runtime.dart +++ b/lib/features/hermes/services/hermes_desktop_live_runtime.dart @@ -397,20 +397,44 @@ extension _HermesDesktopLiveRuntime on HermesDesktopApiService { _bindingSocketGenerations[id] == _rpc.socketGeneration) { return const []; } - final binding = await _resume(id, refresh: true); - final hadBufferedEvent = _eventBuffer.take(binding.runtimeId).isNotEmpty; - final isBotChat = _sessionProfiles.containsKey(binding.storedId); + // The REST transcript does not need a runtime binding, so a session that + // the gateway refuses to resume (too large, wrong profile, transient + // failure) can still be read. Hermes Desktop makes the same split: it + // loads the transcript independently and treats resume as non-fatal for + // display. Sending a prompt still resumes and surfaces that error itself. + HermesSessionBinding? binding; + try { + binding = await _resume(id, refresh: true); + } on HermesDesktopRpcException catch (error) { + final isBotChat = _sessionProfiles.containsKey(id); + // Timeouts and disconnects are transport failures, not gateway replies; + // bot chats read history through the socket and need the runtime ID. + if (error.deliveryAmbiguous || isBotChat) rethrow; + DebugLogger.warning( + 'session-resume-failed-loading-rest', + scope: 'hermes/desktop', + data: { + 'code': error.code, + 'message': validateHermesBoundedString( + error.message, + maxCharacters: 200, + ), + }, + ); + } + final runtimeId = binding?.runtimeId; + final storedId = binding?.storedId ?? id; + final hadBufferedEvent = + runtimeId != null && _eventBuffer.take(runtimeId).isNotEmpty; + final isBotChat = _sessionProfiles.containsKey(storedId); Future>> load() async { - if (isBotChat) { + if (isBotChat && runtimeId != null) { if (cancelToken?.isCancelled == true) throw cancelToken!.cancelError!; final messages = _objects( await _rpc.request( 'session.history', - params: { - 'session_id': binding.runtimeId, - ..._sessionScope(binding.storedId), - }, + params: {'session_id': runtimeId, ..._sessionScope(storedId)}, ), 'messages', ); @@ -421,13 +445,13 @@ extension _HermesDesktopLiveRuntime on HermesDesktopApiService { (offset, limit) async => _objects( await _requestJson( 'GET', - '/api/sessions/${Uri.encodeComponent(binding.storedId)}/messages', + '/api/sessions/${Uri.encodeComponent(storedId)}/messages', query: { 'limit': limit, 'offset': offset, 'order': 'oldest', 'include_compacted': true, - ..._sessionScope(binding.storedId), + ..._sessionScope(storedId), }, cancelToken: cancelToken, ), @@ -440,11 +464,15 @@ extension _HermesDesktopLiveRuntime on HermesDesktopApiService { var messages = const >[]; for (var attempt = 0; attempt < 3; attempt++) { messages = await load(); - final arrivedDuringLoad = _eventBuffer.take(binding.runtimeId).isNotEmpty; + final arrivedDuringLoad = + runtimeId != null && _eventBuffer.take(runtimeId).isNotEmpty; if (!forceReload && !arrivedDuringLoad) break; forceReload = false; if (attempt == 2) _emitTranscriptChange(id); } + // A failed resume left the connection state at "synchronizing"; the + // transcript is loaded, and no turn is known to be running. + if (binding == null) _emitTurnState(HermesDesktopTurnState.idle); final usable = preferLastUsableHermesTranscript( _lastTranscripts[id] ?? const [], messages, diff --git a/lib/features/hermes/services/hermes_desktop_transport.dart b/lib/features/hermes/services/hermes_desktop_transport.dart index 74380be0d..c9acd7d02 100644 --- a/lib/features/hermes/services/hermes_desktop_transport.dart +++ b/lib/features/hermes/services/hermes_desktop_transport.dart @@ -265,7 +265,9 @@ final class HermesDesktopRpcClient { source = raw; } else if (raw is List) { if (raw.length > kMaxHermesDesktopFrameBytes) { - throw const FormatException('Hermes gateway frame is too large.'); + throw const HermesResponseTooLargeException( + 'Hermes gateway frame is too large.', + ); } source = utf8.decode(raw); } else { @@ -274,7 +276,9 @@ final class HermesDesktopRpcClient { if (source.length > kMaxHermesDesktopFrameCharacters || (source.length * 3 > kMaxHermesDesktopFrameBytes && utf8.encode(source).length > kMaxHermesDesktopFrameBytes)) { - throw const FormatException('Hermes gateway frame is too large.'); + throw const HermesResponseTooLargeException( + 'Hermes gateway frame is too large.', + ); } validateHermesJsonSource(source); final decoded = jsonDecode(source); diff --git a/lib/features/hermes/services/hermes_json_guard.dart b/lib/features/hermes/services/hermes_json_guard.dart index e6542ada4..10f97f25d 100644 --- a/lib/features/hermes/services/hermes_json_guard.dart +++ b/lib/features/hermes/services/hermes_json_guard.dart @@ -28,6 +28,27 @@ final class HermesJsonGuardException implements FormatException { String toString() => 'FormatException: $message'; } +/// A Hermes response or frame exceeded Conduit's transport size caps before +/// it was decoded. Callers can page smaller or tell the user the conversation +/// is too large; the payload itself is never retained. +final class HermesResponseTooLargeException implements FormatException { + const HermesResponseTooLargeException([ + this.message = 'Hermes response is too large.', + ]); + + @override + final String message; + + @override + int? get offset => null; + + @override + Object? get source => null; + + @override + String toString() => 'HermesResponseTooLargeException: $message'; +} + /// Scans untrusted JSON without constructing its decoded object graph. /// /// Strings are scanned in place so structural characters inside them do not diff --git a/lib/features/hermes/widgets/hermes_session_tile.dart b/lib/features/hermes/widgets/hermes_session_tile.dart index ed2a2f9b7..9deb5f8cd 100644 --- a/lib/features/hermes/widgets/hermes_session_tile.dart +++ b/lib/features/hermes/widgets/hermes_session_tile.dart @@ -1,4 +1,5 @@ import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:dio/dio.dart' show DioException; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -21,7 +22,10 @@ import '../models/hermes_bot.dart'; import '../providers/hermes_providers.dart'; import '../services/hermes_backend_service.dart'; import '../services/hermes_desktop_api_service.dart'; +import '../services/hermes_desktop_transport.dart'; import '../services/hermes_decision_projection.dart'; +import '../services/hermes_identifier.dart'; +import '../services/hermes_json_guard.dart'; import '../services/hermes_local_document_trust_store.dart'; import '../services/hermes_message_mapper.dart'; import '../services/hermes_pending_decision_store.dart'; @@ -244,6 +248,54 @@ Future deleteHermesSession(WidgetRef ref, String sessionId) async { } } +/// Hermes gateway `session.resume` error code for an unknown session. +const int kHermesDesktopSessionNotFoundCode = 4007; + +const int _maxHermesLoadFailureDetailCharacters = 120; + +/// Picks the user-facing message for a failed session open. +/// +/// Server-provided text is bounded and stripped of control characters before +/// it reaches the UI; anything unrecognised falls back to the generic message. +@visibleForTesting +String hermesSessionLoadFailureMessage(AppLocalizations l10n, Object error) { + if (error is HermesResponseTooLargeException) { + return l10n.hermesSessionTooLarge; + } + if (error is DioException && error.response?.statusCode == 404) { + return l10n.hermesSessionNotFound; + } + if (error is HermesDesktopRpcException) { + if (error.code == kHermesDesktopSessionNotFoundCode) { + return l10n.hermesSessionNotFound; + } + if (error.deliveryAmbiguous) return l10n.hermesSessionLoadFailed; + final detail = _hermesLoadFailureDetail(error.message); + if (detail != null) { + final code = error.code; + return l10n.hermesSessionLoadFailedDetail( + code == null ? detail : '$detail ($code)', + ); + } + } + return l10n.hermesSessionLoadFailed; +} + +String? _hermesLoadFailureDetail(String raw) { + final collapsed = raw + .replaceAll(RegExp(r'[\u0000-\u001F\u007F-\u009F\s]+'), ' ') + .trim(); + if (collapsed.isEmpty) return null; + final runes = collapsed.runes.toList(growable: false); + final truncated = runes.length > _maxHermesLoadFailureDetailCharacters + ? '${String.fromCharCodes(runes.take(_maxHermesLoadFailureDetailCharacters - 1))}\u2026' + : collapsed; + return validateHermesBoundedString( + truncated, + maxCharacters: _maxHermesLoadFailureDetailCharacters, + ); +} + /// Loads a Hermes session's transcript, binds it as the active chat, selects the /// Hermes model, and navigates to the chat view. Subsequent sends continue the /// same server-side session. @@ -274,12 +326,16 @@ Future openHermesSession( DebugLogger.error( 'open-session-failed', scope: 'hermes/sessions', - data: {'errorType': error.runtimeType.toString()}, + data: { + 'errorType': error.runtimeType.toString(), + if (error is HermesDesktopRpcException) 'rpcCode': error.code, + if (error is DioException) 'httpStatus': error.response?.statusCode, + }, ); if (context.mounted) { UiUtils.showMessage( context, - AppLocalizations.of(context)!.hermesSessionLoadFailed, + hermesSessionLoadFailureMessage(AppLocalizations.of(context)!, error), isError: true, ); } diff --git a/lib/features/profile/widgets/default_model_sheet.dart b/lib/features/profile/widgets/default_model_sheet.dart index 96524b296..d5b569258 100644 --- a/lib/features/profile/widgets/default_model_sheet.dart +++ b/lib/features/profile/widgets/default_model_sheet.dart @@ -188,6 +188,9 @@ class DefaultModelBottomSheetState isSelected: isSelected, isAutoSelect: isAutoSelect, iconUrl: iconUrl, + surfaceColor: context + .conduitTheme + .surfaceBackground, onTap: () { final selectedId = isAutoSelect ? 'auto-select' diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index dd461cd6f..3e141d77b 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -4909,4 +4909,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Tato konverzace je příliš velká na načtení v tomto zařízení." + ,"hermesSessionNotFound": "Tato konverzace už není na serveru Hermes dostupná." + ,"hermesSessionLoadFailedDetail": "Tuto konverzaci se nepodařilo načíst: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Odešle přihlašovací údaje v hlavičce Authorization: Bearer. Většina serverů kompatibilních s OpenAI (llama.cpp, vLLM, LM Studio, Ollama) to očekává." + ,"directApiKeyHeaderAzureOnlyDescription": "Odešle přihlašovací údaje v hlavičce api-key. Používejte pouze pro Azure OpenAI; ostatní servery kompatibilní s OpenAI očekávají token Bearer." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8822dbdda..5590fab68 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3254,4 +3254,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Diese Unterhaltung ist zu groß, um sie auf diesem Gerät zu laden." + ,"hermesSessionNotFound": "Diese Unterhaltung ist auf dem Hermes-Server nicht mehr verfügbar." + ,"hermesSessionLoadFailedDetail": "Diese Unterhaltung konnte nicht geladen werden: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Sendet die Anmeldedaten im Header Authorization: Bearer. Die meisten OpenAI-kompatiblen Server (llama.cpp, vLLM, LM Studio, Ollama) erwarten das." + ,"directApiKeyHeaderAzureOnlyDescription": "Sendet die Anmeldedaten im Header api-key. Nur für Azure OpenAI verwenden; andere OpenAI-kompatible Server erwarten ein Bearer-Token." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 565d4a4cb..3869dbe9d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5564,6 +5564,23 @@ "@hermesSessionLoadFailed": { "description": "Error shown when a Hermes conversation cannot be loaded." }, + "hermesSessionTooLarge": "This conversation is too large to load on this device.", + "@hermesSessionTooLarge": { + "description": "Error shown when a Hermes conversation transcript exceeds the size the app can load." + }, + "hermesSessionNotFound": "This conversation is no longer available on the Hermes server.", + "@hermesSessionNotFound": { + "description": "Error shown when the Hermes server reports that a conversation does not exist." + }, + "hermesSessionLoadFailedDetail": "Could not load this conversation: {detail}", + "@hermesSessionLoadFailedDetail": { + "description": "Error shown when a Hermes conversation cannot be loaded and the server gave a reason.", + "placeholders": { + "detail": { + "type": "String" + } + } + }, "hermesMemoryKeyFieldLabel": "Memory key", "@hermesMemoryKeyFieldLabel": { "description": "Label for the Hermes long-term memory key field." @@ -6001,5 +6018,9 @@ "directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network.", "@directMcpInsecureCredentialsMessage": {"description":"Warning before allowing MCP credentials over non-loopback HTTP."}, "directMcpInsecureCredentialsConfirm": "Send without encryption", - "@directMcpInsecureCredentialsConfirm": {"description":"Action confirming MCP credentials may be sent over unencrypted HTTP."} + "@directMcpInsecureCredentialsConfirm": {"description":"Action confirming MCP credentials may be sent over unencrypted HTTP."}, + "directBearerDescription": "Sends the credential as an Authorization: Bearer header. Most OpenAI-compatible servers (llama.cpp, vLLM, LM Studio, Ollama) expect this.", + "@directBearerDescription": {"description":"Help text for the Bearer token authentication mode of a direct connection."}, + "directApiKeyHeaderAzureOnlyDescription": "Sends the credential in the api-key header. Use this only for Azure OpenAI; other OpenAI-compatible servers expect a Bearer token.", + "@directApiKeyHeaderAzureOnlyDescription": {"description":"Help text for the Azure OpenAI api-key header authentication mode, noting it is Azure-only."} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0ed96c45d..3f51916ef 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3254,4 +3254,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Esta conversación es demasiado grande para cargarla en este dispositivo." + ,"hermesSessionNotFound": "Esta conversación ya no está disponible en el servidor Hermes." + ,"hermesSessionLoadFailedDetail": "No se pudo cargar esta conversación: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Envía la credencial en la cabecera Authorization: Bearer. La mayoría de los servidores compatibles con OpenAI (llama.cpp, vLLM, LM Studio, Ollama) lo esperan así." + ,"directApiKeyHeaderAzureOnlyDescription": "Envía la credencial en la cabecera api-key. Úsalo solo para Azure OpenAI; otros servidores compatibles con OpenAI esperan un token Bearer." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 1b1374ad7..eb7ed02d8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3254,4 +3254,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Cette conversation est trop volumineuse pour être chargée sur cet appareil." + ,"hermesSessionNotFound": "Cette conversation n'est plus disponible sur le serveur Hermes." + ,"hermesSessionLoadFailedDetail": "Impossible de charger cette conversation : {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Envoie l'identifiant dans l'en-tête Authorization: Bearer. La plupart des serveurs compatibles OpenAI (llama.cpp, vLLM, LM Studio, Ollama) l'attendent ainsi." + ,"directApiKeyHeaderAzureOnlyDescription": "Envoie l'identifiant dans l'en-tête api-key. À utiliser uniquement pour Azure OpenAI ; les autres serveurs compatibles OpenAI attendent un jeton Bearer." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index fcc4c9117..a263ca179 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -3254,4 +3254,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Questa conversazione è troppo grande per essere caricata su questo dispositivo." + ,"hermesSessionNotFound": "Questa conversazione non è più disponibile sul server Hermes." + ,"hermesSessionLoadFailedDetail": "Impossibile caricare questa conversazione: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Invia la credenziale nell'intestazione Authorization: Bearer. La maggior parte dei server compatibili con OpenAI (llama.cpp, vLLM, LM Studio, Ollama) si aspetta questo." + ,"directApiKeyHeaderAzureOnlyDescription": "Invia la credenziale nell'intestazione api-key. Usalo solo per Azure OpenAI; gli altri server compatibili con OpenAI si aspettano un token Bearer." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 69b91ecba..d056d2301 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -4594,4 +4594,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "この会話は大きすぎるため、このデバイスでは読み込めません。" + ,"hermesSessionNotFound": "この会話は Hermes サーバー上で利用できなくなりました。" + ,"hermesSessionLoadFailedDetail": "この会話を読み込めませんでした: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "認証情報を Authorization: Bearer ヘッダーで送信します。ほとんどの OpenAI 互換サーバー(llama.cpp、vLLM、LM Studio、Ollama)はこの方式を想定しています。" + ,"directApiKeyHeaderAzureOnlyDescription": "認証情報を api-key ヘッダーで送信します。Azure OpenAI 専用です。他の OpenAI 互換サーバーは Bearer トークンを想定しています。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 53de921bf..d126f4c13 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -3032,4 +3032,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "이 대화는 너무 커서 이 기기에서 불러올 수 없습니다." + ,"hermesSessionNotFound": "이 대화는 더 이상 Hermes 서버에서 사용할 수 없습니다." + ,"hermesSessionLoadFailedDetail": "이 대화를 불러올 수 없습니다: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "자격 증명을 Authorization: Bearer 헤더로 보냅니다. 대부분의 OpenAI 호환 서버(llama.cpp, vLLM, LM Studio, Ollama)가 이 방식을 사용합니다." + ,"directApiKeyHeaderAzureOnlyDescription": "자격 증명을 api-key 헤더로 보냅니다. Azure OpenAI에만 사용하세요. 다른 OpenAI 호환 서버는 Bearer 토큰을 기대합니다." } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 32b2ac442..c01ba5fd2 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -3254,4 +3254,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Dit gesprek is te groot om op dit apparaat te laden." + ,"hermesSessionNotFound": "Dit gesprek is niet meer beschikbaar op de Hermes-server." + ,"hermesSessionLoadFailedDetail": "Kan dit gesprek niet laden: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Verstuurt de inloggegevens in de header Authorization: Bearer. De meeste OpenAI-compatibele servers (llama.cpp, vLLM, LM Studio, Ollama) verwachten dit." + ,"directApiKeyHeaderAzureOnlyDescription": "Verstuurt de inloggegevens in de header api-key. Gebruik dit alleen voor Azure OpenAI; andere OpenAI-compatibele servers verwachten een Bearer-token." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index ede27e11b..05d4d9a8c 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -6415,4 +6415,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Ta rozmowa jest zbyt duża, aby wczytać ją na tym urządzeniu." + ,"hermesSessionNotFound": "Ta rozmowa nie jest już dostępna na serwerze Hermes." + ,"hermesSessionLoadFailedDetail": "Nie można wczytać tej rozmowy: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Wysyła dane logowania w nagłówku Authorization: Bearer. Większość serwerów zgodnych z OpenAI (llama.cpp, vLLM, LM Studio, Ollama) tego oczekuje." + ,"directApiKeyHeaderAzureOnlyDescription": "Wysyła dane logowania w nagłówku api-key. Używaj tego tylko dla Azure OpenAI; inne serwery zgodne z OpenAI oczekują tokenu Bearer." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 530465727..6873a24ea 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3368,5 +3368,17 @@ "workspaceKnowledgeSubtitle": "Коллекции документов для семантического поиска", "directMcpInsecureCredentialsTitle": "Send credentials without encryption?", "directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network.", - "directMcpInsecureCredentialsConfirm": "Send without encryption" + "directMcpInsecureCredentialsConfirm": "Send without encryption", + "hermesSessionTooLarge": "Этот разговор слишком большой, чтобы загрузить его на этом устройстве.", + "hermesSessionNotFound": "Этот разговор больше недоступен на сервере Hermes.", + "hermesSessionLoadFailedDetail": "Не удалось загрузить этот разговор: {detail}", + "@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + }, + "directBearerDescription": "Отправляет учётные данные в заголовке Authorization: Bearer. Большинство OpenAI-совместимых серверов (llama.cpp, vLLM, LM Studio, Ollama) ожидают именно это.", + "directApiKeyHeaderAzureOnlyDescription": "Отправляет учётные данные в заголовке api-key. Используйте только для Azure OpenAI; другие OpenAI-совместимые серверы ожидают токен Bearer." } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 7727a7ffe..599994f4f 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -4909,4 +4909,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "Táto konverzácia je príliš veľká na načítanie na tomto zariadení." + ,"hermesSessionNotFound": "Táto konverzácia už nie je na serveri Hermes dostupná." + ,"hermesSessionLoadFailedDetail": "Túto konverzáciu sa nepodarilo načítať: {detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "Odošle prihlasovacie údaje v hlavičke Authorization: Bearer. Väčšina serverov kompatibilných s OpenAI (llama.cpp, vLLM, LM Studio, Ollama) to očakáva." + ,"directApiKeyHeaderAzureOnlyDescription": "Odošle prihlasovacie údaje v hlavičke api-key. Používajte iba pre Azure OpenAI; ostatné servery kompatibilné s OpenAI očakávajú token Bearer." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6cfd44d66..0e27b01ee 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3254,4 +3254,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "此对话过大,无法在此设备上加载。" + ,"hermesSessionNotFound": "此对话在 Hermes 服务器上已不可用。" + ,"hermesSessionLoadFailedDetail": "无法加载此对话:{detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "通过 Authorization: Bearer 请求头发送凭据。大多数 OpenAI 兼容服务器(llama.cpp、vLLM、LM Studio、Ollama)都采用此方式。" + ,"directApiKeyHeaderAzureOnlyDescription": "通过 api-key 请求头发送凭据。仅用于 Azure OpenAI;其他 OpenAI 兼容服务器需要 Bearer 令牌。" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 48d311444..35883da24 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -3254,4 +3254,16 @@ ,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?" ,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network." ,"directMcpInsecureCredentialsConfirm": "Send without encryption" + ,"hermesSessionTooLarge": "此對話過大,無法在此裝置上載入。" + ,"hermesSessionNotFound": "此對話在 Hermes 伺服器上已不可用。" + ,"hermesSessionLoadFailedDetail": "無法載入此對話:{detail}" + ,"@hermesSessionLoadFailedDetail": { + "placeholders": { + "detail": { + "type": "String" + } + } + } + ,"directBearerDescription": "透過 Authorization: Bearer 標頭傳送憑證。大多數 OpenAI 相容伺服器(llama.cpp、vLLM、LM Studio、Ollama)都採用此方式。" + ,"directApiKeyHeaderAzureOnlyDescription": "透過 api-key 標頭傳送憑證。僅用於 Azure OpenAI;其他 OpenAI 相容伺服器需要 Bearer 權杖。" } diff --git a/lib/shared/widgets/model_list_tile.dart b/lib/shared/widgets/model_list_tile.dart index 8103eee8f..6febca2d9 100644 --- a/lib/shared/widgets/model_list_tile.dart +++ b/lib/shared/widgets/model_list_tile.dart @@ -167,6 +167,14 @@ class ModelListTile extends StatelessWidget { /// Optional row-level action that does not select the model. final Widget? trailing; + /// The opaque surface this row is painted on. + /// + /// Defaults to the card background because model selectors group rows + /// inside a `ConduitCard`. The selected highlight and the trailing overflow + /// fade both derive from this color so they match what is actually behind + /// the row. + final Color? surfaceColor; + const ModelListTile({ super.key, required this.model, @@ -177,6 +185,7 @@ class ModelListTile extends StatelessWidget { this.isPinned = false, this.isLoaded = false, this.trailing, + this.surfaceColor, }); @override @@ -185,13 +194,14 @@ class ModelListTile extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final borderRadius = BorderRadius.circular(AppBorderRadius.card); - final baseBackground = theme.surfaceBackground; - final background = isSelected + final baseBackground = surfaceColor ?? theme.cardBackground; + final rowSurface = isSelected ? Color.alphaBlend( theme.buttonPrimary.withValues(alpha: 0.1), baseBackground, ) - : Colors.transparent; + : baseBackground; + final background = isSelected ? rowSurface : Colors.transparent; final Widget leading; if (isAutoSelect) { @@ -288,6 +298,7 @@ class ModelListTile extends StatelessWidget { ConstrainedBox( constraints: const BoxConstraints(minHeight: 22), child: HorizontalOverflowFade( + color: rowSurface, child: HorizontalScrollGestureBoundary( child: SingleChildScrollView( scrollDirection: Axis.horizontal, diff --git a/lib/shared/widgets/server_version_warning_card.dart b/lib/shared/widgets/server_version_warning_card.dart index 539a0328d..da74edcea 100644 --- a/lib/shared/widgets/server_version_warning_card.dart +++ b/lib/shared/widgets/server_version_warning_card.dart @@ -1,3 +1,6 @@ +import 'dart:io' show Platform; + +import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,8 +9,20 @@ import '../../core/utils/server_version_compat.dart'; import '../../features/auth/providers/unified_auth_providers.dart'; import '../../l10n/app_localizations.dart'; import '../theme/theme_extensions.dart'; +import 'conduit_components.dart'; +import 'server_version_warning_controller.dart'; -/// Persistent, in-flow compatibility warning shown with the empty-chat greeting. +const serverVersionWarningCardKey = ValueKey( + 'server-version-warning-card', +); +const serverVersionWarningCardCloseKey = ValueKey( + 'server-version-warning-card-close', +); + +/// In-flow compatibility warning shown with the empty-chat greeting. +/// +/// Dismissible; the dismissal is remembered per active server and reported +/// version, so it returns for a different server or a newer server version. class ServerVersionWarningCard extends ConsumerWidget { const ServerVersionWarningCard({super.key}); @@ -15,17 +30,57 @@ class ServerVersionWarningCard extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authNavigationStateProvider); final serverIsNewerThanSupported = ref.watch(serverIncompatibleProvider); + final activeServerId = ref.watch(activeServerProvider).asData?.value?.id; final serverVersion = ref .watch(backendConfigProvider) .asData ?.value ?.version; + final dismissedTokens = ref.watch(serverVersionWarningDismissedProvider); + // serverIncompatibleProvider is only true once the active server id is + // known, so the token is always attributable to a real server here. + final token = serverVersionWarningToken( + serverId: activeServerId ?? '', + version: serverVersion, + ); final showWarning = authState == AuthNavigationState.authenticated && - serverIsNewerThanSupported; - if (!showWarning) return const SizedBox.shrink(); + serverIsNewerThanSupported && + !dismissedTokens.contains(token); + final motionDuration = context.motionDuration( + const Duration(milliseconds: 220), + ); + return AnimatedSwitcher( + duration: motionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: showWarning + ? _ServerVersionWarningBody( + key: serverVersionWarningCardKey, + serverVersion: serverVersion, + onDismiss: () => ref + .read(serverVersionWarningDismissedProvider.notifier) + .dismiss(token), + ) + : const SizedBox.shrink(), + ); + } +} + +class _ServerVersionWarningBody extends StatelessWidget { + const _ServerVersionWarningBody({ + super.key, + required this.serverVersion, + required this.onDismiss, + }); + + final String? serverVersion; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final theme = context.conduitTheme; final version = serverVersion?.trim(); @@ -54,9 +109,13 @@ class ServerVersionWarningCard extends ConsumerWidget { liveRegion: true, label: l10n.serverIncompatibleTitle, child: Container( - key: const ValueKey('server-version-warning-card'), width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), + padding: const EdgeInsetsDirectional.fromSTEB( + Spacing.md, + Spacing.md, + Spacing.sm, + Spacing.md, + ), decoration: BoxDecoration( color: theme.warningBackground, borderRadius: BorderRadius.circular(AppBorderRadius.card), @@ -90,6 +149,17 @@ class ServerVersionWarningCard extends ConsumerWidget { ], ), ), + const SizedBox(width: Spacing.xs), + ConduitIconButton( + key: serverVersionWarningCardCloseKey, + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: onDismiss, + icon: Platform.isIOS + ? CupertinoIcons.xmark + : Icons.close_rounded, + iconColor: theme.textSecondary, + isCompact: true, + ), ], ), ), diff --git a/lib/shared/widgets/server_version_warning_controller.dart b/lib/shared/widgets/server_version_warning_controller.dart new file mode 100644 index 000000000..2833002e9 --- /dev/null +++ b/lib/shared/widgets/server_version_warning_controller.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/persistence/persistence_keys.dart'; +import '../../core/persistence/preferences_store.dart'; +import '../../core/utils/debug_logger.dart'; + +/// Builds the dismissal token for the unsupported-server warning. +/// +/// The token is scoped to the active server *and* its reported version, so a +/// dismissed warning reappears only for a different server or after that +/// server upgrades to another unsupported version. +String serverVersionWarningToken({ + required String serverId, + required String? version, +}) => '$serverId|${version?.trim() ?? ''}'; + +/// Upper bound on remembered dismissals. Oldest entries drop first, so the +/// preference cannot grow without limit across many servers and upgrades. +const int kMaxServerVersionWarningDismissals = 32; + +/// The persisted `|` tokens the user dismissed. +final serverVersionWarningDismissedProvider = + NotifierProvider>( + ServerVersionWarningController.new, + ); + +class ServerVersionWarningController extends Notifier> { + @override + Set build() => decodeServerVersionWarningDismissals( + PreferencesStore.getString(PreferenceKeys.serverVersionWarningDismissed), + ); + + /// Remembers [token] alongside every earlier dismissal, so acknowledging + /// the warning on one server never re-surfaces it on another. + Future dismiss(String token) async { + if (state.contains(token)) return; + final next = {...state, token}; + while (next.length > kMaxServerVersionWarningDismissals) { + next.remove(next.first); + } + state = next; + try { + await PreferencesStore.put( + PreferenceKeys.serverVersionWarningDismissed, + jsonEncode(next.toList(growable: false)), + ); + } catch (error) { + DebugLogger.warning( + 'Failed to persist server version warning dismissal', + scope: 'ui/server-version-warning', + data: {'error': error.toString()}, + ); + } + } +} + +/// Parses the stored dismissal list. A bare token (the first shipped format) +/// is accepted as a single entry; anything unparseable yields no dismissals. +Set decodeServerVersionWarningDismissals(String? raw) { + if (raw == null || raw.isEmpty) return const {}; + if (!raw.startsWith('[')) return {raw}; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const {}; + return { + for (final entry in decoded) + if (entry is String && entry.isNotEmpty) entry, + }; + } on FormatException { + return const {}; + } +} diff --git a/test/core/providers/sign_out_coordinator_test.dart b/test/core/providers/sign_out_coordinator_test.dart index 6fc98db52..39240e0a7 100644 --- a/test/core/providers/sign_out_coordinator_test.dart +++ b/test/core/providers/sign_out_coordinator_test.dart @@ -6,6 +6,7 @@ import 'package:conduit/core/database/app_database.dart'; import 'package:conduit/core/database/database_provider.dart'; import 'package:conduit/core/database/database_manager.dart'; import 'package:conduit/core/database/mappers/chat_blob_mapper.dart'; +import 'package:conduit/core/persistence/persistence_keys.dart'; import 'package:conduit/core/persistence/preferences_store.dart'; import 'package:conduit/core/providers/app_providers.dart'; import 'package:conduit/core/services/secure_credential_storage.dart'; @@ -129,6 +130,54 @@ void main() { }, ); + test('a completed clear leaves no incomplete-clear marker', () async { + final container = ProviderContainer( + overrides: [ + authStateManagerProvider.overrideWith( + () => _ClearedAuthStateManager(FullAppDataClearOutcome.cleared), + ), + directConnectionProfilesProvider.overrideWith(_EmptyDirectProfiles.new), + hermesConfigProvider.overrideWith(_EmptyHermesConfig.new), + directLocalDatabasePurgeProvider.overrideWithValue(() async {}), + ], + ); + addTearDown(container.dispose); + await container.read(authStateManagerProvider.future); + await container.read(directConnectionProfilesProvider.future); + container.read(hermesConfigProvider); + + await container + .read(signOutCoordinatorProvider) + .signOut(keepServerDetails: true); + + check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear)) + .isNull(); + }); + + test('an incomplete clear leaves the restart marker armed', () async { + final container = ProviderContainer( + overrides: [ + authStateManagerProvider.overrideWith( + () => _ClearedAuthStateManager(FullAppDataClearOutcome.incomplete), + ), + directConnectionProfilesProvider.overrideWith(_EmptyDirectProfiles.new), + hermesConfigProvider.overrideWith(_EmptyHermesConfig.new), + directLocalDatabasePurgeProvider.overrideWithValue(() async {}), + ], + ); + addTearDown(container.dispose); + await container.read(authStateManagerProvider.future); + await container.read(directConnectionProfilesProvider.future); + container.read(hermesConfigProvider); + + await container + .read(signOutCoordinatorProvider) + .signOut(keepServerDetails: true); + + check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear)) + .equals(true); + }); + test( 'failed direct-local purge keeps destructive-clear barriers closed', () async { diff --git a/test/core/services/api_service_chat_completion_test.dart b/test/core/services/api_service_chat_completion_test.dart index 04e402432..52b884f27 100644 --- a/test/core/services/api_service_chat_completion_test.dart +++ b/test/core/services/api_service_chat_completion_test.dart @@ -818,6 +818,32 @@ void main() { // Payload builder // ----------------------------------------------------------------------- group('buildChatCompletionPayloadForTest', () { + test('explicit reasoning effort overrides user settings params', () { + final api = _buildApiServiceForTest(_FakeAdapter.json({})); + + Map params(String? reasoningEffort) => + api.buildChatCompletionPayloadForTest( + messages: [ + {'role': 'user', 'content': 'hello'}, + ], + model: 'gpt-5', + messageId: 'msg-1', + sessionId: 'sess-1', + modelItem: {'id': 'gpt-5', 'name': 'gpt-5'}, + userSettings: { + 'params': {'reasoning_effort': 'low', 'temperature': 0.2}, + }, + reasoningEffort: reasoningEffort, + )['params'] + as Map; + + check(params('high')) + .deepEquals({'reasoning_effort': 'high', 'temperature': 0.2}); + check(params(null)) + .deepEquals({'reasoning_effort': 'low', 'temperature': 0.2}); + check(params('automatic')).deepEquals({'temperature': 0.2}); + }); + test('preserves OpenWebUI request shape', () { final api = _buildApiServiceForTest(_FakeAdapter.json({})); diff --git a/test/core/services/native_sheet_hydration_generation_test.dart b/test/core/services/native_sheet_hydration_generation_test.dart index 0d50c749c..50f7e5ae6 100644 --- a/test/core/services/native_sheet_hydration_generation_test.dart +++ b/test/core/services/native_sheet_hydration_generation_test.dart @@ -31,6 +31,20 @@ void main() { check(hydrated.value).equals('vendor_ultra'); }); + test('late effort hydration keeps the user pick over the server value', () { + final hydrated = nativeHydratedServerReasoningEffort( + model: const Model( + id: 'workspace-reasoning-model', + name: 'Workspace reasoning model', + ), + detail: const ServerModelReasoningEffort.known('vendor_ultra'), + personalizationEffort: 'low', + localEffort: 'high', + ); + + check(hydrated.value).equals('high'); + }); + test('late selector hydration cannot update a newer presentation', () { final generations = NativeSheetHydrationGeneration(); final first = generations.begin(); diff --git a/test/core/services/streaming_helper_transport_test.dart b/test/core/services/streaming_helper_transport_test.dart index 593f0856f..73de0997b 100644 --- a/test/core/services/streaming_helper_transport_test.dart +++ b/test/core/services/streaming_helper_transport_test.dart @@ -572,8 +572,10 @@ class _MockSocketService implements SocketService { handlerId: 'test-ch', ); + final StreamController reconnects = StreamController.broadcast(); + @override - Stream get onReconnect => const Stream.empty(); + Stream get onReconnect => reconnects.stream; @override bool get isConnected => true; @@ -1673,6 +1675,202 @@ void main() { }, ); + test( + 'taskSocket response.incomplete keeps the answer without an error', + () async { + // Upstream passes the provider's response.incomplete through as a + // response:completion event but treats it as a no-op: the turn still + // ends with a normal done. Raising an error here flashed a banner + // under the answer until that done cleared it. + final log = _CallbackLog(); + final registrar = FakeSocketInjector(); + + _attach( + session: ChatCompletionSession.taskSocket( + messageId: 'msg-1', + sessionId: 'sess-1', + taskId: 'task-1', + ), + log: log, + socketService: _MockSocketService(registrar), + ); + await pumpMicrotasks(); + + registrar.emitChatEvent('response:completion', { + 'type': 'response.output_text.delta', + 'item_id': 'msg1', + 'output_index': 0, + 'content_index': 0, + 'delta': 'Hello', + }, messageId: 'msg-1'); + await pumpMicrotasks(); + + registrar.emitChatEvent('response:completion', { + 'type': 'response.incomplete', + 'response': { + 'id': 'resp-1', + 'output': [ + { + 'type': 'message', + 'id': 'msg1', + 'status': 'incomplete', + 'content': [ + {'type': 'output_text', 'text': 'Hello'}, + ], + }, + ], + }, + }, messageId: 'msg-1'); + await pumpMicrotasks(); + + check(log.messages.last.error).isNull(); + check(log.messages.last.content).endsWith('Hello'); + + registrar.emitChatEvent('chat:completion', { + 'done': true, + 'output': [ + { + 'type': 'message', + 'id': 'msg1', + 'status': 'completed', + 'content': [ + {'type': 'output_text', 'text': 'Hello'}, + ], + }, + ], + }, messageId: 'msg-1'); + await pumpMicrotasks(); + + check(log.messages.every((message) => message.error == null)).isTrue(); + check(log.finishCount).equals(1); + }, + ); + + test( + 'taskSocket response.failed still surfaces the provider error', + () async { + final log = _CallbackLog(); + final registrar = FakeSocketInjector(); + + _attach( + session: ChatCompletionSession.taskSocket( + messageId: 'msg-1', + sessionId: 'sess-1', + taskId: 'task-1', + ), + log: log, + socketService: _MockSocketService(registrar), + ); + await pumpMicrotasks(); + + registrar.emitChatEvent('response:completion', { + 'type': 'response.failed', + 'response': { + 'id': 'resp-1', + 'error': {'message': 'Provider rejected the request.'}, + }, + }, messageId: 'msg-1'); + await pumpMicrotasks(); + + check(log.messages.last.error?.content) + .equals('Provider rejected the request.'); + }, + ); + + test('reconnect recovery does not finish a live stream from a mid-write snapshot', () async { + // Returning to the foreground reconnects the socket, which polls the + // server. Open WebUI persists the in-flight assistant with + // `done: false` and no `isStreaming` key, so the poll must not treat + // the missing flag as completion: the stream is still running. + final log = _CallbackLog( + initialMessages: fakeStreamingAssistantMessages(content: 'Hel'), + ); + final registrar = FakeSocketInjector(); + final socket = _MockSocketService(registrar); + final api = _buildFakeApi( + pollResponse: _serverConversationResponse( + messages: [ + _serverAssistantMessage(content: 'Hello wor', done: false), + ], + ), + ); + + _attach( + session: ChatCompletionSession.taskSocket( + messageId: 'msg-1', + sessionId: 'sess-1', + taskId: 'task-1', + ), + log: log, + api: api, + socketService: socket, + ); + await pumpMicrotasks(); + + socket.reconnects.add(null); + // The recovery poll adopts the longer server body; that adoption is the + // observable proof the reconnect recovery ran to completion. + await waitForCondition( + () => log.messages.last.content.endsWith('Hello wor'), + timeout: const Duration(seconds: 5), + ); + + check(log.finishCount).equals(0); + check(log.messages.last.isStreaming).isTrue(); + + // The live stream then completes normally. + registrar.emitChatEvent('chat:completion', { + 'choices': [ + { + 'delta': {'content': 'lo'}, + }, + ], + }, messageId: 'msg-1'); + registrar.emitChatEvent('chat:completion', { + 'done': true, + 'content': 'Hello', + }, messageId: 'msg-1'); + await pumpMicrotasks(); + check(log.finishCount).equals(1); + }); + + test( + 'reconnect recovery still finishes on an explicit server done', + () async { + final log = _CallbackLog( + initialMessages: fakeStreamingAssistantMessages(content: 'Hel'), + ); + final registrar = FakeSocketInjector(); + final socket = _MockSocketService(registrar); + final api = _buildFakeApi( + pollResponse: _serverConversationResponse( + messages: [_serverAssistantMessage(content: 'Hello', done: true)], + ), + ); + + _attach( + session: ChatCompletionSession.taskSocket( + messageId: 'msg-1', + sessionId: 'sess-1', + taskId: 'task-1', + ), + log: log, + api: api, + socketService: socket, + ); + await pumpMicrotasks(); + + socket.reconnects.add(null); + await waitForCondition( + () => log.finishCount == 1, + timeout: const Duration(seconds: 5), + ); + + check(log.finishCount).equals(1); + check(log.messages.last.content).endsWith('Hello'); + }, + ); + test('taskSocket plain snapshots do not clear reasoning details', () async { final log = _CallbackLog(); final registrar = FakeSocketInjector(); diff --git a/test/core/utils/persisted_message_content_test.dart b/test/core/utils/persisted_message_content_test.dart new file mode 100644 index 000000000..2c8eb6735 --- /dev/null +++ b/test/core/utils/persisted_message_content_test.dart @@ -0,0 +1,165 @@ +import 'package:checks/checks.dart'; +import 'package:conduit/core/models/chat_message.dart'; +import 'package:conduit/core/services/direct_replay_output.dart'; +import 'package:conduit/core/utils/persisted_message_content.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const rendered = + '
\n' + 'Thought for 1 second\n> plan\n
\n' + '
\n' + 'Tool Executed\n
\n' + 'The answer is 42.'; + + Map messageItem(List> parts) => + { + 'type': 'message', + 'id': 'm', + 'role': 'assistant', + 'status': 'completed', + 'content': parts, + }; + + const toolItems = >[ + { + 'type': 'reasoning', + 'id': 'r1', + 'summary': >[ + {'type': 'summary_text', 'text': 'plan'}, + ], + }, + { + 'type': 'function_call', + 'id': 'c1', + 'call_id': 'c1', + 'name': 'lookup', + 'arguments': '{}', + 'status': 'completed', + }, + { + 'type': 'function_call_output', + 'call_id': 'c1', + 'output': '"42"', + }, + ]; + + ChatMessage assistant(String content, {List>? output}) => + ChatMessage( + id: 'a', + role: 'assistant', + content: content, + timestamp: DateTime.fromMillisecondsSinceEpoch(0), + output: output, + ); + + group('outputItemsMessageText', () { + test('mirrors getOutputText: message items only, parts concatenated, ' + 'items joined by newline, blank items skipped', () { + final text = outputItemsMessageText(>[ + ...toolItems, + messageItem([ + {'type': 'output_text', 'text': 'Hello '}, + {'type': 'output_text', 'text': 'world'}, + ]), + messageItem([ + {'type': 'output_text', 'text': ' '}, + ]), + messageItem([ + {'type': 'output_text', 'text': 'Again'}, + {'type': 'refusal', 'refusal': 'no text key'}, + ]), + ]); + + check(text).equals('Hello world\nAgain'); + }); + + test('stringifies non-string text parts and ignores null text', () { + final text = outputItemsMessageText(>[ + messageItem([ + {'type': 'output_text', 'text': 12}, + {'type': 'output_text', 'text': null}, + ]), + ]); + + check(text).equals('12'); + }); + + test('returns an empty string for string content or no message items', () { + check(outputItemsMessageText(const >[])).equals(''); + check(outputItemsMessageText(toolItems)).equals(''); + check( + outputItemsMessageText(>[ + {'type': 'message', 'content': 'plain'}, + ]), + ).equals(''); + }); + }); + + group('persistedMessageContent', () { + test('returns the output message text when output carries the turn', () { + final message = assistant( + rendered, + output: [ + ...toolItems, + messageItem([ + {'type': 'output_text', 'text': 'The answer is 42.'}, + ]), + ], + ); + + check(persistedMessageContent(message)).equals('The answer is 42.'); + }); + + test('keeps content untouched without output', () { + check(persistedMessageContent(assistant(rendered))).equals(rendered); + check(persistedMessageContent(assistant(rendered, output: const []))) + .equals(rendered); + }); + + test('keeps non-assistant content untouched', () { + final user = ChatMessage( + id: 'u', + role: 'user', + content: rendered, + timestamp: DateTime.fromMillisecondsSinceEpoch(0), + output: [ + messageItem([ + {'type': 'output_text', 'text': 'other'}, + ]), + ], + ); + + check(persistedMessageContent(user)).equals(rendered); + }); + + test('falls back to stripped prose when output has no message text', () { + final message = assistant(rendered, output: toolItems); + + check(persistedMessageContent(message)).equals('The answer is 42.'); + }); + + test('keeps content when neither output nor prose is available', () { + const detailsOnly = + '
\nTool Executed\n' + '
'; + final message = assistant(detailsOnly, output: toolItems); + + check(persistedMessageContent(message)).equals(detailsOnly); + }); + + test('keeps rendered content for a direct replay mirror', () { + final message = assistant( + rendered, + output: buildConduitDirectReplayOutput( + assistantMessageId: 'a', + rawContent: 'The answer is 42.', + ), + ); + + check(persistedMessageContent(message)).equals(rendered); + }); + }); +} diff --git a/test/features/chat/providers/chat_openwebui_transport_ownership_test.dart b/test/features/chat/providers/chat_openwebui_transport_ownership_test.dart index f3a69b9ed..a078304ca 100644 --- a/test/features/chat/providers/chat_openwebui_transport_ownership_test.dart +++ b/test/features/chat/providers/chat_openwebui_transport_ownership_test.dart @@ -191,6 +191,7 @@ class _GatedCompletionApi extends ApiService { String? responseMessageId, Map? userSettings, String? parentId, + String? reasoningEffort, Map? userMessage, Map? variables, List>? files, diff --git a/test/features/chat/providers/chat_regeneration_persistence_guard_test.dart b/test/features/chat/providers/chat_regeneration_persistence_guard_test.dart index 2ec638211..c29f74154 100644 --- a/test/features/chat/providers/chat_regeneration_persistence_guard_test.dart +++ b/test/features/chat/providers/chat_regeneration_persistence_guard_test.dart @@ -126,6 +126,7 @@ class _RecordingCompletionApi extends ApiService { String? responseMessageId, Map? userSettings, String? parentId, + String? reasoningEffort, Map? userMessage, Map? variables, List>? files, diff --git a/test/features/chat/providers/local_echo_payload_test.dart b/test/features/chat/providers/local_echo_payload_test.dart index 3c9421f05..a63b58af7 100644 --- a/test/features/chat/providers/local_echo_payload_test.dart +++ b/test/features/chat/providers/local_echo_payload_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:conduit/core/models/chat_message.dart'; +import 'package:conduit/core/services/direct_replay_output.dart'; import 'package:conduit/features/chat/providers/chat_providers.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -97,5 +98,132 @@ void main() { .equals('1'); check(payload.containsKey('codeExecutions')).isFalse(); }); + + group('persisted content (issue #703)', () { + const renderedDetails = + '
\n' + 'Tool Executed\n
\n'; + const structuredOutput = >[ + { + 'type': 'function_call', + 'id': 'call-1', + 'call_id': 'call-1', + 'name': 'get_weather', + 'arguments': '{"city":"Oslo"}', + 'status': 'completed', + }, + { + 'type': 'function_call_output', + 'call_id': 'call-1', + 'output': '"Sunny"', + }, + { + 'type': 'message', + 'id': 'msg-1', + 'role': 'assistant', + 'status': 'completed', + 'content': >[ + {'type': 'output_text', 'text': 'It is sunny.'}, + ], + }, + ]; + + ChatMessage assistant({ + required String content, + List>? output, + }) => ChatMessage( + id: 'assistant-2', + role: 'assistant', + content: content, + timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000), + model: 'gpt-4o', + isStreaming: false, + output: output, + ); + + test('stores the output message text, not the rendered details', () { + // The OWUI web client persists `content = getOutputText(output)` and + // rebuilds the
presentation from `output` on load; pushing + // the rendered markup leaks it into shares and exports. + final row = localEchoRowForMessage( + 'chat-1', + assistant( + content: '${renderedDetails}It is sunny.', + output: structuredOutput, + ), + ); + + check(row.payload['content']).equals('It is sunny.'); + check(row.content).equals('It is sunny.'); + check(row.payload['output']) + .isA>() + .deepEquals(structuredOutput); + }); + + test('keeps content byte-for-byte when the message has no output', () { + const content = '${renderedDetails}It is sunny.'; + final row = localEchoRowForMessage( + 'chat-1', + assistant(content: content, output: null), + ); + + check(row.payload['content']).equals(content); + check(row.content).equals(content); + check(row.payload.containsKey('output')).isFalse(); + }); + + test('leaves user messages untouched', () { + const content = + '
x' + '
\nPlease keep this literally.'; + final user = ChatMessage( + id: 'user-1', + role: 'user', + content: content, + timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000), + output: structuredOutput, + ); + + check(localEchoRowForMessage('chat-1', user).payload['content']) + .equals(content); + }); + + test('direct payload stores the output message text too', () { + final payload = directPersistedMessagePayloadForTest( + assistant( + content: '${renderedDetails}It is sunny.', + output: structuredOutput, + ), + ); + + check(payload['content']).equals('It is sunny.'); + check(payload['output']) + .isA>() + .deepEquals(structuredOutput); + }); + + test('direct payload keeps the rendered content for a replay mirror', () { + // On load the direct replay mirror short-circuits the structured + // output re-synthesis, so the rendered content is the only copy of + // the reasoning/tool presentation the UI can show. + const content = + '
\n' + 'Thought for 2 seconds\n> hmm\n
\n' + 'It is sunny.'; + final payload = directPersistedMessagePayloadForTest( + assistant( + content: content, + output: buildConduitDirectReplayOutput( + assistantMessageId: 'assistant-2', + rawContent: 'It is sunny.', + ), + ), + ); + + check(payload['content']).equals(content); + }); + }); }); } diff --git a/test/features/chat/providers/reasoning_effort_provider_test.dart b/test/features/chat/providers/reasoning_effort_provider_test.dart index e832ff467..87b0f0cef 100644 --- a/test/features/chat/providers/reasoning_effort_provider_test.dart +++ b/test/features/chat/providers/reasoning_effort_provider_test.dart @@ -170,7 +170,16 @@ void main() { check(container.read(selectedModelProvider)).identicalTo(models.first); }); - test('server model reasoning effort takes precedence over user effort', () { + ApiService buildApi(String serverId) => ApiService( + serverConfig: ServerConfig( + id: serverId, + name: 'Server $serverId', + url: 'https://$serverId.example.test', + ), + workerManager: WorkerManager(), + ); + + test('server model default beats the global user effort without a pick', () { const model = Model( id: 'server-model', name: 'Server model', @@ -180,14 +189,114 @@ void main() { }, }, ); - final container = ProviderContainer(); + final container = ProviderContainer( + overrides: [ + apiServiceProvider.overrideWithValue(buildApi('server-a')), + personalizationSettingsProvider.overrideWith( + () => _FixedPersonalizationSettings( + const ServerUserSettings(reasoningEffort: 'low'), + ), + ), + selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)), + ], + ); addTearDown(container.dispose); - container.read(selectedModelProvider.notifier).set(model); check(container.read(reasoningEffortProvider)).equals('none'); check(reasoningEffortForModel(container.read, model)).equals('none'); }); + test('user pick overrides the server model default per model', () async { + const model = Model( + id: 'server-model', + name: 'Server model', + metadata: { + 'params': {'reasoning_effort': 'medium'}, + }, + ); + const sibling = Model( + id: 'sibling-model', + name: 'Sibling model', + metadata: { + 'params': {'reasoning_effort': 'medium'}, + }, + ); + final container = ProviderContainer( + overrides: [ + apiServiceProvider.overrideWithValue(buildApi('server-a')), + personalizationSettingsProvider.overrideWith( + () => _FixedPersonalizationSettings( + const ServerUserSettings(reasoningEffort: 'low'), + ), + ), + selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)), + ], + ); + addTearDown(container.dispose); + const key = 'openwebui:server-a:server-model'; + + check(localReasoningEffortKeyForModel(container.read, model)).equals(key); + check(container.read(reasoningEffortProvider)).equals('medium'); + + await setReasoningEffortForModel(container.read, model, 'high'); + check(container.read(localReasoningEffortsProvider)[key]).equals('high'); + check(container.read(reasoningEffortProvider)).equals('high'); + check(reasoningEffortForModel(container.read, model)).equals('high'); + check(reasoningEffortForModel(container.read, sibling)).equals('medium'); + check( + jsonDecode( + PreferencesStore.getString(PreferenceKeys.reasoningEffortByModel)!, + ), + ).isA().deepEquals({key: 'high'}); + + // An explicit "automatic" pick is remembered and overrides the model + // default back to "let the server decide". + await setReasoningEffortForModel(container.read, model, 'automatic'); + check(container.read(localReasoningEffortsProvider)[key]) + .equals('automatic'); + check(container.read(reasoningEffortProvider)).equals('automatic'); + check(reasoningEffortForModel(container.read, model)).equals('automatic'); + + // Clearing the pick restores the model default. + await container.read(localReasoningEffortsProvider.notifier).set(key, null); + check(container.read(reasoningEffortProvider)).equals('medium'); + check(reasoningEffortForModel(container.read, model)).equals('medium'); + }); + + test('server model picks are namespaced by server id', () async { + const model = Model(id: 'gpt-5', name: 'GPT-5'); + final serverA = ProviderContainer( + overrides: [apiServiceProvider.overrideWithValue(buildApi('server-a'))], + ); + addTearDown(serverA.dispose); + final serverB = ProviderContainer( + overrides: [apiServiceProvider.overrideWithValue(buildApi('server-b'))], + ); + addTearDown(serverB.dispose); + + await setReasoningEffortForModel(serverA.read, model, 'high'); + + check(localReasoningEffortKeyForModel(serverA.read, model)) + .equals('openwebui:server-a:gpt-5'); + check(localReasoningEffortKeyForModel(serverB.read, model)) + .equals('openwebui:server-b:gpt-5'); + check(reasoningEffortForModel(serverA.read, model)).equals('high'); + check(reasoningEffortForModel(serverB.read, model)).equals('automatic'); + check(localReasoningEffortForModel(serverB.read, model)).isNull(); + }); + + test('server model pick is not stored without an active server', () async { + const model = Model(id: 'gpt-5', name: 'GPT-5'); + final container = ProviderContainer( + overrides: [apiServiceProvider.overrideWithValue(null)], + ); + addTearDown(container.dispose); + + check(localReasoningEffortKeyForModel(container.read, model)).isNull(); + await setReasoningEffortForModel(container.read, model, 'high'); + check(container.read(localReasoningEffortsProvider)).isEmpty(); + }); + test('server model preserves custom reasoning effort values', () { const model = Model( id: 'custom-server-model', @@ -197,7 +306,9 @@ void main() { }, ); - final container = ProviderContainer(); + final container = ProviderContainer( + overrides: [apiServiceProvider.overrideWithValue(buildApi('server-a'))], + ); addTearDown(container.dispose); container.read(selectedModelProvider.notifier).set(model); @@ -419,6 +530,54 @@ void main() { check(supportedParams['reasoning_effort']).equals('medium'); }); + test('chat payload sends the per-model pick over the user default', () { + final api = ApiService( + serverConfig: const ServerConfig( + id: 'reasoning-test', + name: 'Reasoning test', + url: 'https://example.test', + ), + workerManager: WorkerManager(), + ); + + Map params( + String model, + String? effort, { + bool configured = true, + }) => + api.buildChatCompletionPayloadForTest( + messages: const >[ + {'role': 'user', 'content': 'Hello'}, + ], + model: model, + messageId: 'message-id', + sessionId: 'session-id', + modelItem: { + 'id': model, + 'name': model, + if (configured) + 'params': {'reasoning_effort': 'medium'}, + }, + userSettings: { + 'params': {'reasoning_effort': 'low'}, + }, + reasoningEffort: effort, + )['params'] + as Map; + + check(params('gpt-5', 'high')['reasoning_effort']).equals('high'); + check(params('gpt-5', null)['reasoning_effort']).equals('low'); + check(params('gpt-5', 'automatic').containsKey('reasoning_effort')) + .isFalse(); + check( + params( + 'gpt-4o', + 'high', + configured: false, + ).containsKey('reasoning_effort'), + ).isFalse(); + }); + test('custom OpenWebUI model metadata retains supported effort', () { final api = ApiService( serverConfig: const ServerConfig( diff --git a/test/features/chat/services/historical_message_regeneration_test.dart b/test/features/chat/services/historical_message_regeneration_test.dart index b35b96ac6..3dbb0a399 100644 --- a/test/features/chat/services/historical_message_regeneration_test.dart +++ b/test/features/chat/services/historical_message_regeneration_test.dart @@ -239,6 +239,7 @@ class _RecordingCompletionApi extends ApiService { String? responseMessageId, Map? userSettings, String? parentId, + String? reasoningEffort, Map? userMessage, Map? variables, List>? files, diff --git a/test/features/chat/services/voice_input_service_test.dart b/test/features/chat/services/voice_input_service_test.dart index ef4ae0aa0..74315f868 100644 --- a/test/features/chat/services/voice_input_service_test.dart +++ b/test/features/chat/services/voice_input_service_test.dart @@ -342,7 +342,11 @@ void main() { check(config.audioSource).equals(AndroidAudioSource.voiceCommunication); check(config.audioManagerMode) .equals(AudioManagerMode.modeInCommunication); - check(config.manageBluetooth).isTrue(); + // The coordinator owns SCO/communication-device routing during calls. + // record_android's Bluetooth manager clears the communication device on + // every recorder stop, which knocked device TTS back to the earpiece + // (issue #716), so the plugin must not manage Bluetooth here. + check(config.manageBluetooth).isFalse(); }); }); diff --git a/test/features/chat/voice_mode/chat_voice_audio_session_coordinator_test.dart b/test/features/chat/voice_mode/chat_voice_audio_session_coordinator_test.dart index 07261c917..fc7d4e48e 100644 --- a/test/features/chat/voice_mode/chat_voice_audio_session_coordinator_test.dart +++ b/test/features/chat/voice_mode/chat_voice_audio_session_coordinator_test.dart @@ -3,6 +3,7 @@ import 'package:audio_session/audio_session.dart'; import 'package:checks/checks.dart'; import 'package:conduit/features/chat/voice_mode/chat_voice_audio_session_coordinator.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -292,6 +293,142 @@ void main() { ).deepEquals([true]); }); }); + group('ChatVoiceAudioSessionCoordinator Android loudspeaker route', () { + late ChatVoiceAudioSessionCoordinator coordinator; + late _FakeAndroidAudioManagerChannel audioManager; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + audioManager = _FakeAndroidAudioManagerChannel()..install(); + addTearDown(audioManager.uninstall); + coordinator = ChatVoiceAudioSessionCoordinator() + ..debugTreatAsAndroid = true; + addTearDown(coordinator.dispose); + }); + + test( + 'reports a refused move when the read-back is not the speaker', + () async { + // The platform says yes to setCommunicationDevice and takes the legacy + // setSpeakerphoneOn call without complaint, but neither moves the route + // (issue #716). Only the read-back can tell, and it says earpiece. + audioManager.honourLoudspeaker = false; + + check(await coordinator.setSpeakerphoneEnabled(true)).isFalse(); + // The rejected selection is released before the legacy fallback, so + // the system is not left holding a device the read-back disowned. + check(audioManager.communicationDeviceId).isNull(); + }, + ); + + test( + 'reports an applied move once the read-back confirms the speaker', + () async { + check(await coordinator.setSpeakerphoneEnabled(true)).isTrue(); + check(audioManager.communicationDeviceId) + .isNotNull() + .equals(_FakeAndroidAudioManagerChannel.speakerId); + }, + ); + + test( + 'speaking passes re-select the speaker after it was cleared', + () async { + check(await coordinator.setSpeakerphoneEnabled(true)).isTrue(); + + // Something else (the recorder plugin's Bluetooth manager, the system) + // cleared the communication device between listening and speaking. + audioManager.communicationDeviceId = null; + await coordinator.configureForSpeaking(); + check(audioManager.communicationDeviceId) + .isNotNull() + .equals(_FakeAndroidAudioManagerChannel.speakerId); + + audioManager.communicationDeviceId = null; + await coordinator.configureForBargeInSpeaking(); + check(audioManager.communicationDeviceId) + .isNotNull() + .equals(_FakeAndroidAudioManagerChannel.speakerId); + }, + ); + }); +} + +/// Stands in for audio_session's Android audio manager on a test host. +/// +/// Keeps just enough state to answer the route calls the coordinator makes and +/// to read the route back: which communication device is selected, and whether +/// legacy speakerphone is on. +class _FakeAndroidAudioManagerChannel { + static const MethodChannel _channel = MethodChannel( + 'com.ryanheise.android_audio_manager', + ); + static const int earpieceId = 1; + static const int speakerId = 2; + + /// When false, the platform accepts every loudspeaker request but leaves the + /// call on the earpiece, which is what the report in issue #716 describes. + bool honourLoudspeaker = true; + int? communicationDeviceId; + bool speakerphoneOn = false; + + void install() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, _handle); + } + + void uninstall() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + } + + Future _handle(MethodCall call) async { + final args = call.arguments as List? ?? const []; + switch (call.method) { + case 'getMode': + return 0; + case 'isSpeakerphoneOn': + return speakerphoneOn; + case 'setSpeakerphoneOn': + final enabled = args[0] as bool; + if (!enabled || honourLoudspeaker) { + speakerphoneOn = enabled; + } + return null; + case 'getAvailableCommunicationDevices': + return [_device(earpieceId, 1), _device(speakerId, 2)]; + case 'setCommunicationDevice': + final id = args[0] as int; + communicationDeviceId = id == speakerId && !honourLoudspeaker + ? earpieceId + : id; + return true; + case 'getCommunicationDevice': + final id = communicationDeviceId; + if (id == null) return null; + return _device(id, id == speakerId ? 2 : 1); + case 'clearCommunicationDevice': + communicationDeviceId = null; + return null; + default: + return null; + } + } + + /// [type] is the [AndroidAudioDeviceType] index: 1 earpiece, 2 speaker. + Map _device(int id, int type) => { + 'id': id, + 'productName': 'device-$id', + 'address': null, + 'isSource': false, + 'isSink': true, + 'sampleRates': [], + 'channelMasks': [], + 'channelIndexMasks': [], + 'channelCounts': [], + 'encodings': [], + 'type': type, + }; } AudioDevice _outputDevice(AudioDeviceType type) => AudioDevice( diff --git a/test/features/chat/voice_mode/chat_voice_mode_controller_test.dart b/test/features/chat/voice_mode/chat_voice_mode_controller_test.dart index 75a8951b2..69df9615a 100644 --- a/test/features/chat/voice_mode/chat_voice_mode_controller_test.dart +++ b/test/features/chat/voice_mode/chat_voice_mode_controller_test.dart @@ -2111,6 +2111,224 @@ void main() { await controller.stop(); }); + test( + 'responseDone finalizes assistant speech while the message still streams', + () async { + final input = _FakeVoiceInputService(); + final tts = _FakeTextToSpeechService()..holdCompletion = true; + final container = ProviderContainer( + overrides: [ + ...openWebUiStorageOpenOverrides(), + authNavigationStateProvider.overrideWithValue( + AuthNavigationState.authenticated, + ), + selectedModelProvider.overrideWithValue(_model), + appSettingsProvider.overrideWithValue(const AppSettings()), + reviewerModeProvider.overrideWithValue(true), + voiceInputServiceProvider.overrideWithValue(input), + textToSpeechServiceProvider.overrideWithValue(tts), + callKitServiceProvider.overrideWithValue( + _UnavailableCallKitService(), + ), + chatVoiceModeBackgroundCoordinatorProvider.overrideWithValue( + _FakeChatVoiceBackgroundCoordinator(), + ), + chatVoiceAudioSessionCoordinatorProvider.overrideWithValue( + _FakeChatVoiceAudioSessionCoordinator(), + ), + ], + ); + addTearDown(container.dispose); + final controller = container.read( + chatVoiceModeControllerProvider.notifier, + ); + + await controller.start(startNewConversation: false); + await input.completeCurrent('response done marker unique voice turn'); + await _until( + () => + container.read(chatVoiceModeControllerProvider).phase == + ChatVoiceModePhase.sending, + ); + final assistant = _lastAssistant(container); + check(assistant.isStreaming).isTrue(); + check(tts.finishedTexts).isEmpty(); + + // Mirror the transport: a terminal finish reason flushes the buffer and + // marks responseDone without touching isStreaming. + container + .read(chatMessagesProvider.notifier) + .updateMessageById( + assistant.id, + (m) => m.copyWith( + content: 'Response settled before transport close.', + metadata: {...?m.metadata, 'responseDone': true}, + ), + ); + + await _until(() => tts.finishedTexts.isNotEmpty); + check(_lastAssistant(container).isStreaming).isTrue(); + check(tts.finishedTexts.length).equals(1); + check(tts.finishedTexts.single ?? '').isNotEmpty(); + + // The later done event flips isStreaming; it must not finalize twice. + await _until(() => !_lastAssistant(container).isStreaming); + await Future.delayed(const Duration(milliseconds: 200)); + check(tts.finishedTexts.length).equals(1); + + await controller.stop(); + }, + ); + + test( + 'responseDone speaks the settled message, not a stale streaming frame', + () async { + final input = _FakeVoiceInputService(); + final tts = _FakeTextToSpeechService()..holdCompletion = true; + final container = ProviderContainer( + overrides: [ + ...openWebUiStorageOpenOverrides(), + authNavigationStateProvider.overrideWithValue( + AuthNavigationState.authenticated, + ), + selectedModelProvider.overrideWithValue(_model), + appSettingsProvider.overrideWithValue(const AppSettings()), + reviewerModeProvider.overrideWithValue(true), + voiceInputServiceProvider.overrideWithValue(input), + textToSpeechServiceProvider.overrideWithValue(tts), + callKitServiceProvider.overrideWithValue( + _UnavailableCallKitService(), + ), + chatVoiceModeBackgroundCoordinatorProvider.overrideWithValue( + _FakeChatVoiceBackgroundCoordinator(), + ), + chatVoiceAudioSessionCoordinatorProvider.overrideWithValue( + _FakeChatVoiceAudioSessionCoordinator(), + ), + ], + ); + addTearDown(container.dispose); + final controller = container.read( + chatVoiceModeControllerProvider.notifier, + ); + + await controller.start(startNewConversation: false); + await input.completeCurrent('stale streaming frame unique voice turn'); + await _until( + () => + container.read(chatVoiceModeControllerProvider).phase == + ChatVoiceModePhase.sending, + ); + final assistant = _lastAssistant(container); + check(assistant.isStreaming).isTrue(); + + // The visible streaming frame lags behind the flushed message when the + // terminal finish reason lands; the message is the settled text. + container + .read(streamingContentProvider.notifier) + .set('The settled answer is'); + container + .read(chatMessagesProvider.notifier) + .updateMessageById( + assistant.id, + (m) => m.copyWith( + content: 'The settled answer is complete and final.', + metadata: {...?m.metadata, 'responseDone': true}, + ), + ); + + await _until(() => tts.finishedTexts.isNotEmpty); + check(tts.finishedTexts.single ?? '') + .equals('The settled answer is complete and final.'); + + await controller.stop(); + }, + ); + + test( + 'responseDone while paused defers speech and flushes once on resume', + () async { + final input = _FakeVoiceInputService(); + final tts = _FakeTextToSpeechService(); + final container = ProviderContainer( + overrides: [ + ...openWebUiStorageOpenOverrides(), + authNavigationStateProvider.overrideWithValue( + AuthNavigationState.authenticated, + ), + selectedModelProvider.overrideWithValue(_model), + appSettingsProvider.overrideWithValue(const AppSettings()), + reviewerModeProvider.overrideWithValue(true), + voiceInputServiceProvider.overrideWithValue(input), + textToSpeechServiceProvider.overrideWithValue(tts), + callKitServiceProvider.overrideWithValue( + _UnavailableCallKitService(), + ), + chatVoiceModeBackgroundCoordinatorProvider.overrideWithValue( + _FakeChatVoiceBackgroundCoordinator(), + ), + chatVoiceAudioSessionCoordinatorProvider.overrideWithValue( + _FakeChatVoiceAudioSessionCoordinator(), + ), + ], + ); + addTearDown(container.dispose); + final controller = container.read( + chatVoiceModeControllerProvider.notifier, + ); + + await controller.start(startNewConversation: false); + await input.completeCurrent('paused response done unique voice turn'); + await _until( + () => + container.read(chatVoiceModeControllerProvider).phase == + ChatVoiceModePhase.sending, + ); + + await controller.pause(); + expect( + container.read(chatVoiceModeControllerProvider).phase, + ChatVoiceModePhase.paused, + ); + final assistant = _lastAssistant(container); + check(assistant.isStreaming).isTrue(); + container + .read(chatMessagesProvider.notifier) + .updateMessageById( + assistant.id, + (m) => m.copyWith( + content: 'Response settled before transport close.', + metadata: {...?m.metadata, 'responseDone': true}, + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + + expect( + container.read(chatVoiceModeControllerProvider).phase, + ChatVoiceModePhase.paused, + ); + check(tts.finishedTexts).isEmpty(); + + await controller.resume(); + await _until(() => tts.finishedTexts.isNotEmpty); + // The deferred finalization flushes on resume, before transport close. + check(_lastAssistant(container).isStreaming).isTrue(); + check(tts.finishedTexts.length).equals(1); + check(tts.finishedTexts.single ?? '').isNotEmpty(); + + await _until(() => !_lastAssistant(container).isStreaming); + await Future.delayed(const Duration(milliseconds: 200)); + check(tts.finishedTexts.length).equals(1); + await _until(() => input.beginCalls == 2); + expect( + container.read(chatVoiceModeControllerProvider).phase, + ChatVoiceModePhase.listening, + ); + + await controller.stop(); + }, + ); + test('unmuting a paused assistant turn resumes its speech', () async { final input = _FakeVoiceInputService(); final tts = _FakeTextToSpeechService()..holdCompletion = true; @@ -3279,6 +3497,10 @@ class _VoiceSocketTestController extends Notifier { void set(SocketService? socket) => state = socket; } +ChatMessage _lastAssistant(ProviderContainer container) => container + .read(chatMessagesProvider) + .lastWhere((message) => message.role == 'assistant'); + Future _until(bool Function() condition) async { for (var i = 0; i < 300; i++) { if (condition()) return; diff --git a/test/features/direct_connections/direct_adapters_test.dart b/test/features/direct_connections/direct_adapters_test.dart index 1e01d1cf4..c14b43124 100644 --- a/test/features/direct_connections/direct_adapters_test.dart +++ b/test/features/direct_connections/direct_adapters_test.dart @@ -732,6 +732,57 @@ void main() { }, ); + test('OpenRouter discovery drops Batch API-only model variants', () async { + final http = _QueuedAdapter([ + _Reply.json({ + 'data': [ + {'id': 'anthropic/claude-sonnet-4:batch'}, + {'id': 'anthropic/claude-sonnet-4'}, + {'id': 'x:free'}, + ], + }), + ]); + final adapter = OpenAiCompatibleAdapter( + dioFactory: (_) => _dio(http), + closeClients: false, + ); + + final models = await adapter.listModels( + _openAiProfile(baseUrl: kOpenRouterApiBaseUrl), + ); + + expect(models.map((model) => model.id), [ + 'anthropic/claude-sonnet-4', + 'x:free', + ]); + expect( + http.requests.single.uri.toString(), + 'https://openrouter.ai/api/v1/models/user', + ); + }); + + test('non-OpenRouter discovery keeps batch-suffixed model ids', () async { + final http = _QueuedAdapter([ + _Reply.json({ + 'data': [ + {'id': 'custom-model:batch'}, + {'id': 'custom-model'}, + ], + }), + ]); + final adapter = OpenAiCompatibleAdapter( + dioFactory: (_) => _dio(http), + closeClients: false, + ); + + final models = await adapter.listModels(_openAiProfile()); + + expect(models.map((model) => model.id), [ + 'custom-model:batch', + 'custom-model', + ]); + }); + test('OpenRouter discovery normalizes model reasoning metadata', () async { final http = _QueuedAdapter([ _Reply.json({ diff --git a/test/features/direct_connections/direct_providers_test.dart b/test/features/direct_connections/direct_providers_test.dart index abb8088fb..870417b99 100644 --- a/test/features/direct_connections/direct_providers_test.dart +++ b/test/features/direct_connections/direct_providers_test.dart @@ -10,6 +10,7 @@ import 'package:conduit/core/services/secure_credential_storage.dart'; import 'package:conduit/features/direct_connections/models/direct_completion.dart'; import 'package:conduit/core/platform/conduit_platform_apis.g.dart'; import 'package:conduit/features/direct_connections/services/apple_pcc_adapter.dart'; +import 'package:conduit/features/direct_connections/services/direct_adapter_helpers.dart'; import 'package:conduit/features/direct_connections/models/direct_connection_profile.dart'; import 'package:conduit/features/direct_connections/models/direct_remote_model.dart'; import 'package:conduit/features/direct_connections/providers/direct_connection_providers.dart'; @@ -1463,7 +1464,9 @@ void main() { await controller.blockMutationsForAppDataClear(); await expectLater(controller.upsert(_profile()), throwsStateError); - await expectLater(controller.probe(_profile()), throwsStateError); + final blockedProbe = await controller.probe(_profile()); + check(blockedProbe.reachable).isFalse(); + check(blockedProbe.message).isNotNull().contains('app data'); controller.resumeMutationsAfterAppDataClearAbort(); await controller.upsert(_profile()); @@ -1584,7 +1587,7 @@ void main() { ); test( - 'incomplete logout fence suppresses Direct profiles on restart', + 'incomplete logout fence does not hide Direct profiles or block setup', () async { await PreferencesStore.putChecked( PreferenceKeys.incompleteLogoutFence, @@ -1595,21 +1598,243 @@ void main() { _profile(), ]).encode(), }); - final container = _container(_QueuedAdapter()); + final adapter = _CountingProbeAdapter(); + final container = _container(adapter); addTearDown(container.dispose); + check(container.read(incompleteLogoutFenceProvider)).isTrue(); + expect( + await container.read(directConnectionProfilesProvider.future), + hasLength(1), + ); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + + final probe = await controller.probe(_profile(id: 'profile-two')); + check(probe.reachable).isTrue(); + check(adapter.probeCalls).equals(1); + + await controller.upsert(_profile(id: 'profile-two')); + expect( + container.read(directConnectionProfilesProvider).requireValue, + hasLength(2), + ); + }, + ); + + test( + 'incomplete app-data clear hides surviving profiles until the fence lifts', + () async { + final adapter = _CountingProbeAdapter(); + final container = _container(adapter); + addTearDown(container.dispose); + await container.read(directConnectionProfilesProvider.future); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + await controller.upsert(_profile()); + final fence = container.read(incompleteLogoutFenceProvider.notifier); + fence.setSuppressed(true); + + await controller.blockMutationsForAppDataClear(); + // The coordinator still holds the preference barrier at this point; the + // marker must land regardless so it survives a restart. + await PreferencesStore.blockWritesForAppDataClear(); + await controller.revokeRuntimeAfterIncompleteAppDataClear(); + PreferencesStore.resumeWritesAfterAppDataClear(); + + expect( + container.read(directConnectionProfilesProvider).requireValue, + isEmpty, + ); + check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear)) + .equals(true); + final probe = await controller.probe(_profile(id: 'profile-two')); + check(probe.reachable).isFalse(); + check(adapter.probeCalls).equals(0); + await expectLater( + controller.upsert(_profile(id: 'profile-two')), + throwsStateError, + ); + + // A rebuild while the fence is still up stays blocked. + container.invalidate(directConnectionProfilesProvider); expect( await container.read(directConnectionProfilesProvider.future), isEmpty, ); - await expectLater( - container - .read(directConnectionProfilesProvider.notifier) - .upsert(_profile()), - throwsStateError, + + // A completed cleanup or a new authenticated session clears the fence + // and releases the block; the surviving profile is visible again. + fence.setSuppressed(false); + expect( + await container.read(directConnectionProfilesProvider.future), + hasLength(1), + ); + await Future.delayed(Duration.zero); + check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear)) + .isNull(); + await controller.upsert(_profile(id: 'profile-two')); + expect( + container.read(directConnectionProfilesProvider).requireValue, + hasLength(2), ); }, ); + + test( + 'a failed incomplete-clear marker write propagates instead of hiding', + () async { + PreferencesStore.debugOverride( + await SharedPreferences.getInstance(), + writeInterceptor: (prefs, key, value) async => + key == PreferenceKeys.incompleteAppDataClear ? false : null, + ); + final container = _container(_CountingProbeAdapter()); + addTearDown(container.dispose); + await container.read(directConnectionProfilesProvider.future); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + await controller.upsert(_profile()); + await controller.blockMutationsForAppDataClear(); + + await expectLater( + controller.revokeRuntimeAfterIncompleteAppDataClear(), + throwsStateError, + ); + // The in-memory block still holds for this process. + expect( + container.read(directConnectionProfilesProvider).requireValue, + isEmpty, + ); + await expectLater(controller.upsert(_profile()), throwsStateError); + }, + ); + + test('probe reports a validation failure instead of throwing', () async { + final adapter = _CountingProbeAdapter(); + final container = _container(adapter); + addTearDown(container.dispose); + await container.read(directConnectionProfilesProvider.future); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + + final probe = await controller.probe(_profile(name: ' ')); + check(probe.reachable).isFalse(); + check(probe.message).equals('Profile name is required.'); + check(adapter.probeCalls).equals(0); + }); + + test('probe reports a missing adapter instead of throwing', () async { + final container = _container(_CountingProbeAdapter()); + addTearDown(container.dispose); + await container.read(directConnectionProfilesProvider.future); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + + final probe = await controller.probe(_profile(adapterKey: 'unknown')); + check(probe.reachable).isFalse(); + check(probe.message).isNotNull().contains('not available'); + }); + + group('HTTP 401 auth-mode hint', () { + DioException unauthorized() { + final request = RequestOptions(path: '/models'); + return DioException( + requestOptions: request, + type: DioExceptionType.badResponse, + response: Response(requestOptions: request, statusCode: 401), + ); + } + + test('thrown 401 with api-key header mode mentions Bearer', () async { + final container = _container( + _UnsafeMessageAdapter(probeError: unauthorized()), + ); + addTearDown(container.dispose); + await container.read(directConnectionProfilesProvider.future); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + + final probe = await controller.probe( + _profile(apiKey: 'secret-key') + .copyWith(apiKeyAuthMode: DirectApiKeyAuthMode.apiKeyHeader), + ); + check(probe.reachable).isFalse(); + check(probe.message) + .equals('The provider returned HTTP 401. $kDirectBearerAuthModeHint'); + }); + + test( + 'returned 401 probe with api-key header mode mentions Bearer', + () async { + final container = _container( + _UnsafeMessageAdapter( + probeResult: const DirectConnectionProbe( + reachable: false, + message: 'The provider returned HTTP 401.', + ), + ), + ); + addTearDown(container.dispose); + await container.read(directConnectionProfilesProvider.future); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + + final probe = await controller.probe( + _profile(apiKey: 'secret-key') + .copyWith(apiKeyAuthMode: DirectApiKeyAuthMode.apiKeyHeader), + ); + check(probe.reachable).isFalse(); + check(probe.message).isNotNull().contains(kDirectBearerAuthModeHint); + }, + ); + + test('401 with bearer mode does not mention the hint', () async { + final container = _container( + _UnsafeMessageAdapter(probeError: unauthorized()), + ); + addTearDown(container.dispose); + await container.read(directConnectionProfilesProvider.future); + final controller = container.read( + directConnectionProfilesProvider.notifier, + ); + + final probe = await controller.probe(_profile(apiKey: 'secret-key')); + check(probe.reachable).isFalse(); + check(probe.message).equals('The provider returned HTTP 401.'); + }); + }); +} + +final class _CountingProbeAdapter implements DirectProviderAdapter { + int probeCalls = 0; + + @override + String get key => kOpenAiCompatibleAdapterKey; + + @override + Future> listModels( + DirectConnectionProfile profile, + ) async => const []; + + @override + Future probe(DirectConnectionProfile profile) async { + probeCalls++; + return const DirectConnectionProbe(reachable: true); + } + + @override + DirectCompletionRun startCompletion( + DirectConnectionProfile profile, + DirectCompletionRequest request, + ) => throw UnimplementedError(); } ProviderContainer _container( diff --git a/test/features/hermes/hermes_api_service_test.dart b/test/features/hermes/hermes_api_service_test.dart index 420903b64..2b355e030 100644 --- a/test/features/hermes/hermes_api_service_test.dart +++ b/test/features/hermes/hermes_api_service_test.dart @@ -8,6 +8,7 @@ import 'package:conduit/features/hermes/models/hermes_config.dart'; import 'package:conduit/features/hermes/models/hermes_job.dart'; import 'package:conduit/features/hermes/models/hermes_run_event.dart'; import 'package:conduit/features/hermes/services/hermes_api_service.dart'; +import 'package:conduit/features/hermes/services/hermes_json_guard.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_test/flutter_test.dart'; @@ -376,7 +377,7 @@ void main() { await check( service.getSessionMessages('session-1', cancelToken: cancelToken), - ).throws(); + ).throws(); check(cancelToken.isCancelled).isTrue(); check(hermesCancellationWasInternal(cancelToken)).isTrue(); @@ -396,7 +397,7 @@ void main() { await check( _service(capture) .getSessionMessages('session-1', cancelToken: cancelToken), - ).throws(); + ).throws(); check(cancelToken.isCancelled).isTrue(); check(hermesCancellationWasInternal(cancelToken)).isTrue(); diff --git a/test/features/hermes/hermes_desktop_transport_test.dart b/test/features/hermes/hermes_desktop_transport_test.dart index 90061a590..2fd627c8e 100644 --- a/test/features/hermes/hermes_desktop_transport_test.dart +++ b/test/features/hermes/hermes_desktop_transport_test.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:checks/checks.dart'; import 'package:conduit/features/hermes/services/hermes_desktop_api_service.dart'; import 'package:conduit/features/hermes/services/hermes_desktop_transport.dart'; +import 'package:conduit/features/hermes/services/hermes_json_guard.dart'; import 'package:conduit/features/hermes/models/hermes_config.dart'; import 'package:conduit/features/hermes/models/hermes_mcp.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -139,10 +140,75 @@ void main() { ); }); - check(offsets).deepEquals([0, 500]); + check(offsets).deepEquals([0, 200, 400, 600]); check(messages).length.equals(750); }); + test('a short final transcript page stops paging', () async { + final requests = <(int, int)>[]; + final messages = await loadHermesDesktopTranscriptPages(( + offset, + limit, + ) async { + requests.add((offset, limit)); + final remaining = 230 - offset; + return List.generate( + remaining.clamp(0, limit), + (index) => {'id': '${offset + index}'}, + ); + }); + + check(requests).deepEquals([(0, 200), (200, 200)]); + check(messages).length.equals(230); + }); + + test('an oversized transcript page is retried at half the limit', () async { + final requests = <(int, int)>[]; + final messages = await loadHermesDesktopTranscriptPages(( + offset, + limit, + ) async { + requests.add((offset, limit)); + if (limit > 100) throw const HermesResponseTooLargeException(); + final remaining = 250 - offset; + return List.generate( + remaining.clamp(0, limit), + (index) => {'id': '${offset + index}'}, + ); + }); + + check(requests).deepEquals([(0, 200), (0, 100), (100, 100), (200, 100)]); + check(messages).length.equals(250); + check(messages.first['id']).equals('0'); + check(messages.last['id']).equals('249'); + }); + + test('a transcript page that is too large at the floor surfaces', () async { + final limits = []; + await check( + loadHermesDesktopTranscriptPages((offset, limit) async { + limits.add(limit); + throw const HermesResponseTooLargeException(); + }), + ).throws(); + + check(limits).deepEquals([200, 100, 50, 25]); + }); + + test('the transcript pager honours the total maximum', () async { + final requests = <(int, int)>[]; + final messages = await loadHermesDesktopTranscriptPages(( + offset, + limit, + ) async { + requests.add((offset, limit)); + return List.generate(limit, (index) => {'id': '${offset + index}'}); + }, maximum: 450); + + check(requests).deepEquals([(0, 200), (200, 200), (400, 50)]); + check(messages).length.equals(450); + }); + test('keeps the last usable transcript across empty or partial reads', () { final previous = [ {'id': 'one'}, diff --git a/test/features/hermes/hermes_desktop_turn_state_test.dart b/test/features/hermes/hermes_desktop_turn_state_test.dart index 78e1d4da4..1bb76e3c1 100644 --- a/test/features/hermes/hermes_desktop_turn_state_test.dart +++ b/test/features/hermes/hermes_desktop_turn_state_test.dart @@ -42,7 +42,17 @@ final class _GatewayHarness { sent.add(frame); final id = frame['id']; if (id == null) return; - final result = responder(frame['method']?.toString() ?? ''); + final method = frame['method']?.toString() ?? ''; + final error = errorResponder(method); + if (error != null) { + scheduleMicrotask( + () => incoming.add( + jsonEncode({'jsonrpc': '2.0', 'id': id, 'error': error}), + ), + ); + return; + } + final result = responder(method); if (result == null) return; scheduleMicrotask( () => incoming.add( @@ -59,6 +69,10 @@ final class _GatewayHarness { Map? Function(String method) responder = (_) => null; + /// When non-null for a method, the gateway answers with a JSON-RPC error + /// instead of a result. + Map? Function(String method) errorResponder = (_) => null; + void ready() { if (incoming.isClosed) return; incoming.add( @@ -95,6 +109,9 @@ Dio _statusDio([_StubAdapter? adapter]) { /// Serves `/api/status` (auth not required) so `_connect` can reach the socket, /// and records every REST URI so tests can assert profile scoping. final class _StubAdapter implements HttpClientAdapter { + _StubAdapter({this.messages = const >[]}); + + final List> messages; final requested = []; @override @@ -109,7 +126,7 @@ final class _StubAdapter implements HttpClientAdapter { requested.add(options.uri); if (options.uri.path.endsWith('/messages')) { return ResponseBody.fromString( - jsonEncode({'messages': const []}), + jsonEncode({'messages': messages}), 200, headers: { Headers.contentTypeHeader: [Headers.jsonContentType], @@ -131,6 +148,32 @@ final class _StubAdapter implements HttpClientAdapter { } } +/// Serves `/api/status` like [_StubAdapter] but fails the transcript request. +final class _FailingMessagesAdapter extends _StubAdapter { + _FailingMessagesAdapter({required this.status}); + + final int status; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + if (options.uri.path.endsWith('/messages')) { + requested.add(options.uri); + return ResponseBody.fromString( + jsonEncode({'error': 'session_not_found'}), + status, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + return super.fetch(options, requestStream, cancelFuture); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); tearDown(PreferencesStore.debugReset); @@ -279,6 +322,107 @@ void main() { check(historyRequest.queryParameters['order']).equals('latest'); }); + test( + 'a session the gateway refuses to resume still loads over REST', + () async { + SharedPreferences.setMockInitialValues({}); + PreferencesStore.debugOverride(await SharedPreferences.getInstance()); + final harness = _GatewayHarness(); + final adapter = _StubAdapter( + messages: const [ + {'role': 'user', 'content': 'hello from telegram'}, + {'role': 'assistant', 'content': 'hi'}, + ], + ); + final rpc = HermesDesktopRpcClient( + channelFactory: (_, _, {httpClient}) => harness.channel, + ); + final service = HermesDesktopApiService( + config: HermesConfig( + enabled: true, + baseUrl: 'https://hermes.example', + mode: HermesBackendMode.desktopGateway, + desktopCredentials: HermesDesktopCredentials( + legacyToken: 'session-token', + ), + ), + dio: _statusDio(adapter), + rpc: rpc, + ); + addTearDown(() async { + service.close(); + await harness.dispose(); + }); + final turnStates = []; + final subscription = service.turnStates.listen(turnStates.add); + addTearDown(subscription.cancel); + + harness.errorResponder = (method) => switch (method) { + 'session.resume' => { + 'code': 4130, + 'message': 'Session too large to resume', + }, + _ => null, + }; + + final messages = await service.getSessionMessages('telegram:123'); + await Future.delayed(Duration.zero); + + check(messages).length.equals(2); + check(messages.last['content']).equals('hi'); + final historyRequest = adapter.requested.singleWhere( + (uri) => uri.path.endsWith('/messages'), + ); + check(historyRequest.path) + .endsWith('/api/sessions/telegram%3A123/messages'); + check(historyRequest.queryParameters['order']).equals('oldest'); + check(historyRequest.queryParameters['limit']).equals('200'); + check(turnStates.last).equals(HermesDesktopTurnState.idle); + }, + ); + + test('a REST failure after a refused resume is not swallowed', () async { + SharedPreferences.setMockInitialValues({}); + PreferencesStore.debugOverride(await SharedPreferences.getInstance()); + final harness = _GatewayHarness(); + final adapter = _FailingMessagesAdapter(status: 404); + final rpc = HermesDesktopRpcClient( + channelFactory: (_, _, {httpClient}) => harness.channel, + ); + final service = HermesDesktopApiService( + config: HermesConfig( + enabled: true, + baseUrl: 'https://hermes.example', + mode: HermesBackendMode.desktopGateway, + desktopCredentials: HermesDesktopCredentials( + legacyToken: 'session-token', + ), + ), + dio: _statusDio(adapter), + rpc: rpc, + ); + addTearDown(() async { + service.close(); + await harness.dispose(); + }); + + harness.errorResponder = (method) => switch (method) { + 'session.resume' => {'code': 4007, 'message': 'Session not found'}, + _ => null, + }; + + Object? failure; + try { + await service.getSessionMessages('stored-missing'); + } catch (error) { + failure = error; + } + check(failure) + .isA() + .has((error) => error.response?.statusCode, 'status') + .equals(404); + }); + test('a new bot chat opens before its first prompt is persisted', () async { SharedPreferences.setMockInitialValues({}); PreferencesStore.debugOverride(await SharedPreferences.getInstance()); diff --git a/test/features/hermes/hermes_session_tile_test.dart b/test/features/hermes/hermes_session_tile_test.dart index 2afff6147..026a80f38 100644 --- a/test/features/hermes/hermes_session_tile_test.dart +++ b/test/features/hermes/hermes_session_tile_test.dart @@ -12,10 +12,14 @@ import 'package:conduit/features/hermes/models/hermes_model.dart'; import 'package:conduit/features/hermes/models/hermes_session.dart'; import 'package:conduit/features/hermes/providers/hermes_providers.dart'; import 'package:conduit/features/hermes/services/hermes_api_service.dart'; +import 'package:conduit/features/hermes/services/hermes_desktop_transport.dart'; +import 'package:conduit/features/hermes/services/hermes_json_guard.dart'; import 'package:conduit/features/hermes/services/hermes_local_document_service.dart'; import 'package:conduit/features/hermes/services/hermes_local_document_trust_store.dart'; import 'package:conduit/features/hermes/services/hermes_session_provenance.dart'; import 'package:conduit/features/hermes/widgets/hermes_session_tile.dart'; +import 'package:conduit/l10n/app_localizations.dart'; +import 'package:conduit/l10n/app_localizations_en.dart'; import 'package:dio/dio.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -552,6 +556,131 @@ void main() { }); } + group('open failures', () { + final l10n = AppLocalizationsEn(); + final failureScenarios = { + 'an oversized transcript': ( + const HermesResponseTooLargeException(), + l10n.hermesSessionTooLarge, + ), + 'a session.resume not-found reply': ( + const HermesDesktopRpcException('Session not found', code: 4007), + l10n.hermesSessionNotFound, + ), + 'a REST 404': ( + DioException( + requestOptions: RequestOptions(path: '/api/sessions/x/messages'), + response: Response( + requestOptions: RequestOptions(path: '/api/sessions/x/messages'), + statusCode: 404, + ), + ), + l10n.hermesSessionNotFound, + ), + 'another gateway RPC error': ( + const HermesDesktopRpcException( + 'Session too large to resume', + code: 4130, + ), + l10n.hermesSessionLoadFailedDetail( + 'Session too large to resume (4130)', + ), + ), + 'an unrelated failure': ( + StateError('socket closed'), + l10n.hermesSessionLoadFailed, + ), + }; + + for (final scenario in failureScenarios.entries) { + testWidgets('${scenario.key} shows a specific message and does not ' + 'open the chat', (tester) async { + final (error, expectedMessage) = scenario.value; + final service = _FakeHermesApiService(messagesError: error); + final container = ProviderContainer( + retry: (retryCount, error) => null, + overrides: [hermesApiServiceProvider.overrideWithValue(service)], + ); + addTearDown(container.dispose); + + late BuildContext actionContext; + late WidgetRef widgetRef; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Consumer( + builder: (context, ref, child) { + actionContext = context; + widgetRef = ref; + return const Scaffold(body: SizedBox.shrink()); + }, + ), + ), + GoRoute( + path: Routes.chat, + builder: (context, state) => + const Scaffold(body: SizedBox.shrink()), + ), + ], + ); + addTearDown(router.dispose); + NavigationService.attachRouter(router); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); + + await openHermesSession( + actionContext, + widgetRef, + const HermesSessionSummary(id: 'session-1', title: 'Saved session'), + ); + await tester.pump(); + + check(find.text(expectedMessage).evaluate()).length.equals(1); + check(container.read(hermesActiveSessionProvider)).isNull(); + check(container.read(activeConversationProvider)).isNull(); + check(router.state.uri.path).equals('/'); + await tester.pumpAndSettle(); + }); + } + + test('server-provided detail is bounded and stripped of control text', () { + final message = hermesSessionLoadFailureMessage( + l10n, + HermesDesktopRpcException( + 'line one\nline\u0000two ${'x' * 300}', + code: 5000, + ), + ); + + check(message).startsWith('Could not load this conversation: '); + check(message).not((it) => it.contains('\n')); + check(message).not((it) => it.contains('\u0000')); + check(message).contains('line one line two'); + check(message).endsWith('\u2026 (5000)'); + check(message.length).isLessThan(200); + }); + + test('timed-out or disconnected RPC replies stay generic', () { + check( + hermesSessionLoadFailureMessage( + l10n, + const HermesDesktopRpcException('timed out', timedOut: true), + ), + ).equals(l10n.hermesSessionLoadFailed); + }); + }); + testWidgets('opening a session restores locally trusted document prompts', ( tester, ) async { @@ -646,6 +775,7 @@ class _FakeHermesApiService extends HermesApiService { ], this.deleteGate, this.messagesGate, + this.messagesError, }) : super( config: const HermesConfig( enabled: true, @@ -657,6 +787,7 @@ class _FakeHermesApiService extends HermesApiService { final List> messages; final Completer? deleteGate; final Completer? messagesGate; + final Object? messagesError; final deleteStarted = Completer(); final messagesStarted = Completer(); final List deletedSessionIds = []; @@ -673,6 +804,7 @@ class _FakeHermesApiService extends HermesApiService { operationLog.add('get:$id'); if (!messagesStarted.isCompleted) messagesStarted.complete(); await messagesGate?.future; + if (messagesError != null) throw messagesError!; return messages; } diff --git a/test/shared/widgets/model_list_tile_test.dart b/test/shared/widgets/model_list_tile_test.dart new file mode 100644 index 000000000..c4f3d6b70 --- /dev/null +++ b/test/shared/widgets/model_list_tile_test.dart @@ -0,0 +1,109 @@ +import 'package:checks/checks.dart'; +import 'package:conduit/core/models/model.dart'; +import 'package:conduit/l10n/app_localizations.dart'; +import 'package:conduit/l10n/conduit_localizations.dart'; +import 'package:conduit/shared/theme/app_theme.dart'; +import 'package:conduit/shared/theme/tweakcn_themes.dart'; +import 'package:conduit/shared/theme/theme_extensions.dart'; +import 'package:conduit/shared/widgets/conduit_components.dart'; +import 'package:conduit/shared/widgets/horizontal_overflow_fade.dart'; +import 'package:conduit/shared/widgets/model_list_tile.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +const _reasoningModel = Model( + id: 'reasoning-model', + name: 'Reasoning model', + supportedParameters: ['reasoning'], +); + +Future _pumpTile( + WidgetTester tester, { + required bool isSelected, +}) async { + late ConduitThemeExtension theme; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(TweakcnThemes.conduit), + localizationsDelegates: conduitLocalizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) { + theme = context.conduitTheme; + return ConduitCard( + child: ModelListTile( + model: _reasoningModel, + isSelected: isSelected, + onTap: () {}, + ), + ); + }, + ), + ), + ), + ); + return theme; +} + +List _fadeColors(WidgetTester tester) { + final fade = find.byType(HorizontalOverflowFade); + final gradient = tester + .widgetList( + find.descendant(of: fade, matching: find.byType(DecoratedBox)), + ) + .map((box) => box.decoration) + .whereType() + .map((decoration) => decoration.gradient) + .whereType() + .single; + return gradient.colors; +} + +void main() { + testWidgets('unselected overflow fade derives from the card background', ( + tester, + ) async { + final theme = await _pumpTile(tester, isSelected: false); + final card = theme.cardBackground; + + check(card).not((it) => it.equals(theme.surfaceBackground)); + + final colors = _fadeColors(tester); + check(colors) + .deepEquals([card.withValues(alpha: 0), card.withValues(alpha: 0.9)]); + }); + + testWidgets('selected overflow fade matches the highlighted row surface', ( + tester, + ) async { + final theme = await _pumpTile(tester, isSelected: true); + final highlighted = Color.alphaBlend( + theme.buttonPrimary.withValues(alpha: 0.1), + theme.cardBackground, + ); + final surfaceHighlighted = Color.alphaBlend( + theme.buttonPrimary.withValues(alpha: 0.1), + theme.surfaceBackground, + ); + + check(highlighted).not((it) => it.equals(surfaceHighlighted)); + + final colors = _fadeColors(tester); + check(colors).deepEquals([ + highlighted.withValues(alpha: 0), + highlighted.withValues(alpha: 0.9), + ]); + + // The highlight itself paints against the card surface. + final tile = find.byType(ModelListTile); + final containers = tester.widgetList( + find.descendant(of: tile, matching: find.byType(Container)), + ); + final rowBackgrounds = containers + .map((container) => container.decoration) + .whereType() + .map((decoration) => decoration.color); + check(rowBackgrounds).contains(highlighted); + }); +} diff --git a/test/shared/widgets/server_version_warning_card_test.dart b/test/shared/widgets/server_version_warning_card_test.dart index 70339b807..4473c8016 100644 --- a/test/shared/widgets/server_version_warning_card_test.dart +++ b/test/shared/widgets/server_version_warning_card_test.dart @@ -1,15 +1,19 @@ import 'package:conduit/core/models/backend_config.dart'; import 'package:conduit/core/models/server_config.dart'; +import 'package:conduit/core/persistence/persistence_keys.dart'; +import 'package:conduit/core/persistence/preferences_store.dart'; import 'package:conduit/core/providers/app_providers.dart'; import 'package:conduit/features/auth/providers/unified_auth_providers.dart'; import 'package:conduit/features/chat/views/chat_page.dart'; import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/conduit_localizations.dart'; import 'package:conduit/shared/widgets/server_version_warning_card.dart'; +import 'package:conduit/shared/widgets/server_version_warning_controller.dart'; import 'package:checks/checks.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class _FixedBackendConfigNotifier extends BackendConfigNotifier { _FixedBackendConfigNotifier(this._config); @@ -27,10 +31,11 @@ Widget _buildCard({ required AuthNavigationState authState, required BackendConfig? config, Widget? body, + String activeServerId = 'A', }) { return ProviderScope( overrides: [ - activeServerProvider.overrideWith((ref) async => _server('A')), + activeServerProvider.overrideWith((ref) async => _server(activeServerId)), backendConfigProvider.overrideWith( () => _FixedBackendConfigNotifier(config), ), @@ -47,8 +52,18 @@ Widget _buildCard({ ); } +Future _seedPreferences([Map values = const {}]) async { + SharedPreferences.setMockInitialValues(values); + PreferencesStore.debugOverride(await SharedPreferences.getInstance()); +} + void main() { group('ServerVersionWarningCard', () { + setUp(() async { + await _seedPreferences(); + }); + tearDown(PreferencesStore.debugReset); + testWidgets('shows the localized warning for a newer active server', ( tester, ) async { @@ -197,5 +212,112 @@ void main() { check(find.text('Server not supported').evaluate()).isEmpty(); }); + + testWidgets('close button hides the card and persists the token', ( + tester, + ) async { + await tester.pumpWidget( + _buildCard( + authState: AuthNavigationState.authenticated, + config: const BackendConfig(version: '0.11.4', serverId: 'A'), + ), + ); + await tester.pump(); + check(find.byKey(serverVersionWarningCardKey).evaluate()).length + .equals(1); + + await tester.tap(find.byKey(serverVersionWarningCardCloseKey)); + await tester.pumpAndSettle(); + + check(find.byKey(serverVersionWarningCardKey).evaluate()).isEmpty(); + check( + decodeServerVersionWarningDismissals( + PreferencesStore.getString( + PreferenceKeys.serverVersionWarningDismissed, + ), + ), + ).deepEquals({'A|0.11.4'}); + }); + + testWidgets('dismissing a second server keeps the first dismissal', ( + tester, + ) async { + await _seedPreferences({ + PreferenceKeys.serverVersionWarningDismissed: '["A|0.11.4"]', + }); + + await tester.pumpWidget( + _buildCard( + authState: AuthNavigationState.authenticated, + config: const BackendConfig(version: '0.11.6', serverId: 'B'), + activeServerId: 'B', + ), + ); + await tester.pump(); + await tester.tap(find.byKey(serverVersionWarningCardCloseKey)); + await tester.pumpAndSettle(); + + check( + decodeServerVersionWarningDismissals( + PreferencesStore.getString( + PreferenceKeys.serverVersionWarningDismissed, + ), + ), + ).deepEquals({'A|0.11.4', 'B|0.11.6'}); + }); + + testWidgets('a bare legacy token still counts as dismissed', ( + tester, + ) async { + await _seedPreferences({ + PreferenceKeys.serverVersionWarningDismissed: 'A|0.11.4', + }); + + await tester.pumpWidget( + _buildCard( + authState: AuthNavigationState.authenticated, + config: const BackendConfig(version: '0.11.4', serverId: 'A'), + ), + ); + await tester.pump(); + + check(find.byKey(serverVersionWarningCardKey).evaluate()).isEmpty(); + }); + + testWidgets('stays hidden when the same server and version was dismissed', ( + tester, + ) async { + await _seedPreferences({ + PreferenceKeys.serverVersionWarningDismissed: '["A|0.11.4"]', + }); + + await tester.pumpWidget( + _buildCard( + authState: AuthNavigationState.authenticated, + config: const BackendConfig(version: '0.11.4', serverId: 'A'), + ), + ); + await tester.pump(); + + check(find.byKey(serverVersionWarningCardKey).evaluate()).isEmpty(); + }); + + testWidgets('shows again for a different server version', (tester) async { + await _seedPreferences({ + PreferenceKeys.serverVersionWarningDismissed: '["A|0.11.4"]', + }); + + await tester.pumpWidget( + _buildCard( + authState: AuthNavigationState.authenticated, + config: const BackendConfig(version: '0.11.5', serverId: 'A'), + ), + ); + await tester.pump(); + + check(find.byKey(serverVersionWarningCardKey).evaluate()).length + .equals(1); + check(find.textContaining('0.11.5').evaluate()).length.equals(1); + }); }); }