diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 35d024261..a33851216 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -67,13 +67,26 @@ These are the canonical homes. Do not reintroduce private copies elsewhere.
| Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned |
| Gateway connect envelope | `ConnectEnvelopeBuilder` (planned) | planned |
| Gateway request tracking | `PendingRequestRegistry` (planned) | planned |
+| Chat atomic runtime transaction lock and cross-domain commits | `ChatConversationState` | authoritative |
+| Chat queue collections, echo correlation, drain and retry commit mechanics | `ChatQueueState` under the `ChatConversationState` lock | authoritative |
+| Chat reset generations, gates, echoes and backfill state | `ChatResetState` under the `ChatConversationState` lock | authoritative |
+| Chat history identity, revisions and connection-generation tokens | `ChatHistoryState` under the `ChatConversationState` lock | authoritative |
+| Chat sessions, models, catalog and snapshot projection inputs | `ChatPresentationState` under the `ChatConversationState` lock | authoritative |
+| Chat run, abort and terminal lifecycle state | `ChatLifecycleState` under the `ChatConversationState` lock | authoritative |
+| Chat approval identity correlation and dedupe state | `ChatApprovalState` under the `ChatConversationState` lock | authoritative |
+| Chat send admission/retry decision policy | `ChatSendQueuePolicy` with atomic commits coordinated by `ChatConversationState` | authoritative |
+| Chat history request/retry/rebuild mechanics | `ChatHistoryLoader` with token acceptance coordinated by `ChatConversationState` | authoritative |
+| Gateway agent event to chat event mapping | `ChatEventMapper` | authoritative |
+| Chat snapshot projection | `ChatSnapshotProjector` | authoritative |
+| Tool and attachment metadata cache lifecycle | `ChatMetadataStore` | authoritative |
+| Aborted IDs and last-chat-state persistence | `ChatStatePersistence` | authoritative |
## When you touch file X, extract toward Y
| 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`; pure native tool projection stays in `NativeToolProjector` |
+| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | Keep as the `IChatDataProvider` facade; atomic runtime coordination → `ChatConversationState`, lock-internal state mechanics → its queue/reset/history/presentation/lifecycle/approval substates, queue decisions → `ChatSendQueuePolicy`, history IO → `ChatHistoryLoader`, mapping → `ChatEventMapper`, native tool projection → `NativeToolProjector`, snapshots → `ChatSnapshotProjector`, metadata → `ChatMetadataStore`, persistence → `ChatStatePersistence` |
| `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 |
@@ -126,9 +139,23 @@ leading and trailing pipe. Columns, in order:
| app-window-manager | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | window creation/show/hide/shutdown | IWindowManager | composition/delegation only | startup/shutdown ordering deterministic; disposed once | none | review-only | extracted in Phase 3 |
| app-tray-controller | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon/menu/action routing | ITrayController | composition/delegation only | tray actions route unchanged | none | review-only | extracted in Phase 3 |
| app-activation-router | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | deep-link/toast/single-instance activation | IActivationRouter | composition/delegation only | activation routes land on the same UI/actions; current-user pipe security preserved | none | review-only | extracted in Phase 3 |
-| native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | provider calls the projector while retaining stateful live/history application and metadata cache behavior | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - |
-| provider-native-tool-projection-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private static copies of native tool identity, display argument, payload, and flattened-history projection | NativeToolProjector | provider owns run/session/legacy-generation correlation, metadata cache persistence/upsert/migration/matching, active run IDs, and timeline state | provider does not regain pure native tool projection or duplicate NativeToolProjector compatibility wrappers | review-only: the provider retains stateful orchestration and calls the focused projector directly | review-only | when OpenClawChatDataProvider no longer applies native tool events or history |
-| 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 |
+| native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | ChatEventMapper and ChatHistoryLoader call the projector; ChatConversationState supplies scoped correlation plans and ChatMetadataStore owns persistence | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - |
+| provider-native-tool-projection-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private static copies of native tool identity, display argument, payload, flattened-history projection, and scoped metadata upsert | NativeToolProjector + ChatEventMapper + ChatHistoryLoader + ChatConversationState + ChatMetadataStore | provider forwards typed tool metadata writes while retaining bridge IO, telemetry, and event publication only | provider does not regain native tool JSON projection, identity policy, timeline correlation, or metadata persistence | review-only: pure projection, atomic correlation, and persistence are delegated to focused owners while the provider remains the IO facade | review-only | when OpenClawChatDataProvider is retired |
+| chat-conversation-state | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | provider-owned runtime gate and cross-domain state transactions | ChatConversationState | sole lock, timeline and entry metadata, connection/disposal flags, and typed orchestration across lock-free substates; provider supplies bridge context and coordinates IO, telemetry, and events | one authoritative lock atomically commits reset, reconnect, dispose, queue, history, and event transitions without duplicate shared versions | ChatRuntimeOwnershipContractTests.Root_CoordinatesCrossDomainCommitsUnderSoleGate | source-shape | when the chat runtime is replaced by a different atomic transaction boundary |
+| chat-provider-state-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private runtime gate and mutable conversation/queue/reset/history collections | ChatConversationState | bridge subscription, telemetry, public API/events, composition, persistence coordination, and static image preview compatibility | provider cannot regain a private state lock or duplicate runtime collections | ChatRuntimeOwnershipContractTests.Provider_DelegatesRuntimeStateWithoutPrivateGate | source-shape | when OpenClawChatDataProvider is retired |
+| chat-send-queue | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs and monolithic ChatConversationState | send admission, next-drain eligibility, local echo and run correlation, deferred-admission classification/backoff, retry decisions, and queue commit mechanics | ChatSendQueuePolicy + ChatQueueState | ChatConversationState coordinates queue commits with timeline, reset, and lifecycle state; provider executes typed bridge dispatch plans and records telemetry | queue collections and mechanics live in the lock-free substate under the sole conversation lock while pure policy decisions remain separately testable | ChatRuntimeOwnershipContractTests.RuntimeSubstates_AreLockFreeAndVersionOwnershipIsUnique | source-shape | when queue state and decision policy are replaced without a lock-internal substate |
+| chat-reset-state | authoritative | monolithic ChatConversationState | reset versions and cutoffs, accepted and ignored runs, submitted echoes, no-run send proof, buffered starts, and remote-backfill gates | ChatResetState | ChatConversationState supplies queue and lifecycle facts and atomically applies returned typed lifecycle transitions | reset mechanics have no private lock or duplicate version and are invoked only under the conversation lock | ChatRuntimeOwnershipContractTests.RuntimeSubstates_AreLockFreeAndVersionOwnershipIsUnique | source-shape | when reset gating is replaced without a lock-internal substate |
+| chat-history-state | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs and monolithic ChatConversationState | session identity, loaded/revision state, reset-cleared identity, connection generation, activation barrier, commit-token validation, and transcript merge reconciliation | ChatHistoryState | ChatConversationState supplies reset/status/disposal facts and atomically coordinates timeline commit; no history IO lives in the substate | one authoritative connection generation and reset-aware commit token accepts or drops history under the sole conversation lock | ChatConversationStateTests.HistoryGeneration_WaitsForLoaderActivation | behavioral | - |
+| chat-history-loader | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | chat.history fetch lifetime, in-flight ownership, generation cancellation, retry budget/scheduling, ordered transcript rebuild plans, and stale-result delivery filtering | ChatHistoryLoader | ChatHistoryState owns authoritative identity and generation tokens; ChatConversationState coordinates commit acceptance; provider publishes typed completion results and notifications | stale connection/reset responses cannot commit, deliver, clear a newer in-flight owner, or carry retry work/budget across generations; authoritative reload coalescing remains generation-safe | OpenClawChatDataProviderTests.LoadHistoryAsync_DelayedRetryDoesNotCrossResetGeneration | behavioral | - |
+| chat-checkpoint-history-replacement | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | checkpoint-restore timeline clearing, replacement generation fencing, and replacement-over-authoritative reload priority | ChatHistoryState + ChatHistoryLoader coordinated by ChatConversationState | provider starts the typed replacement transition, publishes its immutable snapshot, and delegates gateway IO | replacement atomically clears timeline metadata, invalidates stale results, preserves post-restore live entries, and suppresses stale retries and notifications | ChatConversationStateTests.HistoryReplacement_ClearsTimelineAndAdvancesOwnedTokenAtomically | behavioral | - |
+| chat-presentation-state | authoritative | monolithic ChatConversationState | sessions, usage, models, choices, command catalog/fetch epoch, pending model patches, keyless diagnostics, remembered last state, and immutable projection inputs | ChatPresentationState | ChatConversationState supplies timeline/queue/reset/history snapshots and coordinates session identity and usage timeline updates | presentation mechanics have no private lock, IO, or mutable collection exposure and snapshot values remain byte-for-byte compatible | OpenClawChatDataProviderTests.RuntimeGolden_PublicSnapshotPreservesCrossDomainState | golden | - |
+| chat-lifecycle-state | authoritative | monolithic ChatConversationState | active run IDs/start sequences, pending aborts, aborted runs/threads, terminal-run dedupe, and lifecycle sequence | ChatLifecycleState | ChatConversationState coordinates lifecycle changes with reset gates, queue state, and timeline reducer events | lifecycle mechanics have no private lock and reset/reconnect/dispose remain root-coordinated atomic transitions | ChatRuntimeOwnershipContractTests.Root_CoordinatesCrossDomainCommitsUnderSoleGate | source-shape | when run lifecycle is replaced without a lock-internal substate |
+| chat-approval-state | authoritative | monolithic ChatConversationState | bounded seen-approval identity order/set and alternate-ID correlation | ChatApprovalState | ChatConversationState coordinates approval identity with permission timeline transitions; ChatEventMapper remains the pure payload mapper | approval identity mechanics have no private lock, IO, or timeline callbacks | ChatRuntimeOwnershipContractTests.RuntimeSubstates_AreLockFreeAndVersionOwnershipIsUnique | source-shape | when approval correlation is replaced without a lock-internal substate |
+| chat-event-mapper | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure agent-stream payload to ChatEvent/content mapping and terminal approval decision classification | ChatEventMapper | provider retains stateful approval dedupe and telemetry orchestration through ChatConversationState | tool, reasoning, lifecycle, command-output, job, and permission payloads map without provider-owned JSON mapping branches | ChatEventMapperTests.Map_ApprovalRequestPreservesIdentityAndActions | behavioral | - |
+| chat-snapshot-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | thread/compose-target/status/model/catalog/timeline-generation/history-revision/queued-message snapshot projection including flattened gateway session classification | ChatSnapshotProjector | provider supplies bridge handshake context; ChatConversationState captures immutable projection input; SessionDisplayResolver owns flattened session display mapping | public snapshots preserve defensive dictionary copies, raw session keys, flattened agent/background classification, compose readiness, synthetic pending thread behavior, model order, and render identity generations | OpenClawChatDataProviderTests.RuntimeGolden_PublicSnapshotPreservesCrossDomainState | golden | - |
+| chat-content-formatting | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | content text formatting, truncation, content-block seam repair, and trace hashing | ChatContentFormatting | thin provider forwarders (TruncateForChatEntry, LooksLikeSystemControlNote, RepairContentBlockSeams, TruncateChatEvent) kept for existing test call sites; system-note, native tool, and flattened-history projection belongs to NativeToolProjector | content truncation and seam repair output is preserved byte-for-byte while tool identity/classification has one canonical owner | ContentBlockSeamRepairTests.RepairsKnownSeams | behavioral | when provider forwarders are removed and callers use focused owners directly |
+| chat-metadata-store | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | live tool/attachment metadata dictionaries, scoped native-tool identity upsert, save locks/timers/versions, atomic JSON persistence, session eviction, and attachment-marker build/escape/rehydration | ChatMetadataStore | ChatConversationState supplies a typed session/reset/correlation write plan; provider retains the public static image-preview cache | metadata persistence, identity-strength upgrade, normalization, bounded eviction, marker security, and generation-aware idempotent reset eviction are owned under the metadata lock without raw attachment bytes on disk | ToolMetaCacheTests.CacheToolMeta_SameToolCallId_UpgradesSpecificIdentityWithoutDuplicate | behavioral | - |
+| chat-state-persistence | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | persisted aborted-message IDs plus last-chat-state debounce/version/atomic save lifecycle | ChatStatePersistence | provider retains the nested LastChatState compatibility type and owns bridge history fetch orchestration | corrupted state fails closed, reset removes aborted IDs, stale reset generations cannot persist, and selected/snapshot state writes remain atomic | ChatStatePersistenceTests.ResetFence_RejectsStaleAbortedIds | behavioral | - |
| gateway-pending-requests | planned | src/OpenClaw.Shared/OpenClawGatewayClient.cs | request-id -> method/completion tracking | PendingRequestRegistry | - | request ids never leak after disconnect; thread-safe | none | review-only | extracted in Phase 4 |
| 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 | - |
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatApprovalState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatApprovalState.cs
new file mode 100644
index 000000000..9152a5aaa
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatApprovalState.cs
@@ -0,0 +1,114 @@
+namespace OpenClawTray.Chat;
+
+///
+/// Owns bounded approval-request identity deduplication and alternate-ID
+/// correlation. The conversation-state root serializes every call.
+///
+internal sealed class ChatApprovalState
+{
+ private const int SeenCapacity = 128;
+
+ private readonly LinkedList _seenOrder = new();
+ private readonly HashSet _seen = new(StringComparer.Ordinal);
+ private readonly Dictionary _alternateIds =
+ new(StringComparer.Ordinal);
+
+ internal bool MarkSeen(string requestId, string? alternateId)
+ {
+ if (string.IsNullOrEmpty(requestId))
+ return true;
+
+ RecordAlternateId(requestId, alternateId);
+ if (IsSeen(requestId) ||
+ IsDistinct(requestId, alternateId) && IsSeen(alternateId!))
+ {
+ return false;
+ }
+
+ AddSeen(requestId);
+ if (IsDistinct(requestId, alternateId))
+ AddSeen(alternateId!);
+
+ while (_seenOrder.Count > SeenCapacity)
+ {
+ var oldest = _seenOrder.First!.Value;
+ _seenOrder.RemoveFirst();
+ Evict(oldest);
+ }
+
+ return true;
+ }
+
+ internal bool Matches(
+ string pendingId,
+ string primaryId,
+ string alternateId)
+ {
+ if (string.IsNullOrEmpty(pendingId))
+ return false;
+
+ _alternateIds.TryGetValue(pendingId, out var pendingAlternate);
+ return MatchesOne(primaryId) || MatchesOne(alternateId);
+
+ bool MatchesOne(string value) =>
+ !string.IsNullOrEmpty(value) &&
+ (string.Equals(value, pendingId, StringComparison.Ordinal) ||
+ !string.IsNullOrEmpty(pendingAlternate) &&
+ string.Equals(value, pendingAlternate, StringComparison.Ordinal));
+ }
+
+ internal void Reset()
+ {
+ _seen.Clear();
+ _seenOrder.Clear();
+ _alternateIds.Clear();
+ }
+
+ private void AddSeen(string approvalId)
+ {
+ if (_seen.Add(approvalId))
+ _seenOrder.AddLast(approvalId);
+ }
+
+ private bool IsSeen(string approvalId) =>
+ _seen.Contains(approvalId) ||
+ _alternateIds.TryGetValue(approvalId, out var alternateId) &&
+ _seen.Contains(alternateId);
+
+ private void RecordAlternateId(string requestId, string? alternateId)
+ {
+ if (!IsDistinct(requestId, alternateId))
+ return;
+ _alternateIds[requestId] = alternateId!;
+ _alternateIds[alternateId!] = requestId;
+ }
+
+ private void Evict(string approvalId)
+ {
+ _seen.Remove(approvalId);
+ if (!_alternateIds.TryGetValue(approvalId, out var alternateId))
+ return;
+
+ _alternateIds.Remove(approvalId);
+ if (_alternateIds.TryGetValue(alternateId, out var reverse) &&
+ string.Equals(reverse, approvalId, StringComparison.Ordinal))
+ {
+ _alternateIds.Remove(alternateId);
+ }
+
+ if (!_seen.Remove(alternateId))
+ return;
+
+ for (var node = _seenOrder.First; node is not null; node = node.Next)
+ {
+ if (!string.Equals(node.Value, alternateId, StringComparison.Ordinal))
+ continue;
+ _seenOrder.Remove(node);
+ break;
+ }
+ }
+
+ private static bool IsDistinct(string requestId, string? alternateId) =>
+ !string.IsNullOrEmpty(alternateId) &&
+ !string.Equals(alternateId, requestId, StringComparison.Ordinal);
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatContentFormatting.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatContentFormatting.cs
new file mode 100644
index 000000000..8e700e6ad
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatContentFormatting.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Buffers;
+using OpenClaw.Chat;
+#if !OPENCLAW_TRAY_TESTS
+using OpenClawTray.Helpers;
+#endif
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal static class ChatContentFormatting
+{
+ // Keep this value in sync with OpenClawChatDataProvider.MaxEntryTextBytes for test compatibility.
+ private const int MaxEntryTextBytes = 256 * 1024;
+
+ ///
+ /// Truncate to at most
+ /// bytes when encoded as UTF-8 and
+ /// append a … [N bytes truncated] marker. Slices at a UTF-16
+ /// code-unit boundary that doesn't split a surrogate pair, then
+ /// verifies the byte budget. Returns the input unchanged when it
+ /// already fits or is null/empty.
+ ///
+ internal static string TruncateForChatEntry(string? text)
+ {
+ if (string.IsNullOrEmpty(text)) return text ?? string.Empty;
+ var enc = System.Text.Encoding.UTF8;
+ // Cheap upper bound: every char is at most 3 UTF-8 bytes for the
+ // BMP and surrogate pairs encode to 4 bytes / 2 chars (still ≤ 3
+ // bytes per char). 4 is the worst case and keeps the cheap path
+ // safe. If even the worst case fits, we're done.
+ if ((long)text.Length * 4 <= MaxEntryTextBytes) return text;
+ var actual = enc.GetByteCount(text);
+ if (actual <= MaxEntryTextBytes) return text;
+
+ // Binary search for the largest char-count whose UTF-8 byte count
+ // fits in MaxEntryTextBytes minus a generous margin for the marker.
+ var marker = string.Format(LocalizationHelper.GetString("Chat_TruncationMarkerFormat"), actual);
+ int budget = MaxEntryTextBytes - enc.GetByteCount(marker);
+ if (budget <= 0) budget = MaxEntryTextBytes / 2;
+
+ int lo = 0, hi = text.Length;
+ while (lo < hi)
+ {
+ int mid = (lo + hi + 1) / 2;
+ // Don't split a surrogate pair: nudge mid back if it lands on
+ // a low surrogate.
+ if (mid < text.Length && char.IsLowSurrogate(text[mid])) mid--;
+ if (mid <= lo)
+ {
+ hi = lo;
+ continue;
+ }
+ int bytes = enc.GetByteCount(text.AsSpan(0, mid));
+ if (bytes <= budget) lo = mid;
+ else hi = mid - 1;
+ }
+ if (lo > 0 && char.IsHighSurrogate(text[lo - 1])) lo--;
+
+ Logger.Debug($"[ChatTruncate] message {actual} bytes → {lo} chars (~{enc.GetByteCount(text.AsSpan(0, lo))} bytes); cap={MaxEntryTextBytes}");
+ return string.Concat(text.AsSpan(0, lo), marker.AsSpan());
+ }
+
+ ///
+ /// True when text is one of the approval slash-commands we send on the
+ /// user's behalf (/approve <slug> allow-once,
+ /// /approve <slug> allow-always, or
+ /// /deny <slug>). Matches the exact dashboard grammar
+ /// — not just the prefix — so legitimate user prose like
+ /// "/approve the design changes" still renders as a normal bubble.
+ ///
+ ///
+ /// Slug shape: hex-ish identifier (letters, digits, dashes, underscores;
+ /// 4–64 chars). This mirrors what the gateway emits for
+ /// ``approvalSlug``; we don't anchor on a specific length because the
+ /// gateway has changed it before.
+ ///
+ internal static bool LooksLikeApprovalSlashCommand(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return false;
+ var t = text.Trim();
+ return s_approvalSlashCommandRegex.IsMatch(t);
+ }
+
+ private static readonly System.Text.RegularExpressions.Regex s_approvalSlashCommandRegex =
+ new(@"^/(?:approve\s+[A-Za-z0-9_-]{4,64}(?:\s+(?:allow-once|allow-always))?|deny\s+[A-Za-z0-9_-]{4,64})\s*$",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static readonly System.Text.RegularExpressions.Regex s_seamBoldClose =
+ new(@"(?<=[a-z0-9])(\*\*)(?=[A-Z])",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static readonly System.Text.RegularExpressions.Regex s_seamSentencePunct =
+ new(@"(?<=[a-z0-9][.!?:])(?=[A-Z][a-z]+[\s,;:!?])",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static readonly SearchValues s_seamPunctChars = SearchValues.Create(".!?:");
+
+ ///
+ /// Re-insert paragraph breaks at gateway-glued content-block seams in
+ /// an assistant message. Safe to call on any text — short text, text
+ /// without seams, and text that is entirely fenced code all pass
+ /// through unchanged. Fenced code blocks (``` ``` ``` ```) are skipped
+ /// so JSON/code samples never get whitespace injected inside them.
+ ///
+ internal static string RepairContentBlockSeams(string? text)
+ {
+ if (string.IsNullOrEmpty(text)) return text ?? string.Empty;
+ if (text.Length < 4) return text;
+
+ // Fast path: if neither marker is present we can skip entirely.
+ if (!text.Contains("**", System.StringComparison.Ordinal) &&
+ text.AsSpan().IndexOfAny(s_seamPunctChars) < 0)
+ {
+ return text;
+ }
+
+ // Walk the string, alternating between prose and fenced-code
+ // segments. Apply seam regexes to prose only. We tolerate
+ // unclosed fences by treating everything after the dangling
+ // opener as code (matches Markdown renderer behavior).
+ var sb = new System.Text.StringBuilder(text.Length + 16);
+ int i = 0;
+ while (i < text.Length)
+ {
+ int fenceStart = text.IndexOf("```", i, System.StringComparison.Ordinal);
+ if (fenceStart < 0)
+ {
+ sb.Append(RepairProseSegment(text[i..]));
+ break;
+ }
+
+ sb.Append(RepairProseSegment(text.Substring(i, fenceStart - i)));
+
+ int fenceEnd = text.IndexOf("```", fenceStart + 3, System.StringComparison.Ordinal);
+ if (fenceEnd < 0)
+ {
+ // Unclosed fence — append the rest verbatim as code.
+ sb.Append(text, fenceStart, text.Length - fenceStart);
+ break;
+ }
+
+ // Append fenced block verbatim (including both fence markers).
+ sb.Append(text, fenceStart, fenceEnd - fenceStart + 3);
+ i = fenceEnd + 3;
+ }
+
+ return sb.ToString();
+ }
+
+ internal static string RepairProseSegment(string segment)
+ {
+ if (string.IsNullOrEmpty(segment)) return segment;
+ segment = s_seamBoldClose.Replace(segment, "$1\n\n");
+ // s_seamSentencePunct is a zero-width assertion (lookbehind +
+ // lookahead) so the replacement is a pure insert of "\n\n" at
+ // the seam — no captured punctuation to re-emit.
+ segment = s_seamSentencePunct.Replace(segment, "\n\n");
+ return segment;
+ }
+
+ // Per-process random seed for ChatTraceHash. Mixing this into the FNV
+ // initial state keeps identical-text frames colliding within a single
+ // tray run (so duplicate-bubble diagnostics still work) while making
+ // the hash useless as a content fingerprint outside this process: an
+ // attacker with the log file can no longer rebuild the hash for a
+ // guessed plaintext, and the value rotates on every tray restart.
+ private static readonly uint ChatTraceHashSeed = unchecked((uint)System.Security.Cryptography.RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue));
+
+ // Short FNV-1a-style 32-bit fold of the message text, seeded with a
+ // per-process random value. Used in trace logs to tell two near-
+ // duplicate frames apart at a glance without dumping the text itself.
+ // Not a security hash; not reproducible outside this process.
+ internal static string ChatTraceHash(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return "00000000";
+ uint h = ChatTraceHashSeed;
+ for (int i = 0; i < text.Length; i++)
+ {
+ h ^= text[i];
+ h *= 16777619u;
+ }
+ return h.ToString("x8");
+ }
+
+ ///
+ /// Apply to whichever text
+ /// payload a carries. Returns the input
+ /// unchanged when there is nothing to truncate or the text already
+ /// fits. Used by to enforce the
+ /// per-message size cap on every code path.
+ ///
+ ///
+ /// Coverage: every subtype that carries a
+ /// caller-supplied text payload is truncated here, including the
+ /// currently-unused
+ /// /
+ /// /
+ /// shapes — these don't flow through
+ /// today but covering them now
+ /// prevents a future caller from bypassing the cap when wiring
+ /// them up. The /
+ /// shapes have no untrusted
+ /// text fields and fall through unchanged.
+ ///
+ internal static ChatEvent TruncateChatEvent(ChatEvent evt) => evt switch
+ {
+ ChatUserMessageEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatThinkingEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatReasoningEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatReasoningDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatMessageEvent e => e with
+ {
+ Text = TruncateForChatEntry(e.Text),
+ ReasoningText = e.ReasoningText is null ? null : TruncateForChatEntry(e.ReasoningText)
+ },
+ ChatMessageDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatToolStartEvent e => e with
+ {
+ Text = TruncateForChatEntry(e.Text),
+ ToolName = TruncateForChatEntry(e.ToolName)
+ },
+ ChatToolOutputEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatToolErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatStatusEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatRestoredEvent e => e with { Text = TruncateForChatEntry(e.Text) },
+ ChatRawEvent e => e with { Text = e.Text is null ? null : TruncateForChatEntry(e.Text) },
+ ChatModelChangedEvent e => e with { Model = TruncateForChatEntry(e.Model) },
+ ChatIntentEvent e => e with { Intent = TruncateForChatEntry(e.Intent) },
+ ChatPermissionRequestEvent e => e with
+ {
+ PermissionKind = TruncateForChatEntry(e.PermissionKind),
+ ToolName = TruncateForChatEntry(e.ToolName),
+ Detail = TruncateForChatEntry(e.Detail)
+ },
+ _ => evt
+ };
+
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs
new file mode 100644
index 000000000..d97a26640
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs
@@ -0,0 +1,2457 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+///
+/// Single lock root for atomic conversation, queue, reset, connection, and
+/// history commit state. All exposed operations are closed domain transitions.
+///
+internal sealed class ChatConversationState
+{
+ private static readonly TimeSpan LocalEchoSuppressionWindow = TimeSpan.FromSeconds(30);
+ private readonly object _gate = new();
+ private readonly ChatApprovalState _approval = new();
+ private readonly ChatHistoryState _history = new();
+ private readonly ChatPresentationState _presentation;
+ private readonly ChatQueueState _queue = new();
+ private readonly ChatLifecycleState _lifecycle = new();
+ private readonly ChatResetState _reset = new();
+ private readonly Dictionary _timelines = new();
+ private readonly Dictionary> _entryMeta = new();
+ private ConnectionStatus _status;
+ private bool _disposed;
+
+ internal ChatConversationState(
+ ConnectionStatus status,
+ OpenClawChatDataProvider.LastChatState? lastChatState,
+ ModelsListInfo? seedModels)
+ {
+ _status = status;
+ _presentation = new ChatPresentationState(lastChatState, seedModels);
+ }
+
+ internal bool IsResponseSuppressed
+ {
+ get
+ {
+ lock (_gate)
+ return _lifecycle.IsResponseSuppressed;
+ }
+ }
+
+ internal bool IsDisposed
+ {
+ get
+ {
+ lock (_gate)
+ return _disposed;
+ }
+ }
+
+ internal ConnectionStatus Status
+ {
+ get
+ {
+ lock (_gate)
+ return _status;
+ }
+ }
+
+ internal long HistoryGeneration
+ {
+ get
+ {
+ lock (_gate)
+ return _history.ConnectionGeneration;
+ }
+ }
+
+ internal OpenClawChatDataProvider.LastChatState? CachedLastChatState
+ {
+ get
+ {
+ lock (_gate)
+ return _presentation.CachedLastChatState;
+ }
+ }
+
+ internal ChatDataSnapshot Load(
+ SessionInfo[] sessions,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _presentation.ReplaceSessions(
+ sessions,
+ receivedFromGateway: false);
+ EnsureTimelinesForSessionsLocked();
+ _presentation.RememberLastSessionState(context);
+ return BuildSnapshotLocked(context);
+ }
+ }
+
+ internal IReadOnlyDictionary GetEntryMetadata(string threadId)
+ {
+ lock (_gate)
+ {
+ return _entryMeta.TryGetValue(threadId, out var metadata)
+ ? new Dictionary(metadata)
+ : new Dictionary();
+ }
+ }
+
+ internal OpenClawChatDataProvider.LastChatState? RememberSelectedThread(string threadId)
+ {
+ lock (_gate)
+ {
+ return _presentation.RememberSelectedThread(threadId);
+ }
+ }
+
+ internal ChatDataSnapshot Snapshot(ChatProjectionContext context)
+ {
+ lock (_gate)
+ return BuildSnapshotLocked(context);
+ }
+
+ internal string? ResolveDefaultThreadId(ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ return ChatSnapshotProjector.ResolveDefaultThreadId(
+ CaptureProjectionInputLocked(context));
+ }
+ }
+
+ internal (string CacheKey, long ResetGeneration) ResolveMetadataKey(string threadId)
+ {
+ lock (_gate)
+ {
+ var key = _history.ResolveSessionId(threadId) ?? threadId;
+ return (key, GetResetVersionLocked(threadId));
+ }
+ }
+
+ internal long GetResetGeneration(string threadId)
+ {
+ lock (_gate)
+ return GetResetVersionLocked(threadId);
+ }
+
+ internal ChatHistoryCommitToken CaptureHistoryToken(string threadId)
+ {
+ lock (_gate)
+ {
+ return _history.CreateCommitToken(
+ threadId,
+ GetResetVersionLocked(threadId));
+ }
+ }
+
+ internal ChatHistoryReplacementTransition? BeginHistoryReplacement(
+ string threadId,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return null;
+
+ var token = _history.BeginReplacement(
+ threadId,
+ GetResetVersionLocked(threadId));
+ _timelines[threadId] = ChatTimelineState.Initial();
+ _entryMeta.Remove(threadId);
+ return new(BuildSnapshotLocked(context), token);
+ }
+ }
+
+ internal bool TryBeginHistory(
+ string threadId,
+ bool force,
+ ChatHistoryCommitToken? expectedToken,
+ out ChatHistoryCommitToken token,
+ out string? model,
+ out Task? generationActivation)
+ {
+ lock (_gate)
+ {
+ var canBegin = _history.TryBegin(
+ threadId,
+ force,
+ expectedToken,
+ GetResetVersionLocked(threadId),
+ _status,
+ _disposed,
+ out token,
+ out generationActivation);
+ model = _presentation.ModelForThread(threadId);
+ return canBegin;
+ }
+ }
+
+ internal bool IsHistoryRequestCurrent(ChatHistoryCommitToken token)
+ {
+ lock (_gate)
+ return _history.IsCurrent(
+ token,
+ GetResetVersionLocked(token.ThreadId),
+ _disposed);
+ }
+
+ internal bool CanRetryHistory(
+ ChatHistoryCommitToken token,
+ bool authoritative)
+ {
+ lock (_gate)
+ return _history.CanRetry(
+ token,
+ GetResetVersionLocked(token.ThreadId),
+ _status,
+ authoritative,
+ _disposed);
+ }
+
+ internal bool CommitHistory(
+ ChatHistoryCommitToken token,
+ ChatHistoryRebuildPlan plan,
+ DateTimeOffset requestStartedAt,
+ bool authoritative)
+ {
+ lock (_gate)
+ {
+ if (!_history.IsCurrent(
+ token,
+ GetResetVersionLocked(token.ThreadId),
+ _disposed))
+ {
+ return false;
+ }
+
+ var prior = GetOrCreateTimelineLocked(token.ThreadId);
+ var priorMetadata = _entryMeta.TryGetValue(
+ token.ThreadId,
+ out var metadata)
+ ? metadata
+ : new Dictionary();
+ var merged = ChatHistoryState.MergeWithLiveEntries(
+ plan,
+ prior,
+ priorMetadata,
+ requestStartedAt,
+ authoritative);
+ _timelines[token.ThreadId] = merged.Timeline;
+ _entryMeta[token.ThreadId] = merged.Metadata;
+ _history.MarkCommitted(token, plan.SessionId);
+ return true;
+ }
+ }
+
+ internal ChatDataSnapshot? SnapshotIfHistoryTokenCurrent(
+ ChatHistoryCommitToken token,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ return _history.IsCurrent(
+ token,
+ GetResetVersionLocked(token.ThreadId),
+ _disposed)
+ ? BuildSnapshotLocked(context)
+ : null;
+ }
+ }
+
+ internal bool IsCurrentResetGeneration(string threadId, long generation)
+ {
+ lock (_gate)
+ return GetResetVersionLocked(threadId) == generation;
+ }
+
+ internal ChatStatusTransition ApplyStatus(
+ ConnectionStatus status,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ {
+ return new(
+ BuildSnapshotLocked(context),
+ false,
+ false,
+ [],
+ _history.ConnectionGeneration);
+ }
+
+ var reconnected = status == ConnectionStatus.Connected &&
+ _status != ConnectionStatus.Connected;
+ var disconnected = status != ConnectionStatus.Connected &&
+ _status == ConnectionStatus.Connected;
+ _status = status;
+ if (status != ConnectionStatus.Connected)
+ _presentation.LeaveConnected();
+ if (disconnected)
+ _approval.Reset();
+
+ string[] interruptedThreads = [];
+ if (reconnected)
+ {
+ _history.AdvanceConnectionGeneration(clearLoaded: true);
+ _queue.ClearForReconnect();
+ _reset.ClearSubmittedEchoesForReconnect();
+ _lifecycle.ClearForReconnect();
+ _presentation.ResetKeylessDiagnostic();
+ foreach (var threadId in _timelines.Keys.ToArray())
+ {
+ _timelines[threadId] = ChatTimelineReducer.Apply(
+ _timelines[threadId],
+ new ChatToolReplayResetEvent());
+ }
+ }
+ if (disconnected)
+ {
+ _history.AdvanceConnectionGeneration(clearLoaded: false);
+ _reset.ClearSubmittedEchoesForReconnect();
+ interruptedThreads = _timelines
+ .Where(pair => pair.Value.TurnActive)
+ .Select(pair => pair.Key)
+ .ToArray();
+ _lifecycle.ClearActiveRuns(interruptedThreads);
+ }
+ return new(
+ BuildSnapshotLocked(context),
+ reconnected,
+ disconnected,
+ interruptedThreads,
+ _history.ConnectionGeneration);
+ }
+ }
+
+ internal void ClearToolReplayState()
+ {
+ lock (_gate)
+ {
+ foreach (var threadId in _timelines.Keys.ToArray())
+ {
+ _timelines[threadId] = ChatTimelineReducer.Apply(
+ _timelines[threadId],
+ new ChatToolReplayResetEvent());
+ }
+ }
+ }
+
+ internal ChatSessionsTransition ApplySessions(
+ SessionInfo[] sessions,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ var previousUsage = _presentation.SnapshotUsage();
+ _presentation.ReplaceSessions(sessions);
+ var currentSessions = _presentation.SessionSnapshot();
+ _history.SeedSessionIds(currentSessions);
+ EnsureTimelinesForSessionsLocked();
+ _presentation.RememberLastSessionState(context);
+ foreach (var session in currentSessions)
+ {
+ if (string.IsNullOrEmpty(session.Key))
+ continue;
+ var usage = new ChatUsageSnapshot(
+ session.InputTokens,
+ session.OutputTokens,
+ session.TotalTokens,
+ session.ContextTokens);
+ if (!previousUsage.TryGetValue(session.Key, out var previous) ||
+ previous != usage)
+ {
+ SnapshotLatestAssistantUsageLocked(
+ session,
+ _presentation.ResolveTimelineKey(session, _timelines));
+ }
+ }
+ return new(
+ BuildSnapshotLocked(context),
+ _status == ConnectionStatus.Connected
+ ? _queue.ThreadsWithMessages()
+ : []);
+ }
+ }
+
+ internal ChatDataSnapshot ApplyModels(
+ ModelsListInfo models,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ _presentation.ApplyModels(models);
+ return BuildSnapshotLocked(context);
+ }
+ }
+
+ internal ChatModelPatchLease BeginModelPatch(string threadId)
+ {
+ lock (_gate)
+ return _presentation.BeginModelPatch(threadId);
+ }
+
+ internal void CompleteModelPatch(ChatModelPatchLease lease, Exception? error)
+ {
+ lock (_gate)
+ _presentation.CompleteModelPatch(lease, error);
+ }
+
+ internal Task? GetPendingModelPatch(string threadId)
+ {
+ lock (_gate)
+ return _presentation.GetPendingModelPatch(threadId);
+ }
+
+ internal bool TryBeginCommandCatalogFetch(out int epoch)
+ {
+ lock (_gate)
+ return _presentation.TryBeginCommandCatalogFetch(_status, out epoch);
+ }
+
+ internal bool CompleteCommandCatalogFetch(int epoch, CommandCatalog catalog)
+ {
+ lock (_gate)
+ return _presentation.CompleteCommandCatalogFetch(
+ epoch,
+ _status,
+ catalog);
+ }
+
+ internal bool FailCommandCatalogFetch(int epoch)
+ {
+ lock (_gate)
+ return _presentation.FailCommandCatalogFetch(epoch, _status);
+ }
+
+ internal ChatDataSnapshot? SnapshotCommandCatalogIfFresh(
+ int epoch,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ return _presentation.IsCommandCatalogEpochCurrent(epoch)
+ ? BuildSnapshotLocked(context)
+ : null;
+ }
+ }
+
+ internal ChatDisposeTransition DisposeState()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return new(
+ _history.ConnectionGeneration,
+ IsFirstDispose: false);
+ _disposed = true;
+ _history.AdvanceConnectionGeneration(clearLoaded: false);
+ _queue.ClearForDispose();
+ _lifecycle.ClearForDispose();
+ _reset.ClearSubmittedEchoesForReconnect();
+ return new(
+ _history.ConnectionGeneration,
+ IsFirstDispose: true);
+ }
+ }
+
+ internal bool TryRaiseKeylessDiagnostic()
+ {
+ lock (_gate)
+ return _presentation.TryRaiseKeylessDiagnostic();
+ }
+
+ internal string? PendingPermissionId(string threadId)
+ {
+ lock (_gate)
+ return GetOrCreateTimelineLocked(threadId).PendingPermission?.RequestId;
+ }
+
+ internal void ActivateHistoryGeneration(long generation)
+ {
+ lock (_gate)
+ _history.ActivateConnectionGeneration(generation, _disposed);
+ }
+
+ private bool SnapshotLatestAssistantUsageLocked(
+ SessionInfo session,
+ string threadId)
+ {
+ if (string.IsNullOrEmpty(session.Key))
+ return false;
+ var usedTokens = session.TotalTokens;
+ if (usedTokens <= 0)
+ usedTokens = session.InputTokens + session.OutputTokens;
+ if (usedTokens <= 0 ||
+ string.IsNullOrEmpty(threadId) ||
+ !_timelines.TryGetValue(threadId, out var timeline))
+ {
+ return false;
+ }
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ if (timeline.Entries[i].Kind != ChatTimelineItemKind.Assistant)
+ continue;
+ var metadata = GetOrCreateThreadMetaLocked(threadId);
+ metadata.TryGetValue(timeline.Entries[i].Id, out var existing);
+ var usageSnapshot = Math.Max(
+ usedTokens,
+ existing?.ResponseTokens ?? 0);
+ var usageTokens = ToIntIfPositive(usageSnapshot);
+ var contextTokens = session.ContextTokens > 0
+ ? session.ContextTokens
+ : existing?.ContextTokens;
+ if (existing is not null &&
+ existing.ResponseTokens == usageTokens &&
+ existing.ContextTokens == contextTokens)
+ {
+ return false;
+ }
+ metadata[timeline.Entries[i].Id] =
+ (existing ?? BuildLiveMetaLocked(threadId)) with
+ {
+ InputTokens = ToIntIfPositive(session.InputTokens),
+ OutputTokens = ToIntIfPositive(session.OutputTokens),
+ ResponseTokens = usageTokens,
+ ContextTokens = contextTokens,
+ ContextPercent = existing?.ContextPercent,
+ UsageContributionTokens = existing?.UsageContributionTokens,
+ };
+ return true;
+ }
+ return false;
+ }
+
+ private static int? ToIntIfPositive(long value) =>
+ value > 0 && value <= int.MaxValue ? (int)value : null;
+
+ internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload(
+ ChatEntryMetadata? metadata,
+ int maxHistorySequence,
+ DateTimeOffset requestStartedAt) =>
+ ChatHistoryState.ShouldPreserveLiveEntryDuringAuthoritativeReload(
+ metadata,
+ maxHistorySequence,
+ requestStartedAt);
+
+ internal ChatQueuedAdmission AdmitMessage(
+ string threadId,
+ string text,
+ string displayText,
+ string nonce,
+ IReadOnlyList? attachments,
+ DateTimeOffset createdAt,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var messageId = _queue.NextMessageId();
+ if (CanClearAssistantFallbackPromotionLocked(threadId))
+ _queue.ClearAssistantFallbackPromotion(threadId);
+
+ _lifecycle.ClearThreadSuppression(threadId);
+ _lifecycle.TakePendingAbortCount(threadId);
+ var request = new ChatQueuedSendRequest(
+ messageId,
+ Guid.NewGuid().ToString(),
+ threadId,
+ text,
+ displayText,
+ nonce,
+ attachments?.ToArray());
+
+ var sendDirectly = CanSendDirectlyLocked(threadId);
+ ChatQueuedSendDispatch? dispatch;
+ if (sendDirectly)
+ {
+ dispatch = StartDirectSendLocked(request);
+ }
+ else
+ {
+ _queue.AddMessage(threadId, new ChatQueuedMessage(
+ messageId,
+ displayText,
+ createdAt,
+ nonce));
+ _queue.AddRequest(request);
+ dispatch = TryStartNextQueuedSendLocked(
+ threadId,
+ requireConnected: false,
+ out _);
+ }
+
+ return new ChatQueuedAdmission(
+ messageId,
+ Queued: !sendDirectly,
+ dispatch,
+ BuildSnapshotLocked(context),
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+ }
+
+ internal ChatDataSnapshot EnqueueCompact(
+ string threadId,
+ DateTimeOffset createdAt,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var messageId = _queue.NextMessageId();
+ var request = new ChatQueuedSendRequest(
+ messageId,
+ Guid.NewGuid().ToString(),
+ threadId,
+ "/compact",
+ "/compact",
+ Guid.NewGuid().ToString(),
+ Attachments: null,
+ LifecycleCommand: ChatLifecycleCommandKind.Compact);
+ _queue.AddMessage(threadId, new ChatQueuedMessage(
+ messageId,
+ request.DisplayText,
+ createdAt,
+ request.LocalNonce));
+ _queue.AddRequest(request);
+ return BuildSnapshotLocked(context);
+ }
+ }
+
+ internal (bool Canceled, ChatDataSnapshot? Snapshot) CancelQueuedMessage(
+ string threadId,
+ string messageId,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ var canceled = _queue.CancelMessage(threadId, messageId);
+ if (canceled)
+ {
+ _queue.ClearLocallyInitiatedIfIdle(
+ threadId,
+ _lifecycle.HasActiveRun(threadId),
+ _timelines.TryGetValue(threadId, out var timeline) &&
+ timeline.TurnActive);
+ }
+ return (
+ canceled,
+ canceled ? BuildSnapshotLocked(context) : null);
+ }
+ }
+
+ internal ChatQueueStart TryStartNextQueuedSend(
+ string threadId,
+ bool requireConnected,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return new(null, null, null);
+ var dispatch = TryStartNextQueuedSendLocked(
+ threadId,
+ requireConnected,
+ out var delayedRetry);
+ return new ChatQueueStart(
+ dispatch,
+ delayedRetry,
+ dispatch is null ? null : BuildSnapshotLocked(context));
+ }
+ }
+
+ internal bool TryScheduleQueueDrain(string threadId)
+ {
+ lock (_gate)
+ {
+ return !_disposed && _queue.TryScheduleDrain(threadId);
+ }
+ }
+
+ internal void CompleteQueueDrainSchedule(string threadId)
+ {
+ lock (_gate)
+ _queue.CompleteDrainSchedule(threadId);
+ }
+
+ internal ChatSendPreparation PrepareSendAttempt(
+ ChatQueuedSendDispatch dispatch,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ if (!IsDispatchGenerationCurrentLocked(dispatch) ||
+ !dispatch.StartedDirectly &&
+ _queue.FindRequest(
+ dispatch.Request.ThreadId,
+ dispatch.Request.Id) is null)
+ {
+ return new(
+ false,
+ CleanupStaleDispatchTurnLocked(dispatch, context));
+ }
+ _queue.TrackRun(
+ dispatch.Request.ThreadId,
+ dispatch.Request.SendRunId,
+ dispatch.Request.Id);
+ return new(true, null);
+ }
+ }
+
+ internal ChatSendCommit CommitSendResult(
+ ChatQueuedSendDispatch dispatch,
+ ChatSendResult sendResult,
+ ChatProjectionContext context)
+ {
+ var request = dispatch.Request;
+ var threadId = request.ThreadId;
+ var acceptedRunId = string.IsNullOrWhiteSpace(sendResult.RunId)
+ ? null
+ : sendResult.RunId;
+ lock (_gate)
+ {
+ if (!IsDispatchGenerationCurrentLocked(dispatch))
+ {
+ _reset.RemovePendingLocalSubmission(
+ threadId,
+ request.Id,
+ dispatch.ResetVersion);
+ var staleRunId = acceptedRunId ?? request.SendRunId;
+ _reset.AddIgnoredRun(threadId, staleRunId);
+ return new(
+ IsCurrent: false,
+ AcceptedSnapshot:
+ CleanupStaleDispatchTurnLocked(dispatch, context),
+ RequeuedSnapshot: null,
+ StaleRunIdToAbort: staleRunId,
+ BindAcceptedRun: false,
+ RequeueRequired: false,
+ RetryDeferredSend: false,
+ DeferredRetryDelay: ChatSendQueuePolicy.DrainDelay,
+ OpenedLifecycle: null,
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+
+ ChatDataSnapshot? acceptedSnapshot = null;
+ ChatDataSnapshot? requeuedSnapshot = null;
+ ChatOpenedLifecycleTransition? openedLifecycle = null;
+ var bindAcceptedRun = false;
+ var requeueRequired = false;
+ var retryDeferredSend = false;
+ var deferredRetryDelay = ChatSendQueuePolicy.DrainDelay;
+ if (ChatSendQueuePolicy.IsDeferredAdmissionStatus(sendResult.Status))
+ {
+ _reset.RemovePendingLocalSubmission(
+ threadId,
+ request.Id,
+ dispatch.ResetVersion);
+ var runAlreadyStarted = !string.IsNullOrEmpty(acceptedRunId)
+ && _lifecycle.HasRunStartedAfter(
+ threadId,
+ acceptedRunId,
+ dispatch.StartedRunStartSequence);
+ if (runAlreadyStarted)
+ {
+ bindAcceptedRun = true;
+ _queue.TrackRun(threadId, acceptedRunId!, request.Id);
+ AddResetAcceptedRunIdLocked(threadId, acceptedRunId!);
+ if (PromoteQueuedMessageLocked(threadId, request.Id))
+ acceptedSnapshot = BuildSnapshotLocked(context);
+ else
+ _queue.RemoveRunMappingByMessageId(threadId, request.Id);
+ }
+ else if (_queue.RequeueDeferredAdmission(
+ threadId,
+ request.Id,
+ _lifecycle.HasActiveRun(threadId)) is
+ { Requeued: true } retry)
+ {
+ deferredRetryDelay = retry.Delay;
+ if (retry.ShouldEndTurn)
+ {
+ _timelines[threadId] = ChatTimelineReducer.Apply(
+ GetOrCreateTimelineLocked(threadId),
+ new ChatTurnEndEvent());
+ }
+ requeueRequired = true;
+ if (!string.IsNullOrEmpty(acceptedRunId))
+ {
+ _queue.TrackRun(threadId, acceptedRunId, request.Id);
+ openedLifecycle =
+ AddResetAcceptedRunIdLocked(
+ threadId,
+ acceptedRunId);
+ }
+ requeuedSnapshot = BuildSnapshotLocked(context);
+ retryDeferredSend = true;
+ }
+ else if (dispatch.StartedDirectly)
+ {
+ throw new InvalidOperationException(
+ $"Gateway returned chat.send status {sendResult.Status} before admitting the direct send.");
+ }
+ }
+ else if (!string.IsNullOrEmpty(acceptedRunId))
+ {
+ bindAcceptedRun = true;
+ _queue.TrackRun(threadId, acceptedRunId, request.Id);
+ openedLifecycle =
+ AddResetAcceptedRunIdLocked(
+ threadId,
+ acceptedRunId);
+ var runAlreadyStarted =
+ _lifecycle.HasRunStartedAfter(
+ threadId,
+ acceptedRunId,
+ dispatch.StartedRunStartSequence);
+ if (PromoteQueuedMessageLocked(threadId, request.Id))
+ acceptedSnapshot = BuildSnapshotLocked(context);
+ else if (runAlreadyStarted)
+ _queue.RemoveRunMappingByMessageId(threadId, request.Id);
+ }
+ else if (_reset.IsAwaitingUserMessage(threadId))
+ {
+ _queue.RemoveRunMappingByRunId(threadId, request.SendRunId);
+ openedLifecycle =
+ ApplyBufferedLifecycleOpenLocked(
+ threadId,
+ _reset.RecordLocalSendWithoutRun(
+ threadId,
+ dispatch.ResetVersion,
+ dispatch.StartedLifecycleSequence,
+ request.Id),
+ allowRemoteTurn: false);
+ if (PromoteQueuedMessageLocked(threadId, request.Id))
+ acceptedSnapshot = BuildSnapshotLocked(context);
+ }
+ else if (PromoteQueuedMessageLocked(threadId, request.Id))
+ {
+ _queue.RemoveRunMappingByRunId(threadId, request.SendRunId);
+ acceptedSnapshot = BuildSnapshotLocked(context);
+ }
+
+ return new(
+ IsCurrent: true,
+ acceptedSnapshot,
+ requeuedSnapshot,
+ StaleRunIdToAbort: null,
+ bindAcceptedRun,
+ requeueRequired,
+ retryDeferredSend,
+ deferredRetryDelay,
+ openedLifecycle,
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+ }
+
+ internal ChatSendFailure FailSend(
+ ChatQueuedSendDispatch dispatch,
+ string queueError,
+ string timelineError,
+ ChatProjectionContext context)
+ {
+ var request = dispatch.Request;
+ lock (_gate)
+ {
+ _reset.RemovePendingLocalSubmission(
+ request.ThreadId,
+ request.Id,
+ dispatch.ResetVersion);
+ if (!IsDispatchGenerationCurrentLocked(dispatch))
+ {
+ return new(
+ false,
+ CleanupStaleDispatchTurnLocked(dispatch, context));
+ }
+
+ _queue.RemovePendingLocalEcho(request.ThreadId, request.Id);
+ _queue.MarkFailed(request.ThreadId, request.Id, queueError);
+ _queue.RemoveRequest(request.ThreadId, request.Id);
+ _queue.RemoveRunMappingByMessageId(request.ThreadId, request.Id);
+ if (!_queue.HasSendingMessages(request.ThreadId))
+ _queue.ClearLocallyInitiated(request.ThreadId);
+ ApplyEventLocked(
+ request.ThreadId,
+ ChatContentFormatting.TruncateChatEvent(
+ new ChatErrorEvent(timelineError)),
+ metadata: null);
+ ApplyEventLocked(request.ThreadId, new ChatTurnEndEvent(), metadata: null);
+ return new(true, BuildSnapshotLocked(context));
+ }
+ }
+
+ internal bool IsQueuedDispatchCurrent(ChatQueuedSendDispatch dispatch)
+ {
+ lock (_gate)
+ {
+ return IsDispatchGenerationCurrentLocked(dispatch)
+ && _queue.FindRequest(
+ dispatch.Request.ThreadId,
+ dispatch.Request.Id) is not null;
+ }
+ }
+
+ internal (bool Succeeded, ChatDataSnapshot? Snapshot) CompleteQueuedLifecycle(
+ ChatQueuedSendDispatch dispatch,
+ bool succeeded,
+ string? error,
+ ChatProjectionContext context)
+ {
+ var request = dispatch.Request;
+ lock (_gate)
+ {
+ if (!IsDispatchGenerationCurrentLocked(dispatch) ||
+ _queue.FindRequest(request.ThreadId, request.Id) is null)
+ {
+ return (false, null);
+ }
+
+ if (succeeded)
+ {
+ var removed = _queue.RemoveMessage(request.ThreadId, request.Id);
+ return (true, removed ? BuildSnapshotLocked(context) : null);
+ }
+
+ ApplyEventLocked(
+ request.ThreadId,
+ new ChatErrorEvent(error ?? "The lifecycle command failed."),
+ metadata: null);
+ _queue.MarkFailed(
+ request.ThreadId,
+ request.Id,
+ error ?? "The lifecycle command failed.");
+ _queue.RemoveRequest(request.ThreadId, request.Id);
+ return (true, BuildSnapshotLocked(context));
+ }
+ }
+
+ private bool IsDispatchGenerationCurrentLocked(
+ ChatQueuedSendDispatch dispatch) =>
+ !_disposed &&
+ _history.ConnectionGeneration == dispatch.ConnectionGeneration &&
+ GetResetVersionLocked(dispatch.Request.ThreadId) == dispatch.ResetVersion;
+
+ private ChatDataSnapshot? CleanupStaleDispatchTurnLocked(
+ ChatQueuedSendDispatch dispatch,
+ ChatProjectionContext context)
+ {
+ var threadId = dispatch.Request.ThreadId;
+ if (_disposed ||
+ _lifecycle.HasActiveRun(threadId) ||
+ _queue.IsLocallyInitiated(threadId) ||
+ !_timelines.TryGetValue(threadId, out var timeline) ||
+ !timeline.TurnActive)
+ {
+ return null;
+ }
+ _timelines[threadId] = ChatTimelineReducer.Apply(
+ timeline,
+ new ChatTurnEndEvent());
+ return BuildSnapshotLocked(context);
+ }
+
+ private bool CanSendDirectlyLocked(string threadId) =>
+ _queue.CanSendDirectly(
+ threadId,
+ _lifecycle.HasActiveRun(threadId),
+ _timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive);
+
+ private bool CanClearAssistantFallbackPromotionLocked(string threadId) =>
+ _queue.CanClearAssistantFallback(
+ threadId,
+ _lifecycle.HasActiveRun(threadId),
+ _timelines.TryGetValue(threadId, out var timeline) &&
+ timeline.TurnActive);
+
+ private ChatQueuedSendDispatch StartDirectSendLocked(
+ ChatQueuedSendRequest request)
+ {
+ var threadId = request.ThreadId;
+ var resetVersion = GetResetVersionLocked(threadId);
+ var current = GetOrCreateTimelineLocked(threadId);
+ var entryId = $"e{current.NextId}";
+ _timelines[threadId] = ChatTimelineReducer.AddLocalUser(
+ current,
+ request.DisplayText,
+ request.LocalNonce);
+ GetOrCreateThreadMetaLocked(threadId)[entryId] = BuildLiveMetaLocked(
+ threadId,
+ isLocalQueuedSend: true,
+ localQueuedMessageId: request.Id);
+ var dispatch = _queue.StartDirect(
+ request,
+ _history.ResolveSessionId(threadId),
+ _history.ConnectionGeneration,
+ resetVersion,
+ _reset.LifecycleStartSequence,
+ _lifecycle.LifecycleStartSequence);
+ RegisterResetSubmissionLocked(dispatch);
+ return dispatch;
+ }
+
+ private ChatQueuedSendDispatch? TryStartNextQueuedSendLocked(
+ string threadId,
+ bool requireConnected,
+ out TimeSpan? delayedRetry)
+ {
+ var turnActive = _timelines.TryGetValue(threadId, out var timeline) &&
+ timeline.TurnActive;
+ var dispatch = _queue.TryStartNext(
+ threadId,
+ requireConnected,
+ _status,
+ _lifecycle.HasActiveRun(threadId),
+ turnActive,
+ _history.ResolveSessionId(threadId),
+ _history.ConnectionGeneration,
+ GetResetVersionLocked(threadId),
+ _reset.LifecycleStartSequence,
+ _lifecycle.LifecycleStartSequence,
+ out delayedRetry);
+ if (dispatch?.Request.LifecycleCommand is null && dispatch is not null)
+ {
+ RegisterResetSubmissionLocked(dispatch);
+ _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(
+ GetOrCreateTimelineLocked(threadId));
+ }
+ return dispatch;
+ }
+
+ private void RegisterResetSubmissionLocked(
+ ChatQueuedSendDispatch dispatch)
+ {
+ _reset.RegisterPendingLocalSubmission(
+ dispatch.Request.ThreadId,
+ dispatch.Request.Id,
+ dispatch.Request.Text,
+ dispatch.ResetVersion,
+ dispatch.StartedLifecycleSequence,
+ DateTimeOffset.UtcNow,
+ requiresEcho:
+ !string.IsNullOrWhiteSpace(
+ dispatch.Request.Text));
+ }
+
+ private bool RemoveQueuedMessageLocked(string threadId, string messageId)
+ {
+ var removed = _queue.RemoveMessage(threadId, messageId);
+ if (removed)
+ ClearLocallyInitiatedIfIdleLocked(threadId);
+ return removed;
+ }
+
+ private bool CancelQueuedMessageLocked(string threadId, string messageId)
+ {
+ var canceled = _queue.CancelMessage(threadId, messageId);
+ if (canceled)
+ ClearLocallyInitiatedIfIdleLocked(threadId);
+ return canceled;
+ }
+
+ private bool PromoteQueuedMessageLocked(
+ string threadId,
+ string messageId,
+ ChatEntryMetadata? confirmedMeta = null)
+ {
+ if (!_queue.TryTakeForPromotion(threadId, messageId, out var queued))
+ return false;
+
+ var current = GetOrCreateTimelineLocked(threadId);
+ var entryId = $"e{current.NextId}";
+ _timelines[threadId] = ChatTimelineReducer.AddLocalUser(
+ current,
+ queued.Text,
+ queued.LocalNonce);
+ var meta = confirmedMeta is not null && HasGatewayIdentity(confirmedMeta)
+ ? confirmedMeta with
+ {
+ IsLocalQueuedSend = false,
+ LocalQueuedMessageId = messageId,
+ }
+ : BuildLiveMetaLocked(
+ threadId,
+ isLocalQueuedSend: true,
+ localQueuedMessageId: messageId);
+ GetOrCreateThreadMetaLocked(threadId)[entryId] = meta;
+ return true;
+ }
+
+ private void ClearLocallyInitiatedIfIdleLocked(string threadId)
+ {
+ _queue.ClearLocallyInitiatedIfIdle(
+ threadId,
+ _lifecycle.HasActiveRun(threadId),
+ _timelines.TryGetValue(threadId, out var timeline) &&
+ timeline.TurnActive);
+ }
+
+ private static bool HasGatewayIdentity(ChatEntryMetadata metadata) =>
+ !string.IsNullOrEmpty(metadata.GatewayMessageId) ||
+ metadata.OpenClawSeq is not null;
+
+ internal ChatDataSnapshot ApplyEvent(
+ string threadId,
+ ChatEvent evt,
+ ChatEntryMetadata? metadata,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ ApplyEventLocked(
+ threadId,
+ ChatContentFormatting.TruncateChatEvent(evt),
+ metadata);
+ return BuildSnapshotLocked(context);
+ }
+ }
+
+ internal ChatDataSnapshot ClearPendingPermission(
+ string threadId,
+ string? expectedRequestId,
+ ChatPermissionDecision decision,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ var timeline = GetOrCreateTimelineLocked(threadId);
+ if (expectedRequestId is not null &&
+ !string.Equals(
+ timeline.PendingPermission?.RequestId,
+ expectedRequestId,
+ StringComparison.Ordinal))
+ {
+ return BuildSnapshotLocked(context);
+ }
+ _timelines[threadId] = ChatTimelineReducer.ResolvePermission(
+ timeline,
+ expectedRequestId,
+ decision);
+ return BuildSnapshotLocked(context);
+ }
+ }
+
+ internal ChatEntryMetadata BuildLiveMetadata(
+ string threadId,
+ long? tsMs = null,
+ string? gatewayMessageId = null,
+ int? openClawSeq = null,
+ bool isLocalQueuedSend = false,
+ string? localQueuedMessageId = null,
+ string? openClawKind = null,
+ long? compactionTokensBefore = null,
+ long? compactionTokensAfter = null)
+ {
+ lock (_gate)
+ {
+ return BuildLiveMetaLocked(
+ threadId,
+ tsMs,
+ gatewayMessageId,
+ openClawSeq,
+ isLocalQueuedSend,
+ localQueuedMessageId,
+ openClawKind,
+ compactionTokensBefore,
+ compactionTokensAfter);
+ }
+ }
+
+ internal bool IsLateNonFinalAssistantFrame(string threadId)
+ {
+ lock (_gate)
+ {
+ if (!_timelines.TryGetValue(threadId, out var timeline) ||
+ timeline.TurnActive)
+ {
+ return false;
+ }
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (entry.Kind == ChatTimelineItemKind.User)
+ return false;
+ if (entry.Kind == ChatTimelineItemKind.Assistant)
+ return !entry.IsStreaming;
+ }
+ return false;
+ }
+ }
+
+ internal ChatAbortStart BeginAbort(string threadId)
+ {
+ lock (_gate)
+ {
+ var hadActiveTurn = _timelines.TryGetValue(threadId, out var timeline) &&
+ timeline.TurnActive;
+ return _lifecycle.BeginAbort(threadId, hadActiveTurn);
+ }
+ }
+
+ internal void RollbackAbort(string threadId, string runId)
+ {
+ lock (_gate)
+ {
+ _lifecycle.RollbackAbort(threadId, runId);
+ if (!_queue.HasSendingMessages(threadId))
+ _queue.ClearLocallyInitiated(threadId);
+ }
+ }
+
+ internal ChatDataSnapshot? RollbackAbortAndEndTurnIfCurrent(
+ string threadId,
+ string runId,
+ ChatRuntimeGeneration expectedGeneration,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ if (_disposed ||
+ CurrentRuntimeGenerationLocked(threadId) != expectedGeneration ||
+ !_lifecycle.TryGetActiveRun(threadId, out var activeRunId) ||
+ !string.Equals(activeRunId, runId, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ _lifecycle.RollbackAbort(threadId, runId);
+ if (!_queue.HasSendingMessages(threadId))
+ _queue.ClearLocallyInitiated(threadId);
+ ApplyEventLocked(
+ threadId,
+ new ChatTurnEndEvent(),
+ metadata: null);
+ return BuildSnapshotLocked(context);
+ }
+ }
+
+ internal void CompleteAbort(string threadId, string? runId)
+ {
+ lock (_gate)
+ {
+ _lifecycle.CompleteAbort(threadId, runId);
+ if (!_queue.HasSendingMessages(threadId))
+ _queue.ClearLocallyInitiated(threadId);
+ }
+ }
+
+ internal bool ShouldSuppressChatMessage(string threadId)
+ {
+ lock (_gate)
+ return _lifecycle.IsThreadSuppressed(threadId);
+ }
+
+ internal ChatResetTransition ResetThread(
+ string threadId,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ var oldSessionId = _history.ClearSessionForReset(threadId);
+
+ var submittedRunIds = new HashSet(StringComparer.Ordinal);
+ if (_lifecycle.ActiveRunForReset(threadId) is { Length: > 0 } activeRunId)
+ {
+ submittedRunIds.Add(activeRunId);
+ }
+
+ foreach (var runId in _queue.RunIdsForThread(threadId))
+ submittedRunIds.Add(runId);
+ foreach (var localEcho in _queue.SnapshotLocalEchoes(threadId))
+ {
+ _reset.AddSubmittedLocalEcho(
+ threadId,
+ localEcho.Text,
+ localEcho.SentAt);
+ }
+
+ var generation = _reset.BeginReset(
+ threadId,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ _timelines[threadId] = ChatTimelineState.Initial() with
+ {
+ HistoryLoaded = true,
+ };
+ _entryMeta.Remove(threadId);
+ _lifecycle.ClearThreadForReset(threadId);
+ _queue.ClearThreadForReset(threadId);
+ foreach (var runId in submittedRunIds)
+ _reset.AddIgnoredRun(threadId, runId);
+
+ return new(
+ BuildSnapshotLocked(context),
+ oldSessionId,
+ generation,
+ threadId,
+ submittedRunIds.ToArray());
+ }
+ }
+
+ internal ChatIncomingMessageGate GateIncomingChatMessage(
+ ChatMessageInfo message,
+ ChatProjectionContext context)
+ {
+ var threadId = message.SessionKey!;
+ var role = message.Role?.ToLowerInvariant() ?? string.Empty;
+ var text = message.Text ?? string.Empty;
+ lock (_gate)
+ {
+ _lifecycle.TryGetActiveRun(
+ threadId,
+ out var activeRunId);
+ var resetGate = _reset.EvaluateChatMessage(
+ threadId,
+ role,
+ text,
+ message.Ts,
+ _queue.HasPendingLocalEchoText(threadId, text),
+ activeRunId);
+ var openedLifecycle =
+ ApplyBufferedLifecycleOpenLocked(
+ threadId,
+ resetGate.OpenedLifecycleStart,
+ allowRemoteTurn:
+ resetGate.ConsumeEchoText is null);
+ if (resetGate.Drop)
+ {
+ ChatDataSnapshot? snapshot = null;
+ if (resetGate.ConsumeEchoText is not null &&
+ _queue.TryConsumeLocalEcho(
+ threadId,
+ resetGate.ConsumeEchoText,
+ out var queuedMessageId))
+ {
+ var confirmed = BuildLiveMetaLocked(
+ threadId,
+ message.Ts,
+ message.OpenClawId,
+ message.OpenClawSeq);
+ if (ReconcileQueuedMessageEchoLocked(
+ threadId,
+ queuedMessageId,
+ confirmed))
+ {
+ snapshot = BuildSnapshotLocked(context);
+ }
+ }
+ return new(
+ true,
+ false,
+ resetGate.RequestRemoteBackfill,
+ snapshot,
+ openedLifecycle,
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+ return new(
+ Drop: false,
+ Suppressed: _lifecycle.IsThreadSuppressed(threadId),
+ RequestRemoteBackfill: false,
+ Snapshot: null,
+ openedLifecycle,
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+ }
+
+ internal ChatLocalEchoTransition ConsumeLocalEcho(
+ ChatMessageInfo message,
+ bool removeQueuedMessage,
+ ChatProjectionContext context)
+ {
+ var threadId = message.SessionKey!;
+ var text = (message.Text ?? string.Empty).Trim();
+ lock (_gate)
+ {
+ if (!_queue.TryConsumeLocalEcho(
+ threadId,
+ text,
+ out var queuedMessageId))
+ {
+ return new(false, null);
+ }
+ if (removeQueuedMessage)
+ RemoveQueuedMessageLocked(threadId, queuedMessageId);
+ var confirmed = BuildLiveMetaLocked(
+ threadId,
+ message.Ts,
+ message.OpenClawId,
+ message.OpenClawSeq);
+ return new(
+ true,
+ !removeQueuedMessage &&
+ ReconcileQueuedMessageEchoLocked(
+ threadId,
+ queuedMessageId,
+ confirmed)
+ ? BuildSnapshotLocked(context)
+ : null);
+ }
+ }
+
+ internal ChatLocalEchoTransition ReconcileExistingLocalQueuedUser(
+ ChatMessageInfo message,
+ string userText,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ var metadata = BuildLiveMetaLocked(
+ message.SessionKey!,
+ message.Ts,
+ message.OpenClawId,
+ message.OpenClawSeq);
+ var reconciled = TryReconcileExistingLocalQueuedUserEchoLocked(
+ message.SessionKey!,
+ userText,
+ metadata);
+ return new(
+ reconciled,
+ reconciled ? BuildSnapshotLocked(context) : null);
+ }
+ }
+
+ internal (ChatEntryMetadata Metadata, string? ActiveRunId) BuildMetadataWithRun(
+ ChatMessageInfo message)
+ {
+ lock (_gate)
+ {
+ var metadata = BuildLiveMetaLocked(
+ message.SessionKey!,
+ message.Ts,
+ message.OpenClawId,
+ message.OpenClawSeq);
+ _lifecycle.TryGetActiveRun(message.SessionKey!, out var runId);
+ return (metadata, runId);
+ }
+ }
+
+ internal ChatAssistantPreparation PrepareAssistant(
+ ChatMessageInfo message,
+ string assistantText,
+ ChatProjectionContext context)
+ {
+ var threadId = message.SessionKey!;
+ lock (_gate)
+ {
+ var disposition = ClassifyAssistantQueueFrameLocked(
+ threadId,
+ assistantText,
+ message.OpenClawId,
+ message.OpenClawSeq);
+ ChatDataSnapshot? promotionSnapshot = null;
+ if (disposition == AssistantQueueFrameDisposition.Render &&
+ _queue.IsLocallyInitiated(threadId) &&
+ _queue.TryGetSingleSendingMessage(threadId, out var queued) &&
+ !_lifecycle.HasActiveRun(threadId) &&
+ !_queue.IsAssistantFallbackPromoted(threadId) &&
+ PromoteQueuedMessageLocked(threadId, queued.Id))
+ {
+ promotionSnapshot = BuildSnapshotLocked(context);
+ }
+
+ var metadata = BuildLiveMetaLocked(
+ threadId,
+ message.Ts,
+ message.OpenClawId,
+ message.OpenClawSeq);
+ var hasUsage = message.InputTokens is not null ||
+ message.OutputTokens is not null ||
+ message.ResponseTokens is not null ||
+ message.ContextPercent is not null;
+ if (hasUsage)
+ {
+ var contextTokens = _presentation.ContextTokensForThread(threadId);
+ metadata = metadata with
+ {
+ InputTokens = message.InputTokens ?? metadata.InputTokens,
+ OutputTokens = message.OutputTokens ?? metadata.OutputTokens,
+ ResponseTokens = message.ResponseTokens ?? metadata.ResponseTokens,
+ ContextPercent = message.ContextPercent ?? metadata.ContextPercent,
+ ContextTokens = contextTokens is > 0
+ ? contextTokens
+ : metadata.ContextTokens,
+ };
+ }
+ _lifecycle.TryGetActiveRun(threadId, out var activeRunId);
+ return new(disposition, promotionSnapshot, metadata, activeRunId);
+ }
+ }
+
+ internal string? CompleteAssistantFinal(string threadId)
+ {
+ lock (_gate)
+ {
+ var completedRunId = _lifecycle.CompleteAssistantFinal(threadId);
+ _reset.CompleteRun(threadId, completedRunId);
+ if (!_queue.HasSendingMessages(threadId))
+ _queue.ClearLocallyInitiated(threadId);
+ return completedRunId;
+ }
+ }
+
+ internal ChatAgentEventTransition ProcessAgentEvent(
+ AgentEventInfo evt,
+ string threadId,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ var gate = GateAgentEventLocked(evt, threadId);
+ if (!gate.Process)
+ {
+ return new(
+ Process: false,
+ gate.ReloadHistory,
+ gate.DroppedTerminalReason,
+ DeferredAbortRunId: null,
+ DeferredAbortCount: 0,
+ CompletedRunId: null,
+ CompletionPhase: null,
+ FetchRemoteUser: false,
+ AllowRemoteTurn: false,
+ WasAborted: false,
+ Suppressed: false,
+ MappedEvent: null,
+ ToolMetadata: null,
+ Snapshots: [],
+ gate.OpenedLifecycle,
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+
+ var run = UpdateRunTrackingLocked(evt, threadId, context);
+ var snapshots = new List();
+ if (run.Snapshot is not null)
+ snapshots.Add(run.Snapshot);
+
+ var suppressed = _lifecycle.ShouldSuppress(threadId, evt.RunId);
+ ChatEvent? mapped = null;
+ ChatToolMetadataWrite? toolMetadata = null;
+ if (!suppressed)
+ {
+ var mapping = ChatEventMapper.Map(evt);
+ mapped = mapping.Event;
+ if (mapping.Approval is { } approval &&
+ !_approval.MarkSeen(approval.RequestId, approval.AlternateId))
+ {
+ mapped = null;
+ }
+
+ if (mapped is not null)
+ {
+ ApplyEventLocked(
+ threadId,
+ ChatContentFormatting.TruncateChatEvent(mapped),
+ BuildLiveMetaLocked(
+ threadId,
+ evt.Ts > 0 ? (long)evt.Ts : 0));
+ toolMetadata = BuildToolMetadataWriteLocked(
+ threadId,
+ mapped,
+ evt.Ts > 0 ? (long)evt.Ts : 0);
+ snapshots.Add(BuildSnapshotLocked(context));
+ }
+ else if (TryResolveTerminalApprovalLocked(evt, threadId))
+ {
+ snapshots.Add(BuildSnapshotLocked(context));
+ }
+ }
+
+ return new(
+ Process: true,
+ ReloadHistory: false,
+ run.DroppedTerminalReason,
+ run.DeferredAbortRunId,
+ run.DeferredAbortCount,
+ run.CompletedRunId,
+ run.CompletionPhase,
+ run.FetchRemoteUser,
+ run.AllowRemoteTurn,
+ run.WasAborted,
+ suppressed,
+ mapped,
+ toolMetadata,
+ snapshots.ToArray(),
+ gate.OpenedLifecycle,
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+ }
+
+ private ChatToolMetadataWrite? BuildToolMetadataWriteLocked(
+ string threadId,
+ ChatEvent mapped,
+ long timestampMs)
+ {
+ string toolName;
+ string label;
+ string? toolCallId;
+ System.Text.Json.Nodes.JsonObject? toolArgs;
+ ChatToolIdentityStrength identityStrength;
+ string? runId;
+ switch (mapped)
+ {
+ case ChatToolStartEvent start
+ when !string.IsNullOrWhiteSpace(start.ToolName):
+ toolName = start.ToolName;
+ label = start.Text;
+ toolCallId = start.ToolCallId;
+ toolArgs = start.ToolArgs;
+ identityStrength = start.IdentityStrength;
+ runId = start.RunId;
+ break;
+ case ChatToolPresentationEvent presentation:
+ toolName = presentation.ToolName;
+ label = NativeToolProjector.FirstToolDisplayValue(
+ presentation.ToolArgs);
+ toolCallId = presentation.ParentToolCallId;
+ toolArgs = presentation.ToolArgs;
+ identityStrength = presentation.IdentityStrength;
+ runId = presentation.RunId;
+ break;
+ default:
+ return null;
+ }
+
+ var legacyTurn = ResolveToolCacheLegacyTurnLocked(
+ threadId,
+ mapped,
+ runId,
+ toolCallId);
+ return new ChatToolMetadataWrite(
+ threadId,
+ _history.ResolveSessionId(threadId) ?? threadId,
+ GetResetVersionLocked(threadId),
+ timestampMs,
+ toolName,
+ label,
+ toolCallId,
+ toolArgs,
+ identityStrength,
+ runId,
+ legacyTurn);
+ }
+
+ private long ResolveToolCacheLegacyTurnLocked(
+ string threadId,
+ ChatEvent mapped,
+ string? runId,
+ string? toolCallId)
+ {
+ if (!string.IsNullOrWhiteSpace(runId))
+ return 0;
+ if (!_timelines.TryGetValue(threadId, out var timeline))
+ return ChatTimelineState.Initial().ToolLegacyTurn;
+ if (string.IsNullOrWhiteSpace(toolCallId))
+ return timeline.ToolLegacyTurn;
+
+ if (mapped is ChatToolPresentationEvent)
+ {
+ var pendingKey = timeline.PendingToolPresentations?.Keys
+ .Where(key => key.RunId is null &&
+ string.Equals(
+ key.ToolCallId,
+ toolCallId,
+ StringComparison.Ordinal))
+ .OrderByDescending(key => key.LegacyTurn)
+ .FirstOrDefault();
+ if (pendingKey is { ToolCallId.Length: > 0 })
+ return pendingKey.Value.LegacyTurn;
+ }
+
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (entry.Kind != ChatTimelineItemKind.ToolCall ||
+ entry.ToolRunId is not null)
+ {
+ continue;
+ }
+ if (string.Equals(
+ entry.ToolCallId,
+ toolCallId,
+ StringComparison.Ordinal) ||
+ entry.ToolCorrelationIds?.Contains(toolCallId) == true)
+ {
+ return entry.ToolLegacyTurn;
+ }
+ }
+ return timeline.ToolLegacyTurn;
+ }
+
+ private ChatAgentEventGate GateAgentEventLocked(
+ AgentEventInfo evt,
+ string threadId)
+ {
+ var resetGate = _reset.EvaluateAgentEvent(evt, threadId);
+ ChatOpenedLifecycleTransition? openedLifecycle = null;
+ if (resetGate.OpenedLifecycleStart is { } openedStart &&
+ ChatEventMapper.IsLifecycleStart(openedStart))
+ {
+ ApplyOpenedResetLifecycleStartLocked(
+ threadId,
+ openedStart);
+ }
+ else
+ {
+ openedLifecycle = ApplyBufferedLifecycleOpenLocked(
+ threadId,
+ resetGate.OpenedLifecycleStart,
+ allowRemoteTurn: false);
+ }
+ if (resetGate.Drop)
+ {
+ return new(
+ false,
+ resetGate.ReloadHistory,
+ null,
+ openedLifecycle);
+ }
+ if (ShouldDropTerminalAgentEventLocked(
+ evt,
+ threadId,
+ out var droppedReason))
+ {
+ return new(
+ false,
+ false,
+ droppedReason,
+ openedLifecycle);
+ }
+ return new(
+ true,
+ false,
+ null,
+ openedLifecycle);
+ }
+
+ private ChatRunTransition UpdateRunTrackingLocked(
+ AgentEventInfo evt,
+ string threadId,
+ ChatProjectionContext context)
+ {
+ string? deferredAbortRunId = null;
+ var deferredAbortCount = 0;
+ ChatTerminalEventDropReason? droppedReason = null;
+ string? completionPhase = null;
+ var fetchRemoteUser = false;
+ var allowRemoteTurn = false;
+ var wasAborted = false;
+ ChatDataSnapshot? snapshot = null;
+
+ if (string.Equals(
+ evt.Stream,
+ "lifecycle",
+ StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object &&
+ evt.Data.TryGetProperty("phase", out var phaseProperty))
+ {
+ var phase = phaseProperty.GetString()?.ToLowerInvariant();
+ if (phase == "start")
+ {
+ allowRemoteTurn =
+ !_queue.IsLocallyInitiated(threadId) &&
+ !_lifecycle.IsThreadSuppressed(threadId) &&
+ !_lifecycle.HasPendingAbort(threadId);
+ if (!string.IsNullOrEmpty(evt.RunId))
+ {
+ _lifecycle.StartRun(threadId, evt.RunId);
+ fetchRemoteUser = !_queue.IsLocallyInitiated(threadId);
+ var pendingCount =
+ _lifecycle.TakePendingAbortCount(threadId);
+ if (pendingCount > 0)
+ {
+ _lifecycle.MarkDeferredAbort(
+ threadId,
+ evt.RunId);
+ deferredAbortRunId = evt.RunId;
+ deferredAbortCount = pendingCount;
+ }
+ }
+ if (TryPromoteQueuedMessageOnLocalTurnStartLocked(evt, threadId))
+ snapshot = BuildSnapshotLocked(context);
+ }
+ else if (phase is "end" or "error")
+ {
+ completionPhase = phase;
+ wasAborted = _lifecycle.IsRunAborted(evt.RunId);
+ _reset.CompleteRun(threadId, evt.RunId);
+ _lifecycle.RemoveAbortedRun(evt.RunId);
+ _lifecycle.RemoveActiveRun(threadId);
+ _lifecycle.ClearThreadSuppression(threadId);
+ _queue.RemoveRunMappingByRunId(threadId, evt.RunId);
+ if (!_queue.HasPendingMessages(threadId))
+ _queue.ClearLocallyInitiated(threadId);
+ var pendingCount =
+ _lifecycle.TakePendingAbortCount(threadId);
+ if (pendingCount > 0)
+ {
+ deferredAbortRunId = evt.RunId;
+ deferredAbortCount = pendingCount;
+ }
+ }
+ }
+ else if (string.Equals(
+ evt.Stream,
+ "job",
+ StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object &&
+ evt.Data.TryGetProperty("state", out var stateProperty))
+ {
+ var phase = stateProperty.GetString()?.ToLowerInvariant();
+ if (phase is "done" or "error")
+ {
+ completionPhase = phase == "done" ? "end" : "error";
+ wasAborted = _lifecycle.IsRunAborted(evt.RunId);
+ _reset.CompleteRun(threadId, evt.RunId);
+ if (!string.IsNullOrWhiteSpace(evt.RunId))
+ {
+ _lifecycle.RemoveAbortedRun(evt.RunId);
+ _queue.RemoveRunMappingByRunId(threadId, evt.RunId);
+ }
+ _lifecycle.RemoveActiveRun(threadId);
+ }
+ }
+
+ return new(
+ deferredAbortRunId,
+ deferredAbortCount,
+ droppedReason,
+ evt.RunId,
+ completionPhase,
+ fetchRemoteUser,
+ allowRemoteTurn,
+ wasAborted,
+ snapshot);
+ }
+
+ private bool TryResolveTerminalApprovalLocked(
+ AgentEventInfo evt,
+ string threadId)
+ {
+ var terminal = ChatEventMapper.MapTerminalApproval(evt);
+ if (terminal is null)
+ return false;
+ var timeline = GetOrCreateTimelineLocked(threadId);
+ var pendingId = timeline.PendingPermission?.RequestId;
+ if (pendingId is null ||
+ !_approval.Matches(
+ pendingId,
+ terminal.ApprovalSlug,
+ terminal.ApprovalId))
+ {
+ return false;
+ }
+ _timelines[threadId] = ChatTimelineReducer.ResolvePermission(
+ timeline,
+ pendingId,
+ ChatEventMapper.MapTerminalApprovalDecision(
+ terminal.Phase,
+ terminal.Decision));
+ return true;
+ }
+
+ internal void CompleteRemoteBackfill(string threadId)
+ {
+ lock (_gate)
+ _reset.CompleteRemoteBackfill(threadId);
+ }
+
+ internal ChatRemoteUserBackfillTransition? ApplyRemoteUserBackfill(
+ string threadId,
+ ChatMessageInfo message,
+ long expectedResetGeneration,
+ bool openResetGate,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ if (GetResetVersionLocked(threadId) != expectedResetGeneration ||
+ _reset.IsPreResetTimestamp(threadId, message.Ts))
+ {
+ return null;
+ }
+ if (_timelines.TryGetValue(threadId, out var timeline))
+ {
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ if (timeline.Entries[i].Kind != ChatTimelineItemKind.User)
+ continue;
+ if (timeline.Entries[i].Text == message.Text)
+ return null;
+ break;
+ }
+ }
+ var openedLifecycle = openResetGate
+ ? ApplyBufferedLifecycleOpenLocked(
+ threadId,
+ _reset.RecordRemoteUser(threadId),
+ allowRemoteTurn: true)
+ : null;
+ ApplyEventLocked(
+ threadId,
+ new ChatUserMessageEvent(
+ ChatContentFormatting.TruncateForChatEntry(message.Text)),
+ BuildLiveMetaLocked(
+ threadId,
+ message.Ts,
+ message.OpenClawId,
+ message.OpenClawSeq));
+ return new(
+ BuildSnapshotLocked(context),
+ openedLifecycle,
+ CurrentRuntimeGenerationLocked(threadId));
+ }
+ }
+
+ private bool TryPromoteQueuedMessageOnLocalTurnStartLocked(
+ AgentEventInfo evt,
+ string threadId)
+ {
+ if (!_queue.IsLocallyInitiated(threadId))
+ return false;
+ if (!string.IsNullOrEmpty(evt.RunId) &&
+ _queue.TryResolveMessageForRun(
+ threadId,
+ evt.RunId,
+ out var messageId))
+ {
+ return PromoteQueuedMessageLocked(threadId, messageId);
+ }
+ return string.IsNullOrEmpty(evt.RunId) &&
+ _queue.TryGetSingleSendingMessage(threadId, out var queued) &&
+ PromoteQueuedMessageLocked(threadId, queued.Id);
+ }
+
+ private bool ShouldDropTerminalAgentEventLocked(
+ AgentEventInfo evt,
+ string threadId,
+ out ChatTerminalEventDropReason? droppedReason)
+ {
+ droppedReason = null;
+ if (!TryGetTerminalAgentRunId(evt, out var runId))
+ return false;
+ if (string.IsNullOrWhiteSpace(runId))
+ {
+ droppedReason = ChatTerminalEventDropReason.MissingRunId;
+ return true;
+ }
+ return _lifecycle.ShouldDropTerminal(
+ threadId,
+ runId,
+ _queue.RunIdsForThread(threadId),
+ _timelines.TryGetValue(threadId, out var timeline) &&
+ timeline.TurnActive,
+ out droppedReason);
+ }
+
+ private static bool TryGetTerminalAgentRunId(
+ AgentEventInfo evt,
+ out string runId)
+ {
+ runId = evt.RunId ?? string.Empty;
+ if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object)
+ return false;
+ if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.TryGetProperty("phase", out var phase))
+ {
+ var value = phase.GetString();
+ return string.Equals(value, "end", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(value, "error", StringComparison.OrdinalIgnoreCase);
+ }
+ if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.TryGetProperty("state", out var state))
+ {
+ var value = state.GetString();
+ return string.Equals(value, "done", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(value, "error", StringComparison.OrdinalIgnoreCase);
+ }
+ return false;
+ }
+
+ internal ChatDataSnapshot? SnapshotLatestAssistantUsage(
+ string threadId,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ var session = _presentation.ResolveSessionForThread(
+ threadId,
+ context.MainSessionKey);
+ return session is not null &&
+ SnapshotLatestAssistantUsageLocked(session, threadId)
+ ? BuildSnapshotLocked(context)
+ : null;
+ }
+ }
+
+ internal ChatDataSnapshot? SnapshotAssistantUsageContribution(
+ string threadId,
+ ChatEntryMetadata metadata,
+ ChatProjectionContext context)
+ {
+ lock (_gate)
+ {
+ return SnapshotAssistantUsageContributionLocked(threadId, metadata)
+ ? BuildSnapshotLocked(context)
+ : null;
+ }
+ }
+
+ private bool SnapshotAssistantUsageContributionLocked(
+ string threadId,
+ ChatEntryMetadata metadata)
+ {
+ var currentUsage = UsageValue(metadata);
+ if (currentUsage is null || currentUsage <= 0 ||
+ !_timelines.TryGetValue(threadId, out var timeline))
+ {
+ return false;
+ }
+ var contextTokens = metadata.ContextTokens;
+ if (contextTokens is null || contextTokens <= 0)
+ contextTokens = _presentation.ContextTokensForThread(threadId);
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (entry.Kind != ChatTimelineItemKind.Assistant)
+ continue;
+ var threadMetadata = GetOrCreateThreadMetaLocked(threadId);
+ threadMetadata.TryGetValue(entry.Id, out var existing);
+ var previousUsage = LatestAssistantUsageBeforeLocked(
+ timeline,
+ threadMetadata,
+ i);
+ var cumulative = Math.Max(
+ (previousUsage ?? 0) + currentUsage.Value,
+ existing?.ResponseTokens ?? 0);
+ if (existing?.ResponseTokens == cumulative &&
+ existing.UsageContributionTokens == currentUsage &&
+ existing.ContextTokens == contextTokens)
+ {
+ return false;
+ }
+ threadMetadata[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with
+ {
+ InputTokens = metadata.InputTokens ?? existing?.InputTokens,
+ OutputTokens = metadata.OutputTokens ?? existing?.OutputTokens,
+ ResponseTokens = cumulative,
+ ContextPercent = metadata.ContextPercent ?? existing?.ContextPercent,
+ ContextTokens = contextTokens ?? existing?.ContextTokens,
+ UsageContributionTokens = currentUsage,
+ };
+ return true;
+ }
+ return false;
+ }
+
+ private static int? LatestAssistantUsageBeforeLocked(
+ ChatTimelineState timeline,
+ IReadOnlyDictionary metadata,
+ int beforeIndex)
+ {
+ for (var i = beforeIndex - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (entry.Kind != ChatTimelineItemKind.Assistant ||
+ !metadata.TryGetValue(entry.Id, out var entryMetadata))
+ {
+ continue;
+ }
+ var value = UsageValue(entryMetadata);
+ if (value is > 0)
+ return value;
+ }
+ return null;
+ }
+
+ private static int? UsageValue(ChatEntryMetadata metadata) =>
+ metadata.ResponseTokens ??
+ (metadata.InputTokens is { } input &&
+ metadata.OutputTokens is { } output
+ ? input + output
+ : null);
+
+ private bool TryReconcileExistingLocalQueuedUserEchoLocked(
+ string threadId,
+ string text,
+ ChatEntryMetadata confirmed)
+ {
+ if (!HasGatewayIdentity(confirmed) ||
+ !_timelines.TryGetValue(threadId, out var timeline) ||
+ !_entryMeta.TryGetValue(threadId, out var metadata))
+ {
+ return false;
+ }
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (entry.Kind != ChatTimelineItemKind.User ||
+ !string.Equals(entry.Text, text, StringComparison.Ordinal) ||
+ !metadata.TryGetValue(entry.Id, out var existing) ||
+ !existing.IsLocalQueuedSend ||
+ !IsFreshLocalQueuedPromotion(existing, confirmed))
+ {
+ continue;
+ }
+ metadata[entry.Id] = confirmed with
+ {
+ IsLocalQueuedSend = false,
+ LocalQueuedMessageId = existing.LocalQueuedMessageId,
+ };
+ return true;
+ }
+ return false;
+ }
+
+ private static bool IsFreshLocalQueuedPromotion(
+ ChatEntryMetadata existing,
+ ChatEntryMetadata confirmed)
+ {
+ if (existing.Timestamp is not { } existingTimestamp)
+ return false;
+ return confirmed.Timestamp is { } confirmedTimestamp
+ ? (confirmedTimestamp - existingTimestamp).Duration() <=
+ LocalEchoSuppressionWindow
+ : DateTimeOffset.Now - existingTimestamp <= LocalEchoSuppressionWindow;
+ }
+
+ private bool ReconcileQueuedMessageEchoLocked(
+ string threadId,
+ string messageId,
+ ChatEntryMetadata confirmed)
+ {
+ if (PromoteQueuedMessageLocked(threadId, messageId, confirmed))
+ return true;
+ if (!HasGatewayIdentity(confirmed) ||
+ !_entryMeta.TryGetValue(threadId, out var metadata))
+ {
+ return false;
+ }
+ var match = metadata.FirstOrDefault(pair =>
+ string.Equals(
+ pair.Value.LocalQueuedMessageId,
+ messageId,
+ StringComparison.Ordinal));
+ if (string.IsNullOrEmpty(match.Key))
+ return false;
+ metadata[match.Key] = confirmed with
+ {
+ IsLocalQueuedSend = false,
+ LocalQueuedMessageId = messageId,
+ };
+ return true;
+ }
+
+ private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked(
+ string threadId,
+ string assistantText,
+ string? gatewayMessageId,
+ int? openClawSeq)
+ {
+ if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) &&
+ IsIdentifiedCompletedAssistantDuplicateLocked(
+ threadId,
+ assistantText,
+ gatewayMessageId,
+ openClawSeq))
+ {
+ return AssistantQueueFrameDisposition.Drop;
+ }
+ if (string.IsNullOrEmpty(gatewayMessageId) &&
+ openClawSeq is null &&
+ IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked(
+ threadId,
+ assistantText))
+ {
+ return AssistantQueueFrameDisposition.Drop;
+ }
+ if (!_queue.IsLocallyInitiated(threadId) ||
+ !_queue.TryGetSingleSendingMessage(threadId, out _) ||
+ _lifecycle.HasActiveRun(threadId) ||
+ _queue.IsAssistantFallbackPromoted(threadId) ||
+ !_timelines.TryGetValue(threadId, out var timeline))
+ {
+ return AssistantQueueFrameDisposition.Render;
+ }
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (entry.Kind != ChatTimelineItemKind.Assistant)
+ continue;
+ if (entry.IsStreaming ||
+ !string.Equals(entry.Text, assistantText, StringComparison.Ordinal))
+ {
+ return AssistantQueueFrameDisposition.Render;
+ }
+ if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null)
+ return AssistantQueueFrameDisposition.Drop;
+ if (!_entryMeta.TryGetValue(threadId, out var metadata) ||
+ !metadata.TryGetValue(entry.Id, out var existing))
+ {
+ return AssistantQueueFrameDisposition.Render;
+ }
+ var sameIdentity =
+ !string.IsNullOrEmpty(gatewayMessageId) &&
+ string.Equals(
+ existing.GatewayMessageId,
+ gatewayMessageId,
+ StringComparison.Ordinal) ||
+ openClawSeq is not null && existing.OpenClawSeq == openClawSeq;
+ return sameIdentity
+ ? AssistantQueueFrameDisposition.Drop
+ : AssistantQueueFrameDisposition.Render;
+ }
+ return AssistantQueueFrameDisposition.Render;
+ }
+
+ private bool IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked(
+ string threadId,
+ string assistantText)
+ {
+ if (!_queue.IsLocallyInitiated(threadId) ||
+ _lifecycle.HasActiveRun(threadId) ||
+ !_timelines.TryGetValue(threadId, out var timeline) ||
+ !_entryMeta.TryGetValue(threadId, out var metadata))
+ {
+ return false;
+ }
+ var sawBoundary = false;
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (!sawBoundary)
+ {
+ if (entry.Kind == ChatTimelineItemKind.Assistant)
+ return false;
+ if (entry.Kind == ChatTimelineItemKind.User &&
+ metadata.TryGetValue(entry.Id, out var entryMetadata) &&
+ entryMetadata.IsLocalQueuedSend)
+ {
+ sawBoundary = true;
+ }
+ continue;
+ }
+ if (entry.Kind == ChatTimelineItemKind.Assistant)
+ {
+ return !entry.IsStreaming &&
+ string.Equals(
+ entry.Text,
+ assistantText,
+ StringComparison.Ordinal);
+ }
+ if (entry.Kind == ChatTimelineItemKind.User)
+ return false;
+ }
+ return false;
+ }
+
+ private bool IsIdentifiedCompletedAssistantDuplicateLocked(
+ string threadId,
+ string assistantText,
+ string? gatewayMessageId,
+ int? openClawSeq)
+ {
+ if (!_timelines.TryGetValue(threadId, out var timeline) ||
+ !_entryMeta.TryGetValue(threadId, out var metadata))
+ {
+ return false;
+ }
+ for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ {
+ var entry = timeline.Entries[i];
+ if (entry.Kind != ChatTimelineItemKind.Assistant ||
+ entry.IsStreaming ||
+ !metadata.TryGetValue(entry.Id, out var existing))
+ {
+ continue;
+ }
+ var bothHaveIds = !string.IsNullOrEmpty(gatewayMessageId) &&
+ !string.IsNullOrEmpty(existing.GatewayMessageId);
+ if (bothHaveIds &&
+ string.Equals(
+ existing.GatewayMessageId,
+ gatewayMessageId,
+ StringComparison.Ordinal))
+ {
+ return true;
+ }
+ if (!bothHaveIds &&
+ openClawSeq is not null &&
+ existing.OpenClawSeq == openClawSeq &&
+ string.Equals(entry.Text, assistantText, StringComparison.Ordinal))
+ {
+ if (!string.IsNullOrEmpty(gatewayMessageId) &&
+ string.IsNullOrEmpty(existing.GatewayMessageId))
+ {
+ metadata[entry.Id] = existing with
+ {
+ GatewayMessageId = gatewayMessageId,
+ };
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void ApplyEventLocked(
+ string threadId,
+ ChatEvent evt,
+ ChatEntryMetadata? metadata)
+ {
+ var current = GetOrCreateTimelineLocked(threadId);
+ var beforeIds = current.Entries.Select(entry => entry.Id)
+ .ToHashSet(StringComparer.Ordinal);
+ var next = ChatTimelineReducer.Apply(current, evt);
+ _timelines[threadId] = next;
+ if (metadata is null)
+ return;
+ var threadMetadata = GetOrCreateThreadMetaLocked(threadId);
+ foreach (var entry in next.Entries)
+ {
+ if (!beforeIds.Contains(entry.Id) && !threadMetadata.ContainsKey(entry.Id))
+ threadMetadata[entry.Id] = metadata;
+ }
+ }
+
+ private Dictionary GetOrCreateThreadMetaLocked(
+ string threadId)
+ {
+ if (!_entryMeta.TryGetValue(threadId, out var metadata))
+ {
+ metadata = new Dictionary(StringComparer.Ordinal);
+ _entryMeta[threadId] = metadata;
+ }
+ return metadata;
+ }
+
+ private ChatEntryMetadata BuildLiveMetaLocked(
+ string threadId,
+ long? tsMs = null,
+ string? gatewayMessageId = null,
+ int? openClawSeq = null,
+ bool isLocalQueuedSend = false,
+ string? localQueuedMessageId = null,
+ string? openClawKind = null,
+ long? compactionTokensBefore = null,
+ long? compactionTokensAfter = null)
+ {
+ var timestamp = tsMs is { } value && value > 0
+ ? DateTimeOffset.FromUnixTimeMilliseconds(value).ToLocalTime()
+ : DateTimeOffset.Now;
+ return new ChatEntryMetadata(
+ timestamp,
+ _presentation.ModelForThread(threadId),
+ GatewayMessageId: gatewayMessageId,
+ OpenClawSeq: openClawSeq,
+ OpenClawKind: openClawKind,
+ CompactionTokensBefore: compactionTokensBefore,
+ CompactionTokensAfter: compactionTokensAfter,
+ IsLocalQueuedSend: isLocalQueuedSend,
+ LocalQueuedMessageId: localQueuedMessageId);
+ }
+
+ private ChatOpenedLifecycleTransition? AddResetAcceptedRunIdLocked(
+ string threadId,
+ string runId)
+ {
+ return ApplyBufferedLifecycleOpenLocked(
+ threadId,
+ _reset.AddAcceptedRun(threadId, runId),
+ allowRemoteTurn: false);
+ }
+
+ private ChatOpenedLifecycleTransition? ApplyBufferedLifecycleOpenLocked(
+ string threadId,
+ AgentEventInfo? lifecycleStart,
+ bool allowRemoteTurn)
+ {
+ if (string.IsNullOrEmpty(lifecycleStart?.RunId))
+ return null;
+
+ _lifecycle.StartRun(threadId, lifecycleStart.RunId);
+ var deferredAbortCount =
+ _lifecycle.TakePendingAbortCount(threadId);
+ string? deferredAbortRunId = null;
+ if (deferredAbortCount > 0)
+ {
+ deferredAbortRunId = lifecycleStart.RunId;
+ _lifecycle.MarkDeferredAbort(
+ threadId,
+ deferredAbortRunId);
+ }
+ return new(
+ lifecycleStart,
+ allowRemoteTurn && deferredAbortCount == 0,
+ deferredAbortRunId,
+ deferredAbortCount);
+ }
+
+ private void ApplyOpenedResetLifecycleStartLocked(
+ string threadId,
+ AgentEventInfo? lifecycleStart)
+ {
+ if (!string.IsNullOrEmpty(lifecycleStart?.RunId))
+ _lifecycle.StartRun(threadId, lifecycleStart.RunId);
+ }
+
+ private ChatDataSnapshot BuildSnapshotLocked(ChatProjectionContext context) =>
+ ChatSnapshotProjector.Project(CaptureProjectionInputLocked(context));
+
+ private ChatSnapshotProjectionInput CaptureProjectionInputLocked(
+ ChatProjectionContext context) =>
+ _presentation.CaptureProjectionInput(
+ timelines: new Dictionary(_timelines),
+ timelineGenerations: _reset.SnapshotVersions(),
+ historyRevisions: _history.SnapshotRevisions(),
+ queuedMessages: _queue.SnapshotMessages(),
+ status: _status,
+ context);
+
+ private ChatTimelineState GetOrCreateTimelineLocked(string threadId)
+ {
+ if (!_timelines.TryGetValue(threadId, out var current))
+ {
+ current = ChatTimelineState.Initial();
+ _timelines[threadId] = current;
+ }
+ return current;
+ }
+
+ private void EnsureTimelinesForSessionsLocked()
+ {
+ foreach (var session in _presentation.SessionSnapshot())
+ {
+ if (!string.IsNullOrEmpty(session.Key) &&
+ !_timelines.ContainsKey(session.Key))
+ {
+ _timelines[session.Key] = ChatTimelineState.Initial();
+ }
+ }
+ }
+
+ private long GetResetVersionLocked(string threadId) =>
+ _reset.GetVersion(threadId);
+
+ internal bool IsRuntimeGenerationCurrent(
+ string threadId,
+ ChatRuntimeGeneration generation)
+ {
+ lock (_gate)
+ {
+ return !_disposed &&
+ _history.ConnectionGeneration == generation.ConnectionGeneration &&
+ GetResetVersionLocked(threadId) == generation.ResetGeneration;
+ }
+ }
+
+ private ChatRuntimeGeneration CurrentRuntimeGenerationLocked(string threadId) =>
+ new(
+ _history.ConnectionGeneration,
+ GetResetVersionLocked(threadId));
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs
new file mode 100644
index 000000000..86d8786b3
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs
@@ -0,0 +1,416 @@
+using System.Text.Json;
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+
+namespace OpenClawTray.Chat;
+
+internal sealed record ChatApprovalIdentity(string RequestId, string? AlternateId);
+internal sealed record ChatEventMapping(ChatEvent? Event, ChatApprovalIdentity? Approval = null);
+internal sealed record ChatTerminalApprovalMapping(
+ string Phase,
+ string ApprovalId,
+ string ApprovalSlug,
+ string Decision);
+
+internal sealed record ChatFlattenedToolEvents(
+ ChatToolStartEvent Start,
+ ChatToolOutputEvent Output);
+
+internal static class ChatEventMapper
+{
+ internal static ChatEventMapping Map(AgentEventInfo evt)
+ {
+ var stream = evt.Stream?.ToLowerInvariant();
+ if (string.IsNullOrEmpty(stream))
+ return new(null);
+
+ return stream switch
+ {
+ "assistant" => new(MapAssistant(evt)),
+ "reasoning" => new(MapReasoning(evt)),
+ "lifecycle" => new(MapLifecycle(evt)),
+ "tool" => new(MapTool(evt)),
+ "item" => new(MapItem(evt)),
+ "command_output" => new(MapCommandOutput(evt)),
+ "job" => new(MapJob(evt)),
+ "approval" => MapApproval(evt),
+ _ => new(null),
+ };
+ }
+
+ internal static bool IsLifecycleStart(AgentEventInfo evt) =>
+ string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.ValueKind == JsonValueKind.Object &&
+ evt.Data.TryGetProperty("phase", out var phase) &&
+ string.Equals(phase.GetString(), "start", StringComparison.OrdinalIgnoreCase);
+
+ internal static bool IsTerminalRunEvent(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object)
+ return false;
+ if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.TryGetProperty("phase", out var phase))
+ {
+ var value = phase.GetString();
+ return string.Equals(value, "end", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(value, "error", StringComparison.OrdinalIgnoreCase);
+ }
+ if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.TryGetProperty("state", out var state))
+ {
+ var value = state.GetString();
+ return string.Equals(value, "done", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(value, "error", StringComparison.OrdinalIgnoreCase);
+ }
+ return false;
+ }
+
+ internal static ChatResponseOutputKind? ClassifyInboundOutput(
+ AgentEventInfo evt,
+ ChatEvent mapped)
+ {
+ if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return mapped switch
+ {
+ ChatMessageEvent or ChatMessageDeltaEvent => ChatResponseOutputKind.Assistant,
+ ChatThinkingEvent or ChatReasoningEvent or ChatReasoningDeltaEvent or
+ ChatIntentEvent => ChatResponseOutputKind.Reasoning,
+ ChatToolStartEvent or ChatToolPresentationEvent or
+ ChatToolOutputEvent or ChatToolErrorEvent or
+ ChatPermissionRequestEvent => ChatResponseOutputKind.Tool,
+ ChatStatusEvent or ChatErrorEvent or ChatReasoningEndEvent or
+ ChatTurnEndEvent or ChatUserMessageEvent => null,
+ _ => null,
+ };
+ }
+
+ internal static bool IsTerminalApprovalPhase(string phase)
+ {
+ if (string.IsNullOrEmpty(phase))
+ return false;
+
+ return string.Equals(phase, "resolved", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(phase, "denied", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(phase, "aborted", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(phase, "canceled", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(phase, "cancelled", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(phase, "expired", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(phase, "timeout", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(phase, "error", StringComparison.OrdinalIgnoreCase);
+ }
+
+ internal static ChatTerminalApprovalMapping? MapTerminalApproval(
+ AgentEventInfo evt)
+ {
+ if (!string.Equals(evt.Stream, "approval", StringComparison.OrdinalIgnoreCase) ||
+ evt.Data.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+ var phase = StringProperty(evt.Data, "phase");
+ return IsTerminalApprovalPhase(phase)
+ ? new ChatTerminalApprovalMapping(
+ phase,
+ StringProperty(evt.Data, "approvalId"),
+ StringProperty(evt.Data, "approvalSlug"),
+ StringProperty(evt.Data, "decision"))
+ : null;
+ }
+
+ internal static ChatPermissionDecision MapTerminalApprovalDecision(
+ string phase,
+ string? decision = null)
+ {
+ if (string.Equals(phase, "resolved", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.Equals(
+ decision,
+ ChatPermissionActionKeys.AllowAlways,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return ChatPermissionDecision.AllowedAlways;
+ }
+ if (string.Equals(
+ decision,
+ ChatPermissionActionKeys.Deny,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return ChatPermissionDecision.Denied;
+ }
+ return ChatPermissionDecision.Allowed;
+ }
+
+ return string.Equals(phase, "denied", StringComparison.OrdinalIgnoreCase)
+ ? ChatPermissionDecision.Denied
+ : ChatPermissionDecision.Expired;
+ }
+
+ internal static ChatFlattenedToolEvents MapFlattenedToolOutput(
+ string text,
+ string? runId)
+ {
+ var kind = NativeToolProjector.ClassifyFlattenedToolOutput(text);
+ var label = NativeToolProjector.ExtractFlattenedToolSummary(text);
+ return new(
+ new ChatToolStartEvent(
+ label,
+ kind,
+ IdentityStrength:
+ NativeToolProjector.ClassifyHistoryIdentityStrength(kind),
+ RunId: runId),
+ new ChatToolOutputEvent(text, RunId: runId));
+ }
+
+ private static ChatEvent? MapAssistant(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object)
+ return null;
+ if (evt.Data.TryGetProperty("delta", out var deltaProperty) &&
+ deltaProperty.ValueKind == JsonValueKind.String &&
+ deltaProperty.GetString() is { Length: > 0 } delta)
+ {
+ return new ChatMessageDeltaEvent(delta);
+ }
+ return null;
+ }
+
+ private static ChatEvent? MapReasoning(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object)
+ return null;
+ if (evt.Data.TryGetProperty("delta", out var deltaProperty) &&
+ deltaProperty.ValueKind == JsonValueKind.String &&
+ deltaProperty.GetString() is { Length: > 0 } delta)
+ {
+ return new ChatReasoningDeltaEvent(delta);
+ }
+
+ var content = evt.Data.TryGetProperty("content", out var contentProperty) &&
+ contentProperty.ValueKind == JsonValueKind.String
+ ? contentProperty.GetString()
+ : evt.Data.TryGetProperty("text", out var textProperty) &&
+ textProperty.ValueKind == JsonValueKind.String
+ ? textProperty.GetString()
+ : null;
+ return string.IsNullOrEmpty(content) ? null : new ChatReasoningEvent(content);
+ }
+
+ private static ChatEvent? MapLifecycle(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object ||
+ !evt.Data.TryGetProperty("phase", out var phaseProperty))
+ {
+ return null;
+ }
+
+ return phaseProperty.GetString()?.ToLowerInvariant() switch
+ {
+ "start" => new ChatThinkingEvent(""),
+ "end" => new ChatTurnEndEvent(),
+ "error" => new ChatErrorEvent(
+ evt.Summary ??
+ (evt.Data.TryGetProperty("message", out var message)
+ ? message.GetString() ?? "Agent error"
+ : "Agent error")),
+ _ => null,
+ };
+ }
+
+ private static ChatEvent? MapTool(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object)
+ return null;
+
+ var phase = NativeToolProjector.GetStringProperty(evt.Data, "phase");
+ var identity = NativeToolProjector.ExtractToolIdentity(evt.Data);
+ var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data);
+ var label = NativeToolProjector.ExtractToolLabel(evt.Data, toolArgs);
+ string? toolCallId = NativeToolProjector.GetStringProperty(
+ evt.Data,
+ "itemId",
+ "callId");
+ if (string.IsNullOrWhiteSpace(toolCallId))
+ toolCallId = null;
+
+ return phase.ToLowerInvariant() switch
+ {
+ "start" => new ChatToolStartEvent(
+ label,
+ identity.Name,
+ ToolArgs: toolArgs,
+ ToolCallId: toolCallId,
+ IdentityStrength: identity.Strength,
+ RunId: evt.RunId),
+ "result" => new ChatToolOutputEvent(
+ NativeToolProjector.ExtractToolResultText(evt.Data, label),
+ ToolCallId: toolCallId,
+ RunId: evt.RunId),
+ "error" => new ChatToolErrorEvent(
+ NativeToolProjector.ExtractToolErrorText(evt.Data, label),
+ ToolCallId: toolCallId,
+ RunId: evt.RunId),
+ _ => null,
+ };
+ }
+
+ private static ChatEvent? MapItem(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object)
+ return null;
+
+ var kind = NativeToolProjector.GetStringProperty(evt.Data, "kind");
+ var phase = NativeToolProjector.GetStringProperty(evt.Data, "phase");
+ if (string.Equals(kind, "reasoning", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase)
+ ? new ChatReasoningEndEvent()
+ : null;
+ }
+
+ if (string.Equals(kind, "command", StringComparison.OrdinalIgnoreCase))
+ {
+ var normalizedPhase = phase.ToLowerInvariant();
+ if (normalizedPhase is not ("start" or "update"))
+ return null;
+
+ var parentItemId = NativeToolProjector.ExtractParentToolCallId(evt.Data);
+ if (string.IsNullOrWhiteSpace(parentItemId))
+ return null;
+
+ var childIdentity = NativeToolProjector.ExtractToolIdentity(evt.Data);
+ var commandArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data);
+ var childItemId = NativeToolProjector.GetStringProperty(
+ evt.Data,
+ "itemId",
+ "commandItemId",
+ "callId");
+ return new ChatToolPresentationEvent(
+ parentItemId,
+ childIdentity.Name,
+ childIdentity.Strength,
+ commandArgs,
+ childItemId,
+ ActivatesTurn: normalizedPhase == "start",
+ RunId: evt.RunId);
+ }
+
+ if (!string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ var title = NativeToolProjector.GetStringProperty(evt.Data, "title");
+ var identity = NativeToolProjector.ExtractToolIdentity(evt.Data);
+ var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data);
+ var label = NativeToolProjector.FirstToolDisplayValue(toolArgs);
+ if (string.IsNullOrWhiteSpace(label))
+ label = NativeToolProjector.SanitizeToolDisplayValue(title);
+ var itemId = NativeToolProjector.GetStringProperty(
+ evt.Data,
+ "itemId",
+ "callId");
+ if (string.IsNullOrWhiteSpace(itemId))
+ itemId = null;
+
+ return phase.ToLowerInvariant() switch
+ {
+ "start" => new ChatToolStartEvent(
+ label,
+ identity.Name,
+ ToolArgs: toolArgs,
+ ToolCallId: itemId,
+ IdentityStrength: identity.Strength,
+ RunId: evt.RunId),
+ "end" => new ChatToolOutputEvent(
+ string.Empty,
+ ToolCallId: itemId,
+ RunId: evt.RunId),
+ "error" => new ChatToolErrorEvent(
+ NativeToolProjector.SanitizeToolDisplayValue(title),
+ ToolCallId: itemId,
+ RunId: evt.RunId),
+ _ => null,
+ };
+ }
+
+ private static ChatEvent? MapCommandOutput(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object ||
+ !string.Equals(
+ StringProperty(evt.Data, "phase"),
+ "end",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var output = NativeToolProjector.ExtractCommandOutputText(evt.Data);
+ if (string.IsNullOrEmpty(output))
+ return null;
+ string? itemId = NativeToolProjector.GetStringProperty(
+ evt.Data,
+ "parentItemId",
+ "itemId");
+ if (string.IsNullOrWhiteSpace(itemId))
+ itemId = null;
+ return new ChatToolOutputEvent(
+ output,
+ ToolCallId: itemId,
+ RunId: evt.RunId);
+ }
+
+ private static ChatEvent? MapJob(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object)
+ return null;
+ return StringProperty(evt.Data, "state").ToLowerInvariant() switch
+ {
+ "done" => new ChatTurnEndEvent(),
+ "error" => new ChatErrorEvent(evt.Summary ?? "Agent error"),
+ _ => null,
+ };
+ }
+
+ private static ChatEventMapping MapApproval(AgentEventInfo evt)
+ {
+ if (evt.Data.ValueKind != JsonValueKind.Object ||
+ !string.Equals(
+ StringProperty(evt.Data, "phase"),
+ "requested",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return new(null);
+ }
+
+ var approvalId = StringProperty(evt.Data, "approvalId");
+ var slug = StringProperty(evt.Data, "approvalSlug");
+ var requestId = !string.IsNullOrEmpty(slug) ? slug : approvalId;
+ if (string.IsNullOrEmpty(requestId))
+ return new(null);
+
+ var host = StringProperty(evt.Data, "host");
+ var command = StringProperty(evt.Data, "command");
+ var title = StringProperty(evt.Data, "title");
+ var message = StringProperty(evt.Data, "message");
+ var detail = string.IsNullOrEmpty(message)
+ ? command
+ : string.IsNullOrEmpty(command) ? message : message + "\n\n" + command;
+ var mapped = new ChatPermissionRequestEvent(
+ requestId,
+ !string.IsNullOrEmpty(title) ? title : "Exec approval",
+ !string.IsNullOrEmpty(host) ? host : "node",
+ detail,
+ ChatPermissionActionKeys.ExecApprovalDefaults);
+ var alternateId = !string.IsNullOrEmpty(slug) ? approvalId : slug;
+ return new(mapped, new ChatApprovalIdentity(requestId, alternateId));
+ }
+
+ private static string StringProperty(JsonElement data, string name) =>
+ data.TryGetProperty(name, out var property) &&
+ property.ValueKind == JsonValueKind.String
+ ? property.GetString() ?? string.Empty
+ : string.Empty;
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs
new file mode 100644
index 000000000..33f754336
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs
@@ -0,0 +1,902 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+#if !OPENCLAW_TRAY_TESTS
+using OpenClawTray.Helpers;
+#endif
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal sealed record ChatHistoryLoadResult(
+ ChatHistoryCommitToken Token,
+ bool PublishSnapshot,
+ ChatProviderNotification? Notification = null);
+
+///
+/// Owns history request lifetime, in-flight coalescing, cancellation, retries,
+/// and immutable transcript rebuild plans. Conversation state alone accepts
+/// or rejects plans against its authoritative generation/reset token.
+///
+internal sealed class ChatHistoryLoader : IDisposable
+{
+ private readonly record struct PendingReload(
+ ChatHistoryCommitToken Token,
+ bool Replacement);
+
+ private const int MaxRetries = 3;
+ private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
+
+ private readonly object _gate = new();
+ private readonly IChatGatewayBridge _bridge;
+ private readonly ChatConversationState _state;
+ private readonly ChatMetadataStore _metadata;
+ private readonly ChatStatePersistence _persistence;
+ private readonly ChatTelemetryTracker _telemetry;
+ private readonly Func, Task> _retryScheduler;
+ private readonly Action? _failureReservedForTesting;
+ private readonly Dictionary _inFlight = new(StringComparer.Ordinal);
+ private readonly Dictionary _authoritativePending =
+ new(StringComparer.Ordinal);
+ private readonly Dictionary _replacementPending =
+ new(StringComparer.Ordinal);
+ private readonly Dictionary _retryCounts = new();
+ private CancellationTokenSource _generationCancellation = new();
+ private long _appliedStateGeneration;
+ private long _requestSequence;
+ private bool _disposed;
+
+ internal ChatHistoryLoader(
+ IChatGatewayBridge bridge,
+ ChatConversationState state,
+ ChatMetadataStore metadata,
+ ChatStatePersistence persistence,
+ ChatTelemetryTracker telemetry,
+ Func, Task>? retryScheduler = null,
+ Action? failureReservedForTesting = null)
+ {
+ _bridge = bridge;
+ _state = state;
+ _metadata = metadata;
+ _persistence = persistence;
+ _telemetry = telemetry;
+ _retryScheduler = retryScheduler ?? (static async (delay, token, retry) =>
+ {
+ await Task.Delay(delay, token).ConfigureAwait(false);
+ await retry().ConfigureAwait(false);
+ });
+ _failureReservedForTesting = failureReservedForTesting;
+ }
+
+ internal event EventHandler? Completed;
+
+ internal Task LoadAsync(
+ string threadId,
+ bool force = false,
+ CancellationToken cancellationToken = default,
+ bool authoritative = false,
+ ChatHistoryCommitToken? expectedToken = null) =>
+ LoadCoreAsync(
+ threadId,
+ force,
+ cancellationToken,
+ authoritative,
+ expectedToken,
+ replacement: false,
+ supersedeReplacement: false);
+
+ internal Task LoadReplacementAsync(
+ string threadId,
+ ChatHistoryCommitToken token,
+ CancellationToken cancellationToken = default)
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return Task.CompletedTask;
+ _authoritativePending.Remove(threadId);
+ _replacementPending.Remove(threadId);
+ foreach (var retryToken in _retryCounts.Keys
+ .Where(candidate => string.Equals(
+ candidate.ThreadId,
+ threadId,
+ StringComparison.Ordinal))
+ .ToArray())
+ {
+ _retryCounts.Remove(retryToken);
+ }
+ }
+ return LoadCoreAsync(
+ threadId,
+ force: true,
+ cancellationToken,
+ authoritative: false,
+ expectedToken: token,
+ replacement: true,
+ supersedeReplacement: true);
+ }
+
+ internal ChatStatusTransition ApplyStatusAndAdvanceGeneration(
+ ConnectionStatus status,
+ ChatProjectionContext context)
+ {
+ CancellationTokenSource? previous = null;
+ ChatStatusTransition transition;
+ lock (_gate)
+ {
+ transition = _state.ApplyStatus(status, context);
+ if ((transition.Reconnected || transition.Disconnected) &&
+ transition.HistoryGeneration > _appliedStateGeneration)
+ {
+ _appliedStateGeneration = transition.HistoryGeneration;
+ previous = _generationCancellation;
+ _generationCancellation = new CancellationTokenSource();
+ _inFlight.Clear();
+ _authoritativePending.Clear();
+ _replacementPending.Clear();
+ _retryCounts.Clear();
+ _state.ActivateHistoryGeneration(transition.HistoryGeneration);
+ }
+ }
+ previous?.Cancel();
+ previous?.Dispose();
+ return transition;
+ }
+
+ internal void ApplyReset(string threadId, long resetGeneration)
+ {
+ lock (_gate)
+ {
+ RemoveOlderPendingReload(
+ _authoritativePending,
+ threadId,
+ resetGeneration);
+ RemoveOlderPendingReload(
+ _replacementPending,
+ threadId,
+ resetGeneration);
+ foreach (var token in _retryCounts.Keys
+ .Where(candidate => string.Equals(
+ candidate.ThreadId,
+ threadId,
+ StringComparison.Ordinal) &&
+ candidate.ResetGeneration < resetGeneration)
+ .ToArray())
+ {
+ _retryCounts.Remove(token);
+ }
+ }
+ }
+
+ private static void RemoveOlderPendingReload(
+ Dictionary pending,
+ string threadId,
+ long resetGeneration)
+ {
+ if (pending.TryGetValue(threadId, out var token) &&
+ token.ResetGeneration < resetGeneration)
+ {
+ pending.Remove(threadId);
+ }
+ }
+
+ public void Dispose()
+ {
+ CancellationTokenSource cancellation;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ cancellation = _generationCancellation;
+ _inFlight.Clear();
+ _authoritativePending.Clear();
+ _replacementPending.Clear();
+ _retryCounts.Clear();
+ }
+ cancellation.Cancel();
+ cancellation.Dispose();
+ Completed = null;
+ }
+
+ private async Task LoadCoreAsync(
+ string threadId,
+ bool force,
+ CancellationToken cancellationToken,
+ bool authoritative,
+ ChatHistoryCommitToken? expectedToken,
+ bool replacement,
+ bool supersedeReplacement)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (string.IsNullOrEmpty(threadId))
+ return;
+
+ CancellationToken generationToken;
+ long requestId;
+ ChatHistoryCommitToken commitToken;
+ string? model;
+ Task? generationActivation;
+ bool canBegin;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ if (expectedToken is { } expected &&
+ !_state.IsHistoryRequestCurrent(expected))
+ {
+ return;
+ }
+ if (_inFlight.ContainsKey(threadId))
+ {
+ if (replacement)
+ {
+ var replacementToken = expectedToken ??
+ _state.CaptureHistoryToken(threadId);
+ if (supersedeReplacement)
+ _replacementPending[threadId] = replacementToken;
+ else
+ _replacementPending.TryAdd(threadId, replacementToken);
+ }
+ else if (authoritative)
+ {
+ if (expectedToken is { } retryToken)
+ {
+ _authoritativePending.TryAdd(threadId, retryToken);
+ }
+ else
+ {
+ _authoritativePending[threadId] =
+ _state.CaptureHistoryToken(threadId);
+ }
+ }
+ return;
+ }
+ requestId = ++_requestSequence;
+ _inFlight[threadId] = requestId;
+ generationToken = _generationCancellation.Token;
+ canBegin = _state.TryBeginHistory(
+ threadId,
+ force,
+ expectedToken,
+ out commitToken,
+ out model,
+ out generationActivation);
+ }
+
+ if (!canBegin)
+ {
+ CompleteInFlight(
+ threadId,
+ requestId,
+ out var pendingReload);
+ if (pendingReload is { } pending)
+ {
+ _ = ObserveRetryAsync(RerunAfterActivationAsync(
+ threadId,
+ generationActivation,
+ generationToken,
+ pending));
+ return;
+ }
+ if (generationActivation is not null)
+ {
+ using var activationCancellation =
+ CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ generationToken);
+ await generationActivation
+ .WaitAsync(activationCancellation.Token)
+ .ConfigureAwait(false);
+ await LoadCoreAsync(
+ threadId,
+ force,
+ cancellationToken,
+ authoritative,
+ commitToken,
+ replacement,
+ supersedeReplacement: false)
+ .ConfigureAwait(false);
+ }
+ return;
+ }
+
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ generationToken);
+ var requestStartedAt = DateTimeOffset.Now;
+ var operation = _telemetry.StartHistoryLoad(
+ force ? ChatHistoryTelemetrySource.Forced : ChatHistoryTelemetrySource.Initial);
+ var outcome = ChatTelemetryOutcome.Success;
+ Exception? failure = null;
+ Task? request = null;
+ try
+ {
+ request = _bridge.RequestChatHistoryAsync(threadId);
+ var history = await request
+ .WaitAsync(linkedCancellation.Token)
+ .ConfigureAwait(false);
+ if (!_state.IsHistoryRequestCurrent(commitToken))
+ {
+ outcome = ChatTelemetryOutcome.Canceled;
+ return;
+ }
+
+ var plan = BuildPlan(
+ history,
+ threadId,
+ model,
+ commitToken.ResetGeneration);
+ var committed = _state.CommitHistory(
+ commitToken,
+ plan,
+ requestStartedAt,
+ authoritative);
+ if (!committed)
+ {
+ outcome = ChatTelemetryOutcome.Canceled;
+ return;
+ }
+ lock (_gate)
+ _retryCounts.Remove(commitToken);
+ Completed?.Invoke(
+ this,
+ new ChatHistoryLoadResult(
+ commitToken,
+ PublishSnapshot: true));
+ }
+ catch (OperationCanceledException)
+ {
+ outcome = ChatTelemetryOutcome.Canceled;
+ if (request is not null)
+ _ = ObserveCanceledRequestAsync(request);
+ }
+ catch (Exception ex)
+ {
+ _failureReservedForTesting?.Invoke();
+ if (!_state.IsHistoryRequestCurrent(commitToken))
+ {
+ outcome = ChatTelemetryOutcome.Canceled;
+ failure = null;
+ return;
+ }
+ outcome = ChatTelemetryOutcome.Failure;
+ failure = ex;
+ var shouldRetry = false;
+ lock (_gate)
+ {
+ if (!_disposed &&
+ _state.CanRetryHistory(commitToken, authoritative))
+ {
+ _retryCounts.TryGetValue(commitToken, out var retryCount);
+ shouldRetry = retryCount < MaxRetries;
+ if (shouldRetry)
+ _retryCounts[commitToken] = retryCount + 1;
+ }
+ }
+ if (_state.IsHistoryRequestCurrent(commitToken))
+ {
+ Completed?.Invoke(
+ this,
+ new ChatHistoryLoadResult(
+ commitToken,
+ PublishSnapshot: false,
+ new ChatProviderNotification(
+ ChatProviderNotificationKind.Error,
+ threadId,
+ LocalizationHelper.GetString(
+ "Chat_Notification_LoadHistoryFailed"),
+ ex.Message)));
+ }
+ if (!_state.IsHistoryRequestCurrent(commitToken))
+ {
+ outcome = ChatTelemetryOutcome.Canceled;
+ failure = null;
+ shouldRetry = false;
+ }
+ if (shouldRetry)
+ {
+ _ = ObserveRetryAsync(_retryScheduler(
+ RetryDelay,
+ generationToken,
+ () => LoadCoreAsync(
+ threadId,
+ force: true,
+ CancellationToken.None,
+ authoritative,
+ commitToken,
+ replacement,
+ supersedeReplacement: false)));
+ }
+ }
+ finally
+ {
+ _telemetry.FinishHistoryLoad(operation, outcome, failure);
+ CompleteInFlight(threadId, requestId, out var pendingReload);
+ if (pendingReload is { } pending)
+ {
+ _ = LoadCoreAsync(
+ threadId,
+ force: true,
+ CancellationToken.None,
+ authoritative: !pending.Replacement,
+ expectedToken: pending.Token,
+ replacement: pending.Replacement,
+ supersedeReplacement: false);
+ }
+ }
+ }
+
+ private async Task RerunAfterActivationAsync(
+ string threadId,
+ Task? generationActivation,
+ CancellationToken generationToken,
+ PendingReload pending)
+ {
+ if (generationActivation is not null)
+ {
+ await generationActivation
+ .WaitAsync(generationToken)
+ .ConfigureAwait(false);
+ }
+ generationToken.ThrowIfCancellationRequested();
+ await LoadCoreAsync(
+ threadId,
+ force: true,
+ CancellationToken.None,
+ authoritative: !pending.Replacement,
+ expectedToken: pending.Token,
+ replacement: pending.Replacement,
+ supersedeReplacement: false)
+ .ConfigureAwait(false);
+ }
+
+ private ChatHistoryRebuildPlan BuildPlan(
+ ChatHistoryInfo history,
+ string threadId,
+ string? model,
+ long resetGeneration)
+ {
+ var timeline = ChatTimelineState.Initial() with { HistoryLoaded = true };
+ var metadata = new Dictionary(StringComparer.Ordinal);
+ var cachedTools = _metadata.GetToolMetadata(
+ history.SessionId,
+ threadId,
+ resetGeneration);
+ var attachmentMatcher = _metadata.CreateAttachmentMatcher(
+ history.SessionId,
+ threadId,
+ resetGeneration);
+ var nextAssistantIsAborted = false;
+ var pendingUnkeyedToolCalls = new Queue();
+ var syntheticToolCallSequence = 0;
+ ChatMessageInfo? suppressedAbortedAssistant = null;
+
+ ChatTimelineState Apply(
+ ChatTimelineState current,
+ ChatEvent evt,
+ ChatEntryMetadata? entryMetadata)
+ {
+ var before = current.Entries
+ .Select(entry => entry.Id)
+ .ToHashSet(StringComparer.Ordinal);
+ var next = ChatTimelineReducer.Apply(current, evt);
+ if (entryMetadata is not null)
+ {
+ foreach (var entry in next.Entries)
+ {
+ if (!before.Contains(entry.Id) && !metadata.ContainsKey(entry.Id))
+ metadata[entry.Id] = entryMetadata;
+ }
+ }
+ return next;
+ }
+
+ var orderedMessages = OrderHistoryMessages(history.Messages);
+ foreach (var replayPart in
+ ChatHistoryReplayProjection.Project(orderedMessages))
+ {
+ var message = replayPart.Message;
+ if (suppressedAbortedAssistant is not null)
+ {
+ if (ReferenceEquals(suppressedAbortedAssistant, message))
+ continue;
+ suppressedAbortedAssistant = null;
+ }
+
+ var role = message.Role?.ToLowerInvariant() ?? string.Empty;
+ var entryMetadata = new ChatEntryMetadata(
+ message.Ts > 0
+ ? DateTimeOffset.FromUnixTimeMilliseconds(message.Ts).ToLocalTime()
+ : null,
+ model,
+ message.InputTokens,
+ message.OutputTokens,
+ message.ResponseTokens,
+ message.ContextPercent,
+ GatewayMessageId: message.OpenClawId,
+ OpenClawSeq: message.OpenClawSeq,
+ OpenClawKind: message.OpenClawKind,
+ CompactionTokensBefore: message.CompactionTokensBefore,
+ CompactionTokensAfter: message.CompactionTokensAfter);
+ var text = ChatContentFormatting.TruncateForChatEntry(
+ ChatMetadataStore.EscapeUntrustedAttachmentMarkerLines(
+ replayPart.Text));
+ if (role == "user")
+ text = ChatMetadataStore.RehydrateAttachmentMarkers(
+ attachmentMatcher,
+ text,
+ message.Ts);
+ var hasStructuredToolContent =
+ replayPart.ToolContent.Count > 0;
+
+ if (role == "user" &&
+ _persistence.IsMessageAborted(
+ threadId,
+ message.OpenClawId,
+ resetGeneration))
+ {
+ nextAssistantIsAborted = true;
+ }
+ var gatewayAborted = role == "assistant" &&
+ !string.IsNullOrEmpty(message.StopReason) &&
+ !string.Equals(message.StopReason, "stop", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(message.StopReason, "toolUse", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(message.StopReason, "end_turn", StringComparison.OrdinalIgnoreCase);
+ var isFirstAssistantPart =
+ role == "assistant" && replayPart.IsFirstPart;
+ var markAborted = isFirstAssistantPart &&
+ (nextAssistantIsAborted || gatewayAborted);
+ if (isFirstAssistantPart)
+ nextAssistantIsAborted = false;
+ if (markAborted)
+ {
+ timeline = Apply(
+ timeline,
+ new ChatStatusEvent(
+ "Response was stopped",
+ ChatTone.Warning),
+ entryMetadata);
+ timeline = ChatTimelineReducer.Apply(
+ timeline,
+ new ChatTurnEndEvent());
+ suppressedAbortedAssistant = message;
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(text) &&
+ !hasStructuredToolContent)
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(text))
+ {
+ switch (role)
+ {
+ case "user":
+ if (ChatContentFormatting.LooksLikeApprovalSlashCommand(text) ||
+ NativeToolProjector.LooksLikeSystemControlNote(text))
+ {
+ timeline = Apply(
+ timeline,
+ new ChatStatusEvent(text, ChatTone.Dim),
+ entryMetadata);
+ }
+ else
+ {
+ timeline = timeline with
+ {
+ ActiveAssistantId = null,
+ ActiveReasoningId = null,
+ };
+ timeline = Apply(
+ timeline,
+ new ChatUserMessageEvent(text),
+ entryMetadata);
+ }
+ break;
+ case "assistant":
+ if (ChatMessageInfo.IsSilentAssistantDirective(
+ role,
+ text))
+ {
+ break;
+ }
+ if (NativeToolProjector.LooksLikeSystemControlNote(text))
+ {
+ timeline = Apply(
+ timeline,
+ new ChatStatusEvent(text, ChatTone.Dim),
+ entryMetadata);
+ }
+ else if (NativeToolProjector.LooksLikeFlattenedToolOutput(text))
+ {
+ var cached = ChatMetadataStore.TryMatchCachedTool(
+ cachedTools,
+ message.Ts);
+ var kind = cached?.ToolName ??
+ NativeToolProjector.ClassifyFlattenedToolOutput(text);
+ var label = cached?.Label ??
+ NativeToolProjector.ExtractFlattenedToolSummary(text);
+ timeline = Apply(
+ timeline,
+ new ChatToolStartEvent(
+ label,
+ kind,
+ ToolArgs: cached?.ToolArgs,
+ ToolCallId: cached?.ToolCallId,
+ IdentityStrength: cached?.IdentityStrength ??
+ NativeToolProjector.ClassifyHistoryIdentityStrength(
+ kind),
+ RunId: cached?.RunId),
+ entryMetadata);
+ timeline = Apply(
+ timeline,
+ new ChatToolOutputEvent(
+ text,
+ ToolCallId: cached?.ToolCallId,
+ RunId: cached?.RunId),
+ entryMetadata);
+ }
+ else
+ {
+ timeline = Apply(
+ timeline,
+ new ChatMessageEvent(
+ ChatContentFormatting.RepairContentBlockSeams(
+ text)),
+ entryMetadata);
+ if (timeline.ActiveToolCalls.Count > 0 ||
+ timeline.ActiveToolCallId is not null)
+ {
+ timeline = timeline with
+ {
+ ActiveAssistantId = null,
+ ActiveReasoningId = null,
+ };
+ }
+ else
+ {
+ timeline = ChatTimelineReducer.Apply(
+ timeline,
+ new ChatTurnEndEvent());
+ }
+ }
+ break;
+ case "toolresult":
+ case "tool_result":
+ if (hasStructuredToolContent)
+ break;
+ var cachedTool = ChatMetadataStore.TryMatchCachedTool(
+ cachedTools,
+ message.Ts);
+ var toolKind = cachedTool?.ToolName ??
+ NativeToolProjector.ClassifyFlattenedToolOutput(text);
+ var toolLabel = cachedTool?.Label ??
+ NativeToolProjector.ExtractFlattenedToolSummary(text);
+ timeline = Apply(
+ timeline,
+ new ChatToolStartEvent(
+ toolLabel,
+ toolKind,
+ ToolArgs: cachedTool?.ToolArgs,
+ ToolCallId: cachedTool?.ToolCallId,
+ IdentityStrength: cachedTool?.IdentityStrength ??
+ NativeToolProjector.ClassifyHistoryIdentityStrength(
+ toolKind),
+ RunId: cachedTool?.RunId),
+ entryMetadata);
+ timeline = Apply(
+ timeline,
+ new ChatToolOutputEvent(
+ text,
+ ToolCallId: cachedTool?.ToolCallId,
+ RunId: cachedTool?.RunId),
+ entryMetadata);
+ break;
+ case "system":
+ case "tool":
+ timeline = Apply(
+ timeline,
+ new ChatStatusEvent(text, ChatTone.Dim),
+ entryMetadata);
+ break;
+ default:
+ timeline = Apply(
+ timeline,
+ new ChatMessageEvent(
+ ChatContentFormatting.RepairContentBlockSeams(
+ text)),
+ entryMetadata);
+ timeline = ChatTimelineReducer.Apply(
+ timeline,
+ new ChatTurnEndEvent());
+ break;
+ }
+ }
+
+ foreach (var toolBlock in replayPart.ToolContent)
+ {
+ if (toolBlock.Kind == ChatToolContentKind.Call)
+ {
+ _ = ChatMetadataStore.TryMatchCachedTool(
+ cachedTools,
+ message.Ts);
+ var args =
+ ChatHistoryReplayProjection.ProjectToolArgs(
+ toolBlock.Args);
+ var callId = toolBlock.CallId;
+ if (string.IsNullOrWhiteSpace(callId))
+ {
+ callId =
+ $"history-tool-{syntheticToolCallSequence++}";
+ pendingUnkeyedToolCalls.Enqueue(callId);
+ }
+ timeline = Apply(
+ timeline,
+ new ChatToolStartEvent(
+ ChatHistoryReplayProjection.ToolLabel(
+ toolBlock.ToolName,
+ args),
+ toolBlock.ToolName,
+ args,
+ callId),
+ entryMetadata);
+ continue;
+ }
+
+ var resultCallId = toolBlock.CallId;
+ if (string.IsNullOrWhiteSpace(resultCallId))
+ {
+ resultCallId =
+ pendingUnkeyedToolCalls.Count > 0
+ ? pendingUnkeyedToolCalls.Dequeue()
+ : $"history-tool-{syntheticToolCallSequence++}";
+ }
+ var correlationKey = new ChatToolCorrelationKey(
+ RunId: null,
+ LegacyTurn: timeline.ToolLegacyTurn,
+ ToolCallId: resultCallId);
+ if (!timeline.ActiveToolCalls.ContainsKey(correlationKey))
+ {
+ var cached = ChatMetadataStore.TryMatchCachedTool(
+ cachedTools,
+ message.Ts);
+ var toolName =
+ cached?.ToolName ?? toolBlock.ToolName;
+ timeline = Apply(
+ timeline,
+ new ChatToolStartEvent(
+ cached?.Label ?? toolName,
+ toolName,
+ ToolCallId: resultCallId),
+ entryMetadata);
+ }
+ var output = NativeToolProjector.TruncateToolOutput(
+ toolBlock.Text ?? string.Empty);
+ timeline = Apply(
+ timeline,
+ toolBlock.IsError
+ ? new ChatToolErrorEvent(
+ output,
+ resultCallId)
+ : new ChatToolOutputEvent(
+ output,
+ resultCallId),
+ entryMetadata);
+ }
+ }
+
+ if (nextAssistantIsAborted)
+ {
+ timeline = Apply(
+ timeline,
+ new ChatStatusEvent("Response was stopped", ChatTone.Warning),
+ null);
+ timeline = ChatTimelineReducer.Apply(timeline, new ChatTurnEndEvent());
+ }
+ timeline = ChatTimelineReducer.Apply(
+ timeline,
+ new ChatTurnEndEvent());
+ timeline = timeline with
+ {
+ TurnActive = false,
+ ActiveAssistantId = null,
+ ActiveReasoningId = null,
+ };
+ var maxSequence = history.Messages
+ .Where(message => message.OpenClawSeq is not null)
+ .Select(message => message.OpenClawSeq!.Value)
+ .DefaultIfEmpty(int.MinValue)
+ .Max();
+ return new(history.SessionId, timeline, metadata, maxSequence);
+ }
+
+ private void CompleteInFlight(
+ string threadId,
+ long requestId,
+ out PendingReload? pendingReload)
+ {
+ lock (_gate)
+ {
+ if (!_inFlight.TryGetValue(threadId, out var current) ||
+ current != requestId)
+ {
+ pendingReload = null;
+ return;
+ }
+ _inFlight.Remove(threadId);
+ pendingReload = null;
+ if (_disposed)
+ return;
+ if (_replacementPending.Remove(threadId, out var replacementToken))
+ {
+ pendingReload = new(replacementToken, Replacement: true);
+ return;
+ }
+ if (_authoritativePending.Remove(threadId, out var authoritativeToken))
+ pendingReload = new(authoritativeToken, Replacement: false);
+ }
+ }
+
+ private static List OrderHistoryMessages(
+ IReadOnlyList messages)
+ {
+ var indexed = messages
+ .Select((message, index) => (Message: message, Index: index))
+ .ToList();
+ var sequencedCount = indexed.Count(item =>
+ item.Message.OpenClawSeq is not null);
+ if (sequencedCount == indexed.Count)
+ {
+ return indexed
+ .OrderBy(item => item.Message.OpenClawSeq)
+ .ThenBy(item => item.Index)
+ .Select(item => item.Message)
+ .ToList();
+ }
+ if (sequencedCount == 0)
+ {
+ return indexed
+ .OrderBy(item => item.Message.Ts)
+ .ThenBy(item => item.Index)
+ .Select(item => item.Message)
+ .ToList();
+ }
+ return indexed
+ .OrderBy(item => item.Index)
+ .Select(item => item.Message)
+ .ToList();
+ }
+
+ private static async Task ObserveRetryAsync(Task task)
+ {
+ try
+ {
+ await task.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"[ChatHistory] Retry scheduler failed: {ex.GetType().Name}");
+ }
+ }
+
+ private static async Task ObserveCanceledRequestAsync(Task request)
+ {
+ try
+ {
+ await request.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug(
+ $"[ChatHistory] Canceled request completed with {ex.GetType().Name}");
+ }
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs
index 09a47fdda..b42431b6d 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs
@@ -1,3 +1,5 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
using OpenClaw.Shared;
namespace OpenClawTray.Chat;
@@ -10,6 +12,24 @@ internal sealed record ChatHistoryReplayPart(
internal static class ChatHistoryReplayProjection
{
+ internal static JsonObject? ProjectToolArgs(JsonElement? value) =>
+ value is { ValueKind: JsonValueKind.Object } args
+ ? NativeToolProjector.ExtractSafeToolDisplayArgs(args)
+ : null;
+
+ internal static string ToolLabel(string toolName, JsonObject? args)
+ {
+ var label = NativeToolProjector.FirstToolDisplayValue(args);
+ if (string.IsNullOrWhiteSpace(label))
+ return toolName;
+ if (label.Length <= 80)
+ return label;
+ var length = 77;
+ if (char.IsHighSurrogate(label[length - 1]))
+ length--;
+ return label[..length] + "\u2026";
+ }
+
public static IEnumerable Project(
IEnumerable messages)
{
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs
new file mode 100644
index 000000000..0ab9fdabc
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs
@@ -0,0 +1,383 @@
+using OpenClaw.Shared;
+using OpenClaw.Chat;
+using System.Collections.Immutable;
+
+namespace OpenClawTray.Chat;
+
+///
+/// Owns session identity, transcript freshness/revisions, and the single
+/// connection-generation activation/commit-token state. The root supplies
+/// reset generations and serializes every operation under its sole lock.
+///
+internal sealed class ChatHistoryState
+{
+ private readonly Dictionary _sessionIds = new();
+ private readonly HashSet _loadedThreads = new();
+ private readonly Dictionary _revisions = new();
+ private readonly Dictionary _resetClearedSessionIds = new();
+ private readonly Dictionary _replacementGenerations = new();
+
+ private long _connectionGeneration;
+ private bool _generationReady = true;
+ private TaskCompletionSource _generationActivation =
+ CompletedActivation();
+
+ internal long ConnectionGeneration => _connectionGeneration;
+
+ internal string? ResolveSessionId(string threadId) =>
+ _sessionIds.TryGetValue(threadId, out var sessionId)
+ ? sessionId
+ : null;
+
+ internal IReadOnlyDictionary SnapshotRevisions() =>
+ new Dictionary(_revisions);
+
+ internal ChatHistoryCommitToken CreateCommitToken(
+ string threadId,
+ long resetGeneration) =>
+ new(
+ threadId,
+ _connectionGeneration,
+ resetGeneration,
+ GetReplacementGeneration(threadId));
+
+ internal ChatHistoryCommitToken BeginReplacement(
+ string threadId,
+ long resetGeneration)
+ {
+ _replacementGenerations[threadId] =
+ GetReplacementGeneration(threadId) + 1;
+ _loadedThreads.Remove(threadId);
+ return CreateCommitToken(threadId, resetGeneration);
+ }
+
+ internal bool TryBegin(
+ string threadId,
+ bool force,
+ ChatHistoryCommitToken? expectedToken,
+ long resetGeneration,
+ ConnectionStatus status,
+ bool disposed,
+ out ChatHistoryCommitToken token,
+ out Task? generationActivation)
+ {
+ token = CreateCommitToken(threadId, resetGeneration);
+ generationActivation = null;
+ if (disposed || !force && _loadedThreads.Contains(threadId))
+ return false;
+
+ if (!_generationReady)
+ {
+ generationActivation = _generationActivation.Task;
+ return false;
+ }
+
+ return expectedToken is not { } expected ||
+ expected.ConnectionGeneration == _connectionGeneration &&
+ expected.ResetGeneration == resetGeneration &&
+ expected.ReplacementGeneration ==
+ GetReplacementGeneration(threadId) &&
+ (force || status == ConnectionStatus.Connected);
+ }
+
+ internal bool IsCurrent(
+ ChatHistoryCommitToken token,
+ long resetGeneration,
+ bool disposed) =>
+ !disposed &&
+ token.ConnectionGeneration == _connectionGeneration &&
+ token.ResetGeneration == resetGeneration &&
+ token.ReplacementGeneration ==
+ GetReplacementGeneration(token.ThreadId);
+
+ internal bool CanRetry(
+ ChatHistoryCommitToken token,
+ long resetGeneration,
+ ConnectionStatus status,
+ bool authoritative,
+ bool disposed) =>
+ IsCurrent(token, resetGeneration, disposed) &&
+ status == ConnectionStatus.Connected &&
+ (authoritative || !_loadedThreads.Contains(token.ThreadId));
+
+ internal void MarkCommitted(
+ ChatHistoryCommitToken token,
+ string? sessionId)
+ {
+ if (!string.IsNullOrEmpty(sessionId))
+ _sessionIds[token.ThreadId] = sessionId;
+ _revisions[token.ThreadId] =
+ (_revisions.TryGetValue(token.ThreadId, out var revision)
+ ? revision
+ : 0) + 1;
+ _loadedThreads.Add(token.ThreadId);
+ }
+
+ internal long AdvanceConnectionGeneration(bool clearLoaded)
+ {
+ _connectionGeneration++;
+ _generationActivation.TrySetResult();
+ _generationReady = false;
+ _generationActivation = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ if (clearLoaded)
+ _loadedThreads.Clear();
+ return _connectionGeneration;
+ }
+
+ internal void ActivateConnectionGeneration(
+ long generation,
+ bool disposed)
+ {
+ if (disposed ||
+ generation != _connectionGeneration ||
+ _generationReady)
+ {
+ return;
+ }
+ _generationReady = true;
+ _generationActivation.TrySetResult();
+ }
+
+ internal string? ClearSessionForReset(string threadId)
+ {
+ var oldSessionId = ResolveSessionId(threadId);
+ if (!string.IsNullOrEmpty(oldSessionId))
+ _resetClearedSessionIds[threadId] = oldSessionId;
+ else
+ _resetClearedSessionIds.Remove(threadId);
+ _sessionIds.Remove(threadId);
+ _loadedThreads.Add(threadId);
+ return oldSessionId;
+ }
+
+ internal void SeedSessionIds(IEnumerable sessions)
+ {
+ foreach (var session in sessions)
+ {
+ if (string.IsNullOrWhiteSpace(session.Key) ||
+ string.IsNullOrWhiteSpace(session.SessionId))
+ {
+ continue;
+ }
+
+ if (_resetClearedSessionIds.TryGetValue(
+ session.Key,
+ out var clearedSessionId) &&
+ string.Equals(
+ clearedSessionId,
+ session.SessionId,
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+ _sessionIds[session.Key] = session.SessionId;
+ _resetClearedSessionIds.Remove(session.Key);
+ }
+ }
+
+ private long GetReplacementGeneration(string threadId) =>
+ _replacementGenerations.TryGetValue(threadId, out var generation)
+ ? generation
+ : 0;
+
+ internal static (
+ ChatTimelineState Timeline,
+ Dictionary Metadata)
+ MergeWithLiveEntries(
+ ChatHistoryRebuildPlan plan,
+ ChatTimelineState prior,
+ IReadOnlyDictionary priorMetadata,
+ DateTimeOffset requestStartedAt,
+ bool authoritative)
+ {
+ var rebuilt = plan.Timeline;
+ var rebuiltMetadata = new Dictionary(
+ plan.Metadata,
+ StringComparer.Ordinal);
+ if (prior.Entries.Count == 0)
+ return (rebuilt, rebuiltMetadata);
+
+ static string ContentKey(ChatTimelineItemKind kind, string text) =>
+ $"{kind}|{text}";
+ static string SequenceKey(ChatTimelineItemKind kind, int sequence) =>
+ $"{kind}|{sequence}";
+
+ var contentTimestamps = new Dictionary>(
+ StringComparer.Ordinal);
+ var messageIds = new HashSet(StringComparer.Ordinal);
+ var sequenceCounts = new Dictionary(StringComparer.Ordinal);
+ foreach (var entry in rebuilt.Entries)
+ {
+ rebuiltMetadata.TryGetValue(entry.Id, out var metadata);
+ if (!string.IsNullOrEmpty(metadata?.GatewayMessageId))
+ messageIds.Add(metadata.GatewayMessageId);
+ if (metadata?.OpenClawSeq is { } sequence)
+ IncrementCount(sequenceCounts, SequenceKey(entry.Kind, sequence));
+ if (metadata?.Timestamp is { } timestamp && timestamp != default)
+ {
+ var key = ContentKey(entry.Kind, entry.Text);
+ if (!contentTimestamps.TryGetValue(key, out var timestamps))
+ {
+ timestamps = [];
+ contentTimestamps[key] = timestamps;
+ }
+ timestamps.Add(timestamp.ToUnixTimeSeconds());
+ }
+ }
+
+ var existingIds = rebuilt.Entries
+ .Select(entry => entry.Id)
+ .ToHashSet(StringComparer.Ordinal);
+ var maxSuffix = rebuilt.Entries
+ .Select(entry =>
+ entry.Id.Length > 1 &&
+ entry.Id[0] == 'e' &&
+ int.TryParse(entry.Id.AsSpan(1), out var suffix)
+ ? suffix
+ : 0)
+ .DefaultIfEmpty()
+ .Max();
+ var nextId = Math.Max(rebuilt.NextId, maxSuffix + 1);
+ var entries = rebuilt.Entries.ToBuilder();
+ foreach (var entry in prior.Entries)
+ {
+ priorMetadata.TryGetValue(entry.Id, out var metadata);
+ if (!string.IsNullOrEmpty(metadata?.GatewayMessageId) &&
+ messageIds.Contains(metadata.GatewayMessageId))
+ {
+ ConsumeAnyTimestamp(
+ contentTimestamps,
+ ContentKey(entry.Kind, entry.Text));
+ continue;
+ }
+ if (metadata?.OpenClawSeq is { } sequence &&
+ TryConsumeCount(
+ sequenceCounts,
+ SequenceKey(entry.Kind, sequence)))
+ {
+ ConsumeAnyTimestamp(
+ contentTimestamps,
+ ContentKey(entry.Kind, entry.Text));
+ continue;
+ }
+ if (authoritative &&
+ !ShouldPreserveLiveEntryDuringAuthoritativeReload(
+ metadata,
+ plan.MaxHistorySequence,
+ requestStartedAt))
+ {
+ continue;
+ }
+ if (metadata?.Timestamp is { } timestamp &&
+ timestamp != default &&
+ contentTimestamps.TryGetValue(
+ ContentKey(entry.Kind, entry.Text),
+ out var rebuiltTimes))
+ {
+ var priorSeconds = timestamp.ToUnixTimeSeconds();
+ var match = rebuiltTimes.FindIndex(value =>
+ Math.Abs(value - priorSeconds) <= 2);
+ if (match >= 0)
+ {
+ rebuiltTimes.RemoveAt(match);
+ continue;
+ }
+ }
+
+ var entryToAdd = entry;
+ if (existingIds.Contains(entry.Id))
+ entryToAdd = entry with { Id = $"e{nextId++}" };
+ else if (entry.Id.Length > 1 &&
+ entry.Id[0] == 'e' &&
+ int.TryParse(entry.Id.AsSpan(1), out var suffix) &&
+ suffix >= nextId)
+ nextId = suffix + 1;
+ entries.Add(entryToAdd);
+ existingIds.Add(entryToAdd.Id);
+ if (metadata?.Timestamp is { } addedTimestamp &&
+ addedTimestamp != default)
+ {
+ var key = ContentKey(entryToAdd.Kind, entryToAdd.Text);
+ if (!contentTimestamps.TryGetValue(key, out var timestamps))
+ {
+ timestamps = [];
+ contentTimestamps[key] = timestamps;
+ }
+ timestamps.Add(addedTimestamp.ToUnixTimeSeconds());
+ }
+ if (!string.IsNullOrEmpty(metadata?.GatewayMessageId))
+ messageIds.Add(metadata.GatewayMessageId);
+ if (metadata?.OpenClawSeq is { } addedSequence)
+ {
+ IncrementCount(
+ sequenceCounts,
+ SequenceKey(entryToAdd.Kind, addedSequence));
+ }
+ if (metadata is not null)
+ rebuiltMetadata[entryToAdd.Id] = metadata;
+ }
+
+ var merged = rebuilt with
+ {
+ Entries = entries.ToImmutable(),
+ NextId = nextId,
+ TurnActive = prior.TurnActive,
+ PendingToolPresentations = prior.PendingToolPresentations,
+ PendingToolOutcomes = prior.PendingToolOutcomes,
+ TerminalToolCorrelations =
+ prior.TerminalToolCorrelations,
+ NextToolOutcomeSequence =
+ prior.NextToolOutcomeSequence,
+ NextToolCorrelationSequence =
+ prior.NextToolCorrelationSequence,
+ ToolLegacyTurn = prior.ToolLegacyTurn,
+ };
+ merged = ChatTimelineReducer.RebuildActiveToolTracking(merged);
+ return (merged, rebuiltMetadata);
+ }
+
+ internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload(
+ ChatEntryMetadata? metadata,
+ int maxHistorySequence,
+ DateTimeOffset requestStartedAt) =>
+ metadata is null ||
+ metadata.OpenClawSeq is null ||
+ metadata.OpenClawSeq is { } sequence && sequence > maxHistorySequence ||
+ metadata.Timestamp is { } timestamp && timestamp >= requestStartedAt ||
+ metadata.IsLocalQueuedSend;
+
+ private static void IncrementCount(
+ Dictionary counts,
+ string key) =>
+ counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1;
+
+ private static bool TryConsumeCount(
+ Dictionary counts,
+ string key)
+ {
+ if (!counts.TryGetValue(key, out var count) || count <= 0)
+ return false;
+ if (count == 1)
+ counts.Remove(key);
+ else
+ counts[key] = count - 1;
+ return true;
+ }
+
+ private static void ConsumeAnyTimestamp(
+ Dictionary> timestamps,
+ string key)
+ {
+ if (timestamps.TryGetValue(key, out var values) && values.Count > 0)
+ values.RemoveAt(0);
+ }
+
+ private static TaskCompletionSource CompletedActivation()
+ {
+ var activation = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ activation.TrySetResult();
+ return activation;
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleState.cs
new file mode 100644
index 000000000..6e0aadbba
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleState.cs
@@ -0,0 +1,216 @@
+using OpenClaw.Shared;
+
+namespace OpenClawTray.Chat;
+
+///
+/// Owns active-run identity/sequences, abort suppression and deferral, and
+/// bounded terminal-run deduplication. The root serializes every operation.
+///
+internal sealed class ChatLifecycleState
+{
+ private const int TerminalRunCapacity = 64;
+
+ private readonly Dictionary _activeRunIds = new();
+ private readonly Dictionary _activeRunStartSequences = new();
+ private readonly Dictionary _pendingAbortCounts = new();
+ private readonly HashSet _abortedRunIds = new();
+ private readonly HashSet _abortedThreads = new();
+ private readonly Dictionary> _terminalRunIdsByThread =
+ new();
+
+ private long _lifecycleStartSequence;
+
+ internal bool IsResponseSuppressed => _abortedThreads.Count > 0;
+ internal long LifecycleStartSequence => _lifecycleStartSequence;
+
+ internal bool HasActiveRun(string threadId) =>
+ _activeRunIds.ContainsKey(threadId);
+
+ internal bool TryGetActiveRun(string threadId, out string? runId) =>
+ _activeRunIds.TryGetValue(threadId, out runId);
+
+ internal bool HasRunStartedAfter(
+ string threadId,
+ string runId,
+ long sequence)
+ {
+ return _activeRunIds.TryGetValue(threadId, out var activeRunId) &&
+ _activeRunStartSequences.TryGetValue(
+ threadId,
+ out var activeSequence) &&
+ string.Equals(activeRunId, runId, StringComparison.Ordinal) &&
+ activeSequence > sequence;
+ }
+
+ internal ChatAbortStart BeginAbort(
+ string threadId,
+ bool hadActiveTurn)
+ {
+ _activeRunIds.TryGetValue(threadId, out var runId);
+ _abortedThreads.Add(threadId);
+ if (!string.IsNullOrEmpty(runId))
+ {
+ _abortedRunIds.Add(runId);
+ }
+ else
+ {
+ _pendingAbortCounts.TryGetValue(threadId, out var count);
+ _pendingAbortCounts[threadId] = count + 1;
+ }
+ return new(runId, hadActiveTurn);
+ }
+
+ internal void RollbackAbort(string threadId, string runId)
+ {
+ _abortedThreads.Remove(threadId);
+ _abortedRunIds.Remove(runId);
+ RemoveActiveRun(threadId);
+ }
+
+ internal void CompleteAbort(string threadId, string? runId)
+ {
+ if (!string.IsNullOrEmpty(runId))
+ RemoveActiveRun(threadId);
+ _abortedThreads.Remove(threadId);
+ }
+
+ internal bool ShouldSuppress(string threadId, string? runId) =>
+ !string.IsNullOrEmpty(runId) && _abortedRunIds.Contains(runId) ||
+ _abortedThreads.Contains(threadId);
+
+ internal bool IsThreadSuppressed(string threadId) =>
+ _abortedThreads.Contains(threadId);
+
+ internal bool IsRunAborted(string? runId) =>
+ !string.IsNullOrWhiteSpace(runId) && _abortedRunIds.Contains(runId);
+
+ internal bool HasPendingAbort(string threadId) =>
+ _pendingAbortCounts.ContainsKey(threadId);
+
+ internal int TakePendingAbortCount(string threadId)
+ {
+ return _pendingAbortCounts.Remove(threadId, out var count)
+ ? count
+ : 0;
+ }
+
+ internal long StartRun(string threadId, string runId)
+ {
+ _activeRunIds[threadId] = runId;
+ var sequence = ++_lifecycleStartSequence;
+ _activeRunStartSequences[threadId] = sequence;
+ return sequence;
+ }
+
+ internal void MarkDeferredAbort(string threadId, string runId)
+ {
+ _abortedThreads.Add(threadId);
+ _abortedRunIds.Add(runId);
+ }
+
+ internal void RemoveAbortedRun(string? runId)
+ {
+ if (!string.IsNullOrEmpty(runId))
+ _abortedRunIds.Remove(runId);
+ }
+
+ internal void ClearThreadSuppression(string threadId) =>
+ _abortedThreads.Remove(threadId);
+
+ internal string? CompleteAssistantFinal(string threadId)
+ {
+ _activeRunIds.Remove(threadId, out var completedRunId);
+ if (!string.IsNullOrEmpty(completedRunId))
+ {
+ RememberTerminalRun(threadId, completedRunId);
+ _abortedRunIds.Remove(completedRunId);
+ }
+ _activeRunStartSequences.Remove(threadId);
+ _abortedThreads.Remove(threadId);
+ return completedRunId;
+ }
+
+ internal void RemoveActiveRun(string threadId)
+ {
+ _activeRunIds.Remove(threadId);
+ _activeRunStartSequences.Remove(threadId);
+ }
+
+ internal bool ShouldDropTerminal(
+ string threadId,
+ string runId,
+ IReadOnlyCollection queuedRunIds,
+ bool turnActive,
+ out ChatTerminalEventDropReason? droppedReason)
+ {
+ droppedReason = null;
+ if (string.IsNullOrWhiteSpace(runId))
+ {
+ droppedReason = ChatTerminalEventDropReason.MissingRunId;
+ return true;
+ }
+ if (_terminalRunIdsByThread.TryGetValue(threadId, out var terminalRunIds) &&
+ terminalRunIds.Contains(runId, StringComparer.Ordinal))
+ {
+ return true;
+ }
+ if (_activeRunIds.TryGetValue(threadId, out var activeRunId) &&
+ !string.Equals(activeRunId, runId, StringComparison.Ordinal))
+ {
+ droppedReason = ChatTerminalEventDropReason.MismatchedRunId;
+ return true;
+ }
+ if (!_activeRunIds.ContainsKey(threadId) &&
+ queuedRunIds.Count > 0 &&
+ !queuedRunIds.Contains(runId, StringComparer.Ordinal) &&
+ turnActive)
+ {
+ droppedReason = ChatTerminalEventDropReason.MismatchedRunId;
+ return true;
+ }
+ RememberTerminalRun(threadId, runId);
+ return false;
+ }
+
+ internal string? ActiveRunForReset(string threadId) =>
+ _activeRunIds.TryGetValue(threadId, out var runId)
+ ? runId
+ : null;
+
+ internal void ClearThreadForReset(string threadId)
+ {
+ RemoveActiveRun(threadId);
+ _pendingAbortCounts.Remove(threadId);
+ _abortedThreads.Remove(threadId);
+ _terminalRunIdsByThread.Remove(threadId);
+ }
+
+ internal void ClearForReconnect()
+ {
+ _terminalRunIdsByThread.Clear();
+ _activeRunIds.Clear();
+ _activeRunStartSequences.Clear();
+ }
+
+ internal void ClearForDispose() =>
+ _terminalRunIdsByThread.Clear();
+
+ internal void ClearActiveRuns(IEnumerable threadIds)
+ {
+ foreach (var threadId in threadIds)
+ RemoveActiveRun(threadId);
+ }
+
+ private void RememberTerminalRun(string threadId, string runId)
+ {
+ if (!_terminalRunIdsByThread.TryGetValue(threadId, out var runIds))
+ {
+ runIds = [];
+ _terminalRunIdsByThread[threadId] = runIds;
+ }
+ runIds.Remove(runId);
+ runIds.Add(runId);
+ if (runIds.Count > TerminalRunCapacity)
+ runIds.RemoveRange(0, runIds.Count - TerminalRunCapacity);
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatMetadataStore.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatMetadataStore.cs
new file mode 100644
index 000000000..4b41a5a17
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatMetadataStore.cs
@@ -0,0 +1,845 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal sealed class AttachmentMetaMatcher
+{
+ private static readonly TimeSpan MatchWindow = TimeSpan.FromHours(24);
+ private readonly List _entries;
+ private readonly bool[] _used;
+
+ public AttachmentMetaMatcher(List entries)
+ {
+ _entries = entries;
+ _used = new bool[entries.Count];
+ }
+
+ public ChatMetadataStore.CachedAttachmentMeta? TryMatch(string text, long historyTsMs)
+ {
+ for (var i = 0; i < _entries.Count; i++)
+ {
+ if (_used[i])
+ continue;
+
+ var entry = _entries[i];
+ if (!string.Equals(entry.Text, text, StringComparison.Ordinal))
+ continue;
+
+ if (historyTsMs > 0 && entry.Ts > 0 &&
+ Math.Abs(historyTsMs - entry.Ts) > MatchWindow.TotalMilliseconds)
+ {
+ continue;
+ }
+
+ _used[i] = true;
+ return entry;
+ }
+
+ return null;
+ }
+}
+
+///
+/// Owns the live tool and attachment metadata caches, their persistence
+/// lifecycle, and attachment marker security/rehydration.
+///
+internal sealed class ChatMetadataStore : IDisposable
+{
+ internal sealed class CachedToolMeta
+ {
+ public long Ts { get; set; }
+ public string ToolName { get; set; } = "";
+ public string Label { get; set; } = "";
+ public string? ToolCallId { get; set; }
+ public string? RunId { get; set; }
+ public long LegacyTurn { get; set; }
+ public JsonObject? ToolArgs { get; set; }
+ public ChatToolIdentityStrength IdentityStrength { get; set; } =
+ ChatToolIdentityStrength.Heuristic;
+ [JsonIgnore] public string ThreadId { get; set; } = "";
+ [JsonIgnore] public long ResetGeneration { get; set; }
+ }
+
+ internal sealed class CachedAttachmentMeta
+ {
+ public long Ts { get; set; }
+ public string Text { get; set; } = "";
+ public List Attachments { get; set; } = [];
+ [JsonIgnore] public string ThreadId { get; set; } = "";
+ [JsonIgnore] public long ResetGeneration { get; set; }
+ }
+
+ internal sealed class CachedAttachmentItem
+ {
+ public string FileName { get; set; } = "";
+ public bool IsImage { get; set; }
+ }
+
+ internal const int MaxCachedSessions = 20;
+ internal const int MaxToolEntriesPerSession = 500;
+ internal const int MaxAttachmentEntriesPerSession = 500;
+
+ internal static readonly JsonSerializerOptions CacheJsonOptions = new()
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ internal static readonly string LastChatStateFilePath = Path.Combine(
+ AppIdentity.ResolveLocalDataDirectory(), "last-chat-state.json");
+
+ internal static readonly string AbortedIdsFilePath = Path.Combine(
+ AppIdentity.ResolveLocalDataDirectory(), "aborted-messages.json");
+
+ private readonly object _gate = new();
+ private readonly object _toolSaveGate = new();
+ private readonly object _attachmentSaveGate = new();
+ private readonly string _toolCacheFilePath;
+ private readonly string _attachmentCacheFilePath;
+ private Dictionary> _toolCache;
+ private Dictionary> _attachmentCache;
+ private readonly Dictionary _evictedResetGenerations = new(StringComparer.Ordinal);
+ private Timer? _toolSaveTimer;
+ private long _toolSaveVersion;
+ private bool _toolCacheDirty;
+ private bool _disposed;
+
+ internal ChatMetadataStore(string toolCacheFilePath, string? attachmentCacheFilePath = null)
+ {
+ _toolCacheFilePath = !string.IsNullOrWhiteSpace(toolCacheFilePath)
+ ? toolCacheFilePath
+ : throw new ArgumentException("Tool metadata cache path is required.", nameof(toolCacheFilePath));
+ _attachmentCacheFilePath = !string.IsNullOrWhiteSpace(attachmentCacheFilePath)
+ ? attachmentCacheFilePath
+ : DefaultAttachmentMetaCacheFilePath(_toolCacheFilePath);
+ _toolCache = LoadToolMetaCache(_toolCacheFilePath);
+ _attachmentCache = LoadAttachmentMetaCache(_attachmentCacheFilePath);
+ }
+
+ internal static string DefaultToolMetaCacheFilePath =>
+ Path.Combine(AppIdentity.ResolveLocalDataDirectory(), "tool-metadata.json");
+
+ internal static string DefaultAttachmentMetaCacheFilePath(string toolMetaCacheFilePath)
+ {
+ var dir = Path.GetDirectoryName(toolMetaCacheFilePath);
+ return Path.Combine(
+ string.IsNullOrEmpty(dir)
+ ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
+ : dir,
+ "attachment-metadata.json");
+ }
+
+ internal void CacheTool(
+ string threadId,
+ string cacheKey,
+ long resetGeneration,
+ long tsMs,
+ string toolName,
+ string label,
+ string? toolCallId = null,
+ JsonObject? toolArgs = null,
+ ChatToolIdentityStrength identityStrength =
+ ChatToolIdentityStrength.Heuristic,
+ string? runId = null,
+ long legacyTurn = 0)
+ {
+ Timer? timerToDispose;
+ long saveVersion;
+ runId = string.IsNullOrWhiteSpace(runId) ? null : runId;
+ lock (_gate)
+ {
+ if (_disposed || IsStaleResetGenerationLocked(threadId, resetGeneration))
+ return;
+
+ if (!_toolCache.TryGetValue(cacheKey, out var list))
+ {
+ list = [];
+ _toolCache[cacheKey] = list;
+ }
+
+ if (!string.IsNullOrWhiteSpace(toolCallId))
+ {
+ var existing = list.FindLast(entry =>
+ string.Equals(
+ entry.ToolCallId,
+ toolCallId,
+ StringComparison.Ordinal) &&
+ string.Equals(
+ entry.RunId,
+ runId,
+ StringComparison.Ordinal) &&
+ (runId is not null || entry.LegacyTurn == legacyTurn));
+ if (existing is not null)
+ {
+ if (identityStrength > existing.IdentityStrength)
+ {
+ existing.ToolName =
+ NormalizeCachedDisplayText(toolName);
+ existing.IdentityStrength = identityStrength;
+ }
+ if (!string.IsNullOrWhiteSpace(label))
+ {
+ existing.Label =
+ NormalizeCachedDisplayText(label);
+ }
+ existing.ToolArgs = MergeCachedToolArgs(
+ existing.ToolArgs,
+ toolArgs);
+ ScheduleToolSaveLocked(
+ out saveVersion,
+ out timerToDispose);
+ goto ExitLock;
+ }
+ }
+ else if (list.Count > 0 &&
+ list[^1].Ts == tsMs &&
+ list[^1].ToolName == toolName)
+ {
+ return;
+ }
+
+ list.Add(new CachedToolMeta
+ {
+ Ts = tsMs,
+ ToolName = NormalizeCachedDisplayText(toolName),
+ Label = NormalizeCachedDisplayText(label),
+ ToolCallId = toolCallId,
+ RunId = runId,
+ LegacyTurn = runId is null ? legacyTurn : 0,
+ ToolArgs = NormalizeCachedToolArgs(toolArgs),
+ IdentityStrength = identityStrength,
+ ThreadId = threadId,
+ ResetGeneration = resetGeneration,
+ });
+ if (list.Count > MaxToolEntriesPerSession)
+ list.RemoveRange(0, list.Count - MaxToolEntriesPerSession);
+
+ ScheduleToolSaveLocked(
+ out saveVersion,
+ out timerToDispose);
+ ExitLock:
+ ;
+ }
+
+ timerToDispose?.Dispose();
+ }
+
+ internal void CacheTool(ChatToolMetadataWrite metadata) =>
+ CacheTool(
+ metadata.ThreadId,
+ metadata.CacheKey,
+ metadata.ResetGeneration,
+ metadata.TimestampMs,
+ metadata.ToolName,
+ metadata.Label,
+ metadata.ToolCallId,
+ metadata.ToolArgs,
+ metadata.IdentityStrength,
+ metadata.RunId,
+ metadata.LegacyTurn);
+
+ private void ScheduleToolSaveLocked(
+ out long saveVersion,
+ out Timer? timerToDispose)
+ {
+ _toolCacheDirty = true;
+ var version = ++_toolSaveVersion;
+ saveVersion = version;
+ timerToDispose = _toolSaveTimer;
+ _toolSaveTimer = new Timer(
+ _ => SaveToolCache(version),
+ null,
+ TimeSpan.FromMilliseconds(500),
+ Timeout.InfiniteTimeSpan);
+ }
+
+ internal void CacheAttachments(
+ string threadId,
+ string? sessionId,
+ long resetGeneration,
+ string text,
+ IReadOnlyList attachments,
+ long tsMs)
+ {
+ if (attachments.Count == 0)
+ return;
+
+ var items = attachments
+ .Where(attachment => !string.IsNullOrWhiteSpace(attachment.FileName))
+ .Select(attachment => new CachedAttachmentItem
+ {
+ FileName = NormalizeCachedDisplayText(attachment.FileName),
+ IsImage = string.Equals(attachment.Type, "image", StringComparison.OrdinalIgnoreCase),
+ })
+ .ToList();
+ if (items.Count == 0)
+ return;
+
+ lock (_gate)
+ {
+ if (_disposed || IsStaleResetGenerationLocked(threadId, resetGeneration))
+ return;
+
+ var key = !string.IsNullOrEmpty(sessionId) ? sessionId : threadId;
+ if (!_attachmentCache.TryGetValue(key, out var list))
+ {
+ list = [];
+ _attachmentCache[key] = list;
+ }
+
+ list.Add(new CachedAttachmentMeta
+ {
+ Ts = tsMs,
+ Text = NormalizeCachedDisplayText(
+ ChatContentFormatting.TruncateForChatEntry(
+ EscapeUntrustedAttachmentMarkerLines(text))),
+ Attachments = items,
+ ThreadId = threadId,
+ ResetGeneration = resetGeneration,
+ });
+ if (list.Count > MaxAttachmentEntriesPerSession)
+ list.RemoveRange(0, list.Count - MaxAttachmentEntriesPerSession);
+ }
+
+ SaveAttachmentCache();
+ }
+
+ internal Queue? GetToolMetadata(
+ string? sessionId,
+ string threadId,
+ long resetGeneration)
+ {
+ if (string.IsNullOrEmpty(sessionId) && string.IsNullOrEmpty(threadId))
+ return null;
+
+ lock (_gate)
+ {
+ var entries = new List();
+ if (!string.IsNullOrEmpty(sessionId) &&
+ _toolCache.TryGetValue(sessionId, out var sessionEntries))
+ {
+ entries.AddRange(sessionEntries
+ .Where(entry => !IsOlderResetEntry(
+ entry.ThreadId,
+ entry.ResetGeneration,
+ threadId,
+ resetGeneration))
+ .Select(Clone));
+ }
+
+ if (!string.IsNullOrEmpty(threadId) &&
+ (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) &&
+ _toolCache.TryGetValue(threadId, out var threadEntries))
+ {
+ entries.AddRange(threadEntries
+ .Where(entry => !IsOlderResetEntry(
+ entry.ThreadId,
+ entry.ResetGeneration,
+ threadId,
+ resetGeneration))
+ .Select(Clone));
+ }
+
+ return entries.Count == 0
+ ? null
+ : new Queue(entries.OrderBy(entry => entry.Ts));
+ }
+ }
+
+ internal AttachmentMetaMatcher CreateAttachmentMatcher(
+ string? sessionId,
+ string threadId,
+ long resetGeneration)
+ {
+ var entries = new List();
+ lock (_gate)
+ {
+ if (!string.IsNullOrEmpty(sessionId) &&
+ _attachmentCache.TryGetValue(sessionId, out var sessionEntries))
+ {
+ entries.AddRange(sessionEntries
+ .Where(entry => !IsOlderResetEntry(
+ entry.ThreadId,
+ entry.ResetGeneration,
+ threadId,
+ resetGeneration))
+ .Select(Clone));
+ }
+
+ if (!string.IsNullOrEmpty(threadId) &&
+ (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) &&
+ _attachmentCache.TryGetValue(threadId, out var threadEntries))
+ {
+ entries.AddRange(threadEntries
+ .Where(entry => !IsOlderResetEntry(
+ entry.ThreadId,
+ entry.ResetGeneration,
+ threadId,
+ resetGeneration))
+ .Select(Clone));
+ }
+ }
+
+ return new AttachmentMetaMatcher(entries.OrderBy(entry => entry.Ts).ToList());
+ }
+
+ private static bool IsOlderResetEntry(
+ string? entryThreadId,
+ long entryResetGeneration,
+ string threadId,
+ long resetGeneration) =>
+ (string.IsNullOrEmpty(entryThreadId) ||
+ string.Equals(entryThreadId, threadId, StringComparison.Ordinal)) &&
+ entryResetGeneration < resetGeneration;
+
+ internal void EvictReset(string threadId, string? oldSessionId, long resetGeneration)
+ {
+ var saveTool = false;
+ var saveAttachments = false;
+ lock (_gate)
+ {
+ if (_evictedResetGenerations.TryGetValue(threadId, out var current) &&
+ current >= resetGeneration)
+ {
+ return;
+ }
+
+ _evictedResetGenerations[threadId] = resetGeneration;
+ if (!string.IsNullOrEmpty(oldSessionId))
+ {
+ saveTool = RemoveOlderToolEntries(
+ oldSessionId,
+ threadId,
+ resetGeneration);
+ saveAttachments = RemoveOlderAttachmentEntries(
+ oldSessionId,
+ threadId,
+ resetGeneration);
+ }
+
+ saveTool = RemoveOlderToolEntries(
+ threadId,
+ threadId,
+ resetGeneration) || saveTool;
+ saveAttachments = RemoveOlderAttachmentEntries(
+ threadId,
+ threadId,
+ resetGeneration) || saveAttachments;
+ if (saveTool)
+ {
+ _toolCacheDirty = true;
+ _toolSaveVersion++;
+ }
+ }
+
+ if (saveTool)
+ SaveToolCache();
+ if (saveAttachments)
+ SaveAttachmentCache();
+ }
+
+ private bool RemoveOlderToolEntries(
+ string cacheKey,
+ string threadId,
+ long resetGeneration)
+ {
+ if (!_toolCache.TryGetValue(cacheKey, out var entries))
+ return false;
+ var removed = entries.RemoveAll(entry =>
+ (string.IsNullOrEmpty(entry.ThreadId) ||
+ string.Equals(entry.ThreadId, threadId, StringComparison.Ordinal)) &&
+ entry.ResetGeneration < resetGeneration) > 0;
+ if (entries.Count == 0)
+ _toolCache.Remove(cacheKey);
+ return removed;
+ }
+
+ private bool RemoveOlderAttachmentEntries(
+ string cacheKey,
+ string threadId,
+ long resetGeneration)
+ {
+ if (!_attachmentCache.TryGetValue(cacheKey, out var entries))
+ return false;
+ var removed = entries.RemoveAll(entry =>
+ (string.IsNullOrEmpty(entry.ThreadId) ||
+ string.Equals(entry.ThreadId, threadId, StringComparison.Ordinal)) &&
+ entry.ResetGeneration < resetGeneration) > 0;
+ if (entries.Count == 0)
+ _attachmentCache.Remove(cacheKey);
+ return removed;
+ }
+
+ internal static CachedToolMeta? TryMatchCachedTool(
+ Queue? cache,
+ long historyTsMs)
+ {
+ if (cache is null || cache.Count == 0)
+ return null;
+
+ var candidate = cache.Peek();
+ if (historyTsMs > 0 && candidate.Ts > 0 && candidate.Ts > historyTsMs + 300_000)
+ return null;
+
+ var match = cache.Dequeue();
+ match.ToolName = NormalizeCachedDisplayText(match.ToolName);
+ match.Label = NormalizeCachedDisplayText(match.Label);
+ match.ToolArgs = NormalizeCachedToolArgs(match.ToolArgs);
+ return match;
+ }
+
+ internal void Flush()
+ {
+ Timer? timer;
+ lock (_gate)
+ {
+ timer = _toolSaveTimer;
+ _toolSaveTimer = null;
+ _toolSaveVersion++;
+ }
+
+ timer?.Dispose();
+ SaveToolCache();
+ SaveAttachmentCache();
+ }
+
+ public void Dispose()
+ {
+ Timer? timer;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ timer = _toolSaveTimer;
+ _toolSaveTimer = null;
+ _toolSaveVersion++;
+ }
+
+ timer?.Dispose();
+ SaveToolCache();
+ }
+
+ private bool IsStaleResetGenerationLocked(string threadId, long resetGeneration) =>
+ _evictedResetGenerations.TryGetValue(threadId, out var evictedGeneration) &&
+ resetGeneration < evictedGeneration;
+
+ private void SaveToolCache(long? expectedVersion = null)
+ {
+ try
+ {
+ Dictionary> snapshot;
+ lock (_gate)
+ {
+ if (expectedVersion is { } version &&
+ (version != _toolSaveVersion || _disposed))
+ {
+ return;
+ }
+ if (!_toolCacheDirty)
+ return;
+
+ snapshot = _toolCache.ToDictionary(
+ pair => pair.Key,
+ pair => pair.Value.Select(Clone).ToList(),
+ StringComparer.Ordinal);
+ }
+
+ EvictOldestSessions(snapshot);
+ var json = JsonSerializer.Serialize(snapshot, CacheJsonOptions);
+ lock (_toolSaveGate)
+ {
+ if (expectedVersion is { } version)
+ {
+ lock (_gate)
+ {
+ if (version != _toolSaveVersion || _disposed)
+ return;
+ }
+ }
+
+ AtomicWrite(_toolCacheFilePath, json, "tool metadata");
+ lock (_gate)
+ {
+ if (expectedVersion is null || expectedVersion == _toolSaveVersion)
+ _toolCacheDirty = false;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"Chat metadata cache could not be saved: {ex.Message}");
+ }
+ }
+
+ private void SaveAttachmentCache()
+ {
+ try
+ {
+ Dictionary> snapshot;
+ lock (_gate)
+ {
+ snapshot = _attachmentCache.ToDictionary(
+ pair => pair.Key,
+ pair => pair.Value.Select(Clone).ToList(),
+ StringComparer.Ordinal);
+ }
+
+ EvictOldestSessions(snapshot);
+ var json = JsonSerializer.Serialize(snapshot, CacheJsonOptions);
+ lock (_attachmentSaveGate)
+ {
+ AtomicWrite(_attachmentCacheFilePath, json, "attachment metadata");
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"Attachment metadata cache could not be saved: {ex.Message}");
+ }
+ }
+
+ private static void EvictOldestSessions(Dictionary> snapshot)
+ where T : class
+ {
+ if (snapshot.Count <= MaxCachedSessions)
+ return;
+
+ static long Timestamp(T entry) => entry switch
+ {
+ CachedToolMeta tool => tool.Ts,
+ CachedAttachmentMeta attachment => attachment.Ts,
+ _ => 0,
+ };
+
+ var keys = snapshot
+ .OrderBy(pair => pair.Value.Count > 0 ? Timestamp(pair.Value[^1]) : 0)
+ .Take(snapshot.Count - MaxCachedSessions)
+ .Select(pair => pair.Key)
+ .ToArray();
+ foreach (var key in keys)
+ snapshot.Remove(key);
+ }
+
+ private static void AtomicWrite(string path, string json, string cacheName)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ var tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllText(tempPath, json);
+ File.Move(tempPath, path, overwrite: true);
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"{cacheName} temp file cleanup failed: {ex.Message}");
+ }
+ }
+ }
+
+ private static CachedToolMeta Clone(CachedToolMeta entry) => new()
+ {
+ Ts = entry.Ts,
+ ToolName = NormalizeCachedDisplayText(entry.ToolName),
+ Label = NormalizeCachedDisplayText(entry.Label),
+ ToolCallId = entry.ToolCallId,
+ RunId = entry.RunId,
+ LegacyTurn = entry.LegacyTurn,
+ ToolArgs = NormalizeCachedToolArgs(entry.ToolArgs),
+ IdentityStrength = entry.IdentityStrength,
+ ThreadId = entry.ThreadId,
+ ResetGeneration = entry.ResetGeneration,
+ };
+
+ private static CachedAttachmentMeta Clone(CachedAttachmentMeta entry) => new()
+ {
+ Ts = entry.Ts,
+ Text = NormalizeCachedDisplayText(entry.Text),
+ ThreadId = entry.ThreadId,
+ ResetGeneration = entry.ResetGeneration,
+ Attachments = entry.Attachments.Select(attachment => new CachedAttachmentItem
+ {
+ FileName = NormalizeCachedDisplayText(attachment.FileName),
+ IsImage = attachment.IsImage,
+ }).ToList(),
+ };
+
+ internal static Dictionary> LoadToolMetaCache(string cacheFilePath)
+ {
+ try
+ {
+ if (!File.Exists(cacheFilePath))
+ return [];
+ var json = File.ReadAllText(cacheFilePath);
+ var cache = JsonSerializer.Deserialize>>(json) ?? [];
+ foreach (var entry in cache.Values.SelectMany(entries => entries))
+ {
+ entry.ToolName = NormalizeCachedDisplayText(entry.ToolName);
+ entry.Label = NormalizeCachedDisplayText(entry.Label);
+ entry.ToolArgs = NormalizeCachedToolArgs(entry.ToolArgs);
+ }
+ return cache;
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"Tool metadata cache could not be loaded: {ex.Message}");
+ return [];
+ }
+ }
+
+ internal static Dictionary> LoadAttachmentMetaCache(string cacheFilePath)
+ {
+ try
+ {
+ if (!File.Exists(cacheFilePath))
+ return [];
+ var json = File.ReadAllText(cacheFilePath);
+ var cache = JsonSerializer.Deserialize>>(json) ?? [];
+ foreach (var entry in cache.Values.SelectMany(entries => entries))
+ {
+ entry.Text = NormalizeCachedDisplayText(entry.Text);
+ foreach (var attachment in entry.Attachments)
+ attachment.FileName = NormalizeCachedDisplayText(attachment.FileName);
+ }
+ return cache;
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"Attachment metadata cache could not be loaded: {ex.Message}");
+ return [];
+ }
+ }
+
+ internal static string EscapeUntrustedAttachmentMarkerLines(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return text ?? string.Empty;
+
+ var lines = text.Split('\n');
+ var changed = false;
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i];
+ var trimmedStart = line.TrimStart();
+ if (trimmedStart.StartsWith("\u200B🖼️ ", StringComparison.Ordinal) ||
+ trimmedStart.StartsWith("\u200B📎 ", StringComparison.Ordinal))
+ {
+ var prefixLength = line.Length - trimmedStart.Length;
+ lines[i] = string.Concat(line.AsSpan(0, prefixLength), trimmedStart.AsSpan(1));
+ changed = true;
+ }
+ }
+
+ return changed ? string.Join('\n', lines) : text;
+ }
+
+ internal static string BuildAttachmentMarkerLines(IEnumerable attachments) =>
+ string.Join("\n", attachments.Select(attachment =>
+ string.Equals(attachment.Type, "image", StringComparison.OrdinalIgnoreCase)
+ ? $"\u200B🖼️ {attachment.FileName}"
+ : $"\u200B📎 {attachment.FileName}"));
+
+ internal static string BuildAttachmentMarkerLines(IEnumerable attachments) =>
+ string.Join("\n", attachments.Select(attachment =>
+ attachment.IsImage
+ ? $"\u200B🖼️ {attachment.FileName}"
+ : $"\u200B📎 {attachment.FileName}"));
+
+ internal static string RehydrateAttachmentMarkers(
+ AttachmentMetaMatcher matcher,
+ string text,
+ long historyTsMs)
+ {
+ var match = matcher.TryMatch(text, historyTsMs);
+ if (match is null || match.Attachments.Count == 0)
+ return text;
+
+ var markerLines = BuildAttachmentMarkerLines(match.Attachments);
+ return string.IsNullOrEmpty(text)
+ ? markerLines
+ : $"{text}\n{markerLines}";
+ }
+
+ internal static string NormalizeCachedDisplayText(string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ return value
+ .Replace("\r\n", " ", StringComparison.Ordinal)
+ .Replace('\r', ' ')
+ .Replace('\n', ' ');
+ }
+
+ private static JsonObject? NormalizeCachedToolArgs(JsonObject? args)
+ {
+ if (args is null)
+ return null;
+
+ var normalized = new JsonObject();
+ foreach (var key in NativeToolProjector.DisplayArgumentKeys)
+ {
+ if (args[key] is JsonValue value &&
+ value.TryGetValue(out var text))
+ {
+ var safe = NativeToolProjector.SanitizeToolDisplayValue(
+ NormalizeCachedDisplayText(text));
+ if (!string.IsNullOrWhiteSpace(safe))
+ normalized[key] = safe;
+ }
+ }
+ return normalized.Count == 0 ? null : normalized;
+ }
+
+ private static JsonObject? MergeCachedToolArgs(
+ JsonObject? existing,
+ JsonObject? incoming)
+ {
+ var merged = NormalizeCachedToolArgs(existing) ?? new JsonObject();
+ var normalizedIncoming = NormalizeCachedToolArgs(incoming);
+ if (normalizedIncoming is not null)
+ {
+ foreach (var key in NativeToolProjector.DisplayArgumentKeys)
+ {
+ if (normalizedIncoming[key] is not JsonValue value ||
+ !value.TryGetValue(out var incomingText))
+ {
+ continue;
+ }
+
+ if (merged[key] is JsonValue existingValue &&
+ existingValue.TryGetValue(out var existingText) &&
+ !string.Equals(
+ existingText,
+ incomingText,
+ StringComparison.Ordinal))
+ {
+ var combined = existingText + "\n" + incomingText;
+ merged[key] = combined.Length > 512
+ ? combined[..509] + "..."
+ : combined;
+ }
+ else
+ {
+ merged[key] = incomingText;
+ }
+ }
+ }
+ return merged.Count == 0 ? null : merged;
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs
new file mode 100644
index 000000000..f8a835d5b
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs
@@ -0,0 +1,313 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal sealed record ChatUsageSnapshot(
+ long InputTokens,
+ long OutputTokens,
+ long TotalTokens,
+ long ContextTokens);
+
+///
+/// Owns session/model/catalog presentation inputs, serialized model patches,
+/// keyless diagnostics, and remembered chat selection. The root serializes
+/// every operation and supplies immutable timeline/queue snapshots.
+///
+internal sealed class ChatPresentationState
+{
+ private readonly Dictionary _pendingModelPatches = new();
+
+ private SessionInfo[] _sessions = [];
+ private bool _sessionsListReceived;
+ private string[] _availableModels = [];
+ private IReadOnlyList _modelChoices = [];
+ private CommandCatalog? _commandCatalog;
+ private bool _commandsFetchInFlight;
+ private int _commandsEpoch;
+ private int _keylessEventDiagnosticRaised;
+ private OpenClawChatDataProvider.LastChatState? _lastChatState;
+
+ internal ChatPresentationState(
+ OpenClawChatDataProvider.LastChatState? lastChatState,
+ ModelsListInfo? seedModels)
+ {
+ _lastChatState = lastChatState;
+ if (seedModels is not null)
+ {
+ _modelChoices = ChatModelChoice.FromModelsList(seedModels);
+ _availableModels = ModelIdsFromChoices(_modelChoices);
+ }
+ else if (lastChatState?.AvailableModels is { Length: > 0 } cached)
+ {
+ _availableModels = cached.ToArray();
+ _modelChoices = ChoicesFromIds(cached);
+ }
+ }
+
+ internal OpenClawChatDataProvider.LastChatState? CachedLastChatState =>
+ _lastChatState;
+
+ internal SessionInfo[] SessionSnapshot() => _sessions.ToArray();
+
+ internal SessionInfo[] ReplaceSessions(
+ SessionInfo[] sessions,
+ bool receivedFromGateway = true)
+ {
+ var previous = _sessions;
+ _sessions = sessions.ToArray();
+ if (receivedFromGateway)
+ _sessionsListReceived = true;
+ return previous;
+ }
+
+ internal IReadOnlyDictionary SnapshotUsage() =>
+ _sessions
+ .Where(session => !string.IsNullOrEmpty(session.Key))
+ .ToDictionary(
+ session => session.Key,
+ session => new ChatUsageSnapshot(
+ session.InputTokens,
+ session.OutputTokens,
+ session.TotalTokens,
+ session.ContextTokens));
+
+ internal SessionInfo? FindSession(string threadId) =>
+ _sessions.FirstOrDefault(candidate =>
+ string.Equals(candidate.Key, threadId, StringComparison.Ordinal));
+
+ internal string? ModelForThread(string threadId) =>
+ FindSession(threadId)?.Model;
+
+ internal long? ContextTokensForThread(string threadId) =>
+ FindSession(threadId) is { ContextTokens: > 0 } session
+ ? session.ContextTokens
+ : null;
+
+ internal OpenClawChatDataProvider.LastChatState? RememberSelectedThread(
+ string threadId)
+ {
+ var session = FindSession(threadId);
+ if (session is null)
+ return null;
+
+ _lastChatState = new OpenClawChatDataProvider.LastChatState
+ {
+ DefaultThreadId = threadId,
+ ThreadTitle = SessionTitleFormatter.Format(session, _sessions),
+ Model = session.Model,
+ ModelProvider = session.Provider,
+ AvailableModels = _availableModels.ToArray(),
+ };
+ return _lastChatState;
+ }
+
+ internal void RememberLastSessionState(ChatProjectionContext context)
+ {
+ if (_sessions.Length == 0)
+ return;
+
+ var defaultThreadId = ChatSnapshotProjector.ResolveDefaultThreadId(
+ CaptureProjectionInput(
+ timelines: new Dictionary(),
+ timelineGenerations: new Dictionary(),
+ historyRevisions: new Dictionary(),
+ queuedMessages: new Dictionary>(),
+ status: ConnectionStatus.Disconnected,
+ context));
+ var session = defaultThreadId is { Length: > 0 }
+ ? FindSession(defaultThreadId)
+ : null;
+ session ??= _sessions.FirstOrDefault(candidate =>
+ candidate.IsMain && !string.IsNullOrEmpty(candidate.Key));
+ session ??= _sessions.FirstOrDefault(candidate =>
+ !string.IsNullOrEmpty(candidate.Key));
+ if (session is null)
+ return;
+
+ _lastChatState = new OpenClawChatDataProvider.LastChatState
+ {
+ DefaultThreadId = session.Key,
+ ThreadTitle = SessionTitleFormatter.Format(session, _sessions),
+ Model = session.Model,
+ ModelProvider = session.Provider,
+ AvailableModels = _availableModels.ToArray(),
+ };
+ }
+
+ internal void ApplyModels(ModelsListInfo models)
+ {
+ _modelChoices = ChatModelChoice.FromModelsList(models);
+ _availableModels = ModelIdsFromChoices(_modelChoices);
+ }
+
+ internal void LeaveConnected()
+ {
+ _sessionsListReceived = false;
+ _commandsEpoch++;
+ _commandCatalog = null;
+ _commandsFetchInFlight = false;
+ }
+
+ internal ChatModelPatchLease BeginModelPatch(string threadId)
+ {
+ _pendingModelPatches.TryGetValue(threadId, out var previous);
+ var completion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ _pendingModelPatches[threadId] = completion.Task;
+ return new(threadId, previous, completion);
+ }
+
+ internal void CompleteModelPatch(
+ ChatModelPatchLease lease,
+ Exception? error)
+ {
+ if (error is null)
+ lease.Completion.TrySetResult();
+ else
+ lease.Completion.TrySetException(error);
+
+ if (_pendingModelPatches.TryGetValue(lease.ThreadId, out var current) &&
+ ReferenceEquals(current, lease.Completion.Task))
+ {
+ _pendingModelPatches.Remove(lease.ThreadId);
+ }
+ }
+
+ internal Task? GetPendingModelPatch(string threadId) =>
+ _pendingModelPatches.TryGetValue(threadId, out var pending)
+ ? pending
+ : null;
+
+ internal bool TryBeginCommandCatalogFetch(
+ ConnectionStatus status,
+ out int epoch)
+ {
+ epoch = _commandsEpoch;
+ if (status != ConnectionStatus.Connected ||
+ _commandsFetchInFlight ||
+ _commandCatalog is not null)
+ {
+ return false;
+ }
+ _commandsFetchInFlight = true;
+ return true;
+ }
+
+ internal bool CompleteCommandCatalogFetch(
+ int epoch,
+ ConnectionStatus status,
+ CommandCatalog catalog)
+ {
+ if (epoch != _commandsEpoch || status != ConnectionStatus.Connected)
+ return false;
+ _commandsFetchInFlight = false;
+ _commandCatalog = catalog;
+ return true;
+ }
+
+ internal bool FailCommandCatalogFetch(
+ int epoch,
+ ConnectionStatus status)
+ {
+ if (epoch != _commandsEpoch || status != ConnectionStatus.Connected)
+ return false;
+ _commandsFetchInFlight = false;
+ _commandCatalog = new CommandCatalog { IsSupported = false };
+ return true;
+ }
+
+ internal bool IsCommandCatalogEpochCurrent(int epoch) =>
+ epoch == _commandsEpoch;
+
+ internal bool TryRaiseKeylessDiagnostic()
+ {
+ if (_keylessEventDiagnosticRaised != 0)
+ return false;
+ _keylessEventDiagnosticRaised = 1;
+ return true;
+ }
+
+ internal void ResetKeylessDiagnostic() =>
+ _keylessEventDiagnosticRaised = 0;
+
+ internal ChatSnapshotProjectionInput CaptureProjectionInput(
+ IReadOnlyDictionary timelines,
+ IReadOnlyDictionary timelineGenerations,
+ IReadOnlyDictionary historyRevisions,
+ IReadOnlyDictionary> queuedMessages,
+ ConnectionStatus status,
+ ChatProjectionContext context) => new(
+ Sessions: _sessions.ToArray(),
+ Timelines: timelines,
+ TimelineGenerations: timelineGenerations,
+ HistoryRevisions: historyRevisions,
+ QueuedMessages: queuedMessages,
+ SessionsListReceived: _sessionsListReceived,
+ AvailableModels: _availableModels.ToArray(),
+ ModelChoices: _modelChoices.ToArray(),
+ CommandCatalog: _commandCatalog,
+ Status: status,
+ MainSessionKey: context.MainSessionKey,
+ HasHandshakeSnapshot: context.HasHandshakeSnapshot,
+ RememberedDefaultThreadId: _lastChatState?.DefaultThreadId,
+ RememberedThreadTitle: _lastChatState?.ThreadTitle,
+ RememberedModel: _lastChatState?.Model,
+ RememberedModelProvider: _lastChatState?.ModelProvider);
+
+ internal string ResolveTimelineKey(
+ SessionInfo session,
+ IReadOnlyDictionary timelines)
+ {
+ if (session.IsMain &&
+ timelines.TryGetValue("main", out var mainTimeline) &&
+ mainTimeline.Entries.Count > 0)
+ {
+ return "main";
+ }
+ if (!string.IsNullOrEmpty(session.Key) && timelines.ContainsKey(session.Key))
+ return session.Key;
+ if (session.IsMain && timelines.ContainsKey("main"))
+ return "main";
+ return session.Key ?? string.Empty;
+ }
+
+ internal SessionInfo? ResolveSessionForThread(
+ string threadId,
+ string? mainSessionKey)
+ {
+ var byKey = FindSession(threadId);
+ if (byKey is not null)
+ return byKey;
+ if (string.Equals(threadId, "main", StringComparison.Ordinal) &&
+ !string.IsNullOrEmpty(mainSessionKey))
+ {
+ var main = FindSession(mainSessionKey);
+ if (main is not null)
+ return main;
+ }
+ return string.Equals(threadId, "main", StringComparison.Ordinal)
+ ? _sessions.FirstOrDefault(session => session.IsMain)
+ : null;
+ }
+
+ private static string[] ModelIdsFromChoices(
+ IReadOnlyList choices)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ return choices
+ .Where(choice => choice.IsSelectable && seen.Add(choice.Id))
+ .Select(choice => choice.Id)
+ .ToArray();
+ }
+
+ private static IReadOnlyList ChoicesFromIds(string[] ids)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ return ids
+ .Where(id => !string.IsNullOrEmpty(id) && seen.Add(id))
+ .Select(id => new ChatModelChoice(id, id))
+ .ToArray();
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatQueueState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatQueueState.cs
new file mode 100644
index 000000000..33a9ef283
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatQueueState.cs
@@ -0,0 +1,594 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal readonly record struct ChatLocalSentText(
+ string Text,
+ DateTimeOffset SentAt,
+ string QueuedMessageId);
+
+internal sealed record ChatQueueRetryResult(
+ bool Requeued,
+ TimeSpan Delay,
+ bool ShouldEndTurn);
+
+///
+/// Owns queued-message/request collections, local echo correlation, run
+/// mappings, drain scheduling, and queue retry mechanics. The root owns the
+/// only lock and coordinates timeline/run/reset commits around these methods.
+///
+internal sealed class ChatQueueState
+{
+ private const int MaxLocalEchoes = 20;
+ private static readonly TimeSpan LocalEchoWindow = TimeSpan.FromSeconds(30);
+
+ private readonly Dictionary> _localSentTexts =
+ new();
+ private readonly Dictionary> _messages = new();
+ private readonly Dictionary> _requests = new();
+ private readonly Dictionary> _messageIdsByRunId =
+ new();
+ private readonly HashSet _drainScheduledThreads =
+ new(StringComparer.Ordinal);
+ private readonly HashSet _assistantFallbackPromotedThreads =
+ new(StringComparer.Ordinal);
+ private readonly HashSet _locallyInitiatedThreads = new();
+
+ private long _messageSequence;
+
+ internal string NextMessageId() => $"q{++_messageSequence}";
+
+ internal IReadOnlyDictionary>
+ SnapshotMessages() =>
+ _messages.ToDictionary(
+ pair => pair.Key,
+ pair => (IReadOnlyList)pair.Value.ToArray());
+
+ internal string[] ThreadsWithMessages() => _messages.Keys.ToArray();
+
+ internal void AddMessage(string threadId, ChatQueuedMessage message)
+ {
+ if (!_messages.TryGetValue(threadId, out var messages))
+ {
+ messages = [];
+ _messages[threadId] = messages;
+ }
+ messages.RemoveAll(existing => existing.Id == message.Id);
+ messages.Add(message);
+ }
+
+ internal void AddRequest(ChatQueuedSendRequest request)
+ {
+ if (!_requests.TryGetValue(request.ThreadId, out var requests))
+ {
+ requests = [];
+ _requests[request.ThreadId] = requests;
+ }
+ requests.RemoveAll(existing => existing.Id == request.Id);
+ requests.Add(request);
+ }
+
+ internal ChatQueuedSendRequest? FindRequest(
+ string threadId,
+ string messageId) =>
+ _requests.TryGetValue(threadId, out var requests)
+ ? requests.FirstOrDefault(request =>
+ string.Equals(request.Id, messageId, StringComparison.Ordinal))
+ : null;
+
+ internal void RemoveRequest(string threadId, string messageId)
+ {
+ if (!_requests.TryGetValue(threadId, out var requests))
+ return;
+ requests.RemoveAll(request => request.Id == messageId);
+ if (requests.Count == 0)
+ _requests.Remove(threadId);
+ }
+
+ internal bool CanSendDirectly(
+ string threadId,
+ bool hasActiveRun,
+ bool turnActive) =>
+ ChatSendQueuePolicy.CanSendDirectly(
+ hasActiveRun,
+ turnActive,
+ HasPendingMessages(threadId));
+
+ internal bool CanClearAssistantFallback(
+ string threadId,
+ bool hasActiveRun,
+ bool turnActive) =>
+ !HasSendingMessages(threadId) &&
+ !hasActiveRun &&
+ !turnActive;
+
+ internal ChatQueuedSendDispatch StartDirect(
+ ChatQueuedSendRequest request,
+ string? sessionId,
+ long connectionGeneration,
+ long resetVersion,
+ long resetLifecycleSequence,
+ long lifecycleStartSequence)
+ {
+ EnqueueLocalEcho(request.ThreadId, request.Text, request.Id);
+ _locallyInitiatedThreads.Add(request.ThreadId);
+ _assistantFallbackPromotedThreads.Add(request.ThreadId);
+ return new ChatQueuedSendDispatch(
+ request,
+ sessionId,
+ connectionGeneration,
+ resetVersion,
+ resetLifecycleSequence,
+ lifecycleStartSequence,
+ StartedDirectly: true);
+ }
+
+ internal ChatQueuedSendDispatch? TryStartNext(
+ string threadId,
+ bool requireConnected,
+ ConnectionStatus status,
+ bool hasActiveRun,
+ bool turnActive,
+ string? sessionId,
+ long connectionGeneration,
+ long resetVersion,
+ long resetLifecycleSequence,
+ long lifecycleStartSequence,
+ out TimeSpan? delayedRetry)
+ {
+ delayedRetry = null;
+ if (!ChatSendQueuePolicy.CanStartNext(
+ requireConnected,
+ status,
+ hasActiveRun,
+ turnActive,
+ HasSendingMessages(threadId)) ||
+ !_messages.TryGetValue(threadId, out var messages))
+ {
+ return null;
+ }
+
+ for (var index = 0; index < messages.Count; index++)
+ {
+ if (messages[index].SendState != ChatQueuedMessageSendState.Queued)
+ continue;
+ var request = FindRequest(threadId, messages[index].Id);
+ if (request is null)
+ continue;
+
+ var now = DateTimeOffset.UtcNow;
+ if (request.DeferredAdmissionRetryAfter is { } retryAfter)
+ {
+ if (retryAfter > now)
+ {
+ delayedRetry = retryAfter - now;
+ return null;
+ }
+ request = request with { DeferredAdmissionRetryAfter = null };
+ AddRequest(request);
+ }
+
+ _assistantFallbackPromotedThreads.Remove(threadId);
+ messages[index] = messages[index] with
+ {
+ SendState = ChatQueuedMessageSendState.Sending,
+ ErrorText = null,
+ };
+ if (request.LifecycleCommand is null)
+ {
+ EnqueueLocalEcho(threadId, request.Text, request.Id);
+ _locallyInitiatedThreads.Add(threadId);
+ }
+
+ return new ChatQueuedSendDispatch(
+ request,
+ sessionId,
+ connectionGeneration,
+ resetVersion,
+ resetLifecycleSequence,
+ lifecycleStartSequence,
+ StartedDirectly: false);
+ }
+
+ return null;
+ }
+
+ internal bool TryScheduleDrain(string threadId) =>
+ _messages.ContainsKey(threadId) &&
+ _drainScheduledThreads.Add(threadId);
+
+ internal void CompleteDrainSchedule(string threadId) =>
+ _drainScheduledThreads.Remove(threadId);
+
+ internal void TrackRun(string threadId, string runId, string messageId)
+ {
+ if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId))
+ {
+ byRunId = new Dictionary(StringComparer.Ordinal);
+ _messageIdsByRunId[threadId] = byRunId;
+ }
+ byRunId[runId] = messageId;
+ }
+
+ internal bool TryResolveMessageForRun(
+ string threadId,
+ string runId,
+ out string messageId)
+ {
+ messageId = string.Empty;
+ if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId) ||
+ !byRunId.TryGetValue(runId, out var resolved))
+ {
+ return false;
+ }
+ messageId = resolved;
+ return true;
+ }
+
+ internal string[] RunIdsForThread(string threadId) =>
+ _messageIdsByRunId.TryGetValue(threadId, out var byRunId)
+ ? byRunId.Keys.ToArray()
+ : [];
+
+ internal void RemoveRunMappingByMessageId(
+ string threadId,
+ string messageId)
+ {
+ if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId))
+ return;
+ foreach (var runId in byRunId
+ .Where(pair => pair.Value == messageId)
+ .Select(pair => pair.Key)
+ .ToArray())
+ {
+ byRunId.Remove(runId);
+ }
+ if (byRunId.Count == 0)
+ _messageIdsByRunId.Remove(threadId);
+ }
+
+ internal void RemoveRunMappingByRunId(string threadId, string runId)
+ {
+ if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId))
+ return;
+ if (byRunId.TryGetValue(runId, out var messageId))
+ {
+ foreach (var alias in byRunId
+ .Where(pair => pair.Value == messageId)
+ .Select(pair => pair.Key)
+ .ToArray())
+ {
+ byRunId.Remove(alias);
+ }
+ }
+ else
+ {
+ byRunId.Remove(runId);
+ }
+ if (byRunId.Count == 0)
+ _messageIdsByRunId.Remove(threadId);
+ }
+
+ internal bool RemoveMessage(string threadId, string messageId)
+ {
+ if (!_messages.TryGetValue(threadId, out var messages))
+ return false;
+ var removed = messages.RemoveAll(message => message.Id == messageId) > 0;
+ if (removed)
+ {
+ RemoveRunMappingByMessageId(threadId, messageId);
+ RemoveRequest(threadId, messageId);
+ }
+ RemoveEmptyThread(threadId, messages);
+ return removed;
+ }
+
+ internal bool CancelMessage(string threadId, string messageId)
+ {
+ if (!_messages.TryGetValue(threadId, out var messages))
+ return false;
+ var index = messages.FindIndex(message => message.Id == messageId);
+ if (index < 0 ||
+ messages[index].SendState == ChatQueuedMessageSendState.Sending)
+ {
+ return false;
+ }
+ messages.RemoveAt(index);
+ RemovePendingLocalEcho(threadId, messageId);
+ RemoveRunMappingByMessageId(threadId, messageId);
+ RemoveRequest(threadId, messageId);
+ RemoveEmptyThread(threadId, messages);
+ return true;
+ }
+
+ internal bool TryTakeForPromotion(
+ string threadId,
+ string messageId,
+ out ChatQueuedMessage message)
+ {
+ message = default!;
+ if (!_messages.TryGetValue(threadId, out var messages))
+ return false;
+ var index = messages.FindIndex(candidate => candidate.Id == messageId);
+ if (index < 0)
+ return false;
+
+ message = messages[index];
+ messages.RemoveAt(index);
+ _assistantFallbackPromotedThreads.Add(threadId);
+ RemoveRequest(threadId, messageId);
+ RemoveEmptyThread(threadId, messages);
+ return true;
+ }
+
+ internal void MarkFailed(string threadId, string messageId, string error)
+ {
+ if (!_messages.TryGetValue(threadId, out var messages))
+ return;
+ var index = messages.FindIndex(message => message.Id == messageId);
+ if (index >= 0)
+ {
+ messages[index] = messages[index] with
+ {
+ SendState = ChatQueuedMessageSendState.Failed,
+ ErrorText = error,
+ };
+ }
+ }
+
+ internal ChatQueueRetryResult RequeueDeferredAdmission(
+ string threadId,
+ string messageId,
+ bool hasActiveRun)
+ {
+ if (!_messages.TryGetValue(threadId, out var messages))
+ return new(false, ChatSendQueuePolicy.DrainDelay, false);
+ var index = messages.FindIndex(message =>
+ message.Id == messageId &&
+ message.SendState == ChatQueuedMessageSendState.Sending);
+ if (index < 0)
+ return new(false, ChatSendQueuePolicy.DrainDelay, false);
+
+ var retryCount = IncrementDeferredAdmissionRetryCount(
+ threadId,
+ messageId);
+ if (retryCount > ChatSendQueuePolicy.MaxDeferredAdmissionRetries)
+ {
+ throw new InvalidOperationException(
+ $"Gateway kept chat.send status in_flight after {ChatSendQueuePolicy.MaxDeferredAdmissionRetries} retries.");
+ }
+
+ messages[index] = messages[index] with
+ {
+ SendState = ChatQueuedMessageSendState.Queued,
+ ErrorText = null,
+ };
+ var delay = ChatSendQueuePolicy.DeferredAdmissionRetryDelay(retryCount);
+ SetDeferredAdmissionRetryAfter(
+ threadId,
+ messageId,
+ DateTimeOffset.UtcNow + delay);
+ _assistantFallbackPromotedThreads.Remove(threadId);
+ return new(true, delay, ShouldEndTurn: !hasActiveRun);
+ }
+
+ internal bool HasSendingMessages(string threadId) =>
+ _messages.TryGetValue(threadId, out var messages) &&
+ messages.Any(message =>
+ message.SendState == ChatQueuedMessageSendState.Sending);
+
+ internal bool HasPendingMessages(string threadId) =>
+ _messages.TryGetValue(threadId, out var messages) &&
+ messages.Any(message =>
+ message.SendState is ChatQueuedMessageSendState.Queued or
+ ChatQueuedMessageSendState.Sending);
+
+ internal bool TryGetSingleSendingMessage(
+ string threadId,
+ out ChatQueuedMessage message)
+ {
+ message = default!;
+ if (!_messages.TryGetValue(threadId, out var messages))
+ return false;
+ ChatQueuedMessage? found = null;
+ foreach (var candidate in messages)
+ {
+ if (candidate.SendState != ChatQueuedMessageSendState.Sending ||
+ FindRequest(threadId, candidate.Id)?.LifecycleCommand is not null)
+ {
+ continue;
+ }
+ if (found is not null)
+ return false;
+ found = candidate;
+ }
+ if (found is null)
+ return false;
+ message = found;
+ return true;
+ }
+
+ internal bool IsLocallyInitiated(string threadId) =>
+ _locallyInitiatedThreads.Contains(threadId);
+
+ internal void ClearLocallyInitiatedIfIdle(
+ string threadId,
+ bool hasActiveRun,
+ bool turnActive)
+ {
+ if (!hasActiveRun && !turnActive && !HasPendingMessages(threadId))
+ _locallyInitiatedThreads.Remove(threadId);
+ }
+
+ internal void ClearLocallyInitiated(string threadId) =>
+ _locallyInitiatedThreads.Remove(threadId);
+
+ internal bool IsAssistantFallbackPromoted(string threadId) =>
+ _assistantFallbackPromotedThreads.Contains(threadId);
+
+ internal void ClearAssistantFallbackPromotion(string threadId) =>
+ _assistantFallbackPromotedThreads.Remove(threadId);
+
+ internal ChatLocalSentText[] SnapshotLocalEchoes(string threadId) =>
+ _localSentTexts.TryGetValue(threadId, out var queue)
+ ? queue.ToArray()
+ : [];
+
+ internal bool HasPendingLocalEchoText(string threadId, string text) =>
+ !string.IsNullOrWhiteSpace(text) &&
+ _localSentTexts.TryGetValue(threadId, out var queue) &&
+ queue.Any(pending =>
+ string.Equals(pending.Text, text.Trim(), StringComparison.Ordinal));
+
+ internal bool TryConsumeLocalEcho(
+ string threadId,
+ string echoText,
+ out string queuedMessageId)
+ {
+ queuedMessageId = string.Empty;
+ if (!_localSentTexts.TryGetValue(threadId, out var queue))
+ return false;
+
+ var now = DateTimeOffset.Now;
+ while (queue.Count > 0 && now - queue.Peek().SentAt > LocalEchoWindow)
+ queue.Dequeue();
+ if (queue.Count == 0)
+ {
+ _localSentTexts.Remove(threadId);
+ return false;
+ }
+
+ var retained = new Queue();
+ var matched = false;
+ while (queue.Count > 0)
+ {
+ var candidate = queue.Dequeue();
+ if (!matched &&
+ string.Equals(candidate.Text, echoText, StringComparison.Ordinal))
+ {
+ queuedMessageId = candidate.QueuedMessageId;
+ matched = true;
+ continue;
+ }
+ retained.Enqueue(candidate);
+ }
+ StoreLocalEchoQueue(threadId, retained);
+ return matched;
+ }
+
+ internal void RemovePendingLocalEcho(string threadId, string messageId)
+ {
+ if (!_localSentTexts.TryGetValue(threadId, out var queue))
+ return;
+ var retained = new Queue(
+ queue.Where(local => local.QueuedMessageId != messageId));
+ StoreLocalEchoQueue(threadId, retained);
+ }
+
+ internal void ClearForReconnect()
+ {
+ _locallyInitiatedThreads.Clear();
+ _localSentTexts.Clear();
+ _messages.Clear();
+ _requests.Clear();
+ _drainScheduledThreads.Clear();
+ _assistantFallbackPromotedThreads.Clear();
+ _messageIdsByRunId.Clear();
+ }
+
+ internal void ClearForDispose()
+ {
+ _messages.Clear();
+ _requests.Clear();
+ _drainScheduledThreads.Clear();
+ _messageIdsByRunId.Clear();
+ _localSentTexts.Clear();
+ _locallyInitiatedThreads.Clear();
+ }
+
+ internal void ClearThreadForReset(string threadId)
+ {
+ _locallyInitiatedThreads.Remove(threadId);
+ _localSentTexts.Remove(threadId);
+ _messages.Remove(threadId);
+ _requests.Remove(threadId);
+ _drainScheduledThreads.Remove(threadId);
+ _messageIdsByRunId.Remove(threadId);
+ _assistantFallbackPromotedThreads.Remove(threadId);
+ }
+
+ private void EnqueueLocalEcho(
+ string threadId,
+ string text,
+ string messageId)
+ {
+ RemovePendingLocalEcho(threadId, messageId);
+ if (!_localSentTexts.TryGetValue(threadId, out var queue))
+ {
+ queue = new Queue();
+ _localSentTexts[threadId] = queue;
+ }
+ queue.Enqueue(new ChatLocalSentText(
+ text,
+ DateTimeOffset.UtcNow,
+ messageId));
+ while (queue.Count > MaxLocalEchoes)
+ queue.Dequeue();
+ }
+
+ private void RemoveEmptyThread(
+ string threadId,
+ List messages)
+ {
+ if (messages.Count != 0)
+ return;
+ _messages.Remove(threadId);
+ _drainScheduledThreads.Remove(threadId);
+ }
+
+ private void StoreLocalEchoQueue(
+ string threadId,
+ Queue queue)
+ {
+ if (queue.Count == 0)
+ _localSentTexts.Remove(threadId);
+ else
+ _localSentTexts[threadId] = queue;
+ }
+
+ private void SetDeferredAdmissionRetryAfter(
+ string threadId,
+ string messageId,
+ DateTimeOffset retryAfter)
+ {
+ if (!_requests.TryGetValue(threadId, out var requests))
+ return;
+ var index = requests.FindIndex(request => request.Id == messageId);
+ if (index >= 0)
+ {
+ requests[index] = requests[index] with
+ {
+ DeferredAdmissionRetryAfter = retryAfter,
+ };
+ }
+ }
+
+ private int IncrementDeferredAdmissionRetryCount(
+ string threadId,
+ string messageId)
+ {
+ if (!_requests.TryGetValue(threadId, out var requests))
+ return ChatSendQueuePolicy.MaxDeferredAdmissionRetries + 1;
+ var index = requests.FindIndex(request => request.Id == messageId);
+ if (index < 0)
+ return ChatSendQueuePolicy.MaxDeferredAdmissionRetries + 1;
+ var count = requests[index].DeferredAdmissionRetryCount + 1;
+ requests[index] = requests[index] with
+ {
+ DeferredAdmissionRetryCount = count,
+ };
+ return count;
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatResetState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatResetState.cs
new file mode 100644
index 000000000..6055ef0c9
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatResetState.cs
@@ -0,0 +1,869 @@
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal sealed record ChatResetMessageGate(
+ bool Drop,
+ string? ConsumeEchoText,
+ bool RequestRemoteBackfill,
+ AgentEventInfo? OpenedLifecycleStart);
+
+internal sealed record ChatResetAgentGate(
+ bool Drop,
+ bool ReloadHistory,
+ AgentEventInfo? OpenedLifecycleStart);
+
+///
+/// Owns reset generations, timestamp cutoffs, ignored/accepted runs, buffered
+/// lifecycle starts, submitted-echo gates, and remote-backfill state. The root
+/// supplies queue/lifecycle facts and applies any returned lifecycle start.
+///
+internal sealed class ChatResetState
+{
+ private const long TimestampToleranceMs = 1000;
+ private static readonly TimeSpan LocalEchoWindow = TimeSpan.FromSeconds(30);
+
+ private readonly Dictionary _versions = new();
+ private readonly Dictionary _cutoffUtcMs = new();
+ private readonly HashSet _awaitingUserMessage = new();
+ private readonly Dictionary> _ignoredRunIds = new();
+ private readonly Dictionary>>
+ _submittedLocalEchoTexts = new();
+ private readonly Dictionary> _acceptedRunIds = new();
+ private readonly Dictionary _localSendWithoutRunVersions = new();
+ private readonly Dictionary _localSendWithoutRunStartSequences =
+ new();
+ private readonly Dictionary _localEchoSequences = new();
+ private readonly Dictionary>
+ _pendingLocalSubmissions = new();
+ private readonly Dictionary>
+ _pendingLifecycleStarts = new();
+ private readonly Dictionary
+ _acceptedLifecycleFloors = new();
+ private readonly HashSet _remoteBackfillInFlight = new();
+ private readonly HashSet _remoteUserSeen = new();
+
+ private long _lifecycleStartSequence;
+
+ private readonly record struct PendingLifecycleStart(
+ AgentEventInfo Event,
+ long Sequence);
+
+ private sealed record PendingLocalSubmission(
+ string Id,
+ string Text,
+ long Generation,
+ DateTimeOffset SubmittedAt,
+ long StartSequence,
+ bool RequiresEcho)
+ {
+ internal bool EchoObserved { get; set; }
+ internal long EchoTimestampMs { get; set; }
+ internal bool ConfirmedWithoutRun { get; set; }
+ }
+
+ private readonly record struct AcceptedLifecycleFloor(
+ string RunId,
+ long Generation,
+ long TimestampMs);
+
+ internal long LifecycleStartSequence => _lifecycleStartSequence;
+ internal bool IsAwaitingUserMessage(string threadId) =>
+ _awaitingUserMessage.Contains(threadId);
+
+ internal long GetVersion(string threadId) =>
+ _versions.TryGetValue(threadId, out var version) ? version : 0;
+
+ internal IReadOnlyDictionary SnapshotVersions() =>
+ new Dictionary(_versions);
+
+ internal long BeginReset(string threadId, long cutoffUtcMs)
+ {
+ var generation = GetVersion(threadId) + 1;
+ _versions[threadId] = generation;
+ _cutoffUtcMs[threadId] = cutoffUtcMs;
+ _awaitingUserMessage.Add(threadId);
+ _acceptedRunIds.Remove(threadId);
+ _localSendWithoutRunVersions.Remove(threadId);
+ _localSendWithoutRunStartSequences.Remove(threadId);
+ _localEchoSequences.Remove(threadId);
+ _pendingLocalSubmissions.Remove(threadId);
+ _pendingLifecycleStarts.Remove(threadId);
+ _acceptedLifecycleFloors.Remove(threadId);
+ _remoteBackfillInFlight.Remove(threadId);
+ _remoteUserSeen.Remove(threadId);
+ return generation;
+ }
+
+ internal void ClearSubmittedEchoesForReconnect()
+ {
+ _submittedLocalEchoTexts.Clear();
+ _pendingLocalSubmissions.Clear();
+ _acceptedLifecycleFloors.Clear();
+ }
+
+ internal void AddIgnoredRun(string threadId, string runId)
+ {
+ if (!_ignoredRunIds.TryGetValue(threadId, out var runIds))
+ {
+ runIds = new HashSet(StringComparer.Ordinal);
+ _ignoredRunIds[threadId] = runIds;
+ }
+ runIds.Add(runId);
+ if (_acceptedLifecycleFloors.TryGetValue(
+ threadId,
+ out var floor) &&
+ string.Equals(floor.RunId, runId, StringComparison.Ordinal))
+ {
+ _acceptedLifecycleFloors.Remove(threadId);
+ }
+ }
+
+ internal void RegisterPendingLocalSubmission(
+ string threadId,
+ string submissionId,
+ string text,
+ long resetGeneration,
+ long startSequence,
+ DateTimeOffset submittedAt,
+ bool requiresEcho = true)
+ {
+ if (!_awaitingUserMessage.Contains(threadId) ||
+ resetGeneration != GetVersion(threadId) ||
+ requiresEcho && string.IsNullOrWhiteSpace(text))
+ {
+ return;
+ }
+ if (!_pendingLocalSubmissions.TryGetValue(
+ threadId,
+ out var submissions))
+ {
+ submissions = [];
+ _pendingLocalSubmissions[threadId] = submissions;
+ }
+ submissions.RemoveAll(submission =>
+ submission.Generation == resetGeneration &&
+ string.Equals(
+ submission.Id,
+ submissionId,
+ StringComparison.Ordinal));
+ submissions.Add(new PendingLocalSubmission(
+ submissionId,
+ text.Trim(),
+ resetGeneration,
+ submittedAt,
+ startSequence,
+ requiresEcho));
+ Logger.Debug(
+ $"[ResetGate] Registered local submission thread='{threadId}' generation={resetGeneration} startSequence={startSequence} pending={submissions.Count}");
+ PruneExpiredLocalSubmissions(threadId, submissions);
+ if (submissions.Count > 32)
+ submissions.RemoveRange(0, submissions.Count - 32);
+ }
+
+ internal void RemovePendingLocalSubmission(
+ string threadId,
+ string submissionId,
+ long resetGeneration)
+ {
+ if (!_pendingLocalSubmissions.TryGetValue(
+ threadId,
+ out var submissions))
+ {
+ return;
+ }
+ submissions.RemoveAll(submission =>
+ submission.Generation == resetGeneration &&
+ string.Equals(
+ submission.Id,
+ submissionId,
+ StringComparison.Ordinal));
+ if (submissions.Count == 0)
+ _pendingLocalSubmissions.Remove(threadId);
+ }
+
+ internal void CompleteRun(string threadId, string? runId)
+ {
+ _pendingLocalSubmissions.Remove(threadId);
+ if (!_acceptedLifecycleFloors.TryGetValue(
+ threadId,
+ out var floor))
+ {
+ return;
+ }
+ if (string.IsNullOrEmpty(runId) ||
+ string.Equals(floor.RunId, runId, StringComparison.Ordinal))
+ {
+ _acceptedLifecycleFloors.Remove(threadId);
+ }
+ }
+
+ internal void AddSubmittedLocalEcho(
+ string threadId,
+ string text,
+ DateTimeOffset submittedAt)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return;
+ if (!_submittedLocalEchoTexts.TryGetValue(threadId, out var texts))
+ {
+ texts = new Dictionary>(
+ StringComparer.Ordinal);
+ _submittedLocalEchoTexts[threadId] = texts;
+ }
+ var normalized = text.Trim();
+ if (!texts.TryGetValue(normalized, out var timestamps))
+ {
+ timestamps = new Queue();
+ texts[normalized] = timestamps;
+ }
+ timestamps.Enqueue(submittedAt);
+ }
+
+ internal ChatResetMessageGate EvaluateChatMessage(
+ string threadId,
+ string role,
+ string rawText,
+ long timestampMs,
+ bool hasPendingLocalEcho,
+ string? activeRunId = null)
+ {
+ var isNormalUserText = role == "user" &&
+ !ChatContentFormatting.LooksLikeApprovalSlashCommand(rawText) &&
+ !NativeToolProjector.LooksLikeSystemControlNote(rawText);
+
+ if (isNormalUserText &&
+ TryMatchPendingLocalSubmission(
+ threadId,
+ rawText,
+ out var localSubmission))
+ {
+ localSubmission.EchoObserved = true;
+ localSubmission.EchoTimestampMs = timestampMs;
+ _localEchoSequences[threadId] = _lifecycleStartSequence;
+ var opened = _awaitingUserMessage.Contains(threadId)
+ ? TryOpenPendingLifecycle(
+ threadId,
+ acceptedRunId: null,
+ localSubmission)
+ : null;
+ if (opened is null &&
+ !_awaitingUserMessage.Contains(threadId))
+ {
+ LowerAcceptedLifecycleFloor(
+ threadId,
+ activeRunId,
+ timestampMs);
+ }
+ Logger.Debug(
+ $"[ResetGate] Matched local echo thread='{threadId}' generation={GetVersion(threadId)} awaiting={_awaitingUserMessage.Contains(threadId)} hasPending={hasPendingLocalEcho} opened={opened is not null} pendingStarts={PendingLifecycleCount(threadId)} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, timestampMs)}");
+ return new(
+ Drop: true,
+ ConsumeEchoText: rawText.Trim(),
+ RequestRemoteBackfill: false,
+ OpenedLifecycleStart: opened);
+ }
+
+ if (isNormalUserText &&
+ !hasPendingLocalEcho &&
+ TryConsumeSubmittedLocalEcho(threadId, rawText))
+ {
+ AgentEventInfo? opened = null;
+ if (_awaitingUserMessage.Contains(threadId) &&
+ !IsPreResetTimestamp(threadId, timestampMs))
+ {
+ _localEchoSequences[threadId] = _lifecycleStartSequence;
+ opened = TryOpenPendingLifecycle(
+ threadId,
+ acceptedRunId: null);
+ }
+ return new(
+ Drop: true,
+ ConsumeEchoText: rawText.Trim(),
+ RequestRemoteBackfill: false,
+ OpenedLifecycleStart: opened);
+ }
+ if (!_awaitingUserMessage.Contains(threadId))
+ {
+ var timestampAccepted = role == "user"
+ ? !IsPreResetTimestamp(threadId, timestampMs)
+ : IsTimestampAcceptedForRun(
+ threadId,
+ activeRunId,
+ timestampMs);
+ return new(
+ !timestampAccepted,
+ null,
+ false,
+ null);
+ }
+
+ var isFreshUser = isNormalUserText &&
+ !IsPreResetTimestamp(threadId, timestampMs);
+ if (isFreshUser && hasPendingLocalEcho)
+ {
+ _localEchoSequences[threadId] = _lifecycleStartSequence;
+ var opened = TryOpenPendingLifecycle(threadId, acceptedRunId: null);
+ return new(
+ Drop: opened is null,
+ ConsumeEchoText: rawText.Trim(),
+ RequestRemoteBackfill: false,
+ OpenedLifecycleStart: opened);
+ }
+ if (isFreshUser && timestampMs > 0)
+ {
+ _remoteUserSeen.Add(threadId);
+ var opened = TryOpenPendingLifecycle(threadId, acceptedRunId: null);
+ return new(
+ Drop: opened is null,
+ ConsumeEchoText: null,
+ RequestRemoteBackfill: false,
+ OpenedLifecycleStart: opened);
+ }
+ if (isFreshUser && _remoteBackfillInFlight.Add(threadId))
+ {
+ return new(true, null, true, null);
+ }
+ if (isNormalUserText)
+ {
+ Logger.Debug(
+ $"[ResetGate] User echo did not open thread='{threadId}' generation={GetVersion(threadId)} hasPending={hasPendingLocalEcho} isFresh={isFreshUser} localProof={HasRecentCurrentLocalSubmission(threadId)} pendingStarts={PendingLifecycleCount(threadId)} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, timestampMs)}");
+ }
+ return new(true, null, false, null);
+ }
+
+ internal ChatResetAgentGate EvaluateAgentEvent(
+ AgentEventInfo evt,
+ string threadId)
+ {
+ if (string.Equals(
+ evt.Stream,
+ "lifecycle",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ var phase = evt.Data.ValueKind ==
+ System.Text.Json.JsonValueKind.Object &&
+ evt.Data.TryGetProperty("phase", out var phaseProperty)
+ ? phaseProperty.GetString()
+ : null;
+ Logger.Debug(
+ $"[ResetGate] Lifecycle event thread='{threadId}' phase='{phase ?? "(none)"}' runPresent={!string.IsNullOrEmpty(evt.RunId)} awaiting={_awaitingUserMessage.Contains(threadId)} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, evt.Ts > 0 ? (long)evt.Ts : 0)}");
+ }
+ if (IsIgnoredRun(
+ threadId,
+ evt.RunId,
+ evt,
+ out var reloadHistory))
+ {
+ return new(true, reloadHistory, null);
+ }
+
+ var eventTimestamp = evt.Ts > 0 ? (long)evt.Ts : 0;
+ if (!_awaitingUserMessage.Contains(threadId))
+ {
+ return new(
+ !IsTimestampAcceptedForRun(
+ threadId,
+ evt.RunId,
+ eventTimestamp),
+ false,
+ null);
+ }
+ if (IsAcceptedPostResetLifecycleStart(
+ threadId,
+ evt,
+ _lifecycleStartSequence + 1))
+ {
+ OpenGate(threadId, evt);
+ return new(false, false, evt);
+ }
+ if (IsPreResetTimestamp(threadId, eventTimestamp))
+ {
+ var hasLocalProof =
+ HasRecentCurrentLocalSubmission(threadId);
+ if (IsResetLifecycleCandidate(evt) &&
+ hasLocalProof)
+ {
+ BufferLifecycleStart(threadId, evt);
+ }
+ Logger.Debug(
+ $"[ResetGate] Pre-cutoff agent event thread='{threadId}' stream='{evt.Stream}' runPresent={!string.IsNullOrEmpty(evt.RunId)} localProof={hasLocalProof} buffered={IsResetLifecycleCandidate(evt) && hasLocalProof} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, eventTimestamp)}");
+ return new(true, false, null);
+ }
+ if (IsResetLifecycleCandidate(evt))
+ BufferLifecycleStart(threadId, evt);
+ return new(true, false, null);
+ }
+
+ internal AgentEventInfo? AddAcceptedRun(string threadId, string runId)
+ {
+ if (!_awaitingUserMessage.Contains(threadId))
+ return null;
+ if (!_acceptedRunIds.TryGetValue(threadId, out var runIds))
+ {
+ runIds = new HashSet(StringComparer.Ordinal);
+ _acceptedRunIds[threadId] = runIds;
+ }
+ runIds.Add(runId);
+ return TryOpenPendingLifecycle(threadId, runId);
+ }
+
+ internal AgentEventInfo? RecordLocalSendWithoutRun(
+ string threadId,
+ long resetVersion,
+ long lifecycleStartSequence,
+ string? submissionId = null)
+ {
+ _localSendWithoutRunVersions[threadId] = resetVersion;
+ _localSendWithoutRunStartSequences[threadId] = lifecycleStartSequence;
+ PendingLocalSubmission? submission = null;
+ if (!string.IsNullOrEmpty(submissionId) &&
+ _pendingLocalSubmissions.TryGetValue(
+ threadId,
+ out var submissions))
+ {
+ submission = submissions.FirstOrDefault(candidate =>
+ candidate.Generation == resetVersion &&
+ string.Equals(
+ candidate.Id,
+ submissionId,
+ StringComparison.Ordinal));
+ if (submission is not null)
+ submission.ConfirmedWithoutRun = true;
+ }
+ if (submission is { RequiresEcho: false })
+ {
+ return TryOpenPendingLifecycle(
+ threadId,
+ acceptedRunId: null,
+ submission);
+ }
+ return TryOpenPendingLifecycle(threadId, acceptedRunId: null);
+ }
+
+ internal void CompleteRemoteBackfill(string threadId) =>
+ _remoteBackfillInFlight.Remove(threadId);
+
+ internal AgentEventInfo? RecordRemoteUser(string threadId)
+ {
+ _remoteUserSeen.Add(threadId);
+ return TryOpenPendingLifecycle(threadId, acceptedRunId: null);
+ }
+
+ internal bool IsPreResetTimestamp(string threadId, long eventTimestampMs)
+ {
+ if (eventTimestampMs <= 0 ||
+ !_cutoffUtcMs.TryGetValue(threadId, out var cutoff) ||
+ cutoff <= 0)
+ {
+ return false;
+ }
+ return _versions.ContainsKey(threadId) &&
+ eventTimestampMs + TimestampToleranceMs <= cutoff;
+ }
+
+ private bool IsTimestampAcceptedForRun(
+ string threadId,
+ string? runId,
+ long eventTimestampMs)
+ {
+ if (!IsPreResetTimestamp(threadId, eventTimestampMs))
+ return true;
+ return !string.IsNullOrEmpty(runId) &&
+ _acceptedLifecycleFloors.TryGetValue(
+ threadId,
+ out var floor) &&
+ floor.Generation == GetVersion(threadId) &&
+ string.Equals(floor.RunId, runId, StringComparison.Ordinal) &&
+ eventTimestampMs >= floor.TimestampMs;
+ }
+
+ private void LowerAcceptedLifecycleFloor(
+ string threadId,
+ string? runId,
+ long timestampMs)
+ {
+ if (string.IsNullOrEmpty(runId) ||
+ timestampMs <= 0 ||
+ !_acceptedLifecycleFloors.TryGetValue(
+ threadId,
+ out var floor) ||
+ floor.Generation != GetVersion(threadId) ||
+ !string.Equals(
+ floor.RunId,
+ runId,
+ StringComparison.Ordinal) ||
+ timestampMs >= floor.TimestampMs)
+ {
+ return;
+ }
+ _acceptedLifecycleFloors[threadId] =
+ floor with { TimestampMs = timestampMs };
+ }
+
+ private long? TimestampDeltaFromCutoff(
+ string threadId,
+ long eventTimestampMs) =>
+ eventTimestampMs > 0 &&
+ _cutoffUtcMs.TryGetValue(threadId, out var cutoff)
+ ? eventTimestampMs - cutoff
+ : null;
+
+ private int PendingLifecycleCount(string threadId) =>
+ _pendingLifecycleStarts.TryGetValue(
+ threadId,
+ out var pending)
+ ? pending.Count
+ : 0;
+
+ private bool TryMatchPendingLocalSubmission(
+ string threadId,
+ string text,
+ out PendingLocalSubmission submission)
+ {
+ submission = null!;
+ if (string.IsNullOrWhiteSpace(text) ||
+ !_pendingLocalSubmissions.TryGetValue(
+ threadId,
+ out var submissions))
+ {
+ return false;
+ }
+ PruneExpiredLocalSubmissions(threadId, submissions);
+ var generation = GetVersion(threadId);
+ var normalized = text.Trim();
+ var cutoff = _cutoffUtcMs.TryGetValue(threadId, out var value)
+ ? value
+ : 0;
+ submission = submissions.FirstOrDefault(candidate =>
+ candidate.Generation == generation &&
+ candidate.SubmittedAt.ToUnixTimeMilliseconds() >= cutoff &&
+ !candidate.EchoObserved &&
+ string.Equals(
+ candidate.Text,
+ normalized,
+ StringComparison.Ordinal))!;
+ return submission is not null;
+ }
+
+ private bool HasRecentCurrentLocalSubmission(string threadId)
+ {
+ if (!_pendingLocalSubmissions.TryGetValue(
+ threadId,
+ out var submissions))
+ {
+ return false;
+ }
+ PruneExpiredLocalSubmissions(threadId, submissions);
+ var generation = GetVersion(threadId);
+ var cutoff = _cutoffUtcMs.TryGetValue(threadId, out var value)
+ ? value
+ : 0;
+ return submissions.Any(submission =>
+ submission.Generation == generation &&
+ submission.SubmittedAt.ToUnixTimeMilliseconds() >= cutoff);
+ }
+
+ private PendingLocalSubmission? FindAcceptedLocalSubmission(
+ string threadId,
+ long lifecycleStartSequence)
+ {
+ if (!_pendingLocalSubmissions.TryGetValue(
+ threadId,
+ out var submissions))
+ {
+ return null;
+ }
+ PruneExpiredLocalSubmissions(threadId, submissions);
+ var generation = GetVersion(threadId);
+ return submissions.FirstOrDefault(submission =>
+ submission.Generation == generation &&
+ (submission.EchoObserved ||
+ !submission.RequiresEcho &&
+ submission.ConfirmedWithoutRun) &&
+ lifecycleStartSequence > submission.StartSequence);
+ }
+
+ private void PruneExpiredLocalSubmissions(
+ string threadId,
+ List submissions)
+ {
+ var now = DateTimeOffset.UtcNow;
+ submissions.RemoveAll(submission =>
+ now - submission.SubmittedAt > LocalEchoWindow);
+ if (submissions.Count == 0)
+ _pendingLocalSubmissions.Remove(threadId);
+ }
+
+ private bool TryConsumeSubmittedLocalEcho(string threadId, string text)
+ {
+ if (string.IsNullOrWhiteSpace(text) ||
+ !_submittedLocalEchoTexts.TryGetValue(threadId, out var texts))
+ {
+ return false;
+ }
+ var normalized = text.Trim();
+ if (!texts.TryGetValue(normalized, out var timestamps))
+ return false;
+ var now = DateTimeOffset.UtcNow;
+ while (timestamps.Count > 0 &&
+ now - timestamps.Peek() > LocalEchoWindow)
+ {
+ timestamps.Dequeue();
+ }
+ if (timestamps.Count == 0)
+ {
+ texts.Remove(normalized);
+ if (texts.Count == 0)
+ _submittedLocalEchoTexts.Remove(threadId);
+ return false;
+ }
+ timestamps.Dequeue();
+ if (timestamps.Count == 0)
+ texts.Remove(normalized);
+ if (texts.Count == 0)
+ _submittedLocalEchoTexts.Remove(threadId);
+ return true;
+ }
+
+ private bool IsIgnoredRun(
+ string threadId,
+ string? runId,
+ AgentEventInfo evt,
+ out bool reloadHistory)
+ {
+ reloadHistory = false;
+ if (string.IsNullOrEmpty(runId) ||
+ !_ignoredRunIds.TryGetValue(threadId, out var runIds) ||
+ !runIds.Contains(runId))
+ {
+ return false;
+ }
+ if (ChatEventMapper.IsTerminalRunEvent(evt))
+ {
+ runIds.Remove(runId);
+ if (runIds.Count == 0)
+ {
+ _ignoredRunIds.Remove(threadId);
+ _submittedLocalEchoTexts.Remove(threadId);
+ }
+ reloadHistory = true;
+ }
+ return true;
+ }
+
+ private void BufferLifecycleStart(string threadId, AgentEventInfo evt)
+ {
+ if (!_pendingLifecycleStarts.TryGetValue(threadId, out var pending))
+ {
+ pending = [];
+ _pendingLifecycleStarts[threadId] = pending;
+ }
+ if (!string.IsNullOrEmpty(evt.RunId) &&
+ pending.Exists(item =>
+ string.Equals(
+ item.Event.RunId,
+ evt.RunId,
+ StringComparison.Ordinal)))
+ {
+ return;
+ }
+ pending.Add(new PendingLifecycleStart(
+ evt,
+ ++_lifecycleStartSequence));
+ if (pending.Count > 8)
+ pending.RemoveRange(0, pending.Count - 8);
+ }
+
+ private AgentEventInfo? TryOpenPendingLifecycle(
+ string threadId,
+ string? acceptedRunId,
+ PendingLocalSubmission? localSubmission = null)
+ {
+ if (!_awaitingUserMessage.Contains(threadId) ||
+ !_pendingLifecycleStarts.TryGetValue(threadId, out var pending))
+ {
+ return null;
+ }
+
+ if (localSubmission is not null)
+ {
+ var selectedIndex = -1;
+ if (_acceptedRunIds.TryGetValue(
+ threadId,
+ out var acceptedRuns))
+ {
+ for (var index = pending.Count - 1; index >= 0; index--)
+ {
+ var candidate = pending[index];
+ if (candidate.Sequence > localSubmission.StartSequence &&
+ !string.IsNullOrEmpty(candidate.Event.RunId) &&
+ acceptedRuns.Contains(candidate.Event.RunId))
+ {
+ selectedIndex = index;
+ break;
+ }
+ }
+ }
+
+ if (selectedIndex < 0)
+ {
+ for (var index = pending.Count - 1; index >= 0; index--)
+ {
+ if (pending[index].Sequence >
+ localSubmission.StartSequence)
+ {
+ selectedIndex = index;
+ break;
+ }
+ }
+ }
+
+ if (selectedIndex < 0)
+ return null;
+
+ var selected = pending[selectedIndex];
+ pending.RemoveAt(selectedIndex);
+ OpenGate(
+ threadId,
+ selected.Event,
+ localSubmission.EchoTimestampMs);
+ return selected.Event;
+ }
+
+ for (var index = 0; index < pending.Count; index++)
+ {
+ var start = pending[index];
+ if (acceptedRunId is not null)
+ {
+ if (!string.Equals(
+ start.Event.RunId,
+ acceptedRunId,
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+ }
+ else if (!IsAcceptedPostResetLifecycleStart(
+ threadId,
+ start.Event,
+ start.Sequence))
+ {
+ continue;
+ }
+ pending.RemoveAt(index);
+ OpenGate(
+ threadId,
+ start.Event,
+ acceptedEchoTimestampMs: null);
+ return start.Event;
+ }
+ return null;
+ }
+
+ private bool IsAcceptedPostResetLifecycleStart(
+ string threadId,
+ AgentEventInfo evt,
+ long lifecycleStartSequence)
+ {
+ if (!IsResetLifecycleCandidate(evt))
+ return false;
+ if (!string.IsNullOrEmpty(evt.RunId) &&
+ _acceptedRunIds.TryGetValue(threadId, out var accepted) &&
+ accepted.Contains(evt.RunId))
+ {
+ return true;
+ }
+ if (FindAcceptedLocalSubmission(
+ threadId,
+ lifecycleStartSequence) is not null)
+ {
+ return true;
+ }
+ if (_localSendWithoutRunVersions.TryGetValue(threadId, out var version) &&
+ version == GetVersion(threadId) &&
+ _localSendWithoutRunStartSequences.TryGetValue(
+ threadId,
+ out var startSequence) &&
+ _localEchoSequences.TryGetValue(threadId, out var echoSequence) &&
+ echoSequence >= startSequence &&
+ lifecycleStartSequence > startSequence &&
+ evt.Ts > 0 &&
+ !IsPreResetTimestamp(threadId, (long)evt.Ts))
+ {
+ return true;
+ }
+ return _remoteUserSeen.Contains(threadId) &&
+ !IsPreResetTimestamp(
+ threadId,
+ evt.Ts > 0 ? (long)evt.Ts : 0);
+ }
+
+ private void OpenGate(
+ string threadId,
+ AgentEventInfo evt,
+ long? acceptedEchoTimestampMs = null)
+ {
+ _awaitingUserMessage.Remove(threadId);
+ _remoteUserSeen.Remove(threadId);
+ _localSendWithoutRunVersions.Remove(threadId);
+ _localSendWithoutRunStartSequences.Remove(threadId);
+ _localEchoSequences.Remove(threadId);
+ _pendingLifecycleStarts.Remove(threadId);
+ if (acceptedEchoTimestampMs is null)
+ {
+ acceptedEchoTimestampMs =
+ FindAcceptedLocalSubmission(
+ threadId,
+ _lifecycleStartSequence + 1)?
+ .EchoTimestampMs;
+ }
+ var floorTimestamp = evt.Ts > 0 ? (long)evt.Ts : 0;
+ if (acceptedEchoTimestampMs is > 0 &&
+ (floorTimestamp <= 0 ||
+ acceptedEchoTimestampMs.Value < floorTimestamp))
+ {
+ floorTimestamp = acceptedEchoTimestampMs.Value;
+ }
+ if (!string.IsNullOrEmpty(evt.RunId) && floorTimestamp > 0)
+ {
+ _acceptedLifecycleFloors[threadId] =
+ new AcceptedLifecycleFloor(
+ evt.RunId,
+ GetVersion(threadId),
+ floorTimestamp);
+ }
+ else
+ {
+ _acceptedLifecycleFloors.Remove(threadId);
+ }
+ if (!string.IsNullOrEmpty(evt.RunId) &&
+ _acceptedRunIds.TryGetValue(threadId, out var accepted))
+ {
+ accepted.Remove(evt.RunId);
+ if (accepted.Count == 0)
+ _acceptedRunIds.Remove(threadId);
+ }
+ }
+
+ private static bool IsResetLifecycleCandidate(
+ AgentEventInfo evt)
+ {
+ if (ChatEventMapper.IsLifecycleStart(evt))
+ return true;
+ return string.Equals(
+ evt.Stream,
+ "lifecycle",
+ StringComparison.OrdinalIgnoreCase) &&
+ evt.Data.ValueKind ==
+ System.Text.Json.JsonValueKind.Object &&
+ evt.Data.TryGetProperty(
+ "phase",
+ out var phaseProperty) &&
+ string.Equals(
+ phaseProperty.GetString(),
+ "fallback_step",
+ StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatRuntimeTransitions.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatRuntimeTransitions.cs
new file mode 100644
index 000000000..69c4d8542
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatRuntimeTransitions.cs
@@ -0,0 +1,169 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using System.Text.Json.Nodes;
+
+namespace OpenClawTray.Chat;
+
+internal sealed record ChatProjectionContext(
+ string? MainSessionKey,
+ bool HasHandshakeSnapshot);
+
+internal readonly record struct ChatRuntimeGeneration(
+ long ConnectionGeneration,
+ long ResetGeneration);
+
+internal sealed record ChatStatusTransition(
+ ChatDataSnapshot Snapshot,
+ bool Reconnected,
+ bool Disconnected,
+ string[] InterruptedThreads,
+ long HistoryGeneration);
+
+internal sealed record ChatSessionsTransition(
+ ChatDataSnapshot Snapshot,
+ string[] QueuedThreadsToDrain);
+
+internal sealed record ChatResetTransition(
+ ChatDataSnapshot Snapshot,
+ string? OldSessionId,
+ long ResetGeneration,
+ string ThreadId,
+ string[] SubmittedRunIds);
+
+internal sealed record ChatAbortStart(
+ string? RunId,
+ bool HadActiveTurn);
+
+internal sealed record ChatAgentEventTransition(
+ bool Process,
+ bool ReloadHistory,
+ ChatTerminalEventDropReason? DroppedTerminalReason,
+ string? DeferredAbortRunId,
+ int DeferredAbortCount,
+ string? CompletedRunId,
+ string? CompletionPhase,
+ bool FetchRemoteUser,
+ bool AllowRemoteTurn,
+ bool WasAborted,
+ bool Suppressed,
+ ChatEvent? MappedEvent,
+ ChatToolMetadataWrite? ToolMetadata,
+ ChatDataSnapshot[] Snapshots,
+ ChatOpenedLifecycleTransition? OpenedLifecycle,
+ ChatRuntimeGeneration RuntimeGeneration);
+
+internal sealed record ChatToolMetadataWrite(
+ string ThreadId,
+ string CacheKey,
+ long ResetGeneration,
+ long TimestampMs,
+ string ToolName,
+ string Label,
+ string? ToolCallId,
+ JsonObject? ToolArgs,
+ ChatToolIdentityStrength IdentityStrength,
+ string? RunId,
+ long LegacyTurn);
+
+internal sealed record ChatRunTransition(
+ string? DeferredAbortRunId,
+ int DeferredAbortCount,
+ ChatTerminalEventDropReason? DroppedTerminalReason,
+ string? CompletedRunId,
+ string? CompletionPhase,
+ bool FetchRemoteUser,
+ bool AllowRemoteTurn,
+ bool WasAborted,
+ ChatDataSnapshot? Snapshot);
+
+internal sealed record ChatAgentEventGate(
+ bool Process,
+ bool ReloadHistory,
+ ChatTerminalEventDropReason? DroppedTerminalReason,
+ ChatOpenedLifecycleTransition? OpenedLifecycle);
+
+internal sealed record ChatHistoryCommitToken(
+ string ThreadId,
+ long ConnectionGeneration,
+ long ResetGeneration,
+ long ReplacementGeneration);
+
+internal sealed record ChatHistoryReplacementTransition(
+ ChatDataSnapshot Snapshot,
+ ChatHistoryCommitToken Token);
+
+internal sealed record ChatHistoryRebuildPlan(
+ string? SessionId,
+ ChatTimelineState Timeline,
+ IReadOnlyDictionary Metadata,
+ int MaxHistorySequence);
+
+internal sealed record ChatQueuedAdmission(
+ string MessageId,
+ bool Queued,
+ ChatQueuedSendDispatch? Dispatch,
+ ChatDataSnapshot Snapshot,
+ ChatRuntimeGeneration RuntimeGeneration);
+
+internal sealed record ChatQueueStart(
+ ChatQueuedSendDispatch? Dispatch,
+ TimeSpan? DelayedRetry,
+ ChatDataSnapshot? Snapshot);
+
+internal sealed record ChatOpenedLifecycleTransition(
+ AgentEventInfo Event,
+ bool AllowRemoteTurn,
+ string? DeferredAbortRunId,
+ int DeferredAbortCount);
+
+internal sealed record ChatSendCommit(
+ bool IsCurrent,
+ ChatDataSnapshot? AcceptedSnapshot,
+ ChatDataSnapshot? RequeuedSnapshot,
+ string? StaleRunIdToAbort,
+ bool BindAcceptedRun,
+ bool RequeueRequired,
+ bool RetryDeferredSend,
+ TimeSpan DeferredRetryDelay,
+ ChatOpenedLifecycleTransition? OpenedLifecycle,
+ ChatRuntimeGeneration RuntimeGeneration);
+
+internal sealed record ChatSendFailure(
+ bool IsCurrent,
+ ChatDataSnapshot? Snapshot);
+
+internal sealed record ChatSendPreparation(
+ bool IsCurrent,
+ ChatDataSnapshot? Snapshot);
+
+internal sealed record ChatModelPatchLease(
+ string ThreadId,
+ Task? Previous,
+ TaskCompletionSource Completion);
+
+internal sealed record ChatDisposeTransition(
+ long HistoryGeneration,
+ bool IsFirstDispose);
+
+internal sealed record ChatIncomingMessageGate(
+ bool Drop,
+ bool Suppressed,
+ bool RequestRemoteBackfill,
+ ChatDataSnapshot? Snapshot,
+ ChatOpenedLifecycleTransition? OpenedLifecycle,
+ ChatRuntimeGeneration RuntimeGeneration);
+
+internal sealed record ChatRemoteUserBackfillTransition(
+ ChatDataSnapshot Snapshot,
+ ChatOpenedLifecycleTransition? OpenedLifecycle,
+ ChatRuntimeGeneration RuntimeGeneration);
+
+internal sealed record ChatLocalEchoTransition(
+ bool Consumed,
+ ChatDataSnapshot? Snapshot);
+
+internal sealed record ChatAssistantPreparation(
+ AssistantQueueFrameDisposition Disposition,
+ ChatDataSnapshot? PromotionSnapshot,
+ ChatEntryMetadata Metadata,
+ string? ActiveRunId);
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatSendQueue.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatSendQueue.cs
new file mode 100644
index 000000000..7f141881a
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatSendQueue.cs
@@ -0,0 +1,99 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+
+namespace OpenClawTray.Chat;
+
+internal sealed record ChatQueuedSendRequest(
+ string Id,
+ string SendRunId,
+ string ThreadId,
+ string Text,
+ string DisplayText,
+ string LocalNonce,
+ IReadOnlyList? Attachments,
+ int DeferredAdmissionRetryCount = 0,
+ DateTimeOffset? DeferredAdmissionRetryAfter = null,
+ ChatLifecycleCommandKind? LifecycleCommand = null);
+
+internal sealed record ChatQueuedSendDispatch(
+ ChatQueuedSendRequest Request,
+ string? SessionId,
+ long ConnectionGeneration,
+ long ResetVersion,
+ long StartedLifecycleSequence,
+ long StartedRunStartSequence,
+ bool StartedDirectly);
+
+internal enum AssistantQueueFrameDisposition
+{
+ Render,
+ Drop,
+}
+
+internal enum ChatAdmissionOutcome
+{
+ Accepted,
+ Deferred,
+ Rejected,
+ Canceled,
+ Other,
+}
+
+internal static class ChatSendQueuePolicy
+{
+ internal const int MaxDeferredAdmissionRetries = 8;
+ internal static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(100);
+ internal static readonly TimeSpan MaxDeferredAdmissionRetryDelay = TimeSpan.FromSeconds(1);
+
+ internal static bool CanSendDirectly(
+ bool hasActiveRun,
+ bool turnActive,
+ bool hasPendingMessages) =>
+ !hasActiveRun && !turnActive && !hasPendingMessages;
+
+ internal static bool CanStartNext(
+ bool requireConnected,
+ ConnectionStatus status,
+ bool hasActiveRun,
+ bool turnActive,
+ bool hasSendingMessage) =>
+ (!requireConnected || status == ConnectionStatus.Connected)
+ && !hasActiveRun
+ && !turnActive
+ && !hasSendingMessage;
+
+ internal static bool IsDeferredAdmissionStatus(string? status) =>
+ string.Equals(status, "in_flight", StringComparison.OrdinalIgnoreCase);
+
+ internal static bool IsCanceledAdmissionStatus(string? status) =>
+ string.Equals(status, "aborted", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, "cancelled", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, "canceled", StringComparison.OrdinalIgnoreCase);
+
+ internal static ChatAdmissionOutcome ClassifyAdmission(
+ ChatSendResult result)
+ {
+ if (IsDeferredAdmissionStatus(result.Status))
+ return ChatAdmissionOutcome.Deferred;
+ if (result.IsTerminalFailure)
+ {
+ return IsCanceledAdmissionStatus(result.Status)
+ ? ChatAdmissionOutcome.Canceled
+ : ChatAdmissionOutcome.Rejected;
+ }
+ if (string.IsNullOrWhiteSpace(result.Status) ||
+ string.Equals(result.Status, "started", StringComparison.OrdinalIgnoreCase))
+ {
+ return ChatAdmissionOutcome.Accepted;
+ }
+ return ChatAdmissionOutcome.Other;
+ }
+
+ internal static TimeSpan DeferredAdmissionRetryDelay(int retryCount)
+ {
+ var exponent = Math.Min(Math.Max(retryCount - 1, 0), 5);
+ var delayMs = DrainDelay.TotalMilliseconds * (1 << exponent);
+ return TimeSpan.FromMilliseconds(
+ Math.Min(delayMs, MaxDeferredAdmissionRetryDelay.TotalMilliseconds));
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs
new file mode 100644
index 000000000..24371d638
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs
@@ -0,0 +1,156 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal sealed record ChatSnapshotProjectionInput(
+ SessionInfo[] Sessions,
+ IReadOnlyDictionary Timelines,
+ IReadOnlyDictionary TimelineGenerations,
+ IReadOnlyDictionary HistoryRevisions,
+ IReadOnlyDictionary> QueuedMessages,
+ bool SessionsListReceived,
+ string[] AvailableModels,
+ IReadOnlyList ModelChoices,
+ CommandCatalog? CommandCatalog,
+ ConnectionStatus Status,
+ string? MainSessionKey,
+ bool HasHandshakeSnapshot,
+ string? RememberedDefaultThreadId,
+ string? RememberedThreadTitle,
+ string? RememberedModel,
+ string? RememberedModelProvider);
+
+internal static class ChatSnapshotProjector
+{
+ internal static ChatDataSnapshot Project(ChatSnapshotProjectionInput input)
+ {
+ var threadList = new List(input.Sessions.Length + 1);
+ var threadTitles = SessionTitleFormatter.FormatUnique(input.Sessions);
+ for (var i = 0; i < input.Sessions.Length; i++)
+ threadList.Add(ToThread(input.Sessions[i], threadTitles[i]));
+
+ var composeKey = input.MainSessionKey;
+ var composeAgentId = input.Sessions
+ .FirstOrDefault(session =>
+ string.Equals(session.Key, composeKey, StringComparison.Ordinal)) is { } mainSession
+ ? SessionDisplayResolver.Resolve(mainSession).AgentId ?? "main"
+ : "main";
+ var composeReady = input.HasHandshakeSnapshot
+ && !string.IsNullOrWhiteSpace(composeKey)
+ && input.Status == ConnectionStatus.Connected
+ && input.SessionsListReceived;
+
+ if (composeReady
+ && composeKey is { } key
+ && input.Timelines.TryGetValue(key, out var pendingTimeline)
+ && (pendingTimeline.Entries.Count > 0
+ || pendingTimeline.TurnActive
+ || input.QueuedMessages.TryGetValue(key, out var pendingQueue) &&
+ pendingQueue.Count > 0)
+ && !input.Sessions.Any(session =>
+ string.Equals(session.Key, key, StringComparison.Ordinal)))
+ {
+ threadList.Add(new ChatThread
+ {
+ Id = key,
+ AgentId = composeAgentId,
+ Title = input.RememberedThreadTitle ?? "OpenClaw Windows Tray",
+ Model = input.RememberedModel,
+ ModelProvider = input.RememberedModelProvider,
+ Status = ChatThreadStatus.Running,
+ Activity = ChatActivity.Idle,
+ });
+ }
+
+ var connectionLabel = input.Status == ConnectionStatus.Connected
+ && input.HasHandshakeSnapshot
+ && string.IsNullOrWhiteSpace(composeKey)
+ ? "Incompatible gateway"
+ : input.Status switch
+ {
+ ConnectionStatus.Connected => "Connected",
+ ConnectionStatus.Connecting => "Connecting…",
+ ConnectionStatus.Disconnected => "Disconnected",
+ ConnectionStatus.Error => "Disconnected — error",
+ _ => input.Status.ToString(),
+ };
+
+ return new ChatDataSnapshot(
+ Threads: threadList.ToArray(),
+ Timelines: input.Timelines,
+ DefaultThreadId: ResolveDefaultThreadId(input),
+ ConnectionStatus: connectionLabel,
+ AvailableModels: input.AvailableModels,
+ ComposeTarget: composeReady
+ ? new ChatComposeTarget(composeKey, true, composeAgentId)
+ : ChatComposeTarget.NotReady,
+ ModelChoices: input.ModelChoices,
+ AvailableCommands: input.CommandCatalog?.Commands,
+ CommandsSupported: input.CommandCatalog?.IsSupported ?? true,
+ TimelineGenerations: input.TimelineGenerations,
+ HistoryRevisions: input.HistoryRevisions,
+ QueuedMessagesByThread: input.QueuedMessages);
+ }
+
+ internal static string? ResolveDefaultThreadId(ChatSnapshotProjectionInput input)
+ {
+ if (input.RememberedDefaultThreadId is { Length: > 0 } remembered &&
+ (input.Sessions.Any(session =>
+ string.Equals(session.Key, remembered, StringComparison.Ordinal)) ||
+ !input.SessionsListReceived))
+ {
+ return remembered;
+ }
+
+ foreach (var session in input.Sessions)
+ {
+ if (session.IsMain && !string.IsNullOrEmpty(session.Key))
+ return session.Key;
+ }
+ if (input.HasHandshakeSnapshot &&
+ input.MainSessionKey is { } mainKey &&
+ !string.IsNullOrWhiteSpace(mainKey))
+ {
+ return mainKey;
+ }
+ return input.Sessions.FirstOrDefault(session =>
+ !string.IsNullOrEmpty(session.Key))?.Key;
+ }
+
+ private static ChatThread ToThread(SessionInfo session, string title)
+ {
+ var display = SessionDisplayResolver.Resolve(session);
+ return new ChatThread
+ {
+ Id = session.Key ?? string.Empty,
+ Title = title,
+ AgentId = display.AgentId,
+ IsBackground = display.IsBackground,
+ Status = SessionVisibilityFilter.ToChatThreadStatus(session),
+ Activity = SessionVisibilityFilter.ToChatThreadActivity(session),
+ Workspace = session.Channel,
+ Model = session.Model,
+ ModelProvider = session.Provider,
+ ThinkingLevel = session.ThinkingLevel,
+ InputTokens = session.InputTokens,
+ OutputTokens = session.OutputTokens,
+ TotalTokens = session.TotalTokens,
+ ContextTokens = session.ContextTokens,
+ CreatedAt = session.StartedAt is { } started ? ToOffset(started) : null,
+ UpdatedAt = session.UpdatedAt is { } updated ? ToOffset(updated) : null,
+ };
+ }
+
+ private static DateTimeOffset ToOffset(DateTime value)
+ {
+ if (value.Kind == DateTimeKind.Unspecified)
+ {
+ return new DateTimeOffset(
+ DateTime.SpecifyKind(value, DateTimeKind.Utc),
+ TimeSpan.Zero);
+ }
+ return new DateTimeOffset(value);
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatStatePersistence.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatStatePersistence.cs
new file mode 100644
index 000000000..054430882
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatStatePersistence.cs
@@ -0,0 +1,389 @@
+using System.Text.Json;
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Chat;
+
+internal sealed class ChatStatePersistence : IDisposable
+{
+ private readonly object _gate = new();
+ private readonly SemaphoreSlim _abortedFileGate = new(1, 1);
+ private readonly string _lastStatePath;
+ private readonly string _abortedIdsPath;
+ private readonly TimeSpan _lastStateSaveDelay;
+ private readonly Dictionary> _abortedIds;
+ private readonly Dictionary>
+ _abortedIdGenerations;
+ private readonly Dictionary _resetGenerations = new(StringComparer.Ordinal);
+ private Timer? _lastStateSaveTimer;
+ private long _lastStateSaveVersion;
+ private OpenClawChatDataProvider.LastChatState? _lastState;
+ private bool _disposed;
+
+ internal ChatStatePersistence(
+ string? lastStatePath = null,
+ TimeSpan? lastStateSaveDelay = null,
+ string? abortedIdsPath = null)
+ {
+ _lastStatePath = !string.IsNullOrWhiteSpace(lastStatePath)
+ ? lastStatePath
+ : ChatMetadataStore.LastChatStateFilePath;
+ _lastStateSaveDelay = lastStateSaveDelay ?? TimeSpan.FromSeconds(2);
+ _abortedIdsPath = !string.IsNullOrWhiteSpace(abortedIdsPath)
+ ? abortedIdsPath
+ : ChatMetadataStore.AbortedIdsFilePath;
+ InitialLastChatState = LoadLastChatState(_lastStatePath);
+ _lastState = InitialLastChatState;
+ _abortedIds = LoadAbortedIds(_abortedIdsPath);
+ _abortedIdGenerations = _abortedIds.ToDictionary(
+ pair => pair.Key,
+ pair => pair.Value.ToDictionary(
+ id => id,
+ _ => 0L,
+ StringComparer.Ordinal),
+ StringComparer.Ordinal);
+ }
+
+ internal OpenClawChatDataProvider.LastChatState? InitialLastChatState { get; }
+
+ internal bool IsMessageAborted(string threadId, string? openClawId)
+ => IsMessageAborted(threadId, openClawId, resetGeneration: 0);
+
+ internal bool IsMessageAborted(
+ string threadId,
+ string? openClawId,
+ long resetGeneration)
+ {
+ if (openClawId is null)
+ return false;
+ lock (_gate)
+ {
+ return _abortedIds.TryGetValue(threadId, out var ids) &&
+ ids.Contains(openClawId) &&
+ _abortedIdGenerations.TryGetValue(threadId, out var generations) &&
+ generations.TryGetValue(openClawId, out var generation) &&
+ generation >= resetGeneration;
+ }
+ }
+
+ internal bool ApplyReset(string threadId, long resetGeneration)
+ {
+ lock (_gate)
+ {
+ if (_resetGenerations.TryGetValue(threadId, out var current) &&
+ current >= resetGeneration)
+ {
+ return false;
+ }
+ _resetGenerations[threadId] = resetGeneration;
+ if (!_abortedIds.TryGetValue(threadId, out var ids) ||
+ !_abortedIdGenerations.TryGetValue(
+ threadId,
+ out var generations))
+ {
+ return false;
+ }
+ var removed = ids.RemoveWhere(id =>
+ !generations.TryGetValue(id, out var generation) ||
+ generation < resetGeneration) > 0;
+ foreach (var id in generations
+ .Where(pair => pair.Value < resetGeneration)
+ .Select(pair => pair.Key)
+ .ToArray())
+ {
+ generations.Remove(id);
+ }
+ if (ids.Count == 0)
+ {
+ _abortedIds.Remove(threadId);
+ _abortedIdGenerations.Remove(threadId);
+ }
+ return removed;
+ }
+ }
+
+ internal void SaveAbortedIds()
+ {
+ _abortedFileGate.Wait();
+ try
+ {
+ Dictionary> snapshot;
+ lock (_gate)
+ {
+ snapshot = _abortedIds.ToDictionary(
+ pair => pair.Key,
+ pair => pair.Value.ToList(),
+ StringComparer.Ordinal);
+ }
+
+ var directory = Path.GetDirectoryName(_abortedIdsPath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+ var path = _abortedIdsPath;
+ var tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllText(
+ tempPath,
+ JsonSerializer.Serialize(
+ snapshot,
+ new JsonSerializerOptions { WriteIndented = true }));
+ File.Move(tempPath, path, overwrite: true);
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug(
+ $"Aborted ID temp file cleanup failed: {ex.Message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"Chat aborted ID persistence failed: {ex.Message}");
+ }
+ finally
+ {
+ _abortedFileGate.Release();
+ }
+ }
+
+ internal IReadOnlyList FindAbortedMessageIds(
+ string threadId,
+ IReadOnlyList messages,
+ long resetGeneration = 0) =>
+ FindAbortedMessageIds(messages, threadId, resetGeneration);
+
+ internal bool TryAddAbortedIds(
+ string threadId,
+ long resetGeneration,
+ IReadOnlyList newIds)
+ {
+ if (newIds.Count == 0)
+ return false;
+ lock (_gate)
+ {
+ if (_resetGenerations.TryGetValue(threadId, out var fence) &&
+ resetGeneration < fence)
+ {
+ return false;
+ }
+ if (!_abortedIds.TryGetValue(threadId, out var ids))
+ {
+ ids = new HashSet(StringComparer.Ordinal);
+ _abortedIds[threadId] = ids;
+ }
+ if (!_abortedIdGenerations.TryGetValue(
+ threadId,
+ out var generations))
+ {
+ generations = new Dictionary(
+ StringComparer.Ordinal);
+ _abortedIdGenerations[threadId] = generations;
+ }
+ var changed = false;
+ foreach (var id in newIds)
+ {
+ changed |= ids.Add(id);
+ if (!generations.TryGetValue(id, out var generation) ||
+ generation < resetGeneration)
+ {
+ generations[id] = resetGeneration;
+ }
+ }
+ return changed;
+ }
+ }
+
+ internal void SaveSelectedState(OpenClawChatDataProvider.LastChatState state)
+ {
+ Timer? timer;
+ lock (_gate)
+ {
+ timer = _lastStateSaveTimer;
+ _lastStateSaveTimer = null;
+ _lastStateSaveVersion++;
+ _lastState = state;
+ }
+ timer?.Dispose();
+ SaveLastChatState(state, _lastStatePath);
+ }
+
+ internal void DebounceSnapshot(ChatDataSnapshot snapshot)
+ {
+ var defaultThread = snapshot.DefaultThreadId is { } defaultId
+ ? Array.Find(snapshot.Threads, thread => thread.Id == defaultId)
+ : snapshot.Threads.FirstOrDefault();
+ if (defaultThread is null && snapshot.AvailableModels.Length == 0)
+ return;
+
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ var previous = _lastState;
+ var state = new OpenClawChatDataProvider.LastChatState
+ {
+ DefaultThreadId = snapshot.DefaultThreadId ?? previous?.DefaultThreadId,
+ ThreadTitle = defaultThread?.Title ?? previous?.ThreadTitle,
+ Model = defaultThread?.Model ?? previous?.Model,
+ ModelProvider = defaultThread?.ModelProvider ?? previous?.ModelProvider,
+ AvailableModels = snapshot.AvailableModels.ToArray(),
+ };
+ _lastState = state;
+ var version = ++_lastStateSaveVersion;
+ _lastStateSaveTimer?.Dispose();
+ _lastStateSaveTimer = new Timer(
+ _ => SaveLastStateIfCurrent(state, version),
+ null,
+ _lastStateSaveDelay,
+ Timeout.InfiniteTimeSpan);
+ }
+ }
+
+ public void Dispose()
+ {
+ Timer? timer;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ timer = _lastStateSaveTimer;
+ _lastStateSaveTimer = null;
+ _lastStateSaveVersion++;
+ }
+ timer?.Dispose();
+ }
+
+ internal static OpenClawChatDataProvider.LastChatState? LoadLastChatState(
+ string? pathOverride = null)
+ {
+ var path = pathOverride ?? ChatMetadataStore.LastChatStateFilePath;
+ try
+ {
+ if (!File.Exists(path))
+ return null;
+ return JsonSerializer.Deserialize(
+ File.ReadAllText(path));
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Failed to load last chat state from '{path}': {ex.Message}");
+ return null;
+ }
+ }
+
+ private static Dictionary> LoadAbortedIds(string path)
+ {
+ try
+ {
+ if (!File.Exists(path))
+ return [];
+ var persisted = JsonSerializer.Deserialize>>(
+ File.ReadAllText(path));
+ return persisted?.ToDictionary(
+ pair => pair.Key,
+ pair => new HashSet(pair.Value, StringComparer.Ordinal),
+ StringComparer.Ordinal) ?? [];
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"Aborted message IDs could not be loaded: {ex.Message}");
+ return [];
+ }
+ }
+
+ private List FindAbortedMessageIds(
+ IReadOnlyList messages,
+ string threadId,
+ long resetGeneration)
+ {
+ var result = new List();
+ for (var i = 0; i < messages.Count; i++)
+ {
+ var message = messages[i];
+ if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) ||
+ message.OpenClawId is null ||
+ IsMessageAborted(
+ threadId,
+ message.OpenClawId,
+ resetGeneration) ||
+ result.Contains(message.OpenClawId, StringComparer.Ordinal))
+ {
+ continue;
+ }
+
+ ChatMessageInfo? nextAssistant = null;
+ for (var j = i + 1; j < messages.Count; j++)
+ {
+ var candidate = messages[j];
+ if (string.Equals(
+ candidate.Role,
+ "assistant",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ nextAssistant = candidate;
+ break;
+ }
+ if (string.Equals(
+ candidate.Role,
+ "user",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ break;
+ }
+ }
+
+ if (nextAssistant is null ||
+ !string.IsNullOrEmpty(nextAssistant.StopReason) &&
+ !string.Equals(nextAssistant.StopReason, "stop", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(nextAssistant.StopReason, "end_turn", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(nextAssistant.StopReason, "toolUse", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Add(message.OpenClawId);
+ }
+ }
+ return result;
+ }
+
+ private void SaveLastStateIfCurrent(
+ OpenClawChatDataProvider.LastChatState state,
+ long version)
+ {
+ lock (_gate)
+ {
+ if (_disposed || version != _lastStateSaveVersion)
+ return;
+ SaveLastChatState(state, _lastStatePath);
+ _lastStateSaveTimer?.Dispose();
+ _lastStateSaveTimer = null;
+ }
+ }
+
+ private static void SaveLastChatState(
+ OpenClawChatDataProvider.LastChatState state,
+ string path)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+ var tempPath = path + ".tmp";
+ File.WriteAllText(tempPath, JsonSerializer.Serialize(state));
+ File.Move(tempPath, path, overwrite: true);
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug($"Chat state persistence failed: {ex.Message}");
+ }
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs
index e888ab2fb..bfea2582f 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs
@@ -155,7 +155,11 @@ internal sealed class ChatTelemetryTracker
private readonly Dictionary _turnsByMessageId = new(StringComparer.Ordinal);
private readonly Dictionary _turnsByRunId = new(StringComparer.Ordinal);
- public void StartLocalTurn(string messageId, string threadId, bool queued)
+ public void StartLocalTurn(
+ string messageId,
+ string threadId,
+ bool queued,
+ ChatRuntimeGeneration? runtimeGeneration = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(messageId);
@@ -172,7 +176,8 @@ public void StartLocalTurn(string messageId, string threadId, bool queued)
TurnSpanName,
default(ActivityContext),
[OpenClawTelemetryTag.String(OpenClawTelemetryTagKey.Source, SourceLocal)]),
- Stopwatch.GetTimestamp());
+ Stopwatch.GetTimestamp(),
+ runtimeGeneration);
if (queued)
StartQueueSegmentLocked(state);
@@ -248,7 +253,11 @@ public void ObserveAdmissionAccepted(string messageId)
}
}
- public void ObserveLifecycleStart(string threadId, string? runId, bool allowRemoteTurn = true)
+ public void ObserveLifecycleStart(
+ string threadId,
+ string? runId,
+ bool allowRemoteTurn = true,
+ ChatRuntimeGeneration? runtimeGeneration = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(threadId);
if (string.IsNullOrWhiteSpace(runId))
@@ -284,7 +293,9 @@ public void ObserveLifecycleStart(string threadId, string? runId, bool allowRemo
var pendingLocal = _turnsByMessageId.Values.FirstOrDefault(
state => state.Source == SourceLocal &&
state.ThreadId == threadId &&
- state.IsDispatched);
+ state.IsDispatched &&
+ (runtimeGeneration is null ||
+ state.RuntimeGeneration == runtimeGeneration));
if (pendingLocal is not null)
{
BindRunLocked(pendingLocal, runId);
@@ -302,7 +313,8 @@ public void ObserveLifecycleStart(string threadId, string? runId, bool allowRemo
TurnSpanName,
default(ActivityContext),
[OpenClawTelemetryTag.String(OpenClawTelemetryTagKey.Source, SourceRemote)]),
- Stopwatch.GetTimestamp());
+ Stopwatch.GetTimestamp(),
+ runtimeGeneration);
BindRunLocked(remote, runId);
StartResponseWaitLocked(remote);
}
@@ -489,6 +501,33 @@ public void FinishThread(
reason);
}
+ public void FinishBeforeConnectionGeneration(
+ long connectionGeneration,
+ ChatTelemetryOutcome outcome,
+ ChatTurnTelemetryReason reason) =>
+ FinishStates(
+ RemoveWhere(state =>
+ state.RuntimeGeneration is not { } generation ||
+ generation.ConnectionGeneration < connectionGeneration),
+ outcome,
+ reason);
+
+ public void FinishThreadBeforeResetGeneration(
+ string threadId,
+ long resetGeneration,
+ ChatTelemetryOutcome outcome,
+ ChatTurnTelemetryReason reason)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(threadId);
+ FinishStates(
+ RemoveWhere(state =>
+ state.ThreadId == threadId &&
+ (state.RuntimeGeneration is not { } generation ||
+ generation.ResetGeneration < resetGeneration)),
+ outcome,
+ reason);
+ }
+
public void FinishAll(ChatTelemetryOutcome outcome, ChatTurnTelemetryReason reason) =>
FinishStates(RemoveWhere(static _ => true), outcome, reason);
@@ -959,7 +998,8 @@ private sealed class TurnState(
string threadId,
string source,
Activity? activity,
- long startTimestamp)
+ long startTimestamp,
+ ChatRuntimeGeneration? runtimeGeneration)
{
private long? _queueSegmentStart;
@@ -968,6 +1008,7 @@ private sealed class TurnState(
public string Source { get; } = source;
public Activity? Activity { get; } = activity;
public long StartTimestamp { get; } = startTimestamp;
+ public ChatRuntimeGeneration? RuntimeGeneration { get; } = runtimeGeneration;
public HashSet RunIds { get; } = new(StringComparer.Ordinal);
public bool IsDispatched { get; set; }
public bool WasQueued { get; private set; }
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 86816f334..ba33ca255 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -5,7 +5,6 @@
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
-using System.Text.Json.Nodes;
using OpenClaw.Chat;
using OpenClaw.Shared;
#if !OPENCLAW_TRAY_TESTS
@@ -71,12 +70,7 @@ internal static class LocalizationHelper
///
public sealed class OpenClawChatDataProvider : IChatDataProvider
{
- private const long ResetTimestampToleranceMs = 1000;
- private static readonly JsonSerializerOptions CacheJsonOptions = new()
- {
- WriteIndented = true,
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- };
+ internal const int MaxEntryTextBytes = 256 * 1024;
///
/// Process-wide cache mapping an attachment's filename to its raw image
/// bytes. Populated by for image
@@ -89,144 +83,20 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
private readonly IChatGatewayBridge _bridge;
private readonly ChatTelemetryTracker _telemetry = new();
+ private readonly ChatMetadataStore _metadataStore;
+ private readonly ChatStatePersistence _persistence;
+ private readonly ChatConversationState _state;
+ private readonly ChatHistoryLoader _historyLoader;
private readonly Action? _post;
- private readonly object _gate = new();
- private readonly object _toolMetaSaveGate = new();
- private readonly object _attachmentMetaSaveGate = new();
- private readonly string _toolMetaCacheFilePath;
- private readonly string _attachmentMetaCacheFilePath;
- private readonly string _lastChatStateFilePath;
- private readonly TimeSpan _lastChatStateSaveDelay;
- private readonly Func, Task> _scheduleHistoryRetry;
- private readonly Action? _historyFailureReservedForTesting;
- private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes
- private long _toolMetaSaveVersion;
- private bool _toolMetaCacheDirty;
- private readonly Dictionary _timelines = new();
- private readonly Dictionary _activeRunIds = new(); // sessionKey → runId
- private readonly Dictionary _activeRunStartSequences = new(); // sessionKey → lifecycle.start sequence
- private readonly Dictionary _pendingAbortCounts = new(); // threads → count of pending aborts waiting for lifecycle.start
- private readonly HashSet _abortedRunIds = new(); // runIds whose events should be suppressed
- private readonly HashSet _abortedThreads = new(); // threads with active abort — suppress chat messages (no runId on those)
- private Dictionary> _persistedAbortedIds; // threadId → set of __openclaw.id values (loaded from disk)
- private readonly SemaphoreSlim _persistLock = new(1, 1); // serialize persist calls to avoid races
+ private readonly Func, Task> _deferredAbortScheduler;
/// Whether any thread is in an aborted state (suppress TTS/notifications).
- public bool IsResponseSuppressed { get { lock (_gate) return _abortedThreads.Count > 0; } }
-
- private readonly Dictionary _sessionIds = new(); // sessionKey → immutable sessionId
- private readonly HashSet _historyLoaded = new(); // sessionKey
- private readonly HashSet _historyInFlight = new(); // sessionKey
- private readonly HashSet _authoritativeHistoryReloadPending = new(); // sessionKey
- private readonly HashSet _replacementHistoryReloadPending = new(); // sessionKey
- private readonly Dictionary _historyReplacementVersions = new(); // sessionKey -> replacement generation
- private CancellationTokenSource _historyGenerationCancellation = new();
- private long _historyConnectionVersion;
- private readonly Dictionary _pendingModelPatches = new(); // sessionKey -> in-flight model set/clear
- private readonly Dictionary _resetVersions = new(); // sessionKey -> reset generation
- private readonly Dictionary _historyRevisions = new(); // sessionKey -> completed history rebuild revision
- private readonly Dictionary _resetCutoffUtcMs = new(); // sessionKey -> local reset time
- private readonly HashSet _resetAwaitingUserMessage = new(); // threads reset and waiting for first post-reset turn
- private readonly Dictionary> _resetIgnoredRunIds = new(); // sessionKey -> pre-reset run IDs to drop
- private readonly Dictionary>> _resetSubmittedLocalEchoTexts = new(); // sessionKey -> pre-reset local user echoes that reached the gateway
- private readonly Dictionary> _resetAcceptedRunIds = new(); // sessionKey -> post-reset run IDs allowed to open the gate
- private readonly Dictionary _resetLocalSendWithoutRunVersions = new(); // sessionKey -> reset generation for no-runId sends
- private readonly Dictionary _resetLocalSendWithoutRunStartSequences = new(); // sessionKey -> lifecycle sequence at local send start
- private readonly Dictionary _resetLocalEchoSequences = new(); // sessionKey -> lifecycle sequence when local echo was observed
- private readonly Dictionary> _resetPendingLifecycleStarts = new(); // sessionKey -> lifecycle.start seen before proof
- private readonly HashSet _resetRemoteBackfillInFlight = new(); // threads proving a timestamp-less remote user frame via history
- private long _resetLifecycleStartSequence;
- private long _lifecycleStartSequence;
- private readonly HashSet _resetRemoteUserSeen = new(); // threads with a fresh remote post-reset user frame
- private readonly Dictionary _resetClearedSessionIds = new(); // sessionKey -> sessionId cleared by reset
- // Per-session cache of tool metadata from live SSE events.
- // Keyed by gateway sessionId (immutable UUID). Persisted to disk
- // so that history reconstruction on restart can recover tool names.
- private Dictionary> _toolMetaCache;
- private Dictionary> _attachmentMetaCache;
- // Track recently-sent local user message texts so we can suppress
- // SSE echoes while still displaying messages from other clients.
- private readonly Dictionary> _localSentTexts = new();
- private readonly Dictionary> _queuedMessages = new();
- private readonly Dictionary> _queuedSendRequests = new();
- private readonly Dictionary> _queuedMessageIdsByRunId = new();
- private readonly Dictionary> _terminalRunIdsByThread = new();
- private readonly HashSet _queuedDrainScheduledThreads = new(StringComparer.Ordinal);
- private readonly HashSet _assistantFallbackPromotedThreads = new(StringComparer.Ordinal);
- private long _queuedMessageSequence;
- private int _keylessEventDiagnosticRaised;
- // Threads where we locally initiated the current turn (via SendMessageAsync).
- // When lifecycle.start arrives for a thread NOT in this set, we know a remote
- // client started the turn and should fetch the user message from history.
- private readonly HashSet _locallyInitiatedThreads = new();
- // Per-thread retry count for LoadHistoryAsync to prevent unbounded retry loops.
- private readonly Dictionary _historyRetryCount = new();
- private const int MaxHistoryRetries = 3;
- private static readonly TimeSpan HistoryRetryDelay = TimeSpan.FromSeconds(2);
- private const int MaxDeferredAdmissionRetries = 8;
- private static readonly TimeSpan LocalEchoSuppressionWindow = TimeSpan.FromSeconds(30);
- private static readonly TimeSpan DeferredQueueDrainDelay = TimeSpan.FromMilliseconds(100);
- private static readonly TimeSpan MaxDeferredAdmissionRetryDelay = TimeSpan.FromSeconds(1);
- private readonly record struct LocalSentText(string Text, DateTimeOffset SentAt, string QueuedMessageId);
- private sealed record QueuedSendRequest(
- string Id,
- string SendRunId,
- string ThreadId,
- string Text,
- string DisplayText,
- string LocalNonce,
- IReadOnlyList? Attachments,
- int DeferredAdmissionRetryCount = 0,
- DateTimeOffset? DeferredAdmissionRetryAfter = null,
- ChatLifecycleCommandKind? LifecycleCommand = null);
- private sealed record QueuedSendDispatch(
- QueuedSendRequest Request,
- string? SessionId,
- long ResetVersion,
- long StartedLifecycleSequence,
- long StartedRunStartSequence,
- ChatTelemetryTracker.QueuePhaseCompletion? QueueCompletion,
- bool StartedDirectly);
- private enum AssistantQueueFrameDisposition
- {
- Render,
- Drop,
- }
- // Per-thread, per-entry metadata: timestamp + model snapshot at the
- // moment the entry was created. Built up as events are applied so the
- // timeline renderer can show a " · · " footer
- // beneath each message without having to extend the vendored
- // record.
- private readonly Dictionary> _entryMeta = new();
- private SessionInfo[] _sessions = Array.Empty();
- // True once the gateway has delivered a sessions list (even an empty
- // one) for the current connection. Used to gate the synthetic
- // compose-only thread so the UI doesn't briefly render the welcome
- // zero-state in the window between hello-ok (HasHandshakeSnapshot)
- // and the first sessions.list — at that point the gateway may still
- // be about to deliver real sessions for a returning user. Reset to
- // false on disconnect alongside `_status`.
- private bool _sessionsListReceived;
- private string[] _availableModels = Array.Empty();
- private IReadOnlyList _modelChoices = Array.Empty();
- // Gateway command catalog (commands.list), fetched on demand via the typed
- // protocol API. Null until the first fetch completes so the UI can
- // distinguish "still loading" from "loaded but empty". When the gateway
- // reports the method unsupported the catalog carries IsSupported=false.
- private CommandCatalog? _commandCatalog;
- // Guards against overlapping in-flight commands.list fetches.
- private bool _commandsFetchInFlight;
- // Bumped on every transition out of Connected so a commands.list fetch that
- // was already in flight at disconnect time is discarded on completion rather
- // than resurrecting a catalog for a stale connection.
- private int _commandsEpoch;
- private ConnectionStatus _status;
- private bool _disposed;
+ public bool IsResponseSuppressed => _state.IsResponseSuppressed;
public string DisplayName => "OpenClaw gateway";
/// Last-known chat state from a previous session, used for pre-connection UI.
- internal LastChatState? CachedLastChatState => _lastChatState;
+ internal LastChatState? CachedLastChatState => _state.CachedLastChatState;
public event EventHandler? Changed;
public event EventHandler? NotificationRequested;
@@ -241,7 +111,7 @@ private enum AssistantQueueFrameDisposition
/// the source event on (acceptable in unit tests).
///
public OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post = null)
- : this(bridge, post, DefaultToolMetaCacheFilePath)
+ : this(bridge, post, ChatMetadataStore.DefaultToolMetaCacheFilePath)
{
}
@@ -253,47 +123,32 @@ internal OpenClawChatDataProvider(
string? lastChatStateFilePath = null,
TimeSpan? lastChatStateSaveDelay = null,
Func, Task>? historyRetryScheduler = null,
- Action? historyFailureReservedForTesting = null)
+ Action? historyFailureReservedForTesting = null,
+ Func, Task>? deferredAbortScheduler = null)
{
_bridge = bridge ?? throw new ArgumentNullException(nameof(bridge));
_post = post;
- _toolMetaCacheFilePath = !string.IsNullOrWhiteSpace(toolMetaCacheFilePath)
- ? toolMetaCacheFilePath
- : throw new ArgumentException("Tool metadata cache path is required.", nameof(toolMetaCacheFilePath));
- _attachmentMetaCacheFilePath = !string.IsNullOrWhiteSpace(attachmentMetaCacheFilePath)
- ? attachmentMetaCacheFilePath
- : DefaultAttachmentMetaCacheFilePath(_toolMetaCacheFilePath);
- _lastChatStateFilePath = !string.IsNullOrWhiteSpace(lastChatStateFilePath)
- ? lastChatStateFilePath
- : LastChatStateFilePath;
- _lastChatStateSaveDelay = lastChatStateSaveDelay ?? TimeSpan.FromSeconds(2);
- _scheduleHistoryRetry = historyRetryScheduler ?? (static async (delay, cancellationToken, retry) =>
- {
- await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
- await retry().ConfigureAwait(false);
- });
- _historyFailureReservedForTesting = historyFailureReservedForTesting;
- _status = bridge.CurrentStatus;
- _persistedAbortedIds = LoadAbortedIds();
- _toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath);
- _attachmentMetaCache = LoadAttachmentMetaCache(_attachmentMetaCacheFilePath);
- _lastChatState = LoadLastChatState(_lastChatStateFilePath);
-
- // Seed models from whatever the bridge already knows about (a connect
- // that completed before the provider was constructed will have its
- // models.list snapshot cached on the bridge).
- if (bridge.GetCurrentModelsList() is { } seedModels)
- {
- _modelChoices = ChatModelChoice.FromModelsList(seedModels);
- _availableModels = ModelIdsFromChoices(_modelChoices);
- }
- // Fall back to last-known models so the composer shows a real model
- // name while reconnecting instead of the generic "model" placeholder.
- else if (_lastChatState?.AvailableModels is { Length: > 0 } cached)
- {
- _availableModels = cached;
- _modelChoices = ChoicesFromIds(cached);
- }
+ _deferredAbortScheduler =
+ deferredAbortScheduler ?? (work => Task.Run(work));
+ _metadataStore = new ChatMetadataStore(
+ toolMetaCacheFilePath,
+ attachmentMetaCacheFilePath);
+ _persistence = new ChatStatePersistence(
+ lastChatStateFilePath,
+ lastChatStateSaveDelay);
+ _state = new ChatConversationState(
+ bridge.CurrentStatus,
+ _persistence.InitialLastChatState,
+ bridge.GetCurrentModelsList());
+ _historyLoader = new ChatHistoryLoader(
+ bridge,
+ _state,
+ _metadataStore,
+ _persistence,
+ _telemetry,
+ historyRetryScheduler,
+ historyFailureReservedForTesting);
+ _historyLoader.Completed += OnHistoryLoadCompleted;
_bridge.StatusChanged += OnStatusChanged;
_bridge.SessionsUpdated += OnSessionsUpdated;
@@ -316,15 +171,8 @@ internal OpenClawChatDataProvider(
public Task LoadAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- // Seed from whatever the bridge already knows about.
var sessions = _bridge.GetSessionList() ?? Array.Empty();
- lock (_gate)
- {
- _sessions = sessions;
- EnsureTimelinesForSessionsLocked();
- RememberLastSessionStateLocked();
- return Task.FromResult(BuildSnapshotLocked());
- }
+ return Task.FromResult(_state.Load(sessions, ProjectionContext()));
}
internal void RememberSelectedThread(string? threadId)
@@ -332,27 +180,8 @@ internal void RememberSelectedThread(string? threadId)
if (string.IsNullOrWhiteSpace(threadId))
return;
- LastChatState? state;
- lock (_gate)
- {
- if (!TryGetSessionLocked(threadId, out var session))
- return;
-
- state = new LastChatState
- {
- DefaultThreadId = threadId,
- ThreadTitle = SessionTitleFormatter.Format(session, _sessions),
- Model = session.Model,
- ModelProvider = session.Provider,
- AvailableModels = _availableModels,
- };
- _lastChatState = state;
- _lastChatStateSaveVersion++;
- _lastChatStateSaveTimer?.Dispose();
- _lastChatStateSaveTimer = null;
- }
-
- SaveLastChatState(state, _lastChatStateFilePath);
+ if (_state.RememberSelectedThread(threadId) is { } state)
+ _persistence.SaveSelectedState(state);
}
// Explicit interface implementation (no attachments).
@@ -390,66 +219,53 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation
// blank even if the typed message was empty. Uses a unique prefix
// ("\u200B📎 " / "\u200B🖼️ ") with a zero-width space to prevent
// false positives from normal user text.
- var safeUserText = EscapeUntrustedAttachmentMarkerLines(trimmed);
+ var safeUserText = ChatMetadataStore.EscapeUntrustedAttachmentMarkerLines(trimmed);
var displayText = safeUserText;
if (hasAttachments)
{
- var chips = BuildAttachmentMarkerLines(attachments!);
+ var chips = ChatMetadataStore.BuildAttachmentMarkerLines(attachments!);
displayText = string.IsNullOrEmpty(safeUserText)
? chips
: $"{safeUserText}\n{chips}";
}
- // 1. Render immediately when this thread is idle. Follow-up messages
- // enter the visible queue and stay client-only until the turn ends.
- ChatDataSnapshot snapshot;
- string messageId;
- QueuedSendDispatch? dispatch;
- lock (_gate)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
- messageId = $"q{++_queuedMessageSequence}";
- if (CanClearAssistantFallbackPromotionLocked(threadId))
- _assistantFallbackPromotedThreads.Remove(threadId);
-
- // Clear abort suppression — the user is starting a new interaction.
- // Also clear pending abort counts: if the user sends a new message,
- // any queued aborts from before should not fire against the new turn.
- _abortedThreads.Remove(threadId);
- _pendingAbortCounts.Remove(threadId);
-
- var request = new QueuedSendRequest(
- messageId,
- Guid.NewGuid().ToString(),
+ var admission = _state.AdmitMessage(
+ threadId,
+ trimmed,
+ displayText,
+ nonce,
+ attachments,
+ DateTimeOffset.UtcNow,
+ ProjectionContext());
+ _telemetry.StartLocalTurn(
+ admission.MessageId,
+ threadId,
+ queued: admission.Queued,
+ admission.RuntimeGeneration);
+ if (!_state.IsRuntimeGenerationCurrent(
threadId,
- trimmed,
- displayText,
- nonce,
- attachments?.ToArray());
-
- var sendDirectly = CanSendDirectlyLocked(threadId);
- _telemetry.StartLocalTurn(request.Id, threadId, queued: !sendDirectly);
- if (sendDirectly)
- {
- dispatch = StartDirectSendLocked(request);
- }
- else
- {
- AddQueuedMessageLocked(threadId, new ChatQueuedMessage(
- messageId,
- displayText,
- DateTimeOffset.UtcNow,
- nonce));
- AddQueuedSendRequestLocked(request);
- dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: false, out _);
- }
-
- snapshot = BuildSnapshotLocked();
+ admission.RuntimeGeneration))
+ {
+ _telemetry.FinishByMessageId(
+ admission.MessageId,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.Superseded);
}
- Publish(snapshot);
+ var dispatch = admission.Dispatch;
+ var queueCompletion = dispatch is not null &&
+ dispatch.Request.LifecycleCommand is null
+ ? _telemetry.PrepareDispatchLocalTurn(
+ dispatch.Request.Id,
+ dispatch.Request.SendRunId)
+ : null;
+ Publish(admission.Snapshot);
if (dispatch is not null)
- await DispatchQueuedSendAsync(dispatch, rethrow: true, cancellationToken);
+ await DispatchQueuedSendAsync(
+ dispatch,
+ queueCompletion,
+ rethrow: true,
+ cancellationToken);
}
internal Task EnqueueCompactCommandAsync(
@@ -460,30 +276,10 @@ internal Task EnqueueCompactCommandAsync(
if (string.IsNullOrWhiteSpace(threadId))
throw new ArgumentException("Thread id is required.", nameof(threadId));
- ChatDataSnapshot snapshot;
- lock (_gate)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
- var messageId = $"q{++_queuedMessageSequence}";
- var request = new QueuedSendRequest(
- messageId,
- Guid.NewGuid().ToString(),
- threadId,
- "/compact",
- "/compact",
- Guid.NewGuid().ToString(),
- Attachments: null,
- LifecycleCommand: ChatLifecycleCommandKind.Compact);
-
- AddQueuedMessageLocked(threadId, new ChatQueuedMessage(
- messageId,
- request.DisplayText,
- DateTimeOffset.UtcNow,
- request.LocalNonce));
- AddQueuedSendRequestLocked(request);
- snapshot = BuildSnapshotLocked();
- }
-
+ var snapshot = _state.EnqueueCompact(
+ threadId,
+ DateTimeOffset.UtcNow,
+ ProjectionContext());
Publish(snapshot);
TryDispatchNextQueuedSend(threadId);
return Task.FromResult(true);
@@ -564,23 +360,16 @@ public Task CancelQueuedMessageAsync(string threadId, string queuedMessage
if (string.IsNullOrEmpty(queuedMessageId))
throw new ArgumentException("Queued message id is required.", nameof(queuedMessageId));
- ChatDataSnapshot? snapshot = null;
- ChatTelemetryTracker.PreparedTurnCompletion? telemetryCompletion = null;
- var canceled = false;
- lock (_gate)
- {
- ObjectDisposedException.ThrowIf(_disposed, this);
- canceled = CancelQueuedMessageLocked(threadId, queuedMessageId);
- if (canceled)
- {
- telemetryCompletion = _telemetry.PrepareFinishByMessageId(
- queuedMessageId,
- ChatTelemetryOutcome.Canceled,
- ChatTurnTelemetryReason.QueuedCanceled);
- snapshot = BuildSnapshotLocked();
- }
- }
-
+ var (canceled, snapshot) = _state.CancelQueuedMessage(
+ threadId,
+ queuedMessageId,
+ ProjectionContext());
+ var telemetryCompletion = canceled
+ ? _telemetry.PrepareFinishByMessageId(
+ queuedMessageId,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.QueuedCanceled)
+ : null;
_telemetry.CompletePreparedTurn(telemetryCompletion);
if (snapshot is not null)
Publish(snapshot);
@@ -589,7 +378,8 @@ public Task CancelQueuedMessageAsync(string threadId, string queuedMessage
}
private async Task DispatchQueuedSendAsync(
- QueuedSendDispatch dispatch,
+ ChatQueuedSendDispatch dispatch,
+ ChatTelemetryTracker.QueuePhaseCompletion? queueCompletion,
bool rethrow,
CancellationToken cancellationToken = default)
{
@@ -603,7 +393,7 @@ await DispatchQueuedLifecycleCommandAsync(
return;
}
- _telemetry.CompleteQueueDispatch(dispatch.QueueCompletion);
+ _telemetry.CompleteQueueDispatch(queueCompletion);
var threadId = request.ThreadId;
var hasAttachments = request.Attachments is { Count: > 0 };
ChatTelemetryOperation? sendOperation = null;
@@ -611,12 +401,17 @@ await DispatchQueuedLifecycleCommandAsync(
try
{
await AwaitPendingModelPatchAsync(threadId, cancellationToken);
- lock (_gate)
+ var preparation = _state.PrepareSendAttempt(
+ dispatch,
+ ProjectionContext());
+ if (!preparation.IsCurrent)
{
- if (_disposed)
- return;
- if (GetResetVersionLocked(threadId) == dispatch.ResetVersion)
- TrackQueuedMessageRunLocked(threadId, request.SendRunId, request.Id);
+ if (preparation.Snapshot is not null)
+ {
+ Publish(preparation.Snapshot);
+ TryDispatchNextQueuedSend(threadId);
+ }
+ return;
}
sendOperation = _telemetry.StartSendAttempt(request.Id);
var sendResult = await _bridge.SendChatMessageForRunAsync(
@@ -625,7 +420,8 @@ await DispatchQueuedLifecycleCommandAsync(
dispatch.SessionId,
request.Attachments,
idempotencyKey: request.SendRunId);
- var admissionStatus = MapAdmissionTelemetryStatus(sendResult);
+ var admissionStatus = ToTelemetryAdmissionStatus(
+ ChatSendQueuePolicy.ClassifyAdmission(sendResult));
var admissionOutcome = admissionStatus == ChatAdmissionTelemetryStatus.Canceled
? ChatTelemetryOutcome.Canceled
: sendResult.IsTerminalFailure
@@ -639,14 +435,10 @@ await DispatchQueuedLifecycleCommandAsync(
_telemetry.ObserveAdmissionAccepted(request.Id);
if (sendResult.IsTerminalFailure)
{
- ChatTelemetryTracker.PreparedTurnCompletion? rejectedCompletion;
- lock (_gate)
- {
- rejectedCompletion = _telemetry.PrepareFinishByMessageId(
- request.Id,
- admissionOutcome,
- ChatTurnTelemetryReason.SendRejected);
- }
+ var rejectedCompletion = _telemetry.PrepareFinishByMessageId(
+ request.Id,
+ admissionOutcome,
+ ChatTurnTelemetryReason.SendRejected);
_telemetry.CompletePreparedTurn(rejectedCompletion);
var failure = !string.IsNullOrWhiteSpace(sendResult.Error)
? sendResult.Error!
@@ -657,131 +449,63 @@ await DispatchQueuedLifecycleCommandAsync(
throw new InvalidOperationException(failure);
}
- bool sendStillCurrent;
- string? staleRunIdToAbort = null;
- ChatTelemetryTracker.PreparedTurnCompletion? staleCompletion = null;
- ChatDataSnapshot? acceptedSnapshot = null;
- ChatDataSnapshot? requeuedSnapshot = null;
- var retryDeferredSend = false;
- var deferredRetryDelay = DeferredQueueDrainDelay;
var acceptedRunId = string.IsNullOrWhiteSpace(sendResult.RunId)
? null
: sendResult.RunId!;
- lock (_gate)
+ var commit = _state.CommitSendResult(
+ dispatch,
+ sendResult,
+ ProjectionContext());
+ if (commit.BindAcceptedRun && acceptedRunId is not null)
+ _telemetry.BindAcceptedRun(request.Id, acceptedRunId);
+ if (commit.RequeueRequired)
+ _telemetry.RequeueLocalTurn(request.Id);
+ HandleOpenedLifecycle(
+ threadId,
+ commit.OpenedLifecycle,
+ commit.RuntimeGeneration);
+ ChatTelemetryTracker.PreparedTurnCompletion? staleCompletion = null;
+ if (!commit.IsCurrent)
{
- sendStillCurrent = GetResetVersionLocked(threadId) == dispatch.ResetVersion;
- if (!sendStillCurrent)
- {
- staleRunIdToAbort = acceptedRunId ?? request.SendRunId;
- staleCompletion = _telemetry.PrepareFinishByMessageId(
- request.Id,
- ChatTelemetryOutcome.Canceled,
- ChatTurnTelemetryReason.Superseded);
- AddResetIgnoredRunIdLocked(threadId, staleRunIdToAbort);
- }
- else if (IsDeferredAdmissionStatus(sendResult.Status))
- {
- var runAlreadyStarted = !string.IsNullOrEmpty(acceptedRunId)
- && _activeRunIds.TryGetValue(threadId, out var activeRunId)
- && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence)
- && string.Equals(activeRunId, acceptedRunId, StringComparison.Ordinal)
- && activeStartSequence > dispatch.StartedRunStartSequence;
- if (runAlreadyStarted)
- {
- _telemetry.BindAcceptedRun(request.Id, acceptedRunId);
- TrackQueuedMessageRunLocked(threadId, acceptedRunId!, request.Id);
- AddResetAcceptedRunIdLocked(threadId, acceptedRunId!);
- if (PromoteQueuedMessageLocked(threadId, request.Id))
- {
- acceptedSnapshot = BuildSnapshotLocked();
- }
- else
- {
- RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id);
- }
- }
- else if (RequeueDeferredAdmissionLocked(threadId, request.Id, out deferredRetryDelay))
- {
- _telemetry.RequeueLocalTurn(request.Id);
- if (!string.IsNullOrEmpty(acceptedRunId))
- {
- TrackQueuedMessageRunLocked(threadId, acceptedRunId, request.Id);
- AddResetAcceptedRunIdLocked(threadId, acceptedRunId);
- }
- requeuedSnapshot = BuildSnapshotLocked();
- retryDeferredSend = true;
- }
- else if (dispatch.StartedDirectly)
- {
- throw new InvalidOperationException(
- $"Gateway returned chat.send status {sendResult.Status} before admitting the direct send.");
- }
- }
- else if (!string.IsNullOrEmpty(acceptedRunId))
- {
- _telemetry.BindAcceptedRun(request.Id, acceptedRunId);
- TrackQueuedMessageRunLocked(threadId, acceptedRunId, request.Id);
- AddResetAcceptedRunIdLocked(threadId, acceptedRunId);
- var runAlreadyStarted = _activeRunIds.TryGetValue(threadId, out var activeRunId)
- && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence)
- && string.Equals(activeRunId, acceptedRunId, StringComparison.Ordinal)
- && activeStartSequence > dispatch.StartedRunStartSequence;
- if (PromoteQueuedMessageLocked(threadId, request.Id))
- {
- acceptedSnapshot = BuildSnapshotLocked();
- }
- else if (runAlreadyStarted)
- {
- RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id);
- }
- }
- else if (_resetAwaitingUserMessage.Contains(threadId))
- {
- RemoveQueuedRunMappingByRunIdLocked(threadId, request.SendRunId);
- _resetLocalSendWithoutRunVersions[threadId] = dispatch.ResetVersion;
- _resetLocalSendWithoutRunStartSequences[threadId] = dispatch.StartedLifecycleSequence;
- TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null);
- if (PromoteQueuedMessageLocked(threadId, request.Id))
- {
- acceptedSnapshot = BuildSnapshotLocked();
- }
- }
- else if (PromoteQueuedMessageLocked(threadId, request.Id))
- {
- RemoveQueuedRunMappingByRunIdLocked(threadId, request.SendRunId);
- acceptedSnapshot = BuildSnapshotLocked();
- }
+ staleCompletion = _telemetry.PrepareFinishByMessageId(
+ request.Id,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.Superseded);
}
- if (acceptedSnapshot is not null)
- {
- Publish(acceptedSnapshot);
- }
- if (requeuedSnapshot is not null)
- {
- Publish(requeuedSnapshot);
- }
- if (retryDeferredSend)
- {
- ScheduleQueuedSendDrain(threadId, deferredRetryDelay);
- }
+ if (commit.AcceptedSnapshot is not null)
+ Publish(commit.AcceptedSnapshot);
+ if (commit.RequeuedSnapshot is not null)
+ Publish(commit.RequeuedSnapshot);
+ if (commit.RetryDeferredSend)
+ ScheduleQueuedSendDrain(threadId, commit.DeferredRetryDelay);
- if (staleRunIdToAbort is not null)
+ if (commit.StaleRunIdToAbort is not null)
{
_telemetry.CompletePreparedTurn(staleCompletion);
try
{
- Logger.Info($"[Reset] Aborting late pre-reset send runId='{staleRunIdToAbort}' threadId='{threadId}'");
- await _bridge.SendChatAbortAsync(staleRunIdToAbort, threadId);
+ Logger.Info($"[Reset] Aborting late pre-reset send runId='{commit.StaleRunIdToAbort}' threadId='{threadId}'");
+ await _bridge.SendChatAbortAsync(commit.StaleRunIdToAbort, threadId);
}
catch (Exception abortEx)
{
- Logger.Warn($"[Reset] Failed to abort late pre-reset send runId='{staleRunIdToAbort}': {abortEx.Message}");
+ Logger.Warn($"[Reset] Failed to abort late pre-reset send runId='{commit.StaleRunIdToAbort}': {abortEx.Message}");
}
}
+ if (!commit.IsCurrent && commit.AcceptedSnapshot is not null)
+ TryDispatchNextQueuedSend(threadId);
- if (hasAttachments && sendStillCurrent)
- CacheAttachmentMeta(dispatch.SessionId, threadId, request.Text, request.Attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), dispatch.ResetVersion);
+ if (hasAttachments && commit.IsCurrent)
+ {
+ _metadataStore.CacheAttachments(
+ threadId,
+ dispatch.SessionId,
+ dispatch.ResetVersion,
+ request.Text,
+ request.Attachments!,
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ }
}
catch (Exception ex)
{
@@ -792,45 +516,35 @@ ex is OperationCanceledException
? ChatTelemetryOutcome.Canceled
: ChatTelemetryOutcome.Failure,
ex);
- bool sendStillCurrent;
- ChatTelemetryTracker.PreparedTurnCompletion? rejectedCompletion = null;
- ChatDataSnapshot? failureSnapshot = null;
- lock (_gate)
+ var failure = _state.FailSend(
+ dispatch,
+ ex.Message,
+ string.Format(
+ CultureInfo.CurrentCulture,
+ LocalizationHelper.GetString("Chat_Error_SendFailedFormat"),
+ ex.Message),
+ ProjectionContext());
+ if (!failure.IsCurrent)
{
- sendStillCurrent = GetResetVersionLocked(threadId) == dispatch.ResetVersion;
- if (sendStillCurrent)
+ if (failure.Snapshot is not null)
{
- rejectedCompletion = _telemetry.PrepareFinishByMessageId(
- request.Id,
- ex is OperationCanceledException
- ? ChatTelemetryOutcome.Canceled
- : ChatTelemetryOutcome.Failure,
- ChatTurnTelemetryReason.SendRejected);
- RemovePendingLocalEchoLocked(threadId, request.Id);
- MarkQueuedMessageFailedLocked(threadId, request.Id, ex.Message);
- RemoveQueuedSendRequestLocked(threadId, request.Id);
- RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id);
- if (!HasSendingQueuedMessagesLocked(threadId))
- _locallyInitiatedThreads.Remove(threadId);
- failureSnapshot = ApplyEventLocked(
- threadId,
- TruncateChatEvent(new ChatErrorEvent(string.Format(
- CultureInfo.CurrentCulture,
- LocalizationHelper.GetString("Chat_Error_SendFailedFormat"),
- ex.Message))),
- meta: null);
- failureSnapshot = ApplyEventLocked(threadId, new ChatTurnEndEvent(), meta: null);
+ Publish(failure.Snapshot);
+ TryDispatchNextQueuedSend(threadId);
}
- }
-
- if (!sendStillCurrent)
return;
+ }
+ var rejectedCompletion = _telemetry.PrepareFinishByMessageId(
+ request.Id,
+ ex is OperationCanceledException
+ ? ChatTelemetryOutcome.Canceled
+ : ChatTelemetryOutcome.Failure,
+ ChatTurnTelemetryReason.SendRejected);
_telemetry.CompletePreparedTurn(rejectedCompletion);
Logger.Warn($"[Queue] chat.send failed threadId='{threadId}' queuedMessageId='{request.Id}' sendRunId='{request.SendRunId}': {ex.Message}");
// Surface as an error in the timeline + notification, while the
// failed queue card keeps the attempted text visible for retry/edit.
- Publish(failureSnapshot!);
+ Publish(failure.Snapshot!);
RaiseNotification(new ChatProviderNotification(
ChatProviderNotificationKind.Error, threadId, LocalizationHelper.GetString("Chat_Notification_SendFailed"), ex.Message));
TryDispatchNextQueuedSend(threadId);
@@ -840,12 +554,15 @@ ex is OperationCanceledException
}
private async Task DispatchQueuedLifecycleCommandAsync(
- QueuedSendDispatch dispatch,
+ ChatQueuedSendDispatch dispatch,
ChatLifecycleCommandKind command,
CancellationToken cancellationToken)
{
var request = dispatch.Request;
var threadId = request.ThreadId;
+ if (!_state.IsQueuedDispatchCurrent(dispatch))
+ return;
+
ChatLifecycleCommandResult result;
try
{
@@ -868,73 +585,42 @@ private async Task DispatchQueuedLifecycleCommandAsync(
Error: ex.Message);
}
- ChatDataSnapshot? snapshot = null;
- var reloadHistory = false;
- lock (_gate)
- {
- if (_disposed)
- return;
- if (GetResetVersionLocked(threadId) != dispatch.ResetVersion ||
- FindQueuedSendRequestLocked(threadId, request.Id) is null)
- {
- return;
- }
-
- if (result.Succeeded)
- {
- if (RemoveQueuedMessageLocked(threadId, request.Id))
- snapshot = BuildSnapshotLocked();
- reloadHistory = command == ChatLifecycleCommandKind.Compact;
- }
- else
- {
- ApplyEventLocked(
- threadId,
- new ChatErrorEvent(result.Error ?? "The lifecycle command failed."),
- meta: null);
- MarkQueuedMessageFailedLocked(
- threadId,
- request.Id,
- result.Error ?? "The queued lifecycle command failed.");
- RemoveQueuedSendRequestLocked(threadId, request.Id);
- snapshot = BuildSnapshotLocked();
- }
- }
+ var completion = _state.CompleteQueuedLifecycle(
+ dispatch,
+ result.Succeeded,
+ result.Error,
+ ProjectionContext());
+ if (!completion.Succeeded)
+ return;
- if (snapshot is not null)
- Publish(snapshot);
- if (reloadHistory)
+ if (completion.Snapshot is not null)
+ Publish(completion.Snapshot);
+ if (result.Succeeded && command == ChatLifecycleCommandKind.Compact)
_ = LoadHistoryAsync(threadId, force: true, authoritative: true);
TryDispatchNextQueuedSend(threadId);
}
+ private static ChatAdmissionTelemetryStatus ToTelemetryAdmissionStatus(
+ ChatAdmissionOutcome outcome) => outcome switch
+ {
+ ChatAdmissionOutcome.Accepted => ChatAdmissionTelemetryStatus.Accepted,
+ ChatAdmissionOutcome.Deferred => ChatAdmissionTelemetryStatus.Deferred,
+ ChatAdmissionOutcome.Rejected => ChatAdmissionTelemetryStatus.Rejected,
+ ChatAdmissionOutcome.Canceled => ChatAdmissionTelemetryStatus.Canceled,
+ _ => ChatAdmissionTelemetryStatus.Other,
+ };
+
public async Task StopResponseAsync(string threadId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- string? runId;
- bool hadActiveTurn;
- lock (_gate)
- {
- _activeRunIds.TryGetValue(threadId, out runId);
- hadActiveTurn = _timelines.TryGetValue(threadId, out var tl) && tl.TurnActive;
-
- // Suppress all incoming messages for this thread until the next user send.
- _abortedThreads.Add(threadId);
-
- if (!string.IsNullOrEmpty(runId))
- _abortedRunIds.Add(runId);
- else
- {
- _pendingAbortCounts.TryGetValue(threadId, out var count);
- _pendingAbortCounts[threadId] = count + 1;
- }
-
- _telemetry.FinishActiveTurn(
- threadId,
- ChatTelemetryOutcome.Canceled,
- ChatTurnTelemetryReason.AbortRequested);
- }
+ var abort = _state.BeginAbort(threadId);
+ var runId = abort.RunId;
+ var hadActiveTurn = abort.HadActiveTurn;
+ _telemetry.FinishActiveTurn(
+ threadId,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.AbortRequested);
Logger.Info($"[ABORT] StopResponseAsync threadId='{threadId}' runId='{runId ?? "(null)"}' hadActiveTurn={hadActiveTurn} deferred={string.IsNullOrEmpty(runId)}");
@@ -948,16 +634,7 @@ public async Task StopResponseAsync(string threadId, CancellationToken cancellat
}
catch (Exception ex)
{
- // Abort RPC failed — clear suppression so the thread isn't permanently blocked.
- lock (_gate)
- {
- _abortedThreads.Remove(threadId);
- _abortedRunIds.Remove(runId);
- _activeRunIds.Remove(threadId);
- _activeRunStartSequences.Remove(threadId);
- if (!HasSendingQueuedMessagesLocked(threadId))
- _locallyInitiatedThreads.Remove(threadId);
- }
+ _state.RollbackAbort(threadId, runId);
Logger.Warn($"[ABORT] chat.abort failed, cleared suppression: {ex.Message}");
RaiseNotification(new ChatProviderNotification(
ChatProviderNotificationKind.Error, threadId, LocalizationHelper.GetString("Chat_Notification_AbortFailed"), ex.Message));
@@ -982,17 +659,7 @@ public async Task StopResponseAsync(string threadId, CancellationToken cancellat
ApplyEventAndPublish(threadId, new ChatStatusEvent("Aborted", ChatTone.Warning));
}
- lock (_gate)
- {
- if (!string.IsNullOrEmpty(runId))
- {
- _activeRunIds.Remove(threadId);
- _activeRunStartSequences.Remove(threadId);
- }
- _abortedThreads.Remove(threadId);
- if (!HasSendingQueuedMessagesLocked(threadId))
- _locallyInitiatedThreads.Remove(threadId);
- }
+ _state.CompleteAbort(threadId, runId);
// Always clear local "turn active" state — the gateway will emit a
// lifecycle.end if the abort succeeds, but we want the UI to reflect
@@ -1007,955 +674,129 @@ public async Task StopResponseAsync(string threadId, CancellationToken cancellat
/// the timeline; subsequent calls are no-ops unless
/// is true. Safe to call from any thread.
///
- public Task LoadHistoryAsync(string threadId, bool force = false, CancellationToken cancellationToken = default, bool authoritative = false)
- => LoadHistoryCoreAsync(threadId, force, cancellationToken, expectedConnectionVersion: null, authoritative: authoritative);
+ public Task LoadHistoryAsync(
+ string threadId,
+ bool force = false,
+ CancellationToken cancellationToken = default,
+ bool authoritative = false) =>
+ _historyLoader.LoadAsync(
+ threadId,
+ force,
+ cancellationToken,
+ authoritative);
internal Task ReplaceHistoryAfterCheckpointRestoreAsync(
string threadId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(threadId);
+ var transition = _state.BeginHistoryReplacement(
+ threadId,
+ ProjectionContext());
+ if (transition is null)
+ return Task.CompletedTask;
- ChatDataSnapshot snapshot;
- lock (_gate)
- {
- if (_disposed)
- return Task.CompletedTask;
-
- _historyReplacementVersions[threadId] = GetHistoryReplacementVersionLocked(threadId) + 1;
- _timelines[threadId] = ChatTimelineState.Initial();
- _entryMeta.Remove(threadId);
- _historyLoaded.Remove(threadId);
- _historyRetryCount.Remove(threadId);
- _authoritativeHistoryReloadPending.Remove(threadId);
- _replacementHistoryReloadPending.Remove(threadId);
- snapshot = BuildSnapshotLocked();
- }
-
- Publish(snapshot);
- return LoadHistoryCoreAsync(
+ Publish(transition.Snapshot);
+ return _historyLoader.LoadReplacementAsync(
threadId,
- force: true,
- cancellationToken,
- expectedConnectionVersion: null,
- authoritative: false,
- replacementReload: true);
+ transition.Token,
+ cancellationToken);
}
- private async Task LoadHistoryCoreAsync(
- string threadId,
- bool force,
- CancellationToken cancellationToken,
- long? expectedConnectionVersion,
- long? expectedHistoryReplacementVersion = null,
- bool authoritative = false,
- bool replacementReload = false)
+ private void OnHistoryLoadCompleted(
+ object? sender,
+ ChatHistoryLoadResult result)
{
- cancellationToken.ThrowIfCancellationRequested();
- if (string.IsNullOrEmpty(threadId)) return;
-
- long requestResetVersion;
- long requestHistoryReplacementVersion;
- long requestConnectionVersion;
- CancellationToken generationCancellationToken;
- CancellationTokenSource requestCancellation;
- lock (_gate)
+ void Deliver()
{
- if (_disposed) return;
- if (expectedConnectionVersion is { } expected &&
- (_historyConnectionVersion != expected || _status != ConnectionStatus.Connected))
- {
+ var snapshot = _state.SnapshotIfHistoryTokenCurrent(
+ result.Token,
+ ProjectionContext());
+ if (snapshot is null)
return;
- }
- if (expectedHistoryReplacementVersion is { } expectedReplacement &&
- GetHistoryReplacementVersionLocked(threadId) != expectedReplacement)
+ if (result.PublishSnapshot)
{
- return;
+ Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot));
+ if (snapshot.Threads.Length > 0 ||
+ snapshot.AvailableModels.Length > 0)
+ {
+ _persistence.DebounceSnapshot(snapshot);
+ }
}
- if (!force && _historyLoaded.Contains(threadId)) return;
- if (!_historyInFlight.Add(threadId))
+ if (result.Notification is not null)
{
- if (replacementReload)
- _replacementHistoryReloadPending.Add(threadId);
- else if (authoritative)
- _authoritativeHistoryReloadPending.Add(threadId);
- return;
+ NotificationRequested?.Invoke(
+ this,
+ new ChatProviderNotificationEventArgs(result.Notification));
}
- requestResetVersion = GetResetVersionLocked(threadId);
- requestHistoryReplacementVersion = GetHistoryReplacementVersionLocked(threadId);
- requestConnectionVersion = _historyConnectionVersion;
- generationCancellationToken = _historyGenerationCancellation.Token;
- requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
- cancellationToken,
- generationCancellationToken);
}
- using var requestCancellationScope = requestCancellation;
- var historyRequestStartedAt = DateTimeOffset.Now;
- var historyOperation = _telemetry.StartHistoryLoad(
- force ? ChatHistoryTelemetrySource.Forced : ChatHistoryTelemetrySource.Initial);
- var historyOutcome = ChatTelemetryOutcome.Success;
- Exception? historyException = null;
- Task? historyRequest = null;
- try
- {
- historyRequest = _bridge.RequestChatHistoryAsync(threadId);
- var history = await historyRequest
- .WaitAsync(requestCancellation.Token)
- .ConfigureAwait(false);
-
- lock (_gate)
- {
- if (_historyConnectionVersion != requestConnectionVersion ||
- GetResetVersionLocked(threadId) != requestResetVersion ||
- GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion)
- {
- Logger.Info($"[ChatHistory] Ignoring stale history for thread '{threadId}'");
- historyOutcome = ChatTelemetryOutcome.Canceled;
- return;
- }
-
- if (!string.IsNullOrEmpty(history.SessionId))
- _sessionIds[threadId] = history.SessionId!;
-
- // Rebuild timeline from history; preserve any in-flight turn
- // entries that arrived between the request and the response by
- // appending them after the historical entries.
- var prior = GetOrCreateTimelineLocked(threadId);
- var rebuilt = ChatTimelineState.Initial() with { HistoryLoaded = true };
-
- // Prefer the gateway's per-session sequence over timestamps.
- // Spam/queue bursts can produce persisted rows whose timestamps
- // don't reflect the actual processing order; __openclaw.seq is
- // the stable transcript order when present.
- var orderedItems = history.Messages
- .Select((m, i) => (Message: m, Index: i))
- .ToList();
- var ordered = OrderHistoryMessages(orderedItems);
-
- // Build per-entry metadata in lockstep with the reducer.
- var rebuiltMeta = new Dictionary();
- var session = Array.Find(_sessions, s => s.Key == threadId);
- var modelAtLoad = session?.Model;
-
- ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntryMetadata? meta)
- {
- var beforeIds = new HashSet(s.Entries.Count);
- for (int i = 0; i < s.Entries.Count; i++) beforeIds.Add(s.Entries[i].Id);
- var nextState = ChatTimelineReducer.Apply(s, e);
- if (meta is not null)
- {
- for (int i = 0; i < nextState.Entries.Count; i++)
- {
- var id = nextState.Entries[i].Id;
- if (!beforeIds.Contains(id) && !rebuiltMeta.ContainsKey(id))
- rebuiltMeta[id] = meta;
- }
- }
- return nextState;
- }
-
- Logger.Info($"[ChatHistory] Loading thread '{threadId}' — {ordered.Count} messages from gateway");
-
- // Load cached tool metadata for this session to restore tool names
- // that the gateway strips from history responses.
- var cachedTools = GetCachedToolMetaForSession(history.SessionId, threadId);
- if (cachedTools is not null)
- Logger.Info($"[ChatHistory] Found {cachedTools.Count} cached tool metadata entries for session");
-
- bool nextAssistantIsAborted = false;
- var attachmentMatcher = CreateAttachmentMetaMatcher(history.SessionId, threadId);
- var pendingUnkeyedToolCalls = new Queue();
- var syntheticToolCallSequence = 0;
- ChatMessageInfo? suppressedAbortedAssistant = null;
-
- foreach (var replayPart in ChatHistoryReplayProjection.Project(ordered))
- {
- var msg = replayPart.Message;
- if (suppressedAbortedAssistant is not null)
- {
- if (ReferenceEquals(suppressedAbortedAssistant, msg))
- continue;
- suppressedAbortedAssistant = null;
- }
-
- var roleLower = msg.Role?.ToLowerInvariant() ?? "";
- var rawText = replayPart.Text;
- var ts = msg.Ts > 0
- ? DateTimeOffset.FromUnixTimeMilliseconds(msg.Ts).ToLocalTime()
- : (DateTimeOffset?)null;
- var msgMeta = new ChatEntryMetadata(
- ts,
- modelAtLoad,
- msg.InputTokens,
- msg.OutputTokens,
- msg.ResponseTokens,
- msg.ContextPercent,
- GatewayMessageId: msg.OpenClawId,
- OpenClawSeq: msg.OpenClawSeq,
- OpenClawKind: msg.OpenClawKind,
- CompactionTokensBefore: msg.CompactionTokensBefore,
- CompactionTokensAfter: msg.CompactionTokensAfter);
-
- // Cap per-message text up front so heuristics, logging,
- // and the reducer all see the same bounded value
- // (chat rubber-duck MEDIUM 4).
- var text = TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(rawText));
- if (roleLower == "user")
- text = RehydrateAttachmentMarkers(attachmentMatcher, text, msg.Ts);
- var hasStructuredToolContent = replayPart.ToolContent.Count > 0;
-
- // Check if this user message was aborted (persisted __openclaw.id match)
- if (roleLower == "user")
- {
- Logger.Debug($"[ChatHistory] user msg OpenClawId='{msg.OpenClawId ?? "(null)"}' seq={msg.OpenClawSeq}");
- if (IsMessageAborted(threadId, msg.OpenClawId))
- nextAssistantIsAborted = true;
- }
-
- // Check if the gateway itself flagged this as an aborted response
- bool gatewayAborted = roleLower == "assistant" &&
- !string.IsNullOrEmpty(msg.StopReason) &&
- !string.Equals(msg.StopReason, "stop", StringComparison.OrdinalIgnoreCase) &&
- !string.Equals(msg.StopReason, "toolUse", StringComparison.OrdinalIgnoreCase) &&
- !string.Equals(msg.StopReason, "end_turn", StringComparison.OrdinalIgnoreCase);
-
- var isFirstAssistantPart = roleLower == "assistant" && replayPart.IsFirstPart;
- var shouldSuppressAssistant = isFirstAssistantPart
- && (nextAssistantIsAborted || gatewayAborted);
- if (isFirstAssistantPart)
- nextAssistantIsAborted = false;
- if (shouldSuppressAssistant)
- {
- Logger.Debug("[ChatHistory] → routed: ABORTED (response was stopped)");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatStatusEvent("Response was stopped", ChatTone.Warning),
- msgMeta);
- rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent());
- suppressedAbortedAssistant = msg;
- continue;
- }
+ if (_post is null)
+ Deliver();
+ else
+ _post(Deliver);
+ }
- if (string.IsNullOrEmpty(text) && !hasStructuredToolContent) continue;
+ public Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.CompletedTask; // Not supported by gateway — no-op.
+ }
- // Diagnostic: log shape (role + length + heuristic flags) only.
- // Never log the message text — see HIGH 4 logging audit.
- var isFlat = NativeToolProjector.LooksLikeFlattenedToolOutput(text);
- var isSys = NativeToolProjector.LooksLikeSystemControlNote(text);
- Logger.Debug($"[ChatHistory] role='{roleLower}' len={text.Length} flat={isFlat} sys={isSys}");
+ public Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.CompletedTask; // Not supported by gateway — no-op.
+ }
- if (!string.IsNullOrEmpty(text))
- {
- switch (roleLower)
- {
- case "user":
- // Approval slash commands ("/approve allow-once",
- // "/deny ") are transport, not user prose. On
- // history replay we render them as a dim audit-trail
- // status entry so the user can scroll back and see
- // that an approval decision was made on this thread
- // (whether by us or another client — origin is
- // indistinguishable on replay).
- if (LooksLikeApprovalSlashCommand(text))
- {
- Logger.Debug($"[ChatHistory] → routed: AUDIT (approval slash command, dim status)");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatStatusEvent(text, ChatTone.Dim),
- msgMeta);
- break;
- }
- // System-injected notes (the gateway sometimes wraps
- // exec result reports in ``System (untrusted): ...``
- // and sends them as role=user) — render dim instead
- // of as a giant user bubble. See the ChatHistory log.
- if (NativeToolProjector.LooksLikeSystemControlNote(text))
- {
- Logger.Debug($"[ChatHistory] → routed: SYSTEM (dim status, role=user with control prefix)");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatStatusEvent(text, ChatTone.Dim),
- msgMeta);
- break;
- }
- // ApplyUserMessage will set TurnActive=true; if the previous
- // assistant turn never received a turn-end (because the
- // gateway transcript doesn't emit one explicitly), clear
- // ActiveAssistantId here so the next assistant message
- // starts a fresh entry instead of overwriting the previous.
- rebuilt = rebuilt with { ActiveAssistantId = null, ActiveReasoningId = null };
- rebuilt = ApplyAndCaptureMeta(rebuilt, new ChatUserMessageEvent(text), msgMeta);
- break;
-
- case "assistant":
- if (ChatMessageInfo.IsSilentAssistantDirective(roleLower, text))
- {
- Logger.Debug("[ChatHistory] → routed: SILENT assistant directive");
- break;
- }
-
- // ── Heuristic recovery for history-flattened tool calls ──
- // The gateway strips ``stream:"item"`` / ``command_output``
- // detail server-side when serving ``chat.history`` —
- // raw exec output is replayed as plain assistant text.
- // Detect these telltale shapes and route them through
- // the chip pipeline so historic turns look like live ones.
- if (NativeToolProjector.LooksLikeSystemControlNote(text))
- {
- Logger.Debug($"[ChatHistory] → routed: SYSTEM (dim status)");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatStatusEvent(text, ChatTone.Dim),
- msgMeta);
- break;
- }
- if (NativeToolProjector.LooksLikeFlattenedToolOutput(text))
- {
- var cached = TryMatchCachedTool(cachedTools, msg.Ts);
- var kind = cached?.ToolName ?? NativeToolProjector.ClassifyFlattenedToolOutput(text);
- var label = cached?.Label ?? NativeToolProjector.ExtractFlattenedToolSummary(text);
- Logger.Debug($"[ChatHistory] → routed: TOOL chip kind='{kind}' cached={cached is not null}");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatToolStartEvent(
- label,
- kind,
- ToolArgs: cached?.ToolArgs,
- ToolCallId: cached?.ToolCallId,
- IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind),
- RunId: cached?.RunId),
- msgMeta);
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatToolOutputEvent(
- text,
- ToolCallId: cached?.ToolCallId,
- RunId: cached?.RunId),
- msgMeta);
- break;
- }
- Logger.Debug($"[ChatHistory] → routed: ASSISTANT bubble (no flatten/system match)");
- rebuilt = ApplyAndCaptureMeta(rebuilt, new ChatMessageEvent(RepairContentBlockSeams(text)), msgMeta);
- if (rebuilt.ActiveToolCalls.Count > 0
- || rebuilt.ActiveToolCallId is not null)
- {
- // Text can be interleaved between a tool start and its
- // result in one array-valued history message. Preserve
- // tool correlation while ensuring later text becomes a
- // separate chronological entry.
- rebuilt = rebuilt with
- {
- ActiveAssistantId = null,
- ActiveReasoningId = null,
- };
- }
- else
- {
- // End the turn so the next assistant message starts a new
- // entry rather than replacing this one (UpsertAssistant
- // upserts by ActiveAssistantId, which TurnEnd clears).
- rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent());
- }
- break;
-
- case "toolresult":
- case "tool_result":
- if (hasStructuredToolContent)
- break;
-
- // Verified empirically — gateway 2026.4.x emits
- // ``role: "toolresult"`` for shell/exec tool output
- // in chat.history (not the spec's ``"tool"``).
- // Always route to a chip pair regardless of whether
- // the heuristic fires, since the role itself confirms
- // it's tool output.
- {
- var cached = TryMatchCachedTool(cachedTools, msg.Ts);
- var kind = cached?.ToolName ?? NativeToolProjector.ClassifyFlattenedToolOutput(text);
- var label = cached?.Label ?? NativeToolProjector.ExtractFlattenedToolSummary(text);
- Logger.Debug($"[ChatHistory] → routed: TOOL chip (role=toolresult, kind='{kind}' cached={cached is not null})");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatToolStartEvent(
- label,
- kind,
- ToolArgs: cached?.ToolArgs,
- ToolCallId: cached?.ToolCallId,
- IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind),
- RunId: cached?.RunId),
- msgMeta);
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatToolOutputEvent(
- text,
- ToolCallId: cached?.ToolCallId,
- RunId: cached?.RunId),
- msgMeta);
- }
- break;
-
- case "system":
- case "tool":
- // Render system / tool transcript notes as muted Status
- // entries so they're visible but de-emphasized vs. the
- // user/assistant turn flow.
- Logger.Debug($"[ChatHistory] → routed: STATUS (role={roleLower})");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatStatusEvent(text, ChatTone.Dim),
- msgMeta);
- break;
-
- default:
- // Unknown role — fall back to assistant rendering so it's
- // at least visible. Bracket with TurnEnd to avoid
- // collapsing into adjacent assistant entries.
- Logger.Debug($"[ChatHistory] → routed: ASSISTANT (unknown role '{roleLower}', fallback)");
- rebuilt = ApplyAndCaptureMeta(rebuilt, new ChatMessageEvent(RepairContentBlockSeams(text)), msgMeta);
- rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent());
- break;
- }
- }
+ public async Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // The gateway's sessions.patch schema treats `model` as a non-empty
+ // string; a blank value here is a no-op rather than a clear. Use
+ // ClearModelAsync to revert a session to the gateway default.
+ if (string.IsNullOrWhiteSpace(model)) return;
+ await TrackModelPatchAsync(threadId, () => _bridge.PatchSessionModelAsync(threadId, model));
+ }
- foreach (var toolBlock in replayPart.ToolContent)
- {
- if (toolBlock.Kind == ChatToolContentKind.Call)
- {
- _ = TryMatchCachedTool(cachedTools, msg.Ts);
- var args = ConvertToolArgs(toolBlock.Args);
- var callId = toolBlock.CallId;
- if (string.IsNullOrWhiteSpace(callId))
- {
- callId = $"history-tool-{syntheticToolCallSequence++}";
- pendingUnkeyedToolCalls.Enqueue(callId);
- }
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatToolStartEvent(
- ToolLabel(toolBlock.ToolName, args),
- toolBlock.ToolName,
- args,
- callId),
- msgMeta);
- }
- else
- {
- var callId = toolBlock.CallId;
- if (string.IsNullOrWhiteSpace(callId))
- {
- callId = pendingUnkeyedToolCalls.Count > 0
- ? pendingUnkeyedToolCalls.Dequeue()
- : $"history-tool-{syntheticToolCallSequence++}";
- }
-
- var correlationKey = new ChatToolCorrelationKey(
- RunId: null,
- LegacyTurn: rebuilt.ToolLegacyTurn,
- ToolCallId: callId);
- if (!rebuilt.ActiveToolCalls.ContainsKey(correlationKey))
- {
- var cached = TryMatchCachedTool(cachedTools, msg.Ts);
- var toolName = cached?.ToolName ?? toolBlock.ToolName;
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatToolStartEvent(
- cached?.Label ?? toolName,
- toolName,
- ToolCallId: callId),
- msgMeta);
- }
-
- var output = NativeToolProjector.TruncateToolOutput(toolBlock.Text ?? string.Empty);
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- toolBlock.IsError
- ? new ChatToolErrorEvent(output, callId)
- : new ChatToolOutputEvent(output, callId),
- msgMeta);
- }
- }
- }
- // If the last user message was aborted but there's no subsequent
- // assistant message in history (gateway didn't record one), synthesize
- // the "Response was stopped" indicator so the user sees it.
- if (nextAssistantIsAborted)
- {
- Logger.Debug("[ChatHistory] Trailing aborted user message with no assistant response — synthesizing abort indicator");
- rebuilt = ApplyAndCaptureMeta(
- rebuilt,
- new ChatStatusEvent("Response was stopped", ChatTone.Warning),
- null);
- rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent());
- }
+ public async Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // Tri-state clear: removes the session's model override (explicit null)
+ // so it tracks the gateway/agent default again.
+ await TrackModelPatchAsync(threadId, () => _bridge.ClearSessionModelAsync(threadId));
+ }
- // Final safety: close the replayed turn and mark calls that never
- // received a result as interrupted instead of leaving them running.
- rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent());
-
- // Append any prior live entries that weren't part of history.
- // Dedup rules (HIGH 2 / rubber-duck round 2):
- // 1. ID-only dedup is a no-op here because both rebuilt and
- // prior assign sequential e{n} IDs that always collide;
- // treat collisions as coincidences and re-id them.
- // 2. Content+timestamp dedup: only when BOTH sides have a
- // non-zero timestamp AND they agree within 2 seconds.
- // 3. If either side's timestamp is missing/zero, KEEP the
- // live entry — visible duplication beats silent loss.
- if (prior.Entries.Count > 0)
+ private async Task TrackModelPatchAsync(string threadId, Func patchOperation)
+ {
+ var lease = _state.BeginModelPatch(threadId);
+ Exception? failure = null;
+ try
+ {
+ if (lease.Previous is not null)
+ {
+ try { await lease.Previous; }
+ catch (Exception ex)
{
- var priorMeta = _entryMeta.TryGetValue(threadId, out var pm)
- ? pm
- : new Dictionary();
-
- static string ContentKey(ChatTimelineItemKind kind, string text) => $"{kind}|{text}";
- static string SequenceKey(ChatTimelineItemKind kind, int sequence) => $"{kind}|{sequence}";
-
- // (kind|text) → list of unix-second timestamps for rebuilt
- // entries that have a real timestamp. Only these can match.
- var rebuiltContentTimestamps = new Dictionary>(StringComparer.Ordinal);
- var rebuiltMessageIds = new HashSet(StringComparer.Ordinal);
- var rebuiltSequenceCounts = new Dictionary(StringComparer.Ordinal);
- foreach (var entry in rebuilt.Entries)
- {
- rebuiltMeta.TryGetValue(entry.Id, out var em);
- if (!string.IsNullOrEmpty(em?.GatewayMessageId))
- rebuiltMessageIds.Add(em.GatewayMessageId);
- if (em?.OpenClawSeq is { } seq)
- IncrementCount(rebuiltSequenceCounts, SequenceKey(entry.Kind, seq));
- if (em?.Timestamp is { } rts && rts != default)
- {
- var key = ContentKey(entry.Kind, entry.Text);
- if (!rebuiltContentTimestamps.TryGetValue(key, out var list))
- rebuiltContentTimestamps[key] = list = new List();
- list.Add(rts.ToUnixTimeSeconds());
- }
- }
-
- var existingIds = new HashSet(StringComparer.Ordinal);
- var maxSuffix = 0;
- foreach (var entry in rebuilt.Entries)
- {
- existingIds.Add(entry.Id);
- if (entry.Id.Length > 1 && entry.Id[0] == 'e' &&
- int.TryParse(entry.Id.AsSpan(1), out var n) && n > maxSuffix)
- maxSuffix = n;
- }
-
- var nextId = Math.Max(rebuilt.NextId, maxSuffix + 1);
- var newEntries = rebuilt.Entries.ToBuilder();
- var skippedDup = 0;
- var reidCount = 0;
- var authoritativeMaxHistorySequence = authoritative
- ? history.Messages
- .Where(message => message.OpenClawSeq is not null)
- .Select(message => message.OpenClawSeq!.Value)
- .DefaultIfEmpty(int.MinValue)
- .Max()
- : int.MinValue;
-
- foreach (var entry in prior.Entries)
- {
- priorMeta.TryGetValue(entry.Id, out var em);
- var priorTs = em?.Timestamp;
- if (!string.IsNullOrEmpty(em?.GatewayMessageId) &&
- rebuiltMessageIds.Contains(em.GatewayMessageId))
- {
- ConsumeAnyTimestamp(rebuiltContentTimestamps, ContentKey(entry.Kind, entry.Text));
- skippedDup++;
- continue;
- }
-
- if (em?.OpenClawSeq is { } seq &&
- TryConsumeCount(rebuiltSequenceCounts, SequenceKey(entry.Kind, seq)))
- {
- ConsumeAnyTimestamp(rebuiltContentTimestamps, ContentKey(entry.Kind, entry.Text));
- skippedDup++;
- continue;
- }
-
- if (authoritative)
- {
- if (!ShouldPreserveLiveEntryDuringAuthoritativeReload(
- em,
- authoritativeMaxHistorySequence,
- historyRequestStartedAt))
- continue;
- }
-
- // Rule 2: content+timestamp dedup only when BOTH sides
- // have valid timestamps within 2 seconds. Otherwise
- // (Rule 3) fall through and keep the entry — silent
- // data loss is worse than visible duplicates.
- if (priorTs is { } pts && pts != default &&
- rebuiltContentTimestamps.TryGetValue(ContentKey(entry.Kind, entry.Text), out var rebuiltTimes))
- {
- var priorSec = pts.ToUnixTimeSeconds();
- var matched = false;
- for (var rebIndex = 0; rebIndex < rebuiltTimes.Count; rebIndex++)
- {
- var rebSec = rebuiltTimes[rebIndex];
- if (Math.Abs(rebSec - priorSec) <= 2)
- {
- rebuiltTimes.RemoveAt(rebIndex);
- matched = true;
- break;
- }
- }
- if (matched)
- {
- skippedDup++;
- continue;
- }
- }
-
- // Re-id on collision (sequential IDs always collide
- // between rebuilt and prior).
- var entryToAdd = entry;
- if (existingIds.Contains(entry.Id))
- {
- var newId = $"e{nextId++}";
- entryToAdd = entry with { Id = newId };
- reidCount++;
- }
- else if (entry.Id.Length > 1 && entry.Id[0] == 'e' &&
- int.TryParse(entry.Id.AsSpan(1), out var nn) && nn >= nextId)
- {
- // Bump nextId past this entry's suffix to avoid future collisions.
- nextId = nn + 1;
- }
-
- newEntries.Add(entryToAdd);
- existingIds.Add(entryToAdd.Id);
- if (em?.Timestamp is { } addTs && addTs != default)
- {
- var key = ContentKey(entryToAdd.Kind, entryToAdd.Text);
- if (!rebuiltContentTimestamps.TryGetValue(key, out var list))
- rebuiltContentTimestamps[key] = list = new List();
- list.Add(addTs.ToUnixTimeSeconds());
- }
- if (!string.IsNullOrEmpty(em?.GatewayMessageId))
- rebuiltMessageIds.Add(em.GatewayMessageId);
- if (em?.OpenClawSeq is { } addSeq)
- IncrementCount(rebuiltSequenceCounts, SequenceKey(entryToAdd.Kind, addSeq));
- if (em is not null && !rebuiltMeta.ContainsKey(entryToAdd.Id))
- rebuiltMeta[entryToAdd.Id] = em;
- }
-
- if (skippedDup > 0 || reidCount > 0)
- Logger.Debug($"[ChatHistory] dedup: skipped={skippedDup} reid={reidCount} prior={prior.Entries.Count}");
-
- rebuilt = rebuilt with
- {
- Entries = newEntries.ToImmutable(),
- NextId = nextId,
- TurnActive = prior.TurnActive,
- PendingToolPresentations = prior.PendingToolPresentations,
- PendingToolOutcomes = prior.PendingToolOutcomes,
- TerminalToolCorrelations = prior.TerminalToolCorrelations,
- NextToolOutcomeSequence = prior.NextToolOutcomeSequence,
- NextToolCorrelationSequence = prior.NextToolCorrelationSequence,
- ToolLegacyTurn = prior.ToolLegacyTurn
- };
- rebuilt = ChatTimelineReducer.RebuildActiveToolTracking(rebuilt);
+ Logger.Debug($"ChatDataProvider: continuing model patch after previous patch failed: {ex.Message}");
}
-
- _timelines[threadId] = rebuilt;
- _historyRevisions[threadId] = GetHistoryRevisionLocked(threadId) + 1;
- _entryMeta[threadId] = rebuiltMeta;
- _historyLoaded.Add(threadId);
- _historyRetryCount.Remove(threadId);
}
- PublishHistoryIfCurrent(requestConnectionVersion);
+ await patchOperation();
}
catch (Exception ex)
{
- if (ex is OperationCanceledException)
- {
- if (historyRequest is not null)
- _ = ObserveCanceledHistoryRequestAsync(historyRequest);
- historyOutcome = ChatTelemetryOutcome.Canceled;
- return;
- }
-
- bool shouldRetry;
- lock (_gate)
- {
- if (_disposed ||
- _historyConnectionVersion != requestConnectionVersion ||
- GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion)
- {
- historyOutcome = ChatTelemetryOutcome.Canceled;
- return;
- }
-
- historyOutcome = ChatTelemetryOutcome.Failure;
- historyException = ex;
- _historyRetryCount.TryGetValue(threadId, out var retries);
- shouldRetry = _status == ConnectionStatus.Connected
- && (replacementReload || authoritative || !_historyLoaded.Contains(threadId))
- && retries < MaxHistoryRetries;
- if (shouldRetry)
- _historyRetryCount[threadId] = retries + 1;
- }
-
- _historyFailureReservedForTesting?.Invoke();
- lock (_gate)
- {
- if (_disposed ||
- _historyConnectionVersion != requestConnectionVersion ||
- GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion)
- {
- historyOutcome = ChatTelemetryOutcome.Canceled;
- historyException = null;
- shouldRetry = false;
- return;
- }
- }
-
- RaiseHistoryNotificationIfCurrent(
- new ChatProviderNotification(
- ChatProviderNotificationKind.Error,
- threadId,
- LocalizationHelper.GetString("Chat_Notification_LoadHistoryFailed"),
- ex.Message),
- threadId,
- requestConnectionVersion,
- requestHistoryReplacementVersion);
-
- lock (_gate)
- {
- if (_disposed ||
- _historyConnectionVersion != requestConnectionVersion ||
- GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion)
- {
- historyOutcome = ChatTelemetryOutcome.Canceled;
- historyException = null;
- shouldRetry = false;
- }
- }
-
- // If still connected and under the retry limit, retry after a
- // short delay so the UI auto-recovers when the gateway becomes
- // ready to serve history.
- if (shouldRetry)
- {
- _ = ObserveHistoryRetryAsync(_scheduleHistoryRetry(
- HistoryRetryDelay,
- generationCancellationToken,
- async () =>
- {
- await LoadHistoryCoreAsync(
- threadId,
- force: true,
- CancellationToken.None,
- expectedConnectionVersion: requestConnectionVersion,
- expectedHistoryReplacementVersion: requestHistoryReplacementVersion,
- authoritative: authoritative,
- replacementReload: replacementReload);
- }));
- }
- }
- finally
- {
- bool rerunReplacement;
- bool rerunAuthoritative;
- lock (_gate)
- {
- if (_historyConnectionVersion == requestConnectionVersion)
- {
- _historyInFlight.Remove(threadId);
- rerunReplacement = _replacementHistoryReloadPending.Remove(threadId);
- rerunAuthoritative = !rerunReplacement
- && _authoritativeHistoryReloadPending.Remove(threadId);
- }
- else
- {
- // Generation advance owns clearing pending reload state.
- rerunReplacement = false;
- rerunAuthoritative = false;
- }
- }
- _telemetry.FinishHistoryLoad(historyOperation, historyOutcome, historyException);
- if (rerunReplacement)
- {
- _ = LoadHistoryCoreAsync(
- threadId,
- force: true,
- CancellationToken.None,
- expectedConnectionVersion: requestConnectionVersion,
- authoritative: false,
- replacementReload: true);
- }
- else if (rerunAuthoritative)
- _ = LoadHistoryAsync(threadId, force: true, authoritative: true);
- }
- }
-
- private static async Task ObserveHistoryRetryAsync(Task retryTask)
- {
- try
- {
- await retryTask.ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Connection-generation and provider-lifetime cancellation are expected.
- }
- catch (Exception ex)
- {
- Logger.Warn($"[ChatHistory] Retry scheduler failed: {ex.GetType().Name}");
- }
- }
-
- private static async Task ObserveCanceledHistoryRequestAsync(Task historyRequest)
- {
- try
- {
- await historyRequest.ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // The gateway request was canceled with its connection.
- }
- catch (Exception ex)
- {
- Logger.Debug($"[ChatHistory] Canceled request completed with {ex.GetType().Name}");
- }
- }
-
- private void PublishHistoryIfCurrent(long requestConnectionVersion)
- {
- void Deliver()
- {
- ChatDataSnapshot snapshot;
- lock (_gate)
- {
- if (_disposed || _historyConnectionVersion != requestConnectionVersion)
- return;
-
- snapshot = BuildSnapshotLocked();
- }
-
- Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot));
- if (snapshot.Threads.Length > 0 || snapshot.AvailableModels.Length > 0)
- DebounceSaveLastChatState(snapshot);
- }
-
- if (_post is null)
- Deliver();
- else
- _post(Deliver);
- }
-
- private void RaiseHistoryNotificationIfCurrent(
- ChatProviderNotification notification,
- string threadId,
- long requestConnectionVersion,
- long requestHistoryReplacementVersion)
- {
- var args = new ChatProviderNotificationEventArgs(notification);
-
- void Deliver()
- {
- lock (_gate)
- {
- if (_disposed ||
- _historyConnectionVersion != requestConnectionVersion ||
- GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion)
- return;
- }
-
- NotificationRequested?.Invoke(this, args);
- }
-
- if (_post is null)
- Deliver();
- else
- _post(Deliver);
- }
-
- public Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- return Task.CompletedTask; // Not supported by gateway — no-op.
- }
-
- public Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- return Task.CompletedTask; // Not supported by gateway — no-op.
- }
-
- public async Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- // The gateway's sessions.patch schema treats `model` as a non-empty
- // string; a blank value here is a no-op rather than a clear. Use
- // ClearModelAsync to revert a session to the gateway default.
- if (string.IsNullOrWhiteSpace(model)) return;
- await TrackModelPatchAsync(threadId, () => _bridge.PatchSessionModelAsync(threadId, model));
- }
-
- public async Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- // Tri-state clear: removes the session's model override (explicit null)
- // so it tracks the gateway/agent default again.
- await TrackModelPatchAsync(threadId, () => _bridge.ClearSessionModelAsync(threadId));
- }
-
- private async Task TrackModelPatchAsync(string threadId, Func patchOperation)
- {
- var startSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- Task? previous;
- Task pending;
- lock (_gate)
- {
- _pendingModelPatches.TryGetValue(threadId, out previous);
- pending = RunModelPatchAsync(previous, patchOperation, startSignal.Task);
- _pendingModelPatches[threadId] = pending;
- }
-
- startSignal.SetResult();
- try
- {
- await pending;
- }
- finally
- {
- lock (_gate)
- {
- if (_pendingModelPatches.TryGetValue(threadId, out var current)
- && ReferenceEquals(current, pending))
- _pendingModelPatches.Remove(threadId);
- }
- }
- }
-
- private static async Task RunModelPatchAsync(Task? previous, Func patchOperation, Task startSignal)
- {
- await startSignal;
- if (previous is not null)
- {
- try { await previous; }
- catch (Exception ex)
- {
- Logger.Debug($"ChatDataProvider: continuing model patch after previous patch failed: {ex.Message}");
- }
- }
-
- await patchOperation();
- }
+ failure = ex;
+ throw;
+ }
+ finally
+ {
+ _state.CompleteModelPatch(lease, failure);
+ }
+ }
private async Task AwaitPendingModelPatchAsync(string threadId, CancellationToken cancellationToken)
{
- Task? pending;
- lock (_gate)
- {
- _pendingModelPatches.TryGetValue(threadId, out pending);
- }
-
+ var pending = _state.GetPendingModelPatch(threadId);
if (pending is not null)
{
try { await pending.WaitAsync(cancellationToken); }
@@ -1977,27 +818,8 @@ public async Task EnsureCommandCatalogAsync(CancellationToken cancellationToken
{
cancellationToken.ThrowIfCancellationRequested();
- int epoch;
- lock (_gate)
- {
- // Only fetch while connected — the command catalog is a property of
- // the live gateway connection. A not-connected caller would just
- // land in the catch below.
- if (_status != ConnectionStatus.Connected)
- return;
- // Already loaded (or a fetch is running) → reuse the cached catalog
- // rather than hammering commands.list every time the palette opens.
- // A reconnect clears _commandCatalog (see OnStatusChanged), so a
- // fresh fetch happens after reconnect.
- if (_commandsFetchInFlight || _commandCatalog is not null)
- return;
- _commandsFetchInFlight = true;
- // Capture the connection epoch BEFORE the await. If a disconnect (or
- // reconnect) happens while ListCommandsAsync is in flight,
- // OnStatusChanged bumps the epoch; the late result is then discarded
- // rather than resurrecting a stale catalog for the new connection.
- epoch = _commandsEpoch;
- }
+ if (!_state.TryBeginCommandCatalogFetch(out var epoch))
+ return;
CommandCatalog catalog;
try
@@ -2011,34 +833,13 @@ public async Task EnsureCommandCatalogAsync(CancellationToken cancellationToken
catch (Exception ex)
{
Logger.Warn($"[ChatProvider] EnsureCommandCatalogAsync failed: {ex.Message}");
- var shouldPublishFallback = false;
- lock (_gate)
- {
- // Only publish a fallback if no status change superseded this
- // fetch. A failure must still move the UI out of its "loading"
- // state; otherwise slash-leading text would keep trapping Enter
- // until reconnect. Treat the catalog as temporarily unavailable
- // for this connection and let reconnect clear/refetch it.
- if (epoch == _commandsEpoch && _status == ConnectionStatus.Connected)
- {
- _commandsFetchInFlight = false;
- _commandCatalog = new CommandCatalog { IsSupported = false };
- shouldPublishFallback = true;
- }
- }
- if (shouldPublishFallback)
+ if (_state.FailCommandCatalogFetch(epoch))
PublishCommandCatalogIfFresh(epoch);
return;
}
- lock (_gate)
- {
- // Drop the result if the connection changed during the await.
- if (epoch != _commandsEpoch || _status != ConnectionStatus.Connected)
- return;
- _commandsFetchInFlight = false;
- _commandCatalog = catalog;
- }
+ if (!_state.CompleteCommandCatalogFetch(epoch, catalog))
+ return;
Logger.Info($"[ChatProvider] commands.list: supported={catalog.IsSupported} count={catalog.Commands.Count}");
// Re-validate freshness at UI-thread delivery time rather than
// publishing a snapshot captured under the lock above. This closes the
@@ -2059,13 +860,11 @@ private void PublishCommandCatalogIfFresh(int epoch)
{
void Deliver()
{
- ChatDataSnapshot snapshot;
- lock (_gate)
- {
- if (epoch != _commandsEpoch) return;
- snapshot = BuildSnapshotLocked();
- }
- Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot));
+ var snapshot = _state.SnapshotCommandCatalogIfFresh(
+ epoch,
+ ProjectionContext());
+ if (snapshot is not null)
+ Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot));
}
if (_post is null)
@@ -2176,57 +975,39 @@ private static ChatPermissionDecision ChatDecisionForApprovalAction(string actio
private void ClearPendingPermissionAndPublish(string threadId, string? expectedRequestId = null,
ChatPermissionDecision decision = ChatPermissionDecision.Expired)
{
- ChatDataSnapshot snapshot;
- lock (_gate)
+ var pendingId = _state.PendingPermissionId(threadId);
+ if (pendingId is null)
{
- var current = GetOrCreateTimelineLocked(threadId);
- if (current.PendingPermission is null)
- {
- Logger.Info($"[Approval] clear requested but no PendingPermission for thread='{threadId}'");
- return;
- }
- if (expectedRequestId is not null
- && !string.Equals(current.PendingPermission.RequestId, expectedRequestId, System.StringComparison.Ordinal))
- {
- Logger.Info($"[Approval] clear skipped — pending is '{current.PendingPermission.RequestId}', expected '{expectedRequestId}' (newer approval superseded)");
- return;
- }
- Logger.Info($"[Approval] clearing PendingPermission requestId='{current.PendingPermission.RequestId}' on thread='{threadId}' decision={decision}");
- _timelines[threadId] = ChatTimelineReducer.ResolvePermission(current, current.PendingPermission.RequestId, decision);
- snapshot = BuildSnapshotLocked();
+ Logger.Info($"[Approval] clear requested but no PendingPermission for thread='{threadId}'");
+ return;
+ }
+ if (expectedRequestId is not null &&
+ !string.Equals(pendingId, expectedRequestId, StringComparison.Ordinal))
+ {
+ Logger.Info($"[Approval] clear skipped — pending is '{pendingId}', expected '{expectedRequestId}' (newer approval superseded)");
+ return;
}
+ Logger.Info($"[Approval] clearing PendingPermission requestId='{pendingId}' on thread='{threadId}' decision={decision}");
+ var snapshot = _state.ClearPendingPermission(
+ threadId,
+ expectedRequestId,
+ decision,
+ ProjectionContext());
Publish(snapshot);
}
public ValueTask DisposeAsync()
{
- System.Threading.Timer? timerToDispose;
- System.Threading.Timer? chatStateTimerToDispose;
- CancellationTokenSource historyGenerationToCancel;
- lock (_gate)
- {
- if (_disposed) return ValueTask.CompletedTask;
- _disposed = true;
- historyGenerationToCancel = AdvanceHistoryGenerationLocked(clearLoaded: false);
- _telemetry.FinishAll(ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Disposed);
- timerToDispose = _toolMetaSaveTimer;
- _toolMetaSaveTimer = null;
- _toolMetaSaveVersion++;
- chatStateTimerToDispose = _lastChatStateSaveTimer;
- _lastChatStateSaveTimer = null;
- _queuedMessages.Clear();
- _queuedSendRequests.Clear();
- _queuedDrainScheduledThreads.Clear();
- _queuedMessageIdsByRunId.Clear();
- _terminalRunIdsByThread.Clear();
- _localSentTexts.Clear();
- _locallyInitiatedThreads.Clear();
- _resetSubmittedLocalEchoTexts.Clear();
- }
- CancelAndDisposeHistoryGeneration(historyGenerationToCancel);
- timerToDispose?.Dispose();
- chatStateTimerToDispose?.Dispose();
- SaveToolMetaCache();
+ var transition = _state.DisposeState();
+ if (!transition.IsFirstDispose)
+ return ValueTask.CompletedTask;
+ _telemetry.FinishAll(
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.Disposed);
+ _historyLoader.Completed -= OnHistoryLoadCompleted;
+ _historyLoader.Dispose();
+ _metadataStore.Dispose();
+ _persistence.Dispose();
_bridge.StatusChanged -= OnStatusChanged;
_bridge.SessionsUpdated -= OnSessionsUpdated;
_bridge.SessionCommandCompleted -= OnSessionCommandCompleted;
@@ -2243,214 +1024,49 @@ public ValueTask DisposeAsync()
/// adapter mutations. Returns an empty dictionary if nothing is tracked.
///
public IReadOnlyDictionary GetEntryMetadata(string threadId)
- {
- lock (_gate)
- {
- return _entryMeta.TryGetValue(threadId, out var m)
- ? new Dictionary(m)
- : new Dictionary();
- }
- }
+ => _state.GetEntryMetadata(threadId);
// ── Event handlers ──
private void OnStatusChanged(object? sender, ConnectionStatus status)
{
- ChatDataSnapshot snapshot;
- bool justReconnected;
- string[] threadsToInterrupt;
- string[] threadsToReset;
- CancellationTokenSource? historyGenerationToCancel = null;
- lock (_gate)
+ if (_state.IsDisposed)
+ return;
+ var transition = _historyLoader.ApplyStatusAndAdvanceGeneration(
+ status,
+ ProjectionContext());
+ if (transition.Reconnected || transition.Disconnected)
{
- if (_disposed)
- return;
-
- justReconnected = 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.Connected
- && _status == ConnectionStatus.Connected;
- _status = status;
-
- // Reset the sessions-list-received gate whenever we leave the
- // Connected state. Any cached sessions belong to the previous
- // connection; the UI must treat the composer as not-yet-ready
- // until the next sessions.list arrives.
- if (status != ConnectionStatus.Connected)
- _sessionsListReceived = false;
-
- // Drop the cached command catalog whenever we leave Connected so a
- // reconnect re-fetches commands.list (the catalog can change across
- // gateways / agent reconfigurations). Bumping the epoch invalidates
- // any commands.list fetch still in flight so its late result is
- // discarded instead of resurrecting a stale catalog.
- if (status != ConnectionStatus.Connected)
- {
- _commandsEpoch++;
- _commandCatalog = null;
- _commandsFetchInFlight = false;
- }
-
- // Reset the approval-dedupe LRU on every transition out of
- // Connected. IDs from a prior session must not block a fresh
- // approval with a colliding slug from the next connection.
- if (justDisconnected)
- ResetApprovalDedupe();
-
- // On (re)connect, invalidate transcript freshness without fetching
- // every session. The selected-thread render path requests the one
- // transcript the user is viewing; other sessions remain metadata-only
- // until selected. Bumping the version also prevents responses from
- // the prior connection from overwriting a newly selected transcript.
- if (justReconnected)
- {
- _telemetry.FinishAll(ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Disconnected);
- historyGenerationToCancel = AdvanceHistoryGenerationLocked(clearLoaded: true);
- _locallyInitiatedThreads.Clear();
- _localSentTexts.Clear();
- _queuedMessages.Clear();
- _queuedSendRequests.Clear();
- _queuedDrainScheduledThreads.Clear();
- _assistantFallbackPromotedThreads.Clear();
- _queuedMessageIdsByRunId.Clear();
- _terminalRunIdsByThread.Clear();
- _resetSubmittedLocalEchoTexts.Clear();
- _activeRunIds.Clear();
- _activeRunStartSequences.Clear();
- foreach (var threadId in _timelines.Keys.ToArray())
- {
- _timelines[threadId] = ChatTimelineReducer.Apply(
- _timelines[threadId],
- new ChatToolReplayResetEvent());
- }
- // Reset keyless-event diagnostic so a fresh reconnect to a
- // still-broken gateway surfaces the notification again.
- System.Threading.Interlocked.Exchange(ref _keylessEventDiagnosticRaised, 0);
- }
- if (justDisconnected)
- {
- historyGenerationToCancel = AdvanceHistoryGenerationLocked(clearLoaded: false);
- _telemetry.FinishAll(ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Disconnected);
- var list = new List();
- foreach (var (key, tl) in _timelines)
- {
- if (tl.TurnActive) list.Add(key);
- }
- threadsToInterrupt = list.ToArray();
- threadsToReset = _timelines.Keys.ToArray();
- foreach (var threadId in threadsToInterrupt)
- {
- _activeRunIds.Remove(threadId);
- _activeRunStartSequences.Remove(threadId);
- }
- }
- else
- {
- threadsToInterrupt = Array.Empty();
- threadsToReset = Array.Empty();
- }
-
- snapshot = BuildSnapshotLocked();
+ _telemetry.FinishBeforeConnectionGeneration(
+ transition.HistoryGeneration,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.Disconnected);
}
- CancelAndDisposeHistoryGeneration(historyGenerationToCancel);
- Publish(snapshot);
+ Publish(transition.Snapshot);
- // 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");
- foreach (var threadId in threadsToInterrupt)
+ var interruptedMessage = LocalizationHelper.GetString(
+ "Chat_Notification_ConnectionInterrupted");
+ foreach (var threadId in transition.InterruptedThreads)
{
- ApplyEventAndPublish(threadId, new ChatStatusEvent(interruptedMsg, ChatTone.Warning));
+ ApplyEventAndPublish(
+ threadId,
+ new ChatStatusEvent(interruptedMessage, ChatTone.Warning));
ApplyEventAndPublish(threadId, new ChatTurnEndEvent());
}
- if (threadsToReset.Length > 0)
- {
- lock (_gate)
- {
- foreach (var threadId in threadsToReset)
- {
- if (_timelines.TryGetValue(threadId, out var timeline))
- {
- _timelines[threadId] = ChatTimelineReducer.Apply(
- timeline,
- new ChatToolReplayResetEvent());
- }
- }
- }
- }
-
- }
-
- private CancellationTokenSource AdvanceHistoryGenerationLocked(bool clearLoaded)
- {
- var previousCancellation = _historyGenerationCancellation;
- _historyConnectionVersion++;
- if (!_disposed)
- _historyGenerationCancellation = new CancellationTokenSource();
- _historyInFlight.Clear();
- _historyRetryCount.Clear();
- _authoritativeHistoryReloadPending.Clear();
- _replacementHistoryReloadPending.Clear();
- if (clearLoaded)
- _historyLoaded.Clear();
- return previousCancellation;
- }
-
- private static void CancelAndDisposeHistoryGeneration(CancellationTokenSource? cancellation)
- {
- if (cancellation is null)
- return;
-
- try
- {
- cancellation.Cancel();
- }
- finally
- {
- cancellation.Dispose();
- }
+ if (transition.Disconnected)
+ _state.ClearToolReplayState();
}
private void OnSessionsUpdated(object? sender, SessionInfo[] sessions)
{
- ChatDataSnapshot snapshot;
- string[] queuedThreadsToDrain;
- lock (_gate)
- {
- 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();
- SeedSessionIdsFromSessionsLocked(_sessions);
- _sessionsListReceived = true;
- EnsureTimelinesForSessionsLocked();
- RememberLastSessionStateLocked();
- foreach (var s in _sessions)
- {
- if (string.IsNullOrEmpty(s.Key)) continue;
- var currentUsage = (s.InputTokens, s.OutputTokens, s.TotalTokens, s.ContextTokens);
- var usageChanged = !previousUsage.TryGetValue(s.Key, out var prevUsage)
- || prevUsage != currentUsage;
- if (usageChanged)
- SnapshotLatestAssistantUsageLocked(s, ResolveTimelineKeyForSessionLocked(s));
- }
- snapshot = BuildSnapshotLocked();
-
- if (_status == ConnectionStatus.Connected)
- {
- queuedThreadsToDrain = _queuedMessages.Keys.ToArray();
- }
- else
- {
- queuedThreadsToDrain = Array.Empty();
- }
- }
- Publish(snapshot);
+ if (_state.IsDisposed)
+ return;
+ var transition = _state.ApplySessions(
+ sessions ?? [],
+ ProjectionContext());
+ Publish(transition.Snapshot);
- foreach (var threadId in queuedThreadsToDrain)
+ foreach (var threadId in transition.QueuedThreadsToDrain)
{
TryDispatchNextQueuedSend(threadId);
}
@@ -2460,14 +1076,15 @@ internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload(
ChatEntryMetadata? metadata,
int maxHistorySequence,
DateTimeOffset historyRequestStartedAt) =>
- metadata is null ||
- metadata.OpenClawSeq is null ||
- metadata.OpenClawSeq is { } liveSequence && liveSequence > maxHistorySequence ||
- metadata.Timestamp is { } liveTimestamp && liveTimestamp >= historyRequestStartedAt ||
- metadata.IsLocalQueuedSend;
+ ChatConversationState.ShouldPreserveLiveEntryDuringAuthoritativeReload(
+ metadata,
+ maxHistorySequence,
+ historyRequestStartedAt);
private void OnSessionCommandCompleted(object? sender, SessionCommandResult result)
{
+ if (_state.IsDisposed)
+ return;
if (result is not { Ok: true } || string.IsNullOrWhiteSpace(result.Key))
{
return;
@@ -2487,17 +1104,29 @@ private void OnSessionCommandCompleted(object? sender, SessionCommandResult resu
private void ApplySuccessfulReset(string threadId)
{
- ChatDataSnapshot snapshot;
- ResetClearPersistence persistence;
- lock (_gate)
+ var transition = _state.ResetThread(
+ threadId,
+ ProjectionContext());
+ _historyLoader.ApplyReset(
+ threadId,
+ transition.ResetGeneration);
+ _telemetry.FinishThreadBeforeResetGeneration(
+ threadId,
+ transition.ResetGeneration,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.Reset);
+ Publish(transition.Snapshot);
+ if (_persistence.ApplyReset(
+ transition.ThreadId,
+ transition.ResetGeneration))
{
- persistence = ClearThreadHistoryAfterResetLocked(threadId);
- snapshot = BuildSnapshotLocked();
+ _persistence.SaveAbortedIds();
}
-
- Publish(snapshot);
- PersistClearedResetState(persistence);
- AbortSubmittedRunsAfterReset(threadId, persistence.SubmittedRunIds);
+ _metadataStore.EvictReset(
+ transition.ThreadId,
+ transition.OldSessionId,
+ transition.ResetGeneration);
+ AbortSubmittedRunsAfterReset(threadId, transition.SubmittedRunIds);
}
private void AbortSubmittedRunsAfterReset(string threadId, IReadOnlyList runIds)
@@ -2527,742 +1156,410 @@ private void AbortSubmittedRunsAfterReset(string threadId, IReadOnlyList
private void OnModelsListUpdated(object? sender, ModelsListInfo info)
{
- ChatDataSnapshot snapshot;
- lock (_gate)
- {
- _modelChoices = ChatModelChoice.FromModelsList(info);
- _availableModels = ModelIdsFromChoices(_modelChoices);
- snapshot = BuildSnapshotLocked();
- }
- Logger.Info($"[ChatBridge] OnModelsListUpdated: count={_availableModels.Length}");
+ if (_state.IsDisposed)
+ return;
+ var snapshot = _state.ApplyModels(info, ProjectionContext());
+ Logger.Info($"[ChatBridge] OnModelsListUpdated: count={snapshot.AvailableModels.Length}");
Publish(snapshot);
}
- // Selectable wire ids (e.g. "claude-opus-4.5") in gateway order, used by
- // the composer to match against SessionInfo.Model. Kept as a parallel
- // string[] for back-compat and safe reconnect persistence.
- private static string[] ModelIdsFromChoices(IReadOnlyList choices)
+ private void OnChatMessageReceived(object? sender, ChatMessageInfo message)
{
- if (choices.Count == 0) return Array.Empty();
- var seen = new HashSet(StringComparer.Ordinal);
- var ids = new List(choices.Count);
- foreach (var choice in choices)
+ if (message is null || _state.IsDisposed)
+ return;
+ if (string.IsNullOrEmpty(message.SessionKey))
{
- if (!choice.IsSelectable) continue;
- if (seen.Add(choice.Id))
- ids.Add(choice.Id);
+ Logger.Warn($"[ChatProvider] Dropping chat message with empty sessionKey (role={message.Role})");
+ RaiseKeylessEventDiagnosticOnce();
+ return;
}
- return ids.ToArray();
- }
- // Rehydrate minimal choices from a cached id list (reconnect / pre-connect
- // path) when richer gateway metadata isn't available yet.
- private static IReadOnlyList ChoicesFromIds(string[] ids)
- {
- if (ids.Length == 0) return Array.Empty();
- var seen = new HashSet(StringComparer.Ordinal);
- var list = new List(ids.Length);
- foreach (var id in ids)
- {
- if (string.IsNullOrEmpty(id)) continue;
- if (!seen.Add(id)) continue;
- list.Add(new ChatModelChoice(id, id));
- }
- return list;
- }
-
- private void OnChatMessageReceived(object? sender, ChatMessageInfo message)
- {
- if (message is null) return;
-
- // The gateway must include a canonical sessionKey on every chat event.
- // If it doesn't, that's a protocol bug — drop the event rather than
- // routing it to a literal "main" bucket that can't possibly match the
- // optimistic timeline keyed by the canonical key. Surfacing the drop
- // here makes future protocol gaps visible instead of silently merging
- // into a synthetic key.
- if (string.IsNullOrEmpty(message.SessionKey))
- {
- Logger.Warn($"[ChatProvider] Dropping chat message with empty sessionKey (role={message.Role})");
- RaiseKeylessEventDiagnosticOnce();
- return;
- }
-
- // Permanent low-volume trace for chat-message arrivals. One line per
- // frame the gateway sends, ordered by arrival. Includes a short
- // per-process-salted hash so two near-duplicate frames can be told
- // apart at a glance when hunting the duplicate-bubble bug (the
- // reducer's identical-text safety net only catches BYTE-equal
- // dupes; if the two frames differ by a single char the dupe
- // survives). The hash is seeded with a random value that rotates
- // on every tray restart, so it cannot be reproduced from a guessed
- // plaintext outside this process — it is a per-run frame
- // discriminator, not a content fingerprint.
var traceText = message.Text ?? string.Empty;
Logger.Info(
$"[ChatTrace] chat.message thread='{message.SessionKey}' role='{message.Role}' " +
- $"final={message.IsFinal} len={traceText.Length} h={ChatTraceHash(traceText)}");
-
- // Suppress chat messages for threads that were aborted by the user.
- // Chat messages don't carry a runId, so we use thread-level suppression.
- var msgThreadId = message.SessionKey;
- var role = message.Role ?? "";
- var roleLower = role.ToLowerInvariant();
+ $"final={message.IsFinal} len={traceText.Length} h={ChatContentFormatting.ChatTraceHash(traceText)}");
+ var threadId = message.SessionKey;
+ var role = message.Role?.ToLowerInvariant() ?? string.Empty;
var rawText = message.Text ?? string.Empty;
- ChatDataSnapshot? resetLocalEchoSnapshot = null;
- var dropAfterReset = false;
- var requestRemoteBackfillAfterReset = false;
- lock (_gate)
+ var gate = _state.GateIncomingChatMessage(message, ProjectionContext());
+ HandleOpenedLifecycle(
+ threadId,
+ gate.OpenedLifecycle,
+ gate.RuntimeGeneration);
+ if (gate.Suppressed)
{
- if (ShouldDropChatMessageAfterResetLocked(
- msgThreadId,
- roleLower,
- rawText,
- message.Ts,
- out var consumeEchoText,
- out var requestRemoteBackfill))
- {
- dropAfterReset = true;
- requestRemoteBackfillAfterReset = requestRemoteBackfill;
- if (consumeEchoText is not null &&
- _localSentTexts.TryGetValue(msgThreadId, out var resetEchoQueue) &&
- resetEchoQueue.Count > 0 &&
- TryConsumeLocalEchoLocked(msgThreadId, resetEchoQueue, consumeEchoText, out var queuedMessageId))
- {
- var confirmedMeta = BuildLiveMetaLocked(
- msgThreadId,
- message.Ts,
- message.OpenClawId,
- message.OpenClawSeq);
- if (ReconcileQueuedMessageEchoLocked(msgThreadId, queuedMessageId, confirmedMeta))
- resetLocalEchoSnapshot = BuildSnapshotLocked();
- }
- }
- else if (_abortedThreads.Contains(msgThreadId))
- {
- Logger.Debug($"[ABORT] Suppressed ChatMessage for threadId='{msgThreadId}' (role={message.Role})");
- return;
- }
+ Logger.Debug($"[ABORT] Suppressed ChatMessage for threadId='{threadId}' (role={message.Role})");
+ return;
}
- if (dropAfterReset)
+ if (gate.Drop)
{
- if (resetLocalEchoSnapshot is not null)
- {
- Publish(resetLocalEchoSnapshot);
- }
- if (requestRemoteBackfillAfterReset)
- _ = FetchRemoteUserMessageAsync(msgThreadId, openResetGateOnSuccess: true);
-
- Logger.Debug($"[Reset] Dropping stale chat message after reset for threadId='{msgThreadId}' role='{roleLower}'");
+ if (gate.Snapshot is not null)
+ Publish(gate.Snapshot);
+ if (gate.RequestRemoteBackfill)
+ _ = FetchRemoteUserMessageAsync(threadId, openResetGateOnSuccess: true);
+ Logger.Debug($"[Reset] Dropping stale chat message after reset for threadId='{threadId}' role='{role}'");
return;
}
- if (roleLower == "system" &&
+ if (role == "system" &&
string.Equals(message.OpenClawKind, "compaction", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(message.Text))
{
- ChatEntryMetadata compactionMeta;
- lock (_gate)
- {
- compactionMeta = BuildLiveMetaLocked(
- msgThreadId,
+ ApplyEventAndPublish(
+ threadId,
+ new ChatStatusEvent(
+ ChatContentFormatting.TruncateForChatEntry(message.Text),
+ ChatTone.Dim),
+ _state.BuildLiveMetadata(
+ threadId,
message.Ts,
message.OpenClawId,
message.OpenClawSeq,
openClawKind: message.OpenClawKind,
compactionTokensBefore: message.CompactionTokensBefore,
- compactionTokensAfter: message.CompactionTokensAfter);
- }
- ApplyEventAndPublish(
- msgThreadId,
- new ChatStatusEvent(TruncateForChatEntry(message.Text), ChatTone.Dim),
- compactionMeta);
+ compactionTokensAfter: message.CompactionTokensAfter));
return;
}
- // User messages from the SSE stream. System control notes are rendered
- // as dim status entries. Normal user messages: promote echoes of
- // locally-sent queued messages into the transcript, show messages from
- // other clients (e.g. gateway web UI) so the conversation is coherent.
- if (roleLower == "user")
+ if (role == "user")
{
- // Approval slash-commands ("/approve allow-once",
- // "/approve allow-always",
- // "/deny ") are transport, not user prose. If WE sent
- // it (matched + consumed from _localSentTexts) suppress the
- // echo entirely — RespondToPermissionAsync already cleared
- // the banner. If it came from ANOTHER client subscribed to
- // this thread, render a dim audit-trail status so the user
- // can still see that an approval decision was made elsewhere
- // (preserves audit signal).
- if (LooksLikeApprovalSlashCommand(rawText))
+ if (ChatContentFormatting.LooksLikeApprovalSlashCommand(rawText))
{
- var slashEcho = rawText.Trim();
- bool weSentIt = false;
- lock (_gate)
- {
- if (_localSentTexts.TryGetValue(msgThreadId, out var sq) && sq.Count > 0
- && TryConsumeLocalEchoLocked(msgThreadId, sq, slashEcho, out var slashEntryId))
- {
- weSentIt = true;
- RemoveQueuedMessageLocked(msgThreadId, slashEntryId);
- }
- }
- if (weSentIt)
- {
- Logger.Debug($"[Approval] suppressed echo of our slash command on thread='{msgThreadId}'");
+ var echo = _state.ConsumeLocalEcho(
+ message,
+ removeQueuedMessage: true,
+ ProjectionContext());
+ if (echo.Consumed)
return;
- }
- // From another client — render as dim audit status.
- ChatEntryMetadata? approvalMeta;
- lock (_gate) { approvalMeta = BuildLiveMetaLocked(msgThreadId, message.Ts); }
- ApplyEventAndPublish(msgThreadId,
- new ChatStatusEvent(slashEcho, ChatTone.Dim),
- approvalMeta);
+ ApplyEventAndPublish(
+ threadId,
+ new ChatStatusEvent(rawText.Trim(), ChatTone.Dim),
+ _state.BuildLiveMetadata(threadId, message.Ts));
return;
}
-
if (NativeToolProjector.LooksLikeSystemControlNote(rawText))
{
- if (string.IsNullOrEmpty(message.Text)) return;
- var sysThread = message.SessionKey;
- ChatEntryMetadata? sysMeta;
- lock (_gate) { sysMeta = BuildLiveMetaLocked(sysThread, message.Ts); }
- ApplyEventAndPublish(sysThread,
- new ChatStatusEvent(TruncateForChatEntry(message.Text), ChatTone.Dim),
- sysMeta);
- return;
- }
-
- // Check if this is an echo of a locally-sent message.
- var echoText = (message.Text ?? "").Trim();
- bool isLocalEcho = false;
- ChatDataSnapshot? echoSnapshot = null;
- lock (_gate)
- {
- if (_localSentTexts.TryGetValue(msgThreadId, out var q) && q.Count > 0
- && TryConsumeLocalEchoLocked(msgThreadId, q, echoText, out var echoEntryId))
+ if (!string.IsNullOrEmpty(message.Text))
{
- isLocalEcho = true;
- var confirmedMeta = BuildLiveMetaLocked(
- msgThreadId,
- message.Ts,
- message.OpenClawId,
- message.OpenClawSeq);
- if (ReconcileQueuedMessageEchoLocked(msgThreadId, echoEntryId, confirmedMeta))
- echoSnapshot = BuildSnapshotLocked();
+ ApplyEventAndPublish(
+ threadId,
+ new ChatStatusEvent(
+ ChatContentFormatting.TruncateForChatEntry(message.Text),
+ ChatTone.Dim),
+ _state.BuildLiveMetadata(threadId, message.Ts));
}
+ return;
}
- if (isLocalEcho)
+
+ var localEcho = _state.ConsumeLocalEcho(
+ message,
+ removeQueuedMessage: false,
+ ProjectionContext());
+ if (localEcho.Consumed)
{
- if (echoSnapshot is not null)
- {
- Publish(echoSnapshot);
- }
+ if (localEcho.Snapshot is not null)
+ Publish(localEcho.Snapshot);
return;
}
-
- // Not a local echo — show it as a user message from another client.
if (!string.IsNullOrEmpty(message.Text))
{
- var userText = TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(message.Text));
- ChatEntryMetadata? userMeta;
- ChatDataSnapshot? reconciledLocalQueuedSnapshot = null;
- lock (_gate)
- {
- userMeta = BuildLiveMetaLocked(
- msgThreadId,
- message.Ts,
- message.OpenClawId,
- message.OpenClawSeq);
- if (TryReconcileExistingLocalQueuedUserEchoLocked(msgThreadId, userText, userMeta))
- reconciledLocalQueuedSnapshot = BuildSnapshotLocked();
- }
- if (reconciledLocalQueuedSnapshot is not null)
+ var userText = ChatContentFormatting.TruncateForChatEntry(
+ ChatMetadataStore.EscapeUntrustedAttachmentMarkerLines(message.Text));
+ var reconciled = _state.ReconcileExistingLocalQueuedUser(
+ message,
+ userText,
+ ProjectionContext());
+ if (reconciled.Consumed)
{
- Publish(reconciledLocalQueuedSnapshot);
+ if (reconciled.Snapshot is not null)
+ Publish(reconciled.Snapshot);
return;
}
-
- ApplyEventAndPublish(msgThreadId,
+ ApplyEventAndPublish(
+ threadId,
new ChatUserMessageEvent(userText),
- userMeta);
+ _state.BuildLiveMetadata(
+ threadId,
+ message.Ts,
+ message.OpenClawId,
+ message.OpenClawSeq));
}
return;
}
- // ``role=toolresult`` frames are tool-output provenance and need to
- // render as a tool chip, the same way history does at lines 372-390
- // (chat rubber-duck MEDIUM 2).
- if (roleLower == "toolresult" || roleLower == "tool_result")
+ if (role is "toolresult" or "tool_result")
{
- if (string.IsNullOrEmpty(message.Text)) return;
- var trThread = message.SessionKey;
- ChatEntryMetadata? trMeta;
- string? trRunId;
- lock (_gate)
- {
- trMeta = BuildLiveMetaLocked(
- trThread,
- message.Ts,
- message.OpenClawId,
- message.OpenClawSeq);
- _activeRunIds.TryGetValue(trThread, out trRunId);
- }
- var capped = TruncateForChatEntry(message.Text);
- var kind = NativeToolProjector.ClassifyFlattenedToolOutput(capped);
- var label = NativeToolProjector.ExtractFlattenedToolSummary(capped);
- _telemetry.ObserveInboundOutput(
- trThread,
- trRunId,
- ChatResponseOutputKind.Tool);
- ApplyEventAndPublish(
- trThread,
- new ChatToolStartEvent(
- label,
- kind,
- IdentityStrength: NativeToolProjector.ClassifyHistoryIdentityStrength(kind)),
- trMeta);
- ApplyEventAndPublish(trThread, new ChatToolOutputEvent(capped), trMeta);
+ if (string.IsNullOrEmpty(message.Text))
+ return;
+ var (metadata, runId) = _state.BuildMetadataWithRun(message);
+ var capped = ChatContentFormatting.TruncateForChatEntry(message.Text);
+ var mapped = ChatEventMapper.MapFlattenedToolOutput(capped, runId);
+ _telemetry.ObserveInboundOutput(threadId, runId, ChatResponseOutputKind.Tool);
+ ApplyEventAndPublish(threadId, mapped.Start, metadata);
+ ApplyEventAndPublish(threadId, mapped.Output, metadata);
return;
}
- if (roleLower != "assistant")
- return;
- if (ChatMessageInfo.IsSilentAssistantDirective(roleLower, message.Text))
- return;
- if (string.IsNullOrEmpty(message.Text))
- return;
-
- var threadId = message.SessionKey;
- var cappedAssistantText = RepairContentBlockSeams(TruncateForChatEntry(message.Text));
- AssistantQueueFrameDisposition assistantDisposition;
- lock (_gate)
- {
- assistantDisposition = ClassifyAssistantQueueFrameLocked(
- threadId,
- cappedAssistantText,
- message.OpenClawId,
- message.OpenClawSeq);
- }
- if (assistantDisposition != AssistantQueueFrameDisposition.Render)
+ if (role != "assistant" ||
+ ChatMessageInfo.IsSilentAssistantDirective(role, message.Text) ||
+ string.IsNullOrEmpty(message.Text))
{
- Logger.Debug($"[Queue] Dropping retransmitted assistant frame around queued user boundary threadId='{threadId}'");
return;
}
- PromoteOldestQueuedMessageBeforeAssistantIfNeeded(threadId);
- ChatEntryMetadata? meta;
- string? telemetryRunId;
- var hasUsage = message.InputTokens is not null || message.OutputTokens is not null
- || message.ResponseTokens is not null || message.ContextPercent is not null;
- lock (_gate)
- {
- meta = BuildLiveMetaLocked(
- threadId,
- message.Ts,
- message.OpenClawId,
- message.OpenClawSeq);
- _activeRunIds.TryGetValue(threadId, out telemetryRunId);
- // If the gateway included a usage block on this chat event,
- // attach it so the assistant footer pills (↑/↓/R/ctx%) can
- // render. Mostly arrives on state="final" frames.
- if (hasUsage)
- {
- var session = Array.Find(_sessions, s => s.Key == threadId);
- meta = meta with
- {
- InputTokens = message.InputTokens ?? meta.InputTokens,
- OutputTokens = message.OutputTokens ?? meta.OutputTokens,
- ResponseTokens = message.ResponseTokens ?? meta.ResponseTokens,
- ContextPercent = message.ContextPercent ?? meta.ContextPercent,
- ContextTokens = session?.ContextTokens > 0 ? session.ContextTokens : meta.ContextTokens
- };
- }
- }
-
- if (!message.IsFinal && IsLateNonFinalAssistantFrame(threadId))
- {
- Logger.Warn($"[ChatProvider] Dropping late non-final assistant frame after completed turn for threadId='{threadId}' len={traceText.Length}");
+ var assistantText = ChatContentFormatting.RepairContentBlockSeams(
+ ChatContentFormatting.TruncateForChatEntry(message.Text));
+ var preparation = _state.PrepareAssistant(
+ message,
+ assistantText,
+ ProjectionContext());
+ if (preparation.PromotionSnapshot is not null)
+ Publish(preparation.PromotionSnapshot);
+ if (preparation.Disposition != AssistantQueueFrameDisposition.Render)
+ return;
+ if (!message.IsFinal && _state.IsLateNonFinalAssistantFrame(threadId))
return;
- }
_telemetry.ObserveInboundOutput(
threadId,
- telemetryRunId,
+ preparation.ActiveRunId,
ChatResponseOutputKind.Assistant);
- // Both `state: "delta"` and `state: "final"` carry the cumulative
- // assistant text (the gateway's EmbeddedBlockChunker emits completed
- // blocks, not token deltas — see spec §"Block Streaming"). Map both
- // to ChatMessageEvent so the reducer REPLACES the active assistant
- // entry's text. We tag delta frames with IsStreaming:true so the
- // reducer's reconcile-into-previous logic only collapses follow-up
- // finals into a still-streaming preview — a finalised assistant
- // from a completed earlier turn must not be silently overwritten
- // by a brand-new turn's reply (e.g. user → reply → tool → reply).
- // Final additionally ends the turn.
ApplyEventAndPublish(
threadId,
new ChatMessageEvent(
- cappedAssistantText,
+ assistantText,
ReconcilePrevious: true,
IsStreaming: !message.IsFinal),
- meta);
-
- if (hasUsage)
- SnapshotAssistantUsageContribution(threadId, meta);
-
- if (message.IsFinal)
+ preparation.Metadata);
+
+ var hasUsage = message.InputTokens is not null ||
+ message.OutputTokens is not null ||
+ message.ResponseTokens is not null ||
+ message.ContextPercent is not null;
+ if (hasUsage &&
+ _state.SnapshotAssistantUsageContribution(
+ threadId,
+ preparation.Metadata,
+ ProjectionContext()) is { } usageSnapshot)
{
- ChatTelemetryTracker.PreparedTurnCompletion? turnCompletion = null;
- lock (_gate)
- {
- if (_activeRunIds.Remove(threadId, out var completedRunId))
- {
- turnCompletion = _telemetry.PrepareFinishByRunId(
- completedRunId,
- ChatTelemetryOutcome.Success,
- ChatTurnTelemetryReason.AssistantFinal);
- RememberTerminalRunIdLocked(threadId, completedRunId);
- _abortedRunIds.Remove(completedRunId);
- }
- _activeRunStartSequences.Remove(threadId);
- _abortedThreads.Remove(threadId);
- if (!HasSendingQueuedMessagesLocked(threadId))
- _locallyInitiatedThreads.Remove(threadId);
- }
- _telemetry.CompletePreparedTurn(turnCompletion);
- SnapshotLatestAssistantUsage(threadId);
- ApplyEventAndPublish(threadId, new ChatTurnEndEvent());
- RaiseNotification(new ChatProviderNotification(
- ChatProviderNotificationKind.TurnComplete, threadId, LocalizationHelper.GetString("Chat_Notification_AssistantReplied")));
- ScheduleQueuedSendDrain(threadId);
+ Publish(usageSnapshot);
}
- }
-
- private bool IsLateNonFinalAssistantFrame(string threadId)
- {
- lock (_gate)
- {
- if (!_timelines.TryGetValue(threadId, out var timeline))
- return false;
- if (timeline.TurnActive)
- return false;
-
- for (var i = timeline.Entries.Count - 1; i >= 0; i--)
- {
- var entry = timeline.Entries[i];
- if (entry.Kind == ChatTimelineItemKind.User)
- return false;
- if (entry.Kind == ChatTimelineItemKind.Assistant)
- return !entry.IsStreaming;
- }
- return false;
- }
+ if (!message.IsFinal)
+ return;
+ var completedRunId = _state.CompleteAssistantFinal(threadId);
+ var completion = completedRunId is null
+ ? null
+ : _telemetry.PrepareFinishByRunId(
+ completedRunId,
+ ChatTelemetryOutcome.Success,
+ ChatTurnTelemetryReason.AssistantFinal);
+ _telemetry.CompletePreparedTurn(completion);
+ if (_state.SnapshotLatestAssistantUsage(threadId, ProjectionContext()) is { } latestUsage)
+ Publish(latestUsage);
+ ApplyEventAndPublish(threadId, new ChatTurnEndEvent());
+ RaiseNotification(new ChatProviderNotification(
+ ChatProviderNotificationKind.TurnComplete,
+ threadId,
+ LocalizationHelper.GetString("Chat_Notification_AssistantReplied")));
+ ScheduleQueuedSendDrain(threadId);
}
private void OnAgentEventReceived(object? sender, AgentEventInfo evt)
{
- if (evt is null) return;
- // As with chat events, every agent event must carry a canonical
- // sessionKey. Drop the event rather than routing to "main" if missing —
- // see the rationale in OnChatMessageReceived.
+ if (evt is null || _state.IsDisposed)
+ return;
if (string.IsNullOrEmpty(evt.SessionKey))
{
Logger.Warn($"[ChatProvider] Dropping agent event with empty sessionKey (stream={evt.Stream})");
RaiseKeylessEventDiagnosticOnce();
return;
}
- var threadId = evt.SessionKey;
- var isTerminalRunEvent = IsTerminalRunEvent(evt);
- var reloadHistoryAfterResetDrop = false;
- var shouldProcessEvent = false;
- ChatTerminalEventDropReason? droppedTerminalReason = null;
- lock (_gate)
- {
- if (ShouldDropAgentEventAfterResetLocked(evt, threadId, out reloadHistoryAfterResetDrop))
- {
- Logger.Debug($"[Reset] Dropping stale agent event after reset for threadId='{threadId}' stream='{evt.Stream}' runId='{evt.RunId}'");
- }
- else if (ShouldDropTerminalAgentEventLocked(evt, threadId, out droppedTerminalReason))
- {
- Logger.Debug($"[Queue] Dropping stale terminal agent event for threadId='{threadId}' stream='{evt.Stream}' runId='{evt.RunId}'");
- }
- else
- {
- shouldProcessEvent = true;
- }
- }
- if (!shouldProcessEvent)
+ var threadId = evt.SessionKey;
+ var terminal = ChatEventMapper.IsTerminalRunEvent(evt);
+ var transition = _state.ProcessAgentEvent(
+ evt,
+ threadId,
+ ProjectionContext());
+ if (!transition.Process)
{
- if (droppedTerminalReason.HasValue)
- RecordDroppedTerminalEvent(droppedTerminalReason.Value);
- if (reloadHistoryAfterResetDrop)
+ if (transition.DroppedTerminalReason is { } droppedReason)
+ RecordDroppedTerminalEvent(droppedReason);
+ if (transition.ReloadHistory)
_ = LoadHistoryAsync(threadId, force: true);
return;
}
- // Always update run tracking first (state maintenance must not be skipped).
- var deferredAbort = UpdateActiveRunId(evt, threadId);
- if (deferredAbort.DroppedTerminalReason.HasValue)
- RecordDroppedTerminalEvent(deferredAbort.DroppedTerminalReason.Value);
- ClearQueuedMessageOnLocalTurnStart(evt, threadId);
-
- // Fire deferred chat.abort and persist if pending aborts were queued.
- var deferredRunId = deferredAbort.RunId;
- var shouldPersist = deferredAbort.Count > 0;
- if (deferredRunId is not null || shouldPersist)
+ HandleOpenedLifecycle(
+ threadId,
+ transition.OpenedLifecycle,
+ transition.RuntimeGeneration);
+ foreach (var snapshot in transition.Snapshots)
+ Publish(snapshot);
+ if (ChatEventMapper.IsLifecycleStart(evt))
{
- _ = Task.Run(async () =>
+ _telemetry.ObserveLifecycleStart(
+ threadId,
+ evt.RunId,
+ transition.AllowRemoteTurn,
+ transition.RuntimeGeneration);
+ if (!_state.IsRuntimeGenerationCurrent(
+ threadId,
+ transition.RuntimeGeneration))
{
- if (deferredRunId is not null)
- {
- try
- {
- Logger.Info($"[ABORT] Sending deferred chat.abort for runId='{deferredRunId}'");
- await _bridge.SendChatAbortAsync(deferredRunId, threadId);
- Logger.Info($"[ABORT] Deferred chat.abort sent successfully");
- }
- catch (Exception ex)
- {
- Logger.Warn($"[ABORT] Deferred chat.abort failed: {ex.Message}");
- }
- }
- // Always persist — scan history for user messages with missing/truncated responses.
- await PersistAbortedMessageIdAsync(threadId);
- });
+ _telemetry.FinishByRunId(
+ evt.RunId,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.Superseded);
+ }
}
+ if (transition.FetchRemoteUser)
+ _ = FetchRemoteUserMessageAsync(threadId);
- // Suppress rendering for aborted runs/threads (but lifecycle events
- // already ran above for state cleanup).
- var suppressRendering = false;
- lock (_gate)
+ ChatTelemetryTracker.PreparedTurnCompletion? completion = null;
+ if (transition.CompletionPhase is { } phase)
{
- if (!string.IsNullOrEmpty(evt.RunId) && _abortedRunIds.Contains(evt.RunId))
- suppressRendering = true;
- else if (_abortedThreads.Contains(threadId))
- suppressRendering = true;
+ completion = _telemetry.PrepareFinishByRunId(
+ transition.CompletedRunId,
+ phase == "error" ? ChatTelemetryOutcome.Failure : ChatTelemetryOutcome.Success,
+ phase == "error"
+ ? ChatTurnTelemetryReason.LifecycleError
+ : ChatTurnTelemetryReason.LifecycleEnd);
+ if (completion is null && !transition.WasAborted)
+ {
+ RecordDroppedTerminalEvent(
+ string.IsNullOrWhiteSpace(transition.CompletedRunId)
+ ? ChatTerminalEventDropReason.MissingRunId
+ : ChatTerminalEventDropReason.MismatchedRunId);
+ }
}
- if (suppressRendering)
+ _telemetry.CompletePreparedTurn(completion);
+
+ ScheduleDeferredAbort(
+ threadId,
+ transition.DeferredAbortRunId,
+ transition.DeferredAbortCount,
+ transition.RuntimeGeneration);
+
+ if (transition.Suppressed)
{
- if (isTerminalRunEvent)
+ if (terminal)
ScheduleQueuedSendDrain(threadId);
return;
}
- ChatEvent? mapped = MapAgentEvent(evt);
+ var mapped = transition.MappedEvent;
if (mapped is null)
{
- // Approval lifecycle: clear the composer's Allow/Deny banner when
- // the gateway tells us this approval has reached a terminal state
- // (whether the dashboard answered first, the run was aborted, or
- // it expired). MapApprovalEvent only emits for ``requested``, so
- // every other approval phase lands here as a null mapping.
- //
- // Guardrails:
- // • Whitelist terminal phases — we explicitly enumerate the
- // phases that mean "approval is done". Anything else (e.g. a
- // future ``acknowledged``/``in_progress`` phase the gateway
- // might add) must not wipe a live banner.
- // • Match by requestId — clear ONLY on a proven positive match
- // between (evtSlug, evtApprovalId) and (pendingId, its
- // recorded alternate id). The previous "clear unless we can
- // prove a mismatch" default would wipe the banner when the
- // terminal event arrived with both ids empty, or when ids
- // used different precedence on the two ends of the lifecycle
- // (slug-only ``requested`` vs approvalId-only ``resolved``).
- if (string.Equals(evt.Stream, "approval", System.StringComparison.OrdinalIgnoreCase)
- && evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object)
- {
- var phase = evt.Data.TryGetProperty("phase", out var p) && p.ValueKind == System.Text.Json.JsonValueKind.String
- ? (p.GetString() ?? "")
- : "";
- if (IsTerminalApprovalPhase(phase))
- {
- var evtApprovalId = evt.Data.TryGetProperty("approvalId", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.String
- ? (a.GetString() ?? "")
- : "";
- var evtSlug = evt.Data.TryGetProperty("approvalSlug", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String
- ? (s.GetString() ?? "")
- : "";
- var evtDecision = evt.Data.TryGetProperty("decision", out var d) && d.ValueKind == System.Text.Json.JsonValueKind.String
- ? (d.GetString() ?? "")
- : "";
-
- string? pendingId;
- lock (_gate)
- {
- pendingId = GetOrCreateTimelineLocked(threadId).PendingPermission?.RequestId;
- }
-
- if (string.IsNullOrEmpty(pendingId))
- {
- // No live banner — nothing to clear, nothing to log loudly.
- Logger.Debug($"[Approval] terminal phase='{phase}' for slug='{evtSlug}' approvalId='{evtApprovalId}' — no PendingPermission");
- }
- else if (ApprovalIdMatches(pendingId!, evtSlug, evtApprovalId))
- {
- // Honor the gateway's actual decision instead of always
- // stamping Expired. The resolved echo races the local
- // RPC response on the same WebSocket — if Expired wins
- // here, ResolvePermission's no-overwrite guard then
- // blocks the user's Allow/Denied stamp from landing.
- // Phase already passed IsTerminalApprovalPhase; use
- // the exact decision when present so allow-always is
- // preserved, then fall back to phase mapping.
- var resolvedDecision = MapTerminalPhaseToDecision(phase, evtDecision);
- ClearPendingPermissionAndPublish(threadId, expectedRequestId: pendingId, decision: resolvedDecision);
- }
- else
- {
- // Either the event carried no id (gateway protocol drift)
- // or ids didn't match the live banner. Either way we must
- // not clear — preserving the banner is the safer default.
- Logger.Info($"[Approval] terminal phase='{phase}' slug='{evtSlug}' approvalId='{evtApprovalId}' did not match pending '{pendingId}' — banner preserved");
- }
- }
- }
- if (isTerminalRunEvent)
+ if (terminal)
ScheduleQueuedSendDrain(threadId);
return;
}
- var outputKind = ClassifyInboundOutput(evt, mapped);
- if (outputKind.HasValue)
- {
- _telemetry.ObserveInboundOutput(
- threadId,
- evt.RunId,
- outputKind.Value);
- }
-
- // AgentEventInfo.Ts is a double of unix-epoch ms (per OpenClawGatewayClient).
- var tsMs = evt.Ts > 0 ? (long)evt.Ts : 0L;
- ChatEntryMetadata? meta;
- lock (_gate) { meta = BuildLiveMetaLocked(threadId, tsMs); }
-
- ApplyEventAndPublish(threadId, mapped, meta);
- CacheMappedToolMetadata(threadId, mapped, tsMs);
- if (isTerminalRunEvent)
+ if (ChatEventMapper.ClassifyInboundOutput(evt, mapped) is { } outputKind)
+ _telemetry.ObserveInboundOutput(threadId, evt.RunId, outputKind);
+ if (transition.ToolMetadata is { } toolMetadata)
+ _metadataStore.CacheTool(toolMetadata);
+ if (terminal)
ScheduleQueuedSendDrain(threadId);
}
- private void CacheMappedToolMetadata(string threadId, ChatEvent mapped, long tsMs)
+ private void ScheduleDeferredAbort(
+ string threadId,
+ string? runId,
+ int pendingCount,
+ ChatRuntimeGeneration runtimeGeneration)
{
- if (mapped is not ChatToolStartEvent and not ChatToolPresentationEvent)
+ if (runId is null && pendingCount <= 0)
return;
- var legacyTurn = ResolveToolCacheLegacyTurn(threadId, mapped);
- if (mapped is ChatToolStartEvent toolStart && !string.IsNullOrEmpty(toolStart.ToolName))
- {
- CacheToolMeta(
- threadId,
- tsMs,
- toolStart.ToolName,
- toolStart.Text,
- toolStart.ToolCallId,
- toolStart.ToolArgs,
- toolStart.IdentityStrength,
- toolStart.RunId,
- legacyTurn);
- }
- else if (mapped is ChatToolPresentationEvent presentation)
- {
- CacheToolMeta(
- threadId,
- tsMs,
- presentation.ToolName,
- NativeToolProjector.FirstToolDisplayValue(presentation.ToolArgs),
- presentation.ParentToolCallId,
- presentation.ToolArgs,
- presentation.IdentityStrength,
- presentation.RunId,
- legacyTurn);
- }
- }
-
- private long ResolveToolCacheLegacyTurn(string threadId, ChatEvent mapped)
- {
- var runId = mapped switch
+ _ = _deferredAbortScheduler(async () =>
{
- ChatToolStartEvent start => start.RunId,
- ChatToolPresentationEvent presentation => presentation.RunId,
- _ => null
- };
- if (!string.IsNullOrWhiteSpace(runId))
- return 0;
-
- lock (_gate)
- {
- if (!_timelines.TryGetValue(threadId, out var timeline))
- return ChatTimelineState.Initial().ToolLegacyTurn;
-
- var toolCallId = mapped switch
- {
- ChatToolStartEvent start => start.ToolCallId,
- ChatToolPresentationEvent presentation => presentation.ParentToolCallId,
- _ => null
- };
- if (string.IsNullOrWhiteSpace(toolCallId))
- return timeline.ToolLegacyTurn;
-
- if (mapped is ChatToolPresentationEvent)
+ if (!_state.IsRuntimeGenerationCurrent(
+ threadId,
+ runtimeGeneration))
{
- var pendingKey = timeline.PendingToolPresentations?.Keys
- .Where(key => key.RunId is null
- && string.Equals(key.ToolCallId, toolCallId, StringComparison.Ordinal))
- .OrderByDescending(key => key.LegacyTurn)
- .FirstOrDefault();
- if (pendingKey is { ToolCallId.Length: > 0 })
- return pendingKey.Value.LegacyTurn;
+ return;
}
-
- for (var i = timeline.Entries.Count - 1; i >= 0; i--)
+ if (runId is not null)
{
- var entry = timeline.Entries[i];
- if (entry.Kind != ChatTimelineItemKind.ToolCall || entry.ToolRunId is not null)
- continue;
- if (string.Equals(entry.ToolCallId, toolCallId, StringComparison.Ordinal)
- || entry.ToolCorrelationIds?.Contains(toolCallId) == true)
+ try
+ {
+ await _bridge.SendChatAbortAsync(runId, threadId);
+ }
+ catch (Exception ex)
{
- return entry.ToolLegacyTurn;
+ var rollbackSnapshot =
+ _state.RollbackAbortAndEndTurnIfCurrent(
+ threadId,
+ runId,
+ runtimeGeneration,
+ ProjectionContext());
+ if (rollbackSnapshot is not null)
+ {
+ Logger.Warn(
+ $"[ABORT] Deferred chat.abort failed, cleared suppression: {ex.Message}");
+ RaiseNotification(new ChatProviderNotification(
+ ChatProviderNotificationKind.Error,
+ threadId,
+ LocalizationHelper.GetString(
+ "Chat_Notification_AbortFailed"),
+ ex.Message));
+ Publish(rollbackSnapshot);
+ ScheduleQueuedSendDrain(threadId);
+ }
+ return;
}
}
- return timeline.ToolLegacyTurn;
- }
+ if (!_state.IsRuntimeGenerationCurrent(
+ threadId,
+ runtimeGeneration))
+ {
+ return;
+ }
+ await PersistAbortedMessageIdsAsync(
+ threadId,
+ runtimeGeneration.ResetGeneration);
+ });
}
- private static ChatResponseOutputKind? ClassifyInboundOutput(
- AgentEventInfo evt,
- ChatEvent mapped)
+ private void HandleOpenedLifecycle(
+ string threadId,
+ ChatOpenedLifecycleTransition? opened,
+ ChatRuntimeGeneration runtimeGeneration)
{
- if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase))
- {
- return null;
- }
+ if (opened is null)
+ return;
- return mapped switch
+ _telemetry.ObserveLifecycleStart(
+ threadId,
+ opened.Event.RunId,
+ opened.AllowRemoteTurn,
+ runtimeGeneration);
+ if (!_state.IsRuntimeGenerationCurrent(
+ threadId,
+ runtimeGeneration))
{
- ChatMessageEvent or ChatMessageDeltaEvent => ChatResponseOutputKind.Assistant,
- ChatThinkingEvent or ChatReasoningEvent or ChatReasoningDeltaEvent or
- ChatIntentEvent => ChatResponseOutputKind.Reasoning,
- ChatToolStartEvent or ChatToolOutputEvent or ChatToolErrorEvent or
- ChatPermissionRequestEvent => ChatResponseOutputKind.Tool,
- ChatStatusEvent or ChatErrorEvent or ChatReasoningEndEvent or
- ChatTurnEndEvent or ChatUserMessageEvent => null,
- _ => null,
- };
+ _telemetry.FinishByRunId(
+ opened.Event.RunId,
+ ChatTelemetryOutcome.Canceled,
+ ChatTurnTelemetryReason.Superseded);
+ return;
+ }
+ ScheduleDeferredAbort(
+ threadId,
+ opened.DeferredAbortRunId,
+ opened.DeferredAbortCount,
+ runtimeGeneration);
}
private void RaiseKeylessEventDiagnosticOnce()
{
- if (System.Threading.Interlocked.Exchange(ref _keylessEventDiagnosticRaised, 1) != 0)
+ if (!_state.TryRaiseKeylessDiagnostic())
return;
- var threadId = GetKeylessEventDiagnosticThreadId();
+ var threadId = _state.ResolveDefaultThreadId(ProjectionContext());
var title = LocalizationHelper.GetString("Chat_Notification_KeylessEventDropped");
var message = LocalizationHelper.GetString("Chat_Notification_KeylessEventDroppedMessage");
@@ -3276,4070 +1573,226 @@ private void RaiseKeylessEventDiagnosticOnce()
ApplyEventAndPublish(threadId, new ChatStatusEvent(message, ChatTone.Warning));
}
- private string? GetKeylessEventDiagnosticThreadId()
+ private void RecordDroppedTerminalEvent(ChatTerminalEventDropReason reason)
{
- lock (_gate)
- {
- return ResolveDefaultThreadIdLocked()
- ?? _timelines.Keys.FirstOrDefault(k => !string.IsNullOrWhiteSpace(k));
- }
+ _telemetry.RecordDroppedTerminalEvent(reason);
+ Logger.Warn(
+ $"[ChatTelemetry] Dropped terminal chat event because safe run correlation was unavailable " +
+ $"(reason='{ChatTelemetryTracker.ToTelemetryValue(reason)}').");
+ }
+
+ private void TryDispatchNextQueuedSend(string threadId)
+ {
+ var start = _state.TryStartNextQueuedSend(
+ threadId,
+ requireConnected: true,
+ ProjectionContext());
+ var dispatch = start.Dispatch;
+ var queueCompletion = dispatch is not null &&
+ dispatch.Request.LifecycleCommand is null
+ ? _telemetry.PrepareDispatchLocalTurn(
+ dispatch.Request.Id,
+ dispatch.Request.SendRunId)
+ : null;
+
+ if (start.Snapshot is not null)
+ Publish(start.Snapshot);
+ if (dispatch is not null)
+ _ = DispatchQueuedSendAsync(
+ dispatch,
+ queueCompletion,
+ rethrow: false);
+ else if (start.DelayedRetry is { } delay)
+ ScheduleQueuedSendDrain(threadId, delay);
}
- private (string? RunId, int Count, ChatTerminalEventDropReason? DroppedTerminalReason) UpdateActiveRunId(
- AgentEventInfo evt,
- string threadId)
+ private void ScheduleQueuedSendDrain(string threadId)
+ => ScheduleQueuedSendDrain(threadId, ChatSendQueuePolicy.DrainDelay);
+
+ private void ScheduleQueuedSendDrain(string threadId, TimeSpan delay)
{
- string? deferredAbortRunId = null;
- var deferredAbortCount = 0;
- ChatTerminalEventDropReason? droppedTerminalReason = null;
- ChatTelemetryTracker.PreparedTurnCompletion? turnCompletion = null;
-
- if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
- evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object &&
- evt.Data.TryGetProperty("phase", out var phaseProp))
+ if (!_state.TryScheduleQueueDrain(threadId))
+ return;
+
+ _ = Task.Run(async () =>
{
- var phase = phaseProp.GetString()?.ToLowerInvariant();
- lock (_gate)
+ try
{
- if (phase == "start")
- {
- _telemetry.ObserveLifecycleStart(
- threadId,
- evt.RunId,
- allowRemoteTurn: !_locallyInitiatedThreads.Contains(threadId) &&
- !_abortedThreads.Contains(threadId) &&
- !_pendingAbortCounts.ContainsKey(threadId));
- if (!string.IsNullOrEmpty(evt.RunId))
- {
- _activeRunIds[threadId] = evt.RunId;
- _activeRunStartSequences[threadId] = ++_lifecycleStartSequence;
-
- // Detect remote turn: if the turn was NOT locally initiated,
- // a remote client (e.g. gateway web UI) sent the message.
- // Fetch the last user message from history so it appears in
- // the timeline before the assistant response.
- if (!_locallyInitiatedThreads.Contains(threadId))
- {
- _ = FetchRemoteUserMessageAsync(threadId);
- }
-
- // Deferred abort: if user clicked stop before lifecycle.start,
- // fire chat.abort now that we have the runId.
- if (_pendingAbortCounts.TryGetValue(threadId, out var pendingCount) && pendingCount > 0)
- {
- _pendingAbortCounts.Remove(threadId);
- _abortedRunIds.Add(evt.RunId);
- deferredAbortRunId = evt.RunId;
- deferredAbortCount = pendingCount;
- Logger.Info($"[ABORT] Deferred abort fired — lifecycle.start arrived with runId='{evt.RunId}' for threadId='{threadId}' (pendingCount={pendingCount})");
- }
- }
- }
- else if (phase == "end" || phase == "error")
- {
- var wasAborted = !string.IsNullOrWhiteSpace(evt.RunId) &&
- _abortedRunIds.Contains(evt.RunId);
- turnCompletion = _telemetry.PrepareFinishByRunId(
- evt.RunId,
- phase == "error" ? ChatTelemetryOutcome.Failure : ChatTelemetryOutcome.Success,
- phase == "error"
- ? ChatTurnTelemetryReason.LifecycleError
- : ChatTurnTelemetryReason.LifecycleEnd);
- if (turnCompletion is null && !wasAborted)
- {
- droppedTerminalReason = string.IsNullOrWhiteSpace(evt.RunId)
- ? ChatTerminalEventDropReason.MissingRunId
- : ChatTerminalEventDropReason.MismatchedRunId;
- }
- // Clean up: remove aborted runId tracking on terminal events.
- if (!string.IsNullOrEmpty(evt.RunId))
- _abortedRunIds.Remove(evt.RunId);
- _activeRunIds.Remove(threadId);
- _activeRunStartSequences.Remove(threadId);
-
- // Clear thread-level abort suppression on terminal lifecycle events.
- // The turn is over — any remaining abort suppression is no longer needed.
- _abortedThreads.Remove(threadId);
- // Clear locally-initiated flag only when no locally queued
- // follow-up prompts remain for this thread. Multiple rapid
- // sends can queue runs behind the current one; treating the
- // next lifecycle.start as remote would orphan those queued
- // cards and let assistant fallback promote the wrong item.
- RemoveQueuedRunMappingByRunIdLocked(threadId, evt.RunId);
- if (!HasPendingQueuedMessagesLocked(threadId))
- _locallyInitiatedThreads.Remove(threadId);
-
- // Edge case: if we have pending aborts but never saw lifecycle.start
- // (gateway responded so fast start+end were batched), fire the
- // deferred abort now so the persist still runs.
- if (_pendingAbortCounts.TryGetValue(threadId, out var lateCount) && lateCount > 0)
- {
- _pendingAbortCounts.Remove(threadId);
- deferredAbortRunId = evt.RunId; // may be null, that's ok — persist doesn't need it
- deferredAbortCount = lateCount;
- Logger.Info($"[ABORT] Late deferred abort — lifecycle.end arrived with pending aborts for threadId='{threadId}' (pendingCount={lateCount})");
- }
- }
+ await Task.Delay(delay).ConfigureAwait(false);
}
- }
- // Also catch lifecycle via legacy job stream.
- else if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) &&
- evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object &&
- evt.Data.TryGetProperty("state", out var stateProp))
- {
- var state = stateProp.GetString()?.ToLowerInvariant();
- lock (_gate)
+ finally
{
- if (state == "done" || state == "error")
- {
- var wasAborted = !string.IsNullOrWhiteSpace(evt.RunId) &&
- _abortedRunIds.Contains(evt.RunId);
- turnCompletion = _telemetry.PrepareFinishByRunId(
- evt.RunId,
- state == "error" ? ChatTelemetryOutcome.Failure : ChatTelemetryOutcome.Success,
- state == "error"
- ? ChatTurnTelemetryReason.LifecycleError
- : ChatTurnTelemetryReason.LifecycleEnd);
- if (turnCompletion is null && !wasAborted)
- {
- droppedTerminalReason = string.IsNullOrWhiteSpace(evt.RunId)
- ? ChatTerminalEventDropReason.MissingRunId
- : ChatTerminalEventDropReason.MismatchedRunId;
- }
- if (!string.IsNullOrWhiteSpace(evt.RunId))
- {
- _abortedRunIds.Remove(evt.RunId);
- RemoveQueuedRunMappingByRunIdLocked(threadId, evt.RunId);
- }
- _activeRunIds.Remove(threadId);
- _activeRunStartSequences.Remove(threadId);
- }
+ _state.CompleteQueueDrainSchedule(threadId);
}
- }
-
- _telemetry.CompletePreparedTurn(turnCompletion);
- return (deferredAbortRunId, deferredAbortCount, droppedTerminalReason);
- }
- private bool ShouldDropTerminalAgentEventLocked(
- AgentEventInfo evt,
- string threadId,
- out ChatTerminalEventDropReason? droppedTerminalReason)
- {
- droppedTerminalReason = null;
- if (!TryGetTerminalAgentRunId(evt, out var runId))
- return false;
- if (string.IsNullOrWhiteSpace(runId))
- {
- droppedTerminalReason = ChatTerminalEventDropReason.MissingRunId;
- return true;
- }
-
- if (_terminalRunIdsByThread.TryGetValue(threadId, out var terminalRunIds) &&
- terminalRunIds.Contains(runId, StringComparer.Ordinal))
- {
- return true;
- }
-
- if (_activeRunIds.TryGetValue(threadId, out var activeRunId) &&
- !string.Equals(activeRunId, runId, StringComparison.Ordinal))
- {
- droppedTerminalReason = ChatTerminalEventDropReason.MismatchedRunId;
- return true;
- }
-
- if (!_activeRunIds.ContainsKey(threadId) &&
- _queuedMessageIdsByRunId.TryGetValue(threadId, out var queuedRunIds) &&
- queuedRunIds.Count > 0 &&
- !queuedRunIds.ContainsKey(runId) &&
- _timelines.TryGetValue(threadId, out var timeline) &&
- timeline.TurnActive)
- {
- droppedTerminalReason = ChatTerminalEventDropReason.MismatchedRunId;
- return true;
- }
-
- RememberTerminalRunIdLocked(threadId, runId);
- return false;
- }
-
- private void RecordDroppedTerminalEvent(ChatTerminalEventDropReason reason)
- {
- _telemetry.RecordDroppedTerminalEvent(reason);
- Logger.Warn(
- $"[ChatTelemetry] Dropped terminal chat event because safe run correlation was unavailable " +
- $"(reason='{ChatTelemetryTracker.ToTelemetryValue(reason)}').");
- }
-
- private void RememberTerminalRunIdLocked(string threadId, string runId)
- {
- if (!_terminalRunIdsByThread.TryGetValue(threadId, out var terminalRunIds))
- {
- terminalRunIds = new List();
- _terminalRunIdsByThread[threadId] = terminalRunIds;
- }
-
- terminalRunIds.RemoveAll(existing => string.Equals(existing, runId, StringComparison.Ordinal));
- terminalRunIds.Add(runId);
- if (terminalRunIds.Count > 64)
- terminalRunIds.RemoveRange(0, terminalRunIds.Count - 64);
- }
-
- private static bool TryGetTerminalAgentRunId(AgentEventInfo evt, out string runId)
- {
- runId = evt.RunId ?? string.Empty;
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object)
- return false;
-
- if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
- evt.Data.TryGetProperty("phase", out var phaseProp))
- {
- var phase = phaseProp.GetString();
- return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(phase, "error", StringComparison.OrdinalIgnoreCase);
- }
-
- if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) &&
- evt.Data.TryGetProperty("state", out var stateProp))
- {
- var state = stateProp.GetString();
- return string.Equals(state, "done", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(state, "error", StringComparison.OrdinalIgnoreCase);
- }
-
- return false;
- }
-
- private bool TryConsumeLocalEchoLocked(string threadId, Queue queue, string text, out string queuedMessageId)
- {
- queuedMessageId = string.Empty;
- var now = DateTimeOffset.UtcNow;
- while (queue.Count > 0 && now - queue.Peek().SentAt > LocalEchoSuppressionWindow)
- queue.Dequeue();
-
- if (queue.Count == 0)
- {
- _localSentTexts.Remove(threadId);
- return false;
- }
-
- var matched = false;
- string matchedMessageId = string.Empty;
- var pendingEchoes = queue.ToArray();
- queue.Clear();
- foreach (var pending in pendingEchoes)
- {
- if (matched || !string.Equals(pending.Text, text, StringComparison.Ordinal))
- continue;
-
- queuedMessageId = pending.QueuedMessageId;
- matchedMessageId = pending.QueuedMessageId;
- matched = true;
- }
-
- var kept = new Queue(pendingEchoes.Length);
- if (!matched)
- {
- foreach (var pending in pendingEchoes)
- kept.Enqueue(pending);
- StoreLocalEchoQueueLocked(threadId, kept);
- return false;
- }
-
- foreach (var pending in pendingEchoes)
- {
- if (string.Equals(pending.QueuedMessageId, matchedMessageId, StringComparison.Ordinal))
- continue;
-
- kept.Enqueue(pending);
- }
-
- StoreLocalEchoQueueLocked(threadId, kept);
- return true;
- }
-
- private void StoreLocalEchoQueueLocked(string threadId, Queue queue)
- {
- if (queue.Count == 0)
- _localSentTexts.Remove(threadId);
- else
- _localSentTexts[threadId] = queue;
- }
-
- private bool TryReconcileExistingLocalQueuedUserEchoLocked(
- string threadId,
- string text,
- ChatEntryMetadata confirmedMeta)
- {
- if (!HasGatewayIdentity(confirmedMeta))
- return false;
- if (!_entryMeta.TryGetValue(threadId, out var threadMeta) ||
- !_timelines.TryGetValue(threadId, out var timeline))
- return false;
-
- foreach (var entry in timeline.Entries)
- {
- if (entry.Kind != ChatTimelineItemKind.User)
- continue;
- if (!string.Equals(entry.Text, text, StringComparison.Ordinal))
- continue;
- if (!threadMeta.TryGetValue(entry.Id, out var existing) || !existing.IsLocalQueuedSend)
- continue;
- if (HasGatewayIdentity(existing))
- continue;
- if (!IsFreshLocalQueuedPromotion(existing, confirmedMeta))
- continue;
-
- threadMeta[entry.Id] = confirmedMeta with
- {
- IsLocalQueuedSend = false,
- LocalQueuedMessageId = existing.LocalQueuedMessageId,
- };
- return true;
- }
-
- return false;
- }
-
- private static bool HasGatewayIdentity(ChatEntryMetadata meta)
- => !string.IsNullOrEmpty(meta.GatewayMessageId) || meta.OpenClawSeq is not null;
-
- private static bool IsFreshLocalQueuedPromotion(ChatEntryMetadata existing, ChatEntryMetadata confirmed)
- {
- if (existing.Timestamp is not { } existingTimestamp)
- return false;
- if (confirmed.Timestamp is { } confirmedTimestamp)
- return (confirmedTimestamp - existingTimestamp).Duration() <= LocalEchoSuppressionWindow;
-
- return DateTimeOffset.Now - existingTimestamp <= LocalEchoSuppressionWindow;
- }
-
- private void AddQueuedMessageLocked(string threadId, ChatQueuedMessage message)
- {
- if (!_queuedMessages.TryGetValue(threadId, out var list))
- {
- list = new List();
- _queuedMessages[threadId] = list;
- }
-
- list.RemoveAll(existing => existing.Id == message.Id);
- list.Add(message);
- }
-
- private void AddQueuedSendRequestLocked(QueuedSendRequest request)
- {
- if (!_queuedSendRequests.TryGetValue(request.ThreadId, out var list))
- {
- list = new List();
- _queuedSendRequests[request.ThreadId] = list;
- }
-
- list.RemoveAll(existing => existing.Id == request.Id);
- list.Add(request);
- }
-
- private void RemoveQueuedSendRequestLocked(string threadId, string messageId)
- {
- if (!_queuedSendRequests.TryGetValue(threadId, out var list))
- return;
-
- list.RemoveAll(request => request.Id == messageId);
- if (list.Count == 0)
- _queuedSendRequests.Remove(threadId);
- }
-
- private QueuedSendRequest? FindQueuedSendRequestLocked(string threadId, string messageId)
- {
- if (!_queuedSendRequests.TryGetValue(threadId, out var list))
- return null;
-
- return list.FirstOrDefault(request => string.Equals(request.Id, messageId, StringComparison.Ordinal));
- }
-
- private bool CanSendDirectlyLocked(string threadId)
- {
- if (_activeRunIds.ContainsKey(threadId))
- return false;
- if (_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive)
- return false;
- return !HasPendingQueuedMessagesLocked(threadId);
- }
-
- private bool CanClearAssistantFallbackPromotionLocked(string threadId)
- {
- if (HasSendingQueuedMessagesLocked(threadId))
- return false;
- if (_activeRunIds.ContainsKey(threadId))
- return false;
- return !_timelines.TryGetValue(threadId, out var timeline) || !timeline.TurnActive;
- }
-
- private QueuedSendDispatch StartDirectSendLocked(QueuedSendRequest request)
- {
- var threadId = request.ThreadId;
- var resetVersion = GetResetVersionLocked(threadId);
- var startedLifecycleSequence = _resetLifecycleStartSequence;
- var startedRunStartSequence = _lifecycleStartSequence;
- var current = GetOrCreateTimelineLocked(threadId);
- var entryId = $"e{current.NextId}";
- _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, request.DisplayText, request.LocalNonce);
- GetOrCreateThreadMetaLocked(threadId)[entryId] = BuildLiveMetaLocked(
- threadId,
- isLocalQueuedSend: true,
- localQueuedMessageId: request.Id);
- _sessionIds.TryGetValue(threadId, out var sessionId);
-
- EnqueueLocalEchoLocked(threadId, request.Text, request.Id);
- _locallyInitiatedThreads.Add(threadId);
- _assistantFallbackPromotedThreads.Add(threadId);
- var queueCompletion = _telemetry.PrepareDispatchLocalTurn(request.Id, request.SendRunId);
- return new QueuedSendDispatch(
- request,
- sessionId,
- resetVersion,
- startedLifecycleSequence,
- startedRunStartSequence,
- queueCompletion,
- StartedDirectly: true);
- }
-
- private QueuedSendDispatch? TryStartNextQueuedSendLocked(
- string threadId,
- bool requireConnected,
- out TimeSpan? delayedRetry)
- {
- delayedRetry = null;
- if (requireConnected && _status != ConnectionStatus.Connected)
- return null;
- if (_activeRunIds.ContainsKey(threadId))
- return null;
- if (_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive)
- return null;
- if (HasSendingQueuedMessagesLocked(threadId))
- return null;
- if (!_queuedMessages.TryGetValue(threadId, out var queuedMessages))
- return null;
-
- for (var i = 0; i < queuedMessages.Count; i++)
- {
- if (queuedMessages[i].SendState != ChatQueuedMessageSendState.Queued)
- continue;
-
- var request = FindQueuedSendRequestLocked(threadId, queuedMessages[i].Id);
- if (request is null)
- continue;
-
- var now = DateTimeOffset.UtcNow;
- if (request.DeferredAdmissionRetryAfter is { } retryAfter)
- {
- if (retryAfter > now)
- {
- delayedRetry = retryAfter - now;
- return null;
- }
-
- request = request with { DeferredAdmissionRetryAfter = null };
- AddQueuedSendRequestLocked(request);
- }
-
- // Each dispatched prompt gets one opportunity for assistant-frame
- // fallback promotion before its lifecycle/user echo arrives.
- _assistantFallbackPromotedThreads.Remove(threadId);
- queuedMessages[i] = queuedMessages[i] with { SendState = ChatQueuedMessageSendState.Sending, ErrorText = null };
- var resetVersion = GetResetVersionLocked(threadId);
- var startedLifecycleSequence = _resetLifecycleStartSequence;
- var startedRunStartSequence = _lifecycleStartSequence;
- _sessionIds.TryGetValue(threadId, out var sessionId);
-
- ChatTelemetryTracker.QueuePhaseCompletion? queueCompletion = null;
- if (request.LifecycleCommand is null)
- {
- _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(GetOrCreateTimelineLocked(threadId));
- EnqueueLocalEchoLocked(threadId, request.Text, request.Id);
- _locallyInitiatedThreads.Add(threadId);
- queueCompletion = _telemetry.PrepareDispatchLocalTurn(request.Id, request.SendRunId);
- }
- return new QueuedSendDispatch(
- request,
- sessionId,
- resetVersion,
- startedLifecycleSequence,
- startedRunStartSequence,
- queueCompletion,
- StartedDirectly: false);
- }
-
- return null;
- }
-
- private void EnqueueLocalEchoLocked(string threadId, string text, string messageId)
- {
- RemovePendingLocalEchoLocked(threadId, messageId);
- if (!_localSentTexts.TryGetValue(threadId, out var localEchoQueue))
- {
- localEchoQueue = new Queue();
- _localSentTexts[threadId] = localEchoQueue;
- }
-
- localEchoQueue.Enqueue(new LocalSentText(text, DateTimeOffset.UtcNow, messageId));
- while (localEchoQueue.Count > 20)
- localEchoQueue.Dequeue();
- }
-
- private void TryDispatchNextQueuedSend(string threadId)
- {
- ChatDataSnapshot? snapshot = null;
- QueuedSendDispatch? dispatch;
- TimeSpan? delayedRetry;
- lock (_gate)
- {
- if (_disposed)
- return;
- dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: true, out delayedRetry);
- if (dispatch is not null)
- snapshot = BuildSnapshotLocked();
- }
-
- if (snapshot is not null)
- Publish(snapshot);
- if (dispatch is not null)
- _ = DispatchQueuedSendAsync(dispatch, rethrow: false);
- else if (delayedRetry is { } delay)
- ScheduleQueuedSendDrain(threadId, delay);
- }
-
- private void ScheduleQueuedSendDrain(string threadId)
- => ScheduleQueuedSendDrain(threadId, DeferredQueueDrainDelay);
-
- private void ScheduleQueuedSendDrain(string threadId, TimeSpan delay)
- {
- lock (_gate)
- {
- if (_disposed || !_queuedMessages.ContainsKey(threadId))
- return;
- if (!_queuedDrainScheduledThreads.Add(threadId))
- return;
- }
-
- _ = Task.Run(async () =>
- {
- try
- {
- await Task.Delay(delay).ConfigureAwait(false);
- }
- finally
- {
- lock (_gate)
- {
- _queuedDrainScheduledThreads.Remove(threadId);
- }
- }
-
- try
- {
- TryDispatchNextQueuedSend(threadId);
- }
- catch (Exception ex)
- {
- Logger.Warn($"[Queue] Scheduled queued send drain failed for threadId='{threadId}': {ex.Message}");
- }
- });
- }
-
- private static bool IsDeferredAdmissionStatus(string? status) =>
- string.Equals(status, "in_flight", StringComparison.OrdinalIgnoreCase);
-
- private static ChatAdmissionTelemetryStatus MapAdmissionTelemetryStatus(ChatSendResult result)
- {
- if (IsDeferredAdmissionStatus(result.Status))
- return ChatAdmissionTelemetryStatus.Deferred;
- if (result.IsTerminalFailure)
- {
- return IsCanceledAdmissionStatus(result.Status)
- ? ChatAdmissionTelemetryStatus.Canceled
- : ChatAdmissionTelemetryStatus.Rejected;
- }
- if (string.IsNullOrWhiteSpace(result.Status) ||
- string.Equals(result.Status, "started", StringComparison.OrdinalIgnoreCase))
- {
- return ChatAdmissionTelemetryStatus.Accepted;
- }
- return ChatAdmissionTelemetryStatus.Other;
- }
-
- private static bool IsCanceledAdmissionStatus(string? status) =>
- string.Equals(status, "aborted", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(status, "cancelled", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(status, "canceled", StringComparison.OrdinalIgnoreCase);
-
- private static TimeSpan DeferredAdmissionRetryDelay(int retryCount)
- {
- var exponent = Math.Min(Math.Max(retryCount - 1, 0), 5);
- var delayMs = DeferredQueueDrainDelay.TotalMilliseconds * (1 << exponent);
- return TimeSpan.FromMilliseconds(Math.Min(delayMs, MaxDeferredAdmissionRetryDelay.TotalMilliseconds));
- }
-
- private void TrackQueuedMessageRunLocked(string threadId, string runId, string messageId)
- {
- if (!_queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId))
- {
- byRunId = new Dictionary(StringComparer.Ordinal);
- _queuedMessageIdsByRunId[threadId] = byRunId;
- }
-
- byRunId[runId] = messageId;
- }
-
- private bool RemoveQueuedMessageLocked(string threadId, string messageId)
- {
- if (!_queuedMessages.TryGetValue(threadId, out var list))
- return false;
-
- var removed = list.RemoveAll(message => message.Id == messageId) > 0;
- if (removed)
- {
- RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId);
- RemoveQueuedSendRequestLocked(threadId, messageId);
- }
- if (list.Count == 0)
- {
- _queuedMessages.Remove(threadId);
- ClearQueuedDrainScheduleLocked(threadId);
- ClearLocallyInitiatedIfIdleLocked(threadId);
- }
- return removed;
- }
-
- private bool CancelQueuedMessageLocked(string threadId, string messageId)
- {
- if (!_queuedMessages.TryGetValue(threadId, out var list))
- return false;
-
- var index = list.FindIndex(message => string.Equals(message.Id, messageId, StringComparison.Ordinal));
- if (index < 0)
- return false;
-
- if (list[index].SendState == ChatQueuedMessageSendState.Sending)
- return false;
-
- list.RemoveAt(index);
- RemovePendingLocalEchoLocked(threadId, messageId);
- RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId);
- RemoveQueuedSendRequestLocked(threadId, messageId);
- if (list.Count == 0)
- {
- _queuedMessages.Remove(threadId);
- ClearQueuedDrainScheduleLocked(threadId);
- ClearLocallyInitiatedIfIdleLocked(threadId);
- }
- return true;
- }
-
- private void ClearQueuedDrainScheduleLocked(string threadId)
- => _queuedDrainScheduledThreads.Remove(threadId);
-
- private bool PromoteQueuedMessageLocked(
- string threadId,
- string messageId,
- ChatEntryMetadata? confirmedMeta = null)
- {
- if (!_queuedMessages.TryGetValue(threadId, out var list))
- return false;
-
- var index = list.FindIndex(message => message.Id == messageId);
- if (index < 0)
- return false;
-
- var queued = list[index];
- var current = GetOrCreateTimelineLocked(threadId);
- var entryId = $"e{current.NextId}";
- _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, queued.Text, queued.LocalNonce);
-
- var hasGatewayIdentity = confirmedMeta is not null && HasGatewayIdentity(confirmedMeta);
- var meta = hasGatewayIdentity
- ? confirmedMeta! with { IsLocalQueuedSend = false, LocalQueuedMessageId = messageId }
- : BuildLiveMetaLocked(
- threadId,
- isLocalQueuedSend: true,
- localQueuedMessageId: messageId);
- var threadMeta = GetOrCreateThreadMetaLocked(threadId);
- threadMeta[entryId] = meta;
-
- list.RemoveAt(index);
- _assistantFallbackPromotedThreads.Add(threadId);
- RemoveQueuedSendRequestLocked(threadId, messageId);
- if (list.Count == 0)
- {
- _queuedMessages.Remove(threadId);
- ClearQueuedDrainScheduleLocked(threadId);
- }
- return true;
- }
-
- private void ClearLocallyInitiatedIfIdleLocked(string threadId)
- {
- if (_activeRunIds.ContainsKey(threadId))
- return;
- if (_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive)
- return;
- if (HasPendingQueuedMessagesLocked(threadId))
- return;
-
- _locallyInitiatedThreads.Remove(threadId);
- }
-
- private bool ReconcileQueuedMessageEchoLocked(
- string threadId,
- string messageId,
- ChatEntryMetadata confirmedMeta)
- {
- if (PromoteQueuedMessageLocked(threadId, messageId, confirmedMeta))
- return true;
- if (!HasGatewayIdentity(confirmedMeta) ||
- !_entryMeta.TryGetValue(threadId, out var threadMeta))
- {
- return false;
- }
-
- string? matchedEntryId = null;
- foreach (var (entryId, existing) in threadMeta)
- {
- if (!string.Equals(existing.LocalQueuedMessageId, messageId, StringComparison.Ordinal))
- continue;
- matchedEntryId = entryId;
- break;
- }
-
- if (matchedEntryId is null)
- return false;
-
- threadMeta[matchedEntryId] = confirmedMeta with
- {
- IsLocalQueuedSend = false,
- LocalQueuedMessageId = messageId,
- };
- return true;
- }
-
- private void RemoveQueuedRunMappingByMessageIdLocked(string threadId, string messageId)
- {
- if (!_queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId))
- return;
-
- foreach (var runId in byRunId.Where(kvp => kvp.Value == messageId).Select(kvp => kvp.Key).ToArray())
- byRunId.Remove(runId);
-
- if (byRunId.Count == 0)
- _queuedMessageIdsByRunId.Remove(threadId);
- }
-
- private void RemoveQueuedRunMappingByRunIdLocked(string threadId, string runId)
- {
- if (!_queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId))
- return;
-
- if (byRunId.TryGetValue(runId, out var messageId))
- {
- foreach (var aliasRunId in byRunId.Where(kvp => kvp.Value == messageId).Select(kvp => kvp.Key).ToArray())
- byRunId.Remove(aliasRunId);
- }
- else
- {
- byRunId.Remove(runId);
- }
-
- if (byRunId.Count == 0)
- _queuedMessageIdsByRunId.Remove(threadId);
- }
-
- private void MarkQueuedMessageFailedLocked(string threadId, string messageId, string error)
- {
- if (!_queuedMessages.TryGetValue(threadId, out var list))
- return;
-
- for (var i = 0; i < list.Count; i++)
- {
- if (list[i].Id == messageId)
- {
- list[i] = list[i] with
- {
- SendState = ChatQueuedMessageSendState.Failed,
- ErrorText = error
- };
- return;
- }
- }
- }
-
- private bool RequeueDeferredAdmissionLocked(string threadId, string messageId, out TimeSpan retryDelay)
- {
- retryDelay = DeferredQueueDrainDelay;
- var hasActiveRun = _activeRunIds.ContainsKey(threadId);
- if (!_queuedMessages.TryGetValue(threadId, out var list))
- return false;
-
- for (var i = 0; i < list.Count; i++)
- {
- if (list[i].Id != messageId ||
- list[i].SendState != ChatQueuedMessageSendState.Sending)
- {
- continue;
- }
-
- var retryCount = IncrementDeferredAdmissionRetryCountLocked(threadId, messageId);
- if (retryCount > MaxDeferredAdmissionRetries)
- {
- throw new InvalidOperationException(
- $"Gateway kept chat.send status in_flight after {MaxDeferredAdmissionRetries} retries.");
- }
-
- list[i] = list[i] with
- {
- SendState = ChatQueuedMessageSendState.Queued,
- ErrorText = null
- };
- retryDelay = DeferredAdmissionRetryDelay(retryCount);
- SetDeferredAdmissionRetryAfterLocked(threadId, messageId, DateTimeOffset.UtcNow + retryDelay);
- _assistantFallbackPromotedThreads.Remove(threadId);
- if (!hasActiveRun)
- {
- _timelines[threadId] = ChatTimelineReducer.Apply(
- GetOrCreateTimelineLocked(threadId),
- new ChatTurnEndEvent());
- }
- return true;
- }
-
- return false;
- }
-
- private void SetDeferredAdmissionRetryAfterLocked(string threadId, string messageId, DateTimeOffset retryAfter)
- {
- if (!_queuedSendRequests.TryGetValue(threadId, out var requests))
- return;
-
- for (var i = 0; i < requests.Count; i++)
- {
- if (!string.Equals(requests[i].Id, messageId, StringComparison.Ordinal))
- continue;
-
- requests[i] = requests[i] with { DeferredAdmissionRetryAfter = retryAfter };
- return;
- }
- }
-
- private int IncrementDeferredAdmissionRetryCountLocked(string threadId, string messageId)
- {
- if (!_queuedSendRequests.TryGetValue(threadId, out var requests))
- return MaxDeferredAdmissionRetries + 1;
-
- for (var i = 0; i < requests.Count; i++)
- {
- if (!string.Equals(requests[i].Id, messageId, StringComparison.Ordinal))
- continue;
-
- var retryCount = requests[i].DeferredAdmissionRetryCount + 1;
- requests[i] = requests[i] with { DeferredAdmissionRetryCount = retryCount };
- return retryCount;
- }
-
- return MaxDeferredAdmissionRetries + 1;
- }
-
- private void ClearQueuedMessageOnLocalTurnStart(AgentEventInfo evt, string threadId)
- {
- if (!IsLifecycleStart(evt))
- return;
-
- ChatDataSnapshot? snapshot = null;
- lock (_gate)
- {
- if (TryPromoteQueuedMessageOnLocalTurnStartLocked(evt, threadId))
- snapshot = BuildSnapshotLocked();
- }
-
- if (snapshot is not null)
- {
- Publish(snapshot);
- }
- }
-
- private bool TryPromoteQueuedMessageOnLocalTurnStartLocked(AgentEventInfo evt, string threadId)
- {
- if (!_locallyInitiatedThreads.Contains(threadId))
- return false;
-
- var runId = evt.RunId;
- if (!string.IsNullOrEmpty(runId) &&
- _queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId) &&
- byRunId.TryGetValue(runId, out var queuedMessageId))
- {
- return PromoteQueuedMessageLocked(threadId, queuedMessageId);
- }
-
- if (string.IsNullOrEmpty(runId) &&
- TryGetSingleSendingQueuedMessageLocked(threadId, out var queued))
- {
- return PromoteQueuedMessageLocked(threadId, queued.Id);
- }
-
- return false;
- }
-
- private void RemovePendingLocalEchoLocked(string threadId, string messageId)
- {
- if (!_localSentTexts.TryGetValue(threadId, out var queue))
- return;
-
- var kept = new Queue(queue.Count);
- while (queue.Count > 0)
- {
- var pending = queue.Dequeue();
- if (string.Equals(pending.QueuedMessageId, messageId, StringComparison.Ordinal))
- continue;
-
- kept.Enqueue(pending);
- }
-
- StoreLocalEchoQueueLocked(threadId, kept);
- }
-
- ///
- /// Fetch the latest user message from history for a remotely-initiated turn.
- /// Called when lifecycle.start arrives for a thread we didn't locally initiate.
- ///
- private async Task FetchRemoteUserMessageAsync(string threadId, bool openResetGateOnSuccess = false)
- {
- var telemetryReason = openResetGateOnSuccess
- ? ChatBackfillTelemetryReason.ResetReconciliation
- : ChatBackfillTelemetryReason.RemoteTurn;
- var historyOperation = _telemetry.StartHistoryBackfill(telemetryReason);
- var historyOutcome = ChatTelemetryOutcome.Success;
- Exception? historyException = null;
- long requestResetVersion;
- long resetCutoffUtcMs;
- lock (_gate)
- {
- requestResetVersion = GetResetVersionLocked(threadId);
- resetCutoffUtcMs = GetResetCutoffUtcMsLocked(threadId);
- }
-
- try
- {
- var history = await _bridge.RequestChatHistoryAsync(threadId);
- if (history?.Messages is null || history.Messages.Count == 0) return;
-
- // Find the last user message in history.
- ChatMessageInfo? lastUser = null;
- for (int i = history.Messages.Count - 1; i >= 0; i--)
- {
- var role = (history.Messages[i].Role ?? "").ToLowerInvariant();
- var hText = history.Messages[i].Text;
- if (role == "user"
- && !NativeToolProjector.LooksLikeSystemControlNote(hText)
- && !LooksLikeApprovalSlashCommand(hText))
- {
- lastUser = history.Messages[i];
- break;
- }
- }
- if (lastUser is null || string.IsNullOrEmpty(lastUser.Text)) return;
-
- ChatDataSnapshot? snapshotToPublish = null;
-
- // Check if we already have this user message as the last User entry
- // in the timeline (avoid duplicates on reconnect/reload).
- lock (_gate)
- {
- if (GetResetVersionLocked(threadId) != requestResetVersion ||
- IsPreResetTimestampLocked(threadId, lastUser.Ts, resetCutoffUtcMs))
- {
- Logger.Info($"[REMOTE] Ignoring stale remote user backfill after reset for threadId='{threadId}'");
- return;
- }
-
- if (_timelines.TryGetValue(threadId, out var tl))
- {
- for (int i = tl.Entries.Count - 1; i >= 0; i--)
- {
- if (tl.Entries[i].Kind == ChatTimelineItemKind.User)
- {
- if (tl.Entries[i].Text == lastUser.Text)
- return; // already displayed
- break;
- }
- }
- }
-
- if (openResetGateOnSuccess)
- {
- _resetRemoteUserSeen.Add(threadId);
- TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null);
- }
-
- var meta = BuildLiveMetaLocked(
- threadId,
- lastUser.Ts,
- lastUser.OpenClawId,
- lastUser.OpenClawSeq);
- snapshotToPublish = ApplyEventLocked(
- threadId,
- new ChatUserMessageEvent(TruncateForChatEntry(lastUser.Text)),
- meta);
- }
-
- Publish(snapshotToPublish);
- Logger.Info($"[REMOTE] Injected remote user message for threadId='{threadId}' len={lastUser.Text.Length}");
- }
- catch (Exception ex)
- {
- historyOutcome = ex is OperationCanceledException
- ? ChatTelemetryOutcome.Canceled
- : ChatTelemetryOutcome.Failure;
- historyException = ex;
- Logger.Warn($"[REMOTE] Failed to fetch remote user message for threadId='{threadId}': {ex.Message}");
- }
- finally
- {
- if (openResetGateOnSuccess)
- {
- lock (_gate) { _resetRemoteBackfillInFlight.Remove(threadId); }
- }
- _telemetry.FinishHistoryBackfill(historyOperation, historyOutcome, historyException);
- }
- }
-
- private ChatEvent? MapAgentEvent(AgentEventInfo evt)
- {
- var stream = evt.Stream?.ToLowerInvariant();
- if (string.IsNullOrEmpty(stream)) return null;
-
- switch (stream)
- {
- case "assistant":
- return MapAssistantEvent(evt);
- case "reasoning":
- return MapReasoningEvent(evt);
- case "lifecycle":
- return MapLifecycleEvent(evt);
- case "tool":
- // Spec name; gateway 2026.4.x uses ``item`` (kind=tool) instead.
- return MapToolEvent(evt);
- case "item":
- // Verified live shape: stream="item", data.kind ∈
- // {"tool","command","reasoning","message"}, data.phase ∈
- // {"start","end"}, data.title/itemId/details. We surface
- // tool items as chips and ignore the redundant command
- // children (their output arrives on ``command_output``).
- return MapItemEvent(evt);
- case "command_output":
- // Shell command stdout/stderr — attach to the active tool
- // chip as its ``Tool output`` body.
- return MapCommandOutputEvent(evt);
- case "job":
- return MapJobEvent(evt);
- case "approval":
- return MapApprovalEvent(evt);
- default:
- return null;
- }
- }
-
- // Whitelist of approval phases that mean "the approval is finished and
- // the banner should go away". Anything outside this set is treated as
- // an intermediate / unknown phase and leaves the banner alone — that
- // way a future gateway phase like ``acknowledged`` or ``in_progress``
- // can't accidentally wipe a live banner.
- private static bool IsTerminalApprovalPhase(string phase)
- {
- if (string.IsNullOrEmpty(phase)) return false;
- return string.Equals(phase, "resolved", System.StringComparison.OrdinalIgnoreCase)
- || string.Equals(phase, "denied", System.StringComparison.OrdinalIgnoreCase)
- || string.Equals(phase, "aborted", System.StringComparison.OrdinalIgnoreCase)
- || string.Equals(phase, "canceled", System.StringComparison.OrdinalIgnoreCase)
- || string.Equals(phase, "cancelled", System.StringComparison.OrdinalIgnoreCase)
- || string.Equals(phase, "expired", System.StringComparison.OrdinalIgnoreCase)
- || string.Equals(phase, "timeout", System.StringComparison.OrdinalIgnoreCase)
- || string.Equals(phase, "error", System.StringComparison.OrdinalIgnoreCase);
- }
-
- // Map a terminal approval phase (already validated by IsTerminalApprovalPhase)
- // to the timeline decision badge. ``resolved`` carries an allow-* decision
- // upstream (see OpenClawGatewayClient.HandleExecApprovalEvent), so it maps to
- // Allowed. ``denied`` maps to Denied. Every other terminal phase (aborted,
- // canceled/cancelled, expired, timeout, error) collapses to Expired — the
- // "decided elsewhere or never decided" badge.
- private static ChatPermissionDecision MapTerminalPhaseToDecision(string phase, string? decision = null)
- {
- if (string.Equals(phase, "resolved", System.StringComparison.OrdinalIgnoreCase))
- {
- if (string.Equals(decision, ChatPermissionActionKeys.AllowAlways, System.StringComparison.OrdinalIgnoreCase))
- return ChatPermissionDecision.AllowedAlways;
- if (string.Equals(decision, ChatPermissionActionKeys.Deny, System.StringComparison.OrdinalIgnoreCase))
- return ChatPermissionDecision.Denied;
- return ChatPermissionDecision.Allowed;
- }
- if (string.Equals(phase, "denied", System.StringComparison.OrdinalIgnoreCase))
- return ChatPermissionDecision.Denied;
- return ChatPermissionDecision.Expired;
- }
-
- // Approval dedupe: gateway can resend ``requested`` on reconnect/replay.
- // Bounded LRU to keep this from growing unbounded across a long session.
- //
- // Instance-scoped (not static) so the LRU is bound to a single
- // provider/connection lifetime. Static state would survive across a
- // disconnect+reconnect+new-provider cycle in tests and host scenarios,
- // and could silently drop a fresh approval whose ID collides with a
- // long-dead one from a prior run. ``ResetApprovalDedupe`` is also
- // invoked when we leave the Connected state so the next connection
- // starts clean.
- private readonly object _approvalSeenLock = new();
- private readonly System.Collections.Generic.LinkedList _approvalSeenOrder = new();
- private readonly System.Collections.Generic.HashSet _approvalSeen
- = new(System.StringComparer.Ordinal);
- // Capacity is counted by id, not by logical approval. Paired slug/UUID
- // approvals consume two entries, so 128 preserves the prior ~64-approval
- // dedupe window.
- private const int ApprovalSeenCap = 128;
-
- // Approval id-asymmetry tracking.
- // The gateway sometimes emits ``approvalSlug`` only on ``requested``
- // and the full ``approvalId`` only on terminal events (or vice versa).
- // We prefer slug on both sides for matching (see ``MapApprovalEvent``)
- // but record the alternate identifier here so a terminal event that
- // carries only the "other" id can still resolve back to the live
- // pending banner. Stored bidirectionally and bounded by ApprovalSeenCap
- // via the same trim loop.
- private readonly Dictionary _approvalAltIds = new(System.StringComparer.Ordinal);
-
- // Dedupe accepts both id forms (slug and full approvalId) so the same
- // approval doesn't render twice when two upstream paths surface it with
- // different ids — e.g. the top-level ``exec.approval.requested``
- // translator emits with the UUID while the agent-stream variant emits
- // with the shorter slug. If either form has been seen (or is already
- // linked to a seen form), suppress; when both forms are known, record the
- // link before suppressing so terminal events in either form can resolve.
- private bool MarkApprovalSeen(string requestId, string? altId = null)
- {
- if (string.IsNullOrEmpty(requestId)) return true; // can't dedupe — render
- lock (_approvalSeenLock)
- {
- RecordApprovalAltIdLocked(requestId, altId);
-
- if (ApprovalIdSeenLocked(requestId)) return false;
- if (IsDistinctApprovalId(requestId, altId) && ApprovalIdSeenLocked(altId!))
- {
- return false;
- }
-
- if (_approvalSeen.Add(requestId))
- {
- _approvalSeenOrder.AddLast(requestId);
- }
-
- if (IsDistinctApprovalId(requestId, altId) && _approvalSeen.Add(altId!))
- {
- _approvalSeenOrder.AddLast(altId!);
- }
-
- while (_approvalSeenOrder.Count > ApprovalSeenCap)
- {
- var oldest = _approvalSeenOrder.First!.Value;
- _approvalSeenOrder.RemoveFirst();
- EvictApprovalSeenIdLocked(oldest);
- }
- return true;
- }
- }
-
- private static bool IsDistinctApprovalId(string requestId, string? altId)
- => !string.IsNullOrEmpty(altId)
- && !string.Equals(altId, requestId, System.StringComparison.Ordinal);
-
- private bool ApprovalIdSeenLocked(string approvalId)
- {
- if (_approvalSeen.Contains(approvalId)) return true;
- return _approvalAltIds.TryGetValue(approvalId, out var altId)
- && _approvalSeen.Contains(altId);
- }
-
- private void RecordApprovalAltIdLocked(string requestId, string? altId)
- {
- if (!IsDistinctApprovalId(requestId, altId)) return;
-
- _approvalAltIds[requestId] = altId!;
- _approvalAltIds[altId!] = requestId;
- }
-
- private void EvictApprovalSeenIdLocked(string approvalId)
- {
- _approvalSeen.Remove(approvalId);
- if (!_approvalAltIds.TryGetValue(approvalId, out var altId))
- return;
-
- _approvalAltIds.Remove(approvalId);
- if (_approvalAltIds.TryGetValue(altId, out var reverse)
- && string.Equals(reverse, approvalId, System.StringComparison.Ordinal))
- {
- _approvalAltIds.Remove(altId);
- }
-
- if (_approvalSeen.Remove(altId))
- {
- RemoveApprovalSeenOrderValueLocked(altId);
- }
- }
-
- private void RemoveApprovalSeenOrderValueLocked(string approvalId)
- {
- for (var node = _approvalSeenOrder.First; node is not null; node = node.Next)
- {
- if (!string.Equals(node.Value, approvalId, System.StringComparison.Ordinal))
- continue;
-
- _approvalSeenOrder.Remove(node);
- return;
- }
- }
-
- private void ResetApprovalDedupe()
- {
- lock (_approvalSeenLock)
- {
- _approvalSeen.Clear();
- _approvalSeenOrder.Clear();
- _approvalAltIds.Clear();
- }
- }
-
- // Returns true if either of (evtPrimary, evtAlt) matches the pending
- // request — checking pendingId directly AND its recorded alternate id
- // (see ``_approvalAltIds`` above). All three inputs may be empty;
- // empty values never match.
- private bool ApprovalIdMatches(string pendingId, string evtPrimary, string evtAlt)
- {
- if (string.IsNullOrEmpty(pendingId)) return false;
-
- string? pendingAlt;
- lock (_approvalSeenLock)
- {
- _approvalAltIds.TryGetValue(pendingId, out pendingAlt);
- }
-
- bool Matches(string evt) =>
- !string.IsNullOrEmpty(evt)
- && (string.Equals(evt, pendingId, System.StringComparison.Ordinal)
- || (!string.IsNullOrEmpty(pendingAlt) && string.Equals(evt, pendingAlt, System.StringComparison.Ordinal)));
-
- return Matches(evtPrimary) || Matches(evtAlt);
- }
-
- // Render exec-approval prompts as a Permission-Request event so the
- // composer's existing Allow/Deny banner surfaces, matching the
- // dashboard modal's Allow once / Deny buttons. We only render on
- // phase=``requested``; ``resolved`` clears the banner via the
- // dedicated path in OnAgentEventReceived.
- //
- // Privacy: title/host/command are reflected back into the chat UI
- // (the user already sees them in the dashboard); no separate
- // telemetry log is emitted from this handler.
- private ChatEvent? MapApprovalEvent(AgentEventInfo evt)
- {
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
-
- static string SafeStr(System.Text.Json.JsonElement obj, string name)
- => obj.TryGetProperty(name, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String
- ? (v.GetString() ?? "")
- : "";
-
- var phase = SafeStr(evt.Data, "phase");
- if (!string.Equals(phase, "requested", System.StringComparison.OrdinalIgnoreCase))
- return null;
-
- var approvalId = SafeStr(evt.Data, "approvalId");
- var slug = SafeStr(evt.Data, "approvalSlug");
- var host = SafeStr(evt.Data, "host");
- var command = SafeStr(evt.Data, "command");
- var title = SafeStr(evt.Data, "title");
- var message = SafeStr(evt.Data, "message");
-
- // Prefer the short slug (matches dashboard "/approve " format).
- // Fall back to full UUID only if slug is missing.
- var requestId = !string.IsNullOrEmpty(slug) ? slug : approvalId;
- if (string.IsNullOrEmpty(requestId)) return null;
-
- // The alternate id (the one we didn't pick as requestId). Pass it
- // to MarkApprovalSeen so a duplicate emission from the sibling
- // upstream path (slug-form vs UUID-form for the same approval) is
- // suppressed instead of creating a second timeline entry, which
- // would mark the first as Expired via ApplyPermissionRequest.
- var altId = !string.IsNullOrEmpty(slug) ? approvalId : slug;
-
- if (!MarkApprovalSeen(requestId, altId))
- {
- Logger.Info($"[Approval] suppressed duplicate requestId={requestId} altId={altId}");
- return null;
- }
-
- // MarkApprovalSeen also records the alternate id, including on the
- // duplicate-suppression path, so terminal events in either id form
- // can resolve back to this pending banner.
-
- // PermissionKind is the short tool/category label the composer shows;
- // ToolName is the contextual subtitle (host); Detail is the body
- // (command + optional message).
- var permissionKind = !string.IsNullOrEmpty(title) ? title : "Exec approval";
- var toolName = !string.IsNullOrEmpty(host) ? host : "node";
-
- var detail = command;
- if (!string.IsNullOrEmpty(message))
- detail = string.IsNullOrEmpty(detail) ? message : message + "\n\n" + detail;
-
- Logger.Info($"[Approval] emitting ChatPermissionRequestEvent requestId={requestId} kind='{permissionKind}' tool='{toolName}' detail.len={detail.Length}");
- return new ChatPermissionRequestEvent(requestId, permissionKind, toolName, detail, ChatPermissionActionKeys.ExecApprovalDefaults);
- }
-
- private static ChatEvent? MapAssistantEvent(AgentEventInfo evt)
- {
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
-
- // Streaming token deltas: data.delta = "...next chunk..."
- if (evt.Data.TryGetProperty("delta", out var deltaProp) &&
- deltaProp.ValueKind == System.Text.Json.JsonValueKind.String)
- {
- var delta = deltaProp.GetString();
- if (!string.IsNullOrEmpty(delta))
- return new ChatMessageDeltaEvent(delta);
- }
-
- // NOTE: Cumulative `content`/`text` blocks are intentionally ignored
- // here — the gateway also fires a `chat.message` (role=assistant)
- // event carrying the same cumulative text, which OnChatMessageReceived
- // already maps to ChatMessageEvent. Honoring both paths produced two
- // identical assistant bubbles per turn (delta-bubble sealed by
- // lifecycle.end, then a fresh bubble from the chat.message arrival).
- return null;
- }
-
- private static ChatEvent? MapReasoningEvent(AgentEventInfo evt)
- {
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
-
- if (evt.Data.TryGetProperty("delta", out var deltaProp) &&
- deltaProp.ValueKind == System.Text.Json.JsonValueKind.String)
- {
- var delta = deltaProp.GetString();
- if (!string.IsNullOrEmpty(delta))
- {
- try { Logger.Trace($"[ReasoningStream] kind=delta len={delta.Length}"); } catch { }
- return new ChatReasoningDeltaEvent(delta);
- }
- }
-
- var contentText = evt.Data.TryGetProperty("content", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.String
- ? c.GetString()
- : (evt.Data.TryGetProperty("text", out var t) && t.ValueKind == System.Text.Json.JsonValueKind.String
- ? t.GetString()
- : null);
- if (!string.IsNullOrEmpty(contentText))
- {
- try { Logger.Trace($"[ReasoningStream] kind=full len={contentText!.Length}"); } catch { }
- return new ChatReasoningEvent(contentText!);
- }
-
- return null;
- }
-
- private static ChatEvent? MapLifecycleEvent(AgentEventInfo evt)
- {
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
- if (!evt.Data.TryGetProperty("phase", out var phaseProp)) return null;
- var phase = phaseProp.GetString()?.ToLowerInvariant();
-
- return phase switch
- {
- "start" => new ChatThinkingEvent(""),
- "end" => new ChatTurnEndEvent(),
- "error" => new ChatErrorEvent(evt.Summary
- ?? (evt.Data.TryGetProperty("message", out var m) ? m.GetString() ?? "Agent error" : "Agent error")),
- _ => null
- };
- }
-
- private static ChatEvent? MapToolEvent(AgentEventInfo evt)
- {
- // Expected payload shape: data.phase ∈ {"start","result","error"}, data.name, data.args
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
-
- var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : "";
- var identity = NativeToolProjector.ExtractToolIdentity(evt.Data);
- var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data);
- var label = NativeToolProjector.ExtractToolLabel(evt.Data, toolArgs);
- var toolCallId = evt.Data.TryGetProperty("itemId", out var idProp) ? idProp.GetString()
- : (evt.Data.TryGetProperty("callId", out var cProp) ? cProp.GetString() : null);
-
- return phase.ToLowerInvariant() switch
- {
- "start" => new ChatToolStartEvent(
- label,
- identity.Name,
- ToolArgs: toolArgs,
- ToolCallId: toolCallId,
- IdentityStrength: identity.Strength,
- RunId: evt.RunId),
- "result" => new ChatToolOutputEvent(
- NativeToolProjector.ExtractToolResultText(evt.Data, fallback: label),
- ToolCallId: toolCallId,
- RunId: evt.RunId),
- "error" => new ChatToolErrorEvent(
- NativeToolProjector.ExtractToolErrorText(evt.Data, fallback: label),
- ToolCallId: toolCallId,
- RunId: evt.RunId),
- _ => null
- };
- }
-
- ///
- /// Map ``stream: "item"`` agent events (the gateway's actual tool/command
- /// lifecycle channel as of 2026.4.x — distinct from the spec's ``"tool"``
- /// stream which has not been observed in the wild).
- ///
- /// Verified payload shape:
- ///
- /// {
- /// "stream": "item",
- /// "data": {
- /// "itemId": "tool:call_xxx|fc_yyy",
- /// "phase": "start" | "end",
- /// "kind": "tool" | "command" | "reasoning" | "message",
- /// "title": "exec run command openclaw → ..."
- /// }
- /// }
- ///
- ///
- /// Tool items create chips. Command children upgrade the parent chip with
- /// their specific identity and bounded safe display arguments.
- ///
- private static ChatEvent? MapItemEvent(AgentEventInfo evt)
- {
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
-
- var kind = evt.Data.TryGetProperty("kind", out var kindProp) ? kindProp.GetString() ?? "" : "";
- var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : "";
-
- // ``kind=reasoning`` brackets each distinct thinking pass the model
- // performs within a turn (model reasons → tool call → reasons again).
- // The reasoning prose itself arrives on ``stream:"reasoning"``; here
- // we only need the ``phase=end`` boundary so the timeline reducer can
- // close the active reasoning bubble. Without this signal consecutive
- // reasoning passes concatenate into a single ever-growing entry,
- // because ActiveReasoningId is otherwise only cleared on turn end.
- if (string.Equals(kind, "reasoning", StringComparison.OrdinalIgnoreCase))
- {
- try { Logger.Trace($"[ReasoningItem] phase={phase}"); } catch { }
- return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase)
- ? new ChatReasoningEndEvent()
- : null;
- }
-
- if (string.Equals(kind, "command", StringComparison.OrdinalIgnoreCase))
- {
- var normalizedPhase = phase.ToLowerInvariant();
- if (normalizedPhase is not ("start" or "update"))
- return null;
-
- var parentItemId = NativeToolProjector.ExtractParentToolCallId(evt.Data);
- if (string.IsNullOrWhiteSpace(parentItemId))
- return null;
-
- var childIdentity = NativeToolProjector.ExtractToolIdentity(evt.Data);
- var commandArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data);
- var childItemId = NativeToolProjector.GetStringProperty(evt.Data, "itemId", "commandItemId", "callId");
- return new ChatToolPresentationEvent(
- parentItemId,
- childIdentity.Name,
- childIdentity.Strength,
- commandArgs,
- childItemId,
- ActivatesTurn: normalizedPhase == "start",
- RunId: evt.RunId);
- }
-
- if (!string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase))
- return null;
-
- var title = NativeToolProjector.GetStringProperty(evt.Data, "title");
- var identity = NativeToolProjector.ExtractToolIdentity(evt.Data);
- var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data);
- var label = NativeToolProjector.FirstToolDisplayValue(toolArgs);
- if (string.IsNullOrWhiteSpace(label))
- label = NativeToolProjector.SanitizeToolDisplayValue(title);
- string? itemId = NativeToolProjector.GetStringProperty(evt.Data, "itemId", "callId");
- if (string.IsNullOrWhiteSpace(itemId))
- itemId = null;
-
- return phase.ToLowerInvariant() switch
- {
- "start" => new ChatToolStartEvent(
- label,
- identity.Name,
- ToolArgs: toolArgs,
- ToolCallId: itemId,
- IdentityStrength: identity.Strength,
- RunId: evt.RunId),
- // ``end`` flips the active tool's status to Success even when no
- // command_output arrived (e.g. ``read``, ``glob`` — non-shell).
- // Use the title as a no-op output so the reducer marks Success.
- "end" => new ChatToolOutputEvent(string.Empty, ToolCallId: itemId, RunId: evt.RunId),
- "error" => new ChatToolErrorEvent(
- NativeToolProjector.SanitizeToolDisplayValue(title),
- ToolCallId: itemId,
- RunId: evt.RunId),
- _ => null
- };
- }
-
- private static JsonObject? ConvertToolArgs(JsonElement? value)
- {
- if (value is not { ValueKind: JsonValueKind.Object } args)
- return null;
- return NativeToolProjector.ExtractSafeToolDisplayArgs(args);
- }
-
- private static string ToolLabel(string toolName, JsonObject? args)
- {
- foreach (var key in new[] { "command", "path", "file_path", "query", "url", "pattern" })
- {
- if (args?[key] is JsonValue value
- && value.TryGetValue(out var text)
- && !string.IsNullOrWhiteSpace(text))
- {
- return TruncateToolLabel(text);
- }
- }
-
- return toolName;
- }
-
- private static string TruncateToolLabel(string text)
- {
- if (text.Length <= 80)
- return text;
-
- var length = 77;
- if (char.IsHighSurrogate(text[length - 1]))
- length--;
- return text[..length] + "\u2026";
- }
-
- ///
- /// Map ``stream: "command_output"`` agent events. These carry shell
- /// stdout/stderr and may arrive in chunks (phase=delta) and as a final
- /// (phase=end) — we attach the text to the currently-active tool chip.
- ///
- private static ChatEvent? MapCommandOutputEvent(AgentEventInfo evt)
- {
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
-
- var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : "";
- // Only emit on ``end`` — accumulating deltas into the same chip
- // would require a new reducer event; the consolidated final
- // payload is enough to populate the body in one go.
- if (!string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase))
- return null;
-
- var output = NativeToolProjector.ExtractCommandOutputText(evt.Data);
- if (string.IsNullOrEmpty(output))
- return null;
-
- // command_output events may carry an itemId or parentItemId that
- // identifies the parent tool call this output belongs to.
- var itemId = evt.Data.TryGetProperty("parentItemId", out var pidProp) ? pidProp.GetString()
- : (evt.Data.TryGetProperty("itemId", out var idProp) ? idProp.GetString() : null);
-
- return new ChatToolOutputEvent(output, ToolCallId: itemId, RunId: evt.RunId);
- }
-
- private static ChatEvent? MapJobEvent(AgentEventInfo evt)
- {
- if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
- var state = evt.Data.TryGetProperty("state", out var stateProp) ? stateProp.GetString() ?? "" : "";
- return state.ToLowerInvariant() switch
- {
- "done" => new ChatTurnEndEvent(),
- "error" => new ChatErrorEvent(evt.Summary ?? "Agent error"),
- _ => null
- };
- }
-
- ///
- /// Per-message UTF-8 byte cap applied to ANY chat-bubble payload that
- /// flows from the gateway into the timeline (live assistant text, live
- /// tool output, live system control notes, history replays, status /
- /// reasoning / error entries). Above this size the entry text is
- /// truncated at a code-point boundary and a marker is appended.
- ///
- ///
- /// SECURITY (chat rubber-duck MEDIUM 4): very large markdown payloads
- /// can hang reducers or rendering work, and a
- /// multi-MB string can hang the reducer / virtualized list. 256 KiB is
- /// well above any reasonable chat message (a typical book chapter is
- /// ~50 KB). Truncation events are logged at Debug level so they
- /// don't dominate the operator log under normal use.
- ///
- internal const int MaxEntryTextBytes = 256 * 1024;
-
- ///
- /// Truncate to at most
- /// bytes when encoded as UTF-8 and
- /// append a … [N bytes truncated] marker. Slices at a UTF-16
- /// code-unit boundary that doesn't split a surrogate pair, then
- /// verifies the byte budget. Returns the input unchanged when it
- /// already fits or is null/empty.
- ///
- internal static string TruncateForChatEntry(string? text)
- {
- if (string.IsNullOrEmpty(text)) return text ?? string.Empty;
- var enc = System.Text.Encoding.UTF8;
- // Cheap upper bound: every char is at most 3 UTF-8 bytes for the
- // BMP and surrogate pairs encode to 4 bytes / 2 chars (still ≤ 3
- // bytes per char). 4 is the worst case and keeps the cheap path
- // safe. If even the worst case fits, we're done.
- if ((long)text.Length * 4 <= MaxEntryTextBytes) return text;
- var actual = enc.GetByteCount(text);
- if (actual <= MaxEntryTextBytes) return text;
-
- // Binary search for the largest char-count whose UTF-8 byte count
- // fits in MaxEntryTextBytes minus a generous margin for the marker.
- var marker = string.Format(LocalizationHelper.GetString("Chat_TruncationMarkerFormat"), actual);
- int budget = MaxEntryTextBytes - enc.GetByteCount(marker);
- if (budget <= 0) budget = MaxEntryTextBytes / 2;
-
- int lo = 0, hi = text.Length;
- while (lo < hi)
- {
- int mid = (lo + hi + 1) / 2;
- // Don't split a surrogate pair: nudge mid back if it lands on
- // a low surrogate.
- if (mid < text.Length && char.IsLowSurrogate(text[mid])) mid--;
- if (mid <= lo)
- {
- hi = lo;
- continue;
- }
- int bytes = enc.GetByteCount(text.AsSpan(0, mid));
- if (bytes <= budget) lo = mid;
- else hi = mid - 1;
- }
- if (lo > 0 && char.IsHighSurrogate(text[lo - 1])) lo--;
-
- Logger.Debug($"[ChatTruncate] message {actual} bytes → {lo} chars (~{enc.GetByteCount(text.AsSpan(0, lo))} bytes); cap={MaxEntryTextBytes}");
- return string.Concat(text.AsSpan(0, lo), marker.AsSpan());
- }
-
- // ── chat.history flattened-tool-output recovery ──
-
- ///
- /// True when text is one of the approval slash-commands we send on the
- /// user's behalf (/approve <slug> allow-once,
- /// /approve <slug> allow-always, or
- /// /deny <slug>). Matches the exact dashboard grammar
- /// — not just the prefix — so legitimate user prose like
- /// "/approve the design changes" still renders as a normal bubble.
- ///
- ///
- /// Slug shape: hex-ish identifier (letters, digits, dashes, underscores;
- /// 4–64 chars). This mirrors what the gateway emits for
- /// ``approvalSlug``; we don't anchor on a specific length because the
- /// gateway has changed it before.
- ///
- internal static bool LooksLikeApprovalSlashCommand(string text)
- {
- if (string.IsNullOrEmpty(text)) return false;
- var t = text.Trim();
- return s_approvalSlashCommandRegex.IsMatch(t);
- }
-
- private static readonly System.Text.RegularExpressions.Regex s_approvalSlashCommandRegex =
- new(@"^/(?:approve\s+[A-Za-z0-9_-]{4,64}(?:\s+(?:allow-once|allow-always))?|deny\s+[A-Za-z0-9_-]{4,64})\s*$",
- System.Text.RegularExpressions.RegexOptions.Compiled);
-
- // ── Content-block-seam repair ──────────────────────────────────────
- // Anthropic Claude returns an assistant turn as an ordered list of
- // content blocks ({text}, {tool_use}, {text}, …). The OpenClaw gateway
- // strips out the tool_use blocks (they're surfaced as tool chips) but
- // currently joins the remaining text blocks into a single
- // chat.message.text string WITHOUT inserting any whitespace between
- // them. That produces visibly glued seams in the assistant bubble,
- // e.g. (real captures from Sonnet 4.5 / Opus 4.7):
- //
- // "...C:\Windows\System32**The command was blocked..." (** + capital)
- // "...with PowerShell:The C:\temp directory doesn't..." (: + capital)
- // "...on your Windows node.Looks like there's a..." (. + capital)
- // "...the deletion works?Got it - less exploring..." (? + capital)
- //
- // The proper fix lives in the gateway. Until that ships, this pass
- // re-inserts a paragraph break at each high-confidence seam so the
- // rendered Markdown bubble reads naturally. Patterns are kept narrow
- // to minimize false positives in normal prose:
- //
- // • Bold-close seam: lowercase/digit, then ``**``, then a capital
- // letter — i.e. a heading-style bold span immediately followed by
- // new sentence text. Inline emphasis like ``**foo**bar`` is left
- // alone (next char is lowercase).
- // • Punctuation seam: lowercase/digit, then ``. ! ? :``, then a
- // capital letter — i.e. a sentence terminator immediately followed
- // by a new sentence. Single-letter abbreviations such as ``U.S.A``
- // are skipped (the lookbehind requires lowercase/digit, so ``S.A``
- // doesn't match). File paths like ``C:\temp`` and URLs like
- // ``https://`` don't match either (next char after the punctuation
- // is not a capital letter).
- //
- // Fenced code blocks are skipped entirely so we never inject newlines
- // inside JSON/code samples. Inline single-backtick spans are left
- // unhandled (false-positive rate inside short inline code is low).
- private static readonly System.Text.RegularExpressions.Regex s_seamBoldClose =
- new(@"(?<=[a-z0-9])(\*\*)(?=[A-Z])",
- System.Text.RegularExpressions.RegexOptions.Compiled);
-
- // The sentence-punctuation seam matches a sentence-terminator (``. ! ? :``)
- // glued to the start of a new sentence (capital + lowercase run). The
- // tricky case is distinguishing real sentence seams (``done.Next step``)
- // from member-access in code identifiers (``Path.Combine``,
- // ``System.IO.File``, ``obj.Method``). Two guards do the work:
- //
- // • Lookbehind ``[a-z0-9][.!?:]`` — the punctuation must follow a
- // lowercase letter or digit. This already rejects ALL-CAPS
- // abbreviations like ``U.S.A`` (S before the trailing ``.`` is
- // uppercase, so the lookbehind fails).
- //
- // • Lookahead ``[A-Z][a-z]+(?:[\s,;:!?]|$)`` — the next word must be
- // a Pascal-case run (capital + ≥1 lowercase) followed immediately by
- // whitespace, sentence punctuation, or end-of-string. That single
- // trailing-char constraint is what rejects identifiers:
- // ``Path.Combine(a, b)`` → ``Combine`` is followed by ``(`` ✗
- // ``obj.Method()`` → ``Method`` is followed by ``(`` ✗
- // ``System.IO.File.ReadAll…`` → ``Read`` is followed by ``A`` ✗
- // ``db.Server01`` → ``Server`` is followed by ``0`` ✗
- // ``MyVar.OtherVar`` → ``Other`` is followed by ``V`` ✗
- // ``the field is x.Baz`` (EOS) → ``Baz`` is at end-of-string ✗
- // while legitimate seams pass:
- // ``PowerShell:The C:\\…`` → ``The`` is followed by `` `` ✓
- // ``done.Next step`` → ``Next`` is followed by `` `` ✓
- // ``All set!Anything else?`` → ``Anything`` is followed by `` `` ✓
- // ``All done!Next, let's…`` → ``Next`` is followed by ``,`` ✓
- //
- // Note: we intentionally do NOT include end-of-string as a valid
- // "trailing" position. LLMs end explanations with bare identifiers
- // (``the field is x.Baz``, ``stored in obj.Foo``) and chat-message
- // frames don't always carry a trailing punctuation/newline, so the
- // ``$`` alternative would shred those into two paragraphs. Real
- // content-block seams always have more prose after them.
- //
- // The whole pattern is a pair of zero-width assertions, so the
- // replacement is a pure ``\n\n`` insert at the seam — no captured
- // punctuation to re-emit.
- private static readonly System.Text.RegularExpressions.Regex s_seamSentencePunct =
- new(@"(?<=[a-z0-9][.!?:])(?=[A-Z][a-z]+[\s,;:!?])",
- System.Text.RegularExpressions.RegexOptions.Compiled);
-
- // SearchValues gives SIMD-accelerated scan without a per-call heap allocation.
- private static readonly SearchValues s_seamPunctChars = SearchValues.Create(".!?:");
-
- ///
- /// Re-insert paragraph breaks at gateway-glued content-block seams in
- /// an assistant message. Safe to call on any text — short text, text
- /// without seams, and text that is entirely fenced code all pass
- /// through unchanged. Fenced code blocks (``` ``` ``` ```) are skipped
- /// so JSON/code samples never get whitespace injected inside them.
- ///
- internal static string RepairContentBlockSeams(string? text)
- {
- if (string.IsNullOrEmpty(text)) return text ?? string.Empty;
- if (text.Length < 4) return text;
-
- // Fast path: if neither marker is present we can skip entirely.
- if (!text.Contains("**", System.StringComparison.Ordinal) &&
- text.AsSpan().IndexOfAny(s_seamPunctChars) < 0)
- {
- return text;
- }
-
- // Walk the string, alternating between prose and fenced-code
- // segments. Apply seam regexes to prose only. We tolerate
- // unclosed fences by treating everything after the dangling
- // opener as code (matches Markdown renderer behavior).
- var sb = new System.Text.StringBuilder(text.Length + 16);
- int i = 0;
- while (i < text.Length)
- {
- int fenceStart = text.IndexOf("```", i, System.StringComparison.Ordinal);
- if (fenceStart < 0)
- {
- sb.Append(RepairProseSegment(text[i..]));
- break;
- }
-
- sb.Append(RepairProseSegment(text.Substring(i, fenceStart - i)));
-
- int fenceEnd = text.IndexOf("```", fenceStart + 3, System.StringComparison.Ordinal);
- if (fenceEnd < 0)
- {
- // Unclosed fence — append the rest verbatim as code.
- sb.Append(text, fenceStart, text.Length - fenceStart);
- break;
- }
-
- // Append fenced block verbatim (including both fence markers).
- sb.Append(text, fenceStart, fenceEnd - fenceStart + 3);
- i = fenceEnd + 3;
- }
-
- return sb.ToString();
- }
-
- private static string RepairProseSegment(string segment)
- {
- if (string.IsNullOrEmpty(segment)) return segment;
- segment = s_seamBoldClose.Replace(segment, "$1\n\n");
- // s_seamSentencePunct is a zero-width assertion (lookbehind +
- // lookahead) so the replacement is a pure insert of "\n\n" at
- // the seam — no captured punctuation to re-emit.
- segment = s_seamSentencePunct.Replace(segment, "\n\n");
- return segment;
- }
-
- // ── [ChatTrace] helpers ─────────────────────────────────────────────
- // Per-process random seed for ChatTraceHash. Mixing this into the FNV
- // initial state keeps identical-text frames colliding within a single
- // tray run (so duplicate-bubble diagnostics still work) while making
- // the hash useless as a content fingerprint outside this process: an
- // attacker with the log file can no longer rebuild the hash for a
- // guessed plaintext, and the value rotates on every tray restart.
- private static readonly uint ChatTraceHashSeed = unchecked((uint)System.Security.Cryptography.RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue));
-
- // Short FNV-1a-style 32-bit fold of the message text, seeded with a
- // per-process random value. Used in trace logs to tell two near-
- // duplicate frames apart at a glance without dumping the text itself.
- // Not a security hash; not reproducible outside this process.
- private static string ChatTraceHash(string text)
- {
- if (string.IsNullOrEmpty(text)) return "00000000";
- uint h = ChatTraceHashSeed;
- for (int i = 0; i < text.Length; i++)
- {
- h ^= text[i];
- h *= 16777619u;
- }
- return h.ToString("x8");
- }
-
-
- // ── State helpers ──
-
- ///
- /// Apply to whichever text
- /// payload a carries. Returns the input
- /// unchanged when there is nothing to truncate or the text already
- /// fits. Used by to enforce the
- /// per-message size cap on every code path.
- ///
- ///
- /// Coverage: every subtype that carries a
- /// caller-supplied text payload is truncated here, including the
- /// currently-unused
- /// /
- /// /
- /// shapes — these don't flow through
- /// today but covering them now
- /// prevents a future caller from bypassing the cap when wiring
- /// them up. The /
- /// shapes have no untrusted
- /// text fields and fall through unchanged.
- ///
- internal static ChatEvent TruncateChatEvent(ChatEvent evt) => evt switch
- {
- ChatUserMessageEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatThinkingEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatReasoningEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatReasoningDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatMessageEvent e => e with
- {
- Text = TruncateForChatEntry(e.Text),
- ReasoningText = e.ReasoningText is null ? null : TruncateForChatEntry(e.ReasoningText)
- },
- ChatMessageDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatToolStartEvent e => e with
- {
- Text = TruncateForChatEntry(e.Text),
- ToolName = TruncateForChatEntry(e.ToolName)
- },
- ChatToolPresentationEvent e => e with
- {
- ToolName = TruncateForChatEntry(e.ToolName)
- },
- ChatToolOutputEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatToolErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatStatusEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatRestoredEvent e => e with { Text = TruncateForChatEntry(e.Text) },
- ChatRawEvent e => e with { Text = e.Text is null ? null : TruncateForChatEntry(e.Text) },
- ChatModelChangedEvent e => e with { Model = TruncateForChatEntry(e.Model) },
- ChatIntentEvent e => e with { Intent = TruncateForChatEntry(e.Intent) },
- ChatPermissionRequestEvent e => e with
- {
- PermissionKind = TruncateForChatEntry(e.PermissionKind),
- ToolName = TruncateForChatEntry(e.ToolName),
- Detail = TruncateForChatEntry(e.Detail)
- },
- _ => evt
- };
-
- private void ApplyEventAndPublish(string threadId, ChatEvent evt, ChatEntryMetadata? meta = null)
- {
- // Defense-in-depth (chat rubber-duck MEDIUM 4): cap text on every
- // event that lands in the timeline. Live history-load and
- // OnChatMessageReceived already truncate at the call site, but
- // agent-event paths (reasoning deltas, status notes, raw tool
- // output, errors) flow through here directly. Keeping the cap
- // here too guarantees no untrusted gateway payload bypasses the
- // limit.
- evt = TruncateChatEvent(evt);
-
- ChatDataSnapshot snapshot;
- lock (_gate)
- {
- snapshot = ApplyEventLocked(threadId, evt, meta);
- }
- Publish(snapshot);
- }
-
- private ChatDataSnapshot ApplyEventLocked(string threadId, ChatEvent evt, ChatEntryMetadata? meta)
- {
- var current = GetOrCreateTimelineLocked(threadId);
- var beforeIds = new HashSet(current.Entries.Count);
- for (int i = 0; i < current.Entries.Count; i++) beforeIds.Add(current.Entries[i].Id);
-
- var next = ChatTimelineReducer.Apply(current, evt);
- _timelines[threadId] = next;
-
- // Capture metadata for any newly-created entries. Updates to
- // existing entries (e.g. UpsertAssistant on the active assistant)
- // intentionally don't overwrite — the original creation timestamp
- // for the turn is more useful than the most-recent-delta time.
- // EXCEPTION: if the new metadata carries usage tokens (only
- // emitted on terminal frames), merge them into the existing entry
- // so the footer pills (↑/↓/R/ctx%) light up at end-of-turn.
- if (meta is not null)
- {
- var threadMeta = GetOrCreateThreadMetaLocked(threadId);
- var hasUsage = meta.InputTokens is not null || meta.OutputTokens is not null
- || meta.ResponseTokens is not null || meta.ContextPercent is not null;
- for (int i = 0; i < next.Entries.Count; i++)
- {
- var id = next.Entries[i].Id;
- var isNew = !beforeIds.Contains(id);
- if (isNew && !threadMeta.ContainsKey(id))
- {
- threadMeta[id] = meta;
- }
- else if (hasUsage && threadMeta.TryGetValue(id, out var existing)
- && (existing.InputTokens is null && existing.OutputTokens is null))
- {
- // Merge usage onto the existing assistant entry whose
- // text was just upserted by this final delta.
- threadMeta[id] = existing with
- {
- InputTokens = meta.InputTokens ?? existing.InputTokens,
- OutputTokens = meta.OutputTokens ?? existing.OutputTokens,
- ResponseTokens = meta.ResponseTokens ?? existing.ResponseTokens,
- ContextPercent = meta.ContextPercent ?? existing.ContextPercent
- };
- }
- }
- }
-
- return BuildSnapshotLocked();
- }
-
- private Dictionary GetOrCreateThreadMetaLocked(string threadId)
- {
- if (!_entryMeta.TryGetValue(threadId, out var meta))
- {
- meta = new Dictionary();
- _entryMeta[threadId] = meta;
- }
- return meta;
- }
-
- private readonly record struct ResetClearPersistence(
- bool SaveAbortedIds,
- bool SaveToolMeta,
- bool SaveAttachmentMeta,
- string[] SubmittedRunIds);
-
- private long GetResetVersionLocked(string threadId) =>
- _resetVersions.TryGetValue(threadId, out var version) ? version : 0;
-
- private long GetHistoryReplacementVersionLocked(string threadId) =>
- _historyReplacementVersions.TryGetValue(threadId, out var version) ? version : 0;
-
- private long GetHistoryRevisionLocked(string threadId) =>
- _historyRevisions.TryGetValue(threadId, out var revision) ? revision : 0;
-
- private long GetResetCutoffUtcMsLocked(string threadId) =>
- _resetCutoffUtcMs.TryGetValue(threadId, out var cutoff) ? cutoff : 0;
-
- private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string threadId)
- {
- _telemetry.FinishThread(threadId, ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Reset);
- var oldSessionId = _sessionIds.TryGetValue(threadId, out var sid) ? sid : null;
- var saveToolMeta = false;
- var saveAttachmentMeta = false;
- var saveAbortedIds = _persistedAbortedIds.Remove(threadId);
-
- if (!string.IsNullOrEmpty(oldSessionId))
- {
- saveToolMeta = _toolMetaCache.Remove(oldSessionId);
- saveAttachmentMeta = _attachmentMetaCache.Remove(oldSessionId);
- _resetClearedSessionIds[threadId] = oldSessionId;
- }
- else
- {
- _resetClearedSessionIds.Remove(threadId);
- }
- saveToolMeta = _toolMetaCache.Remove(threadId) || saveToolMeta;
- saveAttachmentMeta = _attachmentMetaCache.Remove(threadId) || saveAttachmentMeta;
-
- if (saveToolMeta)
- {
- _toolMetaCacheDirty = true;
- _toolMetaSaveVersion++;
- }
-
- var submittedRunIds = new HashSet(StringComparer.Ordinal);
- if (_activeRunIds.TryGetValue(threadId, out var activeRunId) && !string.IsNullOrEmpty(activeRunId))
- submittedRunIds.Add(activeRunId);
- if (_queuedMessageIdsByRunId.TryGetValue(threadId, out var queuedRunIds))
- {
- foreach (var queuedRunId in queuedRunIds.Keys)
- submittedRunIds.Add(queuedRunId);
- }
- if (_localSentTexts.TryGetValue(threadId, out var localEchoes))
- {
- foreach (var localEcho in localEchoes)
- AddResetSubmittedLocalEchoTextLocked(threadId, localEcho.Text, localEcho.SentAt);
- }
-
- _resetVersions[threadId] = GetResetVersionLocked(threadId) + 1;
- _resetCutoffUtcMs[threadId] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
- _resetAwaitingUserMessage.Add(threadId);
- _timelines[threadId] = ChatTimelineState.Initial() with { HistoryLoaded = true };
- _entryMeta.Remove(threadId);
- _sessionIds.Remove(threadId);
- _historyLoaded.Add(threadId);
- _historyRetryCount.Remove(threadId);
- _activeRunIds.Remove(threadId);
- _activeRunStartSequences.Remove(threadId);
- _pendingAbortCounts.Remove(threadId);
- _abortedThreads.Remove(threadId);
- _locallyInitiatedThreads.Remove(threadId);
- _localSentTexts.Remove(threadId);
- _queuedMessages.Remove(threadId);
- _queuedSendRequests.Remove(threadId);
- ClearQueuedDrainScheduleLocked(threadId);
- _queuedMessageIdsByRunId.Remove(threadId);
- _terminalRunIdsByThread.Remove(threadId);
- _assistantFallbackPromotedThreads.Remove(threadId);
- _resetAcceptedRunIds.Remove(threadId);
- _resetLocalSendWithoutRunVersions.Remove(threadId);
- _resetLocalSendWithoutRunStartSequences.Remove(threadId);
- _resetLocalEchoSequences.Remove(threadId);
- _resetPendingLifecycleStarts.Remove(threadId);
- _resetRemoteBackfillInFlight.Remove(threadId);
- _resetRemoteUserSeen.Remove(threadId);
- foreach (var submittedRunId in submittedRunIds)
- AddResetIgnoredRunIdLocked(threadId, submittedRunId);
-
- return new ResetClearPersistence(saveAbortedIds, saveToolMeta, saveAttachmentMeta, submittedRunIds.ToArray());
- }
-
- private void PersistClearedResetState(ResetClearPersistence persistence)
- {
- if (persistence.SaveAbortedIds)
- SaveAbortedIds();
- if (persistence.SaveToolMeta)
- SaveToolMetaCache();
- if (persistence.SaveAttachmentMeta)
- SaveAttachmentMetaCache();
- }
-
- private void AddResetIgnoredRunIdLocked(string threadId, string runId)
- {
- if (!_resetIgnoredRunIds.TryGetValue(threadId, out var set))
- {
- set = new HashSet(StringComparer.Ordinal);
- _resetIgnoredRunIds[threadId] = set;
- }
- set.Add(runId);
- }
-
- private void AddResetSubmittedLocalEchoTextLocked(string threadId, string text, DateTimeOffset sentAt)
- {
- if (string.IsNullOrWhiteSpace(text))
- return;
-
- if (!_resetSubmittedLocalEchoTexts.TryGetValue(threadId, out var texts))
- {
- texts = new Dictionary>(StringComparer.Ordinal);
- _resetSubmittedLocalEchoTexts[threadId] = texts;
- }
-
- var normalized = text.Trim();
- if (!texts.TryGetValue(normalized, out var timestamps))
- {
- timestamps = new Queue();
- texts[normalized] = timestamps;
- }
- timestamps.Enqueue(sentAt);
- }
-
- private bool TryConsumeResetSubmittedLocalEchoTextLocked(string threadId, string text)
- {
- if (string.IsNullOrWhiteSpace(text) ||
- !_resetSubmittedLocalEchoTexts.TryGetValue(threadId, out var texts))
- {
- return false;
- }
-
- var normalized = text.Trim();
- if (!texts.TryGetValue(normalized, out var timestamps))
- return false;
-
- var now = DateTimeOffset.UtcNow;
- while (timestamps.Count > 0 && now - timestamps.Peek() > LocalEchoSuppressionWindow)
- timestamps.Dequeue();
-
- if (timestamps.Count == 0)
- {
- texts.Remove(normalized);
- if (texts.Count == 0)
- _resetSubmittedLocalEchoTexts.Remove(threadId);
- return false;
- }
-
- timestamps.Dequeue();
- if (timestamps.Count == 0)
- texts.Remove(normalized);
-
- if (texts.Count == 0)
- _resetSubmittedLocalEchoTexts.Remove(threadId);
- return true;
- }
-
- private bool HasPendingLocalEchoTextLocked(string threadId, string text)
- {
- if (string.IsNullOrWhiteSpace(text) ||
- !_localSentTexts.TryGetValue(threadId, out var queue) ||
- queue.Count == 0)
- {
- return false;
- }
-
- var normalized = text.Trim();
- return queue.Any(pending => string.Equals(pending.Text, normalized, StringComparison.Ordinal));
- }
-
- private void AddResetAcceptedRunIdLocked(string threadId, string runId)
- {
- if (!_resetAwaitingUserMessage.Contains(threadId))
- return;
-
- if (!_resetAcceptedRunIds.TryGetValue(threadId, out var set))
- {
- set = new HashSet(StringComparer.Ordinal);
- _resetAcceptedRunIds[threadId] = set;
- }
- set.Add(runId);
- TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: runId);
- }
-
- private readonly record struct PendingResetLifecycleStart(AgentEventInfo Event, long Sequence);
-
- private bool ShouldDropChatMessageAfterResetLocked(
- string threadId,
- string roleLower,
- string rawText,
- long tsMs,
- out string? consumeEchoText,
- out bool requestRemoteBackfill)
- {
- consumeEchoText = null;
- requestRemoteBackfill = false;
- var isNormalUserText = roleLower == "user" &&
- !LooksLikeApprovalSlashCommand(rawText) &&
- !NativeToolProjector.LooksLikeSystemControlNote(rawText);
-
- if (isNormalUserText &&
- !HasPendingLocalEchoTextLocked(threadId, rawText) &&
- TryConsumeResetSubmittedLocalEchoTextLocked(threadId, rawText))
- {
- return true;
- }
-
- if (!_resetAwaitingUserMessage.Contains(threadId))
- {
- return IsPreResetTimestampLocked(threadId, tsMs, GetResetCutoffUtcMsLocked(threadId));
- }
-
- var isFreshUser = isNormalUserText &&
- !IsPreResetTimestampLocked(threadId, tsMs, GetResetCutoffUtcMsLocked(threadId));
-
- if (isFreshUser &&
- _localSentTexts.TryGetValue(threadId, out var echoQueue) &&
- echoQueue.Count > 0 &&
- echoQueue.Any(pending => string.Equals(pending.Text, rawText.Trim(), StringComparison.Ordinal)))
- {
- consumeEchoText = rawText.Trim();
- _resetLocalEchoSequences[threadId] = _resetLifecycleStartSequence;
- if (TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null))
- return false;
- }
- else if (isFreshUser && tsMs > 0)
- {
- _resetRemoteUserSeen.Add(threadId);
- if (TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null))
- return false;
- }
- else if (isFreshUser && _resetRemoteBackfillInFlight.Add(threadId))
- {
- requestRemoteBackfill = true;
- }
-
- return true;
- }
-
- private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId)
- {
- ChatDataSnapshot? snapshot = null;
- lock (_gate)
- {
- // This fallback covers the degenerate case where an assistant frame
- // arrives before any user echo or lifecycle.start/run mapping. When
- // a run is active, lifecycle/ACK correlation owns the handoff; when
- // multiple queued prompts exist, positional assistant fallback is
- // ambiguous and can create false user boundaries that duplicate the
- // assistant bubble.
- if (_locallyInitiatedThreads.Contains(threadId)
- && TryGetSingleSendingQueuedMessageLocked(threadId, out var queued)
- && !_activeRunIds.ContainsKey(threadId)
- && !_assistantFallbackPromotedThreads.Contains(threadId)
- && PromoteQueuedMessageLocked(threadId, queued.Id))
- {
- snapshot = BuildSnapshotLocked();
- }
- }
-
- if (snapshot is not null)
- Publish(snapshot);
- }
-
- private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked(
- string threadId,
- string assistantText,
- string? gatewayMessageId,
- int? openClawSeq)
- {
- if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) &&
- IsIdentifiedCompletedAssistantDuplicateLocked(
- threadId,
- assistantText,
- gatewayMessageId,
- openClawSeq))
- {
- return AssistantQueueFrameDisposition.Drop;
- }
-
- if (string.IsNullOrEmpty(gatewayMessageId) &&
- openClawSeq is null &&
- IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked(threadId, assistantText))
- {
- return AssistantQueueFrameDisposition.Drop;
- }
-
- if (!_locallyInitiatedThreads.Contains(threadId) ||
- !TryGetSingleSendingQueuedMessageLocked(threadId, out _) ||
- _activeRunIds.ContainsKey(threadId) ||
- _assistantFallbackPromotedThreads.Contains(threadId) ||
- !_timelines.TryGetValue(threadId, out var timeline))
- {
- return AssistantQueueFrameDisposition.Render;
- }
-
- for (var i = timeline.Entries.Count - 1; i >= 0; i--)
- {
- var entry = timeline.Entries[i];
- if (entry.Kind != ChatTimelineItemKind.Assistant)
- continue;
- if (entry.IsStreaming || !string.Equals(entry.Text, assistantText, StringComparison.Ordinal))
- return AssistantQueueFrameDisposition.Render;
- if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null)
- // In this queue-boundary window, an identity-less same-text frame cannot be tied
- // to the queued prompt; replaying it can attach stale output to the next prompt.
- return AssistantQueueFrameDisposition.Drop;
- if (!_entryMeta.TryGetValue(threadId, out var threadMeta) ||
- !threadMeta.TryGetValue(entry.Id, out var existing))
- {
- return AssistantQueueFrameDisposition.Render;
- }
-
- var sameGatewayIdentity =
- (!string.IsNullOrEmpty(gatewayMessageId) &&
- string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) ||
- (openClawSeq is not null && existing.OpenClawSeq == openClawSeq);
- return sameGatewayIdentity
- ? AssistantQueueFrameDisposition.Drop
- : AssistantQueueFrameDisposition.Render;
- }
-
- return AssistantQueueFrameDisposition.Render;
- }
-
- private bool IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked(string threadId, string assistantText)
- {
- if (!_locallyInitiatedThreads.Contains(threadId) ||
- _activeRunIds.ContainsKey(threadId) ||
- !_timelines.TryGetValue(threadId, out var timeline) ||
- !_entryMeta.TryGetValue(threadId, out var threadMeta))
- {
- return false;
- }
-
- var sawLatestLocalUserBoundary = false;
- for (var i = timeline.Entries.Count - 1; i >= 0; i--)
- {
- var entry = timeline.Entries[i];
- if (!sawLatestLocalUserBoundary)
- {
- if (entry.Kind == ChatTimelineItemKind.Assistant)
- return false;
- if (entry.Kind == ChatTimelineItemKind.User &&
- threadMeta.TryGetValue(entry.Id, out var meta) &&
- meta.IsLocalQueuedSend)
- {
- sawLatestLocalUserBoundary = true;
- }
- continue;
- }
-
- if (entry.Kind == ChatTimelineItemKind.Assistant)
- return !entry.IsStreaming && string.Equals(entry.Text, assistantText, StringComparison.Ordinal);
- if (entry.Kind == ChatTimelineItemKind.User)
- return false;
- }
-
- return false;
- }
-
- private bool IsIdentifiedCompletedAssistantDuplicateLocked(
- string threadId,
- string assistantText,
- string? gatewayMessageId,
- int? openClawSeq)
- {
- if (!_timelines.TryGetValue(threadId, out var timeline) ||
- !_entryMeta.TryGetValue(threadId, out var threadMeta))
- {
- return false;
- }
-
- for (var i = timeline.Entries.Count - 1; i >= 0; i--)
- {
- var entry = timeline.Entries[i];
- if (entry.Kind != ChatTimelineItemKind.Assistant ||
- entry.IsStreaming ||
- !threadMeta.TryGetValue(entry.Id, out var existing))
- {
- continue;
- }
-
- var bothHaveGatewayIds =
- !string.IsNullOrEmpty(gatewayMessageId) &&
- !string.IsNullOrEmpty(existing.GatewayMessageId);
- if (bothHaveGatewayIds &&
- string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal))
- {
- return true;
- }
- if (!bothHaveGatewayIds &&
- openClawSeq is not null &&
- existing.OpenClawSeq == openClawSeq &&
- string.Equals(entry.Text, assistantText, StringComparison.Ordinal))
- {
- if (!string.IsNullOrEmpty(gatewayMessageId) &&
- string.IsNullOrEmpty(existing.GatewayMessageId))
- {
- threadMeta[entry.Id] = existing with { GatewayMessageId = gatewayMessageId };
- }
- return true;
- }
- }
-
- return false;
- }
-
- private bool HasSendingQueuedMessagesLocked(string threadId)
- => _queuedMessages.TryGetValue(threadId, out var queued) &&
- queued.Any(message => message.SendState == ChatQueuedMessageSendState.Sending);
-
- private bool HasPendingQueuedMessagesLocked(string threadId)
- => _queuedMessages.TryGetValue(threadId, out var queued) &&
- queued.Any(message => message.SendState is ChatQueuedMessageSendState.Queued or ChatQueuedMessageSendState.Sending);
-
- private bool TryGetSingleSendingQueuedMessageLocked(string threadId, out ChatQueuedMessage message)
- {
- message = default!;
- if (!_queuedMessages.TryGetValue(threadId, out var queued))
- return false;
-
- ChatQueuedMessage? found = null;
- foreach (var candidate in queued)
- {
- if (candidate.SendState != ChatQueuedMessageSendState.Sending)
- continue;
- if (FindQueuedSendRequestLocked(threadId, candidate.Id)?.LifecycleCommand is not null)
- continue;
- if (found is not null)
- return false;
- found = candidate;
- }
-
- if (found is null)
- return false;
-
- message = found;
- return true;
- }
-
- private bool ShouldDropAgentEventAfterResetLocked(AgentEventInfo evt, string threadId, out bool reloadHistoryAfterDrop)
- {
- reloadHistoryAfterDrop = false;
- if (IsResetIgnoredRunLocked(threadId, evt.RunId, evt, out reloadHistoryAfterDrop))
- return true;
-
- var eventTsMs = evt.Ts > 0 ? (long)evt.Ts : 0L;
- var cutoff = GetResetCutoffUtcMsLocked(threadId);
- if (!_resetAwaitingUserMessage.Contains(threadId))
- return IsPreResetTimestampLocked(threadId, eventTsMs, cutoff);
-
- if (IsAcceptedPostResetLifecycleStartLocked(threadId, evt, _resetLifecycleStartSequence + 1))
- {
- OpenResetGateForLifecycleStartLocked(threadId, evt);
- return false;
- }
-
- if (IsPreResetTimestampLocked(threadId, eventTsMs, cutoff))
- return true;
-
- if (IsLifecycleStart(evt))
- BufferResetLifecycleStartLocked(threadId, evt);
-
- return true;
- }
-
- private bool IsAcceptedPostResetLifecycleStartLocked(string threadId, AgentEventInfo evt, long lifecycleStartSequence)
- {
- if (!IsLifecycleStart(evt))
- return false;
-
- if (!string.IsNullOrEmpty(evt.RunId) &&
- _resetAcceptedRunIds.TryGetValue(threadId, out var acceptedRunIds) &&
- acceptedRunIds.Contains(evt.RunId))
- {
- return true;
- }
-
- if (_resetLocalSendWithoutRunVersions.TryGetValue(threadId, out var localSendVersion) &&
- localSendVersion == GetResetVersionLocked(threadId) &&
- _resetLocalSendWithoutRunStartSequences.TryGetValue(threadId, out var localSendStartSequence) &&
- _resetLocalEchoSequences.TryGetValue(threadId, out var localEchoSequence) &&
- localEchoSequence >= localSendStartSequence &&
- lifecycleStartSequence > localSendStartSequence &&
- evt.Ts > 0 &&
- !IsPreResetTimestampLocked(threadId, (long)evt.Ts, GetResetCutoffUtcMsLocked(threadId)))
- {
- return true;
- }
-
- return _resetRemoteUserSeen.Contains(threadId) &&
- !IsPreResetTimestampLocked(threadId, evt.Ts > 0 ? (long)evt.Ts : 0L, GetResetCutoffUtcMsLocked(threadId));
- }
-
- private void BufferResetLifecycleStartLocked(string threadId, AgentEventInfo evt)
- {
- if (!_resetPendingLifecycleStarts.TryGetValue(threadId, out var pending))
- {
- pending = new List();
- _resetPendingLifecycleStarts[threadId] = pending;
- }
-
- if (!string.IsNullOrEmpty(evt.RunId) &&
- pending.Exists(e => string.Equals(e.Event.RunId, evt.RunId, StringComparison.Ordinal)))
- {
- return;
- }
-
- pending.Add(new PendingResetLifecycleStart(evt, ++_resetLifecycleStartSequence));
- if (pending.Count > 8)
- pending.RemoveRange(0, pending.Count - 8);
- }
-
- private bool TryOpenResetGateFromPendingLifecycleLocked(string threadId, string? acceptedRunId)
- {
- if (!_resetAwaitingUserMessage.Contains(threadId) ||
- !_resetPendingLifecycleStarts.TryGetValue(threadId, out var pending))
- {
- return false;
- }
-
- for (var i = 0; i < pending.Count; i++)
- {
- var pendingStart = pending[i];
- var evt = pendingStart.Event;
- if (acceptedRunId is not null)
- {
- if (!string.Equals(evt.RunId, acceptedRunId, StringComparison.Ordinal))
- continue;
- }
- else if (!IsAcceptedPostResetLifecycleStartLocked(threadId, evt, pendingStart.Sequence))
- {
- continue;
- }
-
- pending.RemoveAt(i);
- OpenResetGateForLifecycleStartLocked(threadId, evt);
- return true;
- }
-
- return false;
- }
-
- private void SnapshotLatestAssistantUsage(string threadId)
- {
- ChatDataSnapshot? snapshot = null;
- lock (_gate)
- {
- var session = ResolveSessionForThreadLocked(threadId);
- if (session is null) return;
- if (SnapshotLatestAssistantUsageLocked(session, threadId))
- snapshot = BuildSnapshotLocked();
- }
-
- if (snapshot is not null)
- Publish(snapshot);
- }
-
- private void SnapshotAssistantUsageContribution(string threadId, ChatEntryMetadata meta)
- {
- ChatDataSnapshot? snapshot = null;
- lock (_gate)
- {
- if (SnapshotAssistantUsageContributionLocked(threadId, meta))
- snapshot = BuildSnapshotLocked();
- }
-
- if (snapshot is not null)
- Publish(snapshot);
- }
-
- private bool SnapshotAssistantUsageContributionLocked(string threadId, ChatEntryMetadata meta)
- {
- var currentUsage = UsageValue(meta);
- if (currentUsage is null || currentUsage <= 0)
- return false;
-
- if (!_timelines.TryGetValue(threadId, out var timeline))
- return false;
-
- var contextTokens = meta.ContextTokens;
- if ((contextTokens is null || contextTokens <= 0)
- && _sessions.FirstOrDefault(s => string.Equals(s.Key, threadId, StringComparison.Ordinal)) is { ContextTokens: > 0 } session)
- {
- contextTokens = session.ContextTokens;
- }
-
- for (var i = timeline.Entries.Count - 1; i >= 0; i--)
- {
- var entry = timeline.Entries[i];
- if (entry.Kind != ChatTimelineItemKind.Assistant)
- continue;
-
- var threadMeta = GetOrCreateThreadMetaLocked(threadId);
- threadMeta.TryGetValue(entry.Id, out var existing);
- var previousUsage = LatestAssistantUsageBeforeLocked(timeline, threadMeta, i);
- var candidateUsage = (previousUsage ?? 0) + currentUsage.Value;
- var cumulativeUsage = Math.Max(candidateUsage, existing?.ResponseTokens ?? 0);
- if (existing?.ResponseTokens == cumulativeUsage
- && existing.UsageContributionTokens == currentUsage
- && existing.ContextTokens == contextTokens)
- {
- return false;
- }
-
- threadMeta[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with
- {
- InputTokens = meta.InputTokens ?? existing?.InputTokens,
- OutputTokens = meta.OutputTokens ?? existing?.OutputTokens,
- ResponseTokens = cumulativeUsage,
- ContextPercent = meta.ContextPercent ?? existing?.ContextPercent,
- ContextTokens = contextTokens ?? existing?.ContextTokens,
- UsageContributionTokens = currentUsage,
- };
- return true;
- }
-
- return false;
- }
-
- private void OpenResetGateForLifecycleStartLocked(string threadId, AgentEventInfo evt)
- {
- _resetAwaitingUserMessage.Remove(threadId);
- _resetRemoteUserSeen.Remove(threadId);
- _resetLocalSendWithoutRunVersions.Remove(threadId);
- _resetLocalSendWithoutRunStartSequences.Remove(threadId);
- _resetLocalEchoSequences.Remove(threadId);
- _resetPendingLifecycleStarts.Remove(threadId);
-
- if (!string.IsNullOrEmpty(evt.RunId))
- {
- _activeRunIds[threadId] = evt.RunId;
- _activeRunStartSequences[threadId] = ++_lifecycleStartSequence;
- if (_resetAcceptedRunIds.TryGetValue(threadId, out var acceptedRunIds))
- {
- acceptedRunIds.Remove(evt.RunId);
- if (acceptedRunIds.Count == 0)
- _resetAcceptedRunIds.Remove(threadId);
- }
- }
- }
-
- private bool IsResetIgnoredRunLocked(string threadId, string? runId, AgentEventInfo evt, out bool reloadHistoryAfterDrop)
- {
- reloadHistoryAfterDrop = false;
- if (string.IsNullOrEmpty(runId) ||
- !_resetIgnoredRunIds.TryGetValue(threadId, out var runIds) ||
- !runIds.Contains(runId))
- {
- return false;
- }
-
- if (IsTerminalRunEvent(evt))
- {
- runIds.Remove(runId);
- if (runIds.Count == 0)
- {
- _resetIgnoredRunIds.Remove(threadId);
- _resetSubmittedLocalEchoTexts.Remove(threadId);
- }
- reloadHistoryAfterDrop = true;
- }
-
- return true;
- }
-
- private bool IsPreResetTimestampLocked(string threadId, long eventTsMs, long resetCutoffUtcMs)
- {
- if (eventTsMs <= 0 || resetCutoffUtcMs <= 0)
- return false;
-
- return _resetVersions.ContainsKey(threadId) &&
- eventTsMs + ResetTimestampToleranceMs <= resetCutoffUtcMs;
- }
-
- private static bool IsLifecycleStart(AgentEventInfo evt) =>
- string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
- evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object &&
- evt.Data.TryGetProperty("phase", out var phaseProp) &&
- string.Equals(phaseProp.GetString(), "start", StringComparison.OrdinalIgnoreCase);
-
- private static bool IsTerminalRunEvent(AgentEventInfo evt)
- {
- if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
- evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object &&
- evt.Data.TryGetProperty("phase", out var phaseProp))
- {
- var phase = phaseProp.GetString();
- return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(phase, "error", StringComparison.OrdinalIgnoreCase);
- }
-
- if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) &&
- evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object &&
- evt.Data.TryGetProperty("state", out var stateProp))
- {
- var state = stateProp.GetString();
- return string.Equals(state, "done", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(state, "error", StringComparison.OrdinalIgnoreCase);
- }
-
- return false;
- }
-
- private static int? LatestAssistantUsageBeforeLocked(ChatTimelineState timeline, Dictionary threadMeta, int beforeIndex)
- {
- for (var i = beforeIndex - 1; i >= 0; i--)
- {
- var entry = timeline.Entries[i];
- if (entry.Kind != ChatTimelineItemKind.Assistant)
- continue;
-
- if (!threadMeta.TryGetValue(entry.Id, out var meta))
- continue;
-
- var usage = UsageValue(meta);
- if (usage is null)
- continue;
-
- return usage;
- }
-
- return null;
- }
-
- private static int? UsageValue(ChatEntryMetadata meta)
- => meta.ResponseTokens
- ?? (meta.InputTokens is int input && meta.OutputTokens is int output
- ? input + output
- : null);
-
- private bool SnapshotLatestAssistantUsageLocked(SessionInfo session, string? timelineKey = null)
- {
- if (string.IsNullOrEmpty(session.Key)) return false;
-
- var usedTokens = session.TotalTokens;
- if (usedTokens <= 0)
- usedTokens = session.InputTokens + session.OutputTokens;
- if (usedTokens <= 0) return false;
-
- timelineKey ??= session.Key;
- if (string.IsNullOrEmpty(timelineKey)) return false;
- if (!_timelines.TryGetValue(timelineKey, out var timeline)) return false;
-
- for (var i = timeline.Entries.Count - 1; i >= 0; i--)
- {
- var entry = timeline.Entries[i];
- if (entry.Kind != ChatTimelineItemKind.Assistant) continue;
-
- var threadMeta = GetOrCreateThreadMetaLocked(timelineKey);
- threadMeta.TryGetValue(entry.Id, out var existing);
- var usageSnapshot = Math.Max(usedTokens, existing?.ResponseTokens ?? 0);
- var usageSnapshotTokens = ToIntIfPositive(usageSnapshot);
- var contextSnapshot = session.ContextTokens > 0 ? session.ContextTokens : existing?.ContextTokens;
- if (existing is not null
- && existing.ResponseTokens == usageSnapshotTokens
- && existing.ContextTokens == contextSnapshot)
- return false;
-
- threadMeta[entry.Id] = (existing ?? BuildLiveMetaLocked(timelineKey)) with
- {
- InputTokens = ToIntIfPositive(session.InputTokens),
- OutputTokens = ToIntIfPositive(session.OutputTokens),
- ResponseTokens = usageSnapshotTokens,
- ContextTokens = contextSnapshot,
- ContextPercent = existing?.ContextPercent,
- UsageContributionTokens = existing?.UsageContributionTokens
- };
- return true;
- }
-
- return false;
- }
-
- private SessionInfo? ResolveSessionForThreadLocked(string threadId)
- {
- var session = Array.Find(_sessions, s => string.Equals(s.Key, threadId, StringComparison.Ordinal));
- if (session is not null) return session;
-
- if (string.Equals(threadId, "main", StringComparison.Ordinal)
- && _bridge.MainSessionKey is { Length: > 0 } mainKey)
- {
- session = Array.Find(_sessions, s => string.Equals(s.Key, mainKey, StringComparison.Ordinal));
- if (session is not null) return session;
- }
-
- if (string.Equals(threadId, "main", StringComparison.Ordinal))
- return Array.Find(_sessions, s => s.IsMain);
-
- return null;
- }
-
- private string ResolveTimelineKeyForSessionLocked(SessionInfo session)
- {
- if (session.IsMain && _timelines.TryGetValue("main", out var mainTimeline)
- && mainTimeline.Entries.Count > 0)
- {
- return "main";
- }
-
- if (!string.IsNullOrEmpty(session.Key) && _timelines.ContainsKey(session.Key))
- return session.Key;
-
- if (session.IsMain && _timelines.ContainsKey("main"))
- return "main";
-
- return session.Key;
- }
-
- private static int? ToIntIfPositive(long value)
- => value > 0 && value <= int.MaxValue ? (int)value : null;
-
- private ChatEntryMetadata BuildLiveMetaLocked(
- string threadId,
- long? tsMs = null,
- string? gatewayMessageId = null,
- int? openClawSeq = null,
- bool isLocalQueuedSend = false,
- string? localQueuedMessageId = null,
- string? openClawKind = null,
- long? compactionTokensBefore = null,
- long? compactionTokensAfter = null)
- {
- var ts = tsMs is { } v && v > 0
- ? DateTimeOffset.FromUnixTimeMilliseconds(v).ToLocalTime()
- : (DateTimeOffset?)DateTimeOffset.Now;
- var session = Array.Find(_sessions, s => s.Key == threadId);
- return new ChatEntryMetadata(
- ts,
- session?.Model,
- GatewayMessageId: gatewayMessageId,
- OpenClawSeq: openClawSeq,
- OpenClawKind: openClawKind,
- CompactionTokensBefore: compactionTokensBefore,
- CompactionTokensAfter: compactionTokensAfter,
- IsLocalQueuedSend: isLocalQueuedSend,
- LocalQueuedMessageId: localQueuedMessageId);
- }
-
- private static List OrderHistoryMessages(List<(ChatMessageInfo Message, int Index)> messages)
- {
- if (messages.Count == 0)
- return new List();
-
- var sequencedCount = messages.Count(item => item.Message.OpenClawSeq is not null);
- if (sequencedCount == messages.Count)
- {
- return messages
- .OrderBy(item => item.Message.OpenClawSeq)
- .ThenBy(item => item.Index)
- .Select(item => item.Message)
- .ToList();
- }
-
- if (sequencedCount == 0)
- {
- return messages
- .OrderBy(item => item.Message.Ts)
- .ThenBy(item => item.Index)
- .Select(item => item.Message)
- .ToList();
- }
-
- // Mixed old/new rows are already in gateway transcript order. Sorting
- // timestamped-but-unsequenced rows against sequenced rows can drag a
- // later queued burst (e.g. "t") ahead of the actual transcript start.
- return messages
- .OrderBy(item => item.Index)
- .Select(item => item.Message)
- .ToList();
- }
-
- private static void IncrementCount(Dictionary counts, string key)
- => counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1;
-
- private static bool TryConsumeCount(Dictionary counts, string key)
- {
- if (!counts.TryGetValue(key, out var count) || count <= 0)
- return false;
-
- if (count == 1)
- counts.Remove(key);
- else
- counts[key] = count - 1;
- return true;
- }
-
- private static void ConsumeAnyTimestamp(Dictionary> timestamps, string key)
- {
- if (timestamps.TryGetValue(key, out var values) && values.Count > 0)
- values.RemoveAt(0);
- }
-
- private void SeedSessionIdsFromSessionsLocked(IEnumerable sessions)
- {
- foreach (var session in sessions)
- {
- if (!string.IsNullOrWhiteSpace(session.Key) &&
- !string.IsNullOrWhiteSpace(session.SessionId))
- {
- if (_resetClearedSessionIds.TryGetValue(session.Key, out var clearedSessionId) &&
- string.Equals(clearedSessionId, session.SessionId, StringComparison.Ordinal))
- {
- continue;
- }
-
- _sessionIds[session.Key] = session.SessionId!;
- _resetClearedSessionIds.Remove(session.Key);
- }
- }
- }
-
- private ChatTimelineState GetOrCreateTimelineLocked(string threadId)
- {
- if (!_timelines.TryGetValue(threadId, out var current))
- {
- // HistoryLoaded stays false until LoadHistoryAsync rebuilds
- // the timeline from the gateway. The UI relies on this flag
- // to distinguish "session exists, history still fetching"
- // (show reconnecting view) from "session truly empty"
- // (show welcome zero-state).
- current = ChatTimelineState.Initial();
- _timelines[threadId] = current;
- }
- return current;
- }
-
- private void EnsureTimelinesForSessionsLocked()
- {
- foreach (var s in _sessions)
- {
- if (string.IsNullOrEmpty(s.Key)) continue;
- if (!_timelines.ContainsKey(s.Key))
- _timelines[s.Key] = ChatTimelineState.Initial();
- }
- }
-
- private ChatDataSnapshot BuildSnapshotLocked()
- {
- // Build threads from the gateway's authoritative session list.
- // No synthesis based on local timeline keys — the UI's compose target
- // is exposed separately via ChatComposeTarget so the renderer can show
- // a usable composer even before the first session materializes server-
- // side (e.g. fresh install with zero sessions).
- var threadList = new List(_sessions.Length + 1);
- var threadTitles = SessionTitleFormatter.FormatUnique(_sessions);
- for (int i = 0; i < _sessions.Length; i++)
- threadList.Add(ToThread(_sessions[i], threadTitles[i]));
-
- var composeKey = _bridge.MainSessionKey;
- var composeAgentId = _sessions
- .FirstOrDefault(session => string.Equals(session.Key, composeKey, StringComparison.Ordinal)) is { } mainSession
- ? SessionDisplayResolver.Resolve(mainSession).AgentId ?? "main"
- : "main";
- var composeReady = _bridge.HasHandshakeSnapshot
- && !string.IsNullOrWhiteSpace(composeKey)
- && _status == ConnectionStatus.Connected
- // Wait until sessions.list has been delivered for this
- // connection — otherwise the UI may synthesize a compose-only
- // thread (and render the welcome zero-state) in the brief
- // window before a returning user's real sessions arrive.
- && _sessionsListReceived;
-
- // If the compose target hasn't materialized as a real session yet but
- // already has local pending chat state (because the user sent a message
- // before the gateway echoed back sessions.list), surface a synthetic
- // thread record so the UI can render the queued card/transcript without
- // falling back into the "no thread selected" zero state. The synthetic
- // thread's Id is the canonical compose key, so when SessionsUpdated
- // eventually arrives with the same key it replaces the synthetic in
- // place — no migration, no re-keying.
- if (composeReady
- && composeKey is { } ck
- && _timelines.TryGetValue(ck, out var pendingTl)
- && (pendingTl.Entries.Count > 0
- || pendingTl.TurnActive
- || (_queuedMessages.TryGetValue(ck, out var pendingQueue) && pendingQueue.Count > 0))
- && !_sessions.Any(s => string.Equals(s.Key, ck, StringComparison.Ordinal)))
- {
- threadList.Add(new ChatThread
- {
- Id = ck,
- AgentId = composeAgentId,
- Title = _lastChatState?.ThreadTitle ?? "OpenClaw Windows Tray",
- Model = _lastChatState?.Model,
- ModelProvider = _lastChatState?.ModelProvider,
- Status = ChatThreadStatus.Running,
- Activity = ChatActivity.Idle,
- });
- }
-
- var threads = threadList.ToArray();
-
- // Snapshot a defensive copy of the timeline dict.
- var timelinesCopy = new Dictionary(_timelines);
- var timelineGenerationsCopy = new Dictionary(_resetVersions);
- var historyRevisionsCopy = new Dictionary(_historyRevisions);
- var queuedMessagesCopy = _queuedMessages.ToDictionary(
- kvp => kvp.Key,
- kvp => (IReadOnlyList)kvp.Value.ToArray());
-
- var defaultThreadId = ResolveDefaultThreadIdLocked();
-
- // When the gateway is connected and the handshake completed but no
- // session key was advertised, distinguish this from a normal "Connected"
- // state so the UI can surface a clear compatibility warning.
- var connectionLabel = (_status == ConnectionStatus.Connected
- && _bridge.HasHandshakeSnapshot
- && string.IsNullOrWhiteSpace(composeKey))
- ? "Incompatible gateway"
- : _status switch
- {
- ConnectionStatus.Connected => "Connected",
- ConnectionStatus.Connecting => "Connecting…",
- ConnectionStatus.Disconnected => "Disconnected",
- ConnectionStatus.Error => "Disconnected — error",
- _ => _status.ToString()
- };
-
- var composeTarget = composeReady
- ? new ChatComposeTarget(composeKey, true, composeAgentId)
- : ChatComposeTarget.NotReady;
-
- return new ChatDataSnapshot(
- Threads: threads,
- Timelines: timelinesCopy,
- DefaultThreadId: defaultThreadId,
- ConnectionStatus: connectionLabel,
- AvailableModels: _availableModels,
- ComposeTarget: composeTarget,
- ModelChoices: _modelChoices,
- // Null until the first commands.list fetch completes so the UI can
- // distinguish "loading" from "loaded but empty". IsSupported=false
- // surfaces the unsupported state.
- AvailableCommands: _commandCatalog?.Commands,
- CommandsSupported: _commandCatalog?.IsSupported ?? true,
- TimelineGenerations: timelineGenerationsCopy,
- HistoryRevisions: historyRevisionsCopy,
- QueuedMessagesByThread: queuedMessagesCopy);
- }
-
- private string? ResolveDefaultThreadIdLocked()
- {
- if (_lastChatState?.DefaultThreadId is { Length: > 0 } rememberedThreadId)
- {
- if (TryGetSessionLocked(rememberedThreadId, out _) || !_sessionsListReceived)
- return rememberedThreadId;
- }
-
- // Prefer the gateway's canonical main session (IsMain on SessionInfo)
- // so we never have to guess from a literal like "main". Only fall back
- // to the compose target (pre-materialization) or the first available
- // session when no main is present.
- for (int i = 0; i < _sessions.Length; i++)
- {
- var s = _sessions[i];
- if (s.IsMain && !string.IsNullOrEmpty(s.Key))
- return s.Key;
- }
- if (_bridge.HasHandshakeSnapshot
- && _bridge.MainSessionKey is { } mk
- && !string.IsNullOrWhiteSpace(mk))
- return mk;
- if (_sessions.Length > 0 && !string.IsNullOrEmpty(_sessions[0].Key))
- return _sessions[0].Key;
- return null;
- }
-
- private void RememberLastSessionStateLocked()
- {
- if (_sessions.Length == 0) return;
- var defaultThreadId = ResolveDefaultThreadIdLocked();
- var session = defaultThreadId is { Length: > 0 } && TryGetSessionLocked(defaultThreadId, out var selected)
- ? selected
- : _sessions.FirstOrDefault(s => s.IsMain && !string.IsNullOrEmpty(s.Key))
- ?? _sessions.FirstOrDefault(s => !string.IsNullOrEmpty(s.Key));
- if (session is null) return;
-
- _lastChatState = new LastChatState
- {
- DefaultThreadId = session.Key,
- ThreadTitle = SessionTitleFormatter.Format(session, _sessions),
- Model = session.Model,
- ModelProvider = session.Provider,
- AvailableModels = _availableModels,
- };
- }
-
- private bool TryGetSessionLocked(string threadId, out SessionInfo session)
- {
- for (int i = 0; i < _sessions.Length; i++)
- {
- var candidate = _sessions[i];
- if (string.Equals(candidate.Key, threadId, StringComparison.Ordinal))
- {
- session = candidate;
- return true;
- }
- }
-
- session = default!;
- return false;
- }
-
- private static ChatThread ToThread(SessionInfo s, string title)
- {
- var display = SessionDisplayResolver.Resolve(s);
- return new ChatThread
- {
- Id = s.Key ?? string.Empty,
- Title = title,
- AgentId = display.AgentId,
- IsBackground = display.IsBackground,
- Status = SessionVisibilityFilter.ToChatThreadStatus(s),
- Activity = SessionVisibilityFilter.ToChatThreadActivity(s),
- Workspace = s.Channel,
- Model = s.Model,
- ModelProvider = s.Provider,
- ThinkingLevel = s.ThinkingLevel,
- InputTokens = s.InputTokens,
- OutputTokens = s.OutputTokens,
- TotalTokens = s.TotalTokens,
- ContextTokens = s.ContextTokens,
- CreatedAt = s.StartedAt is { } st ? ToOffset(st) : null,
- UpdatedAt = s.UpdatedAt is { } ut ? ToOffset(ut) : null,
- };
- }
-
- private static DateTimeOffset ToOffset(DateTime dt)
- {
- // SessionInfo.StartedAt/UpdatedAt arrive as DateTimeKind.Local or
- // Unspecified depending on the parser path; new DateTimeOffset(local, Zero)
- // throws because the offset must match the kind. Treat Unspecified as
- // UTC (matches the gateway's wire format), and let the DateTimeOffset(dt)
- // single-arg ctor handle Local/Utc using the value's actual offset.
- if (dt.Kind == DateTimeKind.Unspecified)
- return new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc), TimeSpan.Zero);
- return new DateTimeOffset(dt);
- }
-
- // ── Dispatcher marshaling ──
-
- private void Publish(ChatDataSnapshot snapshot)
- {
- var args = new ChatDataChangedEventArgs(snapshot);
- if (_post is null)
- {
- Changed?.Invoke(this, args);
- }
- else
- {
- _post(() => Changed?.Invoke(this, args));
- }
-
- // Debounce-save last-known UI state so the next launch can show
- // meaningful labels while reconnecting instead of "Main session"/"model".
- if (snapshot.Threads.Length > 0 || snapshot.AvailableModels.Length > 0)
- DebounceSaveLastChatState(snapshot);
- }
-
- // ── Last-chat-state cache ──────────────────────────────────────────
- // Persists the last-known thread title, model, and available models so
- // the UI can show them while reconnecting instead of generic placeholders.
-
- private static readonly string LastChatStateFilePath = Path.Combine(
- AppIdentity.ResolveLocalDataDirectory(), "last-chat-state.json");
-
- private System.Threading.Timer? _lastChatStateSaveTimer;
- private long _lastChatStateSaveVersion;
-
- internal sealed class LastChatState
- {
- public string? DefaultThreadId { get; set; }
- public string? ThreadTitle { get; set; }
- public string? Model { get; set; }
- public string? ModelProvider { get; set; }
- public string[]? AvailableModels { get; set; }
- }
-
- private LastChatState? _lastChatState;
-
- internal static LastChatState? LoadLastChatState(string? pathOverride = null)
- {
- var path = pathOverride ?? LastChatStateFilePath;
- try
- {
- if (!File.Exists(path)) return null;
- var json = File.ReadAllText(path);
- return System.Text.Json.JsonSerializer.Deserialize(json);
- }
- catch (Exception ex)
- {
- Logger.Warn($"Failed to load last chat state from '{path}': {ex.Message}");
- return null;
- }
- }
-
- private void DebounceSaveLastChatState(ChatDataSnapshot snapshot)
- {
- // Find the default thread to capture its title/model
- var defaultThread = snapshot.DefaultThreadId is { } dtId
- ? Array.Find(snapshot.Threads, t => t.Id == dtId)
- : snapshot.Threads.Length > 0 ? snapshot.Threads[0] : null;
-
- if (defaultThread is null && snapshot.AvailableModels.Length == 0) return;
- var previous = _lastChatState;
-
- var state = new LastChatState
- {
- DefaultThreadId = snapshot.DefaultThreadId ?? previous?.DefaultThreadId,
- ThreadTitle = defaultThread?.Title ?? previous?.ThreadTitle,
- Model = defaultThread?.Model ?? previous?.Model,
- ModelProvider = defaultThread?.ModelProvider ?? previous?.ModelProvider,
- AvailableModels = snapshot.AvailableModels,
- };
-
- lock (_gate)
- {
- _lastChatState = state;
- _lastChatStateSaveVersion++;
- var saveVersion = _lastChatStateSaveVersion;
- _lastChatStateSaveTimer?.Dispose();
- var path = _lastChatStateFilePath;
- _lastChatStateSaveTimer = new System.Threading.Timer(_ => SaveLastChatStateIfCurrent(state, path, saveVersion), null, _lastChatStateSaveDelay, Timeout.InfiniteTimeSpan);
- }
- }
-
- private void SaveLastChatStateIfCurrent(LastChatState state, string path, long saveVersion)
- {
- lock (_gate)
- {
- if (saveVersion != _lastChatStateSaveVersion)
- return;
-
- SaveLastChatState(state, path);
- _lastChatStateSaveTimer?.Dispose();
- _lastChatStateSaveTimer = null;
- }
- }
-
- private static void SaveLastChatState(LastChatState state, string? pathOverride = null)
- {
- var path = pathOverride ?? LastChatStateFilePath;
- try
- {
- var dir = Path.GetDirectoryName(path);
- if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
- var json = System.Text.Json.JsonSerializer.Serialize(state);
- var tmp = path + ".tmp";
- File.WriteAllText(tmp, json);
- File.Move(tmp, path, overwrite: true);
- }
- catch (Exception ex) { Logger.Debug($"ChatDataProvider: persist LastChatState failed: {ex.Message}"); }
- }
-
- private void RaiseNotification(ChatProviderNotification notification)
- {
- var args = new ChatProviderNotificationEventArgs(notification);
- if (_post is null)
- {
- NotificationRequested?.Invoke(this, args);
- return;
- }
- _post(() => NotificationRequested?.Invoke(this, args));
- }
-
- // ── Abort persistence ──────────────────────────────────────────────
-
- private static readonly string AbortedIdsFilePath = Path.Combine(
- AppIdentity.ResolveLocalDataDirectory(), "aborted-messages.json");
-
- private static Dictionary> LoadAbortedIds()
- {
- try
- {
- if (!File.Exists(AbortedIdsFilePath))
- return new();
- var json = File.ReadAllText(AbortedIdsFilePath);
- var dict = System.Text.Json.JsonSerializer.Deserialize>>(json);
- if (dict is null) return new();
- var result = new Dictionary>();
- foreach (var (k, v) in dict)
- result[k] = new HashSet(v);
- return result;
- }
- catch (Exception ex)
- {
- Logger.Debug($"Aborted message IDs could not be loaded: {ex.Message}");
- return new();
- }
- }
-
- private void SaveAbortedIds()
- {
- try
- {
- Dictionary> snapshot;
- lock (_gate) snapshot = new Dictionary>(_persistedAbortedIds);
-
- var dir = Path.GetDirectoryName(AbortedIdsFilePath);
- if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
-
- // Convert HashSet to List for JSON serialization
- var serializable = new Dictionary>();
- foreach (var (k, v) in snapshot)
- serializable[k] = new List(v);
-
- var json = System.Text.Json.JsonSerializer.Serialize(serializable,
- new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
- File.WriteAllText(AbortedIdsFilePath, json);
- }
- catch (Exception ex) { Logger.Debug($"ChatDataProvider: persist aborted IDs failed: {ex.Message}"); }
- }
-
- // ── Tool metadata persistence ─────────────────────────────────────
-
- /// Cached tool call metadata entry persisted to disk.
- internal sealed class CachedToolMeta
- {
- public long Ts { get; set; }
- public string ToolName { get; set; } = "";
- public string Label { get; set; } = "";
- public string? ToolCallId { get; set; }
- public string? RunId { get; set; }
- public long LegacyTurn { get; set; }
- public JsonObject? ToolArgs { get; set; }
- public ChatToolIdentityStrength IdentityStrength { get; set; } = ChatToolIdentityStrength.Heuristic;
- }
-
- /// Attachment display metadata persisted without attachment bytes.
- internal sealed class CachedAttachmentMeta
- {
- public long Ts { get; set; }
- public string Text { get; set; } = "";
- public List Attachments { get; set; } = new();
- }
-
- internal sealed class CachedAttachmentItem
- {
- public string FileName { get; set; } = "";
- public bool IsImage { get; set; }
- }
-
- private static string DefaultToolMetaCacheFilePath
- {
- get
- {
- return Path.Combine(AppIdentity.ResolveLocalDataDirectory(), "tool-metadata.json");
- }
- }
-
- private static string DefaultAttachmentMetaCacheFilePath(string toolMetaCacheFilePath)
- {
- var dir = Path.GetDirectoryName(toolMetaCacheFilePath);
- return Path.Combine(
- string.IsNullOrEmpty(dir)
- ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
- : dir,
- "attachment-metadata.json");
- }
-
- /// Max sessions to keep in the tool metadata cache.
- internal const int MaxCachedSessions = 20;
-
- /// Max tool entries per session in the cache.
- internal const int MaxToolEntriesPerSession = 500;
-
- /// Max attachment-bearing user messages per session in the cache.
- internal const int MaxAttachmentEntriesPerSession = 500;
-
- private static Dictionary> LoadToolMetaCache(string cacheFilePath)
- {
- try
- {
- if (!File.Exists(cacheFilePath))
- return new();
- var json = File.ReadAllText(cacheFilePath);
- var dict = System.Text.Json.JsonSerializer.Deserialize>>(json);
- if (dict is not null)
- {
- foreach (var entry in dict.Values.SelectMany(entries => entries))
- {
- entry.ToolName = NormalizeCachedDisplayText(entry.ToolName);
- entry.Label = NormalizeCachedDisplayText(entry.Label);
- }
- }
- return dict ?? new();
- }
- catch (Exception ex)
- {
- Logger.Debug($"Tool metadata cache could not be loaded: {ex.Message}");
- return new();
- }
- }
-
- private static Dictionary> LoadAttachmentMetaCache(string cacheFilePath)
- {
- try
- {
- if (!File.Exists(cacheFilePath))
- return new();
- var json = File.ReadAllText(cacheFilePath);
- var dict = System.Text.Json.JsonSerializer.Deserialize>>(json);
- if (dict is not null)
- {
- foreach (var entry in dict.Values.SelectMany(entries => entries))
- {
- entry.Text = NormalizeCachedDisplayText(entry.Text);
- foreach (var attachment in entry.Attachments)
- attachment.FileName = NormalizeCachedDisplayText(attachment.FileName);
- }
- }
- return dict ?? new();
- }
- catch (Exception ex)
- {
- Logger.Debug($"Attachment metadata cache could not be loaded: {ex.Message}");
- return new();
- }
- }
-
- private void SaveAttachmentMetaCache()
- {
- try
- {
- Dictionary> snapshot;
- lock (_gate)
- {
- snapshot = _attachmentMetaCache.ToDictionary(
- kv => kv.Key,
- kv => kv.Value.Select(e => new CachedAttachmentMeta
- {
- Ts = e.Ts,
- Text = NormalizeCachedDisplayText(e.Text),
- Attachments = e.Attachments.Select(a => new CachedAttachmentItem
- {
- FileName = NormalizeCachedDisplayText(a.FileName),
- IsImage = a.IsImage
- }).ToList()
- }).ToList(),
- StringComparer.Ordinal);
- }
-
- if (snapshot.Count > MaxCachedSessions)
- {
- var toRemove = snapshot
- .OrderBy(kv => kv.Value.Count > 0 ? kv.Value[^1].Ts : 0)
- .Take(snapshot.Count - MaxCachedSessions)
- .Select(kv => kv.Key)
- .ToList();
- foreach (var k in toRemove) snapshot.Remove(k);
- }
-
- var json = System.Text.Json.JsonSerializer.Serialize(snapshot, CacheJsonOptions);
-
- lock (_attachmentMetaSaveGate)
- {
- var dir = Path.GetDirectoryName(_attachmentMetaCacheFilePath);
- if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
-
- var tempPath = _attachmentMetaCacheFilePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
- try
- {
- File.WriteAllText(tempPath, json);
- File.Move(tempPath, _attachmentMetaCacheFilePath, overwrite: true);
- }
- finally
- {
- try
- {
- if (File.Exists(tempPath))
- File.Delete(tempPath);
- }
- catch (Exception ex)
- {
- Logger.Debug($"Attachment metadata temp file cleanup failed: {ex.Message}");
- }
- }
- }
- }
- catch (Exception ex)
- {
- Logger.Debug($"Attachment metadata cache could not be saved: {ex.Message}");
- }
- }
-
- private void CacheAttachmentMeta(
- string? sessionId,
- string threadId,
- string text,
- IReadOnlyList attachments,
- long tsMs,
- long? expectedResetVersion = null)
- {
- if (attachments.Count == 0)
- return;
-
- var items = attachments
- .Where(a => !string.IsNullOrWhiteSpace(a.FileName))
- .Select(a => new CachedAttachmentItem
- {
- FileName = NormalizeCachedDisplayText(a.FileName),
- IsImage = string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase)
- })
- .ToList();
- if (items.Count == 0)
- return;
-
- lock (_gate)
- {
- if (_disposed)
- return;
-
- if (expectedResetVersion is { } version &&
- GetResetVersionLocked(threadId) != version)
+ try
{
- return;
+ TryDispatchNextQueuedSend(threadId);
}
-
- var key = !string.IsNullOrEmpty(sessionId) ? sessionId! : threadId;
- if (!_attachmentMetaCache.TryGetValue(key, out var list))
+ catch (Exception ex)
{
- list = new List();
- _attachmentMetaCache[key] = list;
+ Logger.Warn($"[Queue] Scheduled queued send drain failed for threadId='{threadId}': {ex.Message}");
}
-
- list.Add(new CachedAttachmentMeta
- {
- Ts = tsMs,
- Text = NormalizeCachedDisplayText(TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(text))),
- Attachments = items
- });
-
- if (list.Count > MaxAttachmentEntriesPerSession)
- list.RemoveRange(0, list.Count - MaxAttachmentEntriesPerSession);
- }
-
- SaveAttachmentMetaCache();
- }
-
- private AttachmentMetaMatcher CreateAttachmentMetaMatcher(string? sessionId, string threadId)
- {
- var entries = new List();
- lock (_gate)
- {
- if (!string.IsNullOrEmpty(sessionId) &&
- _attachmentMetaCache.TryGetValue(sessionId!, out var sessionEntries))
- entries.AddRange(CloneAttachmentMeta(sessionEntries));
-
- if (!string.IsNullOrEmpty(threadId) &&
- (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) &&
- _attachmentMetaCache.TryGetValue(threadId, out var threadEntries))
- entries.AddRange(CloneAttachmentMeta(threadEntries));
- }
-
- return new AttachmentMetaMatcher(entries.OrderBy(e => e.Ts).ToList());
+ });
}
- private static List CloneAttachmentMeta(List entries) =>
- entries.Select(e => new CachedAttachmentMeta
- {
- Ts = e.Ts,
- Text = NormalizeCachedDisplayText(e.Text),
- Attachments = e.Attachments.Select(a => new CachedAttachmentItem
- {
- FileName = NormalizeCachedDisplayText(a.FileName),
- IsImage = a.IsImage
- }).ToList()
- }).ToList();
-
- private sealed class AttachmentMetaMatcher
+ private async Task FetchRemoteUserMessageAsync(string threadId, bool openResetGateOnSuccess = false)
{
- private static readonly TimeSpan MatchWindow = TimeSpan.FromHours(24);
- private readonly List