Fix ten reported issues and vendor Hermes Agent as an API reference (#725)
Some checks are pending
L10n / l10n (push) Waiting to run

* Fix ten reported issues and vendor Hermes Agent as an API reference

Closes #693, #695, #699, #700, #701, #703, #708, #712, #716, #723.

- Hermes (#693, #699): load transcripts over REST even when session.resume
  fails, page at 200 messages and shrink on oversized responses, and show
  distinct errors for too-large, not-found, and RPC failures. Add the
  hermes-src submodule (NousResearch/hermes-agent v2026.9.11) as the RPC and
  REST contract reference.
- Server version banner (#695): dismissible, remembered per server and version.
- Model selector (#700): overflow fade and selection tint use the card surface.
- OpenRouter (#701): skip Batch API-only ":batch" model variants.
- Persisted chat content (#703): store plain output text instead of rendered
  <details> markup when structured output exists, matching the web client.
- Direct connections (#708): probes report their real failure instead of a
  generic message, the Open WebUI logout fence no longer blocks Direct setup,
  and 401/403 under the api-key header mode hints at Bearer auth.
- Reasoning effort (#712): per-server, per-model user picks override the
  model's configured default and are sent explicitly in the request.
- Voice calls (#716): stop the record plugin from clearing the Android
  communication device and verify the loudspeaker route before reporting it.
- Voice mode (#723): finalize speech on the responseDone marker.
- Add the five new strings to every locale.

* Address review: keep incomplete-clear block, pass effort on sends, keep all dismissals

- Direct profiles stay hidden and unusable after an incomplete full app-data
  clear, in memory and across restarts, until the logout fence is cleared by a
  completed cleanup or a new authenticated session. Ordinary failed sign-outs
  still do not block Direct setup.
- The primary Open WebUI send path now passes the resolved per-model reasoning
  effort like regeneration and queued completions already did.
- Server version warning dismissals are stored as a bounded set so dismissing
  one server no longer forgets another.

* Address review: release rejected speaker device, speak settled text on responseDone

- When Android accepts the built-in speaker but the read-back disagrees,
  clear that selection before the legacy setSpeakerphoneOn fallback, which is
  ignored while a communication device stays selected.
- Voice mode takes the final text from the message once responseDone is set;
  the streaming content provider can lag behind the flushed message.

* Address review: await the incomplete-clear restart marker before returning

The marker is written through the app-data-clear barrier with a checked write
and awaited by the clear coordinator, so a process death right after an
incomplete clear cannot reload the surviving Direct profiles on restart.

* Address review: arm the restart fence before the wipe and propagate marker failures

The incomplete-clear marker is now armed before any data is wiped, disarmed
only when the clear completes or yields ownership, re-asserted on an
incomplete outcome, and a failed checked write aborts the clear instead of
being swallowed. The in-memory block is unchanged.

* Do not raise an error banner for response.incomplete

Open WebUI forwards the provider's `response.incomplete` event over the
socket but treats it as a no-op: the output is kept and the turn still ends
with a normal `done`. Raising an error on that event flashed "The response
stopped before it was complete" under the answer until `done` cleared it.
`response.failed` still surfaces the provider error.
This commit is contained in:
cogwheel 2026-09-15 00:48:03 +05:30 • committed by GitHub
parent 6c6e02f824
commit 4e73c9a0ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3132 additions and 158 deletions

4
.gitmodules vendored
View file

@ -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

View file

@ -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:

1
hermes-src Submodule

@ -0,0 +1 @@
Subproject commit 939e45c91d751fadd94dcd1b873ac3cb44846213

View file

@ -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';

View file

@ -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();

View file

@ -7333,6 +7333,7 @@ class ApiService {
List<Map<String, dynamic>>? toolServers,
Map<String, dynamic>? backgroundTasks,
Map<String, dynamic>? userSettings,
String? reasoningEffort,
String? parentId,
Map<String, dynamic>? userMessage,
Map<String, dynamic>? 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<String, dynamic>? backgroundTasks,
String? responseMessageId,
Map<String, dynamic>? userSettings,
String? reasoningEffort,
String? parentId,
Map<String, dynamic>? userMessage,
Map<String, dynamic>? 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<Map<String, dynamic>>? toolServers,
Map<String, dynamic>? backgroundTasks,
Map<String, dynamic>? userSettings,
String? reasoningEffort,
String? parentId,
Map<String, dynamic>? userMessage,
Map<String, dynamic>? variables,
@ -8218,6 +8231,7 @@ class ApiService {
toolServers: toolServers,
backgroundTasks: backgroundTasks,
userSettings: userSettings,
reasoningEffort: reasoningEffort,
parentId: parentId,
userMessage: userMessage,
variables: variables,

View file

@ -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,

View file

@ -420,7 +420,10 @@ class _AssistantServerPatch {
Future<void> _handleReconnectRecovery({
required bool Function() hasFinished,
required List<ChatMessage> Function() getMessages,
required Future<_ServerMessageSnapshot?> Function() pollServerForMessage,
required Future<_ServerMessageSnapshot?> Function({
bool inferDoneFromMissingStreaming,
})
pollServerForMessage,
required bool Function(
String,
List<String>, {
@ -442,7 +445,13 @@ Future<void> _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,

View file

@ -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 `<details>` 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 `<details>` 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<dynamic> output) {
final texts = <String>[];
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<dynamic, dynamic> 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();
}

View file

@ -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<List<ChatMessage>>
/// 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 `<details>` 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<void> 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<void> 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<void> 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<String, dynamic> _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<void> _sendMessageInternal(
backgroundTasks: bgTasks,
responseMessageId: assistantMessageId,
userSettings: userSettingsData,
reasoningEffort: reasoningEffortForModel(ref.read, selectedModel),
parentId: userMessageMap?['parentId']?.toString(),
userMessage: userMessageMap,
variables: promptVariables,

View file

@ -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<String?>((ref) {
@ -220,12 +250,16 @@ final configuredReasoningEffortProvider = Provider<String?>((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<void> 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);
}
}

View file

@ -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,
);
}

View file

@ -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<void> 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<bool> _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<bool> _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 = <String, Object?>{
'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<bool> _selectAndroidCommunicationDevice(
AndroidAudioManager manager,
AndroidAudioDeviceType type, {
@ -799,7 +879,7 @@ class ChatVoiceAudioSessionCoordinator {
}
Future<void> _restoreAndroidVoiceRoute() async {
if (!Platform.isAndroid) {
if (!_isAndroid) {
return;
}

View file

@ -1550,7 +1550,10 @@ class ChatVoiceModeController extends Notifier<ChatVoiceModeSnapshot> {
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<ChatVoiceModeSnapshot> {
String? _visibleAssistantText([List<ChatMessage>? 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;

View file

@ -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<DirectEditorActionResult> 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]');
}

View file

@ -451,21 +451,43 @@ class DirectConnectionProfilesController
extends AsyncNotifier<List<DirectConnectionProfile>> {
Future<void> _mutationQueue = Future<void>.value();
bool _appDataClearBlocked = false;
bool _durableLogoutFenceBlocked = false;
bool _incompleteAppDataClearBlocked = false;
List<DirectConnectionProfile>? _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<List<DirectConnectionProfile>> 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 <DirectConnectionProfile>[]);
}
_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<DirectConnectionProbe> 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<void> 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<void> 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 <DirectConnectionProfile>[];
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<void> _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<void> 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<void> 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()},
);
}
}

View file

@ -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;

View file

@ -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

View file

@ -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,
),

View file

@ -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>().map((m) => m.cast<String, dynamic>()).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

View file

@ -113,16 +113,43 @@ List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> loadHermesDesktopTranscriptPages(
Future<List<Map<String, dynamic>>> 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 = <Map<String, dynamic>>[];
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<Map<String, dynamic>> 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;
}

View file

@ -260,12 +260,12 @@ extension _HermesDesktopAuthRest on HermesDesktopApiService {
);
final bytes = response.data ?? const <int>[];
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);

View file

@ -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<List<Map<String, dynamic>>> load() async {
if (isBotChat) {
if (isBotChat && runtimeId != null) {
if (cancelToken?.isCancelled == true) throw cancelToken!.cancelError!;
final messages = _objects(
await _rpc.request<Object?>(
'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 <Map<String, dynamic>>[];
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,

View file

@ -265,7 +265,9 @@ final class HermesDesktopRpcClient {
source = raw;
} else if (raw is List<int>) {
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);

View file

@ -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

View file

@ -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<void> 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<void> 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,
);
}

View file

@ -188,6 +188,9 @@ class DefaultModelBottomSheetState
isSelected: isSelected,
isAutoSelect: isAutoSelect,
iconUrl: iconUrl,
surfaceColor: context
.conduitTheme
.surfaceBackground,
onTap: () {
final selectedId = isAutoSelect
? 'auto-select'

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."}
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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 トークンを想定しています。"
}

View file

@ -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 토큰을 기대합니다."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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 令牌。"
}

View file

@ -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 權杖。"
}

View file

@ -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,

View file

@ -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<String>(
'server-version-warning-card',
);
const serverVersionWarningCardCloseKey = ValueKey<String>(
'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,
),
],
),
),

View file

@ -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 `<serverId>|<version>` tokens the user dismissed.
final serverVersionWarningDismissedProvider =
NotifierProvider<ServerVersionWarningController, Set<String>>(
ServerVersionWarningController.new,
);
class ServerVersionWarningController extends Notifier<Set<String>> {
@override
Set<String> 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<void> dismiss(String token) async {
if (state.contains(token)) return;
final next = <String>{...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<String> decodeServerVersionWarningDismissals(String? raw) {
if (raw == null || raw.isEmpty) return const <String>{};
if (!raw.startsWith('[')) return {raw};
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const <String>{};
return {
for (final entry in decoded)
if (entry is String && entry.isNotEmpty) entry,
};
} on FormatException {
return const <String>{};
}
}

View file

@ -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 {

View file

@ -818,6 +818,32 @@ void main() {
// Payload builder
// -----------------------------------------------------------------------
group('buildChatCompletionPayloadForTest', () {
test('explicit reasoning effort overrides user settings params', () {
final api = _buildApiServiceForTest(_FakeAdapter.json({}));
Map<String, dynamic> 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<String, dynamic>;
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({}));

View file

@ -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();

View file

@ -572,8 +572,10 @@ class _MockSocketService implements SocketService {
handlerId: 'test-ch',
);
final StreamController<void> reconnects = StreamController<void>.broadcast();
@override
Stream<void> get onReconnect => const Stream.empty();
Stream<void> 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();

View file

@ -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 =
'<details type="reasoning" done="true" duration="1">\n'
'<summary>Thought for 1 second</summary>\n> plan\n</details>\n'
'<details type="tool_calls" done="true" id="c1" name="lookup" '
'arguments="{}" result="&quot;42&quot;">\n'
'<summary>Tool Executed</summary>\n</details>\n'
'The answer is 42.';
Map<String, dynamic> messageItem(List<Map<String, dynamic>> parts) =>
<String, dynamic>{
'type': 'message',
'id': 'm',
'role': 'assistant',
'status': 'completed',
'content': parts,
};
const toolItems = <Map<String, dynamic>>[
<String, dynamic>{
'type': 'reasoning',
'id': 'r1',
'summary': <Map<String, dynamic>>[
<String, dynamic>{'type': 'summary_text', 'text': 'plan'},
],
},
<String, dynamic>{
'type': 'function_call',
'id': 'c1',
'call_id': 'c1',
'name': 'lookup',
'arguments': '{}',
'status': 'completed',
},
<String, dynamic>{
'type': 'function_call_output',
'call_id': 'c1',
'output': '"42"',
},
];
ChatMessage assistant(String content, {List<Map<String, dynamic>>? 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(<Map<String, dynamic>>[
...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(<Map<String, dynamic>>[
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 <Map<String, dynamic>>[])).equals('');
check(outputItemsMessageText(toolItems)).equals('');
check(
outputItemsMessageText(<Map<String, dynamic>>[
<String, dynamic>{'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 =
'<details type="tool_calls" done="true" id="c1" name="lookup" '
'arguments="{}" result="1">\n<summary>Tool Executed</summary>\n'
'</details>';
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);
});
});
}

View file

@ -191,6 +191,7 @@ class _GatedCompletionApi extends ApiService {
String? responseMessageId,
Map<String, dynamic>? userSettings,
String? parentId,
String? reasoningEffort,
Map<String, dynamic>? userMessage,
Map<String, dynamic>? variables,
List<Map<String, dynamic>>? files,

View file

@ -126,6 +126,7 @@ class _RecordingCompletionApi extends ApiService {
String? responseMessageId,
Map<String, dynamic>? userSettings,
String? parentId,
String? reasoningEffort,
Map<String, dynamic>? userMessage,
Map<String, dynamic>? variables,
List<Map<String, dynamic>>? files,

View file

@ -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 =
'<details type="tool_calls" done="true" id="call-1" '
'name="get_weather" arguments="{&quot;city&quot;:&quot;Oslo&quot;}" '
'result="&quot;Sunny&quot;">\n'
'<summary>Tool Executed</summary>\n</details>\n';
const structuredOutput = <Map<String, dynamic>>[
<String, dynamic>{
'type': 'function_call',
'id': 'call-1',
'call_id': 'call-1',
'name': 'get_weather',
'arguments': '{"city":"Oslo"}',
'status': 'completed',
},
<String, dynamic>{
'type': 'function_call_output',
'call_id': 'call-1',
'output': '"Sunny"',
},
<String, dynamic>{
'type': 'message',
'id': 'msg-1',
'role': 'assistant',
'status': 'completed',
'content': <Map<String, dynamic>>[
<String, dynamic>{'type': 'output_text', 'text': 'It is sunny.'},
],
},
];
ChatMessage assistant({
required String content,
List<Map<String, dynamic>>? 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 <details> 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<List<Object?>>()
.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 =
'<details type="tool_calls"><summary>x</summary>'
'</details>\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<List<Object?>>()
.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 =
'<details type="reasoning" done="true" duration="2">\n'
'<summary>Thought for 2 seconds</summary>\n> hmm\n</details>\n'
'It is sunny.';
final payload = directPersistedMessagePayloadForTest(
assistant(
content: content,
output: buildConduitDirectReplayOutput(
assistantMessageId: 'assistant-2',
rawContent: 'It is sunny.',
),
),
);
check(payload['content']).equals(content);
});
});
});
}

View file

@ -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<Map>().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<String, dynamic> params(
String model,
String? effort, {
bool configured = true,
}) =>
api.buildChatCompletionPayloadForTest(
messages: const <Map<String, dynamic>>[
<String, dynamic>{'role': 'user', 'content': 'Hello'},
],
model: model,
messageId: 'message-id',
sessionId: 'session-id',
modelItem: <String, dynamic>{
'id': model,
'name': model,
if (configured)
'params': <String, dynamic>{'reasoning_effort': 'medium'},
},
userSettings: <String, dynamic>{
'params': <String, dynamic>{'reasoning_effort': 'low'},
},
reasoningEffort: effort,
)['params']
as Map<String, dynamic>;
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(

View file

@ -239,6 +239,7 @@ class _RecordingCompletionApi extends ApiService {
String? responseMessageId,
Map<String, dynamic>? userSettings,
String? parentId,
String? reasoningEffort,
Map<String, dynamic>? userMessage,
Map<String, dynamic>? variables,
List<Map<String, dynamic>>? files,

View file

@ -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();
});
});

View file

@ -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(<bool>[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<Object?> _handle(MethodCall call) async {
final args = call.arguments as List<dynamic>? ?? const <dynamic>[];
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<String, Object?> _device(int id, int type) => <String, Object?>{
'id': id,
'productName': 'device-$id',
'address': null,
'isSource': false,
'isSink': true,
'sampleRates': <int>[],
'channelMasks': <int>[],
'channelIndexMasks': <int>[],
'channelCounts': <int>[],
'encodings': <int>[],
'type': type,
};
}
AudioDevice _outputDevice(AudioDeviceType type) => AudioDevice(

View file

@ -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<void>.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<void>.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<void>.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<SocketService?> {
void set(SocketService? socket) => state = socket;
}
ChatMessage _lastAssistant(ProviderContainer container) => container
.read(chatMessagesProvider)
.lastWhere((message) => message.role == 'assistant');
Future<void> _until(bool Function() condition) async {
for (var i = 0; i < 300; i++) {
if (condition()) return;

View file

@ -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({

View file

@ -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<void>.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<void>(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<List<DirectRemoteModel>> listModels(
DirectConnectionProfile profile,
) async => const [];
@override
Future<DirectConnectionProbe> probe(DirectConnectionProfile profile) async {
probeCalls++;
return const DirectConnectionProbe(reachable: true);
}
@override
DirectCompletionRun startCompletion(
DirectConnectionProfile profile,
DirectCompletionRequest request,
) => throw UnimplementedError();
}
ProviderContainer _container(

View file

@ -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<HermesStreamGuardException>();
).throws<HermesResponseTooLargeException>();
check(cancelToken.isCancelled).isTrue();
check(hermesCancellationWasInternal(cancelToken)).isTrue();
@ -396,7 +397,7 @@ void main() {
await check(
_service(capture)
.getSessionMessages('session-1', cancelToken: cancelToken),
).throws<HermesStreamGuardException>();
).throws<HermesResponseTooLargeException>();
check(cancelToken.isCancelled).isTrue();
check(hermesCancellationWasInternal(cancelToken)).isTrue();

View file

@ -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 = <int>[];
await check(
loadHermesDesktopTranscriptPages((offset, limit) async {
limits.add(limit);
throw const HermesResponseTooLargeException();
}),
).throws<HermesResponseTooLargeException>();
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'},

View file

@ -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<String, dynamic>? Function(String method) responder = (_) => null;
/// When non-null for a method, the gateway answers with a JSON-RPC error
/// instead of a result.
Map<String, dynamic>? 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 <Map<String, dynamic>>[]});
final List<Map<String, dynamic>> messages;
final requested = <Uri>[];
@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<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? 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 = <HermesDesktopTurnState>[];
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<void>.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<DioException>()
.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());

View file

@ -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 = <String, (Object, String)>{
'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<Map<String, dynamic>> messages;
final Completer<void>? deleteGate;
final Completer<void>? messagesGate;
final Object? messagesError;
final deleteStarted = Completer<void>();
final messagesStarted = Completer<void>();
final List<String> 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;
}

View file

@ -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<ConduitThemeExtension> _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<Color> _fadeColors(WidgetTester tester) {
final fade = find.byType(HorizontalOverflowFade);
final gradient = tester
.widgetList<DecoratedBox>(
find.descendant(of: fade, matching: find.byType(DecoratedBox)),
)
.map((box) => box.decoration)
.whereType<BoxDecoration>()
.map((decoration) => decoration.gradient)
.whereType<LinearGradient>()
.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<Container>(
find.descendant(of: tile, matching: find.byType(Container)),
);
final rowBackgrounds = containers
.map((container) => container.decoration)
.whereType<BoxDecoration>()
.map((decoration) => decoration.color);
check(rowBackgrounds).contains(highlighted);
});
}

View file

@ -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<void> _seedPreferences([Map<String, Object> 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);
});
});
}