Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 | - |
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ Task SendMessageAsync(string threadId, string message, CancellationToken cancell
/// </summary>
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) =>
Expand Down
19 changes: 19 additions & 0 deletions src/OpenClaw.Chat/ThinkingLevelClearReconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/// <summary>
/// Advances connection authority. A reconnect or client swap invalidates all
/// earlier refresh requests and requests current-generation convergence for
Expand Down
17 changes: 17 additions & 0 deletions src/OpenClaw.Shared/IOperatorGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,23 @@ Task<CommandCatalog> ListCommandsAsync(CommandCatalogQuery? query = null, int ti
/// <summary>Apply an extended <see cref="SessionPatch"/> (rich field set) to a session.</summary>
Task<bool> PatchSessionAsync(string key, SessionPatch patch)
=> Task.FromResult(false);
/// <summary>
/// Response-aware variant of <see cref="PatchSessionAsync(string, SessionPatch)"/>.
/// Returns the gateway's terminal result instead of completing after the frame is sent.
/// </summary>
Task<SessionCommandResult> 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."
});
/// <summary>List session files, optionally scoped to a sub-path/search (<c>sessions.files.list</c>).</summary>
Task<SessionFileList> ListSessionFilesAsync(string key, string? path = null, string? search = null, int timeoutMs = 15000)
=> Task.FromResult(new SessionFileList { Key = key, IsSupported = false });
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
90 changes: 90 additions & 0 deletions src/OpenClaw.Shared/OpenClawGatewayClient.Protocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -473,6 +474,95 @@ public Task<bool> PatchSessionAsync(string key, SessionPatch patch)
return TrySendTrackedRequestAsync("sessions.patch", patch.ToPayload(key));
}

/// <summary>
/// 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.
/// </summary>
public async Task<SessionCommandResult> 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 ──

/// <summary>
Expand Down
57 changes: 55 additions & 2 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public async Task<JsonElement> SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000)
public Task<JsonElement> SendWizardRequestAsync(
string method,
object? parameters = null,
int timeoutMs = 30000) =>
SendWizardRequestAsync(method, parameters, timeoutMs, CancellationToken.None);

private async Task<JsonElement> SendWizardRequestAsync(
string method,
object? parameters,
int timeoutMs,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsConnected)
throw new InvalidOperationException("Gateway connection is not open");

Expand All @@ -690,7 +701,9 @@ public async Task<JsonElement> 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)
{
Expand All @@ -712,6 +725,46 @@ public async Task RequestSessionsAsync(string? agentId = null)
await SendTrackedRequestAsync("sessions.list");
}

/// <summary>
/// Requests and returns the session snapshot from the matching
/// <c>sessions.list</c> response.
/// </summary>
public async Task<SessionInfo[]> 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<SessionInfo[]>(
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);
}
}

/// <summary>Subscribe to session change events so the gateway pushes
/// <c>sessions.changed</c> notifications when sessions are mutated.</summary>
public async Task SubscribeSessionEventsAsync()
Expand Down
Loading