diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ed6f0c03..81b2004de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,7 +348,7 @@ jobs: include: - name: setup-connect timeout_minutes: 45 - filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.RequestRoutingProofCollectorTests" + filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.RequestRoutingProofCollectorTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.SessionsPatchThinkingLevelE2ETests" - name: revocation-recovery timeout_minutes: 25 filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.RevocationAndRecoveryTests diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 302f74fab..a495540ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,7 +73,7 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | If you are editing… | Do not grow it. Extract toward… | | --- | --- | | `src/OpenClaw.Tray.WinUI/App.xaml.cs` | `IWindowManager`, `ITrayController`, `IActivationRouter`, `ISettingsChangeCoordinator`, `AppBootstrapper` | -| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | `ChatSendQueue`, `ChatBridgeEventPump`, `ChatHistoryLoader`, `ChatSnapshotProjector`, `AttachmentMetadataStore`, `ThinkingLevelClearReconciler` (production delegation planned in Layer 3) | +| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | `ChatSendQueue`, `ChatBridgeEventPump`, `ChatHistoryLoader`, `ChatSnapshotProjector`, `AttachmentMetadataStore`; thinking-level clear reconciliation is closed and stays in `ThinkingLevelClearReconciler` | | `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs` | `ReactorChatTimeline` (production `ItemsView` / `ItemContainer`), `ChatBubbleRenderer`, `ToolCallCardRenderer`, `PermissionRequestCard`, `AttachmentBubbleRenderer` | | `src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` | | `src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs` | `ConnectionPagePlan` (pure), `ConnectionPageViewModel`, gateway row models | @@ -129,8 +129,8 @@ leading and trailing pipe. Columns, in order: | chat-send-queue | planned | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | send queue/admission/abort state | ChatSendQueue | - | queued send/abort/generation semantics preserved | none | review-only | extracted in Phase 4 | | gateway-pending-requests | authoritative | src/OpenClaw.Shared/OpenClawGatewayClient.cs | request-id -> method/completion tracking | PendingRequestRegistry | response payload interpretation remains in OpenClawGatewayClient | one atomic owner per request id; tombstoned duplicate and late responses are suppressed; genuinely ownerless responses preserve generic compatibility routing; disconnect closes registration and cancels active owners | PendingRequestRegistryTests.ConcurrentRegisterAndTake_LeavesNoActiveRequestsAndBoundsTombstones | behavioral | - | | gateway-client-pending-requests-closed | closed | src/OpenClaw.Shared/OpenClawGatewayClient.cs | independent method, wizard, chat-send, approval, or session-snapshot pending stores and locks | PendingRequestRegistry | request creation, typed response interpretation, and legacy ownerless response routing only | OpenClawGatewayClient has one PendingRequestRegistry and no private pending-request dictionaries or locks | PendingRequestRegistryArchitectureTests.OpenClawGatewayClient_DelegatesPendingBookkeepingToRegistry | source-shape | when gateway response interpretation moves to GatewayMessageRouter | -| chat-thinking-level-clear-reconciliation | authoritative | dependent Layer 3 design for OpenClawChatDataProvider | UI-free per-thread thinking-level clear state-machine seam for versions, connection generations, protected canonical intent, patch acknowledgement, correlated snapshot acceptance, bounded retry, supersession, interruption, and disposal | ThinkingLevelClearReconciler | no production provider delegation in Layer 2; callers remain responsible for gateway requests and canonical snapshot storage | stale and uncorrelated snapshots cannot clear protected intent; only current-generation correlated snapshots confirm or externally converge; retries remain bounded and can restart on later current-generation evidence | ThinkingLevelClearReconcilerTests.PreAckNull_IsProtectedUntilPostAckCorrelatedNull | behavioral | - | -| chat-provider-thinking-level-clear-closure | planned | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | production delegation of thinking-level clear reconciliation and removal of provider-local state | ThinkingLevelClearReconciler | provider remains unchanged in Layer 2 | production provider delegates reconciliation state and transitions to ThinkingLevelClearReconciler | none | review-only | delivered in Layer 3 | +| chat-thinking-level-clear-reconciliation | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | per-thread thinking-level clear versions, connection generations, protected canonical intent, patch acknowledgement, correlated snapshot acceptance, bounded retry, supersession, interruption, and disposal state | ThinkingLevelClearReconciler | provider invokes patch and snapshot requests, stores canonical session snapshots, and owns errors, notifications, and thread UI state | stale and uncorrelated snapshots cannot clear protected intent; only current-generation correlated snapshots confirm or externally converge; retries remain bounded and can restart on later current-generation evidence | ThinkingLevelClearReconcilerTests.PreAckNull_IsProtectedUntilPostAckCorrelatedNull | behavioral | - | +| chat-provider-thinking-level-clear-closure | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | production delegation of thinking-level clear reconciliation and removal of provider-local state | ThinkingLevelClearReconciler | narrow calls that begin operations, apply effective thinking levels, execute emitted refresh requests, and surface errors and notifications | production provider delegates reconciliation state and transitions to ThinkingLevelClearReconciler without independent maps, lifecycle records, retry counters, timers, or connection-generation state | ThinkingLevelClearReconcilerArchitectureTests.Provider_DelegatesThinkingLevelClearReconciliationState | source-shape | when OpenClawChatDataProvider is replaced by narrower chat coordinators | | connect-envelope | planned | src/OpenClaw.Shared/OpenClawGatewayClient.cs + WindowsNodeClient.cs | connect message + auth precedence + signature version | ConnectEnvelopeBuilder | - | credential precedence never downgrades a device token; v3->v2 fallback preserved | none | review-only | extracted in Phase 4 | | ui-dispatcher | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | UI-thread marshaling abstraction for presentation code | IUiDispatcher | App and existing WinUI code may call DispatcherQueue directly until the view-model migration | presentation view models depend on IUiDispatcher not a concrete DispatcherQueue | UiDispatcherContractTests.PageViewModel_ReceivesRegisteredDispatcher | behavioral | - | | navigation-scope | authoritative | src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs | page view-model activation/deactivation and disposal lifetime | NavigationScopeManager | HubWindow keeps frame navigation back-stack and rail selection | transient page view models are activated on navigation and deactivated then disposed on navigate-away | NavigationScopeManagerTests.NavigatingAway_DeactivatesAndDisposesPreviousViewModel | behavioral | - | diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index 855b74430..9125d84bc 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -293,6 +293,7 @@ Task SendMessageAsync(string threadId, string message, CancellationToken cancell /// Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default); + Task ClearThinkingLevelAsync(string threadId, CancellationToken cancellationToken = default) => Task.CompletedTask; Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default); Task RespondToPermissionAsync(string threadId, string requestId, string action, CancellationToken cancellationToken = default); Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default) => diff --git a/src/OpenClaw.Chat/ThinkingLevelClearReconciler.cs b/src/OpenClaw.Chat/ThinkingLevelClearReconciler.cs index 0a3486f9f..3fc1bae6c 100644 --- a/src/OpenClaw.Chat/ThinkingLevelClearReconciler.cs +++ b/src/OpenClaw.Chat/ThinkingLevelClearReconciler.cs @@ -410,6 +410,25 @@ public SnapshotResolution ApplyCorrelatedSnapshot( } } + public bool TryGetRefreshCancellationToken( + RefreshRequest request, + out CancellationToken cancellationToken) + { + lock (_gate) + { + if (_disposed || + !_entries.TryGetValue(request.ThreadId, out var entry) || + !IsCurrentRefresh(entry, request)) + { + cancellationToken = new CancellationToken(canceled: true); + return false; + } + + cancellationToken = entry.LifetimeCancellation.Token; + return true; + } + } + /// /// Advances connection authority. A reconnect or client swap invalidates all /// earlier refresh requests and requests current-generation convergence for diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 6c93e99c2..81411ed12 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -157,6 +157,23 @@ Task ListCommandsAsync(CommandCatalogQuery? query = null, int ti /// Apply an extended (rich field set) to a session. Task PatchSessionAsync(string key, SessionPatch patch) => Task.FromResult(false); + /// + /// Response-aware variant of . + /// Returns the gateway's terminal result instead of completing after the frame is sent. + /// + Task PatchSessionDetailedAsync( + string key, + SessionPatch patch, + int timeoutMs = 15000, + CancellationToken cancellationToken = default) + => Task.FromResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + IsSupported = false, + Key = key, + Error = "Response-aware sessions.patch is not supported by this gateway client." + }); /// List session files, optionally scoped to a sub-path/search (sessions.files.list). Task ListSessionFilesAsync(string key, string? path = null, string? search = null, int timeoutMs = 15000) => Task.FromResult(new SessionFileList { Key = key, IsSupported = false }); diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index e295118fb..23c4f3828 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -520,6 +520,7 @@ public class SessionCommandResult { public string Method { get; set; } = ""; public bool Ok { get; set; } + public bool IsSupported { get; set; } = true; public string? Key { get; set; } public bool? Deleted { get; set; } public bool? Compacted { get; set; } diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs index 25962ec1c..cd179e076 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; namespace OpenClaw.Shared; @@ -473,6 +474,95 @@ public Task PatchSessionAsync(string key, SessionPatch patch) return TrySendTrackedRequestAsync("sessions.patch", patch.ToPayload(key)); } + /// + /// Applies an extended session patch and waits for the gateway response. + /// Unsupported gateways and protocol failures return a truthful typed result; + /// caller cancellation remains cancellation. + /// + public async Task PatchSessionDetailedAsync( + string key, + SessionPatch patch, + int timeoutMs = 15000, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(key)) + { + return CreateSessionPatchFailure(key, "Session key is required"); + } + if (patch is null || !patch.HasChanges) + { + return CreateSessionPatchFailure(key, "Session patch has no changes"); + } + if (!IsConnected) + { + return CreateSessionPatchFailure(key, "Gateway connection is not open"); + } + + try + { + var payload = await SendWizardRequestAsync( + "sessions.patch", + patch.ToPayload(key), + timeoutMs, + cancellationToken).ConfigureAwait(false); + return ParseSessionPatchResult(payload, key); + } + catch (OperationCanceledException) + { + throw; + } + catch (TimeoutException ex) + { + _logger.Warn($"sessions.patch timed out: {ex.Message}"); + return CreateSessionPatchFailure( + key, + "The gateway did not respond before the session change timed out."); + } + catch (InvalidOperationException ex) when (IsUnknownMethodError(ex.Message)) + { + _logger.Warn("sessions.patch unsupported on gateway"); + return new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + IsSupported = false, + Key = key, + Error = ex.Message + }; + } + catch (InvalidOperationException ex) + { + _logger.Warn($"sessions.patch failed: {ex.Message}"); + return CreateSessionPatchFailure(key, ex.Message); + } + } + + internal static SessionCommandResult ParseSessionPatchResult(JsonElement payload, string key) + { + var ok = !payload.TryGetProperty("ok", out var okElement) || + okElement.ValueKind == JsonValueKind.True; + var reason = GetStringSafe(payload, "reason"); + var error = GetStringSafe(payload, "error"); + return new SessionCommandResult + { + Method = "sessions.patch", + Ok = ok, + IsSupported = true, + Key = FirstNonEmpty(GetStringSafe(payload, "key"), key), + Reason = reason, + Error = ok ? null : error ?? reason ?? "The gateway could not change the session." + }; + } + + private static SessionCommandResult CreateSessionPatchFailure(string? key, string error) => new() + { + Method = "sessions.patch", + Ok = false, + IsSupported = true, + Key = key, + Error = error + }; + // ── sessions.files.list / sessions.files.get ── /// diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 34c792310..a69810ac9 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -676,8 +676,19 @@ private static string ExtractMessageText(JsonElement message) /// Sends a wizard RPC request and waits for the response payload. /// Used for wizard.start, wizard.next, wizard.cancel, wizard.status. /// - public async Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000) + public Task SendWizardRequestAsync( + string method, + object? parameters = null, + int timeoutMs = 30000) => + SendWizardRequestAsync(method, parameters, timeoutMs, CancellationToken.None); + + private async Task SendWizardRequestAsync( + string method, + object? parameters, + int timeoutMs, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); if (!IsConnected) throw new InvalidOperationException("Gateway connection is not open"); @@ -690,7 +701,9 @@ public async Task SendWizardRequestAsync(string method, object? par { if (registration.Accepted) await SendRawAsync(SerializeRequest(requestId, method, parameters)); - return await completion.Task.WaitAsync(TimeSpan.FromMilliseconds(timeoutMs), CancellationToken); + return await completion.Task.WaitAsync( + TimeSpan.FromMilliseconds(timeoutMs), + cancellationToken); } catch (TimeoutException ex) { @@ -712,6 +725,46 @@ public async Task RequestSessionsAsync(string? agentId = null) await SendTrackedRequestAsync("sessions.list"); } + /// + /// Requests and returns the session snapshot from the matching + /// sessions.list response. + /// + public async Task RequestSessionsSnapshotAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_operatorReadScopeUnavailable) + throw new NotSupportedException("sessions.list is unavailable for this connection."); + if (!IsConnected) + throw new InvalidOperationException("Gateway connection is not open"); + + const string method = "sessions.list"; + var requestId = Guid.NewGuid().ToString(); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var registration = _pendingRequests.RegisterSessionSnapshot( + requestId, + completion, + method); + + try + { + if (registration.Accepted) + await SendRawAsync(SerializeRequest(requestId, method, parameters: null)); + return await completion.Task.WaitAsync( + TimeSpan.FromSeconds(30), + cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException ex) + { + throw new TimeoutException($"Timed out waiting for {method} response", ex); + } + finally + { + _pendingRequests.Remove(registration); + } + } + /// Subscribe to session change events so the gateway pushes /// sessions.changed notifications when sessions are mutated. public async Task SubscribeSessionEventsAsync() diff --git a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs index 5788ce32a..0fc63bc11 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs @@ -70,6 +70,8 @@ Task CompactSessionDetailedAsync(string sessionKey) => Error = "Response-aware sessions.compact is not supported by this chat bridge." }); Task RequestSessionsAsync() => Task.CompletedTask; + Task RequestSessionsSnapshotAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); Task PatchSessionModelAsync(string sessionKey, string model); /// /// Clears the session's model override (tri-state sessions.patch with @@ -79,6 +81,17 @@ Task CompactSessionDetailedAsync(string sessionKey) => /// Task ClearSessionModelAsync(string sessionKey); Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel); + Task ClearSessionThinkingLevelAsync( + string sessionKey, + CancellationToken cancellationToken = default) => + Task.FromResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + IsSupported = false, + Key = sessionKey, + Error = "Response-aware thinking-level clear is not supported by this chat bridge." + }); Task RequestChatHistoryAsync(string? sessionKey); Task SendChatAbortAsync(string runId, string? sessionKey = null); Task ResolveExecApprovalAsync(string approvalId, string decision); @@ -214,6 +227,14 @@ public Task ClearSessionModelAsync(string sessionKey) => public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => _client.PatchSessionAsync(sessionKey, new SessionPatch { ThinkingLevel = thinkingLevel }); + public Task ClearSessionThinkingLevelAsync( + string sessionKey, + CancellationToken cancellationToken = default) => + _client.PatchSessionDetailedAsync( + sessionKey, + new SessionPatch { ThinkingLevel = SessionPatch.Clear }, + cancellationToken: cancellationToken); + public Task ListCommandsAsync(CommandCatalogQuery? query = null) => _client.ListCommandsAsync(query); @@ -235,6 +256,10 @@ public Task CompactSessionDetailedAsync(string sessionKey) public Task RequestSessionsAsync() => _client.RequestSessionsAsync(); + public async Task RequestSessionsSnapshotAsync( + CancellationToken cancellationToken = default) => + await _client.RequestSessionsSnapshotAsync(cancellationToken).ConfigureAwait(false); + public Task RequestChatHistoryAsync(string? sessionKey) => _client.RequestChatHistoryAsync(sessionKey); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index eeb155011..9786bec43 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -31,6 +31,10 @@ internal static class LocalizationHelper "Chat_Permission_ResultSubmittedFormat" => "Approval {0} submitted for {1}.", "Chat_Error_SendReturnedStatusFormat" => "Gateway returned send status '{0}'.", "Chat_Error_SendFailedFormat" => "Send failed: {0}", + "Chat_Notification_ClearThinkingFailed" => "Reasoning setting not changed", + "Chat_Error_ClearThinkingFailedFormat" => "Could not use default reasoning: {0}", + "Chat_Error_ClearThinkingCanceled" => "The change was canceled.", + "Chat_Error_ClearThinkingInterrupted" => "The gateway connection changed before the update was confirmed.", _ => resourceKey }; } @@ -98,6 +102,9 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly string _lastChatStateFilePath; private readonly TimeSpan _lastChatStateSaveDelay; private readonly Func, Task> _scheduleHistoryRetry; + private readonly ThinkingLevelClearReconciler _thinkingLevelClearReconciler; + private readonly Dictionary _confirmedNullThinkingAuthorityTokens = + new(StringComparer.Ordinal); private readonly Action? _historyFailureReservedForTesting; private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes private long _toolMetaSaveVersion; @@ -218,6 +225,7 @@ private enum AssistantQueueFrameDisposition // was already in flight at disconnect time is discarded on completion rather // than resurrecting a catalog for a stale connection. private int _commandsEpoch; + private long _nextConfirmedNullThinkingAuthorityToken; private ConnectionStatus _status; private bool _disposed; @@ -251,7 +259,9 @@ internal OpenClawChatDataProvider( string? lastChatStateFilePath = null, TimeSpan? lastChatStateSaveDelay = null, Func, Task>? historyRetryScheduler = null, - Action? historyFailureReservedForTesting = null) + Action? historyFailureReservedForTesting = null, + TimeSpan? thinkingLevelConfirmationTimeout = null, + Func, Task>? thinkingLevelRetryScheduler = null) { _bridge = bridge ?? throw new ArgumentNullException(nameof(bridge)); _post = post; @@ -272,6 +282,14 @@ internal OpenClawChatDataProvider( }); _historyFailureReservedForTesting = historyFailureReservedForTesting; _status = bridge.CurrentStatus; + _thinkingLevelClearReconciler = new ThinkingLevelClearReconciler( + connected: _status == ConnectionStatus.Connected, + confirmationTimeout: thinkingLevelConfirmationTimeout, + delay: thinkingLevelRetryScheduler is null + ? null + : (delay, cancellationToken) => + thinkingLevelRetryScheduler(delay, static () => Task.CompletedTask) + .WaitAsync(cancellationToken)); _persistedAbortedIds = LoadAbortedIds(); _toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath); _attachmentMetaCache = LoadAttachmentMetaCache(_attachmentMetaCacheFilePath); @@ -316,13 +334,52 @@ public Task LoadAsync(CancellationToken cancellationToken = de cancellationToken.ThrowIfCancellationRequested(); // Seed from whatever the bridge already knows about. var sessions = _bridge.GetSessionList() ?? Array.Empty(); + List? thinkingLevelsToRefresh = null; + ChatDataSnapshot snapshot; lock (_gate) { - _sessions = sessions; + ReleaseMissingConfirmedNullThinkingAuthoritiesLocked(sessions); + _sessions = sessions.Select(session => + { + if (string.IsNullOrEmpty(session.Key)) + return session; + + var projectedSession = ApplyConfirmedNullThinkingAuthorityLocked( + session, + releaseOnNull: true); + var resolution = _thinkingLevelClearReconciler.ObserveSnapshot( + projectedSession.Key, + projectedSession.ThinkingLevel); + if (resolution.RefreshRequest is { } refresh) + (thinkingLevelsToRefresh ??= []).Add(refresh); + var effectiveThinkingLevel = + _confirmedNullThinkingAuthorityTokens.ContainsKey(projectedSession.Key) + ? null + : resolution.EffectiveThinkingLevel; + if (string.Equals( + projectedSession.ThinkingLevel, + effectiveThinkingLevel, + StringComparison.Ordinal)) + { + return projectedSession; + } + + var reconciled = projectedSession.Clone(); + reconciled.ThinkingLevel = effectiveThinkingLevel; + return reconciled; + }).ToArray(); EnsureTimelinesForSessionsLocked(); RememberLastSessionStateLocked(); - return Task.FromResult(BuildSnapshotLocked()); + snapshot = BuildSnapshotLocked(); } + + if (thinkingLevelsToRefresh is not null) + { + foreach (var refresh in thinkingLevelsToRefresh) + ObserveBackgroundTask(ExecuteThinkingLevelRefreshAsync(refresh)); + } + + return Task.FromResult(snapshot); } internal void RememberSelectedThread(string? threadId) @@ -1776,7 +1833,196 @@ private async Task AwaitPendingModelPatchAsync(string threadId, CancellationToke public async Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - await _bridge.PatchSessionThinkingLevelAsync(threadId, thinkingLevel); + ThinkingLevelClearReconciler.ConcreteSelection selection; + long? confirmedNullAuthorityToken; + lock (_gate) + { + var canonicalThinkingLevel = _sessions + .FirstOrDefault(session => string.Equals(session.Key, threadId, StringComparison.Ordinal)) + ?.ThinkingLevel; + confirmedNullAuthorityToken = + _confirmedNullThinkingAuthorityTokens.TryGetValue(threadId, out var token) + ? token + : null; + selection = _thinkingLevelClearReconciler.BeginConcreteSelection( + threadId, + thinkingLevel, + canonicalThinkingLevel); + } + + try + { + await _bridge.PatchSessionThinkingLevelAsync(threadId, thinkingLevel); + if (confirmedNullAuthorityToken is { } expectedToken) + { + lock (_gate) + { + if (!_disposed && + _confirmedNullThinkingAuthorityTokens.TryGetValue( + threadId, + out var currentToken) && + currentToken == expectedToken) + { + _confirmedNullThinkingAuthorityTokens.Remove(threadId); + } + } + } + } + catch + { + _thinkingLevelClearReconciler.RejectConcreteSelection(selection); + throw; + } + } + + public async Task ClearThinkingLevelAsync(string threadId, CancellationToken cancellationToken = default) + { + ThinkingLevelClearReconciler.ClearOperation operation; + var failureSurfaced = false; + lock (_gate) + { + var canonicalThinkingLevel = _sessions + .FirstOrDefault(session => string.Equals(session.Key, threadId, StringComparison.Ordinal)) + ?.ThinkingLevel; + operation = _thinkingLevelClearReconciler.BeginClear( + threadId, + canonicalThinkingLevel); + ObserveBackgroundTask(operation.Confirmation); + } + + try + { + var result = await _bridge.ClearSessionThinkingLevelAsync(threadId, cancellationToken); + if (!result.Ok) + { + var failure = new InvalidOperationException( + result.Error ?? + (result.IsSupported + ? "The gateway rejected the reasoning change." + : "The gateway does not support clearing the reasoning override.")); + if (_thinkingLevelClearReconciler.RejectPatch(operation, failure)) + { + SurfaceThinkingLevelClearFailure(threadId, failure, cancellationToken); + failureSurfaced = true; + throw failure; + } + + await _thinkingLevelClearReconciler + .WaitForConfirmationAsync(operation, cancellationToken) + .ConfigureAwait(false); + throw failure; + } + + if (_thinkingLevelClearReconciler.TryAcknowledgePatch(operation, out var refresh)) + ObserveBackgroundTask(ExecuteThinkingLevelRefreshAsync(refresh)); + + var outcome = await _thinkingLevelClearReconciler + .WaitForConfirmationAsync(operation, cancellationToken) + .ConfigureAwait(false); + if (outcome == ThinkingLevelClearReconciler.ClearOutcome.Confirmed) + { + // Correlated confirmation occurs while ApplySessionsUpdated holds this lock. + // Reacquiring it prevents the clear from returning before the snapshot is stored. + lock (_gate) + { + } + } + } + catch (Exception ex) + { + if (!failureSurfaced && + _thinkingLevelClearReconciler.RejectPatch(operation, ex)) + { + SurfaceThinkingLevelClearFailure(threadId, ex, cancellationToken); + } + else if (!failureSurfaced && + !operation.PatchAcknowledged && + operation.State == ThinkingLevelClearReconciler.ReconciliationState.Interrupted) + { + SurfaceThinkingLevelClearFailure(threadId, ex, cancellationToken); + } + + throw; + } + } + + private async Task ExecuteThinkingLevelRefreshAsync( + ThinkingLevelClearReconciler.RefreshRequest request) + { + if (!_thinkingLevelClearReconciler.TryGetRefreshCancellationToken( + request, + out var requestCancellation)) + return; + + try + { + var sessions = await _bridge + .RequestSessionsSnapshotAsync(requestCancellation) + .ConfigureAwait(false); + if (sessions is null) + { + throw new NotSupportedException( + "Response-correlated sessions.list is not supported by this chat bridge."); + } + if (!sessions.Any(session => string.Equals( + session.Key, + request.ThreadId, + StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"sessions.list did not include the expected session '{request.ThreadId}'."); + } + + ApplySessionsUpdated(sessions, request); + } + catch (OperationCanceledException) when (requestCancellation.IsCancellationRequested) + { + } + catch + { + var retry = await _thinkingLevelClearReconciler + .RetryAfterFailureAsync(request) + .ConfigureAwait(false); + if (retry is { } next) + ObserveBackgroundTask(ExecuteThinkingLevelRefreshAsync(next)); + } + } + + private static void ObserveBackgroundTask(Task task) + { + _ = task.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private void SurfaceThinkingLevelClearFailure( + string threadId, + Exception exception, + CancellationToken cancellationToken) + { + var reason = exception switch + { + ThinkingLevelClearInterruptedException => + LocalizationHelper.GetString("Chat_Error_ClearThinkingInterrupted"), + OperationCanceledException when !cancellationToken.IsCancellationRequested && + _bridge.CurrentStatus != ConnectionStatus.Connected => + LocalizationHelper.GetString("Chat_Error_ClearThinkingInterrupted"), + OperationCanceledException => + LocalizationHelper.GetString("Chat_Error_ClearThinkingCanceled"), + _ => exception.Message + }; + var message = string.Format( + CultureInfo.CurrentCulture, + LocalizationHelper.GetString("Chat_Error_ClearThinkingFailedFormat"), + reason); + ApplyEventAndPublish(threadId, new ChatErrorEvent(message)); + RaiseNotification(new ChatProviderNotification( + ChatProviderNotificationKind.Error, + threadId, + LocalizationHelper.GetString("Chat_Notification_ClearThinkingFailed"), + message)); } public async Task EnsureCommandCatalogAsync(CancellationToken cancellationToken = default) @@ -2028,7 +2274,9 @@ public ValueTask DisposeAsync() _localSentTexts.Clear(); _locallyInitiatedThreads.Clear(); _resetSubmittedLocalEchoTexts.Clear(); + _confirmedNullThinkingAuthorityTokens.Clear(); } + _thinkingLevelClearReconciler.Dispose(); CancelAndDisposeHistoryGeneration(historyGenerationToCancel); timerToDispose?.Dispose(); chatStateTimerToDispose?.Dispose(); @@ -2062,6 +2310,7 @@ public IReadOnlyDictionary GetEntryMetadata(string th private void OnStatusChanged(object? sender, ConnectionStatus status) { + IReadOnlyList thinkingLevelsToRefresh; ChatDataSnapshot snapshot; bool justReconnected; string[] threadsToInterrupt; @@ -2073,12 +2322,19 @@ private void OnStatusChanged(object? sender, ConnectionStatus status) justReconnected = status == ConnectionStatus.Connected && _status != ConnectionStatus.Connected; + var connectedAuthorityChanged = + (status == ConnectionStatus.Connected) != + (_status == ConnectionStatus.Connected); // MEDIUM 5: detect Connected → Disconnected/Error transitions so // we can synthesise a turn-end + status entry on every thread that // had an in-flight turn (otherwise the UI sits "thinking" forever). var justDisconnected = (status == ConnectionStatus.Disconnected || status == ConnectionStatus.Error) && _status == ConnectionStatus.Connected; _status = status; + thinkingLevelsToRefresh = _thinkingLevelClearReconciler + .OnConnectionChanged(status == ConnectionStatus.Connected); + if (connectedAuthorityChanged) + _confirmedNullThinkingAuthorityTokens.Clear(); // Reset the sessions-list-received gate whenever we leave the // Connected state. Any cached sessions belong to the previous @@ -2155,6 +2411,11 @@ private void OnStatusChanged(object? sender, ConnectionStatus status) CancelAndDisposeHistoryGeneration(historyGenerationToCancel); Publish(snapshot); + foreach (var refresh in thinkingLevelsToRefresh) + { + ObserveBackgroundTask(ExecuteThinkingLevelRefreshAsync(refresh)); + } + // MEDIUM 5: synthesize the turn-end + status note for any threads // that were mid-turn when the connection dropped. var interruptedMsg = LocalizationHelper.GetString("Chat_Notification_ConnectionInterrupted"); @@ -2196,15 +2457,111 @@ private static void CancelAndDisposeHistoryGeneration(CancellationTokenSource? c } private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) + { + ApplySessionsUpdated(sessions, refreshRequest: null); + } + + private bool ApplySessionsUpdated( + SessionInfo[]? sessions, + ThinkingLevelClearReconciler.RefreshRequest? refreshRequest) { ChatDataSnapshot snapshot; string[] queuedThreadsToDrain; + bool shouldPublish; + List? thinkingLevelsToRefresh = null; lock (_gate) { + if (_disposed) + return false; + + var incomingSessions = sessions ?? Array.Empty(); + ThinkingLevelClearReconciler.SnapshotResolution? correlatedResolution = null; + if (refreshRequest is { } correlatedRequest) + { + var correlatedSession = incomingSessions.FirstOrDefault(session => + string.Equals( + session.Key, + correlatedRequest.ThreadId, + StringComparison.Ordinal)); + var resolution = _thinkingLevelClearReconciler.ApplyCorrelatedSnapshot( + correlatedRequest, + correlatedSession?.ThinkingLevel); + if (!resolution.Accepted) + return false; + + correlatedResolution = resolution; + if (resolution.State == ThinkingLevelClearReconciler.ReconciliationState.Confirmed) + { + if (resolution.EffectiveThinkingLevel is null) + { + _confirmedNullThinkingAuthorityTokens[correlatedRequest.ThreadId] = + ++_nextConfirmedNullThinkingAuthorityToken; + } + else + { + _confirmedNullThinkingAuthorityTokens.Remove(correlatedRequest.ThreadId); + } + } + if (resolution.RefreshRequest is { } retry) + (thinkingLevelsToRefresh ??= []).Add(retry); + } + + if (refreshRequest is null) + ReleaseMissingConfirmedNullThinkingAuthoritiesLocked(incomingSessions); + + var previousThreads = refreshRequest is null + ? null + : BuildSnapshotLocked().Threads; var previousUsage = _sessions .Where(s => !string.IsNullOrEmpty(s.Key)) .ToDictionary(s => s.Key, s => (s.InputTokens, s.OutputTokens, s.TotalTokens, s.ContextTokens)); - _sessions = sessions ?? Array.Empty(); + var reconciledSessions = new SessionInfo[incomingSessions.Length]; + for (var index = 0; index < incomingSessions.Length; index++) + { + var incoming = incomingSessions[index]; + var isCorrelatedTarget = + refreshRequest is { } request && + string.Equals(request.ThreadId, incoming.Key, StringComparison.Ordinal); + var projectedSession = isCorrelatedTarget + ? incoming + : ApplyConfirmedNullThinkingAuthorityLocked( + incoming, + releaseOnNull: refreshRequest is null); + reconciledSessions[index] = projectedSession; + if (string.IsNullOrEmpty(incoming.Key)) + continue; + + ThinkingLevelClearReconciler.SnapshotResolution resolution; + if (isCorrelatedTarget) + { + resolution = correlatedResolution!.Value; + } + else + { + resolution = _thinkingLevelClearReconciler.ObserveSnapshot( + projectedSession.Key, + projectedSession.ThinkingLevel); + if (resolution.RefreshRequest is { } refresh) + (thinkingLevelsToRefresh ??= []).Add(refresh); + } + + var effectiveThinkingLevel = + !isCorrelatedTarget && + _confirmedNullThinkingAuthorityTokens.ContainsKey(projectedSession.Key) + ? null + : resolution.EffectiveThinkingLevel; + if (!string.Equals( + projectedSession.ThinkingLevel, + effectiveThinkingLevel, + StringComparison.Ordinal)) + { + var reconciled = projectedSession.Clone(); + reconciled.ThinkingLevel = effectiveThinkingLevel; + reconciledSessions[index] = reconciled; + } + } + + _sessions = reconciledSessions; SeedSessionIdsFromSessionsLocked(_sessions); _sessionsListReceived = true; EnsureTimelinesForSessionsLocked(); @@ -2219,6 +2576,8 @@ private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) SnapshotLatestAssistantUsageLocked(s, ResolveTimelineKeyForSessionLocked(s)); } snapshot = BuildSnapshotLocked(); + shouldPublish = previousThreads is null || + !previousThreads.SequenceEqual(snapshot.Threads); if (_status == ConnectionStatus.Connected) { @@ -2229,12 +2588,59 @@ private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) queuedThreadsToDrain = Array.Empty(); } } - Publish(snapshot); + if (shouldPublish) + Publish(snapshot); + + if (thinkingLevelsToRefresh is not null) + { + foreach (var refresh in thinkingLevelsToRefresh) + ObserveBackgroundTask(ExecuteThinkingLevelRefreshAsync(refresh)); + } foreach (var threadId in queuedThreadsToDrain) { TryDispatchNextQueuedSend(threadId); } + return true; + } + + private SessionInfo ApplyConfirmedNullThinkingAuthorityLocked( + SessionInfo session, + bool releaseOnNull) + { + if (string.IsNullOrEmpty(session.Key) || + !_confirmedNullThinkingAuthorityTokens.ContainsKey(session.Key)) + { + return session; + } + + if (session.ThinkingLevel is null) + { + if (releaseOnNull) + _confirmedNullThinkingAuthorityTokens.Remove(session.Key); + return session; + } + + var projected = session.Clone(); + projected.ThinkingLevel = null; + return projected; + } + + private void ReleaseMissingConfirmedNullThinkingAuthoritiesLocked( + IReadOnlyCollection sessions) + { + if (_confirmedNullThinkingAuthorityTokens.Count == 0) + return; + + var incomingKeys = sessions + .Where(session => !string.IsNullOrEmpty(session.Key)) + .Select(session => session.Key) + .ToHashSet(StringComparer.Ordinal); + var missingThreads = _confirmedNullThinkingAuthorityTokens.Keys + .Where(threadId => !incomingKeys.Contains(threadId)) + .ToArray(); + foreach (var threadId in missingThreads) + _confirmedNullThinkingAuthorityTokens.Remove(threadId); } internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload( diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index f438e9756..ccca66e68 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -609,7 +609,8 @@ selectedThread is not null && }, OnModelChanged: model => ObserveFireAndForget(_provider.SetModelAsync(composerThread.Id!, model)), OnModelCleared: () => ObserveFireAndForget(_provider.ClearModelAsync(composerThread.Id!)), - OnThinkingLevelChanged: level => RunFireAndForget(ct => _provider.SetThinkingLevelAsync(composerThread.Id!, level, ct)), + OnThinkingLevelChanged: level => ObserveFireAndForget(_provider.SetThinkingLevelAsync(composerThread.Id!, level)), + OnThinkingLevelCleared: () => ObserveFireAndForget(_provider.ClearThinkingLevelAsync(composerThread.Id!)), OnPermissionsChanged: allowAll => RunFireAndForget(ct => _provider.SetPermissionModeAsync(composerThread.Id!, allowAll, ct)), OnVoiceRequest: _onVoiceRequest, OnAttachClick: _onAttachClick, diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index bc3cd4ee9..d1d48b360 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -63,6 +63,7 @@ public record OpenClawComposerProps( Action OnChannelChanged, Action OnModelChanged, Action OnThinkingLevelChanged, + Action OnThinkingLevelCleared, Action OnPermissionsChanged, Func>? OnVoiceRequest = null, Action? OnAttachClick = null, @@ -99,10 +100,8 @@ public sealed class OpenClawComposer : Component // rather than OnModelChanged. private static readonly object ClearModelTag = new(); - // Thinking levels matching the gateway's sessions.patch thinkingLevel values. - // "medium" is the default when the session has no explicit thinkingLevel set. - private static readonly string[] ThinkingLevelIds = { "off", "minimal", "low", "medium", "high" }; - private static readonly string[] ThinkingLevelLabels = { "off", "minimal", "low", "medium (default)", "high" }; + // Concrete gateway thinkingLevel values. Default is a distinct clear action. + private static readonly string[] ThinkingLevelIds = { "off", "minimal", "low", "medium", "high" }; public override Element Render() { @@ -374,16 +373,21 @@ public override Element Render() ? (defaultChoice?.DisplayName ?? "Default") : (currentChoice?.DisplayName ?? Props.CurrentModel ?? "Model"); - var thinkingLevel = Props.CurrentThinkingLevel ?? "medium"; - var thinkingIndex = Array.IndexOf(ThinkingLevelIds, thinkingLevel); - if (thinkingIndex < 0) thinkingIndex = 3; // default to "medium (default)" + var defaultReasoningLabel = LocalizationHelper.GetString("Chat_Composer_Reasoning_Default"); + var thinkingIndex = Props.CurrentThinkingLevel is null + ? -1 + : Array.IndexOf(ThinkingLevelIds, Props.CurrentThinkingLevel); - var reasoningMenuItems = new CoreMenuFlyoutItemBase[ThinkingLevelLabels.Length]; - for (int i = 0; i < ThinkingLevelLabels.Length; i++) + var reasoningMenuItems = new CoreMenuFlyoutItemBase[ThinkingLevelIds.Length + 1]; + reasoningMenuItems[0] = ToggleMenuItem( + defaultReasoningLabel, + isChecked: Props.CurrentThinkingLevel is null, + onClick: Props.OnThinkingLevelCleared); + for (int i = 0; i < ThinkingLevelIds.Length; i++) { var levelIndex = i; - reasoningMenuItems[i] = ToggleMenuItem( - ThinkingLevelLabels[i], + reasoningMenuItems[i + 1] = ToggleMenuItem( + ThinkingLevelIds[i], isChecked: i == thinkingIndex, onClick: () => { @@ -509,12 +513,11 @@ Element SessionRow((string Id, string Title, string? Model, string? ModelProvide FlyoutPlacementMode.Top); - // Title-case the level id for the picker button (menu rows show the - // fuller "medium (default)" style labels). - var reasoningId = ThinkingLevelIds[thinkingIndex]; - var reasoningPickerLabel = reasoningId.Length == 0 - ? reasoningId - : char.ToUpperInvariant(reasoningId[0]) + reasoningId.Substring(1); + var reasoningPickerLabel = Props.CurrentThinkingLevel is null + ? defaultReasoningLabel + : thinkingIndex >= 0 + ? char.ToUpperInvariant(ThinkingLevelIds[thinkingIndex][0]) + ThinkingLevelIds[thinkingIndex].Substring(1) + : Props.CurrentThinkingLevel; // ── Row 2: multi-line composer textbox ───────────────────────── var recording = isRecording.Value; diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs index 06fbb94a4..29be5b65b 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs @@ -349,7 +349,8 @@ void SelectThread(string threadId) SelectThread, model => ObserveFireAndForget(props.Provider.SetModelAsync(effectiveThread.Id, model)), () => ObserveFireAndForget(props.Provider.ClearModelAsync(effectiveThread.Id)), - level => RunFireAndForget(ct => props.Provider.SetThinkingLevelAsync(effectiveThread.Id, level, ct)), + level => ObserveFireAndForget(props.Provider.SetThinkingLevelAsync(effectiveThread.Id, level)), + () => ObserveFireAndForget(props.Provider.ClearThinkingLevelAsync(effectiveThread.Id)), allowAll => RunFireAndForget(ct => props.Provider.SetPermissionModeAsync(effectiveThread.Id, allowAll, ct)), props.OnVoiceRequest, props.OnAttachClick, @@ -562,6 +563,7 @@ public sealed record ReactorChatComposerProps( Action OnModelChanged, Action OnModelCleared, Action OnThinkingLevelChanged, + Action OnThinkingLevelCleared, Action OnPermissionsChanged, Func>? OnVoiceRequest, Action? OnAttachClick, @@ -895,9 +897,10 @@ void Send() : Math.Max(0, Array.FindIndex( selectableModels, model => model.MatchesModel(props.CurrentThread.Model, props.CurrentThread.ModelProvider)) + 1); - var thinkingIndex = Math.Max(0, Array.IndexOf( - ThinkingLevels, - props.CurrentThread.ThinkingLevel ?? "medium")); + var defaultReasoningLabel = Localized("Chat_Composer_Reasoning_Default", "Default"); + var thinkingIndex = props.CurrentThread.ThinkingLevel is null + ? -1 + : Array.IndexOf(ThinkingLevels, props.CurrentThread.ThinkingLevel); var actionLabel = props.TurnActive ? Localized("Chat_Composer_Tooltip_Stop", "Stop") : Localized("Chat_Composer_Tooltip_Send", "Send"); @@ -1364,19 +1367,32 @@ void CommitSlashText(string value, ReactorSlashMenuState nextState) })) .ToArray()); + var reasoningPickerLabel = props.CurrentThread.ThinkingLevel is null + ? defaultReasoningLabel + : thinkingIndex >= 0 + ? ThinkingLevels[thinkingIndex] + : props.CurrentThread.ThinkingLevel; + var reasoningMenuItems = new[] + { + RadioMenuItem( + defaultReasoningLabel, + "chat-thinking-level", + props.CurrentThread.ThinkingLevel is null, + props.OnThinkingLevelCleared) + } + .Concat(ThinkingLevels.Select((level, index) => RadioMenuItem( + level, + "chat-thinking-level", + index == thinkingIndex, + () => props.OnThinkingLevelChanged(level)))) + .ToArray(); var reasoningPicker = MenuFlyout( PickerButton( - ThinkingLevels[thinkingIndex], - $"{Localized("Chat_Composer_Accessibility_Reasoning", "Reasoning")}: {ThinkingLevels[thinkingIndex]}", + reasoningPickerLabel, + $"{Localized("Chat_Composer_Accessibility_Reasoning", "Reasoning")}: {reasoningPickerLabel}", !props.MessageOptionsDisabled, props.IsCompact ? 54 : 96), - ThinkingLevels - .Select((level, index) => RadioMenuItem( - level, - "chat-thinking-level", - index == thinkingIndex, - () => props.OnThinkingLevelChanged(level))) - .ToArray()); + reasoningMenuItems); var attachButton = IconButton( "\uE723", diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index 8eb325181..c762e8f1e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -379,6 +379,9 @@ public Task SetModelAsync(string threadId, string model, CancellationToken cance public Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task ClearThinkingLevelAsync(string threadId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + public Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 529a9db94..3b3af2747 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -3288,6 +3288,18 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo Send failed: {0} + + Reasoning setting not changed + + + Could not use default reasoning: {0} + + + The change was canceled. + + + The gateway connection changed before the update was confirmed. + Abort failed diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index ac6fd09aa..faf6fcbbb 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -3241,6 +3241,18 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Échec de l’envoi : {0} + + Paramètre de raisonnement inchangé + + + Impossible d’utiliser le raisonnement par défaut : {0} + + + La modification a été annulée. + + + La connexion à la passerelle a changé avant la confirmation de la mise à jour. + Échec de l'abandon diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 3c5262e8b..eb8474af4 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -3242,6 +3242,18 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Verzenden mislukt: {0} + + Redeneerinstelling niet gewijzigd + + + Standaardredenering kan niet worden gebruikt: {0} + + + De wijziging is geannuleerd. + + + De gatewayverbinding is gewijzigd voordat de update werd bevestigd. + Afbreken mislukt diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 73d972a2e..3518b891b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -3241,6 +3241,18 @@ 发送失败:{0} + + 推理设置未更改 + + + 无法使用默认推理:{0} + + + 更改已取消。 + + + 在确认更新前,网关连接已更改。 + 中止失败 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 62cceb804..bf69ada9f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -3241,6 +3241,18 @@ 傳送失敗:{0} + + 推理設定未變更 + + + 無法使用預設推理:{0} + + + 變更已取消。 + + + 在確認更新前,閘道連線已變更。 + 中止失敗 diff --git a/tests/OpenClaw.E2ETests/Setup/SessionsPatchThinkingLevelE2ETests.cs b/tests/OpenClaw.E2ETests/Setup/SessionsPatchThinkingLevelE2ETests.cs new file mode 100644 index 000000000..5d6f9133d --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/SessionsPatchThinkingLevelE2ETests.cs @@ -0,0 +1,227 @@ +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClaw.E2ETests.Setup; + +[CollectionDefinition("Reasoning Gateway E2E", DisableParallelization = true)] +public sealed class ReasoningGatewayE2ECollection : ICollectionFixture { } + +[Collection("Reasoning Gateway E2E")] +public sealed class SessionsPatchThinkingLevelE2ETests +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30); + private readonly E2ESetupFixture _fixture; + + public SessionsPatchThinkingLevelE2ETests(E2ESetupFixture fixture) + { + _fixture = fixture; + + if (_fixture.SetupError is not null) + throw new InvalidOperationException($"E2E setup failed: {_fixture.SetupError}"); + if (_fixture.Client is null) + throw new InvalidOperationException("E2E fixture MCP client not initialized"); + } + + [E2EFact] + public async Task RealGateway_ThinkingLevelOffThenDefault_PersistsCanonicalNull() + { + var gateway = _fixture.ReadActiveGatewayRecord(); + var credentials = _fixture.ReadActiveGatewayCredentialState(); + Assert.False(string.IsNullOrWhiteSpace(gateway.SharedGatewayToken)); + + using var client = new OpenClawGatewayClient( + gateway.GatewayUrl, + gateway.SharedGatewayToken!, + identityPath: credentials.IdentityDir); + + string? sessionKey = null; + SessionInfo? original = null; + var mutated = false; + Exception? testFailure = null; + var cleanupFailures = new List(); + + try + { + await client.ConnectAsync(); + await WaitForAsync( + () => client.HasHandshakeSnapshot && !string.IsNullOrWhiteSpace(client.MainSessionKey), + "typed Gateway client handshake"); + + sessionKey = client.MainSessionKey!; + original = (await client.RequestSessionsSnapshotAsync()).SingleOrDefault( + candidate => string.Equals(candidate.Key, sessionKey, StringComparison.Ordinal)); + + mutated = true; + var offResult = await client.PatchSessionDetailedAsync( + sessionKey, + new SessionPatch { ThinkingLevel = "off" }); + Assert.True(offResult.Ok, offResult.Error); + + var offSnapshot = await ReadSessionAsync(client, sessionKey); + Assert.Equal("off", offSnapshot.ThinkingLevel); + await WaitForTrayThinkingLevelAsync(sessionKey, "off"); + + var clearResult = await client.PatchSessionDetailedAsync( + sessionKey, + new SessionPatch { ThinkingLevel = SessionPatch.Clear }); + Assert.True(clearResult.Ok, clearResult.Error); + + var defaultSnapshot = await ReadSessionAsync(client, sessionKey); + Assert.Null(defaultSnapshot.ThinkingLevel); + + using (var reconnect = await _fixture.Client!.CallToolExpectSuccessAsync( + "app.connection.reconnect")) + { + Assert.True(reconnect.RootElement.GetProperty("reconnected").GetBoolean()); + } + await _fixture.WaitForConnectionReady(); + await WaitForTrayThinkingLevelAsync(sessionKey, expected: null); + + Console.WriteLine( + "[E2E] Real WSL Gateway persisted off -> null (Default), and the isolated Tray " + + "confirmed canonical null after a current-generation reconnect snapshot."); + } + catch (Exception ex) + { + testFailure = ex; + } + finally + { + if (mutated && sessionKey is not null) + { + try + { + var restorePatch = new SessionPatch + { + ThinkingLevel = original?.ThinkingLevel is null + ? SessionPatch.Clear + : original.ThinkingLevel + }; + var restoreResult = await client.PatchSessionDetailedAsync(sessionKey, restorePatch); + if (!restoreResult.Ok) + { + throw new InvalidOperationException( + $"Failed to restore the original thinking-level state: {restoreResult.Error}"); + } + } + catch (Exception ex) + { + cleanupFailures.Add(ex); + } + } + + try + { + await client.DisconnectAsync(); + } + catch (Exception ex) + { + cleanupFailures.Add(ex); + } + } + + if (testFailure is not null) + { + if (cleanupFailures.Count > 0) + { + cleanupFailures.Insert(0, testFailure); + throw new AggregateException("The E2E proof and its cleanup both failed.", cleanupFailures); + } + + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(testFailure).Throw(); + } + + if (cleanupFailures.Count > 0) + throw new AggregateException("The E2E proof cleanup failed.", cleanupFailures); + } + + private static async Task ReadSessionAsync( + OpenClawGatewayClient client, + string sessionKey) + { + var sessions = await client.RequestSessionsSnapshotAsync(); + var session = sessions.SingleOrDefault( + candidate => string.Equals(candidate.Key, sessionKey, StringComparison.Ordinal)); + return Assert.IsType(session); + } + + private async Task WaitForTrayThinkingLevelAsync(string sessionKey, string? expected) + { + string? lastObserved = ""; + var deadline = DateTime.UtcNow.Add(s_timeout); + + while (DateTime.UtcNow < deadline) + { + using var snapshot = await _fixture.Client!.CallToolExpectSuccessAsync( + "app.chat.snapshot", + new { sessionKey }); + var root = snapshot.RootElement; + if (!TryGetPropertyIgnoreCase(root, "connectionStatus", out var connectionStatus) || + connectionStatus.GetString() is not ("Connected" or "Ready") || + !TryGetPropertyIgnoreCase(root, "composeTarget", out var composeTarget) || + !TryGetPropertyIgnoreCase(composeTarget, "isReady", out var isReady) || + !isReady.GetBoolean() || + !TryGetPropertyIgnoreCase(composeTarget, "sessionKey", out var composeSessionKey) || + !string.Equals(composeSessionKey.GetString(), sessionKey, StringComparison.Ordinal) || + !TryGetPropertyIgnoreCase(root, "threads", out var threads) || + threads.ValueKind != JsonValueKind.Array) + { + lastObserved = ""; + await Task.Delay(250); + continue; + } + + var thread = threads.EnumerateArray().FirstOrDefault( + candidate => + TryGetPropertyIgnoreCase(candidate, "id", out var id) && + string.Equals(id.GetString(), sessionKey, StringComparison.Ordinal)); + + if (thread.ValueKind != JsonValueKind.Undefined && + TryGetPropertyIgnoreCase(thread, "thinkingLevel", out var thinkingLevel)) + { + lastObserved = thinkingLevel.ValueKind == JsonValueKind.Null + ? null + : thinkingLevel.GetString(); + if (string.Equals(lastObserved, expected, StringComparison.Ordinal)) + return; + } + + await Task.Delay(250); + } + + throw new TimeoutException( + $"Tray did not converge to thinkingLevel '{expected ?? ""}'. " + + $"Last observed: '{lastObserved ?? ""}'."); + } + + private static async Task WaitForAsync(Func condition, string description) + { + var deadline = DateTime.UtcNow.Add(s_timeout); + while (DateTime.UtcNow < deadline) + { + if (condition()) + return; + await Task.Delay(100); + } + + throw new TimeoutException($"Timed out waiting for {description}."); + } + + private static bool TryGetPropertyIgnoreCase( + JsonElement element, + string propertyName, + out JsonElement property) + { + foreach (var candidate in element.EnumerateObject()) + { + if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + property = candidate.Value; + return true; + } + } + + property = default; + return false; + } +} diff --git a/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs b/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs index b5e6469b7..0550035f5 100644 --- a/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs +++ b/tests/OpenClaw.Shared.Tests/GatewayProtocolLiveRoundTripTests.cs @@ -117,6 +117,33 @@ public async Task NewProtocolMethods_RealWebSocketRoundTrip_SendCorrectWireFrame var clearFrame = await server.WaitFrameAsync("sessions.patch", occurrence: 1, timeoutMs: rpc); Assert.Contains("\"model\":null", clearFrame); + var detailedClear = await client.PatchSessionDetailedAsync( + key, + new SessionPatch { ThinkingLevel = SessionPatch.Clear }, + timeoutMs: rpc); + Assert.True(detailedClear.Ok); + Assert.True(detailedClear.IsSupported); + Assert.Equal(key, detailedClear.Key); + var detailedClearFrame = await server.WaitFrameAsync("sessions.patch", occurrence: 2, timeoutMs: rpc); + Assert.Contains("\"method\":\"sessions.patch\"", detailedClearFrame); + Assert.Contains("\"key\":\"agent:main:main\"", detailedClearFrame); + Assert.Contains("\"thinkingLevel\":null", detailedClearFrame); + Assert.DoesNotContain("\"thinkingLevel\":\"default\"", detailedClearFrame); + Assert.DoesNotContain("\"thinkingLevel\":\"medium\"", detailedClearFrame); + + // ── 6. sessions.list correlated snapshot ── + var sessionsUpdated = 0; + client.SessionsUpdated += (_, _) => sessionsUpdated++; + var sessions = await client.RequestSessionsSnapshotAsync(); + var refreshedSession = Assert.Single(sessions); + Assert.Equal(key, refreshedSession.Key); + Assert.Null(refreshedSession.ThinkingLevel); + Assert.Equal("gpt-5", refreshedSession.Model); + Assert.Equal(0, sessionsUpdated); + Assert.Empty(client.GetSessionList()); + var sessionsFrame = await server.WaitFrameAsync("sessions.list", occurrence: 0, timeoutMs: rpc); + Assert.Contains("\"method\":\"sessions.list\"", sessionsFrame); + PrintProof(server); } finally @@ -214,6 +241,62 @@ public async Task ChatSend_UsesCallerProvidedIdempotencyKeyOnWire() } } + [Fact] + public async Task SessionPatchDetailed_RepresentsDisconnectedUnknownTimeoutAndCancellation() + { + var disconnected = new OpenClawGatewayClient( + "ws://127.0.0.1:1/", + "test-token", + new TestLogger(), + identityPath: _identityDir); + var disconnectedResult = await disconnected.PatchSessionDetailedAsync( + "agent:main:main", + new SessionPatch { ThinkingLevel = SessionPatch.Clear }); + Assert.False(disconnectedResult.Ok); + Assert.True(disconnectedResult.IsSupported); + Assert.Contains("not open", disconnectedResult.Error); + + using var server = new LoopbackGatewayServer(); + server.OnMethod("sessions.patch", _ => LoopbackResponse.Fail("unknown method: sessions.patch")); + var client = new OpenClawGatewayClient( + server.WebSocketUrl, + "test-token", + new TestLogger(), + identityPath: _identityDir); + try + { + await ConnectAndWaitAsync(client, server); + var unknown = await client.PatchSessionDetailedAsync( + "agent:main:main", + new SessionPatch { ThinkingLevel = SessionPatch.Clear }, + timeoutMs: 20000); + Assert.False(unknown.Ok); + Assert.False(unknown.IsSupported); + Assert.Contains("unknown method", unknown.Error); + + server.OnMethod("sessions.patch", _ => LoopbackResponse.NoResponse()); + var timeout = await client.PatchSessionDetailedAsync( + "agent:main:main", + new SessionPatch { ThinkingLevel = SessionPatch.Clear }, + timeoutMs: 50); + Assert.False(timeout.Ok); + Assert.True(timeout.IsSupported); + Assert.Contains("timed out", timeout.Error); + + using var cancellation = new CancellationTokenSource(50); + await Assert.ThrowsAnyAsync(() => + client.PatchSessionDetailedAsync( + "agent:main:main", + new SessionPatch { ThinkingLevel = SessionPatch.Clear }, + timeoutMs: 20000, + cancellationToken: cancellation.Token)); + } + finally + { + await client.DisconnectAsync(); + } + } + private static void ConfigureResponders(LoopbackGatewayServer server) { // NOTE: intentionally NO hello-ok responder. The new methods only require @@ -248,6 +331,21 @@ private static void ConfigureResponders(LoopbackGatewayServer server) } }); + server.OnMethod("sessions.list", _ => new + { + sessions = new object[] + { + new + { + key = "agent:main:main", + status = "active", + model = "gpt-5", + thinkingLevel = (string?)null, + isMain = true + } + } + }); + server.OnMethod("sessions.files.get", _ => new { sessionKey = "agent:main:main", @@ -513,10 +611,14 @@ private async Task HandleFrameAsync(WebSocket socket, string frame) : new { }; var response = payload is LoopbackResponse loopbackResponse - ? loopbackResponse.Ok + ? loopbackResponse.SuppressResponse + ? null + : loopbackResponse.Ok ? JsonSerializer.Serialize(new { type = "res", id, ok = true, payload = loopbackResponse.Payload }) : JsonSerializer.Serialize(new { type = "res", id, ok = false, error = loopbackResponse.Error }) : JsonSerializer.Serialize(new { type = "res", id, ok = true, payload }); + if (response is null) + return; var bytes = Encoding.UTF8.GetBytes(response); await socket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); } @@ -540,8 +642,13 @@ public void Dispose() } } - private sealed record LoopbackResponse(bool Ok, object? Payload = null, string? Error = null) + private sealed record LoopbackResponse( + bool Ok, + object? Payload = null, + string? Error = null, + bool SuppressResponse = false) { public static LoopbackResponse Fail(string error) => new(false, Error: error); + public static LoopbackResponse NoResponse() => new(false, SuppressResponse: true); } } diff --git a/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs b/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs index 634a9c0e9..b65511e79 100644 --- a/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs @@ -27,6 +27,7 @@ public void NewGatewayProtocolMembers_AreDefaultInterfaceMethods_SoTheyDoNotSour { ("ListCommandsAsync", new[] { typeof(CommandCatalogQuery), typeof(int) }), ("PatchSessionAsync", new[] { typeof(string), typeof(SessionPatch) }), + ("PatchSessionDetailedAsync", new[] { typeof(string), typeof(SessionPatch), typeof(int), typeof(CancellationToken) }), ("ListSessionFilesAsync", new[] { typeof(string), typeof(string), typeof(string), typeof(int) }), ("GetSessionFileAsync", new[] { typeof(string), typeof(string), typeof(int) }), ("ListCompactionCheckpointsAsync", new[] { typeof(string), typeof(int) }), @@ -470,6 +471,32 @@ public void SessionPatch_ToPayload_ClearSerializesToJsonNull() var fastJson = JsonSerializer.Serialize(new SessionPatch { FastMode = SessionPatch.Clear }.ToPayload("k")); Assert.Contains("\"fastMode\":null", fastJson); + + var thinkingJson = JsonSerializer.Serialize( + new SessionPatch { ThinkingLevel = SessionPatch.Clear }.ToPayload("k")); + Assert.Contains("\"thinkingLevel\":null", thinkingJson); + Assert.DoesNotContain("\"thinkingLevel\":\"default\"", thinkingJson); + Assert.DoesNotContain("\"thinkingLevel\":\"medium\"", thinkingJson); + } + + [Fact] + public void ParseSessionPatchResult_PreservesGatewaySuccessAndPayloadFailure() + { + var succeeded = OpenClawGatewayClient.ParseSessionPatchResult( + Parse("""{"ok":true,"key":"agent:main:main"}"""), + "fallback"); + Assert.True(succeeded.Ok); + Assert.True(succeeded.IsSupported); + Assert.Equal("agent:main:main", succeeded.Key); + Assert.Null(succeeded.Error); + + var failed = OpenClawGatewayClient.ParseSessionPatchResult( + Parse("""{"ok":false,"reason":"policy rejected the change"}"""), + "agent:main:main"); + Assert.False(failed.Ok); + Assert.True(failed.IsSupported); + Assert.Equal("agent:main:main", failed.Key); + Assert.Equal("policy rejected the change", failed.Error); } [Fact] diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 5239a878f..ba334b2d5 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -101,6 +101,22 @@ public Task RegisterPendingWizardResponse(string requestId) return completion.Task; } + public Task RegisterPendingSessionSnapshot(string requestId) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Assert.True( + _client.PendingRequests.RegisterSessionSnapshot( + requestId, + completion).Accepted); + return completion.Task; + } + + public void RemovePendingSessionSnapshot(string requestId) + { + Assert.True(_client.PendingRequests.Remove(requestId)); + } + public void ClearPendingRequests() { _client.PendingRequests.CloseForDisconnect(); diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index e3d294594..0a29bb0cd 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; using System.Text.Json; namespace OpenClaw.Tray.Tests; @@ -78,6 +79,10 @@ private sealed class FakeBridge : IChatGatewayBridge public Func? SendBehavior { get; set; } public Func? PatchSessionModelBehavior { get; set; } public Func? ClearSessionModelBehavior { get; set; } + public Func? PatchSessionThinkingLevelBehavior { get; set; } + public Func>? ClearSessionThinkingLevelBehavior { get; set; } + public Func? RequestSessionsBehavior { get; set; } + public Func>? RequestSessionsSnapshotBehavior { get; set; } public Func>? HistoryBehavior { get; set; } public Func? AbortBehavior { get; set; } public SessionInfo[] Sessions { get; set; } = Array.Empty(); @@ -110,6 +115,7 @@ private sealed class FakeBridge : IChatGatewayBridge }; public Func>? CompactSessionBehavior { get; set; } public int RequestSessionsCallCount { get; private set; } + public int RequestSessionsSnapshotCallCount { get; private set; } public List RequestedHistoryKeys { get; } = new(); public SessionInfo[] GetSessionList() => Sessions; @@ -156,7 +162,15 @@ public Task CompactSessionDetailedAsync(string sessionKey) public Task RequestSessionsAsync() { RequestSessionsCallCount++; - return Task.CompletedTask; + return RequestSessionsBehavior?.Invoke() ?? Task.CompletedTask; + } + + public Task RequestSessionsSnapshotAsync( + CancellationToken cancellationToken = default) + { + RequestSessionsSnapshotCallCount++; + return RequestSessionsSnapshotBehavior?.Invoke(cancellationToken) + ?? Task.FromResult(Sessions.Select(session => session.Clone()).ToArray()); } public Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList? attachments = null) @@ -194,7 +208,29 @@ public Task ClearSessionModelAsync(string sessionKey) return ClearSessionModelBehavior?.Invoke(sessionKey) ?? Task.CompletedTask; } public List ClearedModelKeys { get; } = new(); - public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => Task.CompletedTask; + public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) + { + PatchedThinkingLevelKeys.Add(sessionKey); + PatchedThinkingLevels.Add(thinkingLevel); + return PatchSessionThinkingLevelBehavior?.Invoke(sessionKey, thinkingLevel) + ?? Task.CompletedTask; + } + public List PatchedThinkingLevelKeys { get; } = new(); + public List PatchedThinkingLevels { get; } = new(); + public Task ClearSessionThinkingLevelAsync( + string sessionKey, + CancellationToken cancellationToken = default) + { + ClearedThinkingLevelKeys.Add(sessionKey); + return ClearSessionThinkingLevelBehavior?.Invoke(sessionKey, cancellationToken) + ?? Task.FromResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = true, + Key = sessionKey + }); + } + public List ClearedThinkingLevelKeys { get; } = new(); public Task RequestChatHistoryAsync(string? sessionKey) { @@ -244,11 +280,14 @@ private static (FakeBridge bridge, OpenClawChatDataProvider provider, List, Task>? historyRetryScheduler = null, - Action? historyFailureReservedForTesting = null) + Action? historyFailureReservedForTesting = null, + TimeSpan? thinkingLevelConfirmationTimeout = null, + Func, Task>? thinkingLevelRetryScheduler = null) { var bridge = new FakeBridge { Sessions = initial ?? Array.Empty() }; var provider = toolMetaCachePath is null && attachmentMetaCachePath is null && lastChatStatePath is null && lastChatStateSaveDelay is null && historyRetryScheduler is null && historyFailureReservedForTesting is null + && thinkingLevelConfirmationTimeout is null && thinkingLevelRetryScheduler is null ? new OpenClawChatDataProvider(bridge) : new OpenClawChatDataProvider( bridge, @@ -258,7 +297,9 @@ lastChatStateSaveDelay is null && historyRetryScheduler is null && historyFailur lastChatStateFilePath: lastChatStatePath, lastChatStateSaveDelay: lastChatStateSaveDelay, historyRetryScheduler: historyRetryScheduler, - historyFailureReservedForTesting: historyFailureReservedForTesting); + historyFailureReservedForTesting: historyFailureReservedForTesting, + thinkingLevelConfirmationTimeout: thinkingLevelConfirmationTimeout, + thinkingLevelRetryScheduler: thinkingLevelRetryScheduler); var snapshots = new List(); var notifications = new List(); provider.Changed += (_, e) => snapshots.Add(e.Snapshot); @@ -1625,6 +1666,1074 @@ public async Task LoadAsync_CarriesSessionModelProviderToThreads() Assert.Equal("openrouter", snapshot.Threads[0].ModelProvider); } + [Fact] + public async Task ClearThinkingLevelAsync_PreAckNullWaitsForPostAckCanonicalNull() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + session.Model = "gpt-5.4"; + session.Provider = "openrouter"; + var (bridge, provider, snapshots, notifications) = CreateProvider([session]); + var clearResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var snapshotRequested = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var snapshotResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.ClearSessionThinkingLevelBehavior = (_, _) => clearResponse.Task; + bridge.RequestSessionsSnapshotBehavior = _ => + { + snapshotRequested.TrySetResult(); + return snapshotResponse.Task; + }; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + var clearTask = provider.ClearThinkingLevelAsync("main"); + + Assert.False(clearTask.IsCompleted); + Assert.Equal(["main"], bridge.ClearedThinkingLevelKeys); + Assert.Equal(0, bridge.RequestSessionsSnapshotCallCount); + Assert.Empty(notifications); + Assert.Empty(snapshots); + + var preAck = session.Clone(); + preAck.ThinkingLevel = null; + bridge.RaiseSessions([preAck]); + Assert.False(clearTask.IsCompleted); + Assert.Equal("off", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + + clearResponse.SetResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = true, + Key = "main" + }); + await snapshotRequested.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(clearTask.IsCompleted); + Assert.Equal(1, bridge.RequestSessionsSnapshotCallCount); + + var postAck = session.Clone(); + postAck.ThinkingLevel = null; + snapshotResponse.SetResult([postAck]); + await clearTask; + + var thread = Assert.Single(snapshots[^1].Threads); + Assert.Null(thread.ThinkingLevel); + Assert.Equal("gpt-5.4", thread.Model); + Assert.Equal("openrouter", thread.ModelProvider); + Assert.Empty(notifications); + } + + [Fact] + public async Task SetThinkingLevelAsync_ConcreteValueUsesExistingPathWithoutOptimisticState() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + session.Model = "gpt-5.4"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var before = await provider.LoadAsync(); + snapshots.Clear(); + + await provider.SetThinkingLevelAsync("main", "high"); + + Assert.Equal(["main"], bridge.PatchedThinkingLevelKeys); + Assert.Equal(["high"], bridge.PatchedThinkingLevels); + Assert.Empty(bridge.ClearedThinkingLevelKeys); + Assert.Empty(snapshots); + Assert.Equal("off", Assert.Single(before.Threads).ThinkingLevel); + Assert.Equal("gpt-5.4", Assert.Single(before.Threads).Model); + } + + [Fact] + public async Task SetThinkingLevelAsync_ConcreteValueSupersedesPendingClearSilently() + { + var session = MainSession(); + session.ThinkingLevel = "high"; + var (bridge, provider, snapshots, notifications) = CreateProvider([session]); + var clearResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var snapshotRequested = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.ClearSessionThinkingLevelBehavior = (_, _) => clearResponse.Task; + bridge.RequestSessionsSnapshotBehavior = _ => + { + snapshotRequested.TrySetResult(); + var current = session.Clone(); + current.ThinkingLevel = "low"; + return Task.FromResult([current]); + }; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + var clearTask = provider.ClearThinkingLevelAsync("main"); + await provider.SetThinkingLevelAsync("main", "low"); + + clearResponse.SetResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + Key = "main", + Error = "stale clear failure" + }); + await Assert.ThrowsAnyAsync(() => clearTask); + + Assert.Equal(["main"], bridge.PatchedThinkingLevelKeys); + Assert.Equal(["low"], bridge.PatchedThinkingLevels); + Assert.Equal(0, bridge.RequestSessionsCallCount); + Assert.Empty(notifications); + + var delayedClear = session.Clone(); + delayedClear.ThinkingLevel = null; + bridge.RaiseSessions([delayedClear]); + await snapshotRequested.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal("low", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + } + + [Fact] + public async Task ClearThinkingLevelAsync_FailedResponseRetainsSelectionAndSurfacesOriginalThreadError() + { + var session = MainSession(); + session.ThinkingLevel = "high"; + session.Model = "gpt-5.4"; + var (bridge, provider, snapshots, notifications) = CreateProvider([session]); + bridge.ClearSessionThinkingLevelBehavior = (key, _) => + Task.FromResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + IsSupported = false, + Key = key, + Error = "unknown method: sessions.patch" + }); + await provider.LoadAsync(); + snapshots.Clear(); + + var exception = await Assert.ThrowsAsync( + () => provider.ClearThinkingLevelAsync("main")); + + Assert.Contains("unknown method", exception.Message); + var notification = Assert.Single(notifications); + Assert.Equal("main", notification.ThreadId); + Assert.Equal(ChatProviderNotificationKind.Error, notification.Kind); + Assert.Contains("Could not use default reasoning", notification.Message); + var latest = Assert.Single(snapshots); + var thread = Assert.Single(latest.Threads); + Assert.Equal("high", thread.ThinkingLevel); + Assert.Equal("gpt-5.4", thread.Model); + Assert.Contains( + latest.Timelines["main"].Entries, + entry => entry.Text.Contains("Could not use default reasoning", StringComparison.Ordinal)); + + var delayedNull = session.Clone(); + delayedNull.ThinkingLevel = null; + delayedNull.Model = "gpt-5.5"; + bridge.RaiseSessions([delayedNull]); + + var afterDelayedNull = Assert.Single(snapshots[^1].Threads); + Assert.Equal("high", afterDelayedNull.ThinkingLevel); + Assert.Equal("gpt-5.5", afterDelayedNull.Model); + } + + [Fact] + public async Task ClearThinkingLevelAsync_SessionSwitchReconnectAndStaleSnapshotStayOnOriginalThread() + { + var main = MainSession(); + main.ThinkingLevel = "off"; + main.Model = "gpt-5.4"; + var other = new SessionInfo + { + Key = "other", + DisplayName = "Other", + Status = "active", + ThinkingLevel = "high", + Model = "claude-sonnet" + }; + var (bridge, provider, snapshots, notifications) = CreateProvider([main, other]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + var clearTask = provider.ClearThinkingLevelAsync("main"); + provider.RememberSelectedThread("other"); + var otherChanged = other.Clone(); + otherChanged.ThinkingLevel = null; + bridge.RaiseSessions([main.Clone(), otherChanged]); + + Assert.False(clearTask.IsCompleted); + bridge.RaiseStatus(ConnectionStatus.Disconnected); + await clearTask; + + var beforeStale = snapshots[^1]; + Assert.Equal("off", Assert.Single(beforeStale.Threads, thread => thread.Id == "main").ThinkingLevel); + Assert.Null(Assert.Single(beforeStale.Threads, thread => thread.Id == "other").ThinkingLevel); + + var staleMain = main.Clone(); + staleMain.ThinkingLevel = null; + staleMain.Model = "gpt-5.5"; + bridge.RaiseSessions([staleMain, otherChanged]); + + var afterStale = snapshots[^1]; + Assert.Equal("off", Assert.Single(afterStale.Threads, thread => thread.Id == "main").ThinkingLevel); + Assert.Equal("gpt-5.5", Assert.Single(afterStale.Threads, thread => thread.Id == "main").Model); + Assert.Equal("claude-sonnet", Assert.Single(afterStale.Threads, thread => thread.Id == "other").Model); + Assert.Empty(notifications); + + var refreshed = staleMain.Clone(); + refreshed.ThinkingLevel = "off"; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([refreshed, otherChanged]); + bridge.RaiseStatus(ConnectionStatus.Connected); + + await WaitForConditionAsync(() => bridge.RequestSessionsSnapshotCallCount == 1); + Assert.Equal("off", Assert.Single(snapshots[^1].Threads, thread => thread.Id == "main").ThinkingLevel); + } + + [Fact] + public async Task ClearThinkingLevelAsync_CancelBeforeAckRetainsSelectionAndSurfacesError() + { + var session = MainSession(); + session.ThinkingLevel = "minimal"; + var (bridge, provider, snapshots, notifications) = CreateProvider([session]); + bridge.ClearSessionThinkingLevelBehavior = async (_, cancellationToken) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new SessionCommandResult { Method = "sessions.patch", Ok = true }; + }; + await provider.LoadAsync(); + snapshots.Clear(); + using var cancellation = new CancellationTokenSource(); + + cancellation.Cancel(); + var clearTask = provider.ClearThinkingLevelAsync("main", cancellation.Token); + + await Assert.ThrowsAnyAsync(() => clearTask); + Assert.Equal("minimal", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Single(notifications); + Assert.Equal("main", notifications[0].ThreadId); + Assert.Contains("The change was canceled", notifications[0].Message); + + var delayedNull = session.Clone(); + delayedNull.ThinkingLevel = null; + bridge.RaiseSessions([delayedNull]); + Assert.Equal("minimal", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + } + + [Fact] + public async Task ClearThinkingLevelAsync_DisconnectBeforeAckRetainsSelectionAndRejectsLateAck() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, notifications) = CreateProvider([session]); + var clearResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.ClearSessionThinkingLevelBehavior = (_, _) => clearResponse.Task; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + var clearTask = provider.ClearThinkingLevelAsync("main"); + bridge.RaiseStatus(ConnectionStatus.Disconnected); + clearResponse.SetResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = true, + Key = "main" + }); + + await Assert.ThrowsAnyAsync(() => clearTask); + Assert.Equal("off", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Single(notifications); + Assert.Equal(0, bridge.RequestSessionsSnapshotCallCount); + + var lateNull = session.Clone(); + lateNull.ThinkingLevel = null; + bridge.RaiseSessions([lateNull]); + Assert.Equal("off", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + } + + [Fact] + public async Task ClearThinkingLevelAsync_CancelAfterAckReturnsWithoutErrorAndLateSnapshotConfirms() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, notifications) = CreateProvider([session]); + var snapshotRequested = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var snapshotResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.RequestSessionsSnapshotBehavior = _ => + { + snapshotRequested.TrySetResult(); + return snapshotResponse.Task; + }; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + using var cancellation = new CancellationTokenSource(); + + var clearTask = provider.ClearThinkingLevelAsync("main", cancellation.Token); + await snapshotRequested.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + await clearTask; + + Assert.Empty(notifications); + Assert.Equal("off", Assert.Single((await provider.LoadAsync()).Threads).ThinkingLevel); + + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + snapshotResponse.SetResult([confirmed]); + await WaitForConditionAsync( + () => snapshots.Count > 0 && snapshots[^1].Threads.Single().ThinkingLevel is null); + + Assert.Empty(notifications); + Assert.Null(Assert.Single(snapshots[^1].Threads).ThinkingLevel); + } + + [Fact] + public async Task ClearThinkingLevelAsync_PostAckTimeoutReturnsWithoutErrorAndLateSnapshotConfirms() + { + var session = MainSession(); + session.ThinkingLevel = "high"; + var (bridge, provider, snapshots, notifications) = CreateProvider( + [session], + thinkingLevelConfirmationTimeout: TimeSpan.FromMilliseconds(20)); + var snapshotResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.RequestSessionsSnapshotBehavior = _ => snapshotResponse.Task; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.ClearThinkingLevelAsync("main"); + + Assert.Empty(notifications); + Assert.Equal("high", Assert.Single((await provider.LoadAsync()).Threads).ThinkingLevel); + + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + snapshotResponse.SetResult([confirmed]); + await WaitForConditionAsync( + () => snapshots.Count > 0 && snapshots[^1].Threads.Single().ThinkingLevel is null); + + Assert.Null(Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Empty(notifications); + } + + [Fact] + public async Task ClearThinkingLevelAsync_PostAckTimeoutThenConcreteSelectionRejectsLateNull() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, notifications) = CreateProvider( + [session], + thinkingLevelConfirmationTimeout: TimeSpan.FromMilliseconds(20)); + var staleClearSnapshot = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var currentConcreteSnapshot = session.Clone(); + currentConcreteSnapshot.ThinkingLevel = "low"; + var refreshCall = 0; + CancellationToken staleRequestCancellation = default; + bridge.RequestSessionsSnapshotBehavior = cancellationToken => + { + if (Volatile.Read(ref refreshCall) == 0) + staleRequestCancellation = cancellationToken; + return + Interlocked.Increment(ref refreshCall) == 1 + ? staleClearSnapshot.Task + : Task.FromResult([currentConcreteSnapshot]); + }; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.ClearThinkingLevelAsync("main"); + await provider.SetThinkingLevelAsync("main", "low"); + Assert.True(staleRequestCancellation.IsCancellationRequested); + + var staleNull = session.Clone(); + staleNull.ThinkingLevel = null; + staleClearSnapshot.SetResult([staleNull]); + bridge.RaiseSessions([staleNull]); + await WaitForConditionAsync(() => bridge.RequestSessionsSnapshotCallCount >= 2); + + Assert.Equal("low", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Empty(notifications); + } + + [Fact] + public async Task ClearThinkingLevelAsync_UnchangedRetryDoesNotPublishDuplicateChanged() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, notifications) = CreateProvider( + [session], + thinkingLevelRetryScheduler: (_, retry) => retry()); + var first = session.Clone(); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + var refreshCall = 0; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult( + Interlocked.Increment(ref refreshCall) == 1 + ? [first] + : [confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.ClearThinkingLevelAsync("main"); + + Assert.Equal(2, bridge.RequestSessionsSnapshotCallCount); + var changed = Assert.Single(snapshots); + Assert.Null(Assert.Single(changed.Threads).ThinkingLevel); + Assert.Empty(notifications); + } + + [Fact] + public async Task ClearThinkingLevelAsync_CommittedReconciliationRetriesBoundedlyAndCanRestart() + { + var session = MainSession(); + session.ThinkingLevel = "minimal"; + var (bridge, provider, snapshots, notifications) = CreateProvider( + [session], + thinkingLevelConfirmationTimeout: TimeSpan.FromMilliseconds(50), + thinkingLevelRetryScheduler: (_, retry) => retry()); + var externalNull = session.Clone(); + externalNull.ThinkingLevel = null; + var confirmationAvailable = false; + bridge.RequestSessionsSnapshotBehavior = _ => confirmationAvailable + ? Task.FromResult([externalNull]) + : Task.FromException(new TimeoutException("confirmation unavailable")); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.ClearThinkingLevelAsync("main"); + await WaitForConditionAsync( + () => bridge.RequestSessionsSnapshotCallCount == 3); + + Assert.Equal(3, bridge.RequestSessionsSnapshotCallCount); + Assert.Equal("minimal", Assert.Single((await provider.LoadAsync()).Threads).ThinkingLevel); + Assert.Empty(notifications); + + confirmationAvailable = true; + bridge.RaiseSessions([externalNull]); + await WaitForConditionAsync( + () => bridge.RequestSessionsSnapshotCallCount == 4 && + snapshots.Count > 0 && + snapshots[^1].Threads.Single().ThinkingLevel is null); + + Assert.Null(Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Empty(notifications); + } + + [Fact] + public async Task ClearThinkingLevelAsync_FailedClearAcceptsExternalNullOnlyAfterCorrelatedRefresh() + { + var session = MainSession(); + session.ThinkingLevel = "high"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + bridge.ClearSessionThinkingLevelBehavior = (key, _) => + Task.FromResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + Key = key, + Error = "rejected" + }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await Assert.ThrowsAsync( + () => provider.ClearThinkingLevelAsync("main")); + + var externalNull = session.Clone(); + externalNull.ThinkingLevel = null; + externalNull.Model = "gpt-5.5"; + var correlatedSnapshot = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.RequestSessionsSnapshotBehavior = _ => + correlatedSnapshot.Task; + bridge.RaiseSessions([externalNull]); + + Assert.Equal("high", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Equal("gpt-5.5", Assert.Single(snapshots[^1].Threads).Model); + correlatedSnapshot.SetResult([externalNull]); + await WaitForConditionAsync( + () => snapshots.Count > 0 && snapshots[^1].Threads.Single().ThinkingLevel is null); + + Assert.Equal(1, bridge.RequestSessionsSnapshotCallCount); + Assert.Null(Assert.Single(snapshots[^1].Threads).ThinkingLevel); + } + + [Fact] + public async Task LoadAsync_DuringFailedClearReconciliationProtectsCachedNullUntilCorrelatedRefresh() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, _, _) = CreateProvider([session]); + bridge.ClearSessionThinkingLevelBehavior = (key, _) => + Task.FromResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + IsSupported = false, + Key = key, + Error = "unknown method: sessions.patch" + }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + await Assert.ThrowsAsync( + () => provider.ClearThinkingLevelAsync("main")); + + var correlatedResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.RequestSessionsSnapshotBehavior = _ => correlatedResponse.Task; + var cachedNull = session.Clone(); + cachedNull.ThinkingLevel = null; + bridge.Sessions = [cachedNull]; + + var snapshot = await provider.LoadAsync(); + + Assert.Equal("off", Assert.Single(snapshot.Threads).ThinkingLevel); + Assert.Equal(1, bridge.RequestSessionsSnapshotCallCount); + } + + [Fact] + public async Task ClearThinkingLevelAsync_TwoRapidClearsOnlyNewestCorrelatedSnapshotWins() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, notifications) = CreateProvider([session]); + var firstResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var secondResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var clearCall = 0; + bridge.ClearSessionThinkingLevelBehavior = (key, _) => + { + var response = Interlocked.Increment(ref clearCall) == 1 + ? firstResponse + : secondResponse; + return response.Task; + }; + var canonicalNull = session.Clone(); + canonicalNull.ThinkingLevel = null; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([canonicalNull]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + var firstClear = provider.ClearThinkingLevelAsync("main"); + var secondClear = provider.ClearThinkingLevelAsync("main"); + + var staleNull = session.Clone(); + staleNull.ThinkingLevel = null; + bridge.RaiseSessions([staleNull]); + Assert.Equal("off", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + + firstResponse.SetResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = true, + Key = "main" + }); + await Assert.ThrowsAnyAsync(() => firstClear); + Assert.Equal(0, bridge.RequestSessionsSnapshotCallCount); + + secondResponse.SetResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = true, + Key = "main" + }); + await secondClear; + + Assert.Equal(1, bridge.RequestSessionsSnapshotCallCount); + Assert.Null(Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Empty(notifications); + } + + [Fact] + public async Task ClearThinkingLevelAsync_OldConnectionRefreshCannotOverwriteReconnectSnapshot() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + session.Model = "gpt-5.4"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var clearResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var oldRefresh = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var newRefresh = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var refreshCall = 0; + bridge.ClearSessionThinkingLevelBehavior = (_, _) => clearResponse.Task; + bridge.RequestSessionsSnapshotBehavior = _ => + Interlocked.Increment(ref refreshCall) == 1 + ? oldRefresh.Task + : newRefresh.Task; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + var clearTask = provider.ClearThinkingLevelAsync("main"); + clearResponse.SetResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = true, + Key = "main" + }); + await WaitForConditionAsync(() => bridge.RequestSessionsSnapshotCallCount == 1); + + bridge.RaiseStatus(ConnectionStatus.Disconnected); + await clearTask; + + bridge.RaiseStatus(ConnectionStatus.Connected); + await WaitForConditionAsync(() => bridge.RequestSessionsSnapshotCallCount == 2); + var current = session.Clone(); + current.Model = "gpt-5.5"; + newRefresh.SetResult([current]); + await WaitForConditionAsync( + () => snapshots.Count > 0 && snapshots[^1].Threads.Single().Model == "gpt-5.5"); + + var stale = session.Clone(); + stale.ThinkingLevel = null; + stale.Model = "stale-model"; + oldRefresh.SetResult([stale]); + await Task.Delay(25); + + var thread = Assert.Single(snapshots[^1].Threads); + Assert.Equal("off", thread.ThinkingLevel); + Assert.Equal("gpt-5.5", thread.Model); + } + + [Fact] + public async Task LoadAsync_FirstMountPreservesNonNullBridgeThinkingLevel() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + session.Model = "gpt-5.4"; + var (bridge, provider, _, _) = CreateProvider([session]); + + var snapshot = await provider.LoadAsync(); + + var thread = Assert.Single(snapshot.Threads); + Assert.Equal("off", thread.ThinkingLevel); + Assert.Equal("gpt-5.4", thread.Model); + Assert.Same(session, Assert.Single(bridge.Sessions)); + } + + [Fact] + public async Task LoadAsync_AfterCorrelatedNullMasksRepeatedStaleBridgeReads() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + session.Model = "gpt-5.4"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + confirmed.Model = "gpt-5.5"; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.ClearThinkingLevelAsync("main"); + + Assert.Null(Assert.Single(snapshots[^1].Threads).ThinkingLevel); + Assert.Equal("off", Assert.Single(bridge.Sessions).ThinkingLevel); + var firstRemount = await provider.LoadAsync(); + var secondRemount = await provider.LoadAsync(); + Assert.Null(Assert.Single(firstRemount.Threads).ThinkingLevel); + Assert.Null(Assert.Single(secondRemount.Threads).ThinkingLevel); + Assert.Equal("gpt-5.4", Assert.Single(secondRemount.Threads).Model); + Assert.Equal("off", Assert.Single(bridge.Sessions).ThinkingLevel); + } + + [Fact] + public async Task SessionsUpdated_ConfirmedNullMasksStaleOffUntilOrdinaryNullReleasesLatch() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + session.Model = "gpt-5.4"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + await provider.ClearThinkingLevelAsync("main"); + snapshots.Clear(); + + var staleOff = session.Clone(); + staleOff.Model = "gpt-5.5"; + bridge.RaiseSessions([staleOff]); + + var masked = Assert.Single(snapshots[^1].Threads); + Assert.Null(masked.ThinkingLevel); + Assert.Equal("gpt-5.5", masked.Model); + Assert.Equal("off", staleOff.ThinkingLevel); + + var ordinaryNull = staleOff.Clone(); + ordinaryNull.ThinkingLevel = null; + ordinaryNull.Model = "gpt-5.6"; + bridge.RaiseSessions([ordinaryNull]); + Assert.Null(Assert.Single(snapshots[^1].Threads).ThinkingLevel); + + var concrete = ordinaryNull.Clone(); + concrete.ThinkingLevel = "high"; + concrete.Model = "gpt-5.7"; + bridge.RaiseSessions([concrete]); + + var accepted = Assert.Single(snapshots[^1].Threads); + Assert.Equal("high", accepted.ThinkingLevel); + Assert.Equal("gpt-5.7", accepted.Model); + } + + [Fact] + public async Task Status_AuthorityTransitionClearsConfirmedNullLatch() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + await provider.ClearThinkingLevelAsync("main"); + snapshots.Clear(); + + bridge.RaiseStatus(ConnectionStatus.Disconnected); + bridge.RaiseStatus(ConnectionStatus.Connected); + var nextGeneration = session.Clone(); + nextGeneration.Model = "next-generation"; + bridge.RaiseSessions([nextGeneration]); + + var thread = Assert.Single(snapshots[^1].Threads); + Assert.Equal("off", thread.ThinkingLevel); + Assert.Equal("next-generation", thread.Model); + } + + [Fact] + public async Task DisposeAsync_ConfirmedNullLatchDoesNotLeakToNewProvider() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridgeA, providerA, snapshotsA, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + bridgeA.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await providerA.LoadAsync(); + bridgeA.RaiseStatus(ConnectionStatus.Connected); + await providerA.ClearThinkingLevelAsync("main"); + Assert.Null(Assert.Single(snapshotsA[^1].Threads).ThinkingLevel); + + await providerA.DisposeAsync(); + + var (_, providerB, _, _) = CreateProvider([session.Clone()]); + var freshSnapshot = await providerB.LoadAsync(); + Assert.Equal("off", Assert.Single(freshSnapshot.Threads).ThinkingLevel); + await providerB.DisposeAsync(); + } + + [Fact] + public async Task CorrelatedSnapshot_IsAuthoritativeOnlyForTargetConfirmedNullLatch() + { + var sessionA = MainSession(); + sessionA.Key = "a"; + sessionA.ThinkingLevel = "off"; + var sessionB = MainSession(); + sessionB.Key = "b"; + sessionB.IsMain = false; + sessionB.DisplayName = "B"; + sessionB.ThinkingLevel = "off"; + var (bridge, provider, snapshots, _) = CreateProvider([sessionA, sessionB]); + var delayedA = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var requestCall = 0; + bridge.RequestSessionsSnapshotBehavior = _ => + { + return Interlocked.Increment(ref requestCall) switch + { + 1 => delayedA.Task, + 2 => Task.FromResult( + [ + sessionA.Clone(), + new SessionInfo + { + Key = "b", + DisplayName = "B", + Status = "active", + ThinkingLevel = null, + }, + ]), + _ => Task.FromResult( + [ + new SessionInfo + { + Key = "a", + IsMain = true, + DisplayName = "A", + Status = "active", + ThinkingLevel = null, + }, + new SessionInfo + { + Key = "b", + DisplayName = "B", + Status = "active", + ThinkingLevel = null, + }, + ]), + }; + }; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + var clearA = provider.ClearThinkingLevelAsync("a"); + await WaitForConditionAsync(() => bridge.RequestSessionsSnapshotCallCount == 1); + await provider.ClearThinkingLevelAsync("b"); + Assert.Null(Assert.Single(snapshots[^1].Threads, thread => thread.Id == "b").ThinkingLevel); + + var nonTargetStaleB = sessionB.Clone(); + nonTargetStaleB.Model = "fresh-b-model"; + var confirmedA = sessionA.Clone(); + confirmedA.ThinkingLevel = null; + delayedA.SetResult([confirmedA, nonTargetStaleB]); + await clearA; + + var afterDelayedA = snapshots[^1]; + Assert.Null(Assert.Single(afterDelayedA.Threads, thread => thread.Id == "a").ThinkingLevel); + var maskedB = Assert.Single(afterDelayedA.Threads, thread => thread.Id == "b"); + Assert.Null(maskedB.ThinkingLevel); + Assert.Equal("fresh-b-model", maskedB.Model); + Assert.Equal("off", nonTargetStaleB.ThinkingLevel); + + await provider.ClearThinkingLevelAsync("a"); + var ordinaryStaleB = sessionB.Clone(); + ordinaryStaleB.Model = "ordinary-b-model"; + var ordinaryA = sessionA.Clone(); + ordinaryA.ThinkingLevel = null; + bridge.RaiseSessions([ordinaryA, ordinaryStaleB]); + + var afterNonTargetNull = snapshots[^1]; + var stillMaskedB = Assert.Single(afterNonTargetNull.Threads, thread => thread.Id == "b"); + Assert.Null(stillMaskedB.ThinkingLevel); + Assert.Equal("ordinary-b-model", stillMaskedB.Model); + } + + [Fact] + public async Task SetThinkingLevelAsync_SuccessRetiresLatchButFailureRetainsNullAuthority() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + await provider.ClearThinkingLevelAsync("main"); + snapshots.Clear(); + + await provider.SetThinkingLevelAsync("main", "high"); + var canonicalHigh = session.Clone(); + canonicalHigh.ThinkingLevel = "high"; + bridge.RaiseSessions([canonicalHigh]); + Assert.Equal("high", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + + await provider.ClearThinkingLevelAsync("main"); + bridge.PatchSessionThinkingLevelBehavior = (_, _) => + Task.FromException(new InvalidOperationException("concrete patch rejected")); + await Assert.ThrowsAsync( + () => provider.SetThinkingLevelAsync("main", "high")); + + var staleAfterFailure = session.Clone(); + staleAfterFailure.Model = "fresh-model"; + bridge.RaiseSessions([staleAfterFailure]); + var protectedThread = Assert.Single(snapshots[^1].Threads); + Assert.Null(protectedThread.ThinkingLevel); + Assert.Equal("fresh-model", protectedThread.Model); + Assert.Equal("off", staleAfterFailure.ThinkingLevel); + } + + [Fact] + public async Task SetThinkingLevelAsync_OlderConcreteCannotRetireNewerConfirmedNullAuthority() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + await provider.ClearThinkingLevelAsync("main"); + snapshots.Clear(); + + var concreteCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.PatchSessionThinkingLevelBehavior = (_, _) => concreteCompletion.Task; + var olderConcrete = provider.SetThinkingLevelAsync("main", "high"); + await WaitForConditionAsync(() => bridge.PatchedThinkingLevels.Count == 1); + + await provider.ClearThinkingLevelAsync("main"); + concreteCompletion.SetResult(); + await olderConcrete; + + var staleOff = session.Clone(); + staleOff.Model = "newer-clear-model"; + bridge.RaiseSessions([staleOff]); + var protectedThread = Assert.Single(snapshots[^1].Threads); + Assert.Null(protectedThread.ThinkingLevel); + Assert.Equal("newer-clear-model", protectedThread.Model); + Assert.Equal("off", staleOff.ThinkingLevel); + } + + [Fact] + public async Task SetThinkingLevelAsync_FailedConcreteCannotProjectOverConfirmedNullAuthority() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + await provider.ClearThinkingLevelAsync("main"); + snapshots.Clear(); + + var clearCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var concreteCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var refreshCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.ClearSessionThinkingLevelBehavior = (_, _) => clearCompletion.Task; + bridge.PatchSessionThinkingLevelBehavior = (_, _) => concreteCompletion.Task; + bridge.RequestSessionsSnapshotBehavior = _ => refreshCompletion.Task; + + var supersededClear = provider.ClearThinkingLevelAsync("main"); + var failedConcrete = provider.SetThinkingLevelAsync("main", "high"); + clearCompletion.SetResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + Key = "main", + Error = "superseded clear", + }); + await Assert.ThrowsAnyAsync(() => supersededClear); + + var staleOff = session.Clone(); + staleOff.Model = "fresh-while-concrete-pending"; + bridge.RaiseSessions([staleOff]); + var whilePending = Assert.Single(snapshots[^1].Threads); + Assert.Null(whilePending.ThinkingLevel); + Assert.Equal("fresh-while-concrete-pending", whilePending.Model); + + concreteCompletion.SetException(new InvalidOperationException("concrete rejected")); + await Assert.ThrowsAsync(() => failedConcrete); + + var afterFailure = Assert.Single(snapshots[^1].Threads); + Assert.Null(afterFailure.ThinkingLevel); + Assert.Equal("fresh-while-concrete-pending", afterFailure.Model); + Assert.Equal("off", staleOff.ThinkingLevel); + refreshCompletion.SetResult([confirmed]); + } + + [Fact] + public async Task SessionsUpdated_RowDisappearanceRemovesConfirmedNullLatch() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, snapshots, _) = CreateProvider([session]); + var confirmed = session.Clone(); + confirmed.ThinkingLevel = null; + bridge.RequestSessionsSnapshotBehavior = _ => + Task.FromResult([confirmed]); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + await provider.ClearThinkingLevelAsync("main"); + snapshots.Clear(); + + bridge.RaiseSessions([]); + bridge.RaiseSessions([session.Clone()]); + + Assert.Equal("off", Assert.Single(snapshots[^1].Threads).ThinkingLevel); + } + + [Fact] + public async Task ClearThinkingLevelAsync_DirectRejectionConfirmationFaultIsObserved() + { + const string rejectionMarker = "direct rejection marker"; + var matchingUnobserved = new ConcurrentQueue(); + EventHandler handler = (_, args) => + { + foreach (var exception in args.Exception.Flatten().InnerExceptions) + { + if (exception.Message.Contains(rejectionMarker, StringComparison.Ordinal)) + matchingUnobserved.Enqueue(exception); + } + args.SetObserved(); + }; + TaskScheduler.UnobservedTaskException += handler; + + try + { + var providerReference = CreateAbandonedDirectRejectionProvider(rejectionMarker); + await AssertEventuallyCollectedAsync(providerReference); + Assert.Empty(matchingUnobserved); + } + finally + { + TaskScheduler.UnobservedTaskException -= handler; + } + } + + [Fact] + public async Task ClearThinkingLevelAsync_PostAckDisconnectConfirmationFaultIsObserved() + { + var matchingUnobserved = new ConcurrentQueue(); + EventHandler handler = (_, args) => + { + foreach (var exception in args.Exception.Flatten().InnerExceptions) + { + if (exception is ThinkingLevelClearInterruptedException) + matchingUnobserved.Enqueue(exception); + } + args.SetObserved(); + }; + TaskScheduler.UnobservedTaskException += handler; + + try + { + var providerReference = CreateAbandonedPostAckDisconnectProvider(); + await AssertEventuallyCollectedAsync(providerReference); + Assert.Empty(matchingUnobserved); + } + finally + { + TaskScheduler.UnobservedTaskException -= handler; + } + } + [Fact] public async Task SendMessageAsync_WhenIdle_RendersTranscriptEntryBeforeAwaitingGateway() { @@ -9010,6 +10119,90 @@ public async Task ApprovalRequested_DedupesSlugFirstUuidTwin_AndUuidOnlyResolved Assert.Null(snapshots[^1].Timelines["main"].PendingPermission); } + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateAbandonedDirectRejectionProvider(string rejectionMarker) + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, _, _) = CreateProvider([session]); + bridge.ClearSessionThinkingLevelBehavior = (key, _) => + Task.FromResult(new SessionCommandResult + { + Method = "sessions.patch", + Ok = false, + Key = key, + Error = rejectionMarker, + }); + provider.LoadAsync().GetAwaiter().GetResult(); + bridge.RaiseStatus(ConnectionStatus.Connected); + + var exception = Assert.Throws( + () => provider.ClearThinkingLevelAsync("main").GetAwaiter().GetResult()); + Assert.Contains(rejectionMarker, exception.Message, StringComparison.Ordinal); + var providerReference = new WeakReference(provider); + provider.DisposeAsync().AsTask().GetAwaiter().GetResult(); + return providerReference; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateAbandonedPostAckDisconnectProvider() + { + var session = MainSession(); + session.ThinkingLevel = "off"; + var (bridge, provider, _, _) = CreateProvider( + [session], + thinkingLevelConfirmationTimeout: TimeSpan.FromMilliseconds(20)); + var requestStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var requestCanceled = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bridge.RequestSessionsSnapshotBehavior = async cancellationToken => + { + requestStarted.TrySetResult(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return null; + } + finally + { + if (cancellationToken.IsCancellationRequested) + requestCanceled.TrySetResult(); + } + }; + provider.LoadAsync().GetAwaiter().GetResult(); + bridge.RaiseStatus(ConnectionStatus.Connected); + + provider.ClearThinkingLevelAsync("main").GetAwaiter().GetResult(); + requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + bridge.RaiseStatus(ConnectionStatus.Disconnected); + requestCanceled.Task.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + var providerReference = new WeakReference(provider); + provider.DisposeAsync().AsTask().GetAwaiter().GetResult(); + return providerReference; + } + + private static async Task AssertEventuallyCollectedAsync(WeakReference reference) + { + for (var attempt = 0; attempt < 20 && reference.IsAlive; attempt++) + { + GC.Collect( + GC.MaxGeneration, + GCCollectionMode.Forced, + blocking: true, + compacting: true); + GC.WaitForPendingFinalizers(); + GC.Collect( + GC.MaxGeneration, + GCCollectionMode.Forced, + blocking: true, + compacting: true); + await Task.Delay(10); + } + + Assert.False(reference.IsAlive, "Expected the abandoned provider to be collected."); + } + private static async Task WaitForConditionAsync(Func condition, int attempts = 50) { for (var i = 0; i < attempts && !condition(); i++) diff --git a/tests/OpenClaw.Tray.Tests/ThinkingLevelClearReconcilerArchitectureTests.cs b/tests/OpenClaw.Tray.Tests/ThinkingLevelClearReconcilerArchitectureTests.cs index 0e2971fc3..c54680070 100644 --- a/tests/OpenClaw.Tray.Tests/ThinkingLevelClearReconcilerArchitectureTests.cs +++ b/tests/OpenClaw.Tray.Tests/ThinkingLevelClearReconcilerArchitectureTests.cs @@ -19,4 +19,34 @@ public void Reconciler_DefinesUiFreeProviderFacingSeam() Assert.DoesNotContain("Microsoft.UI.Xaml", reconciler); Assert.DoesNotContain("OpenClawGatewayClient", reconciler); } + + [Fact] + public void Provider_DelegatesThinkingLevelClearReconciliationState() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var provider = File.ReadAllText(Path.Combine( + root, + "src", + "OpenClaw.Tray.WinUI", + "Chat", + "OpenClawChatDataProvider.cs")); + + Assert.Contains( + "private readonly ThinkingLevelClearReconciler _thinkingLevelClearReconciler;", + provider); + Assert.Contains( + "_thinkingLevelClearReconciler = new ThinkingLevelClearReconciler(", + provider); + Assert.Contains("_thinkingLevelClearReconciler.BeginClear(", provider); + Assert.Contains("_thinkingLevelClearReconciler.BeginConcreteSelection(", provider); + Assert.Contains("_thinkingLevelClearReconciler.ApplyCorrelatedSnapshot(", provider); + Assert.Contains(".OnConnectionChanged(status == ConnectionStatus.Connected)", provider); + Assert.Contains("_thinkingLevelClearReconciler.Dispose();", provider); + + Assert.DoesNotContain("PendingThinkingLevelClear", provider); + Assert.DoesNotContain("ThinkingLevelReconciliation", provider); + Assert.DoesNotContain("_thinkingLevelClearVersions", provider); + Assert.DoesNotContain("MaxThinkingLevelRefreshAttempts", provider); + Assert.DoesNotContain("ScheduleThinkingLevelRetryAsync", provider); + } } diff --git a/tests/OpenClaw.Tray.Tests/ThinkingLevelDefaultContractTests.cs b/tests/OpenClaw.Tray.Tests/ThinkingLevelDefaultContractTests.cs new file mode 100644 index 000000000..7f9a60d70 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ThinkingLevelDefaultContractTests.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; + +namespace OpenClaw.Tray.Tests; + +public sealed class ThinkingLevelDefaultContractTests +{ + [Fact] + public void LegacyAndReactorComposers_SeparateDefaultFromConcreteThinkingLevels() + { + var legacy = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawComposer.cs"); + var reactor = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawReactorChatRoot.cs"); + + Assert.Contains("Action OnThinkingLevelCleared", legacy); + Assert.Contains("Props.OnThinkingLevelCleared", legacy); + Assert.Contains("Chat_Composer_Reasoning_Default", legacy); + Assert.Contains("Props.CurrentThinkingLevel is null", legacy); + Assert.Contains("ToggleMenuItem(", legacy); + Assert.DoesNotContain("medium (default)", legacy, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("CurrentThinkingLevel ?? \"medium\"", legacy); + + Assert.Contains("Action OnThinkingLevelCleared", reactor); + Assert.Contains("props.OnThinkingLevelCleared", reactor); + Assert.Contains("Chat_Composer_Reasoning_Default", reactor); + Assert.Contains("props.CurrentThread.ThinkingLevel is null", reactor); + Assert.Contains("RadioMenuItem(", reactor); + Assert.DoesNotContain("ThinkingLevel ?? \"medium\"", reactor); + Assert.Matches( + new Regex(@"Accessibility_Reasoning.*reasoningPickerLabel", RegexOptions.Singleline), + reactor); + } + + [Fact] + public void ChatRoots_UseTypedClearCallbackWithoutStringSentinel() + { + var legacyRoot = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatRoot.cs"); + var reactorRoot = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawReactorChatRoot.cs"); + var provider = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatDataProvider.cs"); + var bridge = Read("src", "OpenClaw.Tray.WinUI", "Chat", "IChatGatewayBridge.cs"); + + Assert.Contains("OnThinkingLevelCleared:", legacyRoot); + Assert.Contains("ClearThinkingLevelAsync", legacyRoot); + Assert.Contains("ClearThinkingLevelAsync", reactorRoot); + Assert.Contains( + "ObserveFireAndForget(_provider.SetThinkingLevelAsync", + legacyRoot); + Assert.Contains( + "ObserveFireAndForget(_provider.ClearThinkingLevelAsync", + legacyRoot); + Assert.Contains( + "ObserveFireAndForget(props.Provider.SetThinkingLevelAsync", + reactorRoot); + Assert.Contains( + "ObserveFireAndForget(props.Provider.ClearThinkingLevelAsync", + reactorRoot); + Assert.Contains("ClearSessionThinkingLevelAsync", provider); + Assert.Contains("ThinkingLevel = SessionPatch.Clear", bridge); + Assert.DoesNotContain("ThinkingLevel = \"default\"", bridge); + Assert.DoesNotContain("ThinkingLevel = \"medium\"", bridge); + } + + [Fact] + public void ThinkingClearErrors_AreLocalizedInEverySupportedLocale() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var stringsRoot = Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Strings"); + var locales = Directory.GetDirectories(stringsRoot); + Assert.NotEmpty(locales); + + foreach (var locale in locales) + { + var resources = File.ReadAllText(Path.Combine(locale, "Resources.resw")); + Assert.Contains("Chat_Composer_Reasoning_Default", resources); + Assert.Contains("Chat_Notification_ClearThinkingFailed", resources); + Assert.Contains("Chat_Error_ClearThinkingFailedFormat", resources); + Assert.Contains("Chat_Error_ClearThinkingCanceled", resources); + Assert.Contains("Chat_Error_ClearThinkingInterrupted", resources); + } + } + + private static string Read(params string[] segments) => + File.ReadAllText(Path.Combine([TestRepositoryPaths.GetRepositoryRoot(), .. segments])); +}