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;
+
+/// <summary>
+/// Owns bounded approval-request identity deduplication and alternate-ID
+/// correlation. The conversation-state root serializes every call.
+/// </summary>
+internal sealed class ChatApprovalState
+{
+    private const int SeenCapacity = 128;
+
+    private readonly LinkedList<string> _seenOrder = new();
+    private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
+    private readonly Dictionary<string, string> _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;
+
+    /// <summary>
+    /// Truncate <paramref name="text"/> to at most
+    /// <see cref="MaxEntryTextBytes"/> bytes when encoded as UTF-8 and
+    /// append a <c> … [N bytes truncated]</c> 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.
+    /// </summary>
+    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());
+    }
+
+    /// <summary>
+    /// True when text is one of the approval slash-commands we send on the
+    /// user's behalf (<c>/approve &lt;slug&gt; allow-once</c>,
+    /// <c>/approve &lt;slug&gt; allow-always</c>, or
+    /// <c>/deny &lt;slug&gt;</c>). Matches the exact dashboard grammar
+    /// — not just the prefix — so legitimate user prose like
+    /// "/approve the design changes" still renders as a normal bubble.
+    /// </summary>
+    /// <remarks>
+    /// 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.
+    /// </remarks>
+    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<char> s_seamPunctChars = SearchValues.Create(".!?:");
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    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");
+    }
+
+    /// <summary>
+    /// Apply <see cref="TruncateForChatEntry(string?)"/> to whichever text
+    /// payload a <see cref="ChatEvent"/> carries. Returns the input
+    /// unchanged when there is nothing to truncate or the text already
+    /// fits. Used by <see cref="ApplyEventAndPublish"/> to enforce the
+    /// per-message size cap on every code path.
+    /// </summary>
+    /// <remarks>
+    /// Coverage: every <see cref="ChatEvent"/> subtype that carries a
+    /// caller-supplied text payload is truncated here, including the
+    /// currently-unused
+    /// <see cref="ChatModelChangedEvent"/> /
+    /// <see cref="ChatPermissionRequestEvent"/> /
+    /// <see cref="ChatIntentEvent"/> shapes — these don't flow through
+    /// <see cref="ApplyEventAndPublish"/> today but covering them now
+    /// prevents a future caller from bypassing the cap when wiring
+    /// them up. The <see cref="ChatTurnEndEvent"/> /
+    /// <see cref="ChatContextChangedEvent"/> shapes have no untrusted
+    /// text fields and fall through unchanged.
+    /// </remarks>
+    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;
+
+/// <summary>
+/// Single lock root for atomic conversation, queue, reset, connection, and
+/// history commit state. All exposed operations are closed domain transitions.
+/// </summary>
+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<string, ChatTimelineState> _timelines = new();
+    private readonly Dictionary<string, Dictionary<string, ChatEntryMetadata>> _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<string, ChatEntryMetadata> GetEntryMetadata(string threadId)
+    {
+        lock (_gate)
+        {
+            return _entryMeta.TryGetValue(threadId, out var metadata)
+                ? new Dictionary<string, ChatEntryMetadata>(metadata)
+                : new Dictionary<string, ChatEntryMetadata>();
+        }
+    }
+
+    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<string, ChatEntryMetadata>();
+            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<ChatAttachment>? 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<string>(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<ChatDataSnapshot>();
+            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<string, ChatEntryMetadata> 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<string, ChatEntryMetadata> GetOrCreateThreadMetaLocked(
+        string threadId)
+    {
+        if (!_entryMeta.TryGetValue(threadId, out var metadata))
+        {
+            metadata = new Dictionary<string, ChatEntryMetadata>(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<string, ChatTimelineState>(_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);
+
+/// <summary>
+/// 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.
+/// </summary>
+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<TimeSpan, CancellationToken, Func<Task>, Task> _retryScheduler;
+    private readonly Action? _failureReservedForTesting;
+    private readonly Dictionary<string, long> _inFlight = new(StringComparer.Ordinal);
+    private readonly Dictionary<string, ChatHistoryCommitToken> _authoritativePending =
+        new(StringComparer.Ordinal);
+    private readonly Dictionary<string, ChatHistoryCommitToken> _replacementPending =
+        new(StringComparer.Ordinal);
+    private readonly Dictionary<ChatHistoryCommitToken, int> _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<TimeSpan, CancellationToken, Func<Task>, 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<ChatHistoryLoadResult>? 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<string, ChatHistoryCommitToken> 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<ChatHistoryInfo>? 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<string, ChatEntryMetadata>(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<string>();
+        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<ChatMessageInfo> OrderHistoryMessages(
+        IReadOnlyList<ChatMessageInfo> 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<ChatHistoryReplayPart> Project(
         IEnumerable<ChatMessageInfo> 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;
+
+/// <summary>
+/// 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.
+/// </summary>
+internal sealed class ChatHistoryState
+{
+    private readonly Dictionary<string, string> _sessionIds = new();
+    private readonly HashSet<string> _loadedThreads = new();
+    private readonly Dictionary<string, long> _revisions = new();
+    private readonly Dictionary<string, string> _resetClearedSessionIds = new();
+    private readonly Dictionary<string, long> _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<string, long> SnapshotRevisions() =>
+        new Dictionary<string, long>(_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<SessionInfo> 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<string, ChatEntryMetadata> Metadata)
+        MergeWithLiveEntries(
+            ChatHistoryRebuildPlan plan,
+            ChatTimelineState prior,
+            IReadOnlyDictionary<string, ChatEntryMetadata> priorMetadata,
+            DateTimeOffset requestStartedAt,
+            bool authoritative)
+    {
+        var rebuilt = plan.Timeline;
+        var rebuiltMetadata = new Dictionary<string, ChatEntryMetadata>(
+            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<string, List<long>>(
+            StringComparer.Ordinal);
+        var messageIds = new HashSet<string>(StringComparer.Ordinal);
+        var sequenceCounts = new Dictionary<string, int>(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<string, int> counts,
+        string key) =>
+        counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1;
+
+    private static bool TryConsumeCount(
+        Dictionary<string, int> 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<string, List<long>> 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;
+
+/// <summary>
+/// Owns active-run identity/sequences, abort suppression and deferral, and
+/// bounded terminal-run deduplication. The root serializes every operation.
+/// </summary>
+internal sealed class ChatLifecycleState
+{
+    private const int TerminalRunCapacity = 64;
+
+    private readonly Dictionary<string, string> _activeRunIds = new();
+    private readonly Dictionary<string, long> _activeRunStartSequences = new();
+    private readonly Dictionary<string, int> _pendingAbortCounts = new();
+    private readonly HashSet<string> _abortedRunIds = new();
+    private readonly HashSet<string> _abortedThreads = new();
+    private readonly Dictionary<string, List<string>> _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<string> 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<string> 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<ChatMetadataStore.CachedAttachmentMeta> _entries;
+    private readonly bool[] _used;
+
+    public AttachmentMetaMatcher(List<ChatMetadataStore.CachedAttachmentMeta> 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;
+    }
+}
+
+/// <summary>
+/// Owns the live tool and attachment metadata caches, their persistence
+/// lifecycle, and attachment marker security/rehydration.
+/// </summary>
+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<CachedAttachmentItem> 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<string, List<CachedToolMeta>> _toolCache;
+    private Dictionary<string, List<CachedAttachmentMeta>> _attachmentCache;
+    private readonly Dictionary<string, long> _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<ChatAttachment> 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<CachedToolMeta>? GetToolMetadata(
+        string? sessionId,
+        string threadId,
+        long resetGeneration)
+    {
+        if (string.IsNullOrEmpty(sessionId) && string.IsNullOrEmpty(threadId))
+            return null;
+
+        lock (_gate)
+        {
+            var entries = new List<CachedToolMeta>();
+            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<CachedToolMeta>(entries.OrderBy(entry => entry.Ts));
+        }
+    }
+
+    internal AttachmentMetaMatcher CreateAttachmentMatcher(
+        string? sessionId,
+        string threadId,
+        long resetGeneration)
+    {
+        var entries = new List<CachedAttachmentMeta>();
+        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<CachedToolMeta>? 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<string, List<CachedToolMeta>> 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<string, List<CachedAttachmentMeta>> 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<T>(Dictionary<string, List<T>> 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<string, List<CachedToolMeta>> LoadToolMetaCache(string cacheFilePath)
+    {
+        try
+        {
+            if (!File.Exists(cacheFilePath))
+                return [];
+            var json = File.ReadAllText(cacheFilePath);
+            var cache = JsonSerializer.Deserialize<Dictionary<string, List<CachedToolMeta>>>(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<string, List<CachedAttachmentMeta>> LoadAttachmentMetaCache(string cacheFilePath)
+    {
+        try
+        {
+            if (!File.Exists(cacheFilePath))
+                return [];
+            var json = File.ReadAllText(cacheFilePath);
+            var cache = JsonSerializer.Deserialize<Dictionary<string, List<CachedAttachmentMeta>>>(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<ChatAttachment> 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<CachedAttachmentItem> 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<string>(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<string>(out var incomingText))
+                {
+                    continue;
+                }
+
+                if (merged[key] is JsonValue existingValue &&
+                    existingValue.TryGetValue<string>(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);
+
+/// <summary>
+/// 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.
+/// </summary>
+internal sealed class ChatPresentationState
+{
+    private readonly Dictionary<string, Task> _pendingModelPatches = new();
+
+    private SessionInfo[] _sessions = [];
+    private bool _sessionsListReceived;
+    private string[] _availableModels = [];
+    private IReadOnlyList<ChatModelChoice> _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<string, ChatUsageSnapshot> 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<string, ChatTimelineState>(),
+                timelineGenerations: new Dictionary<string, long>(),
+                historyRevisions: new Dictionary<string, long>(),
+                queuedMessages: new Dictionary<string, IReadOnlyList<ChatQueuedMessage>>(),
+                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<string, ChatTimelineState> timelines,
+        IReadOnlyDictionary<string, long> timelineGenerations,
+        IReadOnlyDictionary<string, long> historyRevisions,
+        IReadOnlyDictionary<string, IReadOnlyList<ChatQueuedMessage>> 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<string, ChatTimelineState> 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<ChatModelChoice> choices)
+    {
+        var seen = new HashSet<string>(StringComparer.Ordinal);
+        return choices
+            .Where(choice => choice.IsSelectable && seen.Add(choice.Id))
+            .Select(choice => choice.Id)
+            .ToArray();
+    }
+
+    private static IReadOnlyList<ChatModelChoice> ChoicesFromIds(string[] ids)
+    {
+        var seen = new HashSet<string>(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);
+
+/// <summary>
+/// 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.
+/// </summary>
+internal sealed class ChatQueueState
+{
+    private const int MaxLocalEchoes = 20;
+    private static readonly TimeSpan LocalEchoWindow = TimeSpan.FromSeconds(30);
+
+    private readonly Dictionary<string, Queue<ChatLocalSentText>> _localSentTexts =
+        new();
+    private readonly Dictionary<string, List<ChatQueuedMessage>> _messages = new();
+    private readonly Dictionary<string, List<ChatQueuedSendRequest>> _requests = new();
+    private readonly Dictionary<string, Dictionary<string, string>> _messageIdsByRunId =
+        new();
+    private readonly HashSet<string> _drainScheduledThreads =
+        new(StringComparer.Ordinal);
+    private readonly HashSet<string> _assistantFallbackPromotedThreads =
+        new(StringComparer.Ordinal);
+    private readonly HashSet<string> _locallyInitiatedThreads = new();
+
+    private long _messageSequence;
+
+    internal string NextMessageId() => $"q{++_messageSequence}";
+
+    internal IReadOnlyDictionary<string, IReadOnlyList<ChatQueuedMessage>>
+        SnapshotMessages() =>
+        _messages.ToDictionary(
+            pair => pair.Key,
+            pair => (IReadOnlyList<ChatQueuedMessage>)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<string, string>(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<ChatLocalSentText>();
+        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<ChatLocalSentText>(
+            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<ChatLocalSentText>();
+            _localSentTexts[threadId] = queue;
+        }
+        queue.Enqueue(new ChatLocalSentText(
+            text,
+            DateTimeOffset.UtcNow,
+            messageId));
+        while (queue.Count > MaxLocalEchoes)
+            queue.Dequeue();
+    }
+
+    private void RemoveEmptyThread(
+        string threadId,
+        List<ChatQueuedMessage> messages)
+    {
+        if (messages.Count != 0)
+            return;
+        _messages.Remove(threadId);
+        _drainScheduledThreads.Remove(threadId);
+    }
+
+    private void StoreLocalEchoQueue(
+        string threadId,
+        Queue<ChatLocalSentText> 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);
+
+/// <summary>
+/// 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.
+/// </summary>
+internal sealed class ChatResetState
+{
+    private const long TimestampToleranceMs = 1000;
+    private static readonly TimeSpan LocalEchoWindow = TimeSpan.FromSeconds(30);
+
+    private readonly Dictionary<string, long> _versions = new();
+    private readonly Dictionary<string, long> _cutoffUtcMs = new();
+    private readonly HashSet<string> _awaitingUserMessage = new();
+    private readonly Dictionary<string, HashSet<string>> _ignoredRunIds = new();
+    private readonly Dictionary<string, Dictionary<string, Queue<DateTimeOffset>>>
+        _submittedLocalEchoTexts = new();
+    private readonly Dictionary<string, HashSet<string>> _acceptedRunIds = new();
+    private readonly Dictionary<string, long> _localSendWithoutRunVersions = new();
+    private readonly Dictionary<string, long> _localSendWithoutRunStartSequences =
+        new();
+    private readonly Dictionary<string, long> _localEchoSequences = new();
+    private readonly Dictionary<string, List<PendingLocalSubmission>>
+        _pendingLocalSubmissions = new();
+    private readonly Dictionary<string, List<PendingLifecycleStart>>
+        _pendingLifecycleStarts = new();
+    private readonly Dictionary<string, AcceptedLifecycleFloor>
+        _acceptedLifecycleFloors = new();
+    private readonly HashSet<string> _remoteBackfillInFlight = new();
+    private readonly HashSet<string> _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<string, long> SnapshotVersions() =>
+        new Dictionary<string, long>(_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<string>(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<string, Queue<DateTimeOffset>>(
+                StringComparer.Ordinal);
+            _submittedLocalEchoTexts[threadId] = texts;
+        }
+        var normalized = text.Trim();
+        if (!texts.TryGetValue(normalized, out var timestamps))
+        {
+            timestamps = new Queue<DateTimeOffset>();
+            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<string>(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<PendingLocalSubmission> 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<string, ChatEntryMetadata> 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<ChatAttachment>? 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<string, ChatTimelineState> Timelines,
+    IReadOnlyDictionary<string, long> TimelineGenerations,
+    IReadOnlyDictionary<string, long> HistoryRevisions,
+    IReadOnlyDictionary<string, IReadOnlyList<ChatQueuedMessage>> QueuedMessages,
+    bool SessionsListReceived,
+    string[] AvailableModels,
+    IReadOnlyList<ChatModelChoice> 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<ChatThread>(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<string, HashSet<string>> _abortedIds;
+    private readonly Dictionary<string, Dictionary<string, long>>
+        _abortedIdGenerations;
+    private readonly Dictionary<string, long> _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<string, List<string>> 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<string> FindAbortedMessageIds(
+        string threadId,
+        IReadOnlyList<ChatMessageInfo> messages,
+        long resetGeneration = 0) =>
+        FindAbortedMessageIds(messages, threadId, resetGeneration);
+
+    internal bool TryAddAbortedIds(
+        string threadId,
+        long resetGeneration,
+        IReadOnlyList<string> 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<string>(StringComparer.Ordinal);
+                _abortedIds[threadId] = ids;
+            }
+            if (!_abortedIdGenerations.TryGetValue(
+                    threadId,
+                    out var generations))
+            {
+                generations = new Dictionary<string, long>(
+                    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<OpenClawChatDataProvider.LastChatState>(
+                File.ReadAllText(path));
+        }
+        catch (Exception ex)
+        {
+            Logger.Warn($"Failed to load last chat state from '{path}': {ex.Message}");
+            return null;
+        }
+    }
+
+    private static Dictionary<string, HashSet<string>> LoadAbortedIds(string path)
+    {
+        try
+        {
+            if (!File.Exists(path))
+                return [];
+            var persisted = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(
+                File.ReadAllText(path));
+            return persisted?.ToDictionary(
+                pair => pair.Key,
+                pair => new HashSet<string>(pair.Value, StringComparer.Ordinal),
+                StringComparer.Ordinal) ?? [];
+        }
+        catch (Exception ex)
+        {
+            Logger.Debug($"Aborted message IDs could not be loaded: {ex.Message}");
+            return [];
+        }
+    }
+
+    private List<string> FindAbortedMessageIds(
+        IReadOnlyList<ChatMessageInfo> messages,
+        string threadId,
+        long resetGeneration)
+    {
+        var result = new List<string>();
+        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<string, TurnState> _turnsByMessageId = new(StringComparer.Ordinal);
     private readonly Dictionary<string, TurnState> _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<string> 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
 /// </remarks>
 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;
     /// <summary>
     /// Process-wide cache mapping an attachment's filename to its raw image
     /// bytes. Populated by <see cref="SendMessageAsync"/> 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<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<TimeSpan, CancellationToken, Func<Task>, Task> _scheduleHistoryRetry;
-    private readonly Action? _historyFailureReservedForTesting;
-    private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes
-    private long _toolMetaSaveVersion;
-    private bool _toolMetaCacheDirty;
-    private readonly Dictionary<string, ChatTimelineState> _timelines = new();
-    private readonly Dictionary<string, string> _activeRunIds = new();   // sessionKey → runId
-    private readonly Dictionary<string, long> _activeRunStartSequences = new(); // sessionKey → lifecycle.start sequence
-    private readonly Dictionary<string, int> _pendingAbortCounts = new(); // threads → count of pending aborts waiting for lifecycle.start
-    private readonly HashSet<string> _abortedRunIds = new();             // runIds whose events should be suppressed
-    private readonly HashSet<string> _abortedThreads = new();            // threads with active abort — suppress chat messages (no runId on those)
-    private Dictionary<string, HashSet<string>> _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<Func<Task>, Task> _deferredAbortScheduler;
 
     /// <summary>Whether any thread is in an aborted state (suppress TTS/notifications).</summary>
-    public bool IsResponseSuppressed { get { lock (_gate) return _abortedThreads.Count > 0; } }
-
-    private readonly Dictionary<string, string> _sessionIds = new();      // sessionKey → immutable sessionId
-    private readonly HashSet<string> _historyLoaded = new();              // sessionKey
-    private readonly HashSet<string> _historyInFlight = new();            // sessionKey
-    private readonly HashSet<string> _authoritativeHistoryReloadPending = new(); // sessionKey
-    private readonly HashSet<string> _replacementHistoryReloadPending = new(); // sessionKey
-    private readonly Dictionary<string, long> _historyReplacementVersions = new(); // sessionKey -> replacement generation
-    private CancellationTokenSource _historyGenerationCancellation = new();
-    private long _historyConnectionVersion;
-    private readonly Dictionary<string, Task> _pendingModelPatches = new(); // sessionKey -> in-flight model set/clear
-    private readonly Dictionary<string, long> _resetVersions = new(); // sessionKey -> reset generation
-    private readonly Dictionary<string, long> _historyRevisions = new(); // sessionKey -> completed history rebuild revision
-    private readonly Dictionary<string, long> _resetCutoffUtcMs = new(); // sessionKey -> local reset time
-    private readonly HashSet<string> _resetAwaitingUserMessage = new(); // threads reset and waiting for first post-reset turn
-    private readonly Dictionary<string, HashSet<string>> _resetIgnoredRunIds = new(); // sessionKey -> pre-reset run IDs to drop
-    private readonly Dictionary<string, Dictionary<string, Queue<DateTimeOffset>>> _resetSubmittedLocalEchoTexts = new(); // sessionKey -> pre-reset local user echoes that reached the gateway
-    private readonly Dictionary<string, HashSet<string>> _resetAcceptedRunIds = new(); // sessionKey -> post-reset run IDs allowed to open the gate
-    private readonly Dictionary<string, long> _resetLocalSendWithoutRunVersions = new(); // sessionKey -> reset generation for no-runId sends
-    private readonly Dictionary<string, long> _resetLocalSendWithoutRunStartSequences = new(); // sessionKey -> lifecycle sequence at local send start
-    private readonly Dictionary<string, long> _resetLocalEchoSequences = new(); // sessionKey -> lifecycle sequence when local echo was observed
-    private readonly Dictionary<string, List<PendingResetLifecycleStart>> _resetPendingLifecycleStarts = new(); // sessionKey -> lifecycle.start seen before proof
-    private readonly HashSet<string> _resetRemoteBackfillInFlight = new(); // threads proving a timestamp-less remote user frame via history
-    private long _resetLifecycleStartSequence;
-    private long _lifecycleStartSequence;
-    private readonly HashSet<string> _resetRemoteUserSeen = new(); // threads with a fresh remote post-reset user frame
-    private readonly Dictionary<string, string> _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<string, List<CachedToolMeta>> _toolMetaCache;
-    private Dictionary<string, List<CachedAttachmentMeta>> _attachmentMetaCache;
-    // Track recently-sent local user message texts so we can suppress
-    // SSE echoes while still displaying messages from other clients.
-    private readonly Dictionary<string, Queue<LocalSentText>> _localSentTexts = new();
-    private readonly Dictionary<string, List<ChatQueuedMessage>> _queuedMessages = new();
-    private readonly Dictionary<string, List<QueuedSendRequest>> _queuedSendRequests = new();
-    private readonly Dictionary<string, Dictionary<string, string>> _queuedMessageIdsByRunId = new();
-    private readonly Dictionary<string, List<string>> _terminalRunIdsByThread = new();
-    private readonly HashSet<string> _queuedDrainScheduledThreads = new(StringComparer.Ordinal);
-    private readonly HashSet<string> _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<string> _locallyInitiatedThreads = new();
-    // Per-thread retry count for LoadHistoryAsync to prevent unbounded retry loops.
-    private readonly Dictionary<string, int> _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<ChatAttachment>? 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 "<sender> · <local time> · <model>" footer
-    // beneath each message without having to extend the vendored
-    // <see cref="ChatTimelineItem"/> record.
-    private readonly Dictionary<string, Dictionary<string, ChatEntryMetadata>> _entryMeta = new();
-    private SessionInfo[] _sessions = Array.Empty<SessionInfo>();
-    // 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<string>();
-    private IReadOnlyList<ChatModelChoice> _modelChoices = Array.Empty<ChatModelChoice>();
-    // 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";
 
     /// <summary>Last-known chat state from a previous session, used for pre-connection UI.</summary>
-    internal LastChatState? CachedLastChatState => _lastChatState;
+    internal LastChatState? CachedLastChatState => _state.CachedLastChatState;
 
     public event EventHandler<ChatDataChangedEventArgs>? Changed;
     public event EventHandler<ChatProviderNotificationEventArgs>? NotificationRequested;
@@ -241,7 +111,7 @@ private enum AssistantQueueFrameDisposition
     /// the source event on (acceptable in unit tests).
     /// </param>
     public OpenClawChatDataProvider(IChatGatewayBridge bridge, Action<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<TimeSpan, CancellationToken, Func<Task>, Task>? historyRetryScheduler = null,
-        Action? historyFailureReservedForTesting = null)
+        Action? historyFailureReservedForTesting = null,
+        Func<Func<Task>, 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<ChatDataSnapshot> LoadAsync(CancellationToken cancellationToken = default)
     {
         cancellationToken.ThrowIfCancellationRequested();
-        // Seed from whatever the bridge already knows about.
         var sessions = _bridge.GetSessionList() ?? Array.Empty<SessionInfo>();
-        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<bool> EnqueueCompactCommandAsync(
@@ -460,30 +276,10 @@ internal Task<bool> 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<bool> 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<bool> 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 <paramref name="force"/>
     /// is true. Safe to call from any thread.
     /// </summary>
-    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<ChatHistoryInfo>? 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<string, ChatEntryMetadata>();
-                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<string>(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<string>();
-                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 <slug> allow-once",
-                            // "/deny <slug>") 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<Task> 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<string, ChatEntryMetadata>();
-
-                    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<string, List<long>>(StringComparer.Ordinal);
-                    var rebuiltMessageIds = new HashSet<string>(StringComparer.Ordinal);
-                    var rebuiltSequenceCounts = new Dictionary<string, int>(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<long>();
-                            list.Add(rts.ToUnixTimeSeconds());
-                        }
-                    }
-
-                    var existingIds = new HashSet<string>(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<long>();
-                            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<Task> 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<Task> 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.
     /// </summary>
     public IReadOnlyDictionary<string, ChatEntryMetadata> GetEntryMetadata(string threadId)
-    {
-        lock (_gate)
-        {
-            return _entryMeta.TryGetValue(threadId, out var m)
-                ? new Dictionary<string, ChatEntryMetadata>(m)
-                : new Dictionary<string, ChatEntryMetadata>();
-        }
-    }
+        => _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<string>();
-                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<string>();
-                threadsToReset = Array.Empty<string>();
-            }
-
-            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<SessionInfo>();
-            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<string>();
-            }
-        }
-        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<string> runIds)
@@ -2527,742 +1156,410 @@ private void AbortSubmittedRunsAfterReset(string threadId, IReadOnlyList<string>
 
     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<ChatModelChoice> choices)
+    private void OnChatMessageReceived(object? sender, ChatMessageInfo message)
     {
-        if (choices.Count == 0) return Array.Empty<string>();
-        var seen = new HashSet<string>(StringComparer.Ordinal);
-        var ids = new List<string>(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<ChatModelChoice> ChoicesFromIds(string[] ids)
-    {
-        if (ids.Length == 0) return Array.Empty<ChatModelChoice>();
-        var seen = new HashSet<string>(StringComparer.Ordinal);
-        var list = new List<ChatModelChoice>(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 <slug> allow-once",
-            // "/approve <slug> allow-always",
-            // "/deny <slug>") 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<string>();
-            _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<LocalSentText> 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<LocalSentText>(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<LocalSentText> 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<ChatQueuedMessage>();
-            _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<QueuedSendRequest>();
-            _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<LocalSentText>();
-            _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<string, string>(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<LocalSentText>(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);
-    }
-
-    /// <summary>
-    /// 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.
-    /// </summary>
-    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<string> _approvalSeenOrder = new();
-    private readonly System.Collections.Generic.HashSet<string> _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<string, string> _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 <slug>" 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
-        };
-    }
-
-    /// <summary>
-    /// 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:
-    /// <code>
-    /// {
-    ///   "stream": "item",
-    ///   "data": {
-    ///     "itemId": "tool:call_xxx|fc_yyy",
-    ///     "phase": "start" | "end",
-    ///     "kind": "tool" | "command" | "reasoning" | "message",
-    ///     "title": "exec run command openclaw → ..."
-    ///   }
-    /// }
-    /// </code>
-    ///
-    /// Tool items create chips. Command children upgrade the parent chip with
-    /// their specific identity and bounded safe display arguments.
-    /// </summary>
-    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<string>(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";
-    }
-
-    /// <summary>
-    /// 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.
-    /// </summary>
-    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
-        };
-    }
-
-    /// <summary>
-    /// 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.
-    /// </summary>
-    /// <remarks>
-    /// 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 <c>Debug</c> level so they
-    /// don't dominate the operator log under normal use.
-    /// </remarks>
-    internal const int MaxEntryTextBytes = 256 * 1024;
-
-    /// <summary>
-    /// Truncate <paramref name="text"/> to at most
-    /// <see cref="MaxEntryTextBytes"/> bytes when encoded as UTF-8 and
-    /// append a <c> … [N bytes truncated]</c> 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.
-    /// </summary>
-    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 ──
-
-    /// <summary>
-    /// True when text is one of the approval slash-commands we send on the
-    /// user's behalf (<c>/approve &lt;slug&gt; allow-once</c>,
-    /// <c>/approve &lt;slug&gt; allow-always</c>, or
-    /// <c>/deny &lt;slug&gt;</c>). Matches the exact dashboard grammar
-    /// — not just the prefix — so legitimate user prose like
-    /// "/approve the design changes" still renders as a normal bubble.
-    /// </summary>
-    /// <remarks>
-    /// 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.
-    /// </remarks>
-    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<char> s_seamPunctChars = SearchValues.Create(".!?:");
-
-    /// <summary>
-    /// 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.
-    /// </summary>
-    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 ──
-
-    /// <summary>
-    /// Apply <see cref="TruncateForChatEntry(string?)"/> to whichever text
-    /// payload a <see cref="ChatEvent"/> carries. Returns the input
-    /// unchanged when there is nothing to truncate or the text already
-    /// fits. Used by <see cref="ApplyEventAndPublish"/> to enforce the
-    /// per-message size cap on every code path.
-    /// </summary>
-    /// <remarks>
-    /// Coverage: every <see cref="ChatEvent"/> subtype that carries a
-    /// caller-supplied text payload is truncated here, including the
-    /// currently-unused
-    /// <see cref="ChatModelChangedEvent"/> /
-    /// <see cref="ChatPermissionRequestEvent"/> /
-    /// <see cref="ChatIntentEvent"/> shapes — these don't flow through
-    /// <see cref="ApplyEventAndPublish"/> today but covering them now
-    /// prevents a future caller from bypassing the cap when wiring
-    /// them up. The <see cref="ChatTurnEndEvent"/> /
-    /// <see cref="ChatContextChangedEvent"/> shapes have no untrusted
-    /// text fields and fall through unchanged.
-    /// </remarks>
-    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<string>(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<string, ChatEntryMetadata> GetOrCreateThreadMetaLocked(string threadId)
-    {
-        if (!_entryMeta.TryGetValue(threadId, out var meta))
-        {
-            meta = new Dictionary<string, ChatEntryMetadata>();
-            _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<string>(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<string>(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<string, Queue<DateTimeOffset>>(StringComparer.Ordinal);
-            _resetSubmittedLocalEchoTexts[threadId] = texts;
-        }
-
-        var normalized = text.Trim();
-        if (!texts.TryGetValue(normalized, out var timestamps))
-        {
-            timestamps = new Queue<DateTimeOffset>();
-            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<string>(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<PendingResetLifecycleStart>();
-            _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<string, ChatEntryMetadata> 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<ChatMessageInfo> OrderHistoryMessages(List<(ChatMessageInfo Message, int Index)> messages)
-    {
-        if (messages.Count == 0)
-            return new List<ChatMessageInfo>();
-
-        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<string, int> counts, string key)
-        => counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1;
-
-    private static bool TryConsumeCount(Dictionary<string, int> 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<string, List<long>> timestamps, string key)
-    {
-        if (timestamps.TryGetValue(key, out var values) && values.Count > 0)
-            values.RemoveAt(0);
-    }
-
-    private void SeedSessionIdsFromSessionsLocked(IEnumerable<SessionInfo> 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<ChatThread>(_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<string, ChatTimelineState>(_timelines);
-        var timelineGenerationsCopy = new Dictionary<string, long>(_resetVersions);
-        var historyRevisionsCopy = new Dictionary<string, long>(_historyRevisions);
-        var queuedMessagesCopy = _queuedMessages.ToDictionary(
-            kvp => kvp.Key,
-            kvp => (IReadOnlyList<ChatQueuedMessage>)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<LastChatState>(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<string, HashSet<string>> LoadAbortedIds()
-    {
-        try
-        {
-            if (!File.Exists(AbortedIdsFilePath))
-                return new();
-            var json = File.ReadAllText(AbortedIdsFilePath);
-            var dict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, List<string>>>(json);
-            if (dict is null) return new();
-            var result = new Dictionary<string, HashSet<string>>();
-            foreach (var (k, v) in dict)
-                result[k] = new HashSet<string>(v);
-            return result;
-        }
-        catch (Exception ex)
-        {
-            Logger.Debug($"Aborted message IDs could not be loaded: {ex.Message}");
-            return new();
-        }
-    }
-
-    private void SaveAbortedIds()
-    {
-        try
-        {
-            Dictionary<string, HashSet<string>> snapshot;
-            lock (_gate) snapshot = new Dictionary<string, HashSet<string>>(_persistedAbortedIds);
-
-            var dir = Path.GetDirectoryName(AbortedIdsFilePath);
-            if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
-
-            // Convert HashSet to List for JSON serialization
-            var serializable = new Dictionary<string, List<string>>();
-            foreach (var (k, v) in snapshot)
-                serializable[k] = new List<string>(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 ─────────────────────────────────────
-
-    /// <summary>Cached tool call metadata entry persisted to disk.</summary>
-    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;
-    }
-
-    /// <summary>Attachment display metadata persisted without attachment bytes.</summary>
-    internal sealed class CachedAttachmentMeta
-    {
-        public long Ts { get; set; }
-        public string Text { get; set; } = "";
-        public List<CachedAttachmentItem> 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");
-    }
-
-    /// <summary>Max sessions to keep in the tool metadata cache.</summary>
-    internal const int MaxCachedSessions = 20;
-
-    /// <summary>Max tool entries per session in the cache.</summary>
-    internal const int MaxToolEntriesPerSession = 500;
-
-    /// <summary>Max attachment-bearing user messages per session in the cache.</summary>
-    internal const int MaxAttachmentEntriesPerSession = 500;
-
-    private static Dictionary<string, List<CachedToolMeta>> LoadToolMetaCache(string cacheFilePath)
-    {
-        try
-        {
-            if (!File.Exists(cacheFilePath))
-                return new();
-            var json = File.ReadAllText(cacheFilePath);
-            var dict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, List<CachedToolMeta>>>(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<string, List<CachedAttachmentMeta>> LoadAttachmentMetaCache(string cacheFilePath)
-    {
-        try
-        {
-            if (!File.Exists(cacheFilePath))
-                return new();
-            var json = File.ReadAllText(cacheFilePath);
-            var dict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, List<CachedAttachmentMeta>>>(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<string, List<CachedAttachmentMeta>> 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<ChatAttachment> 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<CachedAttachmentMeta>();
-                _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<CachedAttachmentMeta>();
-        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<CachedAttachmentMeta> CloneAttachmentMeta(List<CachedAttachmentMeta> 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<CachedAttachmentMeta> _entries;
-        private readonly bool[] _used;
+        var telemetryReason = openResetGateOnSuccess
+            ? ChatBackfillTelemetryReason.ResetReconciliation
+            : ChatBackfillTelemetryReason.RemoteTurn;
+        var historyOperation = _telemetry.StartHistoryBackfill(telemetryReason);
+        var historyOutcome = ChatTelemetryOutcome.Success;
+        Exception? historyException = null;
+        var requestResetVersion = _state.GetResetGeneration(threadId);
 
-        public AttachmentMetaMatcher(List<CachedAttachmentMeta> entries)
+        try
         {
-            _entries = entries;
-            _used = new bool[entries.Count];
-        }
+            var history = await _bridge.RequestChatHistoryAsync(threadId);
+            if (history?.Messages is null || history.Messages.Count == 0) return;
 
-        public CachedAttachmentMeta? TryMatch(string text, long historyTsMs)
-        {
-            for (int i = 0; i < _entries.Count; i++)
+            // Find the last user message in history.
+            ChatMessageInfo? lastUser = null;
+            for (int i = history.Messages.Count - 1; i >= 0; 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;
+                var role = (history.Messages[i].Role ?? "").ToLowerInvariant();
+                var hText = history.Messages[i].Text;
+                if (role == "user"
+                    && !NativeToolProjector.LooksLikeSystemControlNote(hText)
+                    && !ChatContentFormatting.LooksLikeApprovalSlashCommand(hText))
+                {
+                    lastUser = history.Messages[i];
+                    break;
+                }
             }
+            if (lastUser is null || string.IsNullOrEmpty(lastUser.Text)) return;
 
-            return null;
+            var transition = _state.ApplyRemoteUserBackfill(
+                threadId,
+                lastUser,
+                requestResetVersion,
+                openResetGateOnSuccess,
+                ProjectionContext());
+            if (transition is null)
+                return;
+            HandleOpenedLifecycle(
+                threadId,
+                transition.OpenedLifecycle,
+                transition.RuntimeGeneration);
+            Publish(transition.Snapshot);
+            Logger.Info($"[REMOTE] Injected remote user message for threadId='{threadId}' len={lastUser.Text.Length}");
         }
-    }
-
-    private 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}";
-    }
-
-    private static string BuildAttachmentMarkerLines(IEnumerable<ChatAttachment> attachments) =>
-        string.Join("\n", attachments.Select(a =>
-            string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase)
-                ? $"\u200B🖼️ {a.FileName}"
-                : $"\u200B📎 {a.FileName}"));
-
-    private static string BuildAttachmentMarkerLines(IEnumerable<CachedAttachmentItem> attachments) =>
-        string.Join("\n", attachments.Select(a =>
-            a.IsImage
-                ? $"\u200B🖼️ {a.FileName}"
-                : $"\u200B📎 {a.FileName}"));
-
-    internal static string EscapeUntrustedAttachmentMarkerLines(string? text)
-    {
-        if (string.IsNullOrEmpty(text))
-            return text ?? string.Empty;
-
-        var lines = text.Split('\n');
-        var changed = false;
-        for (int i = 0; i < lines.Length; i++)
+        catch (Exception ex)
         {
-            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;
-            }
+            historyOutcome = ex is OperationCanceledException
+                ? ChatTelemetryOutcome.Canceled
+                : ChatTelemetryOutcome.Failure;
+            historyException = ex;
+            Logger.Warn($"[REMOTE] Failed to fetch remote user message for threadId='{threadId}': {ex.Message}");
         }
-
-        return changed ? string.Join('\n', lines) : text;
-    }
-
-    private void SaveToolMetaCache(long? expectedVersion = null)
-    {
-        try
+        finally
         {
-            Dictionary<string, List<CachedToolMeta>> snapshot;
-            lock (_gate)
-            {
-                if (expectedVersion is long version && (version != _toolMetaSaveVersion || _disposed))
-                    return;
-                if (!_toolMetaCacheDirty)
-                    return;
-
-                snapshot = _toolMetaCache.ToDictionary(
-                    kv => kv.Key,
-                    kv => kv.Value.Select(e => new CachedToolMeta
-                    {
-                        Ts = e.Ts,
-                        ToolName = NormalizeCachedDisplayText(e.ToolName),
-                        Label = NormalizeCachedDisplayText(e.Label),
-                        ToolCallId = e.ToolCallId,
-                        RunId = e.RunId,
-                        LegacyTurn = e.LegacyTurn,
-                        ToolArgs = NormalizeCachedToolArgs(e.ToolArgs),
-                        IdentityStrength = e.IdentityStrength
-                    }).ToList(),
-                    StringComparer.Ordinal);
-            }
-
-            // Evict oldest sessions if over the cap
-            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 (_toolMetaSaveGate)
-            {
-                if (expectedVersion is long version)
-                {
-                    lock (_gate)
-                    {
-                        if (version != _toolMetaSaveVersion || _disposed)
-                            return;
-                    }
-                }
-
-                var dir = Path.GetDirectoryName(_toolMetaCacheFilePath);
-                if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
-
-                // Write to a unique temp file then atomic move to avoid partial JSON on crash.
-                var tempPath = _toolMetaCacheFilePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
-                try
-                {
-                    File.WriteAllText(tempPath, json);
-                    File.Move(tempPath, _toolMetaCacheFilePath, overwrite: true);
-                    MarkToolMetaCacheSaved(expectedVersion);
-                }
-                finally
-                {
-                    try
-                    {
-                        if (File.Exists(tempPath))
-                            File.Delete(tempPath);
-                    }
-                    catch (Exception ex)
-                    {
-                        // Best-effort cleanup; persistence remains best-effort.
-                        Logger.Debug($"ChatDataProvider: temp tool-meta file delete failed: {ex.Message}");
-                    }
-                }
-            }
+            if (openResetGateOnSuccess)
+                _state.CompleteRemoteBackfill(threadId);
+            _telemetry.FinishHistoryBackfill(historyOperation, historyOutcome, historyException);
         }
-        catch (Exception ex) { Logger.Debug($"ChatDataProvider: persist tool meta cache failed: {ex.Message}"); }
     }
 
-    /// <summary>
-    /// Cache a tool call's metadata so it can be recovered when the gateway
-    /// flattens it during history replay on a future app launch.
-    /// </summary>
-    internal void CacheToolMeta(
+    private async Task PersistAbortedMessageIdsAsync(
         string threadId,
-        long tsMs,
-        string toolName,
-        string label,
-        string? toolCallId = null,
-        JsonObject? toolArgs = null,
-        ChatToolIdentityStrength identityStrength = ChatToolIdentityStrength.Heuristic,
-        string? runId = null,
-        long legacyTurn = 0)
+        long resetGeneration)
     {
-        System.Threading.Timer? timerToDispose = null;
-        long saveVersion;
-        runId = string.IsNullOrWhiteSpace(runId) ? null : runId;
-        lock (_gate)
+        try
         {
-            if (_disposed)
+            await Task.Delay(500).ConfigureAwait(false);
+            var history = await _bridge
+                .RequestChatHistoryAsync(threadId)
+                .ConfigureAwait(false);
+            if (!_state.IsCurrentResetGeneration(threadId, resetGeneration))
                 return;
-
-            var key = _sessionIds.TryGetValue(threadId, out var sessionId) && !string.IsNullOrEmpty(sessionId)
-                ? sessionId
-                : threadId;
-
-            if (!_toolMetaCache.TryGetValue(key, out var list))
-            {
-                list = new List<CachedToolMeta>();
-                _toolMetaCache[key] = list;
-            }
-
-            if (!string.IsNullOrWhiteSpace(toolCallId))
-            {
-                var existing = list.FindLast(entry =>
-                    string.Equals(entry.ToolCallId, toolCallId, StringComparison.Ordinal)
-                    && string.Equals(entry.RunId, runId, StringComparison.Ordinal)
-                    && (!string.IsNullOrWhiteSpace(runId) || 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);
-                    _toolMetaCacheDirty = true;
-                    saveVersion = ++_toolMetaSaveVersion;
-                    timerToDispose = _toolMetaSaveTimer;
-                    _toolMetaSaveTimer = new System.Threading.Timer(
-                        _ => SaveToolMetaCache(saveVersion),
-                        null,
-                        500,
-                        Timeout.Infinite);
-                    goto ExitLock;
-                }
-            }
-            else if (list.Count > 0 && list[^1].Ts == tsMs && list[^1].ToolName == toolName)
+            var ids = _persistence.FindAbortedMessageIds(
+                threadId,
+                history.Messages,
+                resetGeneration);
+            if (_persistence.TryAddAbortedIds(
+                    threadId,
+                    resetGeneration,
+                    ids))
             {
-                return;
+                _persistence.SaveAbortedIds();
             }
-
-            list.Add(new CachedToolMeta
-            {
-                Ts = tsMs,
-                ToolName = NormalizeCachedDisplayText(toolName),
-                Label = NormalizeCachedDisplayText(label),
-                ToolCallId = toolCallId,
-                RunId = string.IsNullOrWhiteSpace(runId) ? null : runId,
-                LegacyTurn = string.IsNullOrWhiteSpace(runId) ? legacyTurn : 0,
-                ToolArgs = NormalizeCachedToolArgs(toolArgs),
-                IdentityStrength = identityStrength
-            });
-
-            // Cap per-session entries
-            if (list.Count > MaxToolEntriesPerSession)
-                list.RemoveRange(0, list.Count - MaxToolEntriesPerSession);
-
-            // Debounce save — reset the timer on each cache addition so we only
-            // write once after 500ms of quiescence, avoiding concurrent file writes.
-            _toolMetaCacheDirty = true;
-            saveVersion = ++_toolMetaSaveVersion;
-            timerToDispose = _toolMetaSaveTimer;
-            _toolMetaSaveTimer = new System.Threading.Timer(_ => SaveToolMetaCache(saveVersion), null, 500, Timeout.Infinite);
-        ExitLock:
-            ;
         }
-        timerToDispose?.Dispose();
-    }
-
-    /// <summary>
-    /// Look up cached tool metadata for a session's history reconstruction.
-    /// Returns a queue of entries sorted by timestamp for sequential consumption.
-    /// </summary>
-    private Queue<CachedToolMeta>? GetCachedToolMetaForSession(string? sessionId, string threadId)
-    {
-        if (string.IsNullOrEmpty(sessionId) && string.IsNullOrEmpty(threadId)) return null;
-        lock (_gate)
+        catch (Exception ex)
         {
-            var entries = new List<CachedToolMeta>();
-            if (!string.IsNullOrEmpty(sessionId) &&
-                _toolMetaCache.TryGetValue(sessionId!, out var sessionEntries))
-            {
-                entries.AddRange(sessionEntries);
-            }
-
-            if (!string.IsNullOrEmpty(threadId) &&
-                (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) &&
-                _toolMetaCache.TryGetValue(threadId, out var threadEntries))
-            {
-                entries.AddRange(threadEntries);
-            }
-
-            if (entries.Count > 0)
-                return new Queue<CachedToolMeta>(entries.OrderBy(e => e.Ts));
+            Logger.Warn(
+                $"[ABORT-PERSIST] Failed to persist abort for thread {threadId}: {ex.Message}");
         }
-        return null;
-    }
-
-    /// <summary>
-    /// Try to match a history tool entry to a cached metadata entry.
-    /// Both the cache and history are chronologically ordered, so we consume
-    /// entries sequentially. The cache stores tool-start timestamps while
-    /// history stores tool-result timestamps (which can be minutes later),
-    /// so we match by order rather than timestamp proximity.
-    /// </summary>
-    internal static CachedToolMeta? TryMatchCachedTool(Queue<CachedToolMeta>? cache, long historyTsMs)
-    {
-        if (cache is null || cache.Count == 0) return null;
-
-        // Both sequences are chronological. Consume the next cached entry
-        // for each tool result we encounter in history.
-        // Guard: if the history timestamp is much OLDER than the next cached
-        // entry, this toolresult predates our cache — skip it.
-        var candidate = cache.Peek();
-        if (historyTsMs > 0 && candidate.Ts > 0 && candidate.Ts > historyTsMs + 300_000)
-            return null; // cached entry is >5 min after this history entry — not a match
-
-        var match = cache.Dequeue();
-        match.ToolName = NormalizeCachedDisplayText(match.ToolName);
-        match.Label = NormalizeCachedDisplayText(match.Label);
-        match.ToolArgs = NormalizeCachedToolArgs(match.ToolArgs);
-        return match;
     }
 
-    private static JsonObject? NormalizeCachedToolArgs(JsonObject? args)
-    {
-        if (args is null)
-            return null;
+    internal static string TruncateForChatEntry(string? text) => ChatContentFormatting.TruncateForChatEntry(text);
 
-        var normalized = new JsonObject();
-        foreach (var key in NativeToolProjector.DisplayArgumentKeys)
-        {
-            if (args[key] is JsonValue value
-                && value.TryGetValue<string>(out var text))
-            {
-                var safe = NativeToolProjector.SanitizeToolDisplayValue(NormalizeCachedDisplayText(text));
-                if (!string.IsNullOrWhiteSpace(safe))
-                    normalized[key] = safe;
-            }
-        }
-        return normalized.Count == 0 ? null : normalized;
-    }
+    internal static bool LooksLikeSystemControlNote(string text) => NativeToolProjector.LooksLikeSystemControlNote(text);
 
-    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<string>(out var incomingText))
-                {
-                    continue;
-                }
+    internal static string RepairContentBlockSeams(string? text) => ChatContentFormatting.RepairContentBlockSeams(text);
 
-                if (merged[key] is JsonValue existingValue
-                    && existingValue.TryGetValue<string>(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;
-    }
+    internal static ChatEvent TruncateChatEvent(ChatEvent evt) => ChatContentFormatting.TruncateChatEvent(evt);
 
-    private static string NormalizeCachedDisplayText(string? value)
+    private void ApplyEventAndPublish(string threadId, ChatEvent evt, ChatEntryMetadata? meta = null)
     {
-        if (string.IsNullOrEmpty(value))
-            return string.Empty;
-
-        return value
-            .Replace("\r\n", " ", StringComparison.Ordinal)
-            .Replace('\r', ' ')
-            .Replace('\n', ' ');
+        var snapshot = _state.ApplyEvent(
+            threadId,
+            evt,
+            meta,
+            ProjectionContext());
+        Publish(snapshot);
     }
 
-    private void MarkToolMetaCacheSaved(long? savedVersion)
-    {
-        lock (_gate)
-        {
-            if (savedVersion is null || savedVersion == _toolMetaSaveVersion)
-                _toolMetaCacheDirty = false;
-        }
-    }
+    private ChatProjectionContext ProjectionContext() =>
+        new(_bridge.MainSessionKey, _bridge.HasHandshakeSnapshot);
 
-    /// <summary>
-    /// After a successful abort, reload chat.history to capture the __openclaw.id
-    /// of the aborted user message and persist it for future sessions.
-    /// </summary>
-    private async Task PersistAbortedMessageIdAsync(string threadId)
+    private void Publish(ChatDataSnapshot snapshot)
     {
-        long requestResetVersion;
-        lock (_gate)
+        var args = new ChatDataChangedEventArgs(snapshot);
+        if (_post is null)
         {
-            requestResetVersion = GetResetVersionLocked(threadId);
+            Changed?.Invoke(this, args);
         }
-
-        await _persistLock.WaitAsync().ConfigureAwait(false);
-        try
+        else
         {
-            await Task.Delay(500).ConfigureAwait(false); // let gateway finalize
-            var history = await _bridge.RequestChatHistoryAsync(threadId).ConfigureAwait(false);
-
-            lock (_gate)
-            {
-                if (GetResetVersionLocked(threadId) != requestResetVersion)
-                {
-                    Logger.Info($"[ABORT-PERSIST] Ignoring stale abort persistence after reset for thread {threadId}");
-                    return;
-                }
-            }
-
-            var newAbortedIds = new List<string>();
-            var msgs = history.Messages;
-
-            // Scan for user messages with missing/truncated assistant responses
-            for (int i = 0; i < msgs.Count; i++)
-            {
-                var msg = msgs[i];
-                if (!string.Equals(msg.Role, "user", StringComparison.OrdinalIgnoreCase))
-                    continue;
-                if (msg.OpenClawId is null) continue;
-                if (IsMessageAborted(threadId, msg.OpenClawId)) continue;
-                if (newAbortedIds.Contains(msg.OpenClawId)) continue;
-
-                ChatMessageInfo? nextAssistant = null;
-                for (int j = i + 1; j < msgs.Count; j++)
-                {
-                    var candidate = msgs[j];
-                    var role = candidate.Role?.ToLowerInvariant();
-                    if (role == "assistant") { nextAssistant = candidate; break; }
-                    if (role == "user") break;
-                }
-
-                if (nextAssistant is null)
-                {
-                    newAbortedIds.Add(msg.OpenClawId);
-                }
-                else if (!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))
-                {
-                    newAbortedIds.Add(msg.OpenClawId);
-                }
-            }
-
-            if (newAbortedIds.Count == 0)
-            {
-                Logger.Debug($"[ABORT-PERSIST] No new aborted message IDs found for thread {threadId}");
-                return;
-            }
+            _post(() => Changed?.Invoke(this, args));
+        }
 
-            lock (_gate)
-            {
-                if (GetResetVersionLocked(threadId) != requestResetVersion)
-                {
-                    Logger.Info($"[ABORT-PERSIST] Ignoring stale abort persistence write after reset for thread {threadId}");
-                    return;
-                }
+        // 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)
+            _persistence.DebounceSnapshot(snapshot);
+    }
 
-                if (!_persistedAbortedIds.TryGetValue(threadId, out var set))
-                {
-                    set = new HashSet<string>();
-                    _persistedAbortedIds[threadId] = set;
-                }
-                foreach (var id in newAbortedIds)
-                    set.Add(id);
-            }
+    // ── 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.
 
-            SaveAbortedIds();
-            Logger.Info($"[ABORT-PERSIST] Persisted {newAbortedIds.Count} aborted IDs for thread {threadId}: {string.Join(", ", newAbortedIds)}");
-        }
-        catch (Exception ex)
-        {
-            Logger.Warn($"[ABORT-PERSIST] Failed to persist abort for thread {threadId}: {ex.Message}");
-        }
-        finally
-        {
-            _persistLock.Release();
-        }
+    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; }
     }
 
-    /// <summary>Check if a user message's __openclaw.id is in the persisted aborted set.</summary>
-    private bool IsMessageAborted(string threadId, string? openClawId)
+    internal static LastChatState? LoadLastChatState(string? pathOverride = null) =>
+        ChatStatePersistence.LoadLastChatState(pathOverride);
+
+    private void RaiseNotification(ChatProviderNotification notification)
     {
-        if (openClawId is null) return false;
-        lock (_gate)
+        var args = new ChatProviderNotificationEventArgs(notification);
+        if (_post is null)
         {
-            var found = _persistedAbortedIds.TryGetValue(threadId, out var set);
-            var contains = found && set!.Contains(openClawId);
-            Logger.Debug($"[IsMessageAborted] thread='{threadId}' id='{openClawId}' dictHasThread={found} setCount={set?.Count ?? 0} match={contains}");
-            return contains;
+            NotificationRequested?.Invoke(this, args);
+            return;
         }
+        _post(() => NotificationRequested?.Invoke(this, args));
     }
+
 }
diff --git a/tests/OpenClaw.Tray.Tests/ChatRuntimeOwnersTests.cs b/tests/OpenClaw.Tray.Tests/ChatRuntimeOwnersTests.cs
new file mode 100644
index 000000000..c850cbc47
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/ChatRuntimeOwnersTests.cs
@@ -0,0 +1,1293 @@
+using System.Text.Json;
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Chat;
+
+namespace OpenClaw.Tray.Tests;
+
+public sealed class ChatConversationStateTests
+{
+    [Fact]
+    public void ResetThread_ClearsQueueAndAdvancesGenerationAtomically()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext("main", HasHandshakeSnapshot: true);
+        state.Load(
+            [new SessionInfo { Key = "main", IsMain = true }],
+            context);
+        state.AdmitMessage(
+            "main",
+            "first",
+            "first",
+            "nonce-1",
+            attachments: null,
+            DateTimeOffset.UnixEpoch,
+            context);
+        state.AdmitMessage(
+            "main",
+            "second",
+            "second",
+            "nonce-2",
+            attachments: null,
+            DateTimeOffset.UnixEpoch.AddSeconds(1),
+            context);
+
+        var reset = state.ResetThread(
+            "main",
+            context);
+
+        Assert.Equal(1, reset.ResetGeneration);
+        Assert.Empty(reset.Snapshot.Timelines["main"].Entries);
+        Assert.True(reset.Snapshot.Timelines["main"].HistoryLoaded);
+        Assert.Empty(reset.Snapshot.QueuedMessagesByThread!);
+        Assert.Equal(1, reset.Snapshot.TimelineGenerations!["main"]);
+    }
+
+    [Fact]
+    public async Task HistoryGeneration_WaitsForLoaderActivation()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext("main", HasHandshakeSnapshot: true);
+        state.Load([new SessionInfo { Key = "main", IsMain = true }], context);
+
+        _ = state.ApplyStatus(
+            ConnectionStatus.Disconnected,
+            context);
+        Assert.False(state.TryBeginHistory(
+            "main",
+            force: true,
+            expectedToken: null,
+            out _,
+            out _,
+            out var activation));
+        Assert.NotNull(activation);
+        Assert.False(activation.IsCompleted);
+
+        var superseding = state.ApplyStatus(
+            ConnectionStatus.Connected,
+            context);
+        await activation;
+        Assert.False(state.TryBeginHistory(
+            "main",
+            force: true,
+            expectedToken: null,
+            out _,
+            out _,
+            out var supersedingActivation));
+        Assert.NotNull(supersedingActivation);
+        Assert.False(supersedingActivation.IsCompleted);
+
+        state.ActivateHistoryGeneration(superseding.HistoryGeneration);
+        await supersedingActivation;
+        Assert.True(state.TryBeginHistory(
+            "main",
+            force: true,
+            expectedToken: null,
+            out _,
+            out _,
+            out _));
+    }
+
+    [Fact]
+    public void UsageContribution_FallsBackToInputPlusOutput()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext("main", HasHandshakeSnapshot: true);
+        state.Load([new SessionInfo { Key = "main", IsMain = true }], context);
+        var metadata = new ChatEntryMetadata(
+            DateTimeOffset.UnixEpoch,
+            Model: null,
+            InputTokens: 100,
+            OutputTokens: 20,
+            ResponseTokens: null);
+        state.ApplyEvent(
+            "main",
+            new ChatMessageEvent("response"),
+            metadata,
+            context);
+
+        state.SnapshotAssistantUsageContribution("main", metadata, context);
+
+        Assert.Equal(120, state.GetEntryMetadata("main")["e1"].ResponseTokens);
+    }
+
+    [Fact]
+    public void ProcessAgentEvent_ResetDropsTerminalWithoutReopeningTurn()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext("main", HasHandshakeSnapshot: true);
+        state.Load([new SessionInfo { Key = "main", IsMain = true }], context);
+        using var start = JsonDocument.Parse("""{"phase":"start"}""");
+        using var end = JsonDocument.Parse("""{"phase":"end"}""");
+
+        var started = state.ProcessAgentEvent(
+            new AgentEventInfo
+            {
+                Stream = "lifecycle",
+                SessionKey = "main",
+                RunId = "run-before-reset",
+                Data = start.RootElement.Clone(),
+            },
+            "main",
+            context);
+        Assert.True(started.Process);
+        Assert.True(started.Snapshots[^1].Timelines["main"].TurnActive);
+
+        var reset = state.ResetThread("main", context);
+        var terminal = state.ProcessAgentEvent(
+            new AgentEventInfo
+            {
+                Stream = "lifecycle",
+                SessionKey = "main",
+                RunId = "run-before-reset",
+                Data = end.RootElement.Clone(),
+            },
+            "main",
+            context);
+
+        Assert.False(terminal.Process);
+        Assert.False(reset.Snapshot.Timelines["main"].TurnActive);
+        Assert.Empty(reset.Snapshot.Timelines["main"].Entries);
+    }
+
+    [Fact]
+    public void RollbackAbortAndEndTurn_StaleGenerationDoesNotEndReplacementTurn()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext(
+            "main",
+            HasHandshakeSnapshot: true);
+        state.Load(
+            [new SessionInfo { Key = "main", IsMain = true }],
+            context);
+        using var start = JsonDocument.Parse("""{"phase":"start"}""");
+        var oldStart = state.ProcessAgentEvent(
+            new AgentEventInfo
+            {
+                Stream = "lifecycle",
+                SessionKey = "main",
+                RunId = "old-run",
+                Data = start.RootElement.Clone(),
+            },
+            "main",
+            context);
+        state.ApplyStatus(ConnectionStatus.Disconnected, context);
+        state.ApplyStatus(ConnectionStatus.Connected, context);
+        var replacement = state.ProcessAgentEvent(
+            new AgentEventInfo
+            {
+                Stream = "lifecycle",
+                SessionKey = "main",
+                RunId = "replacement-run",
+                Data = start.RootElement.Clone(),
+            },
+            "main",
+            context);
+        Assert.True(replacement.Process);
+
+        var rollback = state.RollbackAbortAndEndTurnIfCurrent(
+            "main",
+            "old-run",
+            oldStart.RuntimeGeneration,
+            context);
+
+        Assert.Null(rollback);
+        Assert.True(state.Snapshot(context).Timelines["main"].TurnActive);
+    }
+
+    [Fact]
+    public void HistoryMerge_DeduplicatesPreservedLiveTailEntries()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext("main", HasHandshakeSnapshot: true);
+        state.Load([new SessionInfo { Key = "main", IsMain = true }], context);
+        var metadata = new ChatEntryMetadata(
+            DateTimeOffset.UtcNow,
+            Model: null,
+            GatewayMessageId: "duplicate-live-id");
+        state.ApplyEvent(
+            "main",
+            new ChatStatusEvent("duplicate", ChatTone.Dim),
+            metadata,
+            context);
+        state.ApplyEvent(
+            "main",
+            new ChatStatusEvent("duplicate", ChatTone.Dim),
+            metadata,
+            context);
+        Assert.True(state.TryBeginHistory(
+            "main",
+            force: true,
+            expectedToken: null,
+            out var token,
+            out _,
+            out _));
+
+        Assert.True(state.CommitHistory(
+            token,
+            new ChatHistoryRebuildPlan(
+                SessionId: null,
+                ChatTimelineState.Initial() with { HistoryLoaded = true },
+                new Dictionary<string, ChatEntryMetadata>(),
+                MaxHistorySequence: 0),
+            DateTimeOffset.UtcNow.AddSeconds(-1),
+            authoritative: false));
+
+        var timeline = state.Snapshot(context).Timelines["main"];
+        Assert.Single(timeline.Entries);
+    }
+
+    [Fact]
+    public void HistoryReplacement_ClearsTimelineAndAdvancesOwnedTokenAtomically()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext("main", HasHandshakeSnapshot: true);
+        state.Load([new SessionInfo { Key = "main", IsMain = true }], context);
+        state.ApplyEvent(
+            "main",
+            new ChatUserMessageEvent("archived"),
+            new ChatEntryMetadata(
+                DateTimeOffset.UtcNow,
+                Model: null,
+                GatewayMessageId: "archived-id"),
+            context);
+        var oldToken = state.CaptureHistoryToken("main");
+
+        var replacement = state.BeginHistoryReplacement("main", context);
+
+        Assert.NotNull(replacement);
+        Assert.Empty(replacement.Snapshot.Timelines["main"].Entries);
+        Assert.Empty(state.GetEntryMetadata("main"));
+        Assert.Equal(
+            oldToken.ReplacementGeneration + 1,
+            replacement.Token.ReplacementGeneration);
+        Assert.False(state.IsHistoryRequestCurrent(oldToken));
+        Assert.True(state.IsHistoryRequestCurrent(replacement.Token));
+    }
+
+    [Fact]
+    public void AttachmentOnlyFallbackAfterSendConfirmation_SurfacesOpenedLifecycle()
+    {
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext("main", HasHandshakeSnapshot: true);
+        state.Load([new SessionInfo { Key = "main", IsMain = true }], context);
+        state.ResetThread("main", context);
+        var admission = state.AdmitMessage(
+            "main",
+            text: string.Empty,
+            displayText: "\u200B📎 proof.txt",
+            nonce: "attachment-only",
+            attachments:
+            [
+                new ChatAttachment
+                {
+                    Type = "file",
+                    MimeType = "text/plain",
+                    FileName = "proof.txt",
+                    Content = "cHJvb2Y=",
+                    SizeBytes = 5,
+                },
+            ],
+            DateTimeOffset.UtcNow,
+            context);
+        Assert.NotNull(admission.Dispatch);
+        var commit = state.CommitSendResult(
+            admission.Dispatch!,
+            new ChatSendResult { Status = "started" },
+            context);
+        Assert.Null(commit.OpenedLifecycle);
+        using var fallbackData =
+            JsonDocument.Parse("""{"phase":"fallback_step"}""");
+
+        var fallback = state.ProcessAgentEvent(
+            new AgentEventInfo
+            {
+                Stream = "lifecycle",
+                SessionKey = "main",
+                RunId = "attachment-run",
+                Ts = DateTimeOffset.UtcNow.AddMinutes(-5)
+                    .ToUnixTimeMilliseconds(),
+                Data = fallbackData.RootElement.Clone(),
+            },
+            "main",
+            context);
+
+        Assert.True(fallback.Process);
+        Assert.Equal(
+            "attachment-run",
+            fallback.OpenedLifecycle?.Event.RunId);
+    }
+}
+
+public sealed class ChatResetStateTests
+{
+    [Fact]
+    public void SubmittedEchoWithoutPendingQueue_OpensBufferedLifecycle()
+    {
+        const string threadId = "main";
+        const string marker = "controlled marker";
+        var now = DateTimeOffset.UtcNow;
+        var state = new ChatResetState();
+        var version = state.BeginReset(
+            threadId,
+            now.ToUnixTimeMilliseconds());
+        state.AddSubmittedLocalEcho(threadId, marker, now);
+        Assert.Null(state.RecordLocalSendWithoutRun(
+            threadId,
+            version,
+            state.LifecycleStartSequence));
+        var start = Lifecycle("start", "new-run", now.AddSeconds(1));
+
+        var buffered = state.EvaluateAgentEvent(start, threadId);
+        var earlyAssistant = state.EvaluateAgentEvent(
+            Assistant("early", "new-run", now.AddSeconds(1)),
+            threadId);
+        var echo = state.EvaluateChatMessage(
+            threadId,
+            role: "user",
+            rawText: marker,
+            timestampMs: now.AddSeconds(1).ToUnixTimeMilliseconds(),
+            hasPendingLocalEcho: false);
+        var terminal = state.EvaluateAgentEvent(
+            Lifecycle("end", "new-run", now.AddSeconds(1)),
+            threadId);
+
+        Assert.True(buffered.Drop);
+        Assert.Null(buffered.OpenedLifecycleStart);
+        Assert.True(earlyAssistant.Drop);
+        Assert.True(echo.Drop);
+        Assert.Equal(marker, echo.ConsumeEchoText);
+        Assert.Same(start, echo.OpenedLifecycleStart);
+        Assert.False(state.IsAwaitingUserMessage(threadId));
+        Assert.False(terminal.Drop);
+    }
+
+    [Fact]
+    public void SubmittedEcho_NonmatchingTextDoesNotOpenBufferedLifecycle()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var state = PendingSubmittedEcho(now, submittedText: "expected");
+        var start = Lifecycle("start", "new-run", now.AddSeconds(1));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+
+        var gate = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: "different",
+            timestampMs: 0,
+            hasPendingLocalEcho: false);
+
+        Assert.True(gate.Drop);
+        Assert.True(gate.RequestRemoteBackfill);
+        Assert.Null(gate.OpenedLifecycleStart);
+        Assert.True(state.IsAwaitingUserMessage("main"));
+    }
+
+    [Fact]
+    public void SubmittedEcho_ExpiredCorrelationDoesNotOpenBufferedLifecycle()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var state = new ChatResetState();
+        var version = state.BeginReset("main", now.ToUnixTimeMilliseconds());
+        state.AddSubmittedLocalEcho(
+            "main",
+            "expected",
+            now.AddSeconds(-31));
+        state.RecordLocalSendWithoutRun(
+            "main",
+            version,
+            state.LifecycleStartSequence);
+        var start = Lifecycle("start", "new-run", now.AddSeconds(1));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+
+        var gate = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: "expected",
+            timestampMs: 0,
+            hasPendingLocalEcho: false);
+
+        Assert.True(gate.Drop);
+        Assert.True(gate.RequestRemoteBackfill);
+        Assert.Null(gate.OpenedLifecycleStart);
+        Assert.True(state.IsAwaitingUserMessage("main"));
+    }
+
+    [Fact]
+    public void SubmittedEcho_PreResetTimestampIsConsumedWithoutOpeningLifecycle()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var state = PendingSubmittedEcho(now, submittedText: "expected");
+        var start = Lifecycle("start", "new-run", now.AddSeconds(1));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+
+        var gate = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: "expected",
+            timestampMs: now.AddSeconds(-5).ToUnixTimeMilliseconds(),
+            hasPendingLocalEcho: false);
+
+        Assert.True(gate.Drop);
+        Assert.Equal("expected", gate.ConsumeEchoText);
+        Assert.False(gate.RequestRemoteBackfill);
+        Assert.Null(gate.OpenedLifecycleStart);
+        Assert.True(state.IsAwaitingUserMessage("main"));
+    }
+
+    [Fact]
+    public void ProductionSubmission_SkewedEchoOpensOnlyLifecycleAfterItsStartSequence()
+    {
+        const string text = "same text";
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        var oldStart = Lifecycle(
+            "start",
+            "old-run",
+            now.AddSeconds(1));
+        Assert.True(state.EvaluateAgentEvent(oldStart, "main").Drop);
+        var submissionSequence = state.LifecycleStartSequence;
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "submission-1",
+            text,
+            generation,
+            submissionSequence,
+            now);
+
+        var echo = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: text,
+            timestampMs: cutoff - 5_000,
+            hasPendingLocalEcho: false);
+        Assert.True(echo.Drop);
+        Assert.Null(echo.OpenedLifecycleStart);
+        Assert.True(state.IsAwaitingUserMessage("main"));
+
+        var eligibleStart = Lifecycle(
+            "start",
+            "new-run",
+            now.AddSeconds(-5));
+        var opened = state.EvaluateAgentEvent(
+            eligibleStart,
+            "main");
+
+        Assert.False(opened.Drop);
+        Assert.Same(eligibleStart, opened.OpenedLifecycleStart);
+        Assert.False(state.IsAwaitingUserMessage("main"));
+    }
+
+    [Fact]
+    public void ProductionSubmission_ExactEchoSelectsNewestEligibleLifecycle()
+    {
+        const string text = "current submission";
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "submission",
+            text,
+            generation,
+            state.LifecycleStartSequence,
+            now);
+        var stale = Lifecycle(
+            "start",
+            "stale-run",
+            now.AddSeconds(-6));
+        var current = Lifecycle(
+            "start",
+            "current-run",
+            now.AddSeconds(-5));
+        Assert.True(state.EvaluateAgentEvent(stale, "main").Drop);
+        Assert.True(state.EvaluateAgentEvent(current, "main").Drop);
+
+        var echo = state.EvaluateChatMessage(
+            "main",
+            "user",
+            text,
+            now.AddMilliseconds(-5_500).ToUnixTimeMilliseconds(),
+            hasPendingLocalEcho: false);
+        var staleFrame = state.EvaluateAgentEvent(
+            Assistant(
+                "stale output",
+                "stale-run",
+                now.AddMilliseconds(-5_400)),
+            "main");
+        var currentFrame = state.EvaluateAgentEvent(
+            Assistant(
+                "current output",
+                "current-run",
+                now.AddMilliseconds(-5_400)),
+            "main");
+
+        Assert.True(echo.Drop);
+        Assert.Same(current, echo.OpenedLifecycleStart);
+        Assert.True(staleFrame.Drop);
+        Assert.False(currentFrame.Drop);
+    }
+
+    [Fact]
+    public void ProductionSubmission_WrongExpiredAndWrongGenerationCannotOpen()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "wrong-generation",
+            "expected",
+            generation - 1,
+            state.LifecycleStartSequence,
+            now);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "expired",
+            "expired",
+            generation,
+            state.LifecycleStartSequence,
+            now.AddSeconds(-31));
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "current",
+            "expected",
+            generation,
+            state.LifecycleStartSequence,
+            now);
+        var start = Lifecycle(
+            "start",
+            "new-run",
+            now.AddSeconds(-5));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+
+        var wrong = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: "different",
+            timestampMs: cutoff - 5_000,
+            hasPendingLocalEcho: false);
+        var expired = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: "expired",
+            timestampMs: cutoff - 5_000,
+            hasPendingLocalEcho: false);
+
+        Assert.True(wrong.Drop);
+        Assert.Null(wrong.OpenedLifecycleStart);
+        Assert.True(expired.Drop);
+        Assert.Null(expired.OpenedLifecycleStart);
+        Assert.True(state.IsAwaitingUserMessage("main"));
+    }
+
+    [Fact]
+    public void AcceptedLifecycleFloor_AllowsSameRunAndRejectsOlderOrCompletedRun()
+    {
+        const string text = "exact echo";
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var lifecycleTimestamp = cutoff - 5_000;
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "submission",
+            text,
+            generation,
+            state.LifecycleStartSequence,
+            now);
+        var start = Lifecycle(
+            "start",
+            "new-run",
+            DateTimeOffset.FromUnixTimeMilliseconds(
+                lifecycleTimestamp));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+        var echo = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: text,
+            timestampMs: lifecycleTimestamp + 1,
+            hasPendingLocalEcho: true);
+        Assert.Same(start, echo.OpenedLifecycleStart);
+
+        var accepted = state.EvaluateChatMessage(
+            "main",
+            role: "assistant",
+            rawText: "current",
+            timestampMs: lifecycleTimestamp + 2,
+            hasPendingLocalEcho: false,
+            activeRunId: "new-run");
+        var older = state.EvaluateChatMessage(
+            "main",
+            role: "assistant",
+            rawText: "older",
+            timestampMs: lifecycleTimestamp - 1,
+            hasPendingLocalEcho: false,
+            activeRunId: "new-run");
+        var wrongRun = state.EvaluateChatMessage(
+            "main",
+            role: "assistant",
+            rawText: "wrong run",
+            timestampMs: lifecycleTimestamp + 2,
+            hasPendingLocalEcho: false,
+            activeRunId: "other-run");
+        state.CompleteRun("main", "new-run");
+        var completed = state.EvaluateChatMessage(
+            "main",
+            role: "assistant",
+            rawText: "completed",
+            timestampMs: lifecycleTimestamp + 2,
+            hasPendingLocalEcho: false,
+            activeRunId: "new-run");
+
+        Assert.False(accepted.Drop);
+        Assert.True(older.Drop);
+        Assert.True(wrongRun.Drop);
+        Assert.True(completed.Drop);
+    }
+
+    [Fact]
+    public void AcceptedLifecycleFloor_NeverAuthorizesUserRoleFrames()
+    {
+        const string marker = "exact local echo";
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var lifecycleTimestamp = cutoff - 5_000;
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "submission",
+            marker,
+            generation,
+            state.LifecycleStartSequence,
+            now);
+        var start = Lifecycle(
+            "start",
+            "current-run",
+            DateTimeOffset.FromUnixTimeMilliseconds(
+                lifecycleTimestamp));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+        var echo = state.EvaluateChatMessage(
+            "main",
+            "user",
+            marker,
+            lifecycleTimestamp + 1,
+            hasPendingLocalEcho: false);
+        Assert.Same(start, echo.OpenedLifecycleStart);
+
+        var delayedUser = state.EvaluateChatMessage(
+            "main",
+            "user",
+            "delayed unrelated user",
+            lifecycleTimestamp + 2,
+            hasPendingLocalEcho: false,
+            activeRunId: "current-run");
+        var delayedApproval = state.EvaluateChatMessage(
+            "main",
+            "user",
+            "/approve abcdef allow-once",
+            lifecycleTimestamp + 3,
+            hasPendingLocalEcho: false,
+            activeRunId: "current-run");
+        var delayedControl = state.EvaluateChatMessage(
+            "main",
+            "user",
+            "System: Reset session",
+            lifecycleTimestamp + 4,
+            hasPendingLocalEcho: false,
+            activeRunId: "current-run");
+        var sameRunAssistant = state.EvaluateChatMessage(
+            "main",
+            "assistant",
+            "current response",
+            lifecycleTimestamp + 5,
+            hasPendingLocalEcho: false,
+            activeRunId: "current-run");
+        var postCutoffUser = state.EvaluateChatMessage(
+            "main",
+            "user",
+            "fresh remote user",
+            cutoff + 1,
+            hasPendingLocalEcho: false,
+            activeRunId: "current-run");
+
+        Assert.True(delayedUser.Drop);
+        Assert.True(delayedApproval.Drop);
+        Assert.True(delayedControl.Drop);
+        Assert.False(sameRunAssistant.Drop);
+        Assert.False(postCutoffUser.Drop);
+    }
+
+    [Fact]
+    public void AcceptedLifecycleFloor_ResetAndReconnectClearState()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var lifecycleTimestamp = cutoff - 5_000;
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "submission",
+            "echo",
+            generation,
+            state.LifecycleStartSequence,
+            now);
+        var start = Lifecycle(
+            "start",
+            "new-run",
+            DateTimeOffset.FromUnixTimeMilliseconds(
+                lifecycleTimestamp));
+        state.EvaluateAgentEvent(start, "main");
+        state.EvaluateChatMessage(
+            "main",
+            "user",
+            "echo",
+            lifecycleTimestamp + 1,
+            hasPendingLocalEcho: true);
+        state.BeginReset("main", cutoff + 10_000);
+        var afterReset = state.EvaluateChatMessage(
+            "main",
+            "assistant",
+            "after reset",
+            lifecycleTimestamp + 2,
+            hasPendingLocalEcho: false,
+            activeRunId: "new-run");
+        state.ClearSubmittedEchoesForReconnect();
+        var afterReconnect = state.EvaluateChatMessage(
+            "main",
+            "assistant",
+            "after reconnect",
+            lifecycleTimestamp + 2,
+            hasPendingLocalEcho: false,
+            activeRunId: "new-run");
+
+        Assert.True(afterReset.Drop);
+        Assert.True(afterReconnect.Drop);
+    }
+
+    [Fact]
+    public void RemovePendingSubmission_RemovesOnlyMatchingIdentityAndGeneration()
+    {
+        const string text = "repeated";
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "first",
+            text,
+            generation,
+            state.LifecycleStartSequence,
+            now);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "second",
+            text,
+            generation,
+            state.LifecycleStartSequence,
+            now.AddMilliseconds(1));
+        state.RemovePendingLocalSubmission(
+            "main",
+            "first",
+            generation);
+        state.RemovePendingLocalSubmission(
+            "main",
+            "second",
+            generation - 1);
+        var start = Lifecycle(
+            "start",
+            "new-run",
+            now.AddSeconds(-5));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+
+        var echo = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: text,
+            timestampMs: cutoff - 5_000,
+            hasPendingLocalEcho: false);
+
+        Assert.Same(start, echo.OpenedLifecycleStart);
+        Assert.False(state.IsAwaitingUserMessage("main"));
+    }
+
+    [Fact]
+    public void MatchedSubmissionEcho_DoesNotConsumeLaterIdenticalPostCutoffMessage()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "submission",
+            "same text",
+            generation,
+            state.LifecycleStartSequence,
+            now);
+        var start = Lifecycle(
+            "start",
+            "new-run",
+            now.AddSeconds(-5));
+        Assert.True(state.EvaluateAgentEvent(start, "main").Drop);
+
+        var echo = state.EvaluateChatMessage(
+            "main",
+            "user",
+            "same text",
+            cutoff - 5_000,
+            hasPendingLocalEcho: false);
+        var laterRemote = state.EvaluateChatMessage(
+            "main",
+            "user",
+            "same text",
+            cutoff + 1,
+            hasPendingLocalEcho: false,
+            activeRunId: "new-run");
+
+        Assert.True(echo.Drop);
+        Assert.Same(start, echo.OpenedLifecycleStart);
+        Assert.False(laterRemote.Drop);
+    }
+
+    [Fact]
+    public void AttachmentOnlySubmission_ConfirmedSendOpensBufferedFallback()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var cutoff = now.ToUnixTimeMilliseconds();
+        var fallbackTimestamp = cutoff - 5_000;
+        var state = new ChatResetState();
+        var generation = state.BeginReset("main", cutoff);
+        state.RegisterPendingLocalSubmission(
+            "main",
+            "attachment-only",
+            string.Empty,
+            generation,
+            state.LifecycleStartSequence,
+            now,
+            requiresEcho: false);
+        var fallback = Lifecycle(
+            "fallback_step",
+            "attachment-run",
+            DateTimeOffset.FromUnixTimeMilliseconds(
+                fallbackTimestamp));
+        Assert.True(state.EvaluateAgentEvent(fallback, "main").Drop);
+
+        var opened = state.RecordLocalSendWithoutRun(
+            "main",
+            generation,
+            lifecycleStartSequence: 0,
+            submissionId: "attachment-only");
+        var final = state.EvaluateChatMessage(
+            "main",
+            "assistant",
+            "attachment terminal",
+            fallbackTimestamp + 1,
+            hasPendingLocalEcho: false,
+            activeRunId: "attachment-run");
+
+        Assert.Same(fallback, opened);
+        Assert.False(final.Drop);
+    }
+
+    [Fact]
+    public void IgnoredOldRunAndPreResetMessageRemainDropped()
+    {
+        var now = DateTimeOffset.UtcNow;
+        var state = PendingSubmittedEcho(now, submittedText: "expected");
+        state.AddIgnoredRun("main", "old-run");
+
+        var ignoredStart = state.EvaluateAgentEvent(
+            Lifecycle("start", "old-run", now.AddSeconds(1)),
+            "main");
+        var ignoredTerminal = state.EvaluateAgentEvent(
+            Lifecycle("end", "old-run", now.AddSeconds(1)),
+            "main");
+        var preReset = state.EvaluateChatMessage(
+            "main",
+            role: "user",
+            rawText: "unrelated old message",
+            timestampMs: now.AddSeconds(-5).ToUnixTimeMilliseconds(),
+            hasPendingLocalEcho: false);
+
+        Assert.True(ignoredStart.Drop);
+        Assert.Null(ignoredStart.OpenedLifecycleStart);
+        Assert.True(ignoredTerminal.Drop);
+        Assert.True(ignoredTerminal.ReloadHistory);
+        Assert.True(preReset.Drop);
+        Assert.False(preReset.RequestRemoteBackfill);
+        Assert.Null(preReset.OpenedLifecycleStart);
+        Assert.True(state.IsAwaitingUserMessage("main"));
+    }
+
+    private static ChatResetState PendingSubmittedEcho(
+        DateTimeOffset now,
+        string submittedText)
+    {
+        var state = new ChatResetState();
+        var version = state.BeginReset("main", now.ToUnixTimeMilliseconds());
+        state.AddSubmittedLocalEcho("main", submittedText, now);
+        state.RecordLocalSendWithoutRun(
+            "main",
+            version,
+            state.LifecycleStartSequence);
+        return state;
+    }
+
+    private static AgentEventInfo Lifecycle(
+        string phase,
+        string runId,
+        DateTimeOffset timestamp)
+    {
+        using var document = JsonDocument.Parse(
+            $$"""{"phase":"{{phase}}"}""");
+        return new AgentEventInfo
+        {
+            Stream = "lifecycle",
+            SessionKey = "main",
+            RunId = runId,
+            Ts = timestamp.ToUnixTimeMilliseconds(),
+            Data = document.RootElement.Clone(),
+        };
+    }
+
+    private static AgentEventInfo Assistant(
+        string text,
+        string runId,
+        DateTimeOffset timestamp)
+    {
+        using var document = JsonDocument.Parse(
+            $$"""{"delta":"{{text}}"}""");
+        return new AgentEventInfo
+        {
+            Stream = "assistant",
+            SessionKey = "main",
+            RunId = runId,
+            Ts = timestamp.ToUnixTimeMilliseconds(),
+            Data = document.RootElement.Clone(),
+        };
+    }
+}
+
+public sealed class ChatSendQueuePolicyTests
+{
+    [Theory]
+    [InlineData(false, false, false, true)]
+    [InlineData(true, false, false, false)]
+    [InlineData(false, true, false, false)]
+    [InlineData(false, false, true, false)]
+    public void CanSendDirectly_UsesAtomicQueueInputs(
+        bool hasActiveRun,
+        bool turnActive,
+        bool hasPendingMessages,
+        bool expected)
+    {
+        Assert.Equal(
+            expected,
+            ChatSendQueuePolicy.CanSendDirectly(
+                hasActiveRun,
+                turnActive,
+                hasPendingMessages));
+    }
+}
+
+public sealed class ChatEventMapperTests
+{
+    [Fact]
+    public void Map_ApprovalRequestPreservesIdentityAndActions()
+    {
+        using var document = JsonDocument.Parse(
+            """
+            {
+              "phase": "requested",
+              "approvalSlug": "approve-1",
+              "approvalId": "approval-uuid",
+              "title": "Run command",
+              "host": "node",
+              "command": "echo ok"
+            }
+            """);
+        var mapping = ChatEventMapper.Map(new AgentEventInfo
+        {
+            Stream = "approval",
+            SessionKey = "main",
+            Data = document.RootElement.Clone(),
+        });
+
+        var request = Assert.IsType<ChatPermissionRequestEvent>(mapping.Event);
+        Assert.Equal("approve-1", request.RequestId);
+        Assert.Equal("approval-uuid", mapping.Approval?.AlternateId);
+        Assert.Equal(ChatPermissionActionKeys.ExecApprovalDefaults, request.Actions);
+    }
+
+    [Fact]
+    public void MapTerminalApproval_ReturnsTypedIdentityAndDecision()
+    {
+        using var document = JsonDocument.Parse(
+            """
+            {
+              "phase": "resolved",
+              "approvalSlug": "approve-1",
+              "approvalId": "approval-uuid",
+              "decision": "allow-always"
+            }
+            """);
+
+        var terminal = ChatEventMapper.MapTerminalApproval(new AgentEventInfo
+        {
+            Stream = "approval",
+            SessionKey = "main",
+            Data = document.RootElement.Clone(),
+        });
+
+        Assert.NotNull(terminal);
+        Assert.Equal("approve-1", terminal.ApprovalSlug);
+        Assert.Equal("approval-uuid", terminal.ApprovalId);
+        Assert.Equal(ChatPermissionActionKeys.AllowAlways, terminal.Decision);
+    }
+}
+
+public sealed class ChatStatePersistenceTests
+{
+    [Fact]
+    public void LoadLastChatState_CorruptedJsonReturnsNull()
+    {
+        using var directory = new OpenClaw.TestSupport.TempDirectory();
+        var path = directory.Combine("last-chat-state.json");
+        File.WriteAllText(path, "{broken");
+
+        Assert.Null(ChatStatePersistence.LoadLastChatState(path));
+    }
+
+    [Fact]
+    public void ResetFence_RejectsStaleAbortedIds()
+    {
+        using var directory = new OpenClaw.TestSupport.TempDirectory();
+        using var persistence = new ChatStatePersistence(
+            directory.Combine("last-chat-state.json"));
+        var threadId = "reset-fence-" + Guid.NewGuid().ToString("N");
+
+        persistence.ApplyReset(threadId, resetGeneration: 2);
+
+        Assert.False(persistence.TryAddAbortedIds(
+            threadId,
+            resetGeneration: 1,
+            ["stale-message"]));
+        Assert.False(persistence.IsMessageAborted(threadId, "stale-message"));
+    }
+
+    [Fact]
+    public void ResetFence_PreservesCurrentGenerationIdsAddedBeforeResetApplies()
+    {
+        using var directory = new OpenClaw.TestSupport.TempDirectory();
+        using var persistence = new ChatStatePersistence(
+            directory.Combine("last-chat-state.json"));
+        const string threadId = "current-generation";
+
+        Assert.True(persistence.TryAddAbortedIds(
+            threadId,
+            resetGeneration: 2,
+            ["current-message"]));
+
+        Assert.False(persistence.ApplyReset(threadId, resetGeneration: 2));
+        Assert.True(persistence.IsMessageAborted(
+            threadId,
+            "current-message",
+            resetGeneration: 2));
+    }
+
+    [Fact]
+    public async Task ConcurrentSaves_PersistCompleteAbortedIdSet()
+    {
+        using var directory = new OpenClaw.TestSupport.TempDirectory();
+        var abortedPath = directory.Combine("aborted-messages.json");
+        using var persistence = new ChatStatePersistence(
+            directory.Combine("last-chat-state.json"),
+            abortedIdsPath: abortedPath);
+        var threadId = "concurrent-save";
+
+        await Task.WhenAll(Enumerable.Range(0, 20).Select(index => Task.Run(() =>
+        {
+            persistence.TryAddAbortedIds(
+                threadId,
+                resetGeneration: 0,
+                [$"message-{index}"]);
+            persistence.SaveAbortedIds();
+        })));
+
+        var persisted = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(
+            File.ReadAllText(abortedPath));
+        Assert.Equal(20, persisted![threadId].Distinct(StringComparer.Ordinal).Count());
+    }
+}
+
+public sealed class ChatRuntimeOwnershipContractTests
+{
+    [Fact]
+    public void Provider_DelegatesRuntimeStateWithoutPrivateGate()
+    {
+        var provider = Read("OpenClawChatDataProvider.cs");
+        var state = Read("ChatConversationState.cs");
+        var queue = Read("ChatSendQueue.cs");
+        var history = Read("ChatHistoryLoader.cs");
+        var projector = Read("ChatSnapshotProjector.cs");
+
+        Assert.DoesNotContain("private readonly object _gate", provider);
+        Assert.DoesNotContain("Dictionary<string, ChatTimelineState> _timelines", provider);
+        Assert.DoesNotContain("Dictionary<string, List<ChatQueuedMessage>> _queuedMessages", provider);
+        Assert.DoesNotContain("_historyReplacementVersions", provider);
+        Assert.Contains("private readonly ChatConversationState _state", provider);
+        Assert.Contains("private readonly ChatHistoryLoader _historyLoader", provider);
+        Assert.Contains("private readonly ChatMetadataStore _metadataStore", provider);
+        Assert.Contains("private readonly ChatStatePersistence _persistence", provider);
+        Assert.Contains("private readonly object _gate", state);
+        Assert.Contains("internal ChatResetTransition ResetThread(", state);
+        Assert.Contains("internal ChatHistoryReplacementTransition? BeginHistoryReplacement(", state);
+        Assert.Contains("private readonly Dictionary<string, ChatHistoryCommitToken> _replacementPending", history);
+        Assert.DoesNotContain("Telemetry", state, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("Telemetry", queue, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("TryGetProperty", provider);
+        Assert.DoesNotContain("SessionDisplayResolver", provider);
+        Assert.Contains("SessionDisplayResolver.Resolve(", projector);
+        Assert.Contains(
+            "transition.HistoryGeneration > _appliedStateGeneration",
+            history);
+        var historyAdmission = Slice(
+            history,
+            "private async Task LoadCoreAsync(",
+            "if (!canBegin)");
+        Assert.Contains("lock (_gate)", historyAdmission);
+        Assert.Contains("generationToken = _generationCancellation.Token", historyAdmission);
+        Assert.Contains("canBegin = _state.TryBeginHistory(", historyAdmission);
+    }
+
+    [Fact]
+    public void RuntimeSubstates_AreLockFreeAndVersionOwnershipIsUnique()
+    {
+        var state = Read("ChatConversationState.cs");
+        var substateNames = new[]
+        {
+            "ChatApprovalState.cs",
+            "ChatHistoryState.cs",
+            "ChatPresentationState.cs",
+            "ChatQueueState.cs",
+            "ChatLifecycleState.cs",
+            "ChatResetState.cs",
+        };
+        var substates = substateNames.ToDictionary(name => name, Read);
+
+        Assert.Contains("private readonly object _gate", state);
+        foreach (var (name, source) in substates)
+        {
+            Assert.DoesNotContain("private readonly object _gate", source);
+            Assert.DoesNotContain("lock (", source);
+            Assert.DoesNotContain("SemaphoreSlim", source);
+            Assert.DoesNotContain("ReaderWriterLock", source);
+            Assert.DoesNotContain("Monitor.", source);
+            Assert.DoesNotContain("Telemetry", source, StringComparison.OrdinalIgnoreCase);
+        }
+
+        Assert.Contains("private readonly Dictionary<string, long> _versions", substates["ChatResetState.cs"]);
+        Assert.Contains("private long _connectionGeneration", substates["ChatHistoryState.cs"]);
+        Assert.Contains("private readonly Dictionary<string, long> _revisions", substates["ChatHistoryState.cs"]);
+        Assert.Contains("private readonly Dictionary<string, string> _sessionIds", substates["ChatHistoryState.cs"]);
+        Assert.DoesNotContain("_resetVersions", state);
+        Assert.DoesNotContain("_historyRevisions", state);
+        Assert.DoesNotContain("_connectionGeneration", state);
+        Assert.DoesNotContain("_sessionIds", state);
+    }
+
+    [Fact]
+    public void Root_CoordinatesCrossDomainCommitsUnderSoleGate()
+    {
+        var state = Read("ChatConversationState.cs");
+        var reconnect = Slice(
+            state,
+            "internal ChatStatusTransition ApplyStatus(",
+            "internal ChatSessionsTransition ApplySessions(");
+        var dispose = Slice(
+            state,
+            "internal ChatDisposeTransition DisposeState()",
+            "internal bool TryRaiseKeylessDiagnostic()");
+        var reset = Slice(
+            state,
+            "internal ChatResetTransition ResetThread(",
+            "internal ChatIncomingMessageGate GateIncomingChatMessage(");
+        var history = Slice(
+            state,
+            "internal bool CommitHistory(",
+            "internal ChatDataSnapshot? SnapshotIfHistoryTokenCurrent(");
+        var queue = Slice(
+            state,
+            "internal ChatQueuedAdmission AdmitMessage(",
+            "internal ChatDataSnapshot EnqueueCompact(");
+        var agentEvent = Slice(
+            state,
+            "internal ChatAgentEventTransition ProcessAgentEvent(",
+            "private ChatAgentEventGate GateAgentEventLocked(");
+
+        Assert.All(new[] { reconnect, dispose, reset, history, queue, agentEvent },
+            transition => Assert.Contains("lock (_gate)", transition));
+        Assert.All(new[] { "_history.AdvanceConnectionGeneration", "_queue.ClearForReconnect", "_reset.ClearSubmittedEchoesForReconnect", "_lifecycle.ClearForReconnect" },
+            operation => Assert.Contains(operation, reconnect));
+        Assert.All(new[] { "_history.AdvanceConnectionGeneration", "_queue.ClearForDispose", "_reset.ClearSubmittedEchoesForReconnect", "_lifecycle.ClearForDispose" },
+            operation => Assert.Contains(operation, dispose));
+        Assert.All(new[] { "_history.ClearSessionForReset", "_reset.BeginReset", "_lifecycle.ClearThreadForReset", "_queue.ClearThreadForReset", "_timelines[threadId]" },
+            operation => Assert.Contains(operation, reset));
+        Assert.All(new[] { "_history.IsCurrent", "ChatHistoryState.MergeWithLiveEntries", "_history.MarkCommitted" },
+            operation => Assert.Contains(operation, history));
+        Assert.All(new[] { "_queue.NextMessageId", "_lifecycle.ClearThreadSuppression", "CanSendDirectlyLocked", "StartDirectSendLocked", "BuildSnapshotLocked" },
+            operation => Assert.Contains(operation, queue));
+        Assert.All(new[] { "GateAgentEventLocked", "UpdateRunTrackingLocked", "_lifecycle.ShouldSuppress", "ChatEventMapper.Map", "_approval.MarkSeen", "ApplyEventLocked" },
+            operation => Assert.Contains(operation, agentEvent));
+    }
+
+    private static string Slice(string source, string start, string end)
+    {
+        var startIndex = source.IndexOf(start, StringComparison.Ordinal);
+        var endIndex = source.IndexOf(end, startIndex + start.Length, StringComparison.Ordinal);
+        Assert.True(startIndex >= 0, $"Missing start marker: {start}");
+        Assert.True(endIndex > startIndex, $"Missing end marker: {end}");
+        return source[startIndex..endIndex];
+    }
+
+    private static string Read(string fileName) =>
+        File.ReadAllText(Path.Combine(
+            TestRepositoryPaths.GetRepositoryRoot(),
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Chat",
+            fileName));
+}
diff --git a/tests/OpenClaw.Tray.Tests/ChatTelemetryTrackerTests.cs b/tests/OpenClaw.Tray.Tests/ChatTelemetryTrackerTests.cs
index f1f1f6421..0abfedbdf 100644
--- a/tests/OpenClaw.Tray.Tests/ChatTelemetryTrackerTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatTelemetryTrackerTests.cs
@@ -342,6 +342,67 @@ public void PreparedCompletion_ReservesUnderLockAndEmitsAfterward()
         Assert.Equal("send_rejected", turn.Tag(OpenClawTelemetryTagKey.Reason.ToTelemetryName()));
     }
 
+    [Fact]
+    public void ConnectionCleanup_PreservesTurnsFromCurrentGeneration()
+    {
+        var tracker = new ChatTelemetryTracker();
+        tracker.StartLocalTurn(
+            "old",
+            "thread",
+            queued: false,
+            new ChatRuntimeGeneration(1, 0));
+        tracker.StartLocalTurn(
+            "current",
+            "thread",
+            queued: false,
+            new ChatRuntimeGeneration(2, 0));
+
+        tracker.FinishBeforeConnectionGeneration(
+            2,
+            ChatTelemetryOutcome.Canceled,
+            ChatTurnTelemetryReason.Disconnected);
+
+        Assert.Null(tracker.PrepareFinishByMessageId(
+            "old",
+            ChatTelemetryOutcome.Canceled,
+            ChatTurnTelemetryReason.Disconnected));
+        Assert.NotNull(tracker.PrepareFinishByMessageId(
+            "current",
+            ChatTelemetryOutcome.Success,
+            ChatTurnTelemetryReason.AssistantFinal));
+    }
+
+    [Fact]
+    public void ResetCleanup_PreservesTurnsFromCurrentResetGeneration()
+    {
+        var tracker = new ChatTelemetryTracker();
+        tracker.StartLocalTurn(
+            "old",
+            "thread",
+            queued: false,
+            new ChatRuntimeGeneration(2, 3));
+        tracker.StartLocalTurn(
+            "current",
+            "thread",
+            queued: false,
+            new ChatRuntimeGeneration(2, 4));
+
+        tracker.FinishThreadBeforeResetGeneration(
+            "thread",
+            4,
+            ChatTelemetryOutcome.Canceled,
+            ChatTurnTelemetryReason.Reset);
+
+        Assert.Null(tracker.PrepareFinishByMessageId(
+            "old",
+            ChatTelemetryOutcome.Canceled,
+            ChatTurnTelemetryReason.Reset));
+        Assert.NotNull(tracker.PrepareFinishByMessageId(
+            "current",
+            ChatTelemetryOutcome.Success,
+            ChatTurnTelemetryReason.AssistantFinal));
+    }
+
     [Fact]
     public void DroppedTerminalEvents_RecordOnlyFiniteReasons()
     {
diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs
index 38442ddbe..392c93a11 100644
--- a/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs
@@ -178,6 +178,24 @@ public void ReactorTimeline_RequeuesOnlyForCompletedHistoryReplacement()
             "OpenClaw.Tray.WinUI",
             "Chat",
             "OpenClawChatDataProvider.cs"));
+        var state = File.ReadAllText(Path.Combine(
+            TestRepositoryPaths.GetRepositoryRoot(),
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Chat",
+            "ChatConversationState.cs"));
+        var historyState = File.ReadAllText(Path.Combine(
+            TestRepositoryPaths.GetRepositoryRoot(),
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Chat",
+            "ChatHistoryState.cs"));
+        var projector = File.ReadAllText(Path.Combine(
+            TestRepositoryPaths.GetRepositoryRoot(),
+            "src",
+            "OpenClaw.Tray.WinUI",
+            "Chat",
+            "ChatSnapshotProjector.cs"));
         var root = File.ReadAllText(Path.Combine(
             TestRepositoryPaths.GetRepositoryRoot(),
             "src",
@@ -191,8 +209,11 @@ public void ReactorTimeline_RequeuesOnlyForCompletedHistoryReplacement()
             "Chat",
             "ReactorChatTimeline.cs"));
 
-        Assert.Contains("_historyRevisions[threadId] = GetHistoryRevisionLocked(threadId) + 1", provider);
-        Assert.Contains("HistoryRevisions: historyRevisionsCopy", provider);
+        Assert.Contains("_revisions[token.ThreadId]", historyState);
+        Assert.Contains("new Dictionary<string, long>(_revisions)", historyState);
+        Assert.Contains("_history.SnapshotRevisions()", state);
+        Assert.Contains("_historyLoader.LoadAsync(", provider);
+        Assert.Contains("HistoryRevisions: input.HistoryRevisions", projector);
         Assert.Contains("snapshot.HistoryRevisions", root);
         Assert.Contains("HistoryRevision: historyRevision", root);
         Assert.Contains("props.HistoryRevision", timeline);
diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs
index 7882438fc..78a59dde5 100644
--- a/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs
@@ -34,11 +34,17 @@ public void TimelineGeneration_FlowsFromProviderSnapshotToTimelineProps()
     {
         var models = Read("src", "OpenClaw.Chat", "ChatModels.cs");
         var provider = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatDataProvider.cs");
+        var state = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatConversationState.cs");
+        var resetState = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatResetState.cs");
+        var projector = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatSnapshotProjector.cs");
         var root = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatRoot.cs");
 
         Assert.Contains("IReadOnlyDictionary<string, long>? TimelineGenerations = null", models);
-        Assert.Contains("new Dictionary<string, long>(_resetVersions)", provider);
-        Assert.Contains("TimelineGenerations: timelineGenerationsCopy", provider);
+        Assert.Contains("new Dictionary<string, long>(_versions)", resetState);
+        Assert.Contains("_reset.SnapshotVersions()", state);
+        Assert.Contains("private readonly ChatConversationState _state", provider);
+        Assert.DoesNotContain("private readonly object _gate", provider);
+        Assert.Contains("TimelineGenerations: input.TimelineGenerations", projector);
         Assert.Contains("snapshot.TimelineGenerations", root);
         Assert.Contains("TimelineGeneration: timelineGeneration", root);
     }
@@ -48,14 +54,20 @@ public void QueuedMessages_RenderInComposerAboveInput()
     {
         var models = Read("src", "OpenClaw.Chat", "ChatModels.cs");
         var provider = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatDataProvider.cs");
+        var state = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatConversationState.cs");
+        var queueState = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatQueueState.cs");
+        var projector = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatSnapshotProjector.cs");
         var root = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatRoot.cs");
         var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatTimeline.cs");
         var composer = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawComposer.cs");
 
         Assert.Contains("public record ChatQueuedMessage", models);
         Assert.Contains("QueuedMessagesByThread", models);
-        Assert.Contains("Dictionary<string, List<ChatQueuedMessage>> _queuedMessages", provider);
-        Assert.Contains("QueuedMessagesByThread: queuedMessagesCopy", provider);
+        Assert.Contains("Dictionary<string, List<ChatQueuedMessage>> _messages", queueState);
+        Assert.Contains("_messages.ToDictionary(", queueState);
+        Assert.Contains("_queue.SnapshotMessages()", state);
+        Assert.DoesNotContain("Dictionary<string, List<ChatQueuedMessage>> _queuedMessages", provider);
+        Assert.Contains("QueuedMessagesByThread: input.QueuedMessages", projector);
         Assert.Contains("snapshot.QueuedMessagesByThread", root);
         Assert.Contains("QueuedMessages: queuedMessages", root);
         Assert.Contains("OnQueuedMessageCancel:", root);
@@ -135,11 +147,15 @@ public void Timeline_DoesNotRenderTemporaryDebugMetadata()
     [Fact]
     public void ResetClearPath_BumpsTimelineGenerationBeforeReusingEntryIds()
     {
-        var provider = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatDataProvider.cs");
+        var state = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatConversationState.cs");
+        var resetState = Read("src", "OpenClaw.Tray.WinUI", "Chat", "ChatResetState.cs");
 
         Assert.Matches(
-            new Regex(@"private\s+ResetClearPersistence\s+ClearThreadHistoryAfterResetLocked\(string\s+threadId\)[\s\S]*_resetVersions\[threadId\]\s*=\s*GetResetVersionLocked\(threadId\)\s*\+\s*1;[\s\S]*_timelines\[threadId\]\s*=\s*ChatTimelineState\.Initial\(\)\s*with\s*\{\s*HistoryLoaded\s*=\s*true\s*\};"),
-            provider);
+            new Regex(@"internal\s+ChatResetTransition\s+ResetThread\([\s\S]*lock\s*\(_gate\)[\s\S]*_reset\.BeginReset\([\s\S]*_timelines\[threadId\]\s*=\s*ChatTimelineState\.Initial\(\)\s*with\s*\{\s*HistoryLoaded\s*=\s*true"),
+            state);
+        Assert.Matches(
+            new Regex(@"internal\s+long\s+BeginReset\([\s\S]*_versions\[threadId\]\s*=\s*generation;"),
+            resetState);
     }
 
     [Fact]
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 7b8dfa5d0..be9b8d8fb 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -20,6 +20,7 @@
     <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
     <ProjectReference Include="..\..\src\OpenClaw.Shared\OpenClaw.Shared.csproj" />
     <ProjectReference Include="..\..\src\OpenClaw.Connection\OpenClaw.Connection.csproj" />
+    <ProjectReference Include="..\OpenClaw.TestSupport\OpenClaw.TestSupport.csproj" />
     <!-- Compile only the model-layer files directly so the tests can remain
          pure net10.0 while the tray app owns the native WinUI surface. -->
     <Compile Include="..\..\src\OpenClaw.Chat\ChatModels.cs" Link="Chat\ChatModels.cs" />
@@ -49,6 +50,21 @@
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\TailNavigationPolicy.cs" Link="Chat\TailNavigationPolicy.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ReactorSlashCommandController.cs" Link="Chat\ReactorSlashCommandController.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\NativeToolProjector.cs" Link="Chat\NativeToolProjector.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatApprovalState.cs" Link="Chat\ChatApprovalState.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatConversationState.cs" Link="Chat\ChatConversationState.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatContentFormatting.cs" Link="Chat\ChatContentFormatting.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatEventMapper.cs" Link="Chat\ChatEventMapper.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatHistoryLoader.cs" Link="Chat\ChatHistoryLoader.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatHistoryState.cs" Link="Chat\ChatHistoryState.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatLifecycleState.cs" Link="Chat\ChatLifecycleState.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatMetadataStore.cs" Link="Chat\ChatMetadataStore.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatPresentationState.cs" Link="Chat\ChatPresentationState.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatQueueState.cs" Link="Chat\ChatQueueState.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatResetState.cs" Link="Chat\ChatResetState.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatRuntimeTransitions.cs" Link="Chat\ChatRuntimeTransitions.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatSendQueue.cs" Link="Chat\ChatSendQueue.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatSnapshotProjector.cs" Link="Chat\ChatSnapshotProjector.cs" />
+    <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\ChatStatePersistence.cs" Link="Chat\ChatStatePersistence.cs" />
     <Compile Include="..\..\src\OpenClaw.Tray.WinUI\Chat\OpenClawChatDataProvider.cs" Link="Chat\OpenClawChatDataProvider.cs" />
   </ItemGroup>
 
diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
index d9daa4571..c422b1f96 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
+++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
@@ -225,6 +225,7 @@ public Task ResolveExecApprovalAsync(string approvalId, string decision)
         public event EventHandler<AgentEventInfo>? AgentEventReceived;
         public event EventHandler<ModelsListInfo>? ModelsListUpdated;
         public bool IsDisposed { get; private set; }
+        public int DisposeCount { get; private set; }
 
         public EventHandler<ConnectionStatus>? CaptureStatusChangedHandlers() => StatusChanged;
         public void RaiseStatus(ConnectionStatus s) { CurrentStatus = s; StatusChanged?.Invoke(this, s); }
@@ -233,7 +234,11 @@ public Task ResolveExecApprovalAsync(string approvalId, string decision)
         public void RaiseChat(ChatMessageInfo m) => ChatMessageReceived?.Invoke(this, m);
         public void RaiseAgent(AgentEventInfo a) => AgentEventReceived?.Invoke(this, a);
         public void RaiseModels(ModelsListInfo m) { CurrentModels = m; ModelsListUpdated?.Invoke(this, m); }
-        public void Dispose() => IsDisposed = true;
+        public void Dispose()
+        {
+            IsDisposed = true;
+            DisposeCount++;
+        }
     }
 
     private static (FakeBridge bridge, OpenClawChatDataProvider provider, List<ChatDataSnapshot> snapshots, List<ChatProviderNotification> notifications)
@@ -245,12 +250,13 @@ private static (FakeBridge bridge, OpenClawChatDataProvider provider, List<ChatD
             TimeSpan? lastChatStateSaveDelay = null,
             Func<TimeSpan, CancellationToken, Func<Task>, Task>? historyRetryScheduler = null,
             Action? historyFailureReservedForTesting = null,
+            Func<Func<Task>, Task>? deferredAbortScheduler = null,
             Action<Action>? post = null)
     {
         var bridge = new FakeBridge { Sessions = initial ?? Array.Empty<SessionInfo>() };
         var provider = toolMetaCachePath is null && attachmentMetaCachePath is null && lastChatStatePath is null &&
             lastChatStateSaveDelay is null && historyRetryScheduler is null && historyFailureReservedForTesting is null &&
-            post is null
+            deferredAbortScheduler is null && post is null
             ? new OpenClawChatDataProvider(bridge)
             : new OpenClawChatDataProvider(
                 bridge,
@@ -260,7 +266,8 @@ post is null
                 lastChatStateFilePath: lastChatStatePath,
                 lastChatStateSaveDelay: lastChatStateSaveDelay,
                 historyRetryScheduler: historyRetryScheduler,
-                historyFailureReservedForTesting: historyFailureReservedForTesting);
+                historyFailureReservedForTesting: historyFailureReservedForTesting,
+                deferredAbortScheduler: deferredAbortScheduler);
         var snapshots = new List<ChatDataSnapshot>();
         var notifications = new List<ChatProviderNotification>();
         provider.Changed += (_, e) => snapshots.Add(e.Snapshot);
@@ -268,6 +275,30 @@ post is null
         return (bridge, provider, snapshots, notifications);
     }
 
+    private static ChatStatePersistence GetStatePersistence(
+        OpenClawChatDataProvider provider) =>
+        Assert.IsType<ChatStatePersistence>(
+            typeof(OpenClawChatDataProvider)
+                .GetField(
+                    "_persistence",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(provider));
+
+    private static void InvokeHandleOpenedLifecycle(
+        OpenClawChatDataProvider provider,
+        string threadId,
+        ChatOpenedLifecycleTransition opened,
+        ChatRuntimeGeneration generation) =>
+        typeof(OpenClawChatDataProvider)
+            .GetMethod(
+                "HandleOpenedLifecycle",
+                System.Reflection.BindingFlags.Instance |
+                System.Reflection.BindingFlags.NonPublic)!
+            .Invoke(
+                provider,
+                [threadId, opened, generation]);
+
     private static SessionInfo MainSession() =>
         new() { Key = "main", IsMain = true, DisplayName = "Main session", Status = "active" };
 
@@ -1091,6 +1122,46 @@ public async Task CompactCompletion_QueuesAuthoritativeReloadBehindInflightHisto
         }
     }
 
+    [Fact]
+    public async Task CoalescedAuthoritativeReload_DoesNotCrossResetGeneration()
+    {
+        var (bridge, provider, _, _) = CreateProvider([MainSession()]);
+        var staleHistory = new TaskCompletionSource<ChatHistoryInfo>(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        bridge.HistoryBehavior = _ => staleHistory.Task;
+
+        await using (provider)
+        {
+            var initialLoad = provider.LoadHistoryAsync("main", force: true);
+            await provider.LoadHistoryAsync(
+                "main",
+                force: true,
+                authoritative: true);
+            await provider.ExecuteLifecycleCommandAsync(
+                "main",
+                ChatLifecycleCommandKind.Reset);
+
+            staleHistory.SetResult(new ChatHistoryInfo
+            {
+                SessionKey = "main",
+                Messages =
+                [
+                    new ChatMessageInfo
+                    {
+                        SessionKey = "main",
+                        Role = "user",
+                        Text = "must stay cleared",
+                    }
+                ],
+            });
+            await initialLoad;
+            await Task.Delay(50);
+
+            Assert.Single(bridge.RequestedHistoryKeys);
+            Assert.Empty((await provider.LoadAsync()).Timelines["main"].Entries);
+        }
+    }
+
     [Fact]
     public async Task CompactAuthoritativeReload_RetriesAfterTransientFailureWhenHistoryWasLoaded()
     {
@@ -1355,7 +1426,7 @@ public async Task LifecycleCommandProvider_ResetSuccessClearsTranscriptAfterResp
     public async Task Telemetry_LocalSendAndLifecycle_EmitCorrelatedAllowlistedSpans()
     {
         using var activities = new ChatActivityCollector();
-        var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() });
+        var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() });
         bridge.SendResults.Enqueue(new ChatSendResult { RunId = "private-run", Status = "started" });
 
         await provider.SendMessageAsync("main", "private prompt");
@@ -3779,31 +3850,1264 @@ public async Task SessionResetCompletion_PostResetSendWithoutRunIdCanOpenOnFresh
             Key = "main"
         });
 
-        bridge.SendResults.Enqueue(new ChatSendResult());
-        await provider.SendMessageAsync("main", "after reset local");
-        bridge.RaiseChat(new ChatMessageInfo
-        {
-            SessionKey = "main",
-            Role = "user",
-            Text = "after reset local"
-        });
-
-        var freshStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "gateway-run");
-        freshStart.Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds();
-        bridge.RaiseAgent(freshStart);
-        bridge.RaiseChat(new ChatMessageInfo
-        {
-            SessionKey = "main",
-            Role = "assistant",
-            State = "final",
-            Text = "fresh response"
-        });
-
-        var latest = snapshots[^1];
-        Assert.Contains(latest.Timelines["main"].Entries, e =>
-            e.Kind == ChatTimelineItemKind.User && e.Text == "after reset local");
-        Assert.Contains(latest.Timelines["main"].Entries, e =>
-            e.Kind == ChatTimelineItemKind.Assistant && e.Text == "fresh response");
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.SendMessageAsync("main", "after reset local");
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = "after reset local"
+        });
+
+        var freshStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "gateway-run");
+        freshStart.Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds();
+        bridge.RaiseAgent(freshStart);
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "final",
+            Text = "fresh response"
+        });
+
+        var latest = snapshots[^1];
+        Assert.Contains(latest.Timelines["main"].Entries, e =>
+            e.Kind == ChatTimelineItemKind.User && e.Text == "after reset local");
+        Assert.Contains(latest.Timelines["main"].Entries, e =>
+            e.Kind == ChatTimelineItemKind.Assistant && e.Text == "fresh response");
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_ProductionSubmissionAcceptsSkewedLifecycleAndFinal()
+    {
+        const string marker = "production skew marker";
+        const string response = "production skew terminal";
+        using var activities = new ChatActivityCollector();
+        var sendStarted = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseSend = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var (bridge, provider, snapshots, _) =
+            CreateProvider(new[] { MainSession() });
+        bridge.SendBehavior = async (_, _, _) =>
+        {
+            sendStarted.TrySetResult();
+            await releaseSend.Task;
+        };
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.LoadAsync();
+        var skewedStartTs = DateTimeOffset.UtcNow
+            .AddSeconds(-5)
+            .ToUnixTimeMilliseconds();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+
+        var sendTask = provider.SendMessageAsync("main", marker);
+        await sendStarted.Task;
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "skewed-run");
+        start.Ts = skewedStartTs;
+        bridge.RaiseAgent(start);
+        var early = MakeAgentEvent(
+            "assistant",
+            """{"delta":"early skewed assistant"}""",
+            runId: "skewed-run");
+        early.Ts = skewedStartTs + 1;
+        bridge.RaiseAgent(early);
+        releaseSend.TrySetResult();
+        await sendTask;
+
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = marker,
+            Ts = skewedStartTs + 2,
+            OpenClawId = "user-skewed",
+            OpenClawSeq = 1,
+        });
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "final",
+            Text = response,
+            Ts = skewedStartTs + 3,
+            OpenClawId = "assistant-skewed",
+            OpenClawSeq = 2,
+        });
+
+        var terminal = snapshots[^1].Timelines["main"];
+        Assert.False(terminal.TurnActive);
+        Assert.DoesNotContain(
+            terminal.Entries,
+            entry => entry.Text.Contains(
+                "early skewed assistant",
+                StringComparison.Ordinal));
+        Assert.Single(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.Single(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == response);
+        Assert.Single(
+            activities.Stopped,
+            activity =>
+                activity.OperationName == ChatTelemetryTracker.TurnSpanName);
+
+        bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo
+        {
+            SessionKey = "main",
+            Messages =
+            [
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "user",
+                    Text = marker,
+                    Ts = skewedStartTs + 2,
+                    OpenClawId = "user-skewed",
+                    OpenClawSeq = 1,
+                },
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "assistant",
+                    State = "final",
+                    Text = response,
+                    Ts = skewedStartTs + 3,
+                    OpenClawId = "assistant-skewed",
+                    OpenClawSeq = 2,
+                },
+            ]
+        });
+        await provider.LoadHistoryAsync(
+            "main",
+            force: true,
+            authoritative: true);
+        var replay = snapshots[^1].Timelines["main"];
+        Assert.Single(
+            replay.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.Single(
+            replay.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == response);
+        Assert.Single(
+            activities.Stopped,
+            activity =>
+                activity.OperationName == ChatTelemetryTracker.TurnSpanName);
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_ExactEchoOverridesMismatchedAcceptedRun()
+    {
+        const string marker = "accepted mismatch marker";
+        var (bridge, provider, snapshots, _) =
+            CreateProvider(new[] { MainSession() });
+        bridge.SendResults.Enqueue(new ChatSendResult
+        {
+            RunId = "response-run",
+            Status = "started",
+        });
+        await provider.LoadAsync();
+        var skewedTs = DateTimeOffset.UtcNow
+            .AddSeconds(-5)
+            .ToUnixTimeMilliseconds();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        await provider.SendMessageAsync("main", marker);
+
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "gateway-run");
+        start.Ts = skewedTs;
+        bridge.RaiseAgent(start);
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = marker,
+            Ts = skewedTs + 1,
+        });
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "final",
+            Text = "accepted mismatch terminal",
+            Ts = skewedTs + 2,
+        });
+
+        var latest = snapshots[^1].Timelines["main"];
+        Assert.False(latest.TurnActive);
+        Assert.Single(
+            latest.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.Single(
+            latest.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == "accepted mismatch terminal");
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_FallbackStepUsesExactEchoTimestampFloor()
+    {
+        const string marker = "fallback step marker";
+        var (bridge, provider, snapshots, _) =
+            CreateProvider(new[] { MainSession() });
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.LoadAsync();
+        var lifecycleTimestamp = DateTimeOffset.UtcNow
+            .AddSeconds(-5)
+            .ToUnixTimeMilliseconds();
+        var echoTimestamp = lifecycleTimestamp - 500;
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        await provider.SendMessageAsync("main", marker);
+
+        var fallback = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"fallback_step"}""",
+            runId: "fallback-run");
+        fallback.Ts = lifecycleTimestamp;
+        bridge.RaiseAgent(fallback);
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = marker,
+            Ts = echoTimestamp,
+        });
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "final",
+            Text = "fallback terminal",
+            Ts = echoTimestamp + 1,
+        });
+
+        var latest = snapshots[^1].Timelines["main"];
+        Assert.False(latest.TurnActive);
+        Assert.Single(
+            latest.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.Single(
+            latest.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == "fallback terminal");
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_RunFloorDoesNotAdmitPreResetUserFrames()
+    {
+        const string marker = "user floor marker";
+        const string delayedUser = "delayed unrelated user";
+        const string freshUser = "fresh remote user";
+        const string response = "same-run terminal";
+        using var activities = new ChatActivityCollector();
+        var (bridge, provider, snapshots, _) =
+            CreateProvider(new[] { MainSession() });
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.LoadAsync();
+        var lifecycleTimestamp = DateTimeOffset.UtcNow
+            .AddSeconds(-5)
+            .ToUnixTimeMilliseconds();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        await provider.SendMessageAsync("main", marker);
+
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "current-run");
+        start.Ts = lifecycleTimestamp;
+        bridge.RaiseAgent(start);
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = marker,
+            Ts = lifecycleTimestamp + 1,
+            OpenClawId = "marker-user",
+            OpenClawSeq = 1,
+        });
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = delayedUser,
+            Ts = lifecycleTimestamp + 2,
+            OpenClawId = "delayed-user",
+            OpenClawSeq = 2,
+        });
+        var sameRunFrame = MakeAgentEvent(
+            "assistant",
+            """{"delta":"same-run partial"}""",
+            runId: "current-run");
+        sameRunFrame.Ts = lifecycleTimestamp + 3;
+        bridge.RaiseAgent(sameRunFrame);
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = freshUser,
+            Ts = DateTimeOffset.UtcNow.AddSeconds(1)
+                .ToUnixTimeMilliseconds(),
+            OpenClawId = "fresh-user",
+            OpenClawSeq = 3,
+        });
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "final",
+            Text = response,
+            Ts = lifecycleTimestamp + 4,
+            OpenClawId = "assistant-final",
+            OpenClawSeq = 4,
+        });
+
+        var terminal = snapshots[^1].Timelines["main"];
+        Assert.False(terminal.TurnActive);
+        Assert.Single(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.DoesNotContain(
+            terminal.Entries,
+            entry => entry.Text == delayedUser);
+        Assert.Contains(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == freshUser);
+        Assert.Contains(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == response);
+        Assert.Single(
+            activities.Stopped,
+            activity =>
+                activity.OperationName ==
+                ChatTelemetryTracker.TurnSpanName);
+
+        bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo
+        {
+            SessionKey = "main",
+            Messages =
+            [
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "user",
+                    Text = marker,
+                    Ts = lifecycleTimestamp + 1,
+                    OpenClawId = "marker-user",
+                    OpenClawSeq = 1,
+                },
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "assistant",
+                    State = "final",
+                    Text = response,
+                    Ts = lifecycleTimestamp + 4,
+                    OpenClawId = "assistant-final",
+                    OpenClawSeq = 4,
+                },
+            ]
+        });
+        await provider.LoadHistoryAsync(
+            "main",
+            force: true,
+            authoritative: true);
+        var replay = snapshots[^1].Timelines["main"];
+        Assert.Single(
+            replay.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.DoesNotContain(
+            replay.Entries,
+            entry => entry.Text == delayedUser);
+        Assert.Single(
+            replay.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == response);
+        Assert.Single(
+            activities.Stopped,
+            activity =>
+                activity.OperationName ==
+                ChatTelemetryTracker.TurnSpanName);
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_ExactEchoSelectsNewestBufferedLifecycle()
+    {
+        const string marker = "newest lifecycle marker";
+        const string currentOutput = "current lifecycle output";
+        using var activities = new ChatActivityCollector();
+        var (bridge, provider, snapshots, _) =
+            CreateProvider(new[] { MainSession() });
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.LoadAsync();
+        var now = DateTimeOffset.UtcNow;
+        var staleTimestamp = now.AddSeconds(-6).ToUnixTimeMilliseconds();
+        var currentTimestamp = now.AddSeconds(-5).ToUnixTimeMilliseconds();
+        var echoTimestamp = now.AddMilliseconds(-5_500)
+            .ToUnixTimeMilliseconds();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        await provider.SendMessageAsync("main", marker);
+
+        var staleStart = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "stale-run");
+        staleStart.Ts = staleTimestamp;
+        bridge.RaiseAgent(staleStart);
+        var currentStart = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "current-run");
+        currentStart.Ts = currentTimestamp;
+        bridge.RaiseAgent(currentStart);
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = marker,
+            Ts = echoTimestamp,
+        });
+
+        var staleOutput = MakeAgentEvent(
+            "assistant",
+            """{"delta":"stale lifecycle output"}""",
+            runId: "stale-run");
+        staleOutput.Ts = echoTimestamp + 1;
+        bridge.RaiseAgent(staleOutput);
+        var staleTerminal = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"error"}""",
+            runId: "stale-run");
+        staleTerminal.Ts = echoTimestamp + 2;
+        bridge.RaiseAgent(staleTerminal);
+        Assert.True(snapshots[^1].Timelines["main"].TurnActive);
+
+        var currentFrame = MakeAgentEvent(
+            "assistant",
+            $$"""{"delta":"{{currentOutput}}"}""",
+            runId: "current-run");
+        currentFrame.Ts = echoTimestamp + 3;
+        bridge.RaiseAgent(currentFrame);
+        var currentTerminal = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"error"}""",
+            runId: "current-run");
+        currentTerminal.Ts = echoTimestamp + 4;
+        bridge.RaiseAgent(currentTerminal);
+
+        var terminal = snapshots[^1].Timelines["main"];
+        Assert.False(terminal.TurnActive);
+        Assert.Single(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.DoesNotContain(
+            terminal.Entries,
+            entry => entry.Text.Contains(
+                "stale lifecycle output",
+                StringComparison.Ordinal));
+        Assert.Contains(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == currentOutput);
+        var turn = Assert.Single(
+            activities.Stopped,
+            activity =>
+                activity.OperationName ==
+                ChatTelemetryTracker.TurnSpanName);
+        Assert.Equal(
+            "lifecycle_error",
+            turn.GetTagItem(
+                OpenClawTelemetryTagKey.Reason.ToTelemetryName()));
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_FailedSendRemovesProductionSubmissionProof()
+    {
+        const string marker = "failed production marker";
+        var (bridge, provider, snapshots, _) =
+            CreateProvider(new[] { MainSession() });
+        bridge.SendBehavior = (_, _, _) =>
+            throw new InvalidOperationException("send rejected");
+        await provider.LoadAsync();
+        var skewedTs = DateTimeOffset.UtcNow
+            .AddSeconds(-5)
+            .ToUnixTimeMilliseconds();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+
+        await Assert.ThrowsAsync<InvalidOperationException>(() =>
+            provider.SendMessageAsync("main", marker));
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "rejected-run");
+        start.Ts = skewedTs;
+        bridge.RaiseAgent(start);
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            Text = marker,
+            Ts = skewedTs + 1,
+        });
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "final",
+            Text = "must remain dropped",
+            Ts = skewedTs + 2,
+        });
+
+        var latest = snapshots[^1].Timelines["main"];
+        Assert.DoesNotContain(
+            latest.Entries,
+            entry => entry.Text == "must remain dropped");
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_ReconciledSubmittedEchoOpensBufferedLifecycleAndTerminalizesOnce()
+    {
+        const string marker = "controlled post-reset marker";
+        const string response = "controlled terminal response";
+        using var activities = new ChatActivityCollector();
+        var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() });
+        await provider.LoadAsync();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.SendMessageAsync("main", marker);
+        Assert.Empty(GetQueuedMessages(snapshots[^1], "main"));
+
+        var state = Assert.IsType<ChatConversationState>(
+            typeof(OpenClawChatDataProvider)
+                .GetField(
+                    "_state",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(provider));
+        var queue = Assert.IsType<ChatQueueState>(
+            typeof(ChatConversationState)
+                .GetField(
+                    "_queue",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(state));
+        var lifecycle = Assert.IsType<ChatLifecycleState>(
+            typeof(ChatConversationState)
+                .GetField(
+                    "_lifecycle",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(state));
+        Assert.True(queue.TryConsumeLocalEcho("main", marker, out _));
+        Assert.False(queue.HasPendingLocalEchoText("main", marker));
+
+        var freshTs = DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds();
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "gateway-run");
+        start.Ts = freshTs;
+        bridge.RaiseAgent(start);
+        var early = MakeAgentEvent(
+            "assistant",
+            """{"delta":"early assistant"}""",
+            runId: "gateway-run");
+        early.Ts = freshTs;
+        bridge.RaiseAgent(early);
+        Assert.DoesNotContain(
+            snapshots[^1].Timelines["main"].Entries,
+            entry => entry.Text.Contains("early assistant", StringComparison.Ordinal));
+
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            State = "final",
+            Text = marker,
+            Ts = freshTs,
+            OpenClawId = "user-echo",
+            OpenClawSeq = 1,
+        });
+        Assert.True(lifecycle.TryGetActiveRun("main", out var activeRun));
+        Assert.Equal("gateway-run", activeRun);
+
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            State = "final",
+            Text = marker,
+            Ts = freshTs,
+            OpenClawId = "duplicate-user-echo",
+            OpenClawSeq = 1,
+        });
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "final",
+            Text = response,
+            Ts = freshTs + 1,
+            OpenClawId = "assistant-final",
+            OpenClawSeq = 2,
+        });
+
+        var terminal = snapshots[^1].Timelines["main"];
+        Assert.False(terminal.TurnActive);
+        Assert.False(lifecycle.TryGetActiveRun("main", out _));
+        Assert.Single(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.Single(
+            terminal.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == response);
+        Assert.Single(
+            activities.Stopped,
+            activity => activity.OperationName == ChatTelemetryTracker.TurnSpanName);
+
+        bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo
+        {
+            SessionKey = "main",
+            Messages =
+            [
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "user",
+                    Text = marker,
+                    Ts = freshTs,
+                    OpenClawId = "user-echo",
+                    OpenClawSeq = 1,
+                },
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "assistant",
+                    State = "final",
+                    Text = response,
+                    Ts = freshTs + 1,
+                    OpenClawId = "assistant-final",
+                    OpenClawSeq = 2,
+                },
+            ]
+        });
+        await provider.LoadHistoryAsync(
+            "main",
+            force: true,
+            authoritative: true);
+
+        var replay = snapshots[^1].Timelines["main"];
+        Assert.False(replay.TurnActive);
+        Assert.Single(
+            replay.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.User &&
+                     entry.Text == marker);
+        Assert.Single(
+            replay.Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == response);
+        Assert.Single(
+            activities.Stopped,
+            activity => activity.OperationName == ChatTelemetryTracker.TurnSpanName);
+
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_ReconciledEchoDispatchesPendingAbortForBufferedLifecycle()
+    {
+        const string marker = "controlled abort marker";
+        var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() });
+        await provider.LoadAsync();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.SendMessageAsync("main", marker);
+
+        var state = Assert.IsType<ChatConversationState>(
+            typeof(OpenClawChatDataProvider)
+                .GetField(
+                    "_state",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(provider));
+        var reset = Assert.IsType<ChatResetState>(
+            typeof(ChatConversationState)
+                .GetField(
+                    "_reset",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(state));
+        var queue = Assert.IsType<ChatQueueState>(
+            typeof(ChatConversationState)
+                .GetField(
+                    "_queue",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(state));
+        reset.AddSubmittedLocalEcho("main", marker, DateTimeOffset.UtcNow);
+        Assert.True(queue.TryConsumeLocalEcho("main", marker, out _));
+
+        var freshTs = DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds();
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "gateway-run");
+        start.Ts = freshTs;
+        bridge.RaiseAgent(start);
+        await provider.StopResponseAsync("main");
+        Assert.Empty(bridge.AbortedRunIds);
+
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            State = "final",
+            Text = marker,
+            Ts = freshTs,
+            OpenClawId = "user-echo",
+        });
+        await WaitForConditionAsync(() =>
+            bridge.AbortedRunIds.Contains("gateway-run"));
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "assistant",
+            State = "delta",
+            Text = "must stay suppressed",
+            Ts = freshTs + 1,
+        });
+
+        Assert.Single(
+            bridge.AbortedRunIds,
+            runId => runId == "gateway-run");
+        Assert.DoesNotContain(
+            (await provider.LoadAsync()).Timelines["main"].Entries,
+            entry => entry.Text.Contains(
+                "must stay suppressed",
+                StringComparison.Ordinal));
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_RemoteUserOpenDispatchesPendingAbort()
+    {
+        using var activities = new ChatActivityCollector();
+        var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() });
+        await provider.LoadAsync();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+
+        var freshTs = DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds();
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "remote-run");
+        start.Ts = freshTs;
+        bridge.RaiseAgent(start);
+        await provider.StopResponseAsync("main");
+
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            State = "final",
+            Text = "fresh remote user",
+            Ts = freshTs,
+            OpenClawId = "remote-user",
+        });
+        await WaitForConditionAsync(() =>
+            bridge.AbortedRunIds.Contains("remote-run"));
+
+        Assert.Single(
+            bridge.AbortedRunIds,
+            runId => runId == "remote-run");
+        var end = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"end"}""",
+            runId: "remote-run");
+        end.Ts = freshTs + 1;
+        bridge.RaiseAgent(end);
+        Assert.DoesNotContain(
+            activities.Stopped,
+            activity =>
+                activity.OperationName == ChatTelemetryTracker.TurnSpanName);
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_AcceptedSendOpenDispatchesPendingAbort()
+    {
+        var sendStarted = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseSend = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() });
+        bridge.SendBehavior = async (_, _, _) =>
+        {
+            sendStarted.TrySetResult();
+            await releaseSend.Task;
+        };
+        bridge.SendResults.Enqueue(new ChatSendResult
+        {
+            RunId = "accepted-run",
+            Status = "started"
+        });
+        await provider.LoadAsync();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+
+        var sendTask = provider.SendMessageAsync(
+            "main",
+            "accepted send marker");
+        await sendStarted.Task;
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "accepted-run");
+        start.Ts = DateTimeOffset.UtcNow
+            .AddSeconds(2)
+            .ToUnixTimeMilliseconds();
+        bridge.RaiseAgent(start);
+        await provider.StopResponseAsync("main");
+        releaseSend.TrySetResult();
+        await sendTask;
+        await WaitForConditionAsync(() =>
+            bridge.AbortedRunIds.Contains("accepted-run"));
+
+        Assert.Single(
+            bridge.AbortedRunIds,
+            runId => runId == "accepted-run");
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_RemoteBackfillOpenDispatchesPendingAbort()
+    {
+        var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() });
+        bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo
+        {
+            SessionKey = "main",
+            Messages =
+            [
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "user",
+                    Text = "timestamp-less remote user",
+                    Ts = 0,
+                    OpenClawId = "remote-user",
+                },
+            ]
+        });
+        await provider.LoadAsync();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "backfill-run");
+        start.Ts = DateTimeOffset.UtcNow
+            .AddSeconds(2)
+            .ToUnixTimeMilliseconds();
+        bridge.RaiseAgent(start);
+        await provider.StopResponseAsync("main");
+
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            State = "final",
+            Text = "timestamp-less remote user",
+            Ts = 0,
+            OpenClawId = "remote-user",
+        });
+        await WaitForConditionAsync(() =>
+            bridge.AbortedRunIds.Contains("backfill-run"),
+            attempts: 200);
+
+        Assert.Single(
+            bridge.AbortedRunIds,
+            runId => runId == "backfill-run");
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task SessionResetCompletion_FailedDeferredAbortRollsBackBufferedRun()
+    {
+        const string marker = "controlled failed abort marker";
+        const string queued = "queued while abort fails";
+        var (bridge, provider, _, notifications) =
+            CreateProvider(new[] { MainSession() });
+        bridge.RaiseStatus(ConnectionStatus.Connected);
+        var abortStarted = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseAbort = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        bridge.AbortBehavior = async _ =>
+        {
+            abortStarted.TrySetResult();
+            await releaseAbort.Task;
+            throw new InvalidOperationException("deferred abort failed");
+        };
+        await provider.LoadAsync();
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.SendMessageAsync("main", marker);
+
+        var state = Assert.IsType<ChatConversationState>(
+            typeof(OpenClawChatDataProvider)
+                .GetField(
+                    "_state",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(provider));
+        var reset = Assert.IsType<ChatResetState>(
+            typeof(ChatConversationState)
+                .GetField(
+                    "_reset",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(state));
+        var queue = Assert.IsType<ChatQueueState>(
+            typeof(ChatConversationState)
+                .GetField(
+                    "_queue",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(state));
+        var lifecycle = Assert.IsType<ChatLifecycleState>(
+            typeof(ChatConversationState)
+                .GetField(
+                    "_lifecycle",
+                    System.Reflection.BindingFlags.Instance |
+                    System.Reflection.BindingFlags.NonPublic)!
+                .GetValue(state));
+        reset.AddSubmittedLocalEcho("main", marker, DateTimeOffset.UtcNow);
+        Assert.True(queue.TryConsumeLocalEcho("main", marker, out _));
+
+        var freshTs = DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds();
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "gateway-run");
+        start.Ts = freshTs;
+        bridge.RaiseAgent(start);
+        await provider.StopResponseAsync("main");
+        bridge.RaiseChat(new ChatMessageInfo
+        {
+            SessionKey = "main",
+            Role = "user",
+            State = "final",
+            Text = marker,
+            Ts = freshTs,
+            OpenClawId = "user-echo",
+        });
+        await abortStarted.Task;
+        await provider.SendMessageAsync("main", queued);
+        Assert.DoesNotContain(queued, bridge.SentMessages);
+        releaseAbort.TrySetResult();
+        await WaitForConditionAsync(() =>
+            notifications.Any(notification =>
+                notification.Kind == ChatProviderNotificationKind.Error &&
+                notification.Message == "deferred abort failed"));
+        await WaitForConditionAsync(() =>
+            bridge.SentMessages.Contains(queued),
+            attempts: 200);
+
+        Assert.False(provider.IsResponseSuppressed);
+        Assert.False(lifecycle.TryGetActiveRun("main", out _));
+        Assert.Single(
+            bridge.AbortedRunIds,
+            runId => runId == "gateway-run");
+        Assert.Single(
+            notifications,
+            notification =>
+                notification.Kind == ChatProviderNotificationKind.Error &&
+                notification.Message == "deferred abort failed");
+        Assert.Contains(queued, bridge.SentMessages);
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task DeferredAbort_AgentLifecycleFailureRollsBackRun()
+    {
+        var (bridge, provider, snapshots, notifications) =
+            CreateProvider(new[] { MainSession() });
+        bridge.AbortBehavior = _ =>
+            throw new InvalidOperationException("agent abort failed");
+        bridge.SendResults.Enqueue(new ChatSendResult());
+        await provider.LoadAsync();
+        await provider.SendMessageAsync("main", "waiting for lifecycle");
+        await provider.StopResponseAsync("main");
+
+        var start = MakeAgentEvent(
+            "lifecycle",
+            """{"phase":"start"}""",
+            runId: "gateway-run");
+        start.Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+        bridge.RaiseAgent(start);
+        await WaitForConditionAsync(() =>
+            notifications.Any(notification =>
+                notification.Kind == ChatProviderNotificationKind.Error &&
+                notification.Message == "agent abort failed"));
+
+        bridge.RaiseAgent(MakeAgentEvent(
+            "assistant",
+            """{"delta":"accepted after rollback"}""",
+            runId: "gateway-run"));
+
+        Assert.Single(
+            bridge.AbortedRunIds,
+            runId => runId == "gateway-run");
+        Assert.Single(
+            notifications,
+            notification =>
+                notification.Kind == ChatProviderNotificationKind.Error &&
+                notification.Message == "agent abort failed");
+        Assert.Contains(
+            snapshots[^1].Timelines["main"].Entries,
+            entry => entry.Kind == ChatTimelineItemKind.Assistant &&
+                     entry.Text == "accepted after rollback");
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task DeferredAbort_StaleBeforeHandleDoesNotScheduleWork()
+    {
+        var scheduleCount = 0;
+        var (bridge, provider, _, _) = CreateProvider(
+            new[] { MainSession() },
+            deferredAbortScheduler: work =>
+            {
+                scheduleCount++;
+                return Task.CompletedTask;
+            });
+        var state = GetConversationState(provider);
+        var token = state.CaptureHistoryToken("main");
+        var generation = new ChatRuntimeGeneration(
+            token.ConnectionGeneration,
+            token.ResetGeneration);
+        bridge.RaiseStatus(ConnectionStatus.Connected);
+        var opened = new ChatOpenedLifecycleTransition(
+            MakeAgentEvent(
+                "lifecycle",
+                """{"phase":"start"}""",
+                runId: "stale-run"),
+            AllowRemoteTurn: false,
+            DeferredAbortRunId: "stale-run",
+            DeferredAbortCount: 1);
+
+        InvokeHandleOpenedLifecycle(
+            provider,
+            "main",
+            opened,
+            generation);
+        await Task.Delay(25);
+
+        Assert.Equal(0, scheduleCount);
+        Assert.Empty(bridge.AbortedRunIds);
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task DeferredAbort_ResetBeforeScheduledBodyDoesNotSendAbort()
+    {
+        Func<Task>? scheduledWork = null;
+        var (bridge, provider, _, _) = CreateProvider(
+            new[] { MainSession() },
+            deferredAbortScheduler: work =>
+            {
+                scheduledWork = work;
+                return Task.CompletedTask;
+            });
+        var state = GetConversationState(provider);
+        var token = state.CaptureHistoryToken("main");
+        var generation = new ChatRuntimeGeneration(
+            token.ConnectionGeneration,
+            token.ResetGeneration);
+        var opened = new ChatOpenedLifecycleTransition(
+            MakeAgentEvent(
+                "lifecycle",
+                """{"phase":"start"}""",
+                runId: "stale-run"),
+            AllowRemoteTurn: false,
+            DeferredAbortRunId: "stale-run",
+            DeferredAbortCount: 1);
+        InvokeHandleOpenedLifecycle(
+            provider,
+            "main",
+            opened,
+            generation);
+        Assert.NotNull(scheduledWork);
+
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        await scheduledWork!();
+
+        Assert.Empty(bridge.AbortedRunIds);
+        await provider.DisposeAsync();
+    }
+
+    [Fact]
+    public async Task DeferredAbort_ResetDuringSuccessfulAbortSkipsStalePersistence()
+    {
+        Func<Task>? scheduledWork = null;
+        var abortStarted = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseAbort = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var (bridge, provider, _, _) = CreateProvider(
+            new[] { MainSession() },
+            deferredAbortScheduler: work =>
+            {
+                scheduledWork = work;
+                return Task.CompletedTask;
+            });
+        bridge.AbortBehavior = async _ =>
+        {
+            abortStarted.TrySetResult();
+            await releaseAbort.Task;
+        };
+        bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo
+        {
+            SessionKey = "main",
+            Messages =
+            [
+                new ChatMessageInfo
+                {
+                    SessionKey = "main",
+                    Role = "user",
+                    Text = "aborted user",
+                    OpenClawId = "aborted-user-id",
+                },
+            ]
+        });
+        var state = GetConversationState(provider);
+        var persistence = GetStatePersistence(provider);
+        var token = state.CaptureHistoryToken("main");
+        var generation = new ChatRuntimeGeneration(
+            token.ConnectionGeneration,
+            token.ResetGeneration);
+        var opened = new ChatOpenedLifecycleTransition(
+            MakeAgentEvent(
+                "lifecycle",
+                """{"phase":"start"}""",
+                runId: "stale-run"),
+            AllowRemoteTurn: false,
+            DeferredAbortRunId: "stale-run",
+            DeferredAbortCount: 1);
+        InvokeHandleOpenedLifecycle(
+            provider,
+            "main",
+            opened,
+            generation);
+        Assert.NotNull(scheduledWork);
+
+        var work = scheduledWork!();
+        await abortStarted.Task;
+        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+        {
+            Method = "sessions.reset",
+            Ok = true,
+            Key = "main"
+        });
+        releaseAbort.TrySetResult();
+        await work;
+
+        Assert.Single(
+            bridge.AbortedRunIds,
+            runId => runId == "stale-run");
+        Assert.False(persistence.IsMessageAborted(
+            "main",
+            "aborted-user-id",
+            resetGeneration: 0));
+        await provider.DisposeAsync();
     }
 
     [Fact]
@@ -4048,6 +5352,17 @@ public async Task DisposeAsync_UnsubscribesAndStopsRaisingChanged()
         Assert.True(bridge.IsDisposed);
     }
 
+    [Fact]
+    public async Task DisposeAsync_ConcurrentCalls_DisposesBridgeOnce()
+    {
+        var (bridge, provider, _, _) = CreateProvider([MainSession()]);
+
+        await Task.WhenAll(Enumerable.Range(0, 8)
+            .Select(_ => provider.DisposeAsync().AsTask()));
+
+        Assert.Equal(1, bridge.DisposeCount);
+    }
+
     [Fact]
     public async Task DisposeAsync_WithQueuedFollowUp_DoesNotDrainNextMessage()
     {
@@ -6749,7 +8064,7 @@ public async Task AgentEvent_LateLegacyParentUpsertsResolvedCacheGeneration()
         await provider.DisposeAsync();
 
         var cache = JsonSerializer.Deserialize<
-            Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(
+            Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(
                 File.ReadAllText(cachePath));
         var entry = Assert.Single(Assert.Single(cache!).Value);
         Assert.Equal("tool-1", entry.ToolCallId);
@@ -7820,6 +9135,98 @@ public async Task SendMessageAsync_WaitsForInFlightModelPatchBeforeGatewaySend()
         Assert.Equal(new[] { "Hello" }, bridge.SentMessages);
     }
 
+    [Fact]
+    public async Task SendMessageAsync_ModelPatchWait_DropsDispatchAfterReconnect()
+    {
+        var patchStarted = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var releasePatch = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() });
+        bridge.PatchSessionModelBehavior = (_, _) =>
+        {
+            patchStarted.TrySetResult();
+            return releasePatch.Task;
+        };
+        await provider.LoadAsync();
+
+        var modelTask = provider.SetModelAsync("main", "openai/gpt-5.4");
+        await patchStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
+        var sendTask = provider.SendMessageAsync("main", "stale after reconnect");
+        await Task.Delay(50);
+        Assert.Empty(bridge.SentMessages);
+
+        bridge.RaiseStatus(ConnectionStatus.Connected);
+        await provider.SendMessageAsync("main", "fresh after reconnect");
+        Assert.Empty(bridge.SentMessages);
+        releasePatch.SetResult();
+        await Task.WhenAll(modelTask, sendTask);
+
+        await WaitForConditionAsync(() => bridge.SentMessages.Count == 1);
+        Assert.Equal(new[] { "fresh after reconnect" }, bridge.SentMessages);
+    }
+
+    [Fact]
+    public async Task SendMessageAsync_InFlightResultAfterReconnect_EndsStaleTurn()
+    {
+        var sendStarted = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseSend = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() });
+        bridge.SendBehavior = async (_, _, _) =>
+        {
+            sendStarted.TrySetResult();
+            await releaseSend.Task;
+        };
+        await provider.LoadAsync();
+
+        var staleSend = provider.SendMessageAsync("main", "in flight");
+        await sendStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
+        bridge.RaiseStatus(ConnectionStatus.Connected);
+        bridge.SendBehavior = null;
+        await provider.SendMessageAsync("main", "fresh after stale result");
+        releaseSend.SetResult();
+        await staleSend;
+
+        await WaitForConditionAsync(() => bridge.SentMessages.Count == 2);
+        Assert.Equal(
+            new[] { "in flight", "fresh after stale result" },
+            bridge.SentMessages);
+    }
+
+    [Fact]
+    public async Task SendMessageAsync_StaleFailureAfterReconnect_DrainsFreshMessage()
+    {
+        var sendStarted = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var releaseSend = new TaskCompletionSource(
+            TaskCreationOptions.RunContinuationsAsynchronously);
+        var sendCount = 0;
+        var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() });
+        bridge.SendBehavior = async (_, _, _) =>
+        {
+            if (Interlocked.Increment(ref sendCount) != 1)
+                return;
+            sendStarted.TrySetResult();
+            await releaseSend.Task;
+            throw new InvalidOperationException("stale failure");
+        };
+        await provider.LoadAsync();
+
+        var staleSend = provider.SendMessageAsync("main", "failing in flight");
+        await sendStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
+        bridge.RaiseStatus(ConnectionStatus.Connected);
+        await provider.SendMessageAsync("main", "fresh after stale failure");
+        releaseSend.SetResult();
+        await staleSend;
+
+        await WaitForConditionAsync(() => bridge.SentMessages.Count == 2);
+        Assert.Equal(
+            new[] { "failing in flight", "fresh after stale failure" },
+            bridge.SentMessages);
+    }
+
     [Fact]
     public async Task SendMessageAsync_ContinuesWhenInFlightModelPatchFails()
     {
@@ -10184,6 +11591,299 @@ private static AgentEventInfo MakeApprovalResolvedEvent(
         return MakeAgentEvent("approval", json, sessionKey: sessionKey);
     }
 
+    [Fact]
+    public async Task RuntimeGolden_PublicSnapshotPreservesCrossDomainState()
+    {
+        var session = new SessionInfo
+        {
+            Key = "main",
+            IsMain = true,
+            DisplayName = "Main session",
+            Status = "active",
+            Model = "provider/model-a",
+            Provider = "provider",
+            ThinkingLevel = "high",
+        };
+        var (bridge, provider, snapshots, _) = CreateProvider([session]);
+        bridge.MainSessionKey = "main";
+        bridge.HasHandshakeSnapshot = true;
+        bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" });
+
+        await using (provider)
+        {
+            await provider.LoadAsync();
+            bridge.RaiseStatus(ConnectionStatus.Connected);
+            bridge.RaiseSessions([session]);
+            bridge.RaiseModels(new ModelsListInfo
+            {
+                Models =
+                [
+                    new ModelInfo { Id = "provider/model-a", Name = "Model A", Provider = "provider" },
+                    new ModelInfo { Id = "provider/model-b", Name = "Model B", Provider = "provider" },
+                ],
+            });
+
+            await provider.SendMessageAsync("main", "hello");
+            bridge.RaiseAgent(MakeAgentEvent(
+                "lifecycle",
+                """{"phase":"start"}""",
+                runId: "run-1"));
+            bridge.RaiseAgent(MakeAgentEvent(
+                "item",
+                """{"kind":"tool","phase":"start","title":"read notes.txt","itemId":"tool-1"}""",
+                runId: "run-1"));
+            bridge.RaiseAgent(MakeAgentEvent(
+                "approval",
+                """{"phase":"requested","approvalSlug":"approve-1","approvalId":"approval-1","title":"Run command","host":"node","command":"echo ok"}""",
+                runId: "run-1"));
+            bridge.RaiseChat(new ChatMessageInfo
+            {
+                SessionKey = "main",
+                Role = "assistant",
+                Text = "done",
+                State = "final",
+                OpenClawId = "message-1",
+                OpenClawSeq = 4,
+            });
+
+            var snapshot = snapshots[^1];
+            Assert.Equal(
+                [
+                    "main|Main session||provider/model-a|high|Running|Working",
+                ],
+                snapshot.Threads.Select(thread =>
+                    $"{thread.Id}|{thread.Title}|{thread.AgentId}|{thread.Model}|{thread.ThinkingLevel}|{thread.Status}|{thread.Activity}"));
+            Assert.Equal(
+                [
+                    "e1|User|hello|False|||Pending",
+                    "e2|ToolCall|read notes.txt|False|Tool|Interrupted|Pending",
+                    "e3|PermissionRequest|echo ok|False|node||Pending",
+                    "e4|Assistant|done|False|||Pending",
+                ],
+                snapshot.Timelines["main"].Entries.Select(entry =>
+                    $"{entry.Id}|{entry.Kind}|{entry.Text}|{entry.IsStreaming}|{entry.ToolName}|{entry.ToolResult}|{entry.PermissionDecision}"));
+            Assert.Equal("approve-1", snapshot.Timelines["main"].PendingPermission?.RequestId);
+            Assert.Equal(["provider/model-a", "provider/model-b"], snapshot.AvailableModels);
+            Assert.Equal("main", snapshot.DefaultThreadId);
+            Assert.Equal(new ChatComposeTarget("main", true, "main"), snapshot.ComposeTarget);
+            Assert.Equal("Connected", snapshot.ConnectionStatus);
+            Assert.Equal(0, snapshot.TimelineGenerations!.GetValueOrDefault("main"));
+            Assert.Empty(GetQueuedMessages(snapshot, "main"));
+        }
+    }
+
+    [Fact]
+    public async Task RuntimeGolden_ResetAtomicallyClearsQueueAndAdvancesGeneration()
+    {
+        var (bridge, provider, snapshots, _) = CreateProvider([MainSession()]);
+        bridge.MainSessionKey = "main";
+        bridge.HasHandshakeSnapshot = true;
+        bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" });
+
+        await using (provider)
+        {
+            await provider.LoadAsync();
+            bridge.RaiseStatus(ConnectionStatus.Connected);
+            bridge.RaiseSessions([MainSession()]);
+            await provider.SendMessageAsync("main", "first");
+            bridge.RaiseAgent(MakeAgentEvent(
+                "lifecycle",
+                """{"phase":"start"}""",
+                runId: "run-1"));
+            await provider.SendMessageAsync("main", "second");
+
+            var queuedSnapshot = snapshots[^1];
+            Assert.Equal(
+                ["q2|second|Queued"],
+                GetQueuedMessages(queuedSnapshot, "main")
+                    .Select(message => $"{message.Id}|{message.Text}|{message.SendState}"));
+
+            var result = await provider.ExecuteLifecycleCommandAsync(
+                "main",
+                ChatLifecycleCommandKind.Reset);
+
+            Assert.True(result.Succeeded);
+            var resetSnapshot = snapshots[^1];
+            Assert.Empty(resetSnapshot.Timelines["main"].Entries);
+            Assert.True(resetSnapshot.Timelines["main"].HistoryLoaded);
+            Assert.Empty(GetQueuedMessages(resetSnapshot, "main"));
+            Assert.Equal(1, resetSnapshot.TimelineGenerations!["main"]);
+            await WaitForConditionAsync(() => bridge.AbortedRunIds.Count == 2);
+            Assert.Contains("run-1", bridge.AbortedRunIds);
+            Assert.Equal(2, bridge.AbortedRunIds.Distinct(StringComparer.Ordinal).Count());
+        }
+    }
+
+    [Fact]
+    public async Task LoadHistoryAsync_QueuedDeliveryDropsAfterResetGenerationAdvances()
+    {
+        using var temp = new TempDirectory();
+        var bridge = new FakeBridge
+        {
+            Sessions = [MainSession()],
+            HistoryBehavior = key => Task.FromResult(new ChatHistoryInfo
+            {
+                SessionKey = key ?? string.Empty,
+                Messages =
+                [
+                    new ChatMessageInfo
+                    {
+                        Role = "assistant",
+                        Text = "stale history",
+                        OpenClawSeq = 1,
+                    },
+                ],
+            }),
+        };
+        var deliveries = new List<Action>();
+        var snapshots = new List<ChatDataSnapshot>();
+        var provider = new OpenClawChatDataProvider(
+            bridge,
+            post: deliveries.Add,
+            toolMetaCacheFilePath: Path.Combine(
+                temp.DirectoryPath,
+                "tool-metadata.json"));
+        provider.Changed += (_, args) => snapshots.Add(args.Snapshot);
+        await using (provider)
+        {
+            await provider.LoadAsync();
+            await provider.LoadHistoryAsync("main");
+            Assert.Single(deliveries);
+
+            bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+            {
+                Method = "sessions.reset",
+                Ok = true,
+                Key = "main",
+            });
+            Assert.Equal(2, deliveries.Count);
+
+            deliveries[1]();
+            deliveries[0]();
+
+            var snapshot = Assert.Single(snapshots);
+            Assert.Empty(snapshot.Timelines["main"].Entries);
+            Assert.Equal(1, snapshot.TimelineGenerations!["main"]);
+        }
+    }
+
+    [Fact]
+    public async Task LoadHistoryAsync_DelayedRetryDoesNotCrossResetGeneration()
+    {
+        var retries = new List<Func<Task>>();
+        var calls = 0;
+        var (bridge, provider, _, _) = CreateProvider(
+            [MainSession()],
+            historyRetryScheduler: (_, _, retry) =>
+            {
+                retries.Add(retry);
+                return Task.CompletedTask;
+            });
+        bridge.HistoryBehavior = _ =>
+        {
+            calls++;
+            throw new InvalidOperationException("history unavailable");
+        };
+        await using (provider)
+        {
+            await provider.LoadAsync();
+            bridge.RaiseStatus(ConnectionStatus.Connected);
+            await provider.LoadHistoryAsync("main");
+            var retry = Assert.Single(retries);
+            Assert.Equal(1, GetHistoryRetryEntryCount(provider));
+
+            bridge.RaiseSessionCommandCompleted(new SessionCommandResult
+            {
+                Method = "sessions.reset",
+                Ok = true,
+                Key = "main",
+            });
+            Assert.Equal(0, GetHistoryRetryEntryCount(provider));
+            await retry();
+
+            Assert.Equal(1, calls);
+            Assert.Single(retries);
+        }
+    }
+
+    [Fact]
+    public async Task HistoryResetCleanup_PreservesCurrentGenerationPendingWork()
+    {
+        var (_, provider, _, _) = CreateProvider([MainSession()]);
+        await using (provider)
+        {
+            await provider.LoadAsync();
+            var state = GetConversationState(provider);
+            var loader = GetHistoryLoader(provider);
+            var oldToken = state.CaptureHistoryToken("main");
+            var reset = state.ResetThread(
+                "main",
+                new ChatProjectionContext(
+                    "main",
+                    HasHandshakeSnapshot: true));
+            var currentToken = state.CaptureHistoryToken("main");
+            var pending = GetHistoryPendingReloads(loader);
+            var retries = GetHistoryRetryEntries(loader);
+            pending["main"] = currentToken;
+            retries[oldToken] = 1;
+            retries[currentToken] = 1;
+
+            loader.ApplyReset("main", reset.ResetGeneration);
+
+            Assert.Equal(currentToken, pending["main"]);
+            Assert.False(retries.ContainsKey(oldToken));
+            Assert.Equal(1, retries[currentToken]);
+        }
+    }
+
+    [Fact]
+    public async Task HistoryRetry_StaleTokenCannotOccupyCurrentPendingSlot()
+    {
+        var retries = new List<Func<Task>>();
+        var (bridge, provider, _, _) = CreateProvider(
+            [MainSession()],
+            historyRetryScheduler: (_, _, retry) =>
+            {
+                retries.Add(retry);
+                return Task.CompletedTask;
+            });
+        bridge.HistoryBehavior = _ =>
+            throw new InvalidOperationException("history unavailable");
+        await using (provider)
+        {
+            await provider.LoadAsync();
+            bridge.RaiseStatus(ConnectionStatus.Connected);
+            await provider.LoadHistoryAsync("main", force: true);
+            var staleRetry = Assert.Single(retries);
+            var currentHistory = new TaskCompletionSource<ChatHistoryInfo>(
+                TaskCreationOptions.RunContinuationsAsynchronously);
+            var loader = GetHistoryLoader(provider);
+            bridge.HistoryBehavior = _ => currentHistory.Task;
+
+            var state = GetConversationState(provider);
+            var reset = state.ResetThread(
+                "main",
+                new ChatProjectionContext(
+                    "main",
+                    HasHandshakeSnapshot: true));
+            loader.ApplyReset("main", reset.ResetGeneration);
+            var currentLoad = provider.LoadHistoryAsync(
+                "main",
+                force: true,
+                authoritative: true);
+            await Task.Yield();
+
+            await staleRetry();
+
+            Assert.Empty(GetHistoryPendingReloads(loader));
+            currentHistory.SetResult(new ChatHistoryInfo
+            {
+                SessionKey = "main",
+            });
+            await currentLoad;
+        }
+    }
+
     [Fact]
     public void LoadLastChatState_WithCorruptedJson_ReturnsNull()
     {
@@ -10204,11 +11904,19 @@ private static IReadOnlyList<ChatQueuedMessage> GetQueuedMessages(ChatDataSnapsh
 
     private static ISet<string> GetQueuedDrainScheduledThreads(OpenClawChatDataProvider provider)
     {
-        var field = typeof(OpenClawChatDataProvider).GetField(
-            "_queuedDrainScheduledThreads",
+        var stateField = typeof(OpenClawChatDataProvider).GetField(
+            "_state",
             System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
-        Assert.NotNull(field);
-        return Assert.IsAssignableFrom<ISet<string>>(field.GetValue(provider));
+        var state = Assert.IsType<ChatConversationState>(stateField?.GetValue(provider));
+        var queueField = typeof(ChatConversationState).GetField(
+            "_queue",
+            System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+        var queue = Assert.IsType<ChatQueueState>(queueField?.GetValue(state));
+        var scheduledField = typeof(ChatQueueState).GetField(
+            "_drainScheduledThreads",
+            System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+        Assert.NotNull(scheduledField);
+        return Assert.IsAssignableFrom<ISet<string>>(scheduledField.GetValue(queue));
     }
 
     private static void MarkPersistedMessageAborted(
@@ -10217,11 +11925,78 @@ private static void MarkPersistedMessageAborted(
         string messageId)
     {
         var field = typeof(OpenClawChatDataProvider).GetField(
-            "_persistedAbortedIds",
+            "_persistence",
             System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
         Assert.NotNull(field);
-        var abortedIds = Assert.IsType<Dictionary<string, HashSet<string>>>(field.GetValue(provider));
-        abortedIds[threadId] = [messageId];
+        var persistence =
+            Assert.IsType<ChatStatePersistence>(field.GetValue(provider));
+        Assert.True(persistence.TryAddAbortedIds(
+            threadId,
+            resetGeneration: 0,
+            [messageId]));
+    }
+
+    private static int GetHistoryRetryEntryCount(OpenClawChatDataProvider provider)
+    {
+        var loaderField = typeof(OpenClawChatDataProvider).GetField(
+            "_historyLoader",
+            System.Reflection.BindingFlags.Instance |
+            System.Reflection.BindingFlags.NonPublic);
+        var loader = Assert.IsType<ChatHistoryLoader>(
+            loaderField?.GetValue(provider));
+        var retriesField = typeof(ChatHistoryLoader).GetField(
+            "_retryCounts",
+            System.Reflection.BindingFlags.Instance |
+            System.Reflection.BindingFlags.NonPublic);
+        var retries = Assert.IsAssignableFrom<System.Collections.IDictionary>(
+            retriesField?.GetValue(loader));
+        return retries.Count;
+    }
+
+    private static ChatConversationState GetConversationState(
+        OpenClawChatDataProvider provider)
+    {
+        var field = typeof(OpenClawChatDataProvider).GetField(
+            "_state",
+            System.Reflection.BindingFlags.Instance |
+            System.Reflection.BindingFlags.NonPublic);
+        return Assert.IsType<ChatConversationState>(
+            field?.GetValue(provider));
+    }
+
+    private static ChatHistoryLoader GetHistoryLoader(
+        OpenClawChatDataProvider provider)
+    {
+        var field = typeof(OpenClawChatDataProvider).GetField(
+            "_historyLoader",
+            System.Reflection.BindingFlags.Instance |
+            System.Reflection.BindingFlags.NonPublic);
+        return Assert.IsType<ChatHistoryLoader>(
+            field?.GetValue(provider));
+    }
+
+    private static Dictionary<string, ChatHistoryCommitToken>
+        GetHistoryPendingReloads(ChatHistoryLoader loader)
+    {
+        var field = typeof(ChatHistoryLoader).GetField(
+            "_authoritativePending",
+            System.Reflection.BindingFlags.Instance |
+            System.Reflection.BindingFlags.NonPublic);
+        return Assert.IsType<
+            Dictionary<string, ChatHistoryCommitToken>>(
+            field?.GetValue(loader));
+    }
+
+    private static Dictionary<ChatHistoryCommitToken, int>
+        GetHistoryRetryEntries(ChatHistoryLoader loader)
+    {
+        var field = typeof(ChatHistoryLoader).GetField(
+            "_retryCounts",
+            System.Reflection.BindingFlags.Instance |
+            System.Reflection.BindingFlags.NonPublic);
+        return Assert.IsType<
+            Dictionary<ChatHistoryCommitToken, int>>(
+            field?.GetValue(loader));
     }
 
     private static bool HasFailedQueuedMessage(ChatDataSnapshot snapshot, string threadId, string text) =>
diff --git a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs
index 989dfc205..b8eb30327 100644
--- a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs
@@ -12,7 +12,7 @@ namespace OpenClaw.Tray.Tests;
 /// </summary>
 public class ToolMetaCacheTests
 {
-    private static OpenClawChatDataProvider.CachedToolMeta Meta(long ts, string tool, string label) =>
+    private static ChatMetadataStore.CachedToolMeta Meta(long ts, string tool, string label) =>
         new() { Ts = ts, ToolName = tool, Label = label };
 
     // ── TryMatchCachedTool ──
@@ -20,23 +20,23 @@ private static OpenClawChatDataProvider.CachedToolMeta Meta(long ts, string tool
     [Fact]
     public void TryMatch_NullCache_ReturnsNull()
     {
-        Assert.Null(OpenClawChatDataProvider.TryMatchCachedTool(null, 1000));
+        Assert.Null(ChatMetadataStore.TryMatchCachedTool(null, 1000));
     }
 
     [Fact]
     public void TryMatch_EmptyCache_ReturnsNull()
     {
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
-        Assert.Null(OpenClawChatDataProvider.TryMatchCachedTool(cache, 1000));
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
+        Assert.Null(ChatMetadataStore.TryMatchCachedTool(cache, 1000));
     }
 
     [Fact]
     public void TryMatch_SingleEntry_DequeuesAndReturns()
     {
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(100, "bash", "ls -la"));
 
-        var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200);
+        var result = ChatMetadataStore.TryMatchCachedTool(cache, 200);
 
         Assert.NotNull(result);
         Assert.Equal("bash", result!.ToolName);
@@ -47,15 +47,15 @@ public void TryMatch_SingleEntry_DequeuesAndReturns()
     [Fact]
     public void TryMatch_SequentialOrder_MatchesByPosition()
     {
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(100, "bash", "first"));
         cache.Enqueue(Meta(200, "grep", "second"));
         cache.Enqueue(Meta(300, "view", "third"));
 
         // Each call should dequeue the next entry regardless of timestamp
-        var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 500);
-        var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 600);
-        var r3 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 700);
+        var r1 = ChatMetadataStore.TryMatchCachedTool(cache, 500);
+        var r2 = ChatMetadataStore.TryMatchCachedTool(cache, 600);
+        var r3 = ChatMetadataStore.TryMatchCachedTool(cache, 700);
 
         Assert.Equal("bash", r1!.ToolName);
         Assert.Equal("grep", r2!.ToolName);
@@ -66,11 +66,11 @@ public void TryMatch_SequentialOrder_MatchesByPosition()
     [Fact]
     public void TryMatch_MoreHistoryThanCache_ReturnsNullWhenExhausted()
     {
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(100, "bash", "only entry"));
 
-        var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200);
-        var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 300);
+        var r1 = ChatMetadataStore.TryMatchCachedTool(cache, 200);
+        var r2 = ChatMetadataStore.TryMatchCachedTool(cache, 300);
 
         Assert.NotNull(r1);
         Assert.Null(r2); // exhausted
@@ -81,10 +81,10 @@ public void TryMatch_CachedEntryFarAfterHistory_SkipsMatch()
     {
         // Cache entry is >5 minutes (300_000ms) after the history entry —
         // means this history tool result predates the cache.
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(500_000, "bash", "future entry"));
 
-        var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 100_000);
+        var result = ChatMetadataStore.TryMatchCachedTool(cache, 100_000);
 
         Assert.Null(result);
         Assert.Single(cache); // NOT consumed — entry stays for later
@@ -94,10 +94,10 @@ public void TryMatch_CachedEntryFarAfterHistory_SkipsMatch()
     public void TryMatch_CachedEntrySlightlyAfterHistory_StillMatches()
     {
         // Cache entry is <5 min after history — normal SSE delay, should match.
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(200_000, "bash", "recent entry"));
 
-        var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 100_000);
+        var result = ChatMetadataStore.TryMatchCachedTool(cache, 100_000);
 
         Assert.NotNull(result);
         Assert.Equal("bash", result!.ToolName);
@@ -107,10 +107,10 @@ public void TryMatch_CachedEntrySlightlyAfterHistory_StillMatches()
     public void TryMatch_ZeroTimestamps_AlwaysMatch()
     {
         // When timestamps are 0, the guard is skipped — always dequeue.
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(0, "bash", "no timestamp"));
 
-        var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 0);
+        var result = ChatMetadataStore.TryMatchCachedTool(cache, 0);
 
         Assert.NotNull(result);
     }
@@ -119,13 +119,13 @@ public void TryMatch_ZeroTimestamps_AlwaysMatch()
     public void TryMatch_RepeatedToolNames_PreservesOrder()
     {
         // Multiple entries with the same tool name should be matched in order.
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(100, "bash", "first bash"));
         cache.Enqueue(Meta(200, "bash", "second bash"));
         cache.Enqueue(Meta(300, "bash", "third bash"));
 
-        var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 500);
-        var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 600);
+        var r1 = ChatMetadataStore.TryMatchCachedTool(cache, 500);
+        var r2 = ChatMetadataStore.TryMatchCachedTool(cache, 600);
 
         Assert.Equal("first bash", r1!.Label);
         Assert.Equal("second bash", r2!.Label);
@@ -136,8 +136,8 @@ public void TryMatch_RepeatedToolNames_PreservesOrder()
     [Fact]
     public void SessionLimits_AreReasonable()
     {
-        Assert.Equal(20, OpenClawChatDataProvider.MaxCachedSessions);
-        Assert.Equal(500, OpenClawChatDataProvider.MaxToolEntriesPerSession);
+        Assert.Equal(20, ChatMetadataStore.MaxCachedSessions);
+        Assert.Equal(500, ChatMetadataStore.MaxToolEntriesPerSession);
     }
 
     [Fact]
@@ -145,24 +145,15 @@ public async Task CacheToolMeta_ConcurrentAdds_FlushesCompleteValidJson()
     {
         using var tempDir = new TempDirectory();
         var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
-        var bridge = new FakeBridge
-        {
-            History = new ChatHistoryInfo
-            {
-                SessionKey = "main",
-                SessionId = "session-1"
-            }
-        };
-        var provider = new OpenClawChatDataProvider(bridge, post: null, toolMetaCacheFilePath: cachePath);
-        await provider.LoadHistoryAsync("main");
+        using var store = new ChatMetadataStore(cachePath);
 
         Parallel.For(0, 100, i =>
-            provider.CacheToolMeta("main", 1_000 + i, "bash", $"echo {i}"));
+            store.CacheTool("main", "session-1", 0, 1_000 + i, "bash", $"echo {i}"));
 
-        await provider.DisposeAsync();
+        store.Flush();
 
         var json = File.ReadAllText(cachePath);
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(json);
+        var cache = JsonSerializer.Deserialize<Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(json);
 
         Assert.NotNull(cache);
         Assert.True(cache!.TryGetValue("session-1", out var entries));
@@ -175,27 +166,20 @@ public async Task CacheToolMeta_PersistsReadableJsonWithoutUnicodeOrNewlineEscap
     {
         using var tempDir = new TempDirectory();
         var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
-        var bridge = new FakeBridge
-        {
-            History = new ChatHistoryInfo
-            {
-                SessionKey = "main",
-                SessionId = "session-1"
-            }
-        };
-        var provider = new OpenClawChatDataProvider(bridge, post: null, toolMetaCacheFilePath: cachePath);
-        await provider.LoadHistoryAsync("main");
+        using var store = new ChatMetadataStore(cachePath);
 
-        provider.CacheToolMeta(
+        store.CacheTool(
             "main",
+            "session-1",
+            0,
             1_000,
             "bash",
             "exec search \"duplicate\" -> {\"timestamp\":\"2025-01-01T00:00:00+00:00\",\"message\":\"line1\r\n      line2\"}");
 
-        await provider.DisposeAsync();
+        store.Flush();
 
         var json = File.ReadAllText(cachePath);
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(json);
+        var cache = JsonSerializer.Deserialize<Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(json);
         var entry = Assert.Single(cache!["session-1"]);
 
         Assert.DoesNotContain("\\u0022", json, StringComparison.Ordinal);
@@ -226,8 +210,9 @@ public async Task Constructor_DoesNotRewriteLegacyEscapedToolMetaCache()
             """;
         File.WriteAllText(cachePath, legacyJson);
 
-        var provider = new OpenClawChatDataProvider(new FakeBridge(), post: null, toolMetaCacheFilePath: cachePath);
-        await provider.DisposeAsync();
+        using (var store = new ChatMetadataStore(cachePath))
+        {
+        }
 
         var json = File.ReadAllText(cachePath);
         Assert.Equal(legacyJson, json);
@@ -241,14 +226,14 @@ public async Task CacheToolMeta_WithoutSessionId_FallsBackToThreadKey()
     {
         using var tempDir = new TempDirectory();
         var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
-        var provider = new OpenClawChatDataProvider(new FakeBridge(), post: null, toolMetaCacheFilePath: cachePath);
+        using var store = new ChatMetadataStore(cachePath);
 
-        provider.CacheToolMeta("main", 1_000, "bash", "echo after reset");
+        store.CacheTool("main", "main", 0, 1_000, "bash", "echo after reset");
 
-        await provider.DisposeAsync();
+        store.Flush();
 
         var json = File.ReadAllText(cachePath);
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(json);
+        var cache = JsonSerializer.Deserialize<Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(json);
 
         Assert.NotNull(cache);
         Assert.True(cache!.TryGetValue("main", out var entries));
@@ -258,31 +243,34 @@ public async Task CacheToolMeta_WithoutSessionId_FallsBackToThreadKey()
     }
 
     [Fact]
-    public async Task CacheToolMeta_SameIdAcrossRunsPersistsDistinctRecords()
+    public void CacheToolMeta_SameIdAcrossRunsPersistsDistinctRecords()
     {
         using var tempDir = new TempDirectory();
         var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
-        var provider = new OpenClawChatDataProvider(
-            new FakeBridge(),
-            post: null,
-            toolMetaCacheFilePath: cachePath);
+        using var store = new ChatMetadataStore(cachePath);
 
-        provider.CacheToolMeta(
+        store.CacheTool(
+            "main",
             "main",
+            0,
             1_000,
             "Bash",
             "first",
             toolCallId: "tool-1",
             runId: "run-1");
-        provider.CacheToolMeta(
+        store.CacheTool(
             "main",
+            "main",
+            0,
             2_000,
             "Apply Patch",
             "second",
             toolCallId: "tool-1",
             runId: "run-2");
-        provider.CacheToolMeta(
+        store.CacheTool(
+            "main",
             "main",
+            0,
             2_100,
             "Apply Patch",
             "upgraded second",
@@ -290,9 +278,9 @@ public async Task CacheToolMeta_SameIdAcrossRunsPersistsDistinctRecords()
             identityStrength: ChatToolIdentityStrength.Explicit,
             runId: "run-2");
 
-        await provider.DisposeAsync();
+        store.Flush();
 
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(
+        var cache = JsonSerializer.Deserialize<Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(
             File.ReadAllText(cachePath));
         Assert.Collection(
             cache!["main"],
@@ -309,33 +297,34 @@ public async Task CacheToolMeta_SameIdAcrossRunsPersistsDistinctRecords()
     }
 
     [Fact]
-    public async Task CacheToolMeta_LegacyIdReuseAcrossTurnsPersistsDistinctRecords()
+    public void CacheToolMeta_LegacyIdReuseAcrossTurnsPersistsDistinctRecords()
     {
         using var tempDir = new TempDirectory();
         var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
-        var provider = new OpenClawChatDataProvider(
-            new FakeBridge(),
-            post: null,
-            toolMetaCacheFilePath: cachePath);
+        using var store = new ChatMetadataStore(cachePath);
 
-        provider.CacheToolMeta(
+        store.CacheTool(
             "main",
+            "main",
+            0,
             1_000,
             "Bash",
             "first",
             toolCallId: "tool-1",
             legacyTurn: 1);
-        provider.CacheToolMeta(
+        store.CacheTool(
+            "main",
             "main",
+            0,
             2_000,
             "Apply Patch",
             "second",
             toolCallId: "tool-1",
             legacyTurn: 2);
 
-        await provider.DisposeAsync();
+        store.Flush();
 
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(
+        var cache = JsonSerializer.Deserialize<Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(
             File.ReadAllText(cachePath));
         Assert.Collection(
             cache!["main"],
@@ -355,7 +344,7 @@ public void CachedToolMeta_LegacyJsonWithoutScopeMigratesToNullRunAndZeroTurn()
             }
             """;
 
-        var entry = JsonSerializer.Deserialize<OpenClawChatDataProvider.CachedToolMeta>(json);
+        var entry = JsonSerializer.Deserialize<ChatMetadataStore.CachedToolMeta>(json);
 
         Assert.NotNull(entry);
         Assert.Null(entry!.RunId);
@@ -363,43 +352,65 @@ public void CachedToolMeta_LegacyJsonWithoutScopeMigratesToNullRunAndZeroTurn()
         Assert.Equal("tool-1", entry.ToolCallId);
     }
 
+    [Fact]
+    public void Dispose_RejectsLateCacheAdds()
+    {
+        using var tempDir = new TempDirectory();
+        var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
+        var store = new ChatMetadataStore(cachePath);
+        store.CacheTool("main", "session-1", 0, 1_000, "bash", "before dispose");
+
+        store.Dispose();
+        store.CacheTool("main", "session-1", 0, 2_000, "bash", "after dispose");
+
+        var cache = JsonSerializer.Deserialize<
+            Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(
+                File.ReadAllText(cachePath));
+        var entry = Assert.Single(cache!["session-1"]);
+        Assert.Equal("before dispose", entry.Label);
+    }
+
     [Fact]
     public void TryMatch_NormalizesLegacyCachedNewlines()
     {
-        var cache = new Queue<OpenClawChatDataProvider.CachedToolMeta>();
+        var cache = new Queue<ChatMetadataStore.CachedToolMeta>();
         cache.Enqueue(Meta(100, "bash\r\nname", "line1\r\n      \"line2\""));
 
-        var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200);
+        var result = ChatMetadataStore.TryMatchCachedTool(cache, 200);
 
         Assert.Equal("bash name", result!.ToolName);
         Assert.Equal("line1       \"line2\"", result.Label);
     }
 
     [Fact]
-    public async Task CacheToolMeta_SameToolCallId_UpgradesSpecificIdentityWithoutDuplicate()
+    public void CacheToolMeta_SameToolCallId_UpgradesSpecificIdentityWithoutDuplicate()
     {
         using var tempDir = new TempDirectory();
         var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
-        var provider = new OpenClawChatDataProvider(new FakeBridge(), post: null, toolMetaCacheFilePath: cachePath);
+        using var store = new ChatMetadataStore(cachePath);
 
-        provider.CacheToolMeta(
+        store.CacheTool(
+            "main",
             "main",
+            0,
             100,
             "Tool",
             "Tool",
             "tool-1",
             identityStrength: ChatToolIdentityStrength.Fallback);
-        provider.CacheToolMeta(
+        store.CacheTool(
             "main",
+            "main",
+            0,
             110,
             "Bash",
             "Get-Date",
             "tool-1",
             new System.Text.Json.Nodes.JsonObject { ["command"] = "Get-Date" },
             ChatToolIdentityStrength.Specific);
-        await provider.DisposeAsync();
+        store.Flush();
 
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(
+        var cache = JsonSerializer.Deserialize<Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(
             File.ReadAllText(cachePath));
         var entry = Assert.Single(cache!["main"]);
         Assert.Equal("Bash", entry.ToolName);
@@ -408,37 +419,48 @@ public async Task CacheToolMeta_SameToolCallId_UpgradesSpecificIdentityWithoutDu
     }
 
     [Fact]
-    public async Task Reset_DoesNotReseedClearedSessionIdFromStaleSessionsList()
+    public void Reset_DoesNotReseedClearedSessionIdFromStaleSessionsList()
     {
-        using var tempDir = new TempDirectory();
-        var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
-        var bridge = new FakeBridge
-        {
-            History = new ChatHistoryInfo
+        var state = new ChatConversationState(
+            ConnectionStatus.Connected,
+            lastChatState: null,
+            seedModels: null);
+        var context = new ChatProjectionContext(
+            MainSessionKey: "main",
+            HasHandshakeSnapshot: true);
+        SessionInfo[] staleSessions =
+        [
+            new SessionInfo
             {
-                SessionKey = "main",
+                Key = "main",
+                IsMain = true,
                 SessionId = "old-session"
             }
-        };
-        var provider = new OpenClawChatDataProvider(bridge, post: null, toolMetaCacheFilePath: cachePath);
-        await provider.LoadHistoryAsync("main");
+        ];
 
-        bridge.RaiseSessionCommandCompleted(new SessionCommandResult
-        {
-            Method = "sessions.reset",
-            Ok = true,
-            Key = "main"
-        });
-        bridge.RaiseSessions(new[]
-        {
-            new SessionInfo { Key = "main", IsMain = true, SessionId = "old-session" }
-        });
-        provider.CacheToolMeta("main", 1_000, "bash", "echo after reset");
+        state.ApplySessions(staleSessions, context);
+        Assert.Equal("old-session", state.ResolveMetadataKey("main").CacheKey);
+        state.ResetThread("main", context);
+        state.ApplySessions(staleSessions, context);
 
-        await provider.DisposeAsync();
+        Assert.Equal("main", state.ResolveMetadataKey("main").CacheKey);
+    }
 
-        var json = File.ReadAllText(cachePath);
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(json);
+    [Fact]
+    public void ResetEviction_DropsStaleGenerationAndAcceptsCurrentThreadKey()
+    {
+        using var tempDir = new TempDirectory();
+        var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
+        using var store = new ChatMetadataStore(cachePath);
+        store.CacheTool("main", "old-session", 0, 900, "bash", "stale tool");
+        store.EvictReset("main", "old-session", resetGeneration: 1);
+        store.CacheTool("main", "old-session", 0, 950, "bash", "late stale tool");
+        store.CacheTool("main", "main", 1, 1_000, "bash", "echo after reset");
+        store.Flush();
+
+        var cache = JsonSerializer.Deserialize<
+            Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(
+                File.ReadAllText(cachePath));
 
         Assert.NotNull(cache);
         Assert.False(cache!.ContainsKey("old-session"));
@@ -446,6 +468,43 @@ public async Task Reset_DoesNotReseedClearedSessionIdFromStaleSessionsList()
         Assert.Equal("echo after reset", Assert.Single(entries!).Label);
     }
 
+    [Fact]
+    public void ResetEviction_PreservesCurrentGenerationAddedBeforeEviction()
+    {
+        using var tempDir = new TempDirectory();
+        var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
+        using var store = new ChatMetadataStore(cachePath);
+        store.CacheTool("main", "main", 0, 900, "bash", "stale tool");
+        store.CacheTool("main", "main", 1, 1_000, "bash", "current tool");
+
+        store.EvictReset("main", oldSessionId: null, resetGeneration: 1);
+        store.Flush();
+
+        var cache = JsonSerializer.Deserialize<
+            Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(
+                File.ReadAllText(cachePath));
+        var entry = Assert.Single(cache!["main"]);
+        Assert.Equal("current tool", entry.Label);
+    }
+
+    [Fact]
+    public void CurrentGenerationRead_FiltersOlderMetadataBeforeEviction()
+    {
+        using var tempDir = new TempDirectory();
+        var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
+        using var store = new ChatMetadataStore(cachePath);
+        store.CacheTool("main", "main", 0, 900, "bash", "stale tool");
+        store.CacheTool("main", "main", 1, 1_000, "bash", "current tool");
+
+        var entries = store.GetToolMetadata(
+            sessionId: null,
+            threadId: "main",
+            resetGeneration: 1);
+
+        var entry = Assert.Single(entries!);
+        Assert.Equal("current tool", entry.Label);
+    }
+
     [Fact]
     public async Task Reset_PersistsClearedToolMetaWhenCacheWasClean()
     {
@@ -483,7 +542,7 @@ public async Task Reset_PersistsClearedToolMetaWhenCacheWasClean()
         await provider.DisposeAsync();
 
         var json = File.ReadAllText(cachePath);
-        var cache = JsonSerializer.Deserialize<Dictionary<string, List<OpenClawChatDataProvider.CachedToolMeta>>>(json);
+        var cache = JsonSerializer.Deserialize<Dictionary<string, List<ChatMetadataStore.CachedToolMeta>>>(json);
 
         Assert.NotEqual(initialJson, json);
         Assert.NotNull(cache);
