diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 35d024261..a33851216 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,13 +67,26 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Local MCP exposure policy | `McpCapabilityPolicy` (planned) | planned | | Gateway connect envelope | `ConnectEnvelopeBuilder` (planned) | planned | | Gateway request tracking | `PendingRequestRegistry` (planned) | planned | +| Chat atomic runtime transaction lock and cross-domain commits | `ChatConversationState` | authoritative | +| Chat queue collections, echo correlation, drain and retry commit mechanics | `ChatQueueState` under the `ChatConversationState` lock | authoritative | +| Chat reset generations, gates, echoes and backfill state | `ChatResetState` under the `ChatConversationState` lock | authoritative | +| Chat history identity, revisions and connection-generation tokens | `ChatHistoryState` under the `ChatConversationState` lock | authoritative | +| Chat sessions, models, catalog and snapshot projection inputs | `ChatPresentationState` under the `ChatConversationState` lock | authoritative | +| Chat run, abort and terminal lifecycle state | `ChatLifecycleState` under the `ChatConversationState` lock | authoritative | +| Chat approval identity correlation and dedupe state | `ChatApprovalState` under the `ChatConversationState` lock | authoritative | +| Chat send admission/retry decision policy | `ChatSendQueuePolicy` with atomic commits coordinated by `ChatConversationState` | authoritative | +| Chat history request/retry/rebuild mechanics | `ChatHistoryLoader` with token acceptance coordinated by `ChatConversationState` | authoritative | +| Gateway agent event to chat event mapping | `ChatEventMapper` | authoritative | +| Chat snapshot projection | `ChatSnapshotProjector` | authoritative | +| Tool and attachment metadata cache lifecycle | `ChatMetadataStore` | authoritative | +| Aborted IDs and last-chat-state persistence | `ChatStatePersistence` | authoritative | ## When you touch file X, extract toward Y | If you are editing… | Do not grow it. Extract toward… | | --- | --- | | `src/OpenClaw.Tray.WinUI/App.xaml.cs` | `IWindowManager`, `ITrayController`, `IActivationRouter`, `ISettingsChangeCoordinator`, `AppBootstrapper` | -| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | `ChatSendQueue`, `ChatBridgeEventPump`, `ChatHistoryLoader`, `ChatSnapshotProjector`, `AttachmentMetadataStore`; pure native tool projection stays in `NativeToolProjector` | +| `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs` | Keep as the `IChatDataProvider` facade; atomic runtime coordination → `ChatConversationState`, lock-internal state mechanics → its queue/reset/history/presentation/lifecycle/approval substates, queue decisions → `ChatSendQueuePolicy`, history IO → `ChatHistoryLoader`, mapping → `ChatEventMapper`, native tool projection → `NativeToolProjector`, snapshots → `ChatSnapshotProjector`, metadata → `ChatMetadataStore`, persistence → `ChatStatePersistence` | | `src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs` | `ReactorChatTimeline` (production `ItemsView` / `ItemContainer`), `ChatBubbleRenderer`, `ToolCallCardRenderer`, `PermissionRequestCard`, `AttachmentBubbleRenderer` | | `src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs` | `ComposerViewModel`, `SlashCommandPalette`, `AttachmentPreviewStrip`, `VoiceComposerController` | | `src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs` | `ConnectionPagePlan` (pure), `ConnectionPageViewModel`, gateway row models | @@ -126,9 +139,23 @@ leading and trailing pipe. Columns, in order: | app-window-manager | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | window creation/show/hide/shutdown | IWindowManager | composition/delegation only | startup/shutdown ordering deterministic; disposed once | none | review-only | extracted in Phase 3 | | app-tray-controller | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon/menu/action routing | ITrayController | composition/delegation only | tray actions route unchanged | none | review-only | extracted in Phase 3 | | app-activation-router | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | deep-link/toast/single-instance activation | IActivationRouter | composition/delegation only | activation routes land on the same UI/actions; current-user pipe security preserved | none | review-only | extracted in Phase 3 | -| native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | provider calls the projector while retaining stateful live/history application and metadata cache behavior | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - | -| provider-native-tool-projection-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private static copies of native tool identity, display argument, payload, and flattened-history projection | NativeToolProjector | provider owns run/session/legacy-generation correlation, metadata cache persistence/upsert/migration/matching, active run IDs, and timeline state | provider does not regain pure native tool projection or duplicate NativeToolProjector compatibility wrappers | review-only: the provider retains stateful orchestration and calls the focused projector directly | review-only | when OpenClawChatDataProvider no longer applies native tool events or history | -| chat-send-queue | planned | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | send queue/admission/abort state | ChatSendQueue | - | queued send/abort/generation semantics preserved | none | review-only | extracted in Phase 4 | +| native-tool-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure native tool identity, allowlisted display arguments, payload extraction, and flattened-history detection/classification/summary | NativeToolProjector | ChatEventMapper and ChatHistoryLoader call the projector; ChatConversationState supplies scoped correlation plans and ChatMetadataStore owns persistence | unknown identities remain truthful Tool; title aliases are strict; display arguments are allowlisted, redacted, and bounded; live/history projection stays consistent | NativeToolProjectorTests.ExtractToolIdentity_TitleRequiresExactTrustedAlias | behavioral | - | +| provider-native-tool-projection-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private static copies of native tool identity, display argument, payload, flattened-history projection, and scoped metadata upsert | NativeToolProjector + ChatEventMapper + ChatHistoryLoader + ChatConversationState + ChatMetadataStore | provider forwards typed tool metadata writes while retaining bridge IO, telemetry, and event publication only | provider does not regain native tool JSON projection, identity policy, timeline correlation, or metadata persistence | review-only: pure projection, atomic correlation, and persistence are delegated to focused owners while the provider remains the IO facade | review-only | when OpenClawChatDataProvider is retired | +| chat-conversation-state | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | provider-owned runtime gate and cross-domain state transactions | ChatConversationState | sole lock, timeline and entry metadata, connection/disposal flags, and typed orchestration across lock-free substates; provider supplies bridge context and coordinates IO, telemetry, and events | one authoritative lock atomically commits reset, reconnect, dispose, queue, history, and event transitions without duplicate shared versions | ChatRuntimeOwnershipContractTests.Root_CoordinatesCrossDomainCommitsUnderSoleGate | source-shape | when the chat runtime is replaced by a different atomic transaction boundary | +| chat-provider-state-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private runtime gate and mutable conversation/queue/reset/history collections | ChatConversationState | bridge subscription, telemetry, public API/events, composition, persistence coordination, and static image preview compatibility | provider cannot regain a private state lock or duplicate runtime collections | ChatRuntimeOwnershipContractTests.Provider_DelegatesRuntimeStateWithoutPrivateGate | source-shape | when OpenClawChatDataProvider is retired | +| chat-send-queue | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs and monolithic ChatConversationState | send admission, next-drain eligibility, local echo and run correlation, deferred-admission classification/backoff, retry decisions, and queue commit mechanics | ChatSendQueuePolicy + ChatQueueState | ChatConversationState coordinates queue commits with timeline, reset, and lifecycle state; provider executes typed bridge dispatch plans and records telemetry | queue collections and mechanics live in the lock-free substate under the sole conversation lock while pure policy decisions remain separately testable | ChatRuntimeOwnershipContractTests.RuntimeSubstates_AreLockFreeAndVersionOwnershipIsUnique | source-shape | when queue state and decision policy are replaced without a lock-internal substate | +| chat-reset-state | authoritative | monolithic ChatConversationState | reset versions and cutoffs, accepted and ignored runs, submitted echoes, no-run send proof, buffered starts, and remote-backfill gates | ChatResetState | ChatConversationState supplies queue and lifecycle facts and atomically applies returned typed lifecycle transitions | reset mechanics have no private lock or duplicate version and are invoked only under the conversation lock | ChatRuntimeOwnershipContractTests.RuntimeSubstates_AreLockFreeAndVersionOwnershipIsUnique | source-shape | when reset gating is replaced without a lock-internal substate | +| chat-history-state | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs and monolithic ChatConversationState | session identity, loaded/revision state, reset-cleared identity, connection generation, activation barrier, commit-token validation, and transcript merge reconciliation | ChatHistoryState | ChatConversationState supplies reset/status/disposal facts and atomically coordinates timeline commit; no history IO lives in the substate | one authoritative connection generation and reset-aware commit token accepts or drops history under the sole conversation lock | ChatConversationStateTests.HistoryGeneration_WaitsForLoaderActivation | behavioral | - | +| chat-history-loader | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | chat.history fetch lifetime, in-flight ownership, generation cancellation, retry budget/scheduling, ordered transcript rebuild plans, and stale-result delivery filtering | ChatHistoryLoader | ChatHistoryState owns authoritative identity and generation tokens; ChatConversationState coordinates commit acceptance; provider publishes typed completion results and notifications | stale connection/reset responses cannot commit, deliver, clear a newer in-flight owner, or carry retry work/budget across generations; authoritative reload coalescing remains generation-safe | OpenClawChatDataProviderTests.LoadHistoryAsync_DelayedRetryDoesNotCrossResetGeneration | behavioral | - | +| chat-checkpoint-history-replacement | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | checkpoint-restore timeline clearing, replacement generation fencing, and replacement-over-authoritative reload priority | ChatHistoryState + ChatHistoryLoader coordinated by ChatConversationState | provider starts the typed replacement transition, publishes its immutable snapshot, and delegates gateway IO | replacement atomically clears timeline metadata, invalidates stale results, preserves post-restore live entries, and suppresses stale retries and notifications | ChatConversationStateTests.HistoryReplacement_ClearsTimelineAndAdvancesOwnedTokenAtomically | behavioral | - | +| chat-presentation-state | authoritative | monolithic ChatConversationState | sessions, usage, models, choices, command catalog/fetch epoch, pending model patches, keyless diagnostics, remembered last state, and immutable projection inputs | ChatPresentationState | ChatConversationState supplies timeline/queue/reset/history snapshots and coordinates session identity and usage timeline updates | presentation mechanics have no private lock, IO, or mutable collection exposure and snapshot values remain byte-for-byte compatible | OpenClawChatDataProviderTests.RuntimeGolden_PublicSnapshotPreservesCrossDomainState | golden | - | +| chat-lifecycle-state | authoritative | monolithic ChatConversationState | active run IDs/start sequences, pending aborts, aborted runs/threads, terminal-run dedupe, and lifecycle sequence | ChatLifecycleState | ChatConversationState coordinates lifecycle changes with reset gates, queue state, and timeline reducer events | lifecycle mechanics have no private lock and reset/reconnect/dispose remain root-coordinated atomic transitions | ChatRuntimeOwnershipContractTests.Root_CoordinatesCrossDomainCommitsUnderSoleGate | source-shape | when run lifecycle is replaced without a lock-internal substate | +| chat-approval-state | authoritative | monolithic ChatConversationState | bounded seen-approval identity order/set and alternate-ID correlation | ChatApprovalState | ChatConversationState coordinates approval identity with permission timeline transitions; ChatEventMapper remains the pure payload mapper | approval identity mechanics have no private lock, IO, or timeline callbacks | ChatRuntimeOwnershipContractTests.RuntimeSubstates_AreLockFreeAndVersionOwnershipIsUnique | source-shape | when approval correlation is replaced without a lock-internal substate | +| chat-event-mapper | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | pure agent-stream payload to ChatEvent/content mapping and terminal approval decision classification | ChatEventMapper | provider retains stateful approval dedupe and telemetry orchestration through ChatConversationState | tool, reasoning, lifecycle, command-output, job, and permission payloads map without provider-owned JSON mapping branches | ChatEventMapperTests.Map_ApprovalRequestPreservesIdentityAndActions | behavioral | - | +| chat-snapshot-projector | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | thread/compose-target/status/model/catalog/timeline-generation/history-revision/queued-message snapshot projection including flattened gateway session classification | ChatSnapshotProjector | provider supplies bridge handshake context; ChatConversationState captures immutable projection input; SessionDisplayResolver owns flattened session display mapping | public snapshots preserve defensive dictionary copies, raw session keys, flattened agent/background classification, compose readiness, synthetic pending thread behavior, model order, and render identity generations | OpenClawChatDataProviderTests.RuntimeGolden_PublicSnapshotPreservesCrossDomainState | golden | - | +| chat-content-formatting | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | content text formatting, truncation, content-block seam repair, and trace hashing | ChatContentFormatting | thin provider forwarders (TruncateForChatEntry, LooksLikeSystemControlNote, RepairContentBlockSeams, TruncateChatEvent) kept for existing test call sites; system-note, native tool, and flattened-history projection belongs to NativeToolProjector | content truncation and seam repair output is preserved byte-for-byte while tool identity/classification has one canonical owner | ContentBlockSeamRepairTests.RepairsKnownSeams | behavioral | when provider forwarders are removed and callers use focused owners directly | +| chat-metadata-store | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | live tool/attachment metadata dictionaries, scoped native-tool identity upsert, save locks/timers/versions, atomic JSON persistence, session eviction, and attachment-marker build/escape/rehydration | ChatMetadataStore | ChatConversationState supplies a typed session/reset/correlation write plan; provider retains the public static image-preview cache | metadata persistence, identity-strength upgrade, normalization, bounded eviction, marker security, and generation-aware idempotent reset eviction are owned under the metadata lock without raw attachment bytes on disk | ToolMetaCacheTests.CacheToolMeta_SameToolCallId_UpgradesSpecificIdentityWithoutDuplicate | behavioral | - | +| chat-state-persistence | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | persisted aborted-message IDs plus last-chat-state debounce/version/atomic save lifecycle | ChatStatePersistence | provider retains the nested LastChatState compatibility type and owns bridge history fetch orchestration | corrupted state fails closed, reset removes aborted IDs, stale reset generations cannot persist, and selected/snapshot state writes remain atomic | ChatStatePersistenceTests.ResetFence_RejectsStaleAbortedIds | behavioral | - | | gateway-pending-requests | planned | src/OpenClaw.Shared/OpenClawGatewayClient.cs | request-id -> method/completion tracking | PendingRequestRegistry | - | request ids never leak after disconnect; thread-safe | none | review-only | extracted in Phase 4 | | connect-envelope | planned | src/OpenClaw.Shared/OpenClawGatewayClient.cs + WindowsNodeClient.cs | connect message + auth precedence + signature version | ConnectEnvelopeBuilder | - | credential precedence never downgrades a device token; v3->v2 fallback preserved | none | review-only | extracted in Phase 4 | | ui-dispatcher | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | UI-thread marshaling abstraction for presentation code | IUiDispatcher | App and existing WinUI code may call DispatcherQueue directly until the view-model migration | presentation view models depend on IUiDispatcher not a concrete DispatcherQueue | UiDispatcherContractTests.PageViewModel_ReceivesRegisteredDispatcher | behavioral | - | diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatApprovalState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatApprovalState.cs new file mode 100644 index 000000000..9152a5aaa --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatApprovalState.cs @@ -0,0 +1,114 @@ +namespace OpenClawTray.Chat; + +/// +/// Owns bounded approval-request identity deduplication and alternate-ID +/// correlation. The conversation-state root serializes every call. +/// +internal sealed class ChatApprovalState +{ + private const int SeenCapacity = 128; + + private readonly LinkedList _seenOrder = new(); + private readonly HashSet _seen = new(StringComparer.Ordinal); + private readonly Dictionary _alternateIds = + new(StringComparer.Ordinal); + + internal bool MarkSeen(string requestId, string? alternateId) + { + if (string.IsNullOrEmpty(requestId)) + return true; + + RecordAlternateId(requestId, alternateId); + if (IsSeen(requestId) || + IsDistinct(requestId, alternateId) && IsSeen(alternateId!)) + { + return false; + } + + AddSeen(requestId); + if (IsDistinct(requestId, alternateId)) + AddSeen(alternateId!); + + while (_seenOrder.Count > SeenCapacity) + { + var oldest = _seenOrder.First!.Value; + _seenOrder.RemoveFirst(); + Evict(oldest); + } + + return true; + } + + internal bool Matches( + string pendingId, + string primaryId, + string alternateId) + { + if (string.IsNullOrEmpty(pendingId)) + return false; + + _alternateIds.TryGetValue(pendingId, out var pendingAlternate); + return MatchesOne(primaryId) || MatchesOne(alternateId); + + bool MatchesOne(string value) => + !string.IsNullOrEmpty(value) && + (string.Equals(value, pendingId, StringComparison.Ordinal) || + !string.IsNullOrEmpty(pendingAlternate) && + string.Equals(value, pendingAlternate, StringComparison.Ordinal)); + } + + internal void Reset() + { + _seen.Clear(); + _seenOrder.Clear(); + _alternateIds.Clear(); + } + + private void AddSeen(string approvalId) + { + if (_seen.Add(approvalId)) + _seenOrder.AddLast(approvalId); + } + + private bool IsSeen(string approvalId) => + _seen.Contains(approvalId) || + _alternateIds.TryGetValue(approvalId, out var alternateId) && + _seen.Contains(alternateId); + + private void RecordAlternateId(string requestId, string? alternateId) + { + if (!IsDistinct(requestId, alternateId)) + return; + _alternateIds[requestId] = alternateId!; + _alternateIds[alternateId!] = requestId; + } + + private void Evict(string approvalId) + { + _seen.Remove(approvalId); + if (!_alternateIds.TryGetValue(approvalId, out var alternateId)) + return; + + _alternateIds.Remove(approvalId); + if (_alternateIds.TryGetValue(alternateId, out var reverse) && + string.Equals(reverse, approvalId, StringComparison.Ordinal)) + { + _alternateIds.Remove(alternateId); + } + + if (!_seen.Remove(alternateId)) + return; + + for (var node = _seenOrder.First; node is not null; node = node.Next) + { + if (!string.Equals(node.Value, alternateId, StringComparison.Ordinal)) + continue; + _seenOrder.Remove(node); + break; + } + } + + private static bool IsDistinct(string requestId, string? alternateId) => + !string.IsNullOrEmpty(alternateId) && + !string.Equals(alternateId, requestId, StringComparison.Ordinal); +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatContentFormatting.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatContentFormatting.cs new file mode 100644 index 000000000..8e700e6ad --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatContentFormatting.cs @@ -0,0 +1,240 @@ +using System; +using System.Buffers; +using OpenClaw.Chat; +#if !OPENCLAW_TRAY_TESTS +using OpenClawTray.Helpers; +#endif +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal static class ChatContentFormatting +{ + // Keep this value in sync with OpenClawChatDataProvider.MaxEntryTextBytes for test compatibility. + private const int MaxEntryTextBytes = 256 * 1024; + + /// + /// Truncate to at most + /// bytes when encoded as UTF-8 and + /// append a … [N bytes truncated] marker. Slices at a UTF-16 + /// code-unit boundary that doesn't split a surrogate pair, then + /// verifies the byte budget. Returns the input unchanged when it + /// already fits or is null/empty. + /// + internal static string TruncateForChatEntry(string? text) + { + if (string.IsNullOrEmpty(text)) return text ?? string.Empty; + var enc = System.Text.Encoding.UTF8; + // Cheap upper bound: every char is at most 3 UTF-8 bytes for the + // BMP and surrogate pairs encode to 4 bytes / 2 chars (still ≤ 3 + // bytes per char). 4 is the worst case and keeps the cheap path + // safe. If even the worst case fits, we're done. + if ((long)text.Length * 4 <= MaxEntryTextBytes) return text; + var actual = enc.GetByteCount(text); + if (actual <= MaxEntryTextBytes) return text; + + // Binary search for the largest char-count whose UTF-8 byte count + // fits in MaxEntryTextBytes minus a generous margin for the marker. + var marker = string.Format(LocalizationHelper.GetString("Chat_TruncationMarkerFormat"), actual); + int budget = MaxEntryTextBytes - enc.GetByteCount(marker); + if (budget <= 0) budget = MaxEntryTextBytes / 2; + + int lo = 0, hi = text.Length; + while (lo < hi) + { + int mid = (lo + hi + 1) / 2; + // Don't split a surrogate pair: nudge mid back if it lands on + // a low surrogate. + if (mid < text.Length && char.IsLowSurrogate(text[mid])) mid--; + if (mid <= lo) + { + hi = lo; + continue; + } + int bytes = enc.GetByteCount(text.AsSpan(0, mid)); + if (bytes <= budget) lo = mid; + else hi = mid - 1; + } + if (lo > 0 && char.IsHighSurrogate(text[lo - 1])) lo--; + + Logger.Debug($"[ChatTruncate] message {actual} bytes → {lo} chars (~{enc.GetByteCount(text.AsSpan(0, lo))} bytes); cap={MaxEntryTextBytes}"); + return string.Concat(text.AsSpan(0, lo), marker.AsSpan()); + } + + /// + /// True when text is one of the approval slash-commands we send on the + /// user's behalf (/approve <slug> allow-once, + /// /approve <slug> allow-always, or + /// /deny <slug>). Matches the exact dashboard grammar + /// — not just the prefix — so legitimate user prose like + /// "/approve the design changes" still renders as a normal bubble. + /// + /// + /// Slug shape: hex-ish identifier (letters, digits, dashes, underscores; + /// 4–64 chars). This mirrors what the gateway emits for + /// ``approvalSlug``; we don't anchor on a specific length because the + /// gateway has changed it before. + /// + internal static bool LooksLikeApprovalSlashCommand(string text) + { + if (string.IsNullOrEmpty(text)) return false; + var t = text.Trim(); + return s_approvalSlashCommandRegex.IsMatch(t); + } + + private static readonly System.Text.RegularExpressions.Regex s_approvalSlashCommandRegex = + new(@"^/(?:approve\s+[A-Za-z0-9_-]{4,64}(?:\s+(?:allow-once|allow-always))?|deny\s+[A-Za-z0-9_-]{4,64})\s*$", + System.Text.RegularExpressions.RegexOptions.Compiled); + + private static readonly System.Text.RegularExpressions.Regex s_seamBoldClose = + new(@"(?<=[a-z0-9])(\*\*)(?=[A-Z])", + System.Text.RegularExpressions.RegexOptions.Compiled); + + private static readonly System.Text.RegularExpressions.Regex s_seamSentencePunct = + new(@"(?<=[a-z0-9][.!?:])(?=[A-Z][a-z]+[\s,;:!?])", + System.Text.RegularExpressions.RegexOptions.Compiled); + + private static readonly SearchValues s_seamPunctChars = SearchValues.Create(".!?:"); + + /// + /// Re-insert paragraph breaks at gateway-glued content-block seams in + /// an assistant message. Safe to call on any text — short text, text + /// without seams, and text that is entirely fenced code all pass + /// through unchanged. Fenced code blocks (``` ``` ``` ```) are skipped + /// so JSON/code samples never get whitespace injected inside them. + /// + internal static string RepairContentBlockSeams(string? text) + { + if (string.IsNullOrEmpty(text)) return text ?? string.Empty; + if (text.Length < 4) return text; + + // Fast path: if neither marker is present we can skip entirely. + if (!text.Contains("**", System.StringComparison.Ordinal) && + text.AsSpan().IndexOfAny(s_seamPunctChars) < 0) + { + return text; + } + + // Walk the string, alternating between prose and fenced-code + // segments. Apply seam regexes to prose only. We tolerate + // unclosed fences by treating everything after the dangling + // opener as code (matches Markdown renderer behavior). + var sb = new System.Text.StringBuilder(text.Length + 16); + int i = 0; + while (i < text.Length) + { + int fenceStart = text.IndexOf("```", i, System.StringComparison.Ordinal); + if (fenceStart < 0) + { + sb.Append(RepairProseSegment(text[i..])); + break; + } + + sb.Append(RepairProseSegment(text.Substring(i, fenceStart - i))); + + int fenceEnd = text.IndexOf("```", fenceStart + 3, System.StringComparison.Ordinal); + if (fenceEnd < 0) + { + // Unclosed fence — append the rest verbatim as code. + sb.Append(text, fenceStart, text.Length - fenceStart); + break; + } + + // Append fenced block verbatim (including both fence markers). + sb.Append(text, fenceStart, fenceEnd - fenceStart + 3); + i = fenceEnd + 3; + } + + return sb.ToString(); + } + + internal static string RepairProseSegment(string segment) + { + if (string.IsNullOrEmpty(segment)) return segment; + segment = s_seamBoldClose.Replace(segment, "$1\n\n"); + // s_seamSentencePunct is a zero-width assertion (lookbehind + + // lookahead) so the replacement is a pure insert of "\n\n" at + // the seam — no captured punctuation to re-emit. + segment = s_seamSentencePunct.Replace(segment, "\n\n"); + return segment; + } + + // Per-process random seed for ChatTraceHash. Mixing this into the FNV + // initial state keeps identical-text frames colliding within a single + // tray run (so duplicate-bubble diagnostics still work) while making + // the hash useless as a content fingerprint outside this process: an + // attacker with the log file can no longer rebuild the hash for a + // guessed plaintext, and the value rotates on every tray restart. + private static readonly uint ChatTraceHashSeed = unchecked((uint)System.Security.Cryptography.RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue)); + + // Short FNV-1a-style 32-bit fold of the message text, seeded with a + // per-process random value. Used in trace logs to tell two near- + // duplicate frames apart at a glance without dumping the text itself. + // Not a security hash; not reproducible outside this process. + internal static string ChatTraceHash(string text) + { + if (string.IsNullOrEmpty(text)) return "00000000"; + uint h = ChatTraceHashSeed; + for (int i = 0; i < text.Length; i++) + { + h ^= text[i]; + h *= 16777619u; + } + return h.ToString("x8"); + } + + /// + /// Apply to whichever text + /// payload a carries. Returns the input + /// unchanged when there is nothing to truncate or the text already + /// fits. Used by to enforce the + /// per-message size cap on every code path. + /// + /// + /// Coverage: every subtype that carries a + /// caller-supplied text payload is truncated here, including the + /// currently-unused + /// / + /// / + /// shapes — these don't flow through + /// today but covering them now + /// prevents a future caller from bypassing the cap when wiring + /// them up. The / + /// shapes have no untrusted + /// text fields and fall through unchanged. + /// + internal static ChatEvent TruncateChatEvent(ChatEvent evt) => evt switch + { + ChatUserMessageEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatThinkingEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatReasoningEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatReasoningDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatMessageEvent e => e with + { + Text = TruncateForChatEntry(e.Text), + ReasoningText = e.ReasoningText is null ? null : TruncateForChatEntry(e.ReasoningText) + }, + ChatMessageDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatToolStartEvent e => e with + { + Text = TruncateForChatEntry(e.Text), + ToolName = TruncateForChatEntry(e.ToolName) + }, + ChatToolOutputEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatToolErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatStatusEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatRestoredEvent e => e with { Text = TruncateForChatEntry(e.Text) }, + ChatRawEvent e => e with { Text = e.Text is null ? null : TruncateForChatEntry(e.Text) }, + ChatModelChangedEvent e => e with { Model = TruncateForChatEntry(e.Model) }, + ChatIntentEvent e => e with { Intent = TruncateForChatEntry(e.Intent) }, + ChatPermissionRequestEvent e => e with + { + PermissionKind = TruncateForChatEntry(e.PermissionKind), + ToolName = TruncateForChatEntry(e.ToolName), + Detail = TruncateForChatEntry(e.Detail) + }, + _ => evt + }; + +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs new file mode 100644 index 000000000..d97a26640 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs @@ -0,0 +1,2457 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +/// +/// Single lock root for atomic conversation, queue, reset, connection, and +/// history commit state. All exposed operations are closed domain transitions. +/// +internal sealed class ChatConversationState +{ + private static readonly TimeSpan LocalEchoSuppressionWindow = TimeSpan.FromSeconds(30); + private readonly object _gate = new(); + private readonly ChatApprovalState _approval = new(); + private readonly ChatHistoryState _history = new(); + private readonly ChatPresentationState _presentation; + private readonly ChatQueueState _queue = new(); + private readonly ChatLifecycleState _lifecycle = new(); + private readonly ChatResetState _reset = new(); + private readonly Dictionary _timelines = new(); + private readonly Dictionary> _entryMeta = new(); + private ConnectionStatus _status; + private bool _disposed; + + internal ChatConversationState( + ConnectionStatus status, + OpenClawChatDataProvider.LastChatState? lastChatState, + ModelsListInfo? seedModels) + { + _status = status; + _presentation = new ChatPresentationState(lastChatState, seedModels); + } + + internal bool IsResponseSuppressed + { + get + { + lock (_gate) + return _lifecycle.IsResponseSuppressed; + } + } + + internal bool IsDisposed + { + get + { + lock (_gate) + return _disposed; + } + } + + internal ConnectionStatus Status + { + get + { + lock (_gate) + return _status; + } + } + + internal long HistoryGeneration + { + get + { + lock (_gate) + return _history.ConnectionGeneration; + } + } + + internal OpenClawChatDataProvider.LastChatState? CachedLastChatState + { + get + { + lock (_gate) + return _presentation.CachedLastChatState; + } + } + + internal ChatDataSnapshot Load( + SessionInfo[] sessions, + ChatProjectionContext context) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _presentation.ReplaceSessions( + sessions, + receivedFromGateway: false); + EnsureTimelinesForSessionsLocked(); + _presentation.RememberLastSessionState(context); + return BuildSnapshotLocked(context); + } + } + + internal IReadOnlyDictionary GetEntryMetadata(string threadId) + { + lock (_gate) + { + return _entryMeta.TryGetValue(threadId, out var metadata) + ? new Dictionary(metadata) + : new Dictionary(); + } + } + + internal OpenClawChatDataProvider.LastChatState? RememberSelectedThread(string threadId) + { + lock (_gate) + { + return _presentation.RememberSelectedThread(threadId); + } + } + + internal ChatDataSnapshot Snapshot(ChatProjectionContext context) + { + lock (_gate) + return BuildSnapshotLocked(context); + } + + internal string? ResolveDefaultThreadId(ChatProjectionContext context) + { + lock (_gate) + { + return ChatSnapshotProjector.ResolveDefaultThreadId( + CaptureProjectionInputLocked(context)); + } + } + + internal (string CacheKey, long ResetGeneration) ResolveMetadataKey(string threadId) + { + lock (_gate) + { + var key = _history.ResolveSessionId(threadId) ?? threadId; + return (key, GetResetVersionLocked(threadId)); + } + } + + internal long GetResetGeneration(string threadId) + { + lock (_gate) + return GetResetVersionLocked(threadId); + } + + internal ChatHistoryCommitToken CaptureHistoryToken(string threadId) + { + lock (_gate) + { + return _history.CreateCommitToken( + threadId, + GetResetVersionLocked(threadId)); + } + } + + internal ChatHistoryReplacementTransition? BeginHistoryReplacement( + string threadId, + ChatProjectionContext context) + { + lock (_gate) + { + if (_disposed) + return null; + + var token = _history.BeginReplacement( + threadId, + GetResetVersionLocked(threadId)); + _timelines[threadId] = ChatTimelineState.Initial(); + _entryMeta.Remove(threadId); + return new(BuildSnapshotLocked(context), token); + } + } + + internal bool TryBeginHistory( + string threadId, + bool force, + ChatHistoryCommitToken? expectedToken, + out ChatHistoryCommitToken token, + out string? model, + out Task? generationActivation) + { + lock (_gate) + { + var canBegin = _history.TryBegin( + threadId, + force, + expectedToken, + GetResetVersionLocked(threadId), + _status, + _disposed, + out token, + out generationActivation); + model = _presentation.ModelForThread(threadId); + return canBegin; + } + } + + internal bool IsHistoryRequestCurrent(ChatHistoryCommitToken token) + { + lock (_gate) + return _history.IsCurrent( + token, + GetResetVersionLocked(token.ThreadId), + _disposed); + } + + internal bool CanRetryHistory( + ChatHistoryCommitToken token, + bool authoritative) + { + lock (_gate) + return _history.CanRetry( + token, + GetResetVersionLocked(token.ThreadId), + _status, + authoritative, + _disposed); + } + + internal bool CommitHistory( + ChatHistoryCommitToken token, + ChatHistoryRebuildPlan plan, + DateTimeOffset requestStartedAt, + bool authoritative) + { + lock (_gate) + { + if (!_history.IsCurrent( + token, + GetResetVersionLocked(token.ThreadId), + _disposed)) + { + return false; + } + + var prior = GetOrCreateTimelineLocked(token.ThreadId); + var priorMetadata = _entryMeta.TryGetValue( + token.ThreadId, + out var metadata) + ? metadata + : new Dictionary(); + var merged = ChatHistoryState.MergeWithLiveEntries( + plan, + prior, + priorMetadata, + requestStartedAt, + authoritative); + _timelines[token.ThreadId] = merged.Timeline; + _entryMeta[token.ThreadId] = merged.Metadata; + _history.MarkCommitted(token, plan.SessionId); + return true; + } + } + + internal ChatDataSnapshot? SnapshotIfHistoryTokenCurrent( + ChatHistoryCommitToken token, + ChatProjectionContext context) + { + lock (_gate) + { + return _history.IsCurrent( + token, + GetResetVersionLocked(token.ThreadId), + _disposed) + ? BuildSnapshotLocked(context) + : null; + } + } + + internal bool IsCurrentResetGeneration(string threadId, long generation) + { + lock (_gate) + return GetResetVersionLocked(threadId) == generation; + } + + internal ChatStatusTransition ApplyStatus( + ConnectionStatus status, + ChatProjectionContext context) + { + lock (_gate) + { + if (_disposed) + { + return new( + BuildSnapshotLocked(context), + false, + false, + [], + _history.ConnectionGeneration); + } + + var reconnected = status == ConnectionStatus.Connected && + _status != ConnectionStatus.Connected; + var disconnected = status != ConnectionStatus.Connected && + _status == ConnectionStatus.Connected; + _status = status; + if (status != ConnectionStatus.Connected) + _presentation.LeaveConnected(); + if (disconnected) + _approval.Reset(); + + string[] interruptedThreads = []; + if (reconnected) + { + _history.AdvanceConnectionGeneration(clearLoaded: true); + _queue.ClearForReconnect(); + _reset.ClearSubmittedEchoesForReconnect(); + _lifecycle.ClearForReconnect(); + _presentation.ResetKeylessDiagnostic(); + foreach (var threadId in _timelines.Keys.ToArray()) + { + _timelines[threadId] = ChatTimelineReducer.Apply( + _timelines[threadId], + new ChatToolReplayResetEvent()); + } + } + if (disconnected) + { + _history.AdvanceConnectionGeneration(clearLoaded: false); + _reset.ClearSubmittedEchoesForReconnect(); + interruptedThreads = _timelines + .Where(pair => pair.Value.TurnActive) + .Select(pair => pair.Key) + .ToArray(); + _lifecycle.ClearActiveRuns(interruptedThreads); + } + return new( + BuildSnapshotLocked(context), + reconnected, + disconnected, + interruptedThreads, + _history.ConnectionGeneration); + } + } + + internal void ClearToolReplayState() + { + lock (_gate) + { + foreach (var threadId in _timelines.Keys.ToArray()) + { + _timelines[threadId] = ChatTimelineReducer.Apply( + _timelines[threadId], + new ChatToolReplayResetEvent()); + } + } + } + + internal ChatSessionsTransition ApplySessions( + SessionInfo[] sessions, + ChatProjectionContext context) + { + lock (_gate) + { + var previousUsage = _presentation.SnapshotUsage(); + _presentation.ReplaceSessions(sessions); + var currentSessions = _presentation.SessionSnapshot(); + _history.SeedSessionIds(currentSessions); + EnsureTimelinesForSessionsLocked(); + _presentation.RememberLastSessionState(context); + foreach (var session in currentSessions) + { + if (string.IsNullOrEmpty(session.Key)) + continue; + var usage = new ChatUsageSnapshot( + session.InputTokens, + session.OutputTokens, + session.TotalTokens, + session.ContextTokens); + if (!previousUsage.TryGetValue(session.Key, out var previous) || + previous != usage) + { + SnapshotLatestAssistantUsageLocked( + session, + _presentation.ResolveTimelineKey(session, _timelines)); + } + } + return new( + BuildSnapshotLocked(context), + _status == ConnectionStatus.Connected + ? _queue.ThreadsWithMessages() + : []); + } + } + + internal ChatDataSnapshot ApplyModels( + ModelsListInfo models, + ChatProjectionContext context) + { + lock (_gate) + { + _presentation.ApplyModels(models); + return BuildSnapshotLocked(context); + } + } + + internal ChatModelPatchLease BeginModelPatch(string threadId) + { + lock (_gate) + return _presentation.BeginModelPatch(threadId); + } + + internal void CompleteModelPatch(ChatModelPatchLease lease, Exception? error) + { + lock (_gate) + _presentation.CompleteModelPatch(lease, error); + } + + internal Task? GetPendingModelPatch(string threadId) + { + lock (_gate) + return _presentation.GetPendingModelPatch(threadId); + } + + internal bool TryBeginCommandCatalogFetch(out int epoch) + { + lock (_gate) + return _presentation.TryBeginCommandCatalogFetch(_status, out epoch); + } + + internal bool CompleteCommandCatalogFetch(int epoch, CommandCatalog catalog) + { + lock (_gate) + return _presentation.CompleteCommandCatalogFetch( + epoch, + _status, + catalog); + } + + internal bool FailCommandCatalogFetch(int epoch) + { + lock (_gate) + return _presentation.FailCommandCatalogFetch(epoch, _status); + } + + internal ChatDataSnapshot? SnapshotCommandCatalogIfFresh( + int epoch, + ChatProjectionContext context) + { + lock (_gate) + { + return _presentation.IsCommandCatalogEpochCurrent(epoch) + ? BuildSnapshotLocked(context) + : null; + } + } + + internal ChatDisposeTransition DisposeState() + { + lock (_gate) + { + if (_disposed) + return new( + _history.ConnectionGeneration, + IsFirstDispose: false); + _disposed = true; + _history.AdvanceConnectionGeneration(clearLoaded: false); + _queue.ClearForDispose(); + _lifecycle.ClearForDispose(); + _reset.ClearSubmittedEchoesForReconnect(); + return new( + _history.ConnectionGeneration, + IsFirstDispose: true); + } + } + + internal bool TryRaiseKeylessDiagnostic() + { + lock (_gate) + return _presentation.TryRaiseKeylessDiagnostic(); + } + + internal string? PendingPermissionId(string threadId) + { + lock (_gate) + return GetOrCreateTimelineLocked(threadId).PendingPermission?.RequestId; + } + + internal void ActivateHistoryGeneration(long generation) + { + lock (_gate) + _history.ActivateConnectionGeneration(generation, _disposed); + } + + private bool SnapshotLatestAssistantUsageLocked( + SessionInfo session, + string threadId) + { + if (string.IsNullOrEmpty(session.Key)) + return false; + var usedTokens = session.TotalTokens; + if (usedTokens <= 0) + usedTokens = session.InputTokens + session.OutputTokens; + if (usedTokens <= 0 || + string.IsNullOrEmpty(threadId) || + !_timelines.TryGetValue(threadId, out var timeline)) + { + return false; + } + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + if (timeline.Entries[i].Kind != ChatTimelineItemKind.Assistant) + continue; + var metadata = GetOrCreateThreadMetaLocked(threadId); + metadata.TryGetValue(timeline.Entries[i].Id, out var existing); + var usageSnapshot = Math.Max( + usedTokens, + existing?.ResponseTokens ?? 0); + var usageTokens = ToIntIfPositive(usageSnapshot); + var contextTokens = session.ContextTokens > 0 + ? session.ContextTokens + : existing?.ContextTokens; + if (existing is not null && + existing.ResponseTokens == usageTokens && + existing.ContextTokens == contextTokens) + { + return false; + } + metadata[timeline.Entries[i].Id] = + (existing ?? BuildLiveMetaLocked(threadId)) with + { + InputTokens = ToIntIfPositive(session.InputTokens), + OutputTokens = ToIntIfPositive(session.OutputTokens), + ResponseTokens = usageTokens, + ContextTokens = contextTokens, + ContextPercent = existing?.ContextPercent, + UsageContributionTokens = existing?.UsageContributionTokens, + }; + return true; + } + return false; + } + + private static int? ToIntIfPositive(long value) => + value > 0 && value <= int.MaxValue ? (int)value : null; + + internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload( + ChatEntryMetadata? metadata, + int maxHistorySequence, + DateTimeOffset requestStartedAt) => + ChatHistoryState.ShouldPreserveLiveEntryDuringAuthoritativeReload( + metadata, + maxHistorySequence, + requestStartedAt); + + internal ChatQueuedAdmission AdmitMessage( + string threadId, + string text, + string displayText, + string nonce, + IReadOnlyList? attachments, + DateTimeOffset createdAt, + ChatProjectionContext context) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var messageId = _queue.NextMessageId(); + if (CanClearAssistantFallbackPromotionLocked(threadId)) + _queue.ClearAssistantFallbackPromotion(threadId); + + _lifecycle.ClearThreadSuppression(threadId); + _lifecycle.TakePendingAbortCount(threadId); + var request = new ChatQueuedSendRequest( + messageId, + Guid.NewGuid().ToString(), + threadId, + text, + displayText, + nonce, + attachments?.ToArray()); + + var sendDirectly = CanSendDirectlyLocked(threadId); + ChatQueuedSendDispatch? dispatch; + if (sendDirectly) + { + dispatch = StartDirectSendLocked(request); + } + else + { + _queue.AddMessage(threadId, new ChatQueuedMessage( + messageId, + displayText, + createdAt, + nonce)); + _queue.AddRequest(request); + dispatch = TryStartNextQueuedSendLocked( + threadId, + requireConnected: false, + out _); + } + + return new ChatQueuedAdmission( + messageId, + Queued: !sendDirectly, + dispatch, + BuildSnapshotLocked(context), + CurrentRuntimeGenerationLocked(threadId)); + } + } + + internal ChatDataSnapshot EnqueueCompact( + string threadId, + DateTimeOffset createdAt, + ChatProjectionContext context) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var messageId = _queue.NextMessageId(); + var request = new ChatQueuedSendRequest( + messageId, + Guid.NewGuid().ToString(), + threadId, + "/compact", + "/compact", + Guid.NewGuid().ToString(), + Attachments: null, + LifecycleCommand: ChatLifecycleCommandKind.Compact); + _queue.AddMessage(threadId, new ChatQueuedMessage( + messageId, + request.DisplayText, + createdAt, + request.LocalNonce)); + _queue.AddRequest(request); + return BuildSnapshotLocked(context); + } + } + + internal (bool Canceled, ChatDataSnapshot? Snapshot) CancelQueuedMessage( + string threadId, + string messageId, + ChatProjectionContext context) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var canceled = _queue.CancelMessage(threadId, messageId); + if (canceled) + { + _queue.ClearLocallyInitiatedIfIdle( + threadId, + _lifecycle.HasActiveRun(threadId), + _timelines.TryGetValue(threadId, out var timeline) && + timeline.TurnActive); + } + return ( + canceled, + canceled ? BuildSnapshotLocked(context) : null); + } + } + + internal ChatQueueStart TryStartNextQueuedSend( + string threadId, + bool requireConnected, + ChatProjectionContext context) + { + lock (_gate) + { + if (_disposed) + return new(null, null, null); + var dispatch = TryStartNextQueuedSendLocked( + threadId, + requireConnected, + out var delayedRetry); + return new ChatQueueStart( + dispatch, + delayedRetry, + dispatch is null ? null : BuildSnapshotLocked(context)); + } + } + + internal bool TryScheduleQueueDrain(string threadId) + { + lock (_gate) + { + return !_disposed && _queue.TryScheduleDrain(threadId); + } + } + + internal void CompleteQueueDrainSchedule(string threadId) + { + lock (_gate) + _queue.CompleteDrainSchedule(threadId); + } + + internal ChatSendPreparation PrepareSendAttempt( + ChatQueuedSendDispatch dispatch, + ChatProjectionContext context) + { + lock (_gate) + { + if (!IsDispatchGenerationCurrentLocked(dispatch) || + !dispatch.StartedDirectly && + _queue.FindRequest( + dispatch.Request.ThreadId, + dispatch.Request.Id) is null) + { + return new( + false, + CleanupStaleDispatchTurnLocked(dispatch, context)); + } + _queue.TrackRun( + dispatch.Request.ThreadId, + dispatch.Request.SendRunId, + dispatch.Request.Id); + return new(true, null); + } + } + + internal ChatSendCommit CommitSendResult( + ChatQueuedSendDispatch dispatch, + ChatSendResult sendResult, + ChatProjectionContext context) + { + var request = dispatch.Request; + var threadId = request.ThreadId; + var acceptedRunId = string.IsNullOrWhiteSpace(sendResult.RunId) + ? null + : sendResult.RunId; + lock (_gate) + { + if (!IsDispatchGenerationCurrentLocked(dispatch)) + { + _reset.RemovePendingLocalSubmission( + threadId, + request.Id, + dispatch.ResetVersion); + var staleRunId = acceptedRunId ?? request.SendRunId; + _reset.AddIgnoredRun(threadId, staleRunId); + return new( + IsCurrent: false, + AcceptedSnapshot: + CleanupStaleDispatchTurnLocked(dispatch, context), + RequeuedSnapshot: null, + StaleRunIdToAbort: staleRunId, + BindAcceptedRun: false, + RequeueRequired: false, + RetryDeferredSend: false, + DeferredRetryDelay: ChatSendQueuePolicy.DrainDelay, + OpenedLifecycle: null, + CurrentRuntimeGenerationLocked(threadId)); + } + + ChatDataSnapshot? acceptedSnapshot = null; + ChatDataSnapshot? requeuedSnapshot = null; + ChatOpenedLifecycleTransition? openedLifecycle = null; + var bindAcceptedRun = false; + var requeueRequired = false; + var retryDeferredSend = false; + var deferredRetryDelay = ChatSendQueuePolicy.DrainDelay; + if (ChatSendQueuePolicy.IsDeferredAdmissionStatus(sendResult.Status)) + { + _reset.RemovePendingLocalSubmission( + threadId, + request.Id, + dispatch.ResetVersion); + var runAlreadyStarted = !string.IsNullOrEmpty(acceptedRunId) + && _lifecycle.HasRunStartedAfter( + threadId, + acceptedRunId, + dispatch.StartedRunStartSequence); + if (runAlreadyStarted) + { + bindAcceptedRun = true; + _queue.TrackRun(threadId, acceptedRunId!, request.Id); + AddResetAcceptedRunIdLocked(threadId, acceptedRunId!); + if (PromoteQueuedMessageLocked(threadId, request.Id)) + acceptedSnapshot = BuildSnapshotLocked(context); + else + _queue.RemoveRunMappingByMessageId(threadId, request.Id); + } + else if (_queue.RequeueDeferredAdmission( + threadId, + request.Id, + _lifecycle.HasActiveRun(threadId)) is + { Requeued: true } retry) + { + deferredRetryDelay = retry.Delay; + if (retry.ShouldEndTurn) + { + _timelines[threadId] = ChatTimelineReducer.Apply( + GetOrCreateTimelineLocked(threadId), + new ChatTurnEndEvent()); + } + requeueRequired = true; + if (!string.IsNullOrEmpty(acceptedRunId)) + { + _queue.TrackRun(threadId, acceptedRunId, request.Id); + openedLifecycle = + AddResetAcceptedRunIdLocked( + threadId, + acceptedRunId); + } + requeuedSnapshot = BuildSnapshotLocked(context); + retryDeferredSend = true; + } + else if (dispatch.StartedDirectly) + { + throw new InvalidOperationException( + $"Gateway returned chat.send status {sendResult.Status} before admitting the direct send."); + } + } + else if (!string.IsNullOrEmpty(acceptedRunId)) + { + bindAcceptedRun = true; + _queue.TrackRun(threadId, acceptedRunId, request.Id); + openedLifecycle = + AddResetAcceptedRunIdLocked( + threadId, + acceptedRunId); + var runAlreadyStarted = + _lifecycle.HasRunStartedAfter( + threadId, + acceptedRunId, + dispatch.StartedRunStartSequence); + if (PromoteQueuedMessageLocked(threadId, request.Id)) + acceptedSnapshot = BuildSnapshotLocked(context); + else if (runAlreadyStarted) + _queue.RemoveRunMappingByMessageId(threadId, request.Id); + } + else if (_reset.IsAwaitingUserMessage(threadId)) + { + _queue.RemoveRunMappingByRunId(threadId, request.SendRunId); + openedLifecycle = + ApplyBufferedLifecycleOpenLocked( + threadId, + _reset.RecordLocalSendWithoutRun( + threadId, + dispatch.ResetVersion, + dispatch.StartedLifecycleSequence, + request.Id), + allowRemoteTurn: false); + if (PromoteQueuedMessageLocked(threadId, request.Id)) + acceptedSnapshot = BuildSnapshotLocked(context); + } + else if (PromoteQueuedMessageLocked(threadId, request.Id)) + { + _queue.RemoveRunMappingByRunId(threadId, request.SendRunId); + acceptedSnapshot = BuildSnapshotLocked(context); + } + + return new( + IsCurrent: true, + acceptedSnapshot, + requeuedSnapshot, + StaleRunIdToAbort: null, + bindAcceptedRun, + requeueRequired, + retryDeferredSend, + deferredRetryDelay, + openedLifecycle, + CurrentRuntimeGenerationLocked(threadId)); + } + } + + internal ChatSendFailure FailSend( + ChatQueuedSendDispatch dispatch, + string queueError, + string timelineError, + ChatProjectionContext context) + { + var request = dispatch.Request; + lock (_gate) + { + _reset.RemovePendingLocalSubmission( + request.ThreadId, + request.Id, + dispatch.ResetVersion); + if (!IsDispatchGenerationCurrentLocked(dispatch)) + { + return new( + false, + CleanupStaleDispatchTurnLocked(dispatch, context)); + } + + _queue.RemovePendingLocalEcho(request.ThreadId, request.Id); + _queue.MarkFailed(request.ThreadId, request.Id, queueError); + _queue.RemoveRequest(request.ThreadId, request.Id); + _queue.RemoveRunMappingByMessageId(request.ThreadId, request.Id); + if (!_queue.HasSendingMessages(request.ThreadId)) + _queue.ClearLocallyInitiated(request.ThreadId); + ApplyEventLocked( + request.ThreadId, + ChatContentFormatting.TruncateChatEvent( + new ChatErrorEvent(timelineError)), + metadata: null); + ApplyEventLocked(request.ThreadId, new ChatTurnEndEvent(), metadata: null); + return new(true, BuildSnapshotLocked(context)); + } + } + + internal bool IsQueuedDispatchCurrent(ChatQueuedSendDispatch dispatch) + { + lock (_gate) + { + return IsDispatchGenerationCurrentLocked(dispatch) + && _queue.FindRequest( + dispatch.Request.ThreadId, + dispatch.Request.Id) is not null; + } + } + + internal (bool Succeeded, ChatDataSnapshot? Snapshot) CompleteQueuedLifecycle( + ChatQueuedSendDispatch dispatch, + bool succeeded, + string? error, + ChatProjectionContext context) + { + var request = dispatch.Request; + lock (_gate) + { + if (!IsDispatchGenerationCurrentLocked(dispatch) || + _queue.FindRequest(request.ThreadId, request.Id) is null) + { + return (false, null); + } + + if (succeeded) + { + var removed = _queue.RemoveMessage(request.ThreadId, request.Id); + return (true, removed ? BuildSnapshotLocked(context) : null); + } + + ApplyEventLocked( + request.ThreadId, + new ChatErrorEvent(error ?? "The lifecycle command failed."), + metadata: null); + _queue.MarkFailed( + request.ThreadId, + request.Id, + error ?? "The lifecycle command failed."); + _queue.RemoveRequest(request.ThreadId, request.Id); + return (true, BuildSnapshotLocked(context)); + } + } + + private bool IsDispatchGenerationCurrentLocked( + ChatQueuedSendDispatch dispatch) => + !_disposed && + _history.ConnectionGeneration == dispatch.ConnectionGeneration && + GetResetVersionLocked(dispatch.Request.ThreadId) == dispatch.ResetVersion; + + private ChatDataSnapshot? CleanupStaleDispatchTurnLocked( + ChatQueuedSendDispatch dispatch, + ChatProjectionContext context) + { + var threadId = dispatch.Request.ThreadId; + if (_disposed || + _lifecycle.HasActiveRun(threadId) || + _queue.IsLocallyInitiated(threadId) || + !_timelines.TryGetValue(threadId, out var timeline) || + !timeline.TurnActive) + { + return null; + } + _timelines[threadId] = ChatTimelineReducer.Apply( + timeline, + new ChatTurnEndEvent()); + return BuildSnapshotLocked(context); + } + + private bool CanSendDirectlyLocked(string threadId) => + _queue.CanSendDirectly( + threadId, + _lifecycle.HasActiveRun(threadId), + _timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive); + + private bool CanClearAssistantFallbackPromotionLocked(string threadId) => + _queue.CanClearAssistantFallback( + threadId, + _lifecycle.HasActiveRun(threadId), + _timelines.TryGetValue(threadId, out var timeline) && + timeline.TurnActive); + + private ChatQueuedSendDispatch StartDirectSendLocked( + ChatQueuedSendRequest request) + { + var threadId = request.ThreadId; + var resetVersion = GetResetVersionLocked(threadId); + var current = GetOrCreateTimelineLocked(threadId); + var entryId = $"e{current.NextId}"; + _timelines[threadId] = ChatTimelineReducer.AddLocalUser( + current, + request.DisplayText, + request.LocalNonce); + GetOrCreateThreadMetaLocked(threadId)[entryId] = BuildLiveMetaLocked( + threadId, + isLocalQueuedSend: true, + localQueuedMessageId: request.Id); + var dispatch = _queue.StartDirect( + request, + _history.ResolveSessionId(threadId), + _history.ConnectionGeneration, + resetVersion, + _reset.LifecycleStartSequence, + _lifecycle.LifecycleStartSequence); + RegisterResetSubmissionLocked(dispatch); + return dispatch; + } + + private ChatQueuedSendDispatch? TryStartNextQueuedSendLocked( + string threadId, + bool requireConnected, + out TimeSpan? delayedRetry) + { + var turnActive = _timelines.TryGetValue(threadId, out var timeline) && + timeline.TurnActive; + var dispatch = _queue.TryStartNext( + threadId, + requireConnected, + _status, + _lifecycle.HasActiveRun(threadId), + turnActive, + _history.ResolveSessionId(threadId), + _history.ConnectionGeneration, + GetResetVersionLocked(threadId), + _reset.LifecycleStartSequence, + _lifecycle.LifecycleStartSequence, + out delayedRetry); + if (dispatch?.Request.LifecycleCommand is null && dispatch is not null) + { + RegisterResetSubmissionLocked(dispatch); + _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn( + GetOrCreateTimelineLocked(threadId)); + } + return dispatch; + } + + private void RegisterResetSubmissionLocked( + ChatQueuedSendDispatch dispatch) + { + _reset.RegisterPendingLocalSubmission( + dispatch.Request.ThreadId, + dispatch.Request.Id, + dispatch.Request.Text, + dispatch.ResetVersion, + dispatch.StartedLifecycleSequence, + DateTimeOffset.UtcNow, + requiresEcho: + !string.IsNullOrWhiteSpace( + dispatch.Request.Text)); + } + + private bool RemoveQueuedMessageLocked(string threadId, string messageId) + { + var removed = _queue.RemoveMessage(threadId, messageId); + if (removed) + ClearLocallyInitiatedIfIdleLocked(threadId); + return removed; + } + + private bool CancelQueuedMessageLocked(string threadId, string messageId) + { + var canceled = _queue.CancelMessage(threadId, messageId); + if (canceled) + ClearLocallyInitiatedIfIdleLocked(threadId); + return canceled; + } + + private bool PromoteQueuedMessageLocked( + string threadId, + string messageId, + ChatEntryMetadata? confirmedMeta = null) + { + if (!_queue.TryTakeForPromotion(threadId, messageId, out var queued)) + return false; + + var current = GetOrCreateTimelineLocked(threadId); + var entryId = $"e{current.NextId}"; + _timelines[threadId] = ChatTimelineReducer.AddLocalUser( + current, + queued.Text, + queued.LocalNonce); + var meta = confirmedMeta is not null && HasGatewayIdentity(confirmedMeta) + ? confirmedMeta with + { + IsLocalQueuedSend = false, + LocalQueuedMessageId = messageId, + } + : BuildLiveMetaLocked( + threadId, + isLocalQueuedSend: true, + localQueuedMessageId: messageId); + GetOrCreateThreadMetaLocked(threadId)[entryId] = meta; + return true; + } + + private void ClearLocallyInitiatedIfIdleLocked(string threadId) + { + _queue.ClearLocallyInitiatedIfIdle( + threadId, + _lifecycle.HasActiveRun(threadId), + _timelines.TryGetValue(threadId, out var timeline) && + timeline.TurnActive); + } + + private static bool HasGatewayIdentity(ChatEntryMetadata metadata) => + !string.IsNullOrEmpty(metadata.GatewayMessageId) || + metadata.OpenClawSeq is not null; + + internal ChatDataSnapshot ApplyEvent( + string threadId, + ChatEvent evt, + ChatEntryMetadata? metadata, + ChatProjectionContext context) + { + lock (_gate) + { + ApplyEventLocked( + threadId, + ChatContentFormatting.TruncateChatEvent(evt), + metadata); + return BuildSnapshotLocked(context); + } + } + + internal ChatDataSnapshot ClearPendingPermission( + string threadId, + string? expectedRequestId, + ChatPermissionDecision decision, + ChatProjectionContext context) + { + lock (_gate) + { + var timeline = GetOrCreateTimelineLocked(threadId); + if (expectedRequestId is not null && + !string.Equals( + timeline.PendingPermission?.RequestId, + expectedRequestId, + StringComparison.Ordinal)) + { + return BuildSnapshotLocked(context); + } + _timelines[threadId] = ChatTimelineReducer.ResolvePermission( + timeline, + expectedRequestId, + decision); + return BuildSnapshotLocked(context); + } + } + + internal ChatEntryMetadata BuildLiveMetadata( + string threadId, + long? tsMs = null, + string? gatewayMessageId = null, + int? openClawSeq = null, + bool isLocalQueuedSend = false, + string? localQueuedMessageId = null, + string? openClawKind = null, + long? compactionTokensBefore = null, + long? compactionTokensAfter = null) + { + lock (_gate) + { + return BuildLiveMetaLocked( + threadId, + tsMs, + gatewayMessageId, + openClawSeq, + isLocalQueuedSend, + localQueuedMessageId, + openClawKind, + compactionTokensBefore, + compactionTokensAfter); + } + } + + internal bool IsLateNonFinalAssistantFrame(string threadId) + { + lock (_gate) + { + if (!_timelines.TryGetValue(threadId, out var timeline) || + timeline.TurnActive) + { + return false; + } + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind == ChatTimelineItemKind.User) + return false; + if (entry.Kind == ChatTimelineItemKind.Assistant) + return !entry.IsStreaming; + } + return false; + } + } + + internal ChatAbortStart BeginAbort(string threadId) + { + lock (_gate) + { + var hadActiveTurn = _timelines.TryGetValue(threadId, out var timeline) && + timeline.TurnActive; + return _lifecycle.BeginAbort(threadId, hadActiveTurn); + } + } + + internal void RollbackAbort(string threadId, string runId) + { + lock (_gate) + { + _lifecycle.RollbackAbort(threadId, runId); + if (!_queue.HasSendingMessages(threadId)) + _queue.ClearLocallyInitiated(threadId); + } + } + + internal ChatDataSnapshot? RollbackAbortAndEndTurnIfCurrent( + string threadId, + string runId, + ChatRuntimeGeneration expectedGeneration, + ChatProjectionContext context) + { + lock (_gate) + { + if (_disposed || + CurrentRuntimeGenerationLocked(threadId) != expectedGeneration || + !_lifecycle.TryGetActiveRun(threadId, out var activeRunId) || + !string.Equals(activeRunId, runId, StringComparison.Ordinal)) + { + return null; + } + + _lifecycle.RollbackAbort(threadId, runId); + if (!_queue.HasSendingMessages(threadId)) + _queue.ClearLocallyInitiated(threadId); + ApplyEventLocked( + threadId, + new ChatTurnEndEvent(), + metadata: null); + return BuildSnapshotLocked(context); + } + } + + internal void CompleteAbort(string threadId, string? runId) + { + lock (_gate) + { + _lifecycle.CompleteAbort(threadId, runId); + if (!_queue.HasSendingMessages(threadId)) + _queue.ClearLocallyInitiated(threadId); + } + } + + internal bool ShouldSuppressChatMessage(string threadId) + { + lock (_gate) + return _lifecycle.IsThreadSuppressed(threadId); + } + + internal ChatResetTransition ResetThread( + string threadId, + ChatProjectionContext context) + { + lock (_gate) + { + var oldSessionId = _history.ClearSessionForReset(threadId); + + var submittedRunIds = new HashSet(StringComparer.Ordinal); + if (_lifecycle.ActiveRunForReset(threadId) is { Length: > 0 } activeRunId) + { + submittedRunIds.Add(activeRunId); + } + + foreach (var runId in _queue.RunIdsForThread(threadId)) + submittedRunIds.Add(runId); + foreach (var localEcho in _queue.SnapshotLocalEchoes(threadId)) + { + _reset.AddSubmittedLocalEcho( + threadId, + localEcho.Text, + localEcho.SentAt); + } + + var generation = _reset.BeginReset( + threadId, + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + _timelines[threadId] = ChatTimelineState.Initial() with + { + HistoryLoaded = true, + }; + _entryMeta.Remove(threadId); + _lifecycle.ClearThreadForReset(threadId); + _queue.ClearThreadForReset(threadId); + foreach (var runId in submittedRunIds) + _reset.AddIgnoredRun(threadId, runId); + + return new( + BuildSnapshotLocked(context), + oldSessionId, + generation, + threadId, + submittedRunIds.ToArray()); + } + } + + internal ChatIncomingMessageGate GateIncomingChatMessage( + ChatMessageInfo message, + ChatProjectionContext context) + { + var threadId = message.SessionKey!; + var role = message.Role?.ToLowerInvariant() ?? string.Empty; + var text = message.Text ?? string.Empty; + lock (_gate) + { + _lifecycle.TryGetActiveRun( + threadId, + out var activeRunId); + var resetGate = _reset.EvaluateChatMessage( + threadId, + role, + text, + message.Ts, + _queue.HasPendingLocalEchoText(threadId, text), + activeRunId); + var openedLifecycle = + ApplyBufferedLifecycleOpenLocked( + threadId, + resetGate.OpenedLifecycleStart, + allowRemoteTurn: + resetGate.ConsumeEchoText is null); + if (resetGate.Drop) + { + ChatDataSnapshot? snapshot = null; + if (resetGate.ConsumeEchoText is not null && + _queue.TryConsumeLocalEcho( + threadId, + resetGate.ConsumeEchoText, + out var queuedMessageId)) + { + var confirmed = BuildLiveMetaLocked( + threadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + if (ReconcileQueuedMessageEchoLocked( + threadId, + queuedMessageId, + confirmed)) + { + snapshot = BuildSnapshotLocked(context); + } + } + return new( + true, + false, + resetGate.RequestRemoteBackfill, + snapshot, + openedLifecycle, + CurrentRuntimeGenerationLocked(threadId)); + } + return new( + Drop: false, + Suppressed: _lifecycle.IsThreadSuppressed(threadId), + RequestRemoteBackfill: false, + Snapshot: null, + openedLifecycle, + CurrentRuntimeGenerationLocked(threadId)); + } + } + + internal ChatLocalEchoTransition ConsumeLocalEcho( + ChatMessageInfo message, + bool removeQueuedMessage, + ChatProjectionContext context) + { + var threadId = message.SessionKey!; + var text = (message.Text ?? string.Empty).Trim(); + lock (_gate) + { + if (!_queue.TryConsumeLocalEcho( + threadId, + text, + out var queuedMessageId)) + { + return new(false, null); + } + if (removeQueuedMessage) + RemoveQueuedMessageLocked(threadId, queuedMessageId); + var confirmed = BuildLiveMetaLocked( + threadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + return new( + true, + !removeQueuedMessage && + ReconcileQueuedMessageEchoLocked( + threadId, + queuedMessageId, + confirmed) + ? BuildSnapshotLocked(context) + : null); + } + } + + internal ChatLocalEchoTransition ReconcileExistingLocalQueuedUser( + ChatMessageInfo message, + string userText, + ChatProjectionContext context) + { + lock (_gate) + { + var metadata = BuildLiveMetaLocked( + message.SessionKey!, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + var reconciled = TryReconcileExistingLocalQueuedUserEchoLocked( + message.SessionKey!, + userText, + metadata); + return new( + reconciled, + reconciled ? BuildSnapshotLocked(context) : null); + } + } + + internal (ChatEntryMetadata Metadata, string? ActiveRunId) BuildMetadataWithRun( + ChatMessageInfo message) + { + lock (_gate) + { + var metadata = BuildLiveMetaLocked( + message.SessionKey!, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + _lifecycle.TryGetActiveRun(message.SessionKey!, out var runId); + return (metadata, runId); + } + } + + internal ChatAssistantPreparation PrepareAssistant( + ChatMessageInfo message, + string assistantText, + ChatProjectionContext context) + { + var threadId = message.SessionKey!; + lock (_gate) + { + var disposition = ClassifyAssistantQueueFrameLocked( + threadId, + assistantText, + message.OpenClawId, + message.OpenClawSeq); + ChatDataSnapshot? promotionSnapshot = null; + if (disposition == AssistantQueueFrameDisposition.Render && + _queue.IsLocallyInitiated(threadId) && + _queue.TryGetSingleSendingMessage(threadId, out var queued) && + !_lifecycle.HasActiveRun(threadId) && + !_queue.IsAssistantFallbackPromoted(threadId) && + PromoteQueuedMessageLocked(threadId, queued.Id)) + { + promotionSnapshot = BuildSnapshotLocked(context); + } + + var metadata = BuildLiveMetaLocked( + threadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + var hasUsage = message.InputTokens is not null || + message.OutputTokens is not null || + message.ResponseTokens is not null || + message.ContextPercent is not null; + if (hasUsage) + { + var contextTokens = _presentation.ContextTokensForThread(threadId); + metadata = metadata with + { + InputTokens = message.InputTokens ?? metadata.InputTokens, + OutputTokens = message.OutputTokens ?? metadata.OutputTokens, + ResponseTokens = message.ResponseTokens ?? metadata.ResponseTokens, + ContextPercent = message.ContextPercent ?? metadata.ContextPercent, + ContextTokens = contextTokens is > 0 + ? contextTokens + : metadata.ContextTokens, + }; + } + _lifecycle.TryGetActiveRun(threadId, out var activeRunId); + return new(disposition, promotionSnapshot, metadata, activeRunId); + } + } + + internal string? CompleteAssistantFinal(string threadId) + { + lock (_gate) + { + var completedRunId = _lifecycle.CompleteAssistantFinal(threadId); + _reset.CompleteRun(threadId, completedRunId); + if (!_queue.HasSendingMessages(threadId)) + _queue.ClearLocallyInitiated(threadId); + return completedRunId; + } + } + + internal ChatAgentEventTransition ProcessAgentEvent( + AgentEventInfo evt, + string threadId, + ChatProjectionContext context) + { + lock (_gate) + { + var gate = GateAgentEventLocked(evt, threadId); + if (!gate.Process) + { + return new( + Process: false, + gate.ReloadHistory, + gate.DroppedTerminalReason, + DeferredAbortRunId: null, + DeferredAbortCount: 0, + CompletedRunId: null, + CompletionPhase: null, + FetchRemoteUser: false, + AllowRemoteTurn: false, + WasAborted: false, + Suppressed: false, + MappedEvent: null, + ToolMetadata: null, + Snapshots: [], + gate.OpenedLifecycle, + CurrentRuntimeGenerationLocked(threadId)); + } + + var run = UpdateRunTrackingLocked(evt, threadId, context); + var snapshots = new List(); + if (run.Snapshot is not null) + snapshots.Add(run.Snapshot); + + var suppressed = _lifecycle.ShouldSuppress(threadId, evt.RunId); + ChatEvent? mapped = null; + ChatToolMetadataWrite? toolMetadata = null; + if (!suppressed) + { + var mapping = ChatEventMapper.Map(evt); + mapped = mapping.Event; + if (mapping.Approval is { } approval && + !_approval.MarkSeen(approval.RequestId, approval.AlternateId)) + { + mapped = null; + } + + if (mapped is not null) + { + ApplyEventLocked( + threadId, + ChatContentFormatting.TruncateChatEvent(mapped), + BuildLiveMetaLocked( + threadId, + evt.Ts > 0 ? (long)evt.Ts : 0)); + toolMetadata = BuildToolMetadataWriteLocked( + threadId, + mapped, + evt.Ts > 0 ? (long)evt.Ts : 0); + snapshots.Add(BuildSnapshotLocked(context)); + } + else if (TryResolveTerminalApprovalLocked(evt, threadId)) + { + snapshots.Add(BuildSnapshotLocked(context)); + } + } + + return new( + Process: true, + ReloadHistory: false, + run.DroppedTerminalReason, + run.DeferredAbortRunId, + run.DeferredAbortCount, + run.CompletedRunId, + run.CompletionPhase, + run.FetchRemoteUser, + run.AllowRemoteTurn, + run.WasAborted, + suppressed, + mapped, + toolMetadata, + snapshots.ToArray(), + gate.OpenedLifecycle, + CurrentRuntimeGenerationLocked(threadId)); + } + } + + private ChatToolMetadataWrite? BuildToolMetadataWriteLocked( + string threadId, + ChatEvent mapped, + long timestampMs) + { + string toolName; + string label; + string? toolCallId; + System.Text.Json.Nodes.JsonObject? toolArgs; + ChatToolIdentityStrength identityStrength; + string? runId; + switch (mapped) + { + case ChatToolStartEvent start + when !string.IsNullOrWhiteSpace(start.ToolName): + toolName = start.ToolName; + label = start.Text; + toolCallId = start.ToolCallId; + toolArgs = start.ToolArgs; + identityStrength = start.IdentityStrength; + runId = start.RunId; + break; + case ChatToolPresentationEvent presentation: + toolName = presentation.ToolName; + label = NativeToolProjector.FirstToolDisplayValue( + presentation.ToolArgs); + toolCallId = presentation.ParentToolCallId; + toolArgs = presentation.ToolArgs; + identityStrength = presentation.IdentityStrength; + runId = presentation.RunId; + break; + default: + return null; + } + + var legacyTurn = ResolveToolCacheLegacyTurnLocked( + threadId, + mapped, + runId, + toolCallId); + return new ChatToolMetadataWrite( + threadId, + _history.ResolveSessionId(threadId) ?? threadId, + GetResetVersionLocked(threadId), + timestampMs, + toolName, + label, + toolCallId, + toolArgs, + identityStrength, + runId, + legacyTurn); + } + + private long ResolveToolCacheLegacyTurnLocked( + string threadId, + ChatEvent mapped, + string? runId, + string? toolCallId) + { + if (!string.IsNullOrWhiteSpace(runId)) + return 0; + if (!_timelines.TryGetValue(threadId, out var timeline)) + return ChatTimelineState.Initial().ToolLegacyTurn; + if (string.IsNullOrWhiteSpace(toolCallId)) + return timeline.ToolLegacyTurn; + + if (mapped is ChatToolPresentationEvent) + { + var pendingKey = timeline.PendingToolPresentations?.Keys + .Where(key => key.RunId is null && + string.Equals( + key.ToolCallId, + toolCallId, + StringComparison.Ordinal)) + .OrderByDescending(key => key.LegacyTurn) + .FirstOrDefault(); + if (pendingKey is { ToolCallId.Length: > 0 }) + return pendingKey.Value.LegacyTurn; + } + + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.ToolCall || + entry.ToolRunId is not null) + { + continue; + } + if (string.Equals( + entry.ToolCallId, + toolCallId, + StringComparison.Ordinal) || + entry.ToolCorrelationIds?.Contains(toolCallId) == true) + { + return entry.ToolLegacyTurn; + } + } + return timeline.ToolLegacyTurn; + } + + private ChatAgentEventGate GateAgentEventLocked( + AgentEventInfo evt, + string threadId) + { + var resetGate = _reset.EvaluateAgentEvent(evt, threadId); + ChatOpenedLifecycleTransition? openedLifecycle = null; + if (resetGate.OpenedLifecycleStart is { } openedStart && + ChatEventMapper.IsLifecycleStart(openedStart)) + { + ApplyOpenedResetLifecycleStartLocked( + threadId, + openedStart); + } + else + { + openedLifecycle = ApplyBufferedLifecycleOpenLocked( + threadId, + resetGate.OpenedLifecycleStart, + allowRemoteTurn: false); + } + if (resetGate.Drop) + { + return new( + false, + resetGate.ReloadHistory, + null, + openedLifecycle); + } + if (ShouldDropTerminalAgentEventLocked( + evt, + threadId, + out var droppedReason)) + { + return new( + false, + false, + droppedReason, + openedLifecycle); + } + return new( + true, + false, + null, + openedLifecycle); + } + + private ChatRunTransition UpdateRunTrackingLocked( + AgentEventInfo evt, + string threadId, + ChatProjectionContext context) + { + string? deferredAbortRunId = null; + var deferredAbortCount = 0; + ChatTerminalEventDropReason? droppedReason = null; + string? completionPhase = null; + var fetchRemoteUser = false; + var allowRemoteTurn = false; + var wasAborted = false; + ChatDataSnapshot? snapshot = null; + + if (string.Equals( + evt.Stream, + "lifecycle", + StringComparison.OrdinalIgnoreCase) && + evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object && + evt.Data.TryGetProperty("phase", out var phaseProperty)) + { + var phase = phaseProperty.GetString()?.ToLowerInvariant(); + if (phase == "start") + { + allowRemoteTurn = + !_queue.IsLocallyInitiated(threadId) && + !_lifecycle.IsThreadSuppressed(threadId) && + !_lifecycle.HasPendingAbort(threadId); + if (!string.IsNullOrEmpty(evt.RunId)) + { + _lifecycle.StartRun(threadId, evt.RunId); + fetchRemoteUser = !_queue.IsLocallyInitiated(threadId); + var pendingCount = + _lifecycle.TakePendingAbortCount(threadId); + if (pendingCount > 0) + { + _lifecycle.MarkDeferredAbort( + threadId, + evt.RunId); + deferredAbortRunId = evt.RunId; + deferredAbortCount = pendingCount; + } + } + if (TryPromoteQueuedMessageOnLocalTurnStartLocked(evt, threadId)) + snapshot = BuildSnapshotLocked(context); + } + else if (phase is "end" or "error") + { + completionPhase = phase; + wasAborted = _lifecycle.IsRunAborted(evt.RunId); + _reset.CompleteRun(threadId, evt.RunId); + _lifecycle.RemoveAbortedRun(evt.RunId); + _lifecycle.RemoveActiveRun(threadId); + _lifecycle.ClearThreadSuppression(threadId); + _queue.RemoveRunMappingByRunId(threadId, evt.RunId); + if (!_queue.HasPendingMessages(threadId)) + _queue.ClearLocallyInitiated(threadId); + var pendingCount = + _lifecycle.TakePendingAbortCount(threadId); + if (pendingCount > 0) + { + deferredAbortRunId = evt.RunId; + deferredAbortCount = pendingCount; + } + } + } + else if (string.Equals( + evt.Stream, + "job", + StringComparison.OrdinalIgnoreCase) && + evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object && + evt.Data.TryGetProperty("state", out var stateProperty)) + { + var phase = stateProperty.GetString()?.ToLowerInvariant(); + if (phase is "done" or "error") + { + completionPhase = phase == "done" ? "end" : "error"; + wasAborted = _lifecycle.IsRunAborted(evt.RunId); + _reset.CompleteRun(threadId, evt.RunId); + if (!string.IsNullOrWhiteSpace(evt.RunId)) + { + _lifecycle.RemoveAbortedRun(evt.RunId); + _queue.RemoveRunMappingByRunId(threadId, evt.RunId); + } + _lifecycle.RemoveActiveRun(threadId); + } + } + + return new( + deferredAbortRunId, + deferredAbortCount, + droppedReason, + evt.RunId, + completionPhase, + fetchRemoteUser, + allowRemoteTurn, + wasAborted, + snapshot); + } + + private bool TryResolveTerminalApprovalLocked( + AgentEventInfo evt, + string threadId) + { + var terminal = ChatEventMapper.MapTerminalApproval(evt); + if (terminal is null) + return false; + var timeline = GetOrCreateTimelineLocked(threadId); + var pendingId = timeline.PendingPermission?.RequestId; + if (pendingId is null || + !_approval.Matches( + pendingId, + terminal.ApprovalSlug, + terminal.ApprovalId)) + { + return false; + } + _timelines[threadId] = ChatTimelineReducer.ResolvePermission( + timeline, + pendingId, + ChatEventMapper.MapTerminalApprovalDecision( + terminal.Phase, + terminal.Decision)); + return true; + } + + internal void CompleteRemoteBackfill(string threadId) + { + lock (_gate) + _reset.CompleteRemoteBackfill(threadId); + } + + internal ChatRemoteUserBackfillTransition? ApplyRemoteUserBackfill( + string threadId, + ChatMessageInfo message, + long expectedResetGeneration, + bool openResetGate, + ChatProjectionContext context) + { + lock (_gate) + { + if (GetResetVersionLocked(threadId) != expectedResetGeneration || + _reset.IsPreResetTimestamp(threadId, message.Ts)) + { + return null; + } + if (_timelines.TryGetValue(threadId, out var timeline)) + { + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + if (timeline.Entries[i].Kind != ChatTimelineItemKind.User) + continue; + if (timeline.Entries[i].Text == message.Text) + return null; + break; + } + } + var openedLifecycle = openResetGate + ? ApplyBufferedLifecycleOpenLocked( + threadId, + _reset.RecordRemoteUser(threadId), + allowRemoteTurn: true) + : null; + ApplyEventLocked( + threadId, + new ChatUserMessageEvent( + ChatContentFormatting.TruncateForChatEntry(message.Text)), + BuildLiveMetaLocked( + threadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq)); + return new( + BuildSnapshotLocked(context), + openedLifecycle, + CurrentRuntimeGenerationLocked(threadId)); + } + } + + private bool TryPromoteQueuedMessageOnLocalTurnStartLocked( + AgentEventInfo evt, + string threadId) + { + if (!_queue.IsLocallyInitiated(threadId)) + return false; + if (!string.IsNullOrEmpty(evt.RunId) && + _queue.TryResolveMessageForRun( + threadId, + evt.RunId, + out var messageId)) + { + return PromoteQueuedMessageLocked(threadId, messageId); + } + return string.IsNullOrEmpty(evt.RunId) && + _queue.TryGetSingleSendingMessage(threadId, out var queued) && + PromoteQueuedMessageLocked(threadId, queued.Id); + } + + private bool ShouldDropTerminalAgentEventLocked( + AgentEventInfo evt, + string threadId, + out ChatTerminalEventDropReason? droppedReason) + { + droppedReason = null; + if (!TryGetTerminalAgentRunId(evt, out var runId)) + return false; + if (string.IsNullOrWhiteSpace(runId)) + { + droppedReason = ChatTerminalEventDropReason.MissingRunId; + return true; + } + return _lifecycle.ShouldDropTerminal( + threadId, + runId, + _queue.RunIdsForThread(threadId), + _timelines.TryGetValue(threadId, out var timeline) && + timeline.TurnActive, + out droppedReason); + } + + private static bool TryGetTerminalAgentRunId( + AgentEventInfo evt, + out string runId) + { + runId = evt.RunId ?? string.Empty; + if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) + return false; + if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) && + evt.Data.TryGetProperty("phase", out var phase)) + { + var value = phase.GetString(); + return string.Equals(value, "end", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "error", StringComparison.OrdinalIgnoreCase); + } + if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) && + evt.Data.TryGetProperty("state", out var state)) + { + var value = state.GetString(); + return string.Equals(value, "done", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "error", StringComparison.OrdinalIgnoreCase); + } + return false; + } + + internal ChatDataSnapshot? SnapshotLatestAssistantUsage( + string threadId, + ChatProjectionContext context) + { + lock (_gate) + { + var session = _presentation.ResolveSessionForThread( + threadId, + context.MainSessionKey); + return session is not null && + SnapshotLatestAssistantUsageLocked(session, threadId) + ? BuildSnapshotLocked(context) + : null; + } + } + + internal ChatDataSnapshot? SnapshotAssistantUsageContribution( + string threadId, + ChatEntryMetadata metadata, + ChatProjectionContext context) + { + lock (_gate) + { + return SnapshotAssistantUsageContributionLocked(threadId, metadata) + ? BuildSnapshotLocked(context) + : null; + } + } + + private bool SnapshotAssistantUsageContributionLocked( + string threadId, + ChatEntryMetadata metadata) + { + var currentUsage = UsageValue(metadata); + if (currentUsage is null || currentUsage <= 0 || + !_timelines.TryGetValue(threadId, out var timeline)) + { + return false; + } + var contextTokens = metadata.ContextTokens; + if (contextTokens is null || contextTokens <= 0) + contextTokens = _presentation.ContextTokensForThread(threadId); + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.Assistant) + continue; + var threadMetadata = GetOrCreateThreadMetaLocked(threadId); + threadMetadata.TryGetValue(entry.Id, out var existing); + var previousUsage = LatestAssistantUsageBeforeLocked( + timeline, + threadMetadata, + i); + var cumulative = Math.Max( + (previousUsage ?? 0) + currentUsage.Value, + existing?.ResponseTokens ?? 0); + if (existing?.ResponseTokens == cumulative && + existing.UsageContributionTokens == currentUsage && + existing.ContextTokens == contextTokens) + { + return false; + } + threadMetadata[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with + { + InputTokens = metadata.InputTokens ?? existing?.InputTokens, + OutputTokens = metadata.OutputTokens ?? existing?.OutputTokens, + ResponseTokens = cumulative, + ContextPercent = metadata.ContextPercent ?? existing?.ContextPercent, + ContextTokens = contextTokens ?? existing?.ContextTokens, + UsageContributionTokens = currentUsage, + }; + return true; + } + return false; + } + + private static int? LatestAssistantUsageBeforeLocked( + ChatTimelineState timeline, + IReadOnlyDictionary metadata, + int beforeIndex) + { + for (var i = beforeIndex - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.Assistant || + !metadata.TryGetValue(entry.Id, out var entryMetadata)) + { + continue; + } + var value = UsageValue(entryMetadata); + if (value is > 0) + return value; + } + return null; + } + + private static int? UsageValue(ChatEntryMetadata metadata) => + metadata.ResponseTokens ?? + (metadata.InputTokens is { } input && + metadata.OutputTokens is { } output + ? input + output + : null); + + private bool TryReconcileExistingLocalQueuedUserEchoLocked( + string threadId, + string text, + ChatEntryMetadata confirmed) + { + if (!HasGatewayIdentity(confirmed) || + !_timelines.TryGetValue(threadId, out var timeline) || + !_entryMeta.TryGetValue(threadId, out var metadata)) + { + return false; + } + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.User || + !string.Equals(entry.Text, text, StringComparison.Ordinal) || + !metadata.TryGetValue(entry.Id, out var existing) || + !existing.IsLocalQueuedSend || + !IsFreshLocalQueuedPromotion(existing, confirmed)) + { + continue; + } + metadata[entry.Id] = confirmed with + { + IsLocalQueuedSend = false, + LocalQueuedMessageId = existing.LocalQueuedMessageId, + }; + return true; + } + return false; + } + + private static bool IsFreshLocalQueuedPromotion( + ChatEntryMetadata existing, + ChatEntryMetadata confirmed) + { + if (existing.Timestamp is not { } existingTimestamp) + return false; + return confirmed.Timestamp is { } confirmedTimestamp + ? (confirmedTimestamp - existingTimestamp).Duration() <= + LocalEchoSuppressionWindow + : DateTimeOffset.Now - existingTimestamp <= LocalEchoSuppressionWindow; + } + + private bool ReconcileQueuedMessageEchoLocked( + string threadId, + string messageId, + ChatEntryMetadata confirmed) + { + if (PromoteQueuedMessageLocked(threadId, messageId, confirmed)) + return true; + if (!HasGatewayIdentity(confirmed) || + !_entryMeta.TryGetValue(threadId, out var metadata)) + { + return false; + } + var match = metadata.FirstOrDefault(pair => + string.Equals( + pair.Value.LocalQueuedMessageId, + messageId, + StringComparison.Ordinal)); + if (string.IsNullOrEmpty(match.Key)) + return false; + metadata[match.Key] = confirmed with + { + IsLocalQueuedSend = false, + LocalQueuedMessageId = messageId, + }; + return true; + } + + private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( + string threadId, + string assistantText, + string? gatewayMessageId, + int? openClawSeq) + { + if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) && + IsIdentifiedCompletedAssistantDuplicateLocked( + threadId, + assistantText, + gatewayMessageId, + openClawSeq)) + { + return AssistantQueueFrameDisposition.Drop; + } + if (string.IsNullOrEmpty(gatewayMessageId) && + openClawSeq is null && + IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked( + threadId, + assistantText)) + { + return AssistantQueueFrameDisposition.Drop; + } + if (!_queue.IsLocallyInitiated(threadId) || + !_queue.TryGetSingleSendingMessage(threadId, out _) || + _lifecycle.HasActiveRun(threadId) || + _queue.IsAssistantFallbackPromoted(threadId) || + !_timelines.TryGetValue(threadId, out var timeline)) + { + return AssistantQueueFrameDisposition.Render; + } + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.Assistant) + continue; + if (entry.IsStreaming || + !string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) + { + return AssistantQueueFrameDisposition.Render; + } + if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null) + return AssistantQueueFrameDisposition.Drop; + if (!_entryMeta.TryGetValue(threadId, out var metadata) || + !metadata.TryGetValue(entry.Id, out var existing)) + { + return AssistantQueueFrameDisposition.Render; + } + var sameIdentity = + !string.IsNullOrEmpty(gatewayMessageId) && + string.Equals( + existing.GatewayMessageId, + gatewayMessageId, + StringComparison.Ordinal) || + openClawSeq is not null && existing.OpenClawSeq == openClawSeq; + return sameIdentity + ? AssistantQueueFrameDisposition.Drop + : AssistantQueueFrameDisposition.Render; + } + return AssistantQueueFrameDisposition.Render; + } + + private bool IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked( + string threadId, + string assistantText) + { + if (!_queue.IsLocallyInitiated(threadId) || + _lifecycle.HasActiveRun(threadId) || + !_timelines.TryGetValue(threadId, out var timeline) || + !_entryMeta.TryGetValue(threadId, out var metadata)) + { + return false; + } + var sawBoundary = false; + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (!sawBoundary) + { + if (entry.Kind == ChatTimelineItemKind.Assistant) + return false; + if (entry.Kind == ChatTimelineItemKind.User && + metadata.TryGetValue(entry.Id, out var entryMetadata) && + entryMetadata.IsLocalQueuedSend) + { + sawBoundary = true; + } + continue; + } + if (entry.Kind == ChatTimelineItemKind.Assistant) + { + return !entry.IsStreaming && + string.Equals( + entry.Text, + assistantText, + StringComparison.Ordinal); + } + if (entry.Kind == ChatTimelineItemKind.User) + return false; + } + return false; + } + + private bool IsIdentifiedCompletedAssistantDuplicateLocked( + string threadId, + string assistantText, + string? gatewayMessageId, + int? openClawSeq) + { + if (!_timelines.TryGetValue(threadId, out var timeline) || + !_entryMeta.TryGetValue(threadId, out var metadata)) + { + return false; + } + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.Assistant || + entry.IsStreaming || + !metadata.TryGetValue(entry.Id, out var existing)) + { + continue; + } + var bothHaveIds = !string.IsNullOrEmpty(gatewayMessageId) && + !string.IsNullOrEmpty(existing.GatewayMessageId); + if (bothHaveIds && + string.Equals( + existing.GatewayMessageId, + gatewayMessageId, + StringComparison.Ordinal)) + { + return true; + } + if (!bothHaveIds && + openClawSeq is not null && + existing.OpenClawSeq == openClawSeq && + string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) + { + if (!string.IsNullOrEmpty(gatewayMessageId) && + string.IsNullOrEmpty(existing.GatewayMessageId)) + { + metadata[entry.Id] = existing with + { + GatewayMessageId = gatewayMessageId, + }; + } + return true; + } + } + return false; + } + + private void ApplyEventLocked( + string threadId, + ChatEvent evt, + ChatEntryMetadata? metadata) + { + var current = GetOrCreateTimelineLocked(threadId); + var beforeIds = current.Entries.Select(entry => entry.Id) + .ToHashSet(StringComparer.Ordinal); + var next = ChatTimelineReducer.Apply(current, evt); + _timelines[threadId] = next; + if (metadata is null) + return; + var threadMetadata = GetOrCreateThreadMetaLocked(threadId); + foreach (var entry in next.Entries) + { + if (!beforeIds.Contains(entry.Id) && !threadMetadata.ContainsKey(entry.Id)) + threadMetadata[entry.Id] = metadata; + } + } + + private Dictionary GetOrCreateThreadMetaLocked( + string threadId) + { + if (!_entryMeta.TryGetValue(threadId, out var metadata)) + { + metadata = new Dictionary(StringComparer.Ordinal); + _entryMeta[threadId] = metadata; + } + return metadata; + } + + private ChatEntryMetadata BuildLiveMetaLocked( + string threadId, + long? tsMs = null, + string? gatewayMessageId = null, + int? openClawSeq = null, + bool isLocalQueuedSend = false, + string? localQueuedMessageId = null, + string? openClawKind = null, + long? compactionTokensBefore = null, + long? compactionTokensAfter = null) + { + var timestamp = tsMs is { } value && value > 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(value).ToLocalTime() + : DateTimeOffset.Now; + return new ChatEntryMetadata( + timestamp, + _presentation.ModelForThread(threadId), + GatewayMessageId: gatewayMessageId, + OpenClawSeq: openClawSeq, + OpenClawKind: openClawKind, + CompactionTokensBefore: compactionTokensBefore, + CompactionTokensAfter: compactionTokensAfter, + IsLocalQueuedSend: isLocalQueuedSend, + LocalQueuedMessageId: localQueuedMessageId); + } + + private ChatOpenedLifecycleTransition? AddResetAcceptedRunIdLocked( + string threadId, + string runId) + { + return ApplyBufferedLifecycleOpenLocked( + threadId, + _reset.AddAcceptedRun(threadId, runId), + allowRemoteTurn: false); + } + + private ChatOpenedLifecycleTransition? ApplyBufferedLifecycleOpenLocked( + string threadId, + AgentEventInfo? lifecycleStart, + bool allowRemoteTurn) + { + if (string.IsNullOrEmpty(lifecycleStart?.RunId)) + return null; + + _lifecycle.StartRun(threadId, lifecycleStart.RunId); + var deferredAbortCount = + _lifecycle.TakePendingAbortCount(threadId); + string? deferredAbortRunId = null; + if (deferredAbortCount > 0) + { + deferredAbortRunId = lifecycleStart.RunId; + _lifecycle.MarkDeferredAbort( + threadId, + deferredAbortRunId); + } + return new( + lifecycleStart, + allowRemoteTurn && deferredAbortCount == 0, + deferredAbortRunId, + deferredAbortCount); + } + + private void ApplyOpenedResetLifecycleStartLocked( + string threadId, + AgentEventInfo? lifecycleStart) + { + if (!string.IsNullOrEmpty(lifecycleStart?.RunId)) + _lifecycle.StartRun(threadId, lifecycleStart.RunId); + } + + private ChatDataSnapshot BuildSnapshotLocked(ChatProjectionContext context) => + ChatSnapshotProjector.Project(CaptureProjectionInputLocked(context)); + + private ChatSnapshotProjectionInput CaptureProjectionInputLocked( + ChatProjectionContext context) => + _presentation.CaptureProjectionInput( + timelines: new Dictionary(_timelines), + timelineGenerations: _reset.SnapshotVersions(), + historyRevisions: _history.SnapshotRevisions(), + queuedMessages: _queue.SnapshotMessages(), + status: _status, + context); + + private ChatTimelineState GetOrCreateTimelineLocked(string threadId) + { + if (!_timelines.TryGetValue(threadId, out var current)) + { + current = ChatTimelineState.Initial(); + _timelines[threadId] = current; + } + return current; + } + + private void EnsureTimelinesForSessionsLocked() + { + foreach (var session in _presentation.SessionSnapshot()) + { + if (!string.IsNullOrEmpty(session.Key) && + !_timelines.ContainsKey(session.Key)) + { + _timelines[session.Key] = ChatTimelineState.Initial(); + } + } + } + + private long GetResetVersionLocked(string threadId) => + _reset.GetVersion(threadId); + + internal bool IsRuntimeGenerationCurrent( + string threadId, + ChatRuntimeGeneration generation) + { + lock (_gate) + { + return !_disposed && + _history.ConnectionGeneration == generation.ConnectionGeneration && + GetResetVersionLocked(threadId) == generation.ResetGeneration; + } + } + + private ChatRuntimeGeneration CurrentRuntimeGenerationLocked(string threadId) => + new( + _history.ConnectionGeneration, + GetResetVersionLocked(threadId)); +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs new file mode 100644 index 000000000..86d8786b3 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs @@ -0,0 +1,416 @@ +using System.Text.Json; +using OpenClaw.Chat; +using OpenClaw.Shared; + +namespace OpenClawTray.Chat; + +internal sealed record ChatApprovalIdentity(string RequestId, string? AlternateId); +internal sealed record ChatEventMapping(ChatEvent? Event, ChatApprovalIdentity? Approval = null); +internal sealed record ChatTerminalApprovalMapping( + string Phase, + string ApprovalId, + string ApprovalSlug, + string Decision); + +internal sealed record ChatFlattenedToolEvents( + ChatToolStartEvent Start, + ChatToolOutputEvent Output); + +internal static class ChatEventMapper +{ + internal static ChatEventMapping Map(AgentEventInfo evt) + { + var stream = evt.Stream?.ToLowerInvariant(); + if (string.IsNullOrEmpty(stream)) + return new(null); + + return stream switch + { + "assistant" => new(MapAssistant(evt)), + "reasoning" => new(MapReasoning(evt)), + "lifecycle" => new(MapLifecycle(evt)), + "tool" => new(MapTool(evt)), + "item" => new(MapItem(evt)), + "command_output" => new(MapCommandOutput(evt)), + "job" => new(MapJob(evt)), + "approval" => MapApproval(evt), + _ => new(null), + }; + } + + internal static bool IsLifecycleStart(AgentEventInfo evt) => + string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) && + evt.Data.ValueKind == JsonValueKind.Object && + evt.Data.TryGetProperty("phase", out var phase) && + string.Equals(phase.GetString(), "start", StringComparison.OrdinalIgnoreCase); + + internal static bool IsTerminalRunEvent(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object) + return false; + if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) && + evt.Data.TryGetProperty("phase", out var phase)) + { + var value = phase.GetString(); + return string.Equals(value, "end", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "error", StringComparison.OrdinalIgnoreCase); + } + if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) && + evt.Data.TryGetProperty("state", out var state)) + { + var value = state.GetString(); + return string.Equals(value, "done", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "error", StringComparison.OrdinalIgnoreCase); + } + return false; + } + + internal static ChatResponseOutputKind? ClassifyInboundOutput( + AgentEventInfo evt, + ChatEvent mapped) + { + if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) || + string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return mapped switch + { + ChatMessageEvent or ChatMessageDeltaEvent => ChatResponseOutputKind.Assistant, + ChatThinkingEvent or ChatReasoningEvent or ChatReasoningDeltaEvent or + ChatIntentEvent => ChatResponseOutputKind.Reasoning, + ChatToolStartEvent or ChatToolPresentationEvent or + ChatToolOutputEvent or ChatToolErrorEvent or + ChatPermissionRequestEvent => ChatResponseOutputKind.Tool, + ChatStatusEvent or ChatErrorEvent or ChatReasoningEndEvent or + ChatTurnEndEvent or ChatUserMessageEvent => null, + _ => null, + }; + } + + internal static bool IsTerminalApprovalPhase(string phase) + { + if (string.IsNullOrEmpty(phase)) + return false; + + return string.Equals(phase, "resolved", StringComparison.OrdinalIgnoreCase) + || string.Equals(phase, "denied", StringComparison.OrdinalIgnoreCase) + || string.Equals(phase, "aborted", StringComparison.OrdinalIgnoreCase) + || string.Equals(phase, "canceled", StringComparison.OrdinalIgnoreCase) + || string.Equals(phase, "cancelled", StringComparison.OrdinalIgnoreCase) + || string.Equals(phase, "expired", StringComparison.OrdinalIgnoreCase) + || string.Equals(phase, "timeout", StringComparison.OrdinalIgnoreCase) + || string.Equals(phase, "error", StringComparison.OrdinalIgnoreCase); + } + + internal static ChatTerminalApprovalMapping? MapTerminalApproval( + AgentEventInfo evt) + { + if (!string.Equals(evt.Stream, "approval", StringComparison.OrdinalIgnoreCase) || + evt.Data.ValueKind != JsonValueKind.Object) + { + return null; + } + var phase = StringProperty(evt.Data, "phase"); + return IsTerminalApprovalPhase(phase) + ? new ChatTerminalApprovalMapping( + phase, + StringProperty(evt.Data, "approvalId"), + StringProperty(evt.Data, "approvalSlug"), + StringProperty(evt.Data, "decision")) + : null; + } + + internal static ChatPermissionDecision MapTerminalApprovalDecision( + string phase, + string? decision = null) + { + if (string.Equals(phase, "resolved", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals( + decision, + ChatPermissionActionKeys.AllowAlways, + StringComparison.OrdinalIgnoreCase)) + { + return ChatPermissionDecision.AllowedAlways; + } + if (string.Equals( + decision, + ChatPermissionActionKeys.Deny, + StringComparison.OrdinalIgnoreCase)) + { + return ChatPermissionDecision.Denied; + } + return ChatPermissionDecision.Allowed; + } + + return string.Equals(phase, "denied", StringComparison.OrdinalIgnoreCase) + ? ChatPermissionDecision.Denied + : ChatPermissionDecision.Expired; + } + + internal static ChatFlattenedToolEvents MapFlattenedToolOutput( + string text, + string? runId) + { + var kind = NativeToolProjector.ClassifyFlattenedToolOutput(text); + var label = NativeToolProjector.ExtractFlattenedToolSummary(text); + return new( + new ChatToolStartEvent( + label, + kind, + IdentityStrength: + NativeToolProjector.ClassifyHistoryIdentityStrength(kind), + RunId: runId), + new ChatToolOutputEvent(text, RunId: runId)); + } + + private static ChatEvent? MapAssistant(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object) + return null; + if (evt.Data.TryGetProperty("delta", out var deltaProperty) && + deltaProperty.ValueKind == JsonValueKind.String && + deltaProperty.GetString() is { Length: > 0 } delta) + { + return new ChatMessageDeltaEvent(delta); + } + return null; + } + + private static ChatEvent? MapReasoning(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object) + return null; + if (evt.Data.TryGetProperty("delta", out var deltaProperty) && + deltaProperty.ValueKind == JsonValueKind.String && + deltaProperty.GetString() is { Length: > 0 } delta) + { + return new ChatReasoningDeltaEvent(delta); + } + + var content = evt.Data.TryGetProperty("content", out var contentProperty) && + contentProperty.ValueKind == JsonValueKind.String + ? contentProperty.GetString() + : evt.Data.TryGetProperty("text", out var textProperty) && + textProperty.ValueKind == JsonValueKind.String + ? textProperty.GetString() + : null; + return string.IsNullOrEmpty(content) ? null : new ChatReasoningEvent(content); + } + + private static ChatEvent? MapLifecycle(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object || + !evt.Data.TryGetProperty("phase", out var phaseProperty)) + { + return null; + } + + return phaseProperty.GetString()?.ToLowerInvariant() switch + { + "start" => new ChatThinkingEvent(""), + "end" => new ChatTurnEndEvent(), + "error" => new ChatErrorEvent( + evt.Summary ?? + (evt.Data.TryGetProperty("message", out var message) + ? message.GetString() ?? "Agent error" + : "Agent error")), + _ => null, + }; + } + + private static ChatEvent? MapTool(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object) + return null; + + var phase = NativeToolProjector.GetStringProperty(evt.Data, "phase"); + var identity = NativeToolProjector.ExtractToolIdentity(evt.Data); + var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data); + var label = NativeToolProjector.ExtractToolLabel(evt.Data, toolArgs); + string? toolCallId = NativeToolProjector.GetStringProperty( + evt.Data, + "itemId", + "callId"); + if (string.IsNullOrWhiteSpace(toolCallId)) + toolCallId = null; + + return phase.ToLowerInvariant() switch + { + "start" => new ChatToolStartEvent( + label, + identity.Name, + ToolArgs: toolArgs, + ToolCallId: toolCallId, + IdentityStrength: identity.Strength, + RunId: evt.RunId), + "result" => new ChatToolOutputEvent( + NativeToolProjector.ExtractToolResultText(evt.Data, label), + ToolCallId: toolCallId, + RunId: evt.RunId), + "error" => new ChatToolErrorEvent( + NativeToolProjector.ExtractToolErrorText(evt.Data, label), + ToolCallId: toolCallId, + RunId: evt.RunId), + _ => null, + }; + } + + private static ChatEvent? MapItem(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object) + return null; + + var kind = NativeToolProjector.GetStringProperty(evt.Data, "kind"); + var phase = NativeToolProjector.GetStringProperty(evt.Data, "phase"); + if (string.Equals(kind, "reasoning", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase) + ? new ChatReasoningEndEvent() + : null; + } + + if (string.Equals(kind, "command", StringComparison.OrdinalIgnoreCase)) + { + var normalizedPhase = phase.ToLowerInvariant(); + if (normalizedPhase is not ("start" or "update")) + return null; + + var parentItemId = NativeToolProjector.ExtractParentToolCallId(evt.Data); + if (string.IsNullOrWhiteSpace(parentItemId)) + return null; + + var childIdentity = NativeToolProjector.ExtractToolIdentity(evt.Data); + var commandArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data); + var childItemId = NativeToolProjector.GetStringProperty( + evt.Data, + "itemId", + "commandItemId", + "callId"); + return new ChatToolPresentationEvent( + parentItemId, + childIdentity.Name, + childIdentity.Strength, + commandArgs, + childItemId, + ActivatesTurn: normalizedPhase == "start", + RunId: evt.RunId); + } + + if (!string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase)) + return null; + + var title = NativeToolProjector.GetStringProperty(evt.Data, "title"); + var identity = NativeToolProjector.ExtractToolIdentity(evt.Data); + var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data); + var label = NativeToolProjector.FirstToolDisplayValue(toolArgs); + if (string.IsNullOrWhiteSpace(label)) + label = NativeToolProjector.SanitizeToolDisplayValue(title); + var itemId = NativeToolProjector.GetStringProperty( + evt.Data, + "itemId", + "callId"); + if (string.IsNullOrWhiteSpace(itemId)) + itemId = null; + + return phase.ToLowerInvariant() switch + { + "start" => new ChatToolStartEvent( + label, + identity.Name, + ToolArgs: toolArgs, + ToolCallId: itemId, + IdentityStrength: identity.Strength, + RunId: evt.RunId), + "end" => new ChatToolOutputEvent( + string.Empty, + ToolCallId: itemId, + RunId: evt.RunId), + "error" => new ChatToolErrorEvent( + NativeToolProjector.SanitizeToolDisplayValue(title), + ToolCallId: itemId, + RunId: evt.RunId), + _ => null, + }; + } + + private static ChatEvent? MapCommandOutput(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object || + !string.Equals( + StringProperty(evt.Data, "phase"), + "end", + StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var output = NativeToolProjector.ExtractCommandOutputText(evt.Data); + if (string.IsNullOrEmpty(output)) + return null; + string? itemId = NativeToolProjector.GetStringProperty( + evt.Data, + "parentItemId", + "itemId"); + if (string.IsNullOrWhiteSpace(itemId)) + itemId = null; + return new ChatToolOutputEvent( + output, + ToolCallId: itemId, + RunId: evt.RunId); + } + + private static ChatEvent? MapJob(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object) + return null; + return StringProperty(evt.Data, "state").ToLowerInvariant() switch + { + "done" => new ChatTurnEndEvent(), + "error" => new ChatErrorEvent(evt.Summary ?? "Agent error"), + _ => null, + }; + } + + private static ChatEventMapping MapApproval(AgentEventInfo evt) + { + if (evt.Data.ValueKind != JsonValueKind.Object || + !string.Equals( + StringProperty(evt.Data, "phase"), + "requested", + StringComparison.OrdinalIgnoreCase)) + { + return new(null); + } + + var approvalId = StringProperty(evt.Data, "approvalId"); + var slug = StringProperty(evt.Data, "approvalSlug"); + var requestId = !string.IsNullOrEmpty(slug) ? slug : approvalId; + if (string.IsNullOrEmpty(requestId)) + return new(null); + + var host = StringProperty(evt.Data, "host"); + var command = StringProperty(evt.Data, "command"); + var title = StringProperty(evt.Data, "title"); + var message = StringProperty(evt.Data, "message"); + var detail = string.IsNullOrEmpty(message) + ? command + : string.IsNullOrEmpty(command) ? message : message + "\n\n" + command; + var mapped = new ChatPermissionRequestEvent( + requestId, + !string.IsNullOrEmpty(title) ? title : "Exec approval", + !string.IsNullOrEmpty(host) ? host : "node", + detail, + ChatPermissionActionKeys.ExecApprovalDefaults); + var alternateId = !string.IsNullOrEmpty(slug) ? approvalId : slug; + return new(mapped, new ChatApprovalIdentity(requestId, alternateId)); + } + + private static string StringProperty(JsonElement data, string name) => + data.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() ?? string.Empty + : string.Empty; +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs new file mode 100644 index 000000000..33f754336 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryLoader.cs @@ -0,0 +1,902 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +#if !OPENCLAW_TRAY_TESTS +using OpenClawTray.Helpers; +#endif +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal sealed record ChatHistoryLoadResult( + ChatHistoryCommitToken Token, + bool PublishSnapshot, + ChatProviderNotification? Notification = null); + +/// +/// Owns history request lifetime, in-flight coalescing, cancellation, retries, +/// and immutable transcript rebuild plans. Conversation state alone accepts +/// or rejects plans against its authoritative generation/reset token. +/// +internal sealed class ChatHistoryLoader : IDisposable +{ + private readonly record struct PendingReload( + ChatHistoryCommitToken Token, + bool Replacement); + + private const int MaxRetries = 3; + private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2); + + private readonly object _gate = new(); + private readonly IChatGatewayBridge _bridge; + private readonly ChatConversationState _state; + private readonly ChatMetadataStore _metadata; + private readonly ChatStatePersistence _persistence; + private readonly ChatTelemetryTracker _telemetry; + private readonly Func, Task> _retryScheduler; + private readonly Action? _failureReservedForTesting; + private readonly Dictionary _inFlight = new(StringComparer.Ordinal); + private readonly Dictionary _authoritativePending = + new(StringComparer.Ordinal); + private readonly Dictionary _replacementPending = + new(StringComparer.Ordinal); + private readonly Dictionary _retryCounts = new(); + private CancellationTokenSource _generationCancellation = new(); + private long _appliedStateGeneration; + private long _requestSequence; + private bool _disposed; + + internal ChatHistoryLoader( + IChatGatewayBridge bridge, + ChatConversationState state, + ChatMetadataStore metadata, + ChatStatePersistence persistence, + ChatTelemetryTracker telemetry, + Func, Task>? retryScheduler = null, + Action? failureReservedForTesting = null) + { + _bridge = bridge; + _state = state; + _metadata = metadata; + _persistence = persistence; + _telemetry = telemetry; + _retryScheduler = retryScheduler ?? (static async (delay, token, retry) => + { + await Task.Delay(delay, token).ConfigureAwait(false); + await retry().ConfigureAwait(false); + }); + _failureReservedForTesting = failureReservedForTesting; + } + + internal event EventHandler? Completed; + + internal Task LoadAsync( + string threadId, + bool force = false, + CancellationToken cancellationToken = default, + bool authoritative = false, + ChatHistoryCommitToken? expectedToken = null) => + LoadCoreAsync( + threadId, + force, + cancellationToken, + authoritative, + expectedToken, + replacement: false, + supersedeReplacement: false); + + internal Task LoadReplacementAsync( + string threadId, + ChatHistoryCommitToken token, + CancellationToken cancellationToken = default) + { + lock (_gate) + { + if (_disposed) + return Task.CompletedTask; + _authoritativePending.Remove(threadId); + _replacementPending.Remove(threadId); + foreach (var retryToken in _retryCounts.Keys + .Where(candidate => string.Equals( + candidate.ThreadId, + threadId, + StringComparison.Ordinal)) + .ToArray()) + { + _retryCounts.Remove(retryToken); + } + } + return LoadCoreAsync( + threadId, + force: true, + cancellationToken, + authoritative: false, + expectedToken: token, + replacement: true, + supersedeReplacement: true); + } + + internal ChatStatusTransition ApplyStatusAndAdvanceGeneration( + ConnectionStatus status, + ChatProjectionContext context) + { + CancellationTokenSource? previous = null; + ChatStatusTransition transition; + lock (_gate) + { + transition = _state.ApplyStatus(status, context); + if ((transition.Reconnected || transition.Disconnected) && + transition.HistoryGeneration > _appliedStateGeneration) + { + _appliedStateGeneration = transition.HistoryGeneration; + previous = _generationCancellation; + _generationCancellation = new CancellationTokenSource(); + _inFlight.Clear(); + _authoritativePending.Clear(); + _replacementPending.Clear(); + _retryCounts.Clear(); + _state.ActivateHistoryGeneration(transition.HistoryGeneration); + } + } + previous?.Cancel(); + previous?.Dispose(); + return transition; + } + + internal void ApplyReset(string threadId, long resetGeneration) + { + lock (_gate) + { + RemoveOlderPendingReload( + _authoritativePending, + threadId, + resetGeneration); + RemoveOlderPendingReload( + _replacementPending, + threadId, + resetGeneration); + foreach (var token in _retryCounts.Keys + .Where(candidate => string.Equals( + candidate.ThreadId, + threadId, + StringComparison.Ordinal) && + candidate.ResetGeneration < resetGeneration) + .ToArray()) + { + _retryCounts.Remove(token); + } + } + } + + private static void RemoveOlderPendingReload( + Dictionary pending, + string threadId, + long resetGeneration) + { + if (pending.TryGetValue(threadId, out var token) && + token.ResetGeneration < resetGeneration) + { + pending.Remove(threadId); + } + } + + public void Dispose() + { + CancellationTokenSource cancellation; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + cancellation = _generationCancellation; + _inFlight.Clear(); + _authoritativePending.Clear(); + _replacementPending.Clear(); + _retryCounts.Clear(); + } + cancellation.Cancel(); + cancellation.Dispose(); + Completed = null; + } + + private async Task LoadCoreAsync( + string threadId, + bool force, + CancellationToken cancellationToken, + bool authoritative, + ChatHistoryCommitToken? expectedToken, + bool replacement, + bool supersedeReplacement) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrEmpty(threadId)) + return; + + CancellationToken generationToken; + long requestId; + ChatHistoryCommitToken commitToken; + string? model; + Task? generationActivation; + bool canBegin; + lock (_gate) + { + if (_disposed) + return; + if (expectedToken is { } expected && + !_state.IsHistoryRequestCurrent(expected)) + { + return; + } + if (_inFlight.ContainsKey(threadId)) + { + if (replacement) + { + var replacementToken = expectedToken ?? + _state.CaptureHistoryToken(threadId); + if (supersedeReplacement) + _replacementPending[threadId] = replacementToken; + else + _replacementPending.TryAdd(threadId, replacementToken); + } + else if (authoritative) + { + if (expectedToken is { } retryToken) + { + _authoritativePending.TryAdd(threadId, retryToken); + } + else + { + _authoritativePending[threadId] = + _state.CaptureHistoryToken(threadId); + } + } + return; + } + requestId = ++_requestSequence; + _inFlight[threadId] = requestId; + generationToken = _generationCancellation.Token; + canBegin = _state.TryBeginHistory( + threadId, + force, + expectedToken, + out commitToken, + out model, + out generationActivation); + } + + if (!canBegin) + { + CompleteInFlight( + threadId, + requestId, + out var pendingReload); + if (pendingReload is { } pending) + { + _ = ObserveRetryAsync(RerunAfterActivationAsync( + threadId, + generationActivation, + generationToken, + pending)); + return; + } + if (generationActivation is not null) + { + using var activationCancellation = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + generationToken); + await generationActivation + .WaitAsync(activationCancellation.Token) + .ConfigureAwait(false); + await LoadCoreAsync( + threadId, + force, + cancellationToken, + authoritative, + commitToken, + replacement, + supersedeReplacement: false) + .ConfigureAwait(false); + } + return; + } + + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + generationToken); + var requestStartedAt = DateTimeOffset.Now; + var operation = _telemetry.StartHistoryLoad( + force ? ChatHistoryTelemetrySource.Forced : ChatHistoryTelemetrySource.Initial); + var outcome = ChatTelemetryOutcome.Success; + Exception? failure = null; + Task? request = null; + try + { + request = _bridge.RequestChatHistoryAsync(threadId); + var history = await request + .WaitAsync(linkedCancellation.Token) + .ConfigureAwait(false); + if (!_state.IsHistoryRequestCurrent(commitToken)) + { + outcome = ChatTelemetryOutcome.Canceled; + return; + } + + var plan = BuildPlan( + history, + threadId, + model, + commitToken.ResetGeneration); + var committed = _state.CommitHistory( + commitToken, + plan, + requestStartedAt, + authoritative); + if (!committed) + { + outcome = ChatTelemetryOutcome.Canceled; + return; + } + lock (_gate) + _retryCounts.Remove(commitToken); + Completed?.Invoke( + this, + new ChatHistoryLoadResult( + commitToken, + PublishSnapshot: true)); + } + catch (OperationCanceledException) + { + outcome = ChatTelemetryOutcome.Canceled; + if (request is not null) + _ = ObserveCanceledRequestAsync(request); + } + catch (Exception ex) + { + _failureReservedForTesting?.Invoke(); + if (!_state.IsHistoryRequestCurrent(commitToken)) + { + outcome = ChatTelemetryOutcome.Canceled; + failure = null; + return; + } + outcome = ChatTelemetryOutcome.Failure; + failure = ex; + var shouldRetry = false; + lock (_gate) + { + if (!_disposed && + _state.CanRetryHistory(commitToken, authoritative)) + { + _retryCounts.TryGetValue(commitToken, out var retryCount); + shouldRetry = retryCount < MaxRetries; + if (shouldRetry) + _retryCounts[commitToken] = retryCount + 1; + } + } + if (_state.IsHistoryRequestCurrent(commitToken)) + { + Completed?.Invoke( + this, + new ChatHistoryLoadResult( + commitToken, + PublishSnapshot: false, + new ChatProviderNotification( + ChatProviderNotificationKind.Error, + threadId, + LocalizationHelper.GetString( + "Chat_Notification_LoadHistoryFailed"), + ex.Message))); + } + if (!_state.IsHistoryRequestCurrent(commitToken)) + { + outcome = ChatTelemetryOutcome.Canceled; + failure = null; + shouldRetry = false; + } + if (shouldRetry) + { + _ = ObserveRetryAsync(_retryScheduler( + RetryDelay, + generationToken, + () => LoadCoreAsync( + threadId, + force: true, + CancellationToken.None, + authoritative, + commitToken, + replacement, + supersedeReplacement: false))); + } + } + finally + { + _telemetry.FinishHistoryLoad(operation, outcome, failure); + CompleteInFlight(threadId, requestId, out var pendingReload); + if (pendingReload is { } pending) + { + _ = LoadCoreAsync( + threadId, + force: true, + CancellationToken.None, + authoritative: !pending.Replacement, + expectedToken: pending.Token, + replacement: pending.Replacement, + supersedeReplacement: false); + } + } + } + + private async Task RerunAfterActivationAsync( + string threadId, + Task? generationActivation, + CancellationToken generationToken, + PendingReload pending) + { + if (generationActivation is not null) + { + await generationActivation + .WaitAsync(generationToken) + .ConfigureAwait(false); + } + generationToken.ThrowIfCancellationRequested(); + await LoadCoreAsync( + threadId, + force: true, + CancellationToken.None, + authoritative: !pending.Replacement, + expectedToken: pending.Token, + replacement: pending.Replacement, + supersedeReplacement: false) + .ConfigureAwait(false); + } + + private ChatHistoryRebuildPlan BuildPlan( + ChatHistoryInfo history, + string threadId, + string? model, + long resetGeneration) + { + var timeline = ChatTimelineState.Initial() with { HistoryLoaded = true }; + var metadata = new Dictionary(StringComparer.Ordinal); + var cachedTools = _metadata.GetToolMetadata( + history.SessionId, + threadId, + resetGeneration); + var attachmentMatcher = _metadata.CreateAttachmentMatcher( + history.SessionId, + threadId, + resetGeneration); + var nextAssistantIsAborted = false; + var pendingUnkeyedToolCalls = new Queue(); + var syntheticToolCallSequence = 0; + ChatMessageInfo? suppressedAbortedAssistant = null; + + ChatTimelineState Apply( + ChatTimelineState current, + ChatEvent evt, + ChatEntryMetadata? entryMetadata) + { + var before = current.Entries + .Select(entry => entry.Id) + .ToHashSet(StringComparer.Ordinal); + var next = ChatTimelineReducer.Apply(current, evt); + if (entryMetadata is not null) + { + foreach (var entry in next.Entries) + { + if (!before.Contains(entry.Id) && !metadata.ContainsKey(entry.Id)) + metadata[entry.Id] = entryMetadata; + } + } + return next; + } + + var orderedMessages = OrderHistoryMessages(history.Messages); + foreach (var replayPart in + ChatHistoryReplayProjection.Project(orderedMessages)) + { + var message = replayPart.Message; + if (suppressedAbortedAssistant is not null) + { + if (ReferenceEquals(suppressedAbortedAssistant, message)) + continue; + suppressedAbortedAssistant = null; + } + + var role = message.Role?.ToLowerInvariant() ?? string.Empty; + var entryMetadata = new ChatEntryMetadata( + message.Ts > 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(message.Ts).ToLocalTime() + : null, + model, + message.InputTokens, + message.OutputTokens, + message.ResponseTokens, + message.ContextPercent, + GatewayMessageId: message.OpenClawId, + OpenClawSeq: message.OpenClawSeq, + OpenClawKind: message.OpenClawKind, + CompactionTokensBefore: message.CompactionTokensBefore, + CompactionTokensAfter: message.CompactionTokensAfter); + var text = ChatContentFormatting.TruncateForChatEntry( + ChatMetadataStore.EscapeUntrustedAttachmentMarkerLines( + replayPart.Text)); + if (role == "user") + text = ChatMetadataStore.RehydrateAttachmentMarkers( + attachmentMatcher, + text, + message.Ts); + var hasStructuredToolContent = + replayPart.ToolContent.Count > 0; + + if (role == "user" && + _persistence.IsMessageAborted( + threadId, + message.OpenClawId, + resetGeneration)) + { + nextAssistantIsAborted = true; + } + var gatewayAborted = role == "assistant" && + !string.IsNullOrEmpty(message.StopReason) && + !string.Equals(message.StopReason, "stop", StringComparison.OrdinalIgnoreCase) && + !string.Equals(message.StopReason, "toolUse", StringComparison.OrdinalIgnoreCase) && + !string.Equals(message.StopReason, "end_turn", StringComparison.OrdinalIgnoreCase); + var isFirstAssistantPart = + role == "assistant" && replayPart.IsFirstPart; + var markAborted = isFirstAssistantPart && + (nextAssistantIsAborted || gatewayAborted); + if (isFirstAssistantPart) + nextAssistantIsAborted = false; + if (markAborted) + { + timeline = Apply( + timeline, + new ChatStatusEvent( + "Response was stopped", + ChatTone.Warning), + entryMetadata); + timeline = ChatTimelineReducer.Apply( + timeline, + new ChatTurnEndEvent()); + suppressedAbortedAssistant = message; + continue; + } + + if (string.IsNullOrEmpty(text) && + !hasStructuredToolContent) + { + continue; + } + + if (!string.IsNullOrEmpty(text)) + { + switch (role) + { + case "user": + if (ChatContentFormatting.LooksLikeApprovalSlashCommand(text) || + NativeToolProjector.LooksLikeSystemControlNote(text)) + { + timeline = Apply( + timeline, + new ChatStatusEvent(text, ChatTone.Dim), + entryMetadata); + } + else + { + timeline = timeline with + { + ActiveAssistantId = null, + ActiveReasoningId = null, + }; + timeline = Apply( + timeline, + new ChatUserMessageEvent(text), + entryMetadata); + } + break; + case "assistant": + if (ChatMessageInfo.IsSilentAssistantDirective( + role, + text)) + { + break; + } + if (NativeToolProjector.LooksLikeSystemControlNote(text)) + { + timeline = Apply( + timeline, + new ChatStatusEvent(text, ChatTone.Dim), + entryMetadata); + } + else if (NativeToolProjector.LooksLikeFlattenedToolOutput(text)) + { + var cached = ChatMetadataStore.TryMatchCachedTool( + cachedTools, + message.Ts); + var kind = cached?.ToolName ?? + NativeToolProjector.ClassifyFlattenedToolOutput(text); + var label = cached?.Label ?? + NativeToolProjector.ExtractFlattenedToolSummary(text); + timeline = Apply( + timeline, + new ChatToolStartEvent( + label, + kind, + ToolArgs: cached?.ToolArgs, + ToolCallId: cached?.ToolCallId, + IdentityStrength: cached?.IdentityStrength ?? + NativeToolProjector.ClassifyHistoryIdentityStrength( + kind), + RunId: cached?.RunId), + entryMetadata); + timeline = Apply( + timeline, + new ChatToolOutputEvent( + text, + ToolCallId: cached?.ToolCallId, + RunId: cached?.RunId), + entryMetadata); + } + else + { + timeline = Apply( + timeline, + new ChatMessageEvent( + ChatContentFormatting.RepairContentBlockSeams( + text)), + entryMetadata); + if (timeline.ActiveToolCalls.Count > 0 || + timeline.ActiveToolCallId is not null) + { + timeline = timeline with + { + ActiveAssistantId = null, + ActiveReasoningId = null, + }; + } + else + { + timeline = ChatTimelineReducer.Apply( + timeline, + new ChatTurnEndEvent()); + } + } + break; + case "toolresult": + case "tool_result": + if (hasStructuredToolContent) + break; + var cachedTool = ChatMetadataStore.TryMatchCachedTool( + cachedTools, + message.Ts); + var toolKind = cachedTool?.ToolName ?? + NativeToolProjector.ClassifyFlattenedToolOutput(text); + var toolLabel = cachedTool?.Label ?? + NativeToolProjector.ExtractFlattenedToolSummary(text); + timeline = Apply( + timeline, + new ChatToolStartEvent( + toolLabel, + toolKind, + ToolArgs: cachedTool?.ToolArgs, + ToolCallId: cachedTool?.ToolCallId, + IdentityStrength: cachedTool?.IdentityStrength ?? + NativeToolProjector.ClassifyHistoryIdentityStrength( + toolKind), + RunId: cachedTool?.RunId), + entryMetadata); + timeline = Apply( + timeline, + new ChatToolOutputEvent( + text, + ToolCallId: cachedTool?.ToolCallId, + RunId: cachedTool?.RunId), + entryMetadata); + break; + case "system": + case "tool": + timeline = Apply( + timeline, + new ChatStatusEvent(text, ChatTone.Dim), + entryMetadata); + break; + default: + timeline = Apply( + timeline, + new ChatMessageEvent( + ChatContentFormatting.RepairContentBlockSeams( + text)), + entryMetadata); + timeline = ChatTimelineReducer.Apply( + timeline, + new ChatTurnEndEvent()); + break; + } + } + + foreach (var toolBlock in replayPart.ToolContent) + { + if (toolBlock.Kind == ChatToolContentKind.Call) + { + _ = ChatMetadataStore.TryMatchCachedTool( + cachedTools, + message.Ts); + var args = + ChatHistoryReplayProjection.ProjectToolArgs( + toolBlock.Args); + var callId = toolBlock.CallId; + if (string.IsNullOrWhiteSpace(callId)) + { + callId = + $"history-tool-{syntheticToolCallSequence++}"; + pendingUnkeyedToolCalls.Enqueue(callId); + } + timeline = Apply( + timeline, + new ChatToolStartEvent( + ChatHistoryReplayProjection.ToolLabel( + toolBlock.ToolName, + args), + toolBlock.ToolName, + args, + callId), + entryMetadata); + continue; + } + + var resultCallId = toolBlock.CallId; + if (string.IsNullOrWhiteSpace(resultCallId)) + { + resultCallId = + pendingUnkeyedToolCalls.Count > 0 + ? pendingUnkeyedToolCalls.Dequeue() + : $"history-tool-{syntheticToolCallSequence++}"; + } + var correlationKey = new ChatToolCorrelationKey( + RunId: null, + LegacyTurn: timeline.ToolLegacyTurn, + ToolCallId: resultCallId); + if (!timeline.ActiveToolCalls.ContainsKey(correlationKey)) + { + var cached = ChatMetadataStore.TryMatchCachedTool( + cachedTools, + message.Ts); + var toolName = + cached?.ToolName ?? toolBlock.ToolName; + timeline = Apply( + timeline, + new ChatToolStartEvent( + cached?.Label ?? toolName, + toolName, + ToolCallId: resultCallId), + entryMetadata); + } + var output = NativeToolProjector.TruncateToolOutput( + toolBlock.Text ?? string.Empty); + timeline = Apply( + timeline, + toolBlock.IsError + ? new ChatToolErrorEvent( + output, + resultCallId) + : new ChatToolOutputEvent( + output, + resultCallId), + entryMetadata); + } + } + + if (nextAssistantIsAborted) + { + timeline = Apply( + timeline, + new ChatStatusEvent("Response was stopped", ChatTone.Warning), + null); + timeline = ChatTimelineReducer.Apply(timeline, new ChatTurnEndEvent()); + } + timeline = ChatTimelineReducer.Apply( + timeline, + new ChatTurnEndEvent()); + timeline = timeline with + { + TurnActive = false, + ActiveAssistantId = null, + ActiveReasoningId = null, + }; + var maxSequence = history.Messages + .Where(message => message.OpenClawSeq is not null) + .Select(message => message.OpenClawSeq!.Value) + .DefaultIfEmpty(int.MinValue) + .Max(); + return new(history.SessionId, timeline, metadata, maxSequence); + } + + private void CompleteInFlight( + string threadId, + long requestId, + out PendingReload? pendingReload) + { + lock (_gate) + { + if (!_inFlight.TryGetValue(threadId, out var current) || + current != requestId) + { + pendingReload = null; + return; + } + _inFlight.Remove(threadId); + pendingReload = null; + if (_disposed) + return; + if (_replacementPending.Remove(threadId, out var replacementToken)) + { + pendingReload = new(replacementToken, Replacement: true); + return; + } + if (_authoritativePending.Remove(threadId, out var authoritativeToken)) + pendingReload = new(authoritativeToken, Replacement: false); + } + } + + private static List OrderHistoryMessages( + IReadOnlyList messages) + { + var indexed = messages + .Select((message, index) => (Message: message, Index: index)) + .ToList(); + var sequencedCount = indexed.Count(item => + item.Message.OpenClawSeq is not null); + if (sequencedCount == indexed.Count) + { + return indexed + .OrderBy(item => item.Message.OpenClawSeq) + .ThenBy(item => item.Index) + .Select(item => item.Message) + .ToList(); + } + if (sequencedCount == 0) + { + return indexed + .OrderBy(item => item.Message.Ts) + .ThenBy(item => item.Index) + .Select(item => item.Message) + .ToList(); + } + return indexed + .OrderBy(item => item.Index) + .Select(item => item.Message) + .ToList(); + } + + private static async Task ObserveRetryAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.Warn($"[ChatHistory] Retry scheduler failed: {ex.GetType().Name}"); + } + } + + private static async Task ObserveCanceledRequestAsync(Task request) + { + try + { + await request.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.Debug( + $"[ChatHistory] Canceled request completed with {ex.GetType().Name}"); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs index 09a47fdda..b42431b6d 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryReplayProjection.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Nodes; using OpenClaw.Shared; namespace OpenClawTray.Chat; @@ -10,6 +12,24 @@ internal sealed record ChatHistoryReplayPart( internal static class ChatHistoryReplayProjection { + internal static JsonObject? ProjectToolArgs(JsonElement? value) => + value is { ValueKind: JsonValueKind.Object } args + ? NativeToolProjector.ExtractSafeToolDisplayArgs(args) + : null; + + internal static string ToolLabel(string toolName, JsonObject? args) + { + var label = NativeToolProjector.FirstToolDisplayValue(args); + if (string.IsNullOrWhiteSpace(label)) + return toolName; + if (label.Length <= 80) + return label; + var length = 77; + if (char.IsHighSurrogate(label[length - 1])) + length--; + return label[..length] + "\u2026"; + } + public static IEnumerable Project( IEnumerable messages) { diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs new file mode 100644 index 000000000..0ab9fdabc --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatHistoryState.cs @@ -0,0 +1,383 @@ +using OpenClaw.Shared; +using OpenClaw.Chat; +using System.Collections.Immutable; + +namespace OpenClawTray.Chat; + +/// +/// Owns session identity, transcript freshness/revisions, and the single +/// connection-generation activation/commit-token state. The root supplies +/// reset generations and serializes every operation under its sole lock. +/// +internal sealed class ChatHistoryState +{ + private readonly Dictionary _sessionIds = new(); + private readonly HashSet _loadedThreads = new(); + private readonly Dictionary _revisions = new(); + private readonly Dictionary _resetClearedSessionIds = new(); + private readonly Dictionary _replacementGenerations = new(); + + private long _connectionGeneration; + private bool _generationReady = true; + private TaskCompletionSource _generationActivation = + CompletedActivation(); + + internal long ConnectionGeneration => _connectionGeneration; + + internal string? ResolveSessionId(string threadId) => + _sessionIds.TryGetValue(threadId, out var sessionId) + ? sessionId + : null; + + internal IReadOnlyDictionary SnapshotRevisions() => + new Dictionary(_revisions); + + internal ChatHistoryCommitToken CreateCommitToken( + string threadId, + long resetGeneration) => + new( + threadId, + _connectionGeneration, + resetGeneration, + GetReplacementGeneration(threadId)); + + internal ChatHistoryCommitToken BeginReplacement( + string threadId, + long resetGeneration) + { + _replacementGenerations[threadId] = + GetReplacementGeneration(threadId) + 1; + _loadedThreads.Remove(threadId); + return CreateCommitToken(threadId, resetGeneration); + } + + internal bool TryBegin( + string threadId, + bool force, + ChatHistoryCommitToken? expectedToken, + long resetGeneration, + ConnectionStatus status, + bool disposed, + out ChatHistoryCommitToken token, + out Task? generationActivation) + { + token = CreateCommitToken(threadId, resetGeneration); + generationActivation = null; + if (disposed || !force && _loadedThreads.Contains(threadId)) + return false; + + if (!_generationReady) + { + generationActivation = _generationActivation.Task; + return false; + } + + return expectedToken is not { } expected || + expected.ConnectionGeneration == _connectionGeneration && + expected.ResetGeneration == resetGeneration && + expected.ReplacementGeneration == + GetReplacementGeneration(threadId) && + (force || status == ConnectionStatus.Connected); + } + + internal bool IsCurrent( + ChatHistoryCommitToken token, + long resetGeneration, + bool disposed) => + !disposed && + token.ConnectionGeneration == _connectionGeneration && + token.ResetGeneration == resetGeneration && + token.ReplacementGeneration == + GetReplacementGeneration(token.ThreadId); + + internal bool CanRetry( + ChatHistoryCommitToken token, + long resetGeneration, + ConnectionStatus status, + bool authoritative, + bool disposed) => + IsCurrent(token, resetGeneration, disposed) && + status == ConnectionStatus.Connected && + (authoritative || !_loadedThreads.Contains(token.ThreadId)); + + internal void MarkCommitted( + ChatHistoryCommitToken token, + string? sessionId) + { + if (!string.IsNullOrEmpty(sessionId)) + _sessionIds[token.ThreadId] = sessionId; + _revisions[token.ThreadId] = + (_revisions.TryGetValue(token.ThreadId, out var revision) + ? revision + : 0) + 1; + _loadedThreads.Add(token.ThreadId); + } + + internal long AdvanceConnectionGeneration(bool clearLoaded) + { + _connectionGeneration++; + _generationActivation.TrySetResult(); + _generationReady = false; + _generationActivation = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + if (clearLoaded) + _loadedThreads.Clear(); + return _connectionGeneration; + } + + internal void ActivateConnectionGeneration( + long generation, + bool disposed) + { + if (disposed || + generation != _connectionGeneration || + _generationReady) + { + return; + } + _generationReady = true; + _generationActivation.TrySetResult(); + } + + internal string? ClearSessionForReset(string threadId) + { + var oldSessionId = ResolveSessionId(threadId); + if (!string.IsNullOrEmpty(oldSessionId)) + _resetClearedSessionIds[threadId] = oldSessionId; + else + _resetClearedSessionIds.Remove(threadId); + _sessionIds.Remove(threadId); + _loadedThreads.Add(threadId); + return oldSessionId; + } + + internal void SeedSessionIds(IEnumerable sessions) + { + foreach (var session in sessions) + { + if (string.IsNullOrWhiteSpace(session.Key) || + string.IsNullOrWhiteSpace(session.SessionId)) + { + continue; + } + + if (_resetClearedSessionIds.TryGetValue( + session.Key, + out var clearedSessionId) && + string.Equals( + clearedSessionId, + session.SessionId, + StringComparison.Ordinal)) + { + continue; + } + _sessionIds[session.Key] = session.SessionId; + _resetClearedSessionIds.Remove(session.Key); + } + } + + private long GetReplacementGeneration(string threadId) => + _replacementGenerations.TryGetValue(threadId, out var generation) + ? generation + : 0; + + internal static ( + ChatTimelineState Timeline, + Dictionary Metadata) + MergeWithLiveEntries( + ChatHistoryRebuildPlan plan, + ChatTimelineState prior, + IReadOnlyDictionary priorMetadata, + DateTimeOffset requestStartedAt, + bool authoritative) + { + var rebuilt = plan.Timeline; + var rebuiltMetadata = new Dictionary( + plan.Metadata, + StringComparer.Ordinal); + if (prior.Entries.Count == 0) + return (rebuilt, rebuiltMetadata); + + static string ContentKey(ChatTimelineItemKind kind, string text) => + $"{kind}|{text}"; + static string SequenceKey(ChatTimelineItemKind kind, int sequence) => + $"{kind}|{sequence}"; + + var contentTimestamps = new Dictionary>( + StringComparer.Ordinal); + var messageIds = new HashSet(StringComparer.Ordinal); + var sequenceCounts = new Dictionary(StringComparer.Ordinal); + foreach (var entry in rebuilt.Entries) + { + rebuiltMetadata.TryGetValue(entry.Id, out var metadata); + if (!string.IsNullOrEmpty(metadata?.GatewayMessageId)) + messageIds.Add(metadata.GatewayMessageId); + if (metadata?.OpenClawSeq is { } sequence) + IncrementCount(sequenceCounts, SequenceKey(entry.Kind, sequence)); + if (metadata?.Timestamp is { } timestamp && timestamp != default) + { + var key = ContentKey(entry.Kind, entry.Text); + if (!contentTimestamps.TryGetValue(key, out var timestamps)) + { + timestamps = []; + contentTimestamps[key] = timestamps; + } + timestamps.Add(timestamp.ToUnixTimeSeconds()); + } + } + + var existingIds = rebuilt.Entries + .Select(entry => entry.Id) + .ToHashSet(StringComparer.Ordinal); + var maxSuffix = rebuilt.Entries + .Select(entry => + entry.Id.Length > 1 && + entry.Id[0] == 'e' && + int.TryParse(entry.Id.AsSpan(1), out var suffix) + ? suffix + : 0) + .DefaultIfEmpty() + .Max(); + var nextId = Math.Max(rebuilt.NextId, maxSuffix + 1); + var entries = rebuilt.Entries.ToBuilder(); + foreach (var entry in prior.Entries) + { + priorMetadata.TryGetValue(entry.Id, out var metadata); + if (!string.IsNullOrEmpty(metadata?.GatewayMessageId) && + messageIds.Contains(metadata.GatewayMessageId)) + { + ConsumeAnyTimestamp( + contentTimestamps, + ContentKey(entry.Kind, entry.Text)); + continue; + } + if (metadata?.OpenClawSeq is { } sequence && + TryConsumeCount( + sequenceCounts, + SequenceKey(entry.Kind, sequence))) + { + ConsumeAnyTimestamp( + contentTimestamps, + ContentKey(entry.Kind, entry.Text)); + continue; + } + if (authoritative && + !ShouldPreserveLiveEntryDuringAuthoritativeReload( + metadata, + plan.MaxHistorySequence, + requestStartedAt)) + { + continue; + } + if (metadata?.Timestamp is { } timestamp && + timestamp != default && + contentTimestamps.TryGetValue( + ContentKey(entry.Kind, entry.Text), + out var rebuiltTimes)) + { + var priorSeconds = timestamp.ToUnixTimeSeconds(); + var match = rebuiltTimes.FindIndex(value => + Math.Abs(value - priorSeconds) <= 2); + if (match >= 0) + { + rebuiltTimes.RemoveAt(match); + continue; + } + } + + var entryToAdd = entry; + if (existingIds.Contains(entry.Id)) + entryToAdd = entry with { Id = $"e{nextId++}" }; + else if (entry.Id.Length > 1 && + entry.Id[0] == 'e' && + int.TryParse(entry.Id.AsSpan(1), out var suffix) && + suffix >= nextId) + nextId = suffix + 1; + entries.Add(entryToAdd); + existingIds.Add(entryToAdd.Id); + if (metadata?.Timestamp is { } addedTimestamp && + addedTimestamp != default) + { + var key = ContentKey(entryToAdd.Kind, entryToAdd.Text); + if (!contentTimestamps.TryGetValue(key, out var timestamps)) + { + timestamps = []; + contentTimestamps[key] = timestamps; + } + timestamps.Add(addedTimestamp.ToUnixTimeSeconds()); + } + if (!string.IsNullOrEmpty(metadata?.GatewayMessageId)) + messageIds.Add(metadata.GatewayMessageId); + if (metadata?.OpenClawSeq is { } addedSequence) + { + IncrementCount( + sequenceCounts, + SequenceKey(entryToAdd.Kind, addedSequence)); + } + if (metadata is not null) + rebuiltMetadata[entryToAdd.Id] = metadata; + } + + var merged = rebuilt with + { + Entries = entries.ToImmutable(), + NextId = nextId, + TurnActive = prior.TurnActive, + PendingToolPresentations = prior.PendingToolPresentations, + PendingToolOutcomes = prior.PendingToolOutcomes, + TerminalToolCorrelations = + prior.TerminalToolCorrelations, + NextToolOutcomeSequence = + prior.NextToolOutcomeSequence, + NextToolCorrelationSequence = + prior.NextToolCorrelationSequence, + ToolLegacyTurn = prior.ToolLegacyTurn, + }; + merged = ChatTimelineReducer.RebuildActiveToolTracking(merged); + return (merged, rebuiltMetadata); + } + + internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload( + ChatEntryMetadata? metadata, + int maxHistorySequence, + DateTimeOffset requestStartedAt) => + metadata is null || + metadata.OpenClawSeq is null || + metadata.OpenClawSeq is { } sequence && sequence > maxHistorySequence || + metadata.Timestamp is { } timestamp && timestamp >= requestStartedAt || + metadata.IsLocalQueuedSend; + + private static void IncrementCount( + Dictionary counts, + string key) => + counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1; + + private static bool TryConsumeCount( + Dictionary counts, + string key) + { + if (!counts.TryGetValue(key, out var count) || count <= 0) + return false; + if (count == 1) + counts.Remove(key); + else + counts[key] = count - 1; + return true; + } + + private static void ConsumeAnyTimestamp( + Dictionary> timestamps, + string key) + { + if (timestamps.TryGetValue(key, out var values) && values.Count > 0) + values.RemoveAt(0); + } + + private static TaskCompletionSource CompletedActivation() + { + var activation = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + activation.TrySetResult(); + return activation; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleState.cs new file mode 100644 index 000000000..6e0aadbba --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleState.cs @@ -0,0 +1,216 @@ +using OpenClaw.Shared; + +namespace OpenClawTray.Chat; + +/// +/// Owns active-run identity/sequences, abort suppression and deferral, and +/// bounded terminal-run deduplication. The root serializes every operation. +/// +internal sealed class ChatLifecycleState +{ + private const int TerminalRunCapacity = 64; + + private readonly Dictionary _activeRunIds = new(); + private readonly Dictionary _activeRunStartSequences = new(); + private readonly Dictionary _pendingAbortCounts = new(); + private readonly HashSet _abortedRunIds = new(); + private readonly HashSet _abortedThreads = new(); + private readonly Dictionary> _terminalRunIdsByThread = + new(); + + private long _lifecycleStartSequence; + + internal bool IsResponseSuppressed => _abortedThreads.Count > 0; + internal long LifecycleStartSequence => _lifecycleStartSequence; + + internal bool HasActiveRun(string threadId) => + _activeRunIds.ContainsKey(threadId); + + internal bool TryGetActiveRun(string threadId, out string? runId) => + _activeRunIds.TryGetValue(threadId, out runId); + + internal bool HasRunStartedAfter( + string threadId, + string runId, + long sequence) + { + return _activeRunIds.TryGetValue(threadId, out var activeRunId) && + _activeRunStartSequences.TryGetValue( + threadId, + out var activeSequence) && + string.Equals(activeRunId, runId, StringComparison.Ordinal) && + activeSequence > sequence; + } + + internal ChatAbortStart BeginAbort( + string threadId, + bool hadActiveTurn) + { + _activeRunIds.TryGetValue(threadId, out var runId); + _abortedThreads.Add(threadId); + if (!string.IsNullOrEmpty(runId)) + { + _abortedRunIds.Add(runId); + } + else + { + _pendingAbortCounts.TryGetValue(threadId, out var count); + _pendingAbortCounts[threadId] = count + 1; + } + return new(runId, hadActiveTurn); + } + + internal void RollbackAbort(string threadId, string runId) + { + _abortedThreads.Remove(threadId); + _abortedRunIds.Remove(runId); + RemoveActiveRun(threadId); + } + + internal void CompleteAbort(string threadId, string? runId) + { + if (!string.IsNullOrEmpty(runId)) + RemoveActiveRun(threadId); + _abortedThreads.Remove(threadId); + } + + internal bool ShouldSuppress(string threadId, string? runId) => + !string.IsNullOrEmpty(runId) && _abortedRunIds.Contains(runId) || + _abortedThreads.Contains(threadId); + + internal bool IsThreadSuppressed(string threadId) => + _abortedThreads.Contains(threadId); + + internal bool IsRunAborted(string? runId) => + !string.IsNullOrWhiteSpace(runId) && _abortedRunIds.Contains(runId); + + internal bool HasPendingAbort(string threadId) => + _pendingAbortCounts.ContainsKey(threadId); + + internal int TakePendingAbortCount(string threadId) + { + return _pendingAbortCounts.Remove(threadId, out var count) + ? count + : 0; + } + + internal long StartRun(string threadId, string runId) + { + _activeRunIds[threadId] = runId; + var sequence = ++_lifecycleStartSequence; + _activeRunStartSequences[threadId] = sequence; + return sequence; + } + + internal void MarkDeferredAbort(string threadId, string runId) + { + _abortedThreads.Add(threadId); + _abortedRunIds.Add(runId); + } + + internal void RemoveAbortedRun(string? runId) + { + if (!string.IsNullOrEmpty(runId)) + _abortedRunIds.Remove(runId); + } + + internal void ClearThreadSuppression(string threadId) => + _abortedThreads.Remove(threadId); + + internal string? CompleteAssistantFinal(string threadId) + { + _activeRunIds.Remove(threadId, out var completedRunId); + if (!string.IsNullOrEmpty(completedRunId)) + { + RememberTerminalRun(threadId, completedRunId); + _abortedRunIds.Remove(completedRunId); + } + _activeRunStartSequences.Remove(threadId); + _abortedThreads.Remove(threadId); + return completedRunId; + } + + internal void RemoveActiveRun(string threadId) + { + _activeRunIds.Remove(threadId); + _activeRunStartSequences.Remove(threadId); + } + + internal bool ShouldDropTerminal( + string threadId, + string runId, + IReadOnlyCollection queuedRunIds, + bool turnActive, + out ChatTerminalEventDropReason? droppedReason) + { + droppedReason = null; + if (string.IsNullOrWhiteSpace(runId)) + { + droppedReason = ChatTerminalEventDropReason.MissingRunId; + return true; + } + if (_terminalRunIdsByThread.TryGetValue(threadId, out var terminalRunIds) && + terminalRunIds.Contains(runId, StringComparer.Ordinal)) + { + return true; + } + if (_activeRunIds.TryGetValue(threadId, out var activeRunId) && + !string.Equals(activeRunId, runId, StringComparison.Ordinal)) + { + droppedReason = ChatTerminalEventDropReason.MismatchedRunId; + return true; + } + if (!_activeRunIds.ContainsKey(threadId) && + queuedRunIds.Count > 0 && + !queuedRunIds.Contains(runId, StringComparer.Ordinal) && + turnActive) + { + droppedReason = ChatTerminalEventDropReason.MismatchedRunId; + return true; + } + RememberTerminalRun(threadId, runId); + return false; + } + + internal string? ActiveRunForReset(string threadId) => + _activeRunIds.TryGetValue(threadId, out var runId) + ? runId + : null; + + internal void ClearThreadForReset(string threadId) + { + RemoveActiveRun(threadId); + _pendingAbortCounts.Remove(threadId); + _abortedThreads.Remove(threadId); + _terminalRunIdsByThread.Remove(threadId); + } + + internal void ClearForReconnect() + { + _terminalRunIdsByThread.Clear(); + _activeRunIds.Clear(); + _activeRunStartSequences.Clear(); + } + + internal void ClearForDispose() => + _terminalRunIdsByThread.Clear(); + + internal void ClearActiveRuns(IEnumerable threadIds) + { + foreach (var threadId in threadIds) + RemoveActiveRun(threadId); + } + + private void RememberTerminalRun(string threadId, string runId) + { + if (!_terminalRunIdsByThread.TryGetValue(threadId, out var runIds)) + { + runIds = []; + _terminalRunIdsByThread[threadId] = runIds; + } + runIds.Remove(runId); + runIds.Add(runId); + if (runIds.Count > TerminalRunCapacity) + runIds.RemoveRange(0, runIds.Count - TerminalRunCapacity); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatMetadataStore.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatMetadataStore.cs new file mode 100644 index 000000000..4b41a5a17 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatMetadataStore.cs @@ -0,0 +1,845 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal sealed class AttachmentMetaMatcher +{ + private static readonly TimeSpan MatchWindow = TimeSpan.FromHours(24); + private readonly List _entries; + private readonly bool[] _used; + + public AttachmentMetaMatcher(List entries) + { + _entries = entries; + _used = new bool[entries.Count]; + } + + public ChatMetadataStore.CachedAttachmentMeta? TryMatch(string text, long historyTsMs) + { + for (var i = 0; i < _entries.Count; i++) + { + if (_used[i]) + continue; + + var entry = _entries[i]; + if (!string.Equals(entry.Text, text, StringComparison.Ordinal)) + continue; + + if (historyTsMs > 0 && entry.Ts > 0 && + Math.Abs(historyTsMs - entry.Ts) > MatchWindow.TotalMilliseconds) + { + continue; + } + + _used[i] = true; + return entry; + } + + return null; + } +} + +/// +/// Owns the live tool and attachment metadata caches, their persistence +/// lifecycle, and attachment marker security/rehydration. +/// +internal sealed class ChatMetadataStore : IDisposable +{ + internal sealed class CachedToolMeta + { + public long Ts { get; set; } + public string ToolName { get; set; } = ""; + public string Label { get; set; } = ""; + public string? ToolCallId { get; set; } + public string? RunId { get; set; } + public long LegacyTurn { get; set; } + public JsonObject? ToolArgs { get; set; } + public ChatToolIdentityStrength IdentityStrength { get; set; } = + ChatToolIdentityStrength.Heuristic; + [JsonIgnore] public string ThreadId { get; set; } = ""; + [JsonIgnore] public long ResetGeneration { get; set; } + } + + internal sealed class CachedAttachmentMeta + { + public long Ts { get; set; } + public string Text { get; set; } = ""; + public List Attachments { get; set; } = []; + [JsonIgnore] public string ThreadId { get; set; } = ""; + [JsonIgnore] public long ResetGeneration { get; set; } + } + + internal sealed class CachedAttachmentItem + { + public string FileName { get; set; } = ""; + public bool IsImage { get; set; } + } + + internal const int MaxCachedSessions = 20; + internal const int MaxToolEntriesPerSession = 500; + internal const int MaxAttachmentEntriesPerSession = 500; + + internal static readonly JsonSerializerOptions CacheJsonOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + internal static readonly string LastChatStateFilePath = Path.Combine( + AppIdentity.ResolveLocalDataDirectory(), "last-chat-state.json"); + + internal static readonly string AbortedIdsFilePath = Path.Combine( + AppIdentity.ResolveLocalDataDirectory(), "aborted-messages.json"); + + private readonly object _gate = new(); + private readonly object _toolSaveGate = new(); + private readonly object _attachmentSaveGate = new(); + private readonly string _toolCacheFilePath; + private readonly string _attachmentCacheFilePath; + private Dictionary> _toolCache; + private Dictionary> _attachmentCache; + private readonly Dictionary _evictedResetGenerations = new(StringComparer.Ordinal); + private Timer? _toolSaveTimer; + private long _toolSaveVersion; + private bool _toolCacheDirty; + private bool _disposed; + + internal ChatMetadataStore(string toolCacheFilePath, string? attachmentCacheFilePath = null) + { + _toolCacheFilePath = !string.IsNullOrWhiteSpace(toolCacheFilePath) + ? toolCacheFilePath + : throw new ArgumentException("Tool metadata cache path is required.", nameof(toolCacheFilePath)); + _attachmentCacheFilePath = !string.IsNullOrWhiteSpace(attachmentCacheFilePath) + ? attachmentCacheFilePath + : DefaultAttachmentMetaCacheFilePath(_toolCacheFilePath); + _toolCache = LoadToolMetaCache(_toolCacheFilePath); + _attachmentCache = LoadAttachmentMetaCache(_attachmentCacheFilePath); + } + + internal static string DefaultToolMetaCacheFilePath => + Path.Combine(AppIdentity.ResolveLocalDataDirectory(), "tool-metadata.json"); + + internal static string DefaultAttachmentMetaCacheFilePath(string toolMetaCacheFilePath) + { + var dir = Path.GetDirectoryName(toolMetaCacheFilePath); + return Path.Combine( + string.IsNullOrEmpty(dir) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : dir, + "attachment-metadata.json"); + } + + internal void CacheTool( + string threadId, + string cacheKey, + long resetGeneration, + long tsMs, + string toolName, + string label, + string? toolCallId = null, + JsonObject? toolArgs = null, + ChatToolIdentityStrength identityStrength = + ChatToolIdentityStrength.Heuristic, + string? runId = null, + long legacyTurn = 0) + { + Timer? timerToDispose; + long saveVersion; + runId = string.IsNullOrWhiteSpace(runId) ? null : runId; + lock (_gate) + { + if (_disposed || IsStaleResetGenerationLocked(threadId, resetGeneration)) + return; + + if (!_toolCache.TryGetValue(cacheKey, out var list)) + { + list = []; + _toolCache[cacheKey] = list; + } + + if (!string.IsNullOrWhiteSpace(toolCallId)) + { + var existing = list.FindLast(entry => + string.Equals( + entry.ToolCallId, + toolCallId, + StringComparison.Ordinal) && + string.Equals( + entry.RunId, + runId, + StringComparison.Ordinal) && + (runId is not null || entry.LegacyTurn == legacyTurn)); + if (existing is not null) + { + if (identityStrength > existing.IdentityStrength) + { + existing.ToolName = + NormalizeCachedDisplayText(toolName); + existing.IdentityStrength = identityStrength; + } + if (!string.IsNullOrWhiteSpace(label)) + { + existing.Label = + NormalizeCachedDisplayText(label); + } + existing.ToolArgs = MergeCachedToolArgs( + existing.ToolArgs, + toolArgs); + ScheduleToolSaveLocked( + out saveVersion, + out timerToDispose); + goto ExitLock; + } + } + else if (list.Count > 0 && + list[^1].Ts == tsMs && + list[^1].ToolName == toolName) + { + return; + } + + list.Add(new CachedToolMeta + { + Ts = tsMs, + ToolName = NormalizeCachedDisplayText(toolName), + Label = NormalizeCachedDisplayText(label), + ToolCallId = toolCallId, + RunId = runId, + LegacyTurn = runId is null ? legacyTurn : 0, + ToolArgs = NormalizeCachedToolArgs(toolArgs), + IdentityStrength = identityStrength, + ThreadId = threadId, + ResetGeneration = resetGeneration, + }); + if (list.Count > MaxToolEntriesPerSession) + list.RemoveRange(0, list.Count - MaxToolEntriesPerSession); + + ScheduleToolSaveLocked( + out saveVersion, + out timerToDispose); + ExitLock: + ; + } + + timerToDispose?.Dispose(); + } + + internal void CacheTool(ChatToolMetadataWrite metadata) => + CacheTool( + metadata.ThreadId, + metadata.CacheKey, + metadata.ResetGeneration, + metadata.TimestampMs, + metadata.ToolName, + metadata.Label, + metadata.ToolCallId, + metadata.ToolArgs, + metadata.IdentityStrength, + metadata.RunId, + metadata.LegacyTurn); + + private void ScheduleToolSaveLocked( + out long saveVersion, + out Timer? timerToDispose) + { + _toolCacheDirty = true; + var version = ++_toolSaveVersion; + saveVersion = version; + timerToDispose = _toolSaveTimer; + _toolSaveTimer = new Timer( + _ => SaveToolCache(version), + null, + TimeSpan.FromMilliseconds(500), + Timeout.InfiniteTimeSpan); + } + + internal void CacheAttachments( + string threadId, + string? sessionId, + long resetGeneration, + string text, + IReadOnlyList attachments, + long tsMs) + { + if (attachments.Count == 0) + return; + + var items = attachments + .Where(attachment => !string.IsNullOrWhiteSpace(attachment.FileName)) + .Select(attachment => new CachedAttachmentItem + { + FileName = NormalizeCachedDisplayText(attachment.FileName), + IsImage = string.Equals(attachment.Type, "image", StringComparison.OrdinalIgnoreCase), + }) + .ToList(); + if (items.Count == 0) + return; + + lock (_gate) + { + if (_disposed || IsStaleResetGenerationLocked(threadId, resetGeneration)) + return; + + var key = !string.IsNullOrEmpty(sessionId) ? sessionId : threadId; + if (!_attachmentCache.TryGetValue(key, out var list)) + { + list = []; + _attachmentCache[key] = list; + } + + list.Add(new CachedAttachmentMeta + { + Ts = tsMs, + Text = NormalizeCachedDisplayText( + ChatContentFormatting.TruncateForChatEntry( + EscapeUntrustedAttachmentMarkerLines(text))), + Attachments = items, + ThreadId = threadId, + ResetGeneration = resetGeneration, + }); + if (list.Count > MaxAttachmentEntriesPerSession) + list.RemoveRange(0, list.Count - MaxAttachmentEntriesPerSession); + } + + SaveAttachmentCache(); + } + + internal Queue? GetToolMetadata( + string? sessionId, + string threadId, + long resetGeneration) + { + if (string.IsNullOrEmpty(sessionId) && string.IsNullOrEmpty(threadId)) + return null; + + lock (_gate) + { + var entries = new List(); + if (!string.IsNullOrEmpty(sessionId) && + _toolCache.TryGetValue(sessionId, out var sessionEntries)) + { + entries.AddRange(sessionEntries + .Where(entry => !IsOlderResetEntry( + entry.ThreadId, + entry.ResetGeneration, + threadId, + resetGeneration)) + .Select(Clone)); + } + + if (!string.IsNullOrEmpty(threadId) && + (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) && + _toolCache.TryGetValue(threadId, out var threadEntries)) + { + entries.AddRange(threadEntries + .Where(entry => !IsOlderResetEntry( + entry.ThreadId, + entry.ResetGeneration, + threadId, + resetGeneration)) + .Select(Clone)); + } + + return entries.Count == 0 + ? null + : new Queue(entries.OrderBy(entry => entry.Ts)); + } + } + + internal AttachmentMetaMatcher CreateAttachmentMatcher( + string? sessionId, + string threadId, + long resetGeneration) + { + var entries = new List(); + lock (_gate) + { + if (!string.IsNullOrEmpty(sessionId) && + _attachmentCache.TryGetValue(sessionId, out var sessionEntries)) + { + entries.AddRange(sessionEntries + .Where(entry => !IsOlderResetEntry( + entry.ThreadId, + entry.ResetGeneration, + threadId, + resetGeneration)) + .Select(Clone)); + } + + if (!string.IsNullOrEmpty(threadId) && + (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) && + _attachmentCache.TryGetValue(threadId, out var threadEntries)) + { + entries.AddRange(threadEntries + .Where(entry => !IsOlderResetEntry( + entry.ThreadId, + entry.ResetGeneration, + threadId, + resetGeneration)) + .Select(Clone)); + } + } + + return new AttachmentMetaMatcher(entries.OrderBy(entry => entry.Ts).ToList()); + } + + private static bool IsOlderResetEntry( + string? entryThreadId, + long entryResetGeneration, + string threadId, + long resetGeneration) => + (string.IsNullOrEmpty(entryThreadId) || + string.Equals(entryThreadId, threadId, StringComparison.Ordinal)) && + entryResetGeneration < resetGeneration; + + internal void EvictReset(string threadId, string? oldSessionId, long resetGeneration) + { + var saveTool = false; + var saveAttachments = false; + lock (_gate) + { + if (_evictedResetGenerations.TryGetValue(threadId, out var current) && + current >= resetGeneration) + { + return; + } + + _evictedResetGenerations[threadId] = resetGeneration; + if (!string.IsNullOrEmpty(oldSessionId)) + { + saveTool = RemoveOlderToolEntries( + oldSessionId, + threadId, + resetGeneration); + saveAttachments = RemoveOlderAttachmentEntries( + oldSessionId, + threadId, + resetGeneration); + } + + saveTool = RemoveOlderToolEntries( + threadId, + threadId, + resetGeneration) || saveTool; + saveAttachments = RemoveOlderAttachmentEntries( + threadId, + threadId, + resetGeneration) || saveAttachments; + if (saveTool) + { + _toolCacheDirty = true; + _toolSaveVersion++; + } + } + + if (saveTool) + SaveToolCache(); + if (saveAttachments) + SaveAttachmentCache(); + } + + private bool RemoveOlderToolEntries( + string cacheKey, + string threadId, + long resetGeneration) + { + if (!_toolCache.TryGetValue(cacheKey, out var entries)) + return false; + var removed = entries.RemoveAll(entry => + (string.IsNullOrEmpty(entry.ThreadId) || + string.Equals(entry.ThreadId, threadId, StringComparison.Ordinal)) && + entry.ResetGeneration < resetGeneration) > 0; + if (entries.Count == 0) + _toolCache.Remove(cacheKey); + return removed; + } + + private bool RemoveOlderAttachmentEntries( + string cacheKey, + string threadId, + long resetGeneration) + { + if (!_attachmentCache.TryGetValue(cacheKey, out var entries)) + return false; + var removed = entries.RemoveAll(entry => + (string.IsNullOrEmpty(entry.ThreadId) || + string.Equals(entry.ThreadId, threadId, StringComparison.Ordinal)) && + entry.ResetGeneration < resetGeneration) > 0; + if (entries.Count == 0) + _attachmentCache.Remove(cacheKey); + return removed; + } + + internal static CachedToolMeta? TryMatchCachedTool( + Queue? cache, + long historyTsMs) + { + if (cache is null || cache.Count == 0) + return null; + + var candidate = cache.Peek(); + if (historyTsMs > 0 && candidate.Ts > 0 && candidate.Ts > historyTsMs + 300_000) + return null; + + var match = cache.Dequeue(); + match.ToolName = NormalizeCachedDisplayText(match.ToolName); + match.Label = NormalizeCachedDisplayText(match.Label); + match.ToolArgs = NormalizeCachedToolArgs(match.ToolArgs); + return match; + } + + internal void Flush() + { + Timer? timer; + lock (_gate) + { + timer = _toolSaveTimer; + _toolSaveTimer = null; + _toolSaveVersion++; + } + + timer?.Dispose(); + SaveToolCache(); + SaveAttachmentCache(); + } + + public void Dispose() + { + Timer? timer; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + timer = _toolSaveTimer; + _toolSaveTimer = null; + _toolSaveVersion++; + } + + timer?.Dispose(); + SaveToolCache(); + } + + private bool IsStaleResetGenerationLocked(string threadId, long resetGeneration) => + _evictedResetGenerations.TryGetValue(threadId, out var evictedGeneration) && + resetGeneration < evictedGeneration; + + private void SaveToolCache(long? expectedVersion = null) + { + try + { + Dictionary> snapshot; + lock (_gate) + { + if (expectedVersion is { } version && + (version != _toolSaveVersion || _disposed)) + { + return; + } + if (!_toolCacheDirty) + return; + + snapshot = _toolCache.ToDictionary( + pair => pair.Key, + pair => pair.Value.Select(Clone).ToList(), + StringComparer.Ordinal); + } + + EvictOldestSessions(snapshot); + var json = JsonSerializer.Serialize(snapshot, CacheJsonOptions); + lock (_toolSaveGate) + { + if (expectedVersion is { } version) + { + lock (_gate) + { + if (version != _toolSaveVersion || _disposed) + return; + } + } + + AtomicWrite(_toolCacheFilePath, json, "tool metadata"); + lock (_gate) + { + if (expectedVersion is null || expectedVersion == _toolSaveVersion) + _toolCacheDirty = false; + } + } + } + catch (Exception ex) + { + Logger.Debug($"Chat metadata cache could not be saved: {ex.Message}"); + } + } + + private void SaveAttachmentCache() + { + try + { + Dictionary> snapshot; + lock (_gate) + { + snapshot = _attachmentCache.ToDictionary( + pair => pair.Key, + pair => pair.Value.Select(Clone).ToList(), + StringComparer.Ordinal); + } + + EvictOldestSessions(snapshot); + var json = JsonSerializer.Serialize(snapshot, CacheJsonOptions); + lock (_attachmentSaveGate) + { + AtomicWrite(_attachmentCacheFilePath, json, "attachment metadata"); + } + } + catch (Exception ex) + { + Logger.Debug($"Attachment metadata cache could not be saved: {ex.Message}"); + } + } + + private static void EvictOldestSessions(Dictionary> snapshot) + where T : class + { + if (snapshot.Count <= MaxCachedSessions) + return; + + static long Timestamp(T entry) => entry switch + { + CachedToolMeta tool => tool.Ts, + CachedAttachmentMeta attachment => attachment.Ts, + _ => 0, + }; + + var keys = snapshot + .OrderBy(pair => pair.Value.Count > 0 ? Timestamp(pair.Value[^1]) : 0) + .Take(snapshot.Count - MaxCachedSessions) + .Select(pair => pair.Key) + .ToArray(); + foreach (var key in keys) + snapshot.Remove(key); + } + + private static void AtomicWrite(string path, string json, string cacheName) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(tempPath, json); + File.Move(tempPath, path, overwrite: true); + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch (Exception ex) + { + Logger.Debug($"{cacheName} temp file cleanup failed: {ex.Message}"); + } + } + } + + private static CachedToolMeta Clone(CachedToolMeta entry) => new() + { + Ts = entry.Ts, + ToolName = NormalizeCachedDisplayText(entry.ToolName), + Label = NormalizeCachedDisplayText(entry.Label), + ToolCallId = entry.ToolCallId, + RunId = entry.RunId, + LegacyTurn = entry.LegacyTurn, + ToolArgs = NormalizeCachedToolArgs(entry.ToolArgs), + IdentityStrength = entry.IdentityStrength, + ThreadId = entry.ThreadId, + ResetGeneration = entry.ResetGeneration, + }; + + private static CachedAttachmentMeta Clone(CachedAttachmentMeta entry) => new() + { + Ts = entry.Ts, + Text = NormalizeCachedDisplayText(entry.Text), + ThreadId = entry.ThreadId, + ResetGeneration = entry.ResetGeneration, + Attachments = entry.Attachments.Select(attachment => new CachedAttachmentItem + { + FileName = NormalizeCachedDisplayText(attachment.FileName), + IsImage = attachment.IsImage, + }).ToList(), + }; + + internal static Dictionary> LoadToolMetaCache(string cacheFilePath) + { + try + { + if (!File.Exists(cacheFilePath)) + return []; + var json = File.ReadAllText(cacheFilePath); + var cache = JsonSerializer.Deserialize>>(json) ?? []; + foreach (var entry in cache.Values.SelectMany(entries => entries)) + { + entry.ToolName = NormalizeCachedDisplayText(entry.ToolName); + entry.Label = NormalizeCachedDisplayText(entry.Label); + entry.ToolArgs = NormalizeCachedToolArgs(entry.ToolArgs); + } + return cache; + } + catch (Exception ex) + { + Logger.Debug($"Tool metadata cache could not be loaded: {ex.Message}"); + return []; + } + } + + internal static Dictionary> LoadAttachmentMetaCache(string cacheFilePath) + { + try + { + if (!File.Exists(cacheFilePath)) + return []; + var json = File.ReadAllText(cacheFilePath); + var cache = JsonSerializer.Deserialize>>(json) ?? []; + foreach (var entry in cache.Values.SelectMany(entries => entries)) + { + entry.Text = NormalizeCachedDisplayText(entry.Text); + foreach (var attachment in entry.Attachments) + attachment.FileName = NormalizeCachedDisplayText(attachment.FileName); + } + return cache; + } + catch (Exception ex) + { + Logger.Debug($"Attachment metadata cache could not be loaded: {ex.Message}"); + return []; + } + } + + internal static string EscapeUntrustedAttachmentMarkerLines(string? text) + { + if (string.IsNullOrEmpty(text)) + return text ?? string.Empty; + + var lines = text.Split('\n'); + var changed = false; + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var trimmedStart = line.TrimStart(); + if (trimmedStart.StartsWith("\u200B🖼️ ", StringComparison.Ordinal) || + trimmedStart.StartsWith("\u200B📎 ", StringComparison.Ordinal)) + { + var prefixLength = line.Length - trimmedStart.Length; + lines[i] = string.Concat(line.AsSpan(0, prefixLength), trimmedStart.AsSpan(1)); + changed = true; + } + } + + return changed ? string.Join('\n', lines) : text; + } + + internal static string BuildAttachmentMarkerLines(IEnumerable attachments) => + string.Join("\n", attachments.Select(attachment => + string.Equals(attachment.Type, "image", StringComparison.OrdinalIgnoreCase) + ? $"\u200B🖼️ {attachment.FileName}" + : $"\u200B📎 {attachment.FileName}")); + + internal static string BuildAttachmentMarkerLines(IEnumerable attachments) => + string.Join("\n", attachments.Select(attachment => + attachment.IsImage + ? $"\u200B🖼️ {attachment.FileName}" + : $"\u200B📎 {attachment.FileName}")); + + internal static string RehydrateAttachmentMarkers( + AttachmentMetaMatcher matcher, + string text, + long historyTsMs) + { + var match = matcher.TryMatch(text, historyTsMs); + if (match is null || match.Attachments.Count == 0) + return text; + + var markerLines = BuildAttachmentMarkerLines(match.Attachments); + return string.IsNullOrEmpty(text) + ? markerLines + : $"{text}\n{markerLines}"; + } + + internal static string NormalizeCachedDisplayText(string? value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return value + .Replace("\r\n", " ", StringComparison.Ordinal) + .Replace('\r', ' ') + .Replace('\n', ' '); + } + + private static JsonObject? NormalizeCachedToolArgs(JsonObject? args) + { + if (args is null) + return null; + + var normalized = new JsonObject(); + foreach (var key in NativeToolProjector.DisplayArgumentKeys) + { + if (args[key] is JsonValue value && + value.TryGetValue(out var text)) + { + var safe = NativeToolProjector.SanitizeToolDisplayValue( + NormalizeCachedDisplayText(text)); + if (!string.IsNullOrWhiteSpace(safe)) + normalized[key] = safe; + } + } + return normalized.Count == 0 ? null : normalized; + } + + private static JsonObject? MergeCachedToolArgs( + JsonObject? existing, + JsonObject? incoming) + { + var merged = NormalizeCachedToolArgs(existing) ?? new JsonObject(); + var normalizedIncoming = NormalizeCachedToolArgs(incoming); + if (normalizedIncoming is not null) + { + foreach (var key in NativeToolProjector.DisplayArgumentKeys) + { + if (normalizedIncoming[key] is not JsonValue value || + !value.TryGetValue(out var incomingText)) + { + continue; + } + + if (merged[key] is JsonValue existingValue && + existingValue.TryGetValue(out var existingText) && + !string.Equals( + existingText, + incomingText, + StringComparison.Ordinal)) + { + var combined = existingText + "\n" + incomingText; + merged[key] = combined.Length > 512 + ? combined[..509] + "..." + : combined; + } + else + { + merged[key] = incomingText; + } + } + } + return merged.Count == 0 ? null : merged; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs new file mode 100644 index 000000000..f8a835d5b --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatPresentationState.cs @@ -0,0 +1,313 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal sealed record ChatUsageSnapshot( + long InputTokens, + long OutputTokens, + long TotalTokens, + long ContextTokens); + +/// +/// Owns session/model/catalog presentation inputs, serialized model patches, +/// keyless diagnostics, and remembered chat selection. The root serializes +/// every operation and supplies immutable timeline/queue snapshots. +/// +internal sealed class ChatPresentationState +{ + private readonly Dictionary _pendingModelPatches = new(); + + private SessionInfo[] _sessions = []; + private bool _sessionsListReceived; + private string[] _availableModels = []; + private IReadOnlyList _modelChoices = []; + private CommandCatalog? _commandCatalog; + private bool _commandsFetchInFlight; + private int _commandsEpoch; + private int _keylessEventDiagnosticRaised; + private OpenClawChatDataProvider.LastChatState? _lastChatState; + + internal ChatPresentationState( + OpenClawChatDataProvider.LastChatState? lastChatState, + ModelsListInfo? seedModels) + { + _lastChatState = lastChatState; + if (seedModels is not null) + { + _modelChoices = ChatModelChoice.FromModelsList(seedModels); + _availableModels = ModelIdsFromChoices(_modelChoices); + } + else if (lastChatState?.AvailableModels is { Length: > 0 } cached) + { + _availableModels = cached.ToArray(); + _modelChoices = ChoicesFromIds(cached); + } + } + + internal OpenClawChatDataProvider.LastChatState? CachedLastChatState => + _lastChatState; + + internal SessionInfo[] SessionSnapshot() => _sessions.ToArray(); + + internal SessionInfo[] ReplaceSessions( + SessionInfo[] sessions, + bool receivedFromGateway = true) + { + var previous = _sessions; + _sessions = sessions.ToArray(); + if (receivedFromGateway) + _sessionsListReceived = true; + return previous; + } + + internal IReadOnlyDictionary SnapshotUsage() => + _sessions + .Where(session => !string.IsNullOrEmpty(session.Key)) + .ToDictionary( + session => session.Key, + session => new ChatUsageSnapshot( + session.InputTokens, + session.OutputTokens, + session.TotalTokens, + session.ContextTokens)); + + internal SessionInfo? FindSession(string threadId) => + _sessions.FirstOrDefault(candidate => + string.Equals(candidate.Key, threadId, StringComparison.Ordinal)); + + internal string? ModelForThread(string threadId) => + FindSession(threadId)?.Model; + + internal long? ContextTokensForThread(string threadId) => + FindSession(threadId) is { ContextTokens: > 0 } session + ? session.ContextTokens + : null; + + internal OpenClawChatDataProvider.LastChatState? RememberSelectedThread( + string threadId) + { + var session = FindSession(threadId); + if (session is null) + return null; + + _lastChatState = new OpenClawChatDataProvider.LastChatState + { + DefaultThreadId = threadId, + ThreadTitle = SessionTitleFormatter.Format(session, _sessions), + Model = session.Model, + ModelProvider = session.Provider, + AvailableModels = _availableModels.ToArray(), + }; + return _lastChatState; + } + + internal void RememberLastSessionState(ChatProjectionContext context) + { + if (_sessions.Length == 0) + return; + + var defaultThreadId = ChatSnapshotProjector.ResolveDefaultThreadId( + CaptureProjectionInput( + timelines: new Dictionary(), + timelineGenerations: new Dictionary(), + historyRevisions: new Dictionary(), + queuedMessages: new Dictionary>(), + status: ConnectionStatus.Disconnected, + context)); + var session = defaultThreadId is { Length: > 0 } + ? FindSession(defaultThreadId) + : null; + session ??= _sessions.FirstOrDefault(candidate => + candidate.IsMain && !string.IsNullOrEmpty(candidate.Key)); + session ??= _sessions.FirstOrDefault(candidate => + !string.IsNullOrEmpty(candidate.Key)); + if (session is null) + return; + + _lastChatState = new OpenClawChatDataProvider.LastChatState + { + DefaultThreadId = session.Key, + ThreadTitle = SessionTitleFormatter.Format(session, _sessions), + Model = session.Model, + ModelProvider = session.Provider, + AvailableModels = _availableModels.ToArray(), + }; + } + + internal void ApplyModels(ModelsListInfo models) + { + _modelChoices = ChatModelChoice.FromModelsList(models); + _availableModels = ModelIdsFromChoices(_modelChoices); + } + + internal void LeaveConnected() + { + _sessionsListReceived = false; + _commandsEpoch++; + _commandCatalog = null; + _commandsFetchInFlight = false; + } + + internal ChatModelPatchLease BeginModelPatch(string threadId) + { + _pendingModelPatches.TryGetValue(threadId, out var previous); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _pendingModelPatches[threadId] = completion.Task; + return new(threadId, previous, completion); + } + + internal void CompleteModelPatch( + ChatModelPatchLease lease, + Exception? error) + { + if (error is null) + lease.Completion.TrySetResult(); + else + lease.Completion.TrySetException(error); + + if (_pendingModelPatches.TryGetValue(lease.ThreadId, out var current) && + ReferenceEquals(current, lease.Completion.Task)) + { + _pendingModelPatches.Remove(lease.ThreadId); + } + } + + internal Task? GetPendingModelPatch(string threadId) => + _pendingModelPatches.TryGetValue(threadId, out var pending) + ? pending + : null; + + internal bool TryBeginCommandCatalogFetch( + ConnectionStatus status, + out int epoch) + { + epoch = _commandsEpoch; + if (status != ConnectionStatus.Connected || + _commandsFetchInFlight || + _commandCatalog is not null) + { + return false; + } + _commandsFetchInFlight = true; + return true; + } + + internal bool CompleteCommandCatalogFetch( + int epoch, + ConnectionStatus status, + CommandCatalog catalog) + { + if (epoch != _commandsEpoch || status != ConnectionStatus.Connected) + return false; + _commandsFetchInFlight = false; + _commandCatalog = catalog; + return true; + } + + internal bool FailCommandCatalogFetch( + int epoch, + ConnectionStatus status) + { + if (epoch != _commandsEpoch || status != ConnectionStatus.Connected) + return false; + _commandsFetchInFlight = false; + _commandCatalog = new CommandCatalog { IsSupported = false }; + return true; + } + + internal bool IsCommandCatalogEpochCurrent(int epoch) => + epoch == _commandsEpoch; + + internal bool TryRaiseKeylessDiagnostic() + { + if (_keylessEventDiagnosticRaised != 0) + return false; + _keylessEventDiagnosticRaised = 1; + return true; + } + + internal void ResetKeylessDiagnostic() => + _keylessEventDiagnosticRaised = 0; + + internal ChatSnapshotProjectionInput CaptureProjectionInput( + IReadOnlyDictionary timelines, + IReadOnlyDictionary timelineGenerations, + IReadOnlyDictionary historyRevisions, + IReadOnlyDictionary> queuedMessages, + ConnectionStatus status, + ChatProjectionContext context) => new( + Sessions: _sessions.ToArray(), + Timelines: timelines, + TimelineGenerations: timelineGenerations, + HistoryRevisions: historyRevisions, + QueuedMessages: queuedMessages, + SessionsListReceived: _sessionsListReceived, + AvailableModels: _availableModels.ToArray(), + ModelChoices: _modelChoices.ToArray(), + CommandCatalog: _commandCatalog, + Status: status, + MainSessionKey: context.MainSessionKey, + HasHandshakeSnapshot: context.HasHandshakeSnapshot, + RememberedDefaultThreadId: _lastChatState?.DefaultThreadId, + RememberedThreadTitle: _lastChatState?.ThreadTitle, + RememberedModel: _lastChatState?.Model, + RememberedModelProvider: _lastChatState?.ModelProvider); + + internal string ResolveTimelineKey( + SessionInfo session, + IReadOnlyDictionary timelines) + { + if (session.IsMain && + timelines.TryGetValue("main", out var mainTimeline) && + mainTimeline.Entries.Count > 0) + { + return "main"; + } + if (!string.IsNullOrEmpty(session.Key) && timelines.ContainsKey(session.Key)) + return session.Key; + if (session.IsMain && timelines.ContainsKey("main")) + return "main"; + return session.Key ?? string.Empty; + } + + internal SessionInfo? ResolveSessionForThread( + string threadId, + string? mainSessionKey) + { + var byKey = FindSession(threadId); + if (byKey is not null) + return byKey; + if (string.Equals(threadId, "main", StringComparison.Ordinal) && + !string.IsNullOrEmpty(mainSessionKey)) + { + var main = FindSession(mainSessionKey); + if (main is not null) + return main; + } + return string.Equals(threadId, "main", StringComparison.Ordinal) + ? _sessions.FirstOrDefault(session => session.IsMain) + : null; + } + + private static string[] ModelIdsFromChoices( + IReadOnlyList choices) + { + var seen = new HashSet(StringComparer.Ordinal); + return choices + .Where(choice => choice.IsSelectable && seen.Add(choice.Id)) + .Select(choice => choice.Id) + .ToArray(); + } + + private static IReadOnlyList ChoicesFromIds(string[] ids) + { + var seen = new HashSet(StringComparer.Ordinal); + return ids + .Where(id => !string.IsNullOrEmpty(id) && seen.Add(id)) + .Select(id => new ChatModelChoice(id, id)) + .ToArray(); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatQueueState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatQueueState.cs new file mode 100644 index 000000000..33a9ef283 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatQueueState.cs @@ -0,0 +1,594 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal readonly record struct ChatLocalSentText( + string Text, + DateTimeOffset SentAt, + string QueuedMessageId); + +internal sealed record ChatQueueRetryResult( + bool Requeued, + TimeSpan Delay, + bool ShouldEndTurn); + +/// +/// Owns queued-message/request collections, local echo correlation, run +/// mappings, drain scheduling, and queue retry mechanics. The root owns the +/// only lock and coordinates timeline/run/reset commits around these methods. +/// +internal sealed class ChatQueueState +{ + private const int MaxLocalEchoes = 20; + private static readonly TimeSpan LocalEchoWindow = TimeSpan.FromSeconds(30); + + private readonly Dictionary> _localSentTexts = + new(); + private readonly Dictionary> _messages = new(); + private readonly Dictionary> _requests = new(); + private readonly Dictionary> _messageIdsByRunId = + new(); + private readonly HashSet _drainScheduledThreads = + new(StringComparer.Ordinal); + private readonly HashSet _assistantFallbackPromotedThreads = + new(StringComparer.Ordinal); + private readonly HashSet _locallyInitiatedThreads = new(); + + private long _messageSequence; + + internal string NextMessageId() => $"q{++_messageSequence}"; + + internal IReadOnlyDictionary> + SnapshotMessages() => + _messages.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value.ToArray()); + + internal string[] ThreadsWithMessages() => _messages.Keys.ToArray(); + + internal void AddMessage(string threadId, ChatQueuedMessage message) + { + if (!_messages.TryGetValue(threadId, out var messages)) + { + messages = []; + _messages[threadId] = messages; + } + messages.RemoveAll(existing => existing.Id == message.Id); + messages.Add(message); + } + + internal void AddRequest(ChatQueuedSendRequest request) + { + if (!_requests.TryGetValue(request.ThreadId, out var requests)) + { + requests = []; + _requests[request.ThreadId] = requests; + } + requests.RemoveAll(existing => existing.Id == request.Id); + requests.Add(request); + } + + internal ChatQueuedSendRequest? FindRequest( + string threadId, + string messageId) => + _requests.TryGetValue(threadId, out var requests) + ? requests.FirstOrDefault(request => + string.Equals(request.Id, messageId, StringComparison.Ordinal)) + : null; + + internal void RemoveRequest(string threadId, string messageId) + { + if (!_requests.TryGetValue(threadId, out var requests)) + return; + requests.RemoveAll(request => request.Id == messageId); + if (requests.Count == 0) + _requests.Remove(threadId); + } + + internal bool CanSendDirectly( + string threadId, + bool hasActiveRun, + bool turnActive) => + ChatSendQueuePolicy.CanSendDirectly( + hasActiveRun, + turnActive, + HasPendingMessages(threadId)); + + internal bool CanClearAssistantFallback( + string threadId, + bool hasActiveRun, + bool turnActive) => + !HasSendingMessages(threadId) && + !hasActiveRun && + !turnActive; + + internal ChatQueuedSendDispatch StartDirect( + ChatQueuedSendRequest request, + string? sessionId, + long connectionGeneration, + long resetVersion, + long resetLifecycleSequence, + long lifecycleStartSequence) + { + EnqueueLocalEcho(request.ThreadId, request.Text, request.Id); + _locallyInitiatedThreads.Add(request.ThreadId); + _assistantFallbackPromotedThreads.Add(request.ThreadId); + return new ChatQueuedSendDispatch( + request, + sessionId, + connectionGeneration, + resetVersion, + resetLifecycleSequence, + lifecycleStartSequence, + StartedDirectly: true); + } + + internal ChatQueuedSendDispatch? TryStartNext( + string threadId, + bool requireConnected, + ConnectionStatus status, + bool hasActiveRun, + bool turnActive, + string? sessionId, + long connectionGeneration, + long resetVersion, + long resetLifecycleSequence, + long lifecycleStartSequence, + out TimeSpan? delayedRetry) + { + delayedRetry = null; + if (!ChatSendQueuePolicy.CanStartNext( + requireConnected, + status, + hasActiveRun, + turnActive, + HasSendingMessages(threadId)) || + !_messages.TryGetValue(threadId, out var messages)) + { + return null; + } + + for (var index = 0; index < messages.Count; index++) + { + if (messages[index].SendState != ChatQueuedMessageSendState.Queued) + continue; + var request = FindRequest(threadId, messages[index].Id); + if (request is null) + continue; + + var now = DateTimeOffset.UtcNow; + if (request.DeferredAdmissionRetryAfter is { } retryAfter) + { + if (retryAfter > now) + { + delayedRetry = retryAfter - now; + return null; + } + request = request with { DeferredAdmissionRetryAfter = null }; + AddRequest(request); + } + + _assistantFallbackPromotedThreads.Remove(threadId); + messages[index] = messages[index] with + { + SendState = ChatQueuedMessageSendState.Sending, + ErrorText = null, + }; + if (request.LifecycleCommand is null) + { + EnqueueLocalEcho(threadId, request.Text, request.Id); + _locallyInitiatedThreads.Add(threadId); + } + + return new ChatQueuedSendDispatch( + request, + sessionId, + connectionGeneration, + resetVersion, + resetLifecycleSequence, + lifecycleStartSequence, + StartedDirectly: false); + } + + return null; + } + + internal bool TryScheduleDrain(string threadId) => + _messages.ContainsKey(threadId) && + _drainScheduledThreads.Add(threadId); + + internal void CompleteDrainSchedule(string threadId) => + _drainScheduledThreads.Remove(threadId); + + internal void TrackRun(string threadId, string runId, string messageId) + { + if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId)) + { + byRunId = new Dictionary(StringComparer.Ordinal); + _messageIdsByRunId[threadId] = byRunId; + } + byRunId[runId] = messageId; + } + + internal bool TryResolveMessageForRun( + string threadId, + string runId, + out string messageId) + { + messageId = string.Empty; + if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId) || + !byRunId.TryGetValue(runId, out var resolved)) + { + return false; + } + messageId = resolved; + return true; + } + + internal string[] RunIdsForThread(string threadId) => + _messageIdsByRunId.TryGetValue(threadId, out var byRunId) + ? byRunId.Keys.ToArray() + : []; + + internal void RemoveRunMappingByMessageId( + string threadId, + string messageId) + { + if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId)) + return; + foreach (var runId in byRunId + .Where(pair => pair.Value == messageId) + .Select(pair => pair.Key) + .ToArray()) + { + byRunId.Remove(runId); + } + if (byRunId.Count == 0) + _messageIdsByRunId.Remove(threadId); + } + + internal void RemoveRunMappingByRunId(string threadId, string runId) + { + if (!_messageIdsByRunId.TryGetValue(threadId, out var byRunId)) + return; + if (byRunId.TryGetValue(runId, out var messageId)) + { + foreach (var alias in byRunId + .Where(pair => pair.Value == messageId) + .Select(pair => pair.Key) + .ToArray()) + { + byRunId.Remove(alias); + } + } + else + { + byRunId.Remove(runId); + } + if (byRunId.Count == 0) + _messageIdsByRunId.Remove(threadId); + } + + internal bool RemoveMessage(string threadId, string messageId) + { + if (!_messages.TryGetValue(threadId, out var messages)) + return false; + var removed = messages.RemoveAll(message => message.Id == messageId) > 0; + if (removed) + { + RemoveRunMappingByMessageId(threadId, messageId); + RemoveRequest(threadId, messageId); + } + RemoveEmptyThread(threadId, messages); + return removed; + } + + internal bool CancelMessage(string threadId, string messageId) + { + if (!_messages.TryGetValue(threadId, out var messages)) + return false; + var index = messages.FindIndex(message => message.Id == messageId); + if (index < 0 || + messages[index].SendState == ChatQueuedMessageSendState.Sending) + { + return false; + } + messages.RemoveAt(index); + RemovePendingLocalEcho(threadId, messageId); + RemoveRunMappingByMessageId(threadId, messageId); + RemoveRequest(threadId, messageId); + RemoveEmptyThread(threadId, messages); + return true; + } + + internal bool TryTakeForPromotion( + string threadId, + string messageId, + out ChatQueuedMessage message) + { + message = default!; + if (!_messages.TryGetValue(threadId, out var messages)) + return false; + var index = messages.FindIndex(candidate => candidate.Id == messageId); + if (index < 0) + return false; + + message = messages[index]; + messages.RemoveAt(index); + _assistantFallbackPromotedThreads.Add(threadId); + RemoveRequest(threadId, messageId); + RemoveEmptyThread(threadId, messages); + return true; + } + + internal void MarkFailed(string threadId, string messageId, string error) + { + if (!_messages.TryGetValue(threadId, out var messages)) + return; + var index = messages.FindIndex(message => message.Id == messageId); + if (index >= 0) + { + messages[index] = messages[index] with + { + SendState = ChatQueuedMessageSendState.Failed, + ErrorText = error, + }; + } + } + + internal ChatQueueRetryResult RequeueDeferredAdmission( + string threadId, + string messageId, + bool hasActiveRun) + { + if (!_messages.TryGetValue(threadId, out var messages)) + return new(false, ChatSendQueuePolicy.DrainDelay, false); + var index = messages.FindIndex(message => + message.Id == messageId && + message.SendState == ChatQueuedMessageSendState.Sending); + if (index < 0) + return new(false, ChatSendQueuePolicy.DrainDelay, false); + + var retryCount = IncrementDeferredAdmissionRetryCount( + threadId, + messageId); + if (retryCount > ChatSendQueuePolicy.MaxDeferredAdmissionRetries) + { + throw new InvalidOperationException( + $"Gateway kept chat.send status in_flight after {ChatSendQueuePolicy.MaxDeferredAdmissionRetries} retries."); + } + + messages[index] = messages[index] with + { + SendState = ChatQueuedMessageSendState.Queued, + ErrorText = null, + }; + var delay = ChatSendQueuePolicy.DeferredAdmissionRetryDelay(retryCount); + SetDeferredAdmissionRetryAfter( + threadId, + messageId, + DateTimeOffset.UtcNow + delay); + _assistantFallbackPromotedThreads.Remove(threadId); + return new(true, delay, ShouldEndTurn: !hasActiveRun); + } + + internal bool HasSendingMessages(string threadId) => + _messages.TryGetValue(threadId, out var messages) && + messages.Any(message => + message.SendState == ChatQueuedMessageSendState.Sending); + + internal bool HasPendingMessages(string threadId) => + _messages.TryGetValue(threadId, out var messages) && + messages.Any(message => + message.SendState is ChatQueuedMessageSendState.Queued or + ChatQueuedMessageSendState.Sending); + + internal bool TryGetSingleSendingMessage( + string threadId, + out ChatQueuedMessage message) + { + message = default!; + if (!_messages.TryGetValue(threadId, out var messages)) + return false; + ChatQueuedMessage? found = null; + foreach (var candidate in messages) + { + if (candidate.SendState != ChatQueuedMessageSendState.Sending || + FindRequest(threadId, candidate.Id)?.LifecycleCommand is not null) + { + continue; + } + if (found is not null) + return false; + found = candidate; + } + if (found is null) + return false; + message = found; + return true; + } + + internal bool IsLocallyInitiated(string threadId) => + _locallyInitiatedThreads.Contains(threadId); + + internal void ClearLocallyInitiatedIfIdle( + string threadId, + bool hasActiveRun, + bool turnActive) + { + if (!hasActiveRun && !turnActive && !HasPendingMessages(threadId)) + _locallyInitiatedThreads.Remove(threadId); + } + + internal void ClearLocallyInitiated(string threadId) => + _locallyInitiatedThreads.Remove(threadId); + + internal bool IsAssistantFallbackPromoted(string threadId) => + _assistantFallbackPromotedThreads.Contains(threadId); + + internal void ClearAssistantFallbackPromotion(string threadId) => + _assistantFallbackPromotedThreads.Remove(threadId); + + internal ChatLocalSentText[] SnapshotLocalEchoes(string threadId) => + _localSentTexts.TryGetValue(threadId, out var queue) + ? queue.ToArray() + : []; + + internal bool HasPendingLocalEchoText(string threadId, string text) => + !string.IsNullOrWhiteSpace(text) && + _localSentTexts.TryGetValue(threadId, out var queue) && + queue.Any(pending => + string.Equals(pending.Text, text.Trim(), StringComparison.Ordinal)); + + internal bool TryConsumeLocalEcho( + string threadId, + string echoText, + out string queuedMessageId) + { + queuedMessageId = string.Empty; + if (!_localSentTexts.TryGetValue(threadId, out var queue)) + return false; + + var now = DateTimeOffset.Now; + while (queue.Count > 0 && now - queue.Peek().SentAt > LocalEchoWindow) + queue.Dequeue(); + if (queue.Count == 0) + { + _localSentTexts.Remove(threadId); + return false; + } + + var retained = new Queue(); + var matched = false; + while (queue.Count > 0) + { + var candidate = queue.Dequeue(); + if (!matched && + string.Equals(candidate.Text, echoText, StringComparison.Ordinal)) + { + queuedMessageId = candidate.QueuedMessageId; + matched = true; + continue; + } + retained.Enqueue(candidate); + } + StoreLocalEchoQueue(threadId, retained); + return matched; + } + + internal void RemovePendingLocalEcho(string threadId, string messageId) + { + if (!_localSentTexts.TryGetValue(threadId, out var queue)) + return; + var retained = new Queue( + queue.Where(local => local.QueuedMessageId != messageId)); + StoreLocalEchoQueue(threadId, retained); + } + + internal void ClearForReconnect() + { + _locallyInitiatedThreads.Clear(); + _localSentTexts.Clear(); + _messages.Clear(); + _requests.Clear(); + _drainScheduledThreads.Clear(); + _assistantFallbackPromotedThreads.Clear(); + _messageIdsByRunId.Clear(); + } + + internal void ClearForDispose() + { + _messages.Clear(); + _requests.Clear(); + _drainScheduledThreads.Clear(); + _messageIdsByRunId.Clear(); + _localSentTexts.Clear(); + _locallyInitiatedThreads.Clear(); + } + + internal void ClearThreadForReset(string threadId) + { + _locallyInitiatedThreads.Remove(threadId); + _localSentTexts.Remove(threadId); + _messages.Remove(threadId); + _requests.Remove(threadId); + _drainScheduledThreads.Remove(threadId); + _messageIdsByRunId.Remove(threadId); + _assistantFallbackPromotedThreads.Remove(threadId); + } + + private void EnqueueLocalEcho( + string threadId, + string text, + string messageId) + { + RemovePendingLocalEcho(threadId, messageId); + if (!_localSentTexts.TryGetValue(threadId, out var queue)) + { + queue = new Queue(); + _localSentTexts[threadId] = queue; + } + queue.Enqueue(new ChatLocalSentText( + text, + DateTimeOffset.UtcNow, + messageId)); + while (queue.Count > MaxLocalEchoes) + queue.Dequeue(); + } + + private void RemoveEmptyThread( + string threadId, + List messages) + { + if (messages.Count != 0) + return; + _messages.Remove(threadId); + _drainScheduledThreads.Remove(threadId); + } + + private void StoreLocalEchoQueue( + string threadId, + Queue queue) + { + if (queue.Count == 0) + _localSentTexts.Remove(threadId); + else + _localSentTexts[threadId] = queue; + } + + private void SetDeferredAdmissionRetryAfter( + string threadId, + string messageId, + DateTimeOffset retryAfter) + { + if (!_requests.TryGetValue(threadId, out var requests)) + return; + var index = requests.FindIndex(request => request.Id == messageId); + if (index >= 0) + { + requests[index] = requests[index] with + { + DeferredAdmissionRetryAfter = retryAfter, + }; + } + } + + private int IncrementDeferredAdmissionRetryCount( + string threadId, + string messageId) + { + if (!_requests.TryGetValue(threadId, out var requests)) + return ChatSendQueuePolicy.MaxDeferredAdmissionRetries + 1; + var index = requests.FindIndex(request => request.Id == messageId); + if (index < 0) + return ChatSendQueuePolicy.MaxDeferredAdmissionRetries + 1; + var count = requests[index].DeferredAdmissionRetryCount + 1; + requests[index] = requests[index] with + { + DeferredAdmissionRetryCount = count, + }; + return count; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatResetState.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatResetState.cs new file mode 100644 index 000000000..6055ef0c9 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatResetState.cs @@ -0,0 +1,869 @@ +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal sealed record ChatResetMessageGate( + bool Drop, + string? ConsumeEchoText, + bool RequestRemoteBackfill, + AgentEventInfo? OpenedLifecycleStart); + +internal sealed record ChatResetAgentGate( + bool Drop, + bool ReloadHistory, + AgentEventInfo? OpenedLifecycleStart); + +/// +/// Owns reset generations, timestamp cutoffs, ignored/accepted runs, buffered +/// lifecycle starts, submitted-echo gates, and remote-backfill state. The root +/// supplies queue/lifecycle facts and applies any returned lifecycle start. +/// +internal sealed class ChatResetState +{ + private const long TimestampToleranceMs = 1000; + private static readonly TimeSpan LocalEchoWindow = TimeSpan.FromSeconds(30); + + private readonly Dictionary _versions = new(); + private readonly Dictionary _cutoffUtcMs = new(); + private readonly HashSet _awaitingUserMessage = new(); + private readonly Dictionary> _ignoredRunIds = new(); + private readonly Dictionary>> + _submittedLocalEchoTexts = new(); + private readonly Dictionary> _acceptedRunIds = new(); + private readonly Dictionary _localSendWithoutRunVersions = new(); + private readonly Dictionary _localSendWithoutRunStartSequences = + new(); + private readonly Dictionary _localEchoSequences = new(); + private readonly Dictionary> + _pendingLocalSubmissions = new(); + private readonly Dictionary> + _pendingLifecycleStarts = new(); + private readonly Dictionary + _acceptedLifecycleFloors = new(); + private readonly HashSet _remoteBackfillInFlight = new(); + private readonly HashSet _remoteUserSeen = new(); + + private long _lifecycleStartSequence; + + private readonly record struct PendingLifecycleStart( + AgentEventInfo Event, + long Sequence); + + private sealed record PendingLocalSubmission( + string Id, + string Text, + long Generation, + DateTimeOffset SubmittedAt, + long StartSequence, + bool RequiresEcho) + { + internal bool EchoObserved { get; set; } + internal long EchoTimestampMs { get; set; } + internal bool ConfirmedWithoutRun { get; set; } + } + + private readonly record struct AcceptedLifecycleFloor( + string RunId, + long Generation, + long TimestampMs); + + internal long LifecycleStartSequence => _lifecycleStartSequence; + internal bool IsAwaitingUserMessage(string threadId) => + _awaitingUserMessage.Contains(threadId); + + internal long GetVersion(string threadId) => + _versions.TryGetValue(threadId, out var version) ? version : 0; + + internal IReadOnlyDictionary SnapshotVersions() => + new Dictionary(_versions); + + internal long BeginReset(string threadId, long cutoffUtcMs) + { + var generation = GetVersion(threadId) + 1; + _versions[threadId] = generation; + _cutoffUtcMs[threadId] = cutoffUtcMs; + _awaitingUserMessage.Add(threadId); + _acceptedRunIds.Remove(threadId); + _localSendWithoutRunVersions.Remove(threadId); + _localSendWithoutRunStartSequences.Remove(threadId); + _localEchoSequences.Remove(threadId); + _pendingLocalSubmissions.Remove(threadId); + _pendingLifecycleStarts.Remove(threadId); + _acceptedLifecycleFloors.Remove(threadId); + _remoteBackfillInFlight.Remove(threadId); + _remoteUserSeen.Remove(threadId); + return generation; + } + + internal void ClearSubmittedEchoesForReconnect() + { + _submittedLocalEchoTexts.Clear(); + _pendingLocalSubmissions.Clear(); + _acceptedLifecycleFloors.Clear(); + } + + internal void AddIgnoredRun(string threadId, string runId) + { + if (!_ignoredRunIds.TryGetValue(threadId, out var runIds)) + { + runIds = new HashSet(StringComparer.Ordinal); + _ignoredRunIds[threadId] = runIds; + } + runIds.Add(runId); + if (_acceptedLifecycleFloors.TryGetValue( + threadId, + out var floor) && + string.Equals(floor.RunId, runId, StringComparison.Ordinal)) + { + _acceptedLifecycleFloors.Remove(threadId); + } + } + + internal void RegisterPendingLocalSubmission( + string threadId, + string submissionId, + string text, + long resetGeneration, + long startSequence, + DateTimeOffset submittedAt, + bool requiresEcho = true) + { + if (!_awaitingUserMessage.Contains(threadId) || + resetGeneration != GetVersion(threadId) || + requiresEcho && string.IsNullOrWhiteSpace(text)) + { + return; + } + if (!_pendingLocalSubmissions.TryGetValue( + threadId, + out var submissions)) + { + submissions = []; + _pendingLocalSubmissions[threadId] = submissions; + } + submissions.RemoveAll(submission => + submission.Generation == resetGeneration && + string.Equals( + submission.Id, + submissionId, + StringComparison.Ordinal)); + submissions.Add(new PendingLocalSubmission( + submissionId, + text.Trim(), + resetGeneration, + submittedAt, + startSequence, + requiresEcho)); + Logger.Debug( + $"[ResetGate] Registered local submission thread='{threadId}' generation={resetGeneration} startSequence={startSequence} pending={submissions.Count}"); + PruneExpiredLocalSubmissions(threadId, submissions); + if (submissions.Count > 32) + submissions.RemoveRange(0, submissions.Count - 32); + } + + internal void RemovePendingLocalSubmission( + string threadId, + string submissionId, + long resetGeneration) + { + if (!_pendingLocalSubmissions.TryGetValue( + threadId, + out var submissions)) + { + return; + } + submissions.RemoveAll(submission => + submission.Generation == resetGeneration && + string.Equals( + submission.Id, + submissionId, + StringComparison.Ordinal)); + if (submissions.Count == 0) + _pendingLocalSubmissions.Remove(threadId); + } + + internal void CompleteRun(string threadId, string? runId) + { + _pendingLocalSubmissions.Remove(threadId); + if (!_acceptedLifecycleFloors.TryGetValue( + threadId, + out var floor)) + { + return; + } + if (string.IsNullOrEmpty(runId) || + string.Equals(floor.RunId, runId, StringComparison.Ordinal)) + { + _acceptedLifecycleFloors.Remove(threadId); + } + } + + internal void AddSubmittedLocalEcho( + string threadId, + string text, + DateTimeOffset submittedAt) + { + if (string.IsNullOrWhiteSpace(text)) + return; + if (!_submittedLocalEchoTexts.TryGetValue(threadId, out var texts)) + { + texts = new Dictionary>( + StringComparer.Ordinal); + _submittedLocalEchoTexts[threadId] = texts; + } + var normalized = text.Trim(); + if (!texts.TryGetValue(normalized, out var timestamps)) + { + timestamps = new Queue(); + texts[normalized] = timestamps; + } + timestamps.Enqueue(submittedAt); + } + + internal ChatResetMessageGate EvaluateChatMessage( + string threadId, + string role, + string rawText, + long timestampMs, + bool hasPendingLocalEcho, + string? activeRunId = null) + { + var isNormalUserText = role == "user" && + !ChatContentFormatting.LooksLikeApprovalSlashCommand(rawText) && + !NativeToolProjector.LooksLikeSystemControlNote(rawText); + + if (isNormalUserText && + TryMatchPendingLocalSubmission( + threadId, + rawText, + out var localSubmission)) + { + localSubmission.EchoObserved = true; + localSubmission.EchoTimestampMs = timestampMs; + _localEchoSequences[threadId] = _lifecycleStartSequence; + var opened = _awaitingUserMessage.Contains(threadId) + ? TryOpenPendingLifecycle( + threadId, + acceptedRunId: null, + localSubmission) + : null; + if (opened is null && + !_awaitingUserMessage.Contains(threadId)) + { + LowerAcceptedLifecycleFloor( + threadId, + activeRunId, + timestampMs); + } + Logger.Debug( + $"[ResetGate] Matched local echo thread='{threadId}' generation={GetVersion(threadId)} awaiting={_awaitingUserMessage.Contains(threadId)} hasPending={hasPendingLocalEcho} opened={opened is not null} pendingStarts={PendingLifecycleCount(threadId)} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, timestampMs)}"); + return new( + Drop: true, + ConsumeEchoText: rawText.Trim(), + RequestRemoteBackfill: false, + OpenedLifecycleStart: opened); + } + + if (isNormalUserText && + !hasPendingLocalEcho && + TryConsumeSubmittedLocalEcho(threadId, rawText)) + { + AgentEventInfo? opened = null; + if (_awaitingUserMessage.Contains(threadId) && + !IsPreResetTimestamp(threadId, timestampMs)) + { + _localEchoSequences[threadId] = _lifecycleStartSequence; + opened = TryOpenPendingLifecycle( + threadId, + acceptedRunId: null); + } + return new( + Drop: true, + ConsumeEchoText: rawText.Trim(), + RequestRemoteBackfill: false, + OpenedLifecycleStart: opened); + } + if (!_awaitingUserMessage.Contains(threadId)) + { + var timestampAccepted = role == "user" + ? !IsPreResetTimestamp(threadId, timestampMs) + : IsTimestampAcceptedForRun( + threadId, + activeRunId, + timestampMs); + return new( + !timestampAccepted, + null, + false, + null); + } + + var isFreshUser = isNormalUserText && + !IsPreResetTimestamp(threadId, timestampMs); + if (isFreshUser && hasPendingLocalEcho) + { + _localEchoSequences[threadId] = _lifecycleStartSequence; + var opened = TryOpenPendingLifecycle(threadId, acceptedRunId: null); + return new( + Drop: opened is null, + ConsumeEchoText: rawText.Trim(), + RequestRemoteBackfill: false, + OpenedLifecycleStart: opened); + } + if (isFreshUser && timestampMs > 0) + { + _remoteUserSeen.Add(threadId); + var opened = TryOpenPendingLifecycle(threadId, acceptedRunId: null); + return new( + Drop: opened is null, + ConsumeEchoText: null, + RequestRemoteBackfill: false, + OpenedLifecycleStart: opened); + } + if (isFreshUser && _remoteBackfillInFlight.Add(threadId)) + { + return new(true, null, true, null); + } + if (isNormalUserText) + { + Logger.Debug( + $"[ResetGate] User echo did not open thread='{threadId}' generation={GetVersion(threadId)} hasPending={hasPendingLocalEcho} isFresh={isFreshUser} localProof={HasRecentCurrentLocalSubmission(threadId)} pendingStarts={PendingLifecycleCount(threadId)} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, timestampMs)}"); + } + return new(true, null, false, null); + } + + internal ChatResetAgentGate EvaluateAgentEvent( + AgentEventInfo evt, + string threadId) + { + if (string.Equals( + evt.Stream, + "lifecycle", + StringComparison.OrdinalIgnoreCase)) + { + var phase = evt.Data.ValueKind == + System.Text.Json.JsonValueKind.Object && + evt.Data.TryGetProperty("phase", out var phaseProperty) + ? phaseProperty.GetString() + : null; + Logger.Debug( + $"[ResetGate] Lifecycle event thread='{threadId}' phase='{phase ?? "(none)"}' runPresent={!string.IsNullOrEmpty(evt.RunId)} awaiting={_awaitingUserMessage.Contains(threadId)} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, evt.Ts > 0 ? (long)evt.Ts : 0)}"); + } + if (IsIgnoredRun( + threadId, + evt.RunId, + evt, + out var reloadHistory)) + { + return new(true, reloadHistory, null); + } + + var eventTimestamp = evt.Ts > 0 ? (long)evt.Ts : 0; + if (!_awaitingUserMessage.Contains(threadId)) + { + return new( + !IsTimestampAcceptedForRun( + threadId, + evt.RunId, + eventTimestamp), + false, + null); + } + if (IsAcceptedPostResetLifecycleStart( + threadId, + evt, + _lifecycleStartSequence + 1)) + { + OpenGate(threadId, evt); + return new(false, false, evt); + } + if (IsPreResetTimestamp(threadId, eventTimestamp)) + { + var hasLocalProof = + HasRecentCurrentLocalSubmission(threadId); + if (IsResetLifecycleCandidate(evt) && + hasLocalProof) + { + BufferLifecycleStart(threadId, evt); + } + Logger.Debug( + $"[ResetGate] Pre-cutoff agent event thread='{threadId}' stream='{evt.Stream}' runPresent={!string.IsNullOrEmpty(evt.RunId)} localProof={hasLocalProof} buffered={IsResetLifecycleCandidate(evt) && hasLocalProof} timestampDeltaMs={TimestampDeltaFromCutoff(threadId, eventTimestamp)}"); + return new(true, false, null); + } + if (IsResetLifecycleCandidate(evt)) + BufferLifecycleStart(threadId, evt); + return new(true, false, null); + } + + internal AgentEventInfo? AddAcceptedRun(string threadId, string runId) + { + if (!_awaitingUserMessage.Contains(threadId)) + return null; + if (!_acceptedRunIds.TryGetValue(threadId, out var runIds)) + { + runIds = new HashSet(StringComparer.Ordinal); + _acceptedRunIds[threadId] = runIds; + } + runIds.Add(runId); + return TryOpenPendingLifecycle(threadId, runId); + } + + internal AgentEventInfo? RecordLocalSendWithoutRun( + string threadId, + long resetVersion, + long lifecycleStartSequence, + string? submissionId = null) + { + _localSendWithoutRunVersions[threadId] = resetVersion; + _localSendWithoutRunStartSequences[threadId] = lifecycleStartSequence; + PendingLocalSubmission? submission = null; + if (!string.IsNullOrEmpty(submissionId) && + _pendingLocalSubmissions.TryGetValue( + threadId, + out var submissions)) + { + submission = submissions.FirstOrDefault(candidate => + candidate.Generation == resetVersion && + string.Equals( + candidate.Id, + submissionId, + StringComparison.Ordinal)); + if (submission is not null) + submission.ConfirmedWithoutRun = true; + } + if (submission is { RequiresEcho: false }) + { + return TryOpenPendingLifecycle( + threadId, + acceptedRunId: null, + submission); + } + return TryOpenPendingLifecycle(threadId, acceptedRunId: null); + } + + internal void CompleteRemoteBackfill(string threadId) => + _remoteBackfillInFlight.Remove(threadId); + + internal AgentEventInfo? RecordRemoteUser(string threadId) + { + _remoteUserSeen.Add(threadId); + return TryOpenPendingLifecycle(threadId, acceptedRunId: null); + } + + internal bool IsPreResetTimestamp(string threadId, long eventTimestampMs) + { + if (eventTimestampMs <= 0 || + !_cutoffUtcMs.TryGetValue(threadId, out var cutoff) || + cutoff <= 0) + { + return false; + } + return _versions.ContainsKey(threadId) && + eventTimestampMs + TimestampToleranceMs <= cutoff; + } + + private bool IsTimestampAcceptedForRun( + string threadId, + string? runId, + long eventTimestampMs) + { + if (!IsPreResetTimestamp(threadId, eventTimestampMs)) + return true; + return !string.IsNullOrEmpty(runId) && + _acceptedLifecycleFloors.TryGetValue( + threadId, + out var floor) && + floor.Generation == GetVersion(threadId) && + string.Equals(floor.RunId, runId, StringComparison.Ordinal) && + eventTimestampMs >= floor.TimestampMs; + } + + private void LowerAcceptedLifecycleFloor( + string threadId, + string? runId, + long timestampMs) + { + if (string.IsNullOrEmpty(runId) || + timestampMs <= 0 || + !_acceptedLifecycleFloors.TryGetValue( + threadId, + out var floor) || + floor.Generation != GetVersion(threadId) || + !string.Equals( + floor.RunId, + runId, + StringComparison.Ordinal) || + timestampMs >= floor.TimestampMs) + { + return; + } + _acceptedLifecycleFloors[threadId] = + floor with { TimestampMs = timestampMs }; + } + + private long? TimestampDeltaFromCutoff( + string threadId, + long eventTimestampMs) => + eventTimestampMs > 0 && + _cutoffUtcMs.TryGetValue(threadId, out var cutoff) + ? eventTimestampMs - cutoff + : null; + + private int PendingLifecycleCount(string threadId) => + _pendingLifecycleStarts.TryGetValue( + threadId, + out var pending) + ? pending.Count + : 0; + + private bool TryMatchPendingLocalSubmission( + string threadId, + string text, + out PendingLocalSubmission submission) + { + submission = null!; + if (string.IsNullOrWhiteSpace(text) || + !_pendingLocalSubmissions.TryGetValue( + threadId, + out var submissions)) + { + return false; + } + PruneExpiredLocalSubmissions(threadId, submissions); + var generation = GetVersion(threadId); + var normalized = text.Trim(); + var cutoff = _cutoffUtcMs.TryGetValue(threadId, out var value) + ? value + : 0; + submission = submissions.FirstOrDefault(candidate => + candidate.Generation == generation && + candidate.SubmittedAt.ToUnixTimeMilliseconds() >= cutoff && + !candidate.EchoObserved && + string.Equals( + candidate.Text, + normalized, + StringComparison.Ordinal))!; + return submission is not null; + } + + private bool HasRecentCurrentLocalSubmission(string threadId) + { + if (!_pendingLocalSubmissions.TryGetValue( + threadId, + out var submissions)) + { + return false; + } + PruneExpiredLocalSubmissions(threadId, submissions); + var generation = GetVersion(threadId); + var cutoff = _cutoffUtcMs.TryGetValue(threadId, out var value) + ? value + : 0; + return submissions.Any(submission => + submission.Generation == generation && + submission.SubmittedAt.ToUnixTimeMilliseconds() >= cutoff); + } + + private PendingLocalSubmission? FindAcceptedLocalSubmission( + string threadId, + long lifecycleStartSequence) + { + if (!_pendingLocalSubmissions.TryGetValue( + threadId, + out var submissions)) + { + return null; + } + PruneExpiredLocalSubmissions(threadId, submissions); + var generation = GetVersion(threadId); + return submissions.FirstOrDefault(submission => + submission.Generation == generation && + (submission.EchoObserved || + !submission.RequiresEcho && + submission.ConfirmedWithoutRun) && + lifecycleStartSequence > submission.StartSequence); + } + + private void PruneExpiredLocalSubmissions( + string threadId, + List submissions) + { + var now = DateTimeOffset.UtcNow; + submissions.RemoveAll(submission => + now - submission.SubmittedAt > LocalEchoWindow); + if (submissions.Count == 0) + _pendingLocalSubmissions.Remove(threadId); + } + + private bool TryConsumeSubmittedLocalEcho(string threadId, string text) + { + if (string.IsNullOrWhiteSpace(text) || + !_submittedLocalEchoTexts.TryGetValue(threadId, out var texts)) + { + return false; + } + var normalized = text.Trim(); + if (!texts.TryGetValue(normalized, out var timestamps)) + return false; + var now = DateTimeOffset.UtcNow; + while (timestamps.Count > 0 && + now - timestamps.Peek() > LocalEchoWindow) + { + timestamps.Dequeue(); + } + if (timestamps.Count == 0) + { + texts.Remove(normalized); + if (texts.Count == 0) + _submittedLocalEchoTexts.Remove(threadId); + return false; + } + timestamps.Dequeue(); + if (timestamps.Count == 0) + texts.Remove(normalized); + if (texts.Count == 0) + _submittedLocalEchoTexts.Remove(threadId); + return true; + } + + private bool IsIgnoredRun( + string threadId, + string? runId, + AgentEventInfo evt, + out bool reloadHistory) + { + reloadHistory = false; + if (string.IsNullOrEmpty(runId) || + !_ignoredRunIds.TryGetValue(threadId, out var runIds) || + !runIds.Contains(runId)) + { + return false; + } + if (ChatEventMapper.IsTerminalRunEvent(evt)) + { + runIds.Remove(runId); + if (runIds.Count == 0) + { + _ignoredRunIds.Remove(threadId); + _submittedLocalEchoTexts.Remove(threadId); + } + reloadHistory = true; + } + return true; + } + + private void BufferLifecycleStart(string threadId, AgentEventInfo evt) + { + if (!_pendingLifecycleStarts.TryGetValue(threadId, out var pending)) + { + pending = []; + _pendingLifecycleStarts[threadId] = pending; + } + if (!string.IsNullOrEmpty(evt.RunId) && + pending.Exists(item => + string.Equals( + item.Event.RunId, + evt.RunId, + StringComparison.Ordinal))) + { + return; + } + pending.Add(new PendingLifecycleStart( + evt, + ++_lifecycleStartSequence)); + if (pending.Count > 8) + pending.RemoveRange(0, pending.Count - 8); + } + + private AgentEventInfo? TryOpenPendingLifecycle( + string threadId, + string? acceptedRunId, + PendingLocalSubmission? localSubmission = null) + { + if (!_awaitingUserMessage.Contains(threadId) || + !_pendingLifecycleStarts.TryGetValue(threadId, out var pending)) + { + return null; + } + + if (localSubmission is not null) + { + var selectedIndex = -1; + if (_acceptedRunIds.TryGetValue( + threadId, + out var acceptedRuns)) + { + for (var index = pending.Count - 1; index >= 0; index--) + { + var candidate = pending[index]; + if (candidate.Sequence > localSubmission.StartSequence && + !string.IsNullOrEmpty(candidate.Event.RunId) && + acceptedRuns.Contains(candidate.Event.RunId)) + { + selectedIndex = index; + break; + } + } + } + + if (selectedIndex < 0) + { + for (var index = pending.Count - 1; index >= 0; index--) + { + if (pending[index].Sequence > + localSubmission.StartSequence) + { + selectedIndex = index; + break; + } + } + } + + if (selectedIndex < 0) + return null; + + var selected = pending[selectedIndex]; + pending.RemoveAt(selectedIndex); + OpenGate( + threadId, + selected.Event, + localSubmission.EchoTimestampMs); + return selected.Event; + } + + for (var index = 0; index < pending.Count; index++) + { + var start = pending[index]; + if (acceptedRunId is not null) + { + if (!string.Equals( + start.Event.RunId, + acceptedRunId, + StringComparison.Ordinal)) + { + continue; + } + } + else if (!IsAcceptedPostResetLifecycleStart( + threadId, + start.Event, + start.Sequence)) + { + continue; + } + pending.RemoveAt(index); + OpenGate( + threadId, + start.Event, + acceptedEchoTimestampMs: null); + return start.Event; + } + return null; + } + + private bool IsAcceptedPostResetLifecycleStart( + string threadId, + AgentEventInfo evt, + long lifecycleStartSequence) + { + if (!IsResetLifecycleCandidate(evt)) + return false; + if (!string.IsNullOrEmpty(evt.RunId) && + _acceptedRunIds.TryGetValue(threadId, out var accepted) && + accepted.Contains(evt.RunId)) + { + return true; + } + if (FindAcceptedLocalSubmission( + threadId, + lifecycleStartSequence) is not null) + { + return true; + } + if (_localSendWithoutRunVersions.TryGetValue(threadId, out var version) && + version == GetVersion(threadId) && + _localSendWithoutRunStartSequences.TryGetValue( + threadId, + out var startSequence) && + _localEchoSequences.TryGetValue(threadId, out var echoSequence) && + echoSequence >= startSequence && + lifecycleStartSequence > startSequence && + evt.Ts > 0 && + !IsPreResetTimestamp(threadId, (long)evt.Ts)) + { + return true; + } + return _remoteUserSeen.Contains(threadId) && + !IsPreResetTimestamp( + threadId, + evt.Ts > 0 ? (long)evt.Ts : 0); + } + + private void OpenGate( + string threadId, + AgentEventInfo evt, + long? acceptedEchoTimestampMs = null) + { + _awaitingUserMessage.Remove(threadId); + _remoteUserSeen.Remove(threadId); + _localSendWithoutRunVersions.Remove(threadId); + _localSendWithoutRunStartSequences.Remove(threadId); + _localEchoSequences.Remove(threadId); + _pendingLifecycleStarts.Remove(threadId); + if (acceptedEchoTimestampMs is null) + { + acceptedEchoTimestampMs = + FindAcceptedLocalSubmission( + threadId, + _lifecycleStartSequence + 1)? + .EchoTimestampMs; + } + var floorTimestamp = evt.Ts > 0 ? (long)evt.Ts : 0; + if (acceptedEchoTimestampMs is > 0 && + (floorTimestamp <= 0 || + acceptedEchoTimestampMs.Value < floorTimestamp)) + { + floorTimestamp = acceptedEchoTimestampMs.Value; + } + if (!string.IsNullOrEmpty(evt.RunId) && floorTimestamp > 0) + { + _acceptedLifecycleFloors[threadId] = + new AcceptedLifecycleFloor( + evt.RunId, + GetVersion(threadId), + floorTimestamp); + } + else + { + _acceptedLifecycleFloors.Remove(threadId); + } + if (!string.IsNullOrEmpty(evt.RunId) && + _acceptedRunIds.TryGetValue(threadId, out var accepted)) + { + accepted.Remove(evt.RunId); + if (accepted.Count == 0) + _acceptedRunIds.Remove(threadId); + } + } + + private static bool IsResetLifecycleCandidate( + AgentEventInfo evt) + { + if (ChatEventMapper.IsLifecycleStart(evt)) + return true; + return string.Equals( + evt.Stream, + "lifecycle", + StringComparison.OrdinalIgnoreCase) && + evt.Data.ValueKind == + System.Text.Json.JsonValueKind.Object && + evt.Data.TryGetProperty( + "phase", + out var phaseProperty) && + string.Equals( + phaseProperty.GetString(), + "fallback_step", + StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatRuntimeTransitions.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatRuntimeTransitions.cs new file mode 100644 index 000000000..69c4d8542 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatRuntimeTransitions.cs @@ -0,0 +1,169 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using System.Text.Json.Nodes; + +namespace OpenClawTray.Chat; + +internal sealed record ChatProjectionContext( + string? MainSessionKey, + bool HasHandshakeSnapshot); + +internal readonly record struct ChatRuntimeGeneration( + long ConnectionGeneration, + long ResetGeneration); + +internal sealed record ChatStatusTransition( + ChatDataSnapshot Snapshot, + bool Reconnected, + bool Disconnected, + string[] InterruptedThreads, + long HistoryGeneration); + +internal sealed record ChatSessionsTransition( + ChatDataSnapshot Snapshot, + string[] QueuedThreadsToDrain); + +internal sealed record ChatResetTransition( + ChatDataSnapshot Snapshot, + string? OldSessionId, + long ResetGeneration, + string ThreadId, + string[] SubmittedRunIds); + +internal sealed record ChatAbortStart( + string? RunId, + bool HadActiveTurn); + +internal sealed record ChatAgentEventTransition( + bool Process, + bool ReloadHistory, + ChatTerminalEventDropReason? DroppedTerminalReason, + string? DeferredAbortRunId, + int DeferredAbortCount, + string? CompletedRunId, + string? CompletionPhase, + bool FetchRemoteUser, + bool AllowRemoteTurn, + bool WasAborted, + bool Suppressed, + ChatEvent? MappedEvent, + ChatToolMetadataWrite? ToolMetadata, + ChatDataSnapshot[] Snapshots, + ChatOpenedLifecycleTransition? OpenedLifecycle, + ChatRuntimeGeneration RuntimeGeneration); + +internal sealed record ChatToolMetadataWrite( + string ThreadId, + string CacheKey, + long ResetGeneration, + long TimestampMs, + string ToolName, + string Label, + string? ToolCallId, + JsonObject? ToolArgs, + ChatToolIdentityStrength IdentityStrength, + string? RunId, + long LegacyTurn); + +internal sealed record ChatRunTransition( + string? DeferredAbortRunId, + int DeferredAbortCount, + ChatTerminalEventDropReason? DroppedTerminalReason, + string? CompletedRunId, + string? CompletionPhase, + bool FetchRemoteUser, + bool AllowRemoteTurn, + bool WasAborted, + ChatDataSnapshot? Snapshot); + +internal sealed record ChatAgentEventGate( + bool Process, + bool ReloadHistory, + ChatTerminalEventDropReason? DroppedTerminalReason, + ChatOpenedLifecycleTransition? OpenedLifecycle); + +internal sealed record ChatHistoryCommitToken( + string ThreadId, + long ConnectionGeneration, + long ResetGeneration, + long ReplacementGeneration); + +internal sealed record ChatHistoryReplacementTransition( + ChatDataSnapshot Snapshot, + ChatHistoryCommitToken Token); + +internal sealed record ChatHistoryRebuildPlan( + string? SessionId, + ChatTimelineState Timeline, + IReadOnlyDictionary Metadata, + int MaxHistorySequence); + +internal sealed record ChatQueuedAdmission( + string MessageId, + bool Queued, + ChatQueuedSendDispatch? Dispatch, + ChatDataSnapshot Snapshot, + ChatRuntimeGeneration RuntimeGeneration); + +internal sealed record ChatQueueStart( + ChatQueuedSendDispatch? Dispatch, + TimeSpan? DelayedRetry, + ChatDataSnapshot? Snapshot); + +internal sealed record ChatOpenedLifecycleTransition( + AgentEventInfo Event, + bool AllowRemoteTurn, + string? DeferredAbortRunId, + int DeferredAbortCount); + +internal sealed record ChatSendCommit( + bool IsCurrent, + ChatDataSnapshot? AcceptedSnapshot, + ChatDataSnapshot? RequeuedSnapshot, + string? StaleRunIdToAbort, + bool BindAcceptedRun, + bool RequeueRequired, + bool RetryDeferredSend, + TimeSpan DeferredRetryDelay, + ChatOpenedLifecycleTransition? OpenedLifecycle, + ChatRuntimeGeneration RuntimeGeneration); + +internal sealed record ChatSendFailure( + bool IsCurrent, + ChatDataSnapshot? Snapshot); + +internal sealed record ChatSendPreparation( + bool IsCurrent, + ChatDataSnapshot? Snapshot); + +internal sealed record ChatModelPatchLease( + string ThreadId, + Task? Previous, + TaskCompletionSource Completion); + +internal sealed record ChatDisposeTransition( + long HistoryGeneration, + bool IsFirstDispose); + +internal sealed record ChatIncomingMessageGate( + bool Drop, + bool Suppressed, + bool RequestRemoteBackfill, + ChatDataSnapshot? Snapshot, + ChatOpenedLifecycleTransition? OpenedLifecycle, + ChatRuntimeGeneration RuntimeGeneration); + +internal sealed record ChatRemoteUserBackfillTransition( + ChatDataSnapshot Snapshot, + ChatOpenedLifecycleTransition? OpenedLifecycle, + ChatRuntimeGeneration RuntimeGeneration); + +internal sealed record ChatLocalEchoTransition( + bool Consumed, + ChatDataSnapshot? Snapshot); + +internal sealed record ChatAssistantPreparation( + AssistantQueueFrameDisposition Disposition, + ChatDataSnapshot? PromotionSnapshot, + ChatEntryMetadata Metadata, + string? ActiveRunId); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatSendQueue.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatSendQueue.cs new file mode 100644 index 000000000..7f141881a --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatSendQueue.cs @@ -0,0 +1,99 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; + +namespace OpenClawTray.Chat; + +internal sealed record ChatQueuedSendRequest( + string Id, + string SendRunId, + string ThreadId, + string Text, + string DisplayText, + string LocalNonce, + IReadOnlyList? Attachments, + int DeferredAdmissionRetryCount = 0, + DateTimeOffset? DeferredAdmissionRetryAfter = null, + ChatLifecycleCommandKind? LifecycleCommand = null); + +internal sealed record ChatQueuedSendDispatch( + ChatQueuedSendRequest Request, + string? SessionId, + long ConnectionGeneration, + long ResetVersion, + long StartedLifecycleSequence, + long StartedRunStartSequence, + bool StartedDirectly); + +internal enum AssistantQueueFrameDisposition +{ + Render, + Drop, +} + +internal enum ChatAdmissionOutcome +{ + Accepted, + Deferred, + Rejected, + Canceled, + Other, +} + +internal static class ChatSendQueuePolicy +{ + internal const int MaxDeferredAdmissionRetries = 8; + internal static readonly TimeSpan DrainDelay = TimeSpan.FromMilliseconds(100); + internal static readonly TimeSpan MaxDeferredAdmissionRetryDelay = TimeSpan.FromSeconds(1); + + internal static bool CanSendDirectly( + bool hasActiveRun, + bool turnActive, + bool hasPendingMessages) => + !hasActiveRun && !turnActive && !hasPendingMessages; + + internal static bool CanStartNext( + bool requireConnected, + ConnectionStatus status, + bool hasActiveRun, + bool turnActive, + bool hasSendingMessage) => + (!requireConnected || status == ConnectionStatus.Connected) + && !hasActiveRun + && !turnActive + && !hasSendingMessage; + + internal static bool IsDeferredAdmissionStatus(string? status) => + string.Equals(status, "in_flight", StringComparison.OrdinalIgnoreCase); + + internal static bool IsCanceledAdmissionStatus(string? status) => + string.Equals(status, "aborted", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "cancelled", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "canceled", StringComparison.OrdinalIgnoreCase); + + internal static ChatAdmissionOutcome ClassifyAdmission( + ChatSendResult result) + { + if (IsDeferredAdmissionStatus(result.Status)) + return ChatAdmissionOutcome.Deferred; + if (result.IsTerminalFailure) + { + return IsCanceledAdmissionStatus(result.Status) + ? ChatAdmissionOutcome.Canceled + : ChatAdmissionOutcome.Rejected; + } + if (string.IsNullOrWhiteSpace(result.Status) || + string.Equals(result.Status, "started", StringComparison.OrdinalIgnoreCase)) + { + return ChatAdmissionOutcome.Accepted; + } + return ChatAdmissionOutcome.Other; + } + + internal static TimeSpan DeferredAdmissionRetryDelay(int retryCount) + { + var exponent = Math.Min(Math.Max(retryCount - 1, 0), 5); + var delayMs = DrainDelay.TotalMilliseconds * (1 << exponent); + return TimeSpan.FromMilliseconds( + Math.Min(delayMs, MaxDeferredAdmissionRetryDelay.TotalMilliseconds)); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs new file mode 100644 index 000000000..24371d638 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatSnapshotProjector.cs @@ -0,0 +1,156 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal sealed record ChatSnapshotProjectionInput( + SessionInfo[] Sessions, + IReadOnlyDictionary Timelines, + IReadOnlyDictionary TimelineGenerations, + IReadOnlyDictionary HistoryRevisions, + IReadOnlyDictionary> QueuedMessages, + bool SessionsListReceived, + string[] AvailableModels, + IReadOnlyList ModelChoices, + CommandCatalog? CommandCatalog, + ConnectionStatus Status, + string? MainSessionKey, + bool HasHandshakeSnapshot, + string? RememberedDefaultThreadId, + string? RememberedThreadTitle, + string? RememberedModel, + string? RememberedModelProvider); + +internal static class ChatSnapshotProjector +{ + internal static ChatDataSnapshot Project(ChatSnapshotProjectionInput input) + { + var threadList = new List(input.Sessions.Length + 1); + var threadTitles = SessionTitleFormatter.FormatUnique(input.Sessions); + for (var i = 0; i < input.Sessions.Length; i++) + threadList.Add(ToThread(input.Sessions[i], threadTitles[i])); + + var composeKey = input.MainSessionKey; + var composeAgentId = input.Sessions + .FirstOrDefault(session => + string.Equals(session.Key, composeKey, StringComparison.Ordinal)) is { } mainSession + ? SessionDisplayResolver.Resolve(mainSession).AgentId ?? "main" + : "main"; + var composeReady = input.HasHandshakeSnapshot + && !string.IsNullOrWhiteSpace(composeKey) + && input.Status == ConnectionStatus.Connected + && input.SessionsListReceived; + + if (composeReady + && composeKey is { } key + && input.Timelines.TryGetValue(key, out var pendingTimeline) + && (pendingTimeline.Entries.Count > 0 + || pendingTimeline.TurnActive + || input.QueuedMessages.TryGetValue(key, out var pendingQueue) && + pendingQueue.Count > 0) + && !input.Sessions.Any(session => + string.Equals(session.Key, key, StringComparison.Ordinal))) + { + threadList.Add(new ChatThread + { + Id = key, + AgentId = composeAgentId, + Title = input.RememberedThreadTitle ?? "OpenClaw Windows Tray", + Model = input.RememberedModel, + ModelProvider = input.RememberedModelProvider, + Status = ChatThreadStatus.Running, + Activity = ChatActivity.Idle, + }); + } + + var connectionLabel = input.Status == ConnectionStatus.Connected + && input.HasHandshakeSnapshot + && string.IsNullOrWhiteSpace(composeKey) + ? "Incompatible gateway" + : input.Status switch + { + ConnectionStatus.Connected => "Connected", + ConnectionStatus.Connecting => "Connecting…", + ConnectionStatus.Disconnected => "Disconnected", + ConnectionStatus.Error => "Disconnected — error", + _ => input.Status.ToString(), + }; + + return new ChatDataSnapshot( + Threads: threadList.ToArray(), + Timelines: input.Timelines, + DefaultThreadId: ResolveDefaultThreadId(input), + ConnectionStatus: connectionLabel, + AvailableModels: input.AvailableModels, + ComposeTarget: composeReady + ? new ChatComposeTarget(composeKey, true, composeAgentId) + : ChatComposeTarget.NotReady, + ModelChoices: input.ModelChoices, + AvailableCommands: input.CommandCatalog?.Commands, + CommandsSupported: input.CommandCatalog?.IsSupported ?? true, + TimelineGenerations: input.TimelineGenerations, + HistoryRevisions: input.HistoryRevisions, + QueuedMessagesByThread: input.QueuedMessages); + } + + internal static string? ResolveDefaultThreadId(ChatSnapshotProjectionInput input) + { + if (input.RememberedDefaultThreadId is { Length: > 0 } remembered && + (input.Sessions.Any(session => + string.Equals(session.Key, remembered, StringComparison.Ordinal)) || + !input.SessionsListReceived)) + { + return remembered; + } + + foreach (var session in input.Sessions) + { + if (session.IsMain && !string.IsNullOrEmpty(session.Key)) + return session.Key; + } + if (input.HasHandshakeSnapshot && + input.MainSessionKey is { } mainKey && + !string.IsNullOrWhiteSpace(mainKey)) + { + return mainKey; + } + return input.Sessions.FirstOrDefault(session => + !string.IsNullOrEmpty(session.Key))?.Key; + } + + private static ChatThread ToThread(SessionInfo session, string title) + { + var display = SessionDisplayResolver.Resolve(session); + return new ChatThread + { + Id = session.Key ?? string.Empty, + Title = title, + AgentId = display.AgentId, + IsBackground = display.IsBackground, + Status = SessionVisibilityFilter.ToChatThreadStatus(session), + Activity = SessionVisibilityFilter.ToChatThreadActivity(session), + Workspace = session.Channel, + Model = session.Model, + ModelProvider = session.Provider, + ThinkingLevel = session.ThinkingLevel, + InputTokens = session.InputTokens, + OutputTokens = session.OutputTokens, + TotalTokens = session.TotalTokens, + ContextTokens = session.ContextTokens, + CreatedAt = session.StartedAt is { } started ? ToOffset(started) : null, + UpdatedAt = session.UpdatedAt is { } updated ? ToOffset(updated) : null, + }; + } + + private static DateTimeOffset ToOffset(DateTime value) + { + if (value.Kind == DateTimeKind.Unspecified) + { + return new DateTimeOffset( + DateTime.SpecifyKind(value, DateTimeKind.Utc), + TimeSpan.Zero); + } + return new DateTimeOffset(value); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatStatePersistence.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatStatePersistence.cs new file mode 100644 index 000000000..054430882 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatStatePersistence.cs @@ -0,0 +1,389 @@ +using System.Text.Json; +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Chat; + +internal sealed class ChatStatePersistence : IDisposable +{ + private readonly object _gate = new(); + private readonly SemaphoreSlim _abortedFileGate = new(1, 1); + private readonly string _lastStatePath; + private readonly string _abortedIdsPath; + private readonly TimeSpan _lastStateSaveDelay; + private readonly Dictionary> _abortedIds; + private readonly Dictionary> + _abortedIdGenerations; + private readonly Dictionary _resetGenerations = new(StringComparer.Ordinal); + private Timer? _lastStateSaveTimer; + private long _lastStateSaveVersion; + private OpenClawChatDataProvider.LastChatState? _lastState; + private bool _disposed; + + internal ChatStatePersistence( + string? lastStatePath = null, + TimeSpan? lastStateSaveDelay = null, + string? abortedIdsPath = null) + { + _lastStatePath = !string.IsNullOrWhiteSpace(lastStatePath) + ? lastStatePath + : ChatMetadataStore.LastChatStateFilePath; + _lastStateSaveDelay = lastStateSaveDelay ?? TimeSpan.FromSeconds(2); + _abortedIdsPath = !string.IsNullOrWhiteSpace(abortedIdsPath) + ? abortedIdsPath + : ChatMetadataStore.AbortedIdsFilePath; + InitialLastChatState = LoadLastChatState(_lastStatePath); + _lastState = InitialLastChatState; + _abortedIds = LoadAbortedIds(_abortedIdsPath); + _abortedIdGenerations = _abortedIds.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToDictionary( + id => id, + _ => 0L, + StringComparer.Ordinal), + StringComparer.Ordinal); + } + + internal OpenClawChatDataProvider.LastChatState? InitialLastChatState { get; } + + internal bool IsMessageAborted(string threadId, string? openClawId) + => IsMessageAborted(threadId, openClawId, resetGeneration: 0); + + internal bool IsMessageAborted( + string threadId, + string? openClawId, + long resetGeneration) + { + if (openClawId is null) + return false; + lock (_gate) + { + return _abortedIds.TryGetValue(threadId, out var ids) && + ids.Contains(openClawId) && + _abortedIdGenerations.TryGetValue(threadId, out var generations) && + generations.TryGetValue(openClawId, out var generation) && + generation >= resetGeneration; + } + } + + internal bool ApplyReset(string threadId, long resetGeneration) + { + lock (_gate) + { + if (_resetGenerations.TryGetValue(threadId, out var current) && + current >= resetGeneration) + { + return false; + } + _resetGenerations[threadId] = resetGeneration; + if (!_abortedIds.TryGetValue(threadId, out var ids) || + !_abortedIdGenerations.TryGetValue( + threadId, + out var generations)) + { + return false; + } + var removed = ids.RemoveWhere(id => + !generations.TryGetValue(id, out var generation) || + generation < resetGeneration) > 0; + foreach (var id in generations + .Where(pair => pair.Value < resetGeneration) + .Select(pair => pair.Key) + .ToArray()) + { + generations.Remove(id); + } + if (ids.Count == 0) + { + _abortedIds.Remove(threadId); + _abortedIdGenerations.Remove(threadId); + } + return removed; + } + } + + internal void SaveAbortedIds() + { + _abortedFileGate.Wait(); + try + { + Dictionary> snapshot; + lock (_gate) + { + snapshot = _abortedIds.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToList(), + StringComparer.Ordinal); + } + + var directory = Path.GetDirectoryName(_abortedIdsPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + var path = _abortedIdsPath; + var tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText( + tempPath, + JsonSerializer.Serialize( + snapshot, + new JsonSerializerOptions { WriteIndented = true })); + File.Move(tempPath, path, overwrite: true); + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch (Exception ex) + { + Logger.Debug( + $"Aborted ID temp file cleanup failed: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Logger.Debug($"Chat aborted ID persistence failed: {ex.Message}"); + } + finally + { + _abortedFileGate.Release(); + } + } + + internal IReadOnlyList FindAbortedMessageIds( + string threadId, + IReadOnlyList messages, + long resetGeneration = 0) => + FindAbortedMessageIds(messages, threadId, resetGeneration); + + internal bool TryAddAbortedIds( + string threadId, + long resetGeneration, + IReadOnlyList newIds) + { + if (newIds.Count == 0) + return false; + lock (_gate) + { + if (_resetGenerations.TryGetValue(threadId, out var fence) && + resetGeneration < fence) + { + return false; + } + if (!_abortedIds.TryGetValue(threadId, out var ids)) + { + ids = new HashSet(StringComparer.Ordinal); + _abortedIds[threadId] = ids; + } + if (!_abortedIdGenerations.TryGetValue( + threadId, + out var generations)) + { + generations = new Dictionary( + StringComparer.Ordinal); + _abortedIdGenerations[threadId] = generations; + } + var changed = false; + foreach (var id in newIds) + { + changed |= ids.Add(id); + if (!generations.TryGetValue(id, out var generation) || + generation < resetGeneration) + { + generations[id] = resetGeneration; + } + } + return changed; + } + } + + internal void SaveSelectedState(OpenClawChatDataProvider.LastChatState state) + { + Timer? timer; + lock (_gate) + { + timer = _lastStateSaveTimer; + _lastStateSaveTimer = null; + _lastStateSaveVersion++; + _lastState = state; + } + timer?.Dispose(); + SaveLastChatState(state, _lastStatePath); + } + + internal void DebounceSnapshot(ChatDataSnapshot snapshot) + { + var defaultThread = snapshot.DefaultThreadId is { } defaultId + ? Array.Find(snapshot.Threads, thread => thread.Id == defaultId) + : snapshot.Threads.FirstOrDefault(); + if (defaultThread is null && snapshot.AvailableModels.Length == 0) + return; + + lock (_gate) + { + if (_disposed) + return; + var previous = _lastState; + var state = new OpenClawChatDataProvider.LastChatState + { + DefaultThreadId = snapshot.DefaultThreadId ?? previous?.DefaultThreadId, + ThreadTitle = defaultThread?.Title ?? previous?.ThreadTitle, + Model = defaultThread?.Model ?? previous?.Model, + ModelProvider = defaultThread?.ModelProvider ?? previous?.ModelProvider, + AvailableModels = snapshot.AvailableModels.ToArray(), + }; + _lastState = state; + var version = ++_lastStateSaveVersion; + _lastStateSaveTimer?.Dispose(); + _lastStateSaveTimer = new Timer( + _ => SaveLastStateIfCurrent(state, version), + null, + _lastStateSaveDelay, + Timeout.InfiniteTimeSpan); + } + } + + public void Dispose() + { + Timer? timer; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + timer = _lastStateSaveTimer; + _lastStateSaveTimer = null; + _lastStateSaveVersion++; + } + timer?.Dispose(); + } + + internal static OpenClawChatDataProvider.LastChatState? LoadLastChatState( + string? pathOverride = null) + { + var path = pathOverride ?? ChatMetadataStore.LastChatStateFilePath; + try + { + if (!File.Exists(path)) + return null; + return JsonSerializer.Deserialize( + File.ReadAllText(path)); + } + catch (Exception ex) + { + Logger.Warn($"Failed to load last chat state from '{path}': {ex.Message}"); + return null; + } + } + + private static Dictionary> LoadAbortedIds(string path) + { + try + { + if (!File.Exists(path)) + return []; + var persisted = JsonSerializer.Deserialize>>( + File.ReadAllText(path)); + return persisted?.ToDictionary( + pair => pair.Key, + pair => new HashSet(pair.Value, StringComparer.Ordinal), + StringComparer.Ordinal) ?? []; + } + catch (Exception ex) + { + Logger.Debug($"Aborted message IDs could not be loaded: {ex.Message}"); + return []; + } + } + + private List FindAbortedMessageIds( + IReadOnlyList messages, + string threadId, + long resetGeneration) + { + var result = new List(); + for (var i = 0; i < messages.Count; i++) + { + var message = messages[i]; + if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) || + message.OpenClawId is null || + IsMessageAborted( + threadId, + message.OpenClawId, + resetGeneration) || + result.Contains(message.OpenClawId, StringComparer.Ordinal)) + { + continue; + } + + ChatMessageInfo? nextAssistant = null; + for (var j = i + 1; j < messages.Count; j++) + { + var candidate = messages[j]; + if (string.Equals( + candidate.Role, + "assistant", + StringComparison.OrdinalIgnoreCase)) + { + nextAssistant = candidate; + break; + } + if (string.Equals( + candidate.Role, + "user", + StringComparison.OrdinalIgnoreCase)) + { + break; + } + } + + if (nextAssistant is null || + !string.IsNullOrEmpty(nextAssistant.StopReason) && + !string.Equals(nextAssistant.StopReason, "stop", StringComparison.OrdinalIgnoreCase) && + !string.Equals(nextAssistant.StopReason, "end_turn", StringComparison.OrdinalIgnoreCase) && + !string.Equals(nextAssistant.StopReason, "toolUse", StringComparison.OrdinalIgnoreCase)) + { + result.Add(message.OpenClawId); + } + } + return result; + } + + private void SaveLastStateIfCurrent( + OpenClawChatDataProvider.LastChatState state, + long version) + { + lock (_gate) + { + if (_disposed || version != _lastStateSaveVersion) + return; + SaveLastChatState(state, _lastStatePath); + _lastStateSaveTimer?.Dispose(); + _lastStateSaveTimer = null; + } + } + + private static void SaveLastChatState( + OpenClawChatDataProvider.LastChatState state, + string path) + { + try + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + var tempPath = path + ".tmp"; + File.WriteAllText(tempPath, JsonSerializer.Serialize(state)); + File.Move(tempPath, path, overwrite: true); + } + catch (Exception ex) + { + Logger.Debug($"Chat state persistence failed: {ex.Message}"); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs index e888ab2fb..bfea2582f 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatTelemetryTracker.cs @@ -155,7 +155,11 @@ internal sealed class ChatTelemetryTracker private readonly Dictionary _turnsByMessageId = new(StringComparer.Ordinal); private readonly Dictionary _turnsByRunId = new(StringComparer.Ordinal); - public void StartLocalTurn(string messageId, string threadId, bool queued) + public void StartLocalTurn( + string messageId, + string threadId, + bool queued, + ChatRuntimeGeneration? runtimeGeneration = null) { ArgumentException.ThrowIfNullOrWhiteSpace(messageId); @@ -172,7 +176,8 @@ public void StartLocalTurn(string messageId, string threadId, bool queued) TurnSpanName, default(ActivityContext), [OpenClawTelemetryTag.String(OpenClawTelemetryTagKey.Source, SourceLocal)]), - Stopwatch.GetTimestamp()); + Stopwatch.GetTimestamp(), + runtimeGeneration); if (queued) StartQueueSegmentLocked(state); @@ -248,7 +253,11 @@ public void ObserveAdmissionAccepted(string messageId) } } - public void ObserveLifecycleStart(string threadId, string? runId, bool allowRemoteTurn = true) + public void ObserveLifecycleStart( + string threadId, + string? runId, + bool allowRemoteTurn = true, + ChatRuntimeGeneration? runtimeGeneration = null) { ArgumentException.ThrowIfNullOrWhiteSpace(threadId); if (string.IsNullOrWhiteSpace(runId)) @@ -284,7 +293,9 @@ public void ObserveLifecycleStart(string threadId, string? runId, bool allowRemo var pendingLocal = _turnsByMessageId.Values.FirstOrDefault( state => state.Source == SourceLocal && state.ThreadId == threadId && - state.IsDispatched); + state.IsDispatched && + (runtimeGeneration is null || + state.RuntimeGeneration == runtimeGeneration)); if (pendingLocal is not null) { BindRunLocked(pendingLocal, runId); @@ -302,7 +313,8 @@ public void ObserveLifecycleStart(string threadId, string? runId, bool allowRemo TurnSpanName, default(ActivityContext), [OpenClawTelemetryTag.String(OpenClawTelemetryTagKey.Source, SourceRemote)]), - Stopwatch.GetTimestamp()); + Stopwatch.GetTimestamp(), + runtimeGeneration); BindRunLocked(remote, runId); StartResponseWaitLocked(remote); } @@ -489,6 +501,33 @@ public void FinishThread( reason); } + public void FinishBeforeConnectionGeneration( + long connectionGeneration, + ChatTelemetryOutcome outcome, + ChatTurnTelemetryReason reason) => + FinishStates( + RemoveWhere(state => + state.RuntimeGeneration is not { } generation || + generation.ConnectionGeneration < connectionGeneration), + outcome, + reason); + + public void FinishThreadBeforeResetGeneration( + string threadId, + long resetGeneration, + ChatTelemetryOutcome outcome, + ChatTurnTelemetryReason reason) + { + ArgumentException.ThrowIfNullOrWhiteSpace(threadId); + FinishStates( + RemoveWhere(state => + state.ThreadId == threadId && + (state.RuntimeGeneration is not { } generation || + generation.ResetGeneration < resetGeneration)), + outcome, + reason); + } + public void FinishAll(ChatTelemetryOutcome outcome, ChatTurnTelemetryReason reason) => FinishStates(RemoveWhere(static _ => true), outcome, reason); @@ -959,7 +998,8 @@ private sealed class TurnState( string threadId, string source, Activity? activity, - long startTimestamp) + long startTimestamp, + ChatRuntimeGeneration? runtimeGeneration) { private long? _queueSegmentStart; @@ -968,6 +1008,7 @@ private sealed class TurnState( public string Source { get; } = source; public Activity? Activity { get; } = activity; public long StartTimestamp { get; } = startTimestamp; + public ChatRuntimeGeneration? RuntimeGeneration { get; } = runtimeGeneration; public HashSet RunIds { get; } = new(StringComparer.Ordinal); public bool IsDispatched { get; set; } public bool WasQueued { get; private set; } diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 86816f334..ba33ca255 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Text.Encodings.Web; using System.Text.Json; -using System.Text.Json.Nodes; using OpenClaw.Chat; using OpenClaw.Shared; #if !OPENCLAW_TRAY_TESTS @@ -71,12 +70,7 @@ internal static class LocalizationHelper /// public sealed class OpenClawChatDataProvider : IChatDataProvider { - private const long ResetTimestampToleranceMs = 1000; - private static readonly JsonSerializerOptions CacheJsonOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; + internal const int MaxEntryTextBytes = 256 * 1024; /// /// Process-wide cache mapping an attachment's filename to its raw image /// bytes. Populated by for image @@ -89,144 +83,20 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly IChatGatewayBridge _bridge; private readonly ChatTelemetryTracker _telemetry = new(); + private readonly ChatMetadataStore _metadataStore; + private readonly ChatStatePersistence _persistence; + private readonly ChatConversationState _state; + private readonly ChatHistoryLoader _historyLoader; private readonly Action? _post; - private readonly object _gate = new(); - private readonly object _toolMetaSaveGate = new(); - private readonly object _attachmentMetaSaveGate = new(); - private readonly string _toolMetaCacheFilePath; - private readonly string _attachmentMetaCacheFilePath; - private readonly string _lastChatStateFilePath; - private readonly TimeSpan _lastChatStateSaveDelay; - private readonly Func, Task> _scheduleHistoryRetry; - private readonly Action? _historyFailureReservedForTesting; - private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes - private long _toolMetaSaveVersion; - private bool _toolMetaCacheDirty; - private readonly Dictionary _timelines = new(); - private readonly Dictionary _activeRunIds = new(); // sessionKey → runId - private readonly Dictionary _activeRunStartSequences = new(); // sessionKey → lifecycle.start sequence - private readonly Dictionary _pendingAbortCounts = new(); // threads → count of pending aborts waiting for lifecycle.start - private readonly HashSet _abortedRunIds = new(); // runIds whose events should be suppressed - private readonly HashSet _abortedThreads = new(); // threads with active abort — suppress chat messages (no runId on those) - private Dictionary> _persistedAbortedIds; // threadId → set of __openclaw.id values (loaded from disk) - private readonly SemaphoreSlim _persistLock = new(1, 1); // serialize persist calls to avoid races + private readonly Func, Task> _deferredAbortScheduler; /// Whether any thread is in an aborted state (suppress TTS/notifications). - public bool IsResponseSuppressed { get { lock (_gate) return _abortedThreads.Count > 0; } } - - private readonly Dictionary _sessionIds = new(); // sessionKey → immutable sessionId - private readonly HashSet _historyLoaded = new(); // sessionKey - private readonly HashSet _historyInFlight = new(); // sessionKey - private readonly HashSet _authoritativeHistoryReloadPending = new(); // sessionKey - private readonly HashSet _replacementHistoryReloadPending = new(); // sessionKey - private readonly Dictionary _historyReplacementVersions = new(); // sessionKey -> replacement generation - private CancellationTokenSource _historyGenerationCancellation = new(); - private long _historyConnectionVersion; - private readonly Dictionary _pendingModelPatches = new(); // sessionKey -> in-flight model set/clear - private readonly Dictionary _resetVersions = new(); // sessionKey -> reset generation - private readonly Dictionary _historyRevisions = new(); // sessionKey -> completed history rebuild revision - private readonly Dictionary _resetCutoffUtcMs = new(); // sessionKey -> local reset time - private readonly HashSet _resetAwaitingUserMessage = new(); // threads reset and waiting for first post-reset turn - private readonly Dictionary> _resetIgnoredRunIds = new(); // sessionKey -> pre-reset run IDs to drop - private readonly Dictionary>> _resetSubmittedLocalEchoTexts = new(); // sessionKey -> pre-reset local user echoes that reached the gateway - private readonly Dictionary> _resetAcceptedRunIds = new(); // sessionKey -> post-reset run IDs allowed to open the gate - private readonly Dictionary _resetLocalSendWithoutRunVersions = new(); // sessionKey -> reset generation for no-runId sends - private readonly Dictionary _resetLocalSendWithoutRunStartSequences = new(); // sessionKey -> lifecycle sequence at local send start - private readonly Dictionary _resetLocalEchoSequences = new(); // sessionKey -> lifecycle sequence when local echo was observed - private readonly Dictionary> _resetPendingLifecycleStarts = new(); // sessionKey -> lifecycle.start seen before proof - private readonly HashSet _resetRemoteBackfillInFlight = new(); // threads proving a timestamp-less remote user frame via history - private long _resetLifecycleStartSequence; - private long _lifecycleStartSequence; - private readonly HashSet _resetRemoteUserSeen = new(); // threads with a fresh remote post-reset user frame - private readonly Dictionary _resetClearedSessionIds = new(); // sessionKey -> sessionId cleared by reset - // Per-session cache of tool metadata from live SSE events. - // Keyed by gateway sessionId (immutable UUID). Persisted to disk - // so that history reconstruction on restart can recover tool names. - private Dictionary> _toolMetaCache; - private Dictionary> _attachmentMetaCache; - // Track recently-sent local user message texts so we can suppress - // SSE echoes while still displaying messages from other clients. - private readonly Dictionary> _localSentTexts = new(); - private readonly Dictionary> _queuedMessages = new(); - private readonly Dictionary> _queuedSendRequests = new(); - private readonly Dictionary> _queuedMessageIdsByRunId = new(); - private readonly Dictionary> _terminalRunIdsByThread = new(); - private readonly HashSet _queuedDrainScheduledThreads = new(StringComparer.Ordinal); - private readonly HashSet _assistantFallbackPromotedThreads = new(StringComparer.Ordinal); - private long _queuedMessageSequence; - private int _keylessEventDiagnosticRaised; - // Threads where we locally initiated the current turn (via SendMessageAsync). - // When lifecycle.start arrives for a thread NOT in this set, we know a remote - // client started the turn and should fetch the user message from history. - private readonly HashSet _locallyInitiatedThreads = new(); - // Per-thread retry count for LoadHistoryAsync to prevent unbounded retry loops. - private readonly Dictionary _historyRetryCount = new(); - private const int MaxHistoryRetries = 3; - private static readonly TimeSpan HistoryRetryDelay = TimeSpan.FromSeconds(2); - private const int MaxDeferredAdmissionRetries = 8; - private static readonly TimeSpan LocalEchoSuppressionWindow = TimeSpan.FromSeconds(30); - private static readonly TimeSpan DeferredQueueDrainDelay = TimeSpan.FromMilliseconds(100); - private static readonly TimeSpan MaxDeferredAdmissionRetryDelay = TimeSpan.FromSeconds(1); - private readonly record struct LocalSentText(string Text, DateTimeOffset SentAt, string QueuedMessageId); - private sealed record QueuedSendRequest( - string Id, - string SendRunId, - string ThreadId, - string Text, - string DisplayText, - string LocalNonce, - IReadOnlyList? Attachments, - int DeferredAdmissionRetryCount = 0, - DateTimeOffset? DeferredAdmissionRetryAfter = null, - ChatLifecycleCommandKind? LifecycleCommand = null); - private sealed record QueuedSendDispatch( - QueuedSendRequest Request, - string? SessionId, - long ResetVersion, - long StartedLifecycleSequence, - long StartedRunStartSequence, - ChatTelemetryTracker.QueuePhaseCompletion? QueueCompletion, - bool StartedDirectly); - private enum AssistantQueueFrameDisposition - { - Render, - Drop, - } - // Per-thread, per-entry metadata: timestamp + model snapshot at the - // moment the entry was created. Built up as events are applied so the - // timeline renderer can show a " · · " footer - // beneath each message without having to extend the vendored - // record. - private readonly Dictionary> _entryMeta = new(); - private SessionInfo[] _sessions = Array.Empty(); - // True once the gateway has delivered a sessions list (even an empty - // one) for the current connection. Used to gate the synthetic - // compose-only thread so the UI doesn't briefly render the welcome - // zero-state in the window between hello-ok (HasHandshakeSnapshot) - // and the first sessions.list — at that point the gateway may still - // be about to deliver real sessions for a returning user. Reset to - // false on disconnect alongside `_status`. - private bool _sessionsListReceived; - private string[] _availableModels = Array.Empty(); - private IReadOnlyList _modelChoices = Array.Empty(); - // Gateway command catalog (commands.list), fetched on demand via the typed - // protocol API. Null until the first fetch completes so the UI can - // distinguish "still loading" from "loaded but empty". When the gateway - // reports the method unsupported the catalog carries IsSupported=false. - private CommandCatalog? _commandCatalog; - // Guards against overlapping in-flight commands.list fetches. - private bool _commandsFetchInFlight; - // Bumped on every transition out of Connected so a commands.list fetch that - // was already in flight at disconnect time is discarded on completion rather - // than resurrecting a catalog for a stale connection. - private int _commandsEpoch; - private ConnectionStatus _status; - private bool _disposed; + public bool IsResponseSuppressed => _state.IsResponseSuppressed; public string DisplayName => "OpenClaw gateway"; /// Last-known chat state from a previous session, used for pre-connection UI. - internal LastChatState? CachedLastChatState => _lastChatState; + internal LastChatState? CachedLastChatState => _state.CachedLastChatState; public event EventHandler? Changed; public event EventHandler? NotificationRequested; @@ -241,7 +111,7 @@ private enum AssistantQueueFrameDisposition /// the source event on (acceptable in unit tests). /// public OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post = null) - : this(bridge, post, DefaultToolMetaCacheFilePath) + : this(bridge, post, ChatMetadataStore.DefaultToolMetaCacheFilePath) { } @@ -253,47 +123,32 @@ internal OpenClawChatDataProvider( string? lastChatStateFilePath = null, TimeSpan? lastChatStateSaveDelay = null, Func, Task>? historyRetryScheduler = null, - Action? historyFailureReservedForTesting = null) + Action? historyFailureReservedForTesting = null, + Func, Task>? deferredAbortScheduler = null) { _bridge = bridge ?? throw new ArgumentNullException(nameof(bridge)); _post = post; - _toolMetaCacheFilePath = !string.IsNullOrWhiteSpace(toolMetaCacheFilePath) - ? toolMetaCacheFilePath - : throw new ArgumentException("Tool metadata cache path is required.", nameof(toolMetaCacheFilePath)); - _attachmentMetaCacheFilePath = !string.IsNullOrWhiteSpace(attachmentMetaCacheFilePath) - ? attachmentMetaCacheFilePath - : DefaultAttachmentMetaCacheFilePath(_toolMetaCacheFilePath); - _lastChatStateFilePath = !string.IsNullOrWhiteSpace(lastChatStateFilePath) - ? lastChatStateFilePath - : LastChatStateFilePath; - _lastChatStateSaveDelay = lastChatStateSaveDelay ?? TimeSpan.FromSeconds(2); - _scheduleHistoryRetry = historyRetryScheduler ?? (static async (delay, cancellationToken, retry) => - { - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - await retry().ConfigureAwait(false); - }); - _historyFailureReservedForTesting = historyFailureReservedForTesting; - _status = bridge.CurrentStatus; - _persistedAbortedIds = LoadAbortedIds(); - _toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath); - _attachmentMetaCache = LoadAttachmentMetaCache(_attachmentMetaCacheFilePath); - _lastChatState = LoadLastChatState(_lastChatStateFilePath); - - // Seed models from whatever the bridge already knows about (a connect - // that completed before the provider was constructed will have its - // models.list snapshot cached on the bridge). - if (bridge.GetCurrentModelsList() is { } seedModels) - { - _modelChoices = ChatModelChoice.FromModelsList(seedModels); - _availableModels = ModelIdsFromChoices(_modelChoices); - } - // Fall back to last-known models so the composer shows a real model - // name while reconnecting instead of the generic "model" placeholder. - else if (_lastChatState?.AvailableModels is { Length: > 0 } cached) - { - _availableModels = cached; - _modelChoices = ChoicesFromIds(cached); - } + _deferredAbortScheduler = + deferredAbortScheduler ?? (work => Task.Run(work)); + _metadataStore = new ChatMetadataStore( + toolMetaCacheFilePath, + attachmentMetaCacheFilePath); + _persistence = new ChatStatePersistence( + lastChatStateFilePath, + lastChatStateSaveDelay); + _state = new ChatConversationState( + bridge.CurrentStatus, + _persistence.InitialLastChatState, + bridge.GetCurrentModelsList()); + _historyLoader = new ChatHistoryLoader( + bridge, + _state, + _metadataStore, + _persistence, + _telemetry, + historyRetryScheduler, + historyFailureReservedForTesting); + _historyLoader.Completed += OnHistoryLoadCompleted; _bridge.StatusChanged += OnStatusChanged; _bridge.SessionsUpdated += OnSessionsUpdated; @@ -316,15 +171,8 @@ internal OpenClawChatDataProvider( public Task LoadAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - // Seed from whatever the bridge already knows about. var sessions = _bridge.GetSessionList() ?? Array.Empty(); - lock (_gate) - { - _sessions = sessions; - EnsureTimelinesForSessionsLocked(); - RememberLastSessionStateLocked(); - return Task.FromResult(BuildSnapshotLocked()); - } + return Task.FromResult(_state.Load(sessions, ProjectionContext())); } internal void RememberSelectedThread(string? threadId) @@ -332,27 +180,8 @@ internal void RememberSelectedThread(string? threadId) if (string.IsNullOrWhiteSpace(threadId)) return; - LastChatState? state; - lock (_gate) - { - if (!TryGetSessionLocked(threadId, out var session)) - return; - - state = new LastChatState - { - DefaultThreadId = threadId, - ThreadTitle = SessionTitleFormatter.Format(session, _sessions), - Model = session.Model, - ModelProvider = session.Provider, - AvailableModels = _availableModels, - }; - _lastChatState = state; - _lastChatStateSaveVersion++; - _lastChatStateSaveTimer?.Dispose(); - _lastChatStateSaveTimer = null; - } - - SaveLastChatState(state, _lastChatStateFilePath); + if (_state.RememberSelectedThread(threadId) is { } state) + _persistence.SaveSelectedState(state); } // Explicit interface implementation (no attachments). @@ -390,66 +219,53 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation // blank even if the typed message was empty. Uses a unique prefix // ("\u200B📎 " / "\u200B🖼️ ") with a zero-width space to prevent // false positives from normal user text. - var safeUserText = EscapeUntrustedAttachmentMarkerLines(trimmed); + var safeUserText = ChatMetadataStore.EscapeUntrustedAttachmentMarkerLines(trimmed); var displayText = safeUserText; if (hasAttachments) { - var chips = BuildAttachmentMarkerLines(attachments!); + var chips = ChatMetadataStore.BuildAttachmentMarkerLines(attachments!); displayText = string.IsNullOrEmpty(safeUserText) ? chips : $"{safeUserText}\n{chips}"; } - // 1. Render immediately when this thread is idle. Follow-up messages - // enter the visible queue and stay client-only until the turn ends. - ChatDataSnapshot snapshot; - string messageId; - QueuedSendDispatch? dispatch; - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - messageId = $"q{++_queuedMessageSequence}"; - if (CanClearAssistantFallbackPromotionLocked(threadId)) - _assistantFallbackPromotedThreads.Remove(threadId); - - // Clear abort suppression — the user is starting a new interaction. - // Also clear pending abort counts: if the user sends a new message, - // any queued aborts from before should not fire against the new turn. - _abortedThreads.Remove(threadId); - _pendingAbortCounts.Remove(threadId); - - var request = new QueuedSendRequest( - messageId, - Guid.NewGuid().ToString(), + var admission = _state.AdmitMessage( + threadId, + trimmed, + displayText, + nonce, + attachments, + DateTimeOffset.UtcNow, + ProjectionContext()); + _telemetry.StartLocalTurn( + admission.MessageId, + threadId, + queued: admission.Queued, + admission.RuntimeGeneration); + if (!_state.IsRuntimeGenerationCurrent( threadId, - trimmed, - displayText, - nonce, - attachments?.ToArray()); - - var sendDirectly = CanSendDirectlyLocked(threadId); - _telemetry.StartLocalTurn(request.Id, threadId, queued: !sendDirectly); - if (sendDirectly) - { - dispatch = StartDirectSendLocked(request); - } - else - { - AddQueuedMessageLocked(threadId, new ChatQueuedMessage( - messageId, - displayText, - DateTimeOffset.UtcNow, - nonce)); - AddQueuedSendRequestLocked(request); - dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: false, out _); - } - - snapshot = BuildSnapshotLocked(); + admission.RuntimeGeneration)) + { + _telemetry.FinishByMessageId( + admission.MessageId, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.Superseded); } - Publish(snapshot); + var dispatch = admission.Dispatch; + var queueCompletion = dispatch is not null && + dispatch.Request.LifecycleCommand is null + ? _telemetry.PrepareDispatchLocalTurn( + dispatch.Request.Id, + dispatch.Request.SendRunId) + : null; + Publish(admission.Snapshot); if (dispatch is not null) - await DispatchQueuedSendAsync(dispatch, rethrow: true, cancellationToken); + await DispatchQueuedSendAsync( + dispatch, + queueCompletion, + rethrow: true, + cancellationToken); } internal Task EnqueueCompactCommandAsync( @@ -460,30 +276,10 @@ internal Task EnqueueCompactCommandAsync( if (string.IsNullOrWhiteSpace(threadId)) throw new ArgumentException("Thread id is required.", nameof(threadId)); - ChatDataSnapshot snapshot; - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - var messageId = $"q{++_queuedMessageSequence}"; - var request = new QueuedSendRequest( - messageId, - Guid.NewGuid().ToString(), - threadId, - "/compact", - "/compact", - Guid.NewGuid().ToString(), - Attachments: null, - LifecycleCommand: ChatLifecycleCommandKind.Compact); - - AddQueuedMessageLocked(threadId, new ChatQueuedMessage( - messageId, - request.DisplayText, - DateTimeOffset.UtcNow, - request.LocalNonce)); - AddQueuedSendRequestLocked(request); - snapshot = BuildSnapshotLocked(); - } - + var snapshot = _state.EnqueueCompact( + threadId, + DateTimeOffset.UtcNow, + ProjectionContext()); Publish(snapshot); TryDispatchNextQueuedSend(threadId); return Task.FromResult(true); @@ -564,23 +360,16 @@ public Task CancelQueuedMessageAsync(string threadId, string queuedMessage if (string.IsNullOrEmpty(queuedMessageId)) throw new ArgumentException("Queued message id is required.", nameof(queuedMessageId)); - ChatDataSnapshot? snapshot = null; - ChatTelemetryTracker.PreparedTurnCompletion? telemetryCompletion = null; - var canceled = false; - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - canceled = CancelQueuedMessageLocked(threadId, queuedMessageId); - if (canceled) - { - telemetryCompletion = _telemetry.PrepareFinishByMessageId( - queuedMessageId, - ChatTelemetryOutcome.Canceled, - ChatTurnTelemetryReason.QueuedCanceled); - snapshot = BuildSnapshotLocked(); - } - } - + var (canceled, snapshot) = _state.CancelQueuedMessage( + threadId, + queuedMessageId, + ProjectionContext()); + var telemetryCompletion = canceled + ? _telemetry.PrepareFinishByMessageId( + queuedMessageId, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.QueuedCanceled) + : null; _telemetry.CompletePreparedTurn(telemetryCompletion); if (snapshot is not null) Publish(snapshot); @@ -589,7 +378,8 @@ public Task CancelQueuedMessageAsync(string threadId, string queuedMessage } private async Task DispatchQueuedSendAsync( - QueuedSendDispatch dispatch, + ChatQueuedSendDispatch dispatch, + ChatTelemetryTracker.QueuePhaseCompletion? queueCompletion, bool rethrow, CancellationToken cancellationToken = default) { @@ -603,7 +393,7 @@ await DispatchQueuedLifecycleCommandAsync( return; } - _telemetry.CompleteQueueDispatch(dispatch.QueueCompletion); + _telemetry.CompleteQueueDispatch(queueCompletion); var threadId = request.ThreadId; var hasAttachments = request.Attachments is { Count: > 0 }; ChatTelemetryOperation? sendOperation = null; @@ -611,12 +401,17 @@ await DispatchQueuedLifecycleCommandAsync( try { await AwaitPendingModelPatchAsync(threadId, cancellationToken); - lock (_gate) + var preparation = _state.PrepareSendAttempt( + dispatch, + ProjectionContext()); + if (!preparation.IsCurrent) { - if (_disposed) - return; - if (GetResetVersionLocked(threadId) == dispatch.ResetVersion) - TrackQueuedMessageRunLocked(threadId, request.SendRunId, request.Id); + if (preparation.Snapshot is not null) + { + Publish(preparation.Snapshot); + TryDispatchNextQueuedSend(threadId); + } + return; } sendOperation = _telemetry.StartSendAttempt(request.Id); var sendResult = await _bridge.SendChatMessageForRunAsync( @@ -625,7 +420,8 @@ await DispatchQueuedLifecycleCommandAsync( dispatch.SessionId, request.Attachments, idempotencyKey: request.SendRunId); - var admissionStatus = MapAdmissionTelemetryStatus(sendResult); + var admissionStatus = ToTelemetryAdmissionStatus( + ChatSendQueuePolicy.ClassifyAdmission(sendResult)); var admissionOutcome = admissionStatus == ChatAdmissionTelemetryStatus.Canceled ? ChatTelemetryOutcome.Canceled : sendResult.IsTerminalFailure @@ -639,14 +435,10 @@ await DispatchQueuedLifecycleCommandAsync( _telemetry.ObserveAdmissionAccepted(request.Id); if (sendResult.IsTerminalFailure) { - ChatTelemetryTracker.PreparedTurnCompletion? rejectedCompletion; - lock (_gate) - { - rejectedCompletion = _telemetry.PrepareFinishByMessageId( - request.Id, - admissionOutcome, - ChatTurnTelemetryReason.SendRejected); - } + var rejectedCompletion = _telemetry.PrepareFinishByMessageId( + request.Id, + admissionOutcome, + ChatTurnTelemetryReason.SendRejected); _telemetry.CompletePreparedTurn(rejectedCompletion); var failure = !string.IsNullOrWhiteSpace(sendResult.Error) ? sendResult.Error! @@ -657,131 +449,63 @@ await DispatchQueuedLifecycleCommandAsync( throw new InvalidOperationException(failure); } - bool sendStillCurrent; - string? staleRunIdToAbort = null; - ChatTelemetryTracker.PreparedTurnCompletion? staleCompletion = null; - ChatDataSnapshot? acceptedSnapshot = null; - ChatDataSnapshot? requeuedSnapshot = null; - var retryDeferredSend = false; - var deferredRetryDelay = DeferredQueueDrainDelay; var acceptedRunId = string.IsNullOrWhiteSpace(sendResult.RunId) ? null : sendResult.RunId!; - lock (_gate) + var commit = _state.CommitSendResult( + dispatch, + sendResult, + ProjectionContext()); + if (commit.BindAcceptedRun && acceptedRunId is not null) + _telemetry.BindAcceptedRun(request.Id, acceptedRunId); + if (commit.RequeueRequired) + _telemetry.RequeueLocalTurn(request.Id); + HandleOpenedLifecycle( + threadId, + commit.OpenedLifecycle, + commit.RuntimeGeneration); + ChatTelemetryTracker.PreparedTurnCompletion? staleCompletion = null; + if (!commit.IsCurrent) { - sendStillCurrent = GetResetVersionLocked(threadId) == dispatch.ResetVersion; - if (!sendStillCurrent) - { - staleRunIdToAbort = acceptedRunId ?? request.SendRunId; - staleCompletion = _telemetry.PrepareFinishByMessageId( - request.Id, - ChatTelemetryOutcome.Canceled, - ChatTurnTelemetryReason.Superseded); - AddResetIgnoredRunIdLocked(threadId, staleRunIdToAbort); - } - else if (IsDeferredAdmissionStatus(sendResult.Status)) - { - var runAlreadyStarted = !string.IsNullOrEmpty(acceptedRunId) - && _activeRunIds.TryGetValue(threadId, out var activeRunId) - && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence) - && string.Equals(activeRunId, acceptedRunId, StringComparison.Ordinal) - && activeStartSequence > dispatch.StartedRunStartSequence; - if (runAlreadyStarted) - { - _telemetry.BindAcceptedRun(request.Id, acceptedRunId); - TrackQueuedMessageRunLocked(threadId, acceptedRunId!, request.Id); - AddResetAcceptedRunIdLocked(threadId, acceptedRunId!); - if (PromoteQueuedMessageLocked(threadId, request.Id)) - { - acceptedSnapshot = BuildSnapshotLocked(); - } - else - { - RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id); - } - } - else if (RequeueDeferredAdmissionLocked(threadId, request.Id, out deferredRetryDelay)) - { - _telemetry.RequeueLocalTurn(request.Id); - if (!string.IsNullOrEmpty(acceptedRunId)) - { - TrackQueuedMessageRunLocked(threadId, acceptedRunId, request.Id); - AddResetAcceptedRunIdLocked(threadId, acceptedRunId); - } - requeuedSnapshot = BuildSnapshotLocked(); - retryDeferredSend = true; - } - else if (dispatch.StartedDirectly) - { - throw new InvalidOperationException( - $"Gateway returned chat.send status {sendResult.Status} before admitting the direct send."); - } - } - else if (!string.IsNullOrEmpty(acceptedRunId)) - { - _telemetry.BindAcceptedRun(request.Id, acceptedRunId); - TrackQueuedMessageRunLocked(threadId, acceptedRunId, request.Id); - AddResetAcceptedRunIdLocked(threadId, acceptedRunId); - var runAlreadyStarted = _activeRunIds.TryGetValue(threadId, out var activeRunId) - && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence) - && string.Equals(activeRunId, acceptedRunId, StringComparison.Ordinal) - && activeStartSequence > dispatch.StartedRunStartSequence; - if (PromoteQueuedMessageLocked(threadId, request.Id)) - { - acceptedSnapshot = BuildSnapshotLocked(); - } - else if (runAlreadyStarted) - { - RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id); - } - } - else if (_resetAwaitingUserMessage.Contains(threadId)) - { - RemoveQueuedRunMappingByRunIdLocked(threadId, request.SendRunId); - _resetLocalSendWithoutRunVersions[threadId] = dispatch.ResetVersion; - _resetLocalSendWithoutRunStartSequences[threadId] = dispatch.StartedLifecycleSequence; - TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null); - if (PromoteQueuedMessageLocked(threadId, request.Id)) - { - acceptedSnapshot = BuildSnapshotLocked(); - } - } - else if (PromoteQueuedMessageLocked(threadId, request.Id)) - { - RemoveQueuedRunMappingByRunIdLocked(threadId, request.SendRunId); - acceptedSnapshot = BuildSnapshotLocked(); - } + staleCompletion = _telemetry.PrepareFinishByMessageId( + request.Id, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.Superseded); } - if (acceptedSnapshot is not null) - { - Publish(acceptedSnapshot); - } - if (requeuedSnapshot is not null) - { - Publish(requeuedSnapshot); - } - if (retryDeferredSend) - { - ScheduleQueuedSendDrain(threadId, deferredRetryDelay); - } + if (commit.AcceptedSnapshot is not null) + Publish(commit.AcceptedSnapshot); + if (commit.RequeuedSnapshot is not null) + Publish(commit.RequeuedSnapshot); + if (commit.RetryDeferredSend) + ScheduleQueuedSendDrain(threadId, commit.DeferredRetryDelay); - if (staleRunIdToAbort is not null) + if (commit.StaleRunIdToAbort is not null) { _telemetry.CompletePreparedTurn(staleCompletion); try { - Logger.Info($"[Reset] Aborting late pre-reset send runId='{staleRunIdToAbort}' threadId='{threadId}'"); - await _bridge.SendChatAbortAsync(staleRunIdToAbort, threadId); + Logger.Info($"[Reset] Aborting late pre-reset send runId='{commit.StaleRunIdToAbort}' threadId='{threadId}'"); + await _bridge.SendChatAbortAsync(commit.StaleRunIdToAbort, threadId); } catch (Exception abortEx) { - Logger.Warn($"[Reset] Failed to abort late pre-reset send runId='{staleRunIdToAbort}': {abortEx.Message}"); + Logger.Warn($"[Reset] Failed to abort late pre-reset send runId='{commit.StaleRunIdToAbort}': {abortEx.Message}"); } } + if (!commit.IsCurrent && commit.AcceptedSnapshot is not null) + TryDispatchNextQueuedSend(threadId); - if (hasAttachments && sendStillCurrent) - CacheAttachmentMeta(dispatch.SessionId, threadId, request.Text, request.Attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), dispatch.ResetVersion); + if (hasAttachments && commit.IsCurrent) + { + _metadataStore.CacheAttachments( + threadId, + dispatch.SessionId, + dispatch.ResetVersion, + request.Text, + request.Attachments!, + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } } catch (Exception ex) { @@ -792,45 +516,35 @@ ex is OperationCanceledException ? ChatTelemetryOutcome.Canceled : ChatTelemetryOutcome.Failure, ex); - bool sendStillCurrent; - ChatTelemetryTracker.PreparedTurnCompletion? rejectedCompletion = null; - ChatDataSnapshot? failureSnapshot = null; - lock (_gate) + var failure = _state.FailSend( + dispatch, + ex.Message, + string.Format( + CultureInfo.CurrentCulture, + LocalizationHelper.GetString("Chat_Error_SendFailedFormat"), + ex.Message), + ProjectionContext()); + if (!failure.IsCurrent) { - sendStillCurrent = GetResetVersionLocked(threadId) == dispatch.ResetVersion; - if (sendStillCurrent) + if (failure.Snapshot is not null) { - rejectedCompletion = _telemetry.PrepareFinishByMessageId( - request.Id, - ex is OperationCanceledException - ? ChatTelemetryOutcome.Canceled - : ChatTelemetryOutcome.Failure, - ChatTurnTelemetryReason.SendRejected); - RemovePendingLocalEchoLocked(threadId, request.Id); - MarkQueuedMessageFailedLocked(threadId, request.Id, ex.Message); - RemoveQueuedSendRequestLocked(threadId, request.Id); - RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id); - if (!HasSendingQueuedMessagesLocked(threadId)) - _locallyInitiatedThreads.Remove(threadId); - failureSnapshot = ApplyEventLocked( - threadId, - TruncateChatEvent(new ChatErrorEvent(string.Format( - CultureInfo.CurrentCulture, - LocalizationHelper.GetString("Chat_Error_SendFailedFormat"), - ex.Message))), - meta: null); - failureSnapshot = ApplyEventLocked(threadId, new ChatTurnEndEvent(), meta: null); + Publish(failure.Snapshot); + TryDispatchNextQueuedSend(threadId); } - } - - if (!sendStillCurrent) return; + } + var rejectedCompletion = _telemetry.PrepareFinishByMessageId( + request.Id, + ex is OperationCanceledException + ? ChatTelemetryOutcome.Canceled + : ChatTelemetryOutcome.Failure, + ChatTurnTelemetryReason.SendRejected); _telemetry.CompletePreparedTurn(rejectedCompletion); Logger.Warn($"[Queue] chat.send failed threadId='{threadId}' queuedMessageId='{request.Id}' sendRunId='{request.SendRunId}': {ex.Message}"); // Surface as an error in the timeline + notification, while the // failed queue card keeps the attempted text visible for retry/edit. - Publish(failureSnapshot!); + Publish(failure.Snapshot!); RaiseNotification(new ChatProviderNotification( ChatProviderNotificationKind.Error, threadId, LocalizationHelper.GetString("Chat_Notification_SendFailed"), ex.Message)); TryDispatchNextQueuedSend(threadId); @@ -840,12 +554,15 @@ ex is OperationCanceledException } private async Task DispatchQueuedLifecycleCommandAsync( - QueuedSendDispatch dispatch, + ChatQueuedSendDispatch dispatch, ChatLifecycleCommandKind command, CancellationToken cancellationToken) { var request = dispatch.Request; var threadId = request.ThreadId; + if (!_state.IsQueuedDispatchCurrent(dispatch)) + return; + ChatLifecycleCommandResult result; try { @@ -868,73 +585,42 @@ private async Task DispatchQueuedLifecycleCommandAsync( Error: ex.Message); } - ChatDataSnapshot? snapshot = null; - var reloadHistory = false; - lock (_gate) - { - if (_disposed) - return; - if (GetResetVersionLocked(threadId) != dispatch.ResetVersion || - FindQueuedSendRequestLocked(threadId, request.Id) is null) - { - return; - } - - if (result.Succeeded) - { - if (RemoveQueuedMessageLocked(threadId, request.Id)) - snapshot = BuildSnapshotLocked(); - reloadHistory = command == ChatLifecycleCommandKind.Compact; - } - else - { - ApplyEventLocked( - threadId, - new ChatErrorEvent(result.Error ?? "The lifecycle command failed."), - meta: null); - MarkQueuedMessageFailedLocked( - threadId, - request.Id, - result.Error ?? "The queued lifecycle command failed."); - RemoveQueuedSendRequestLocked(threadId, request.Id); - snapshot = BuildSnapshotLocked(); - } - } + var completion = _state.CompleteQueuedLifecycle( + dispatch, + result.Succeeded, + result.Error, + ProjectionContext()); + if (!completion.Succeeded) + return; - if (snapshot is not null) - Publish(snapshot); - if (reloadHistory) + if (completion.Snapshot is not null) + Publish(completion.Snapshot); + if (result.Succeeded && command == ChatLifecycleCommandKind.Compact) _ = LoadHistoryAsync(threadId, force: true, authoritative: true); TryDispatchNextQueuedSend(threadId); } + private static ChatAdmissionTelemetryStatus ToTelemetryAdmissionStatus( + ChatAdmissionOutcome outcome) => outcome switch + { + ChatAdmissionOutcome.Accepted => ChatAdmissionTelemetryStatus.Accepted, + ChatAdmissionOutcome.Deferred => ChatAdmissionTelemetryStatus.Deferred, + ChatAdmissionOutcome.Rejected => ChatAdmissionTelemetryStatus.Rejected, + ChatAdmissionOutcome.Canceled => ChatAdmissionTelemetryStatus.Canceled, + _ => ChatAdmissionTelemetryStatus.Other, + }; + public async Task StopResponseAsync(string threadId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - string? runId; - bool hadActiveTurn; - lock (_gate) - { - _activeRunIds.TryGetValue(threadId, out runId); - hadActiveTurn = _timelines.TryGetValue(threadId, out var tl) && tl.TurnActive; - - // Suppress all incoming messages for this thread until the next user send. - _abortedThreads.Add(threadId); - - if (!string.IsNullOrEmpty(runId)) - _abortedRunIds.Add(runId); - else - { - _pendingAbortCounts.TryGetValue(threadId, out var count); - _pendingAbortCounts[threadId] = count + 1; - } - - _telemetry.FinishActiveTurn( - threadId, - ChatTelemetryOutcome.Canceled, - ChatTurnTelemetryReason.AbortRequested); - } + var abort = _state.BeginAbort(threadId); + var runId = abort.RunId; + var hadActiveTurn = abort.HadActiveTurn; + _telemetry.FinishActiveTurn( + threadId, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.AbortRequested); Logger.Info($"[ABORT] StopResponseAsync threadId='{threadId}' runId='{runId ?? "(null)"}' hadActiveTurn={hadActiveTurn} deferred={string.IsNullOrEmpty(runId)}"); @@ -948,16 +634,7 @@ public async Task StopResponseAsync(string threadId, CancellationToken cancellat } catch (Exception ex) { - // Abort RPC failed — clear suppression so the thread isn't permanently blocked. - lock (_gate) - { - _abortedThreads.Remove(threadId); - _abortedRunIds.Remove(runId); - _activeRunIds.Remove(threadId); - _activeRunStartSequences.Remove(threadId); - if (!HasSendingQueuedMessagesLocked(threadId)) - _locallyInitiatedThreads.Remove(threadId); - } + _state.RollbackAbort(threadId, runId); Logger.Warn($"[ABORT] chat.abort failed, cleared suppression: {ex.Message}"); RaiseNotification(new ChatProviderNotification( ChatProviderNotificationKind.Error, threadId, LocalizationHelper.GetString("Chat_Notification_AbortFailed"), ex.Message)); @@ -982,17 +659,7 @@ public async Task StopResponseAsync(string threadId, CancellationToken cancellat ApplyEventAndPublish(threadId, new ChatStatusEvent("Aborted", ChatTone.Warning)); } - lock (_gate) - { - if (!string.IsNullOrEmpty(runId)) - { - _activeRunIds.Remove(threadId); - _activeRunStartSequences.Remove(threadId); - } - _abortedThreads.Remove(threadId); - if (!HasSendingQueuedMessagesLocked(threadId)) - _locallyInitiatedThreads.Remove(threadId); - } + _state.CompleteAbort(threadId, runId); // Always clear local "turn active" state — the gateway will emit a // lifecycle.end if the abort succeeds, but we want the UI to reflect @@ -1007,955 +674,129 @@ public async Task StopResponseAsync(string threadId, CancellationToken cancellat /// the timeline; subsequent calls are no-ops unless /// is true. Safe to call from any thread. /// - public Task LoadHistoryAsync(string threadId, bool force = false, CancellationToken cancellationToken = default, bool authoritative = false) - => LoadHistoryCoreAsync(threadId, force, cancellationToken, expectedConnectionVersion: null, authoritative: authoritative); + public Task LoadHistoryAsync( + string threadId, + bool force = false, + CancellationToken cancellationToken = default, + bool authoritative = false) => + _historyLoader.LoadAsync( + threadId, + force, + cancellationToken, + authoritative); internal Task ReplaceHistoryAfterCheckpointRestoreAsync( string threadId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(threadId); + var transition = _state.BeginHistoryReplacement( + threadId, + ProjectionContext()); + if (transition is null) + return Task.CompletedTask; - ChatDataSnapshot snapshot; - lock (_gate) - { - if (_disposed) - return Task.CompletedTask; - - _historyReplacementVersions[threadId] = GetHistoryReplacementVersionLocked(threadId) + 1; - _timelines[threadId] = ChatTimelineState.Initial(); - _entryMeta.Remove(threadId); - _historyLoaded.Remove(threadId); - _historyRetryCount.Remove(threadId); - _authoritativeHistoryReloadPending.Remove(threadId); - _replacementHistoryReloadPending.Remove(threadId); - snapshot = BuildSnapshotLocked(); - } - - Publish(snapshot); - return LoadHistoryCoreAsync( + Publish(transition.Snapshot); + return _historyLoader.LoadReplacementAsync( threadId, - force: true, - cancellationToken, - expectedConnectionVersion: null, - authoritative: false, - replacementReload: true); + transition.Token, + cancellationToken); } - private async Task LoadHistoryCoreAsync( - string threadId, - bool force, - CancellationToken cancellationToken, - long? expectedConnectionVersion, - long? expectedHistoryReplacementVersion = null, - bool authoritative = false, - bool replacementReload = false) + private void OnHistoryLoadCompleted( + object? sender, + ChatHistoryLoadResult result) { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrEmpty(threadId)) return; - - long requestResetVersion; - long requestHistoryReplacementVersion; - long requestConnectionVersion; - CancellationToken generationCancellationToken; - CancellationTokenSource requestCancellation; - lock (_gate) + void Deliver() { - if (_disposed) return; - if (expectedConnectionVersion is { } expected && - (_historyConnectionVersion != expected || _status != ConnectionStatus.Connected)) - { + var snapshot = _state.SnapshotIfHistoryTokenCurrent( + result.Token, + ProjectionContext()); + if (snapshot is null) return; - } - if (expectedHistoryReplacementVersion is { } expectedReplacement && - GetHistoryReplacementVersionLocked(threadId) != expectedReplacement) + if (result.PublishSnapshot) { - return; + Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot)); + if (snapshot.Threads.Length > 0 || + snapshot.AvailableModels.Length > 0) + { + _persistence.DebounceSnapshot(snapshot); + } } - if (!force && _historyLoaded.Contains(threadId)) return; - if (!_historyInFlight.Add(threadId)) + if (result.Notification is not null) { - if (replacementReload) - _replacementHistoryReloadPending.Add(threadId); - else if (authoritative) - _authoritativeHistoryReloadPending.Add(threadId); - return; + NotificationRequested?.Invoke( + this, + new ChatProviderNotificationEventArgs(result.Notification)); } - requestResetVersion = GetResetVersionLocked(threadId); - requestHistoryReplacementVersion = GetHistoryReplacementVersionLocked(threadId); - requestConnectionVersion = _historyConnectionVersion; - generationCancellationToken = _historyGenerationCancellation.Token; - requestCancellation = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - generationCancellationToken); } - using var requestCancellationScope = requestCancellation; - var historyRequestStartedAt = DateTimeOffset.Now; - var historyOperation = _telemetry.StartHistoryLoad( - force ? ChatHistoryTelemetrySource.Forced : ChatHistoryTelemetrySource.Initial); - var historyOutcome = ChatTelemetryOutcome.Success; - Exception? historyException = null; - Task? historyRequest = null; - try - { - historyRequest = _bridge.RequestChatHistoryAsync(threadId); - var history = await historyRequest - .WaitAsync(requestCancellation.Token) - .ConfigureAwait(false); - - lock (_gate) - { - if (_historyConnectionVersion != requestConnectionVersion || - GetResetVersionLocked(threadId) != requestResetVersion || - GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion) - { - Logger.Info($"[ChatHistory] Ignoring stale history for thread '{threadId}'"); - historyOutcome = ChatTelemetryOutcome.Canceled; - return; - } - - if (!string.IsNullOrEmpty(history.SessionId)) - _sessionIds[threadId] = history.SessionId!; - - // Rebuild timeline from history; preserve any in-flight turn - // entries that arrived between the request and the response by - // appending them after the historical entries. - var prior = GetOrCreateTimelineLocked(threadId); - var rebuilt = ChatTimelineState.Initial() with { HistoryLoaded = true }; - - // Prefer the gateway's per-session sequence over timestamps. - // Spam/queue bursts can produce persisted rows whose timestamps - // don't reflect the actual processing order; __openclaw.seq is - // the stable transcript order when present. - var orderedItems = history.Messages - .Select((m, i) => (Message: m, Index: i)) - .ToList(); - var ordered = OrderHistoryMessages(orderedItems); - - // Build per-entry metadata in lockstep with the reducer. - var rebuiltMeta = new Dictionary(); - var session = Array.Find(_sessions, s => s.Key == threadId); - var modelAtLoad = session?.Model; - - ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntryMetadata? meta) - { - var beforeIds = new HashSet(s.Entries.Count); - for (int i = 0; i < s.Entries.Count; i++) beforeIds.Add(s.Entries[i].Id); - var nextState = ChatTimelineReducer.Apply(s, e); - if (meta is not null) - { - for (int i = 0; i < nextState.Entries.Count; i++) - { - var id = nextState.Entries[i].Id; - if (!beforeIds.Contains(id) && !rebuiltMeta.ContainsKey(id)) - rebuiltMeta[id] = meta; - } - } - return nextState; - } - - Logger.Info($"[ChatHistory] Loading thread '{threadId}' — {ordered.Count} messages from gateway"); - - // Load cached tool metadata for this session to restore tool names - // that the gateway strips from history responses. - var cachedTools = GetCachedToolMetaForSession(history.SessionId, threadId); - if (cachedTools is not null) - Logger.Info($"[ChatHistory] Found {cachedTools.Count} cached tool metadata entries for session"); - - bool nextAssistantIsAborted = false; - var attachmentMatcher = CreateAttachmentMetaMatcher(history.SessionId, threadId); - var pendingUnkeyedToolCalls = new Queue(); - var syntheticToolCallSequence = 0; - ChatMessageInfo? suppressedAbortedAssistant = null; - - foreach (var replayPart in ChatHistoryReplayProjection.Project(ordered)) - { - var msg = replayPart.Message; - if (suppressedAbortedAssistant is not null) - { - if (ReferenceEquals(suppressedAbortedAssistant, msg)) - continue; - suppressedAbortedAssistant = null; - } - - var roleLower = msg.Role?.ToLowerInvariant() ?? ""; - var rawText = replayPart.Text; - var ts = msg.Ts > 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(msg.Ts).ToLocalTime() - : (DateTimeOffset?)null; - var msgMeta = new ChatEntryMetadata( - ts, - modelAtLoad, - msg.InputTokens, - msg.OutputTokens, - msg.ResponseTokens, - msg.ContextPercent, - GatewayMessageId: msg.OpenClawId, - OpenClawSeq: msg.OpenClawSeq, - OpenClawKind: msg.OpenClawKind, - CompactionTokensBefore: msg.CompactionTokensBefore, - CompactionTokensAfter: msg.CompactionTokensAfter); - - // Cap per-message text up front so heuristics, logging, - // and the reducer all see the same bounded value - // (chat rubber-duck MEDIUM 4). - var text = TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(rawText)); - if (roleLower == "user") - text = RehydrateAttachmentMarkers(attachmentMatcher, text, msg.Ts); - var hasStructuredToolContent = replayPart.ToolContent.Count > 0; - - // Check if this user message was aborted (persisted __openclaw.id match) - if (roleLower == "user") - { - Logger.Debug($"[ChatHistory] user msg OpenClawId='{msg.OpenClawId ?? "(null)"}' seq={msg.OpenClawSeq}"); - if (IsMessageAborted(threadId, msg.OpenClawId)) - nextAssistantIsAborted = true; - } - - // Check if the gateway itself flagged this as an aborted response - bool gatewayAborted = roleLower == "assistant" && - !string.IsNullOrEmpty(msg.StopReason) && - !string.Equals(msg.StopReason, "stop", StringComparison.OrdinalIgnoreCase) && - !string.Equals(msg.StopReason, "toolUse", StringComparison.OrdinalIgnoreCase) && - !string.Equals(msg.StopReason, "end_turn", StringComparison.OrdinalIgnoreCase); - - var isFirstAssistantPart = roleLower == "assistant" && replayPart.IsFirstPart; - var shouldSuppressAssistant = isFirstAssistantPart - && (nextAssistantIsAborted || gatewayAborted); - if (isFirstAssistantPart) - nextAssistantIsAborted = false; - if (shouldSuppressAssistant) - { - Logger.Debug("[ChatHistory] → routed: ABORTED (response was stopped)"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatStatusEvent("Response was stopped", ChatTone.Warning), - msgMeta); - rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent()); - suppressedAbortedAssistant = msg; - continue; - } + if (_post is null) + Deliver(); + else + _post(Deliver); + } - if (string.IsNullOrEmpty(text) && !hasStructuredToolContent) continue; + public Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; // Not supported by gateway — no-op. + } - // Diagnostic: log shape (role + length + heuristic flags) only. - // Never log the message text — see HIGH 4 logging audit. - var isFlat = NativeToolProjector.LooksLikeFlattenedToolOutput(text); - var isSys = NativeToolProjector.LooksLikeSystemControlNote(text); - Logger.Debug($"[ChatHistory] role='{roleLower}' len={text.Length} flat={isFlat} sys={isSys}"); + public Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; // Not supported by gateway — no-op. + } - if (!string.IsNullOrEmpty(text)) - { - switch (roleLower) - { - case "user": - // Approval slash commands ("/approve allow-once", - // "/deny ") are transport, not user prose. On - // history replay we render them as a dim audit-trail - // status entry so the user can scroll back and see - // that an approval decision was made on this thread - // (whether by us or another client — origin is - // indistinguishable on replay). - if (LooksLikeApprovalSlashCommand(text)) - { - Logger.Debug($"[ChatHistory] → routed: AUDIT (approval slash command, dim status)"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatStatusEvent(text, ChatTone.Dim), - msgMeta); - break; - } - // System-injected notes (the gateway sometimes wraps - // exec result reports in ``System (untrusted): ...`` - // and sends them as role=user) — render dim instead - // of as a giant user bubble. See the ChatHistory log. - if (NativeToolProjector.LooksLikeSystemControlNote(text)) - { - Logger.Debug($"[ChatHistory] → routed: SYSTEM (dim status, role=user with control prefix)"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatStatusEvent(text, ChatTone.Dim), - msgMeta); - break; - } - // ApplyUserMessage will set TurnActive=true; if the previous - // assistant turn never received a turn-end (because the - // gateway transcript doesn't emit one explicitly), clear - // ActiveAssistantId here so the next assistant message - // starts a fresh entry instead of overwriting the previous. - rebuilt = rebuilt with { ActiveAssistantId = null, ActiveReasoningId = null }; - rebuilt = ApplyAndCaptureMeta(rebuilt, new ChatUserMessageEvent(text), msgMeta); - break; - - case "assistant": - if (ChatMessageInfo.IsSilentAssistantDirective(roleLower, text)) - { - Logger.Debug("[ChatHistory] → routed: SILENT assistant directive"); - break; - } - - // ── Heuristic recovery for history-flattened tool calls ── - // The gateway strips ``stream:"item"`` / ``command_output`` - // detail server-side when serving ``chat.history`` — - // raw exec output is replayed as plain assistant text. - // Detect these telltale shapes and route them through - // the chip pipeline so historic turns look like live ones. - if (NativeToolProjector.LooksLikeSystemControlNote(text)) - { - Logger.Debug($"[ChatHistory] → routed: SYSTEM (dim status)"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatStatusEvent(text, ChatTone.Dim), - msgMeta); - break; - } - if (NativeToolProjector.LooksLikeFlattenedToolOutput(text)) - { - var cached = TryMatchCachedTool(cachedTools, msg.Ts); - var kind = cached?.ToolName ?? NativeToolProjector.ClassifyFlattenedToolOutput(text); - var label = cached?.Label ?? NativeToolProjector.ExtractFlattenedToolSummary(text); - Logger.Debug($"[ChatHistory] → routed: TOOL chip kind='{kind}' cached={cached is not null}"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatToolStartEvent( - label, - kind, - ToolArgs: cached?.ToolArgs, - ToolCallId: cached?.ToolCallId, - IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind), - RunId: cached?.RunId), - msgMeta); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatToolOutputEvent( - text, - ToolCallId: cached?.ToolCallId, - RunId: cached?.RunId), - msgMeta); - break; - } - Logger.Debug($"[ChatHistory] → routed: ASSISTANT bubble (no flatten/system match)"); - rebuilt = ApplyAndCaptureMeta(rebuilt, new ChatMessageEvent(RepairContentBlockSeams(text)), msgMeta); - if (rebuilt.ActiveToolCalls.Count > 0 - || rebuilt.ActiveToolCallId is not null) - { - // Text can be interleaved between a tool start and its - // result in one array-valued history message. Preserve - // tool correlation while ensuring later text becomes a - // separate chronological entry. - rebuilt = rebuilt with - { - ActiveAssistantId = null, - ActiveReasoningId = null, - }; - } - else - { - // End the turn so the next assistant message starts a new - // entry rather than replacing this one (UpsertAssistant - // upserts by ActiveAssistantId, which TurnEnd clears). - rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent()); - } - break; - - case "toolresult": - case "tool_result": - if (hasStructuredToolContent) - break; - - // Verified empirically — gateway 2026.4.x emits - // ``role: "toolresult"`` for shell/exec tool output - // in chat.history (not the spec's ``"tool"``). - // Always route to a chip pair regardless of whether - // the heuristic fires, since the role itself confirms - // it's tool output. - { - var cached = TryMatchCachedTool(cachedTools, msg.Ts); - var kind = cached?.ToolName ?? NativeToolProjector.ClassifyFlattenedToolOutput(text); - var label = cached?.Label ?? NativeToolProjector.ExtractFlattenedToolSummary(text); - Logger.Debug($"[ChatHistory] → routed: TOOL chip (role=toolresult, kind='{kind}' cached={cached is not null})"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatToolStartEvent( - label, - kind, - ToolArgs: cached?.ToolArgs, - ToolCallId: cached?.ToolCallId, - IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind), - RunId: cached?.RunId), - msgMeta); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatToolOutputEvent( - text, - ToolCallId: cached?.ToolCallId, - RunId: cached?.RunId), - msgMeta); - } - break; - - case "system": - case "tool": - // Render system / tool transcript notes as muted Status - // entries so they're visible but de-emphasized vs. the - // user/assistant turn flow. - Logger.Debug($"[ChatHistory] → routed: STATUS (role={roleLower})"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatStatusEvent(text, ChatTone.Dim), - msgMeta); - break; - - default: - // Unknown role — fall back to assistant rendering so it's - // at least visible. Bracket with TurnEnd to avoid - // collapsing into adjacent assistant entries. - Logger.Debug($"[ChatHistory] → routed: ASSISTANT (unknown role '{roleLower}', fallback)"); - rebuilt = ApplyAndCaptureMeta(rebuilt, new ChatMessageEvent(RepairContentBlockSeams(text)), msgMeta); - rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent()); - break; - } - } + public async Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // The gateway's sessions.patch schema treats `model` as a non-empty + // string; a blank value here is a no-op rather than a clear. Use + // ClearModelAsync to revert a session to the gateway default. + if (string.IsNullOrWhiteSpace(model)) return; + await TrackModelPatchAsync(threadId, () => _bridge.PatchSessionModelAsync(threadId, model)); + } - foreach (var toolBlock in replayPart.ToolContent) - { - if (toolBlock.Kind == ChatToolContentKind.Call) - { - _ = TryMatchCachedTool(cachedTools, msg.Ts); - var args = ConvertToolArgs(toolBlock.Args); - var callId = toolBlock.CallId; - if (string.IsNullOrWhiteSpace(callId)) - { - callId = $"history-tool-{syntheticToolCallSequence++}"; - pendingUnkeyedToolCalls.Enqueue(callId); - } - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatToolStartEvent( - ToolLabel(toolBlock.ToolName, args), - toolBlock.ToolName, - args, - callId), - msgMeta); - } - else - { - var callId = toolBlock.CallId; - if (string.IsNullOrWhiteSpace(callId)) - { - callId = pendingUnkeyedToolCalls.Count > 0 - ? pendingUnkeyedToolCalls.Dequeue() - : $"history-tool-{syntheticToolCallSequence++}"; - } - - var correlationKey = new ChatToolCorrelationKey( - RunId: null, - LegacyTurn: rebuilt.ToolLegacyTurn, - ToolCallId: callId); - if (!rebuilt.ActiveToolCalls.ContainsKey(correlationKey)) - { - var cached = TryMatchCachedTool(cachedTools, msg.Ts); - var toolName = cached?.ToolName ?? toolBlock.ToolName; - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatToolStartEvent( - cached?.Label ?? toolName, - toolName, - ToolCallId: callId), - msgMeta); - } - - var output = NativeToolProjector.TruncateToolOutput(toolBlock.Text ?? string.Empty); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - toolBlock.IsError - ? new ChatToolErrorEvent(output, callId) - : new ChatToolOutputEvent(output, callId), - msgMeta); - } - } - } - // If the last user message was aborted but there's no subsequent - // assistant message in history (gateway didn't record one), synthesize - // the "Response was stopped" indicator so the user sees it. - if (nextAssistantIsAborted) - { - Logger.Debug("[ChatHistory] Trailing aborted user message with no assistant response — synthesizing abort indicator"); - rebuilt = ApplyAndCaptureMeta( - rebuilt, - new ChatStatusEvent("Response was stopped", ChatTone.Warning), - null); - rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent()); - } + public async Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // Tri-state clear: removes the session's model override (explicit null) + // so it tracks the gateway/agent default again. + await TrackModelPatchAsync(threadId, () => _bridge.ClearSessionModelAsync(threadId)); + } - // Final safety: close the replayed turn and mark calls that never - // received a result as interrupted instead of leaving them running. - rebuilt = ChatTimelineReducer.Apply(rebuilt, new ChatTurnEndEvent()); - - // Append any prior live entries that weren't part of history. - // Dedup rules (HIGH 2 / rubber-duck round 2): - // 1. ID-only dedup is a no-op here because both rebuilt and - // prior assign sequential e{n} IDs that always collide; - // treat collisions as coincidences and re-id them. - // 2. Content+timestamp dedup: only when BOTH sides have a - // non-zero timestamp AND they agree within 2 seconds. - // 3. If either side's timestamp is missing/zero, KEEP the - // live entry — visible duplication beats silent loss. - if (prior.Entries.Count > 0) + private async Task TrackModelPatchAsync(string threadId, Func patchOperation) + { + var lease = _state.BeginModelPatch(threadId); + Exception? failure = null; + try + { + if (lease.Previous is not null) + { + try { await lease.Previous; } + catch (Exception ex) { - var priorMeta = _entryMeta.TryGetValue(threadId, out var pm) - ? pm - : new Dictionary(); - - static string ContentKey(ChatTimelineItemKind kind, string text) => $"{kind}|{text}"; - static string SequenceKey(ChatTimelineItemKind kind, int sequence) => $"{kind}|{sequence}"; - - // (kind|text) → list of unix-second timestamps for rebuilt - // entries that have a real timestamp. Only these can match. - var rebuiltContentTimestamps = new Dictionary>(StringComparer.Ordinal); - var rebuiltMessageIds = new HashSet(StringComparer.Ordinal); - var rebuiltSequenceCounts = new Dictionary(StringComparer.Ordinal); - foreach (var entry in rebuilt.Entries) - { - rebuiltMeta.TryGetValue(entry.Id, out var em); - if (!string.IsNullOrEmpty(em?.GatewayMessageId)) - rebuiltMessageIds.Add(em.GatewayMessageId); - if (em?.OpenClawSeq is { } seq) - IncrementCount(rebuiltSequenceCounts, SequenceKey(entry.Kind, seq)); - if (em?.Timestamp is { } rts && rts != default) - { - var key = ContentKey(entry.Kind, entry.Text); - if (!rebuiltContentTimestamps.TryGetValue(key, out var list)) - rebuiltContentTimestamps[key] = list = new List(); - list.Add(rts.ToUnixTimeSeconds()); - } - } - - var existingIds = new HashSet(StringComparer.Ordinal); - var maxSuffix = 0; - foreach (var entry in rebuilt.Entries) - { - existingIds.Add(entry.Id); - if (entry.Id.Length > 1 && entry.Id[0] == 'e' && - int.TryParse(entry.Id.AsSpan(1), out var n) && n > maxSuffix) - maxSuffix = n; - } - - var nextId = Math.Max(rebuilt.NextId, maxSuffix + 1); - var newEntries = rebuilt.Entries.ToBuilder(); - var skippedDup = 0; - var reidCount = 0; - var authoritativeMaxHistorySequence = authoritative - ? history.Messages - .Where(message => message.OpenClawSeq is not null) - .Select(message => message.OpenClawSeq!.Value) - .DefaultIfEmpty(int.MinValue) - .Max() - : int.MinValue; - - foreach (var entry in prior.Entries) - { - priorMeta.TryGetValue(entry.Id, out var em); - var priorTs = em?.Timestamp; - if (!string.IsNullOrEmpty(em?.GatewayMessageId) && - rebuiltMessageIds.Contains(em.GatewayMessageId)) - { - ConsumeAnyTimestamp(rebuiltContentTimestamps, ContentKey(entry.Kind, entry.Text)); - skippedDup++; - continue; - } - - if (em?.OpenClawSeq is { } seq && - TryConsumeCount(rebuiltSequenceCounts, SequenceKey(entry.Kind, seq))) - { - ConsumeAnyTimestamp(rebuiltContentTimestamps, ContentKey(entry.Kind, entry.Text)); - skippedDup++; - continue; - } - - if (authoritative) - { - if (!ShouldPreserveLiveEntryDuringAuthoritativeReload( - em, - authoritativeMaxHistorySequence, - historyRequestStartedAt)) - continue; - } - - // Rule 2: content+timestamp dedup only when BOTH sides - // have valid timestamps within 2 seconds. Otherwise - // (Rule 3) fall through and keep the entry — silent - // data loss is worse than visible duplicates. - if (priorTs is { } pts && pts != default && - rebuiltContentTimestamps.TryGetValue(ContentKey(entry.Kind, entry.Text), out var rebuiltTimes)) - { - var priorSec = pts.ToUnixTimeSeconds(); - var matched = false; - for (var rebIndex = 0; rebIndex < rebuiltTimes.Count; rebIndex++) - { - var rebSec = rebuiltTimes[rebIndex]; - if (Math.Abs(rebSec - priorSec) <= 2) - { - rebuiltTimes.RemoveAt(rebIndex); - matched = true; - break; - } - } - if (matched) - { - skippedDup++; - continue; - } - } - - // Re-id on collision (sequential IDs always collide - // between rebuilt and prior). - var entryToAdd = entry; - if (existingIds.Contains(entry.Id)) - { - var newId = $"e{nextId++}"; - entryToAdd = entry with { Id = newId }; - reidCount++; - } - else if (entry.Id.Length > 1 && entry.Id[0] == 'e' && - int.TryParse(entry.Id.AsSpan(1), out var nn) && nn >= nextId) - { - // Bump nextId past this entry's suffix to avoid future collisions. - nextId = nn + 1; - } - - newEntries.Add(entryToAdd); - existingIds.Add(entryToAdd.Id); - if (em?.Timestamp is { } addTs && addTs != default) - { - var key = ContentKey(entryToAdd.Kind, entryToAdd.Text); - if (!rebuiltContentTimestamps.TryGetValue(key, out var list)) - rebuiltContentTimestamps[key] = list = new List(); - list.Add(addTs.ToUnixTimeSeconds()); - } - if (!string.IsNullOrEmpty(em?.GatewayMessageId)) - rebuiltMessageIds.Add(em.GatewayMessageId); - if (em?.OpenClawSeq is { } addSeq) - IncrementCount(rebuiltSequenceCounts, SequenceKey(entryToAdd.Kind, addSeq)); - if (em is not null && !rebuiltMeta.ContainsKey(entryToAdd.Id)) - rebuiltMeta[entryToAdd.Id] = em; - } - - if (skippedDup > 0 || reidCount > 0) - Logger.Debug($"[ChatHistory] dedup: skipped={skippedDup} reid={reidCount} prior={prior.Entries.Count}"); - - rebuilt = rebuilt with - { - Entries = newEntries.ToImmutable(), - NextId = nextId, - TurnActive = prior.TurnActive, - PendingToolPresentations = prior.PendingToolPresentations, - PendingToolOutcomes = prior.PendingToolOutcomes, - TerminalToolCorrelations = prior.TerminalToolCorrelations, - NextToolOutcomeSequence = prior.NextToolOutcomeSequence, - NextToolCorrelationSequence = prior.NextToolCorrelationSequence, - ToolLegacyTurn = prior.ToolLegacyTurn - }; - rebuilt = ChatTimelineReducer.RebuildActiveToolTracking(rebuilt); + Logger.Debug($"ChatDataProvider: continuing model patch after previous patch failed: {ex.Message}"); } - - _timelines[threadId] = rebuilt; - _historyRevisions[threadId] = GetHistoryRevisionLocked(threadId) + 1; - _entryMeta[threadId] = rebuiltMeta; - _historyLoaded.Add(threadId); - _historyRetryCount.Remove(threadId); } - PublishHistoryIfCurrent(requestConnectionVersion); + await patchOperation(); } catch (Exception ex) { - if (ex is OperationCanceledException) - { - if (historyRequest is not null) - _ = ObserveCanceledHistoryRequestAsync(historyRequest); - historyOutcome = ChatTelemetryOutcome.Canceled; - return; - } - - bool shouldRetry; - lock (_gate) - { - if (_disposed || - _historyConnectionVersion != requestConnectionVersion || - GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion) - { - historyOutcome = ChatTelemetryOutcome.Canceled; - return; - } - - historyOutcome = ChatTelemetryOutcome.Failure; - historyException = ex; - _historyRetryCount.TryGetValue(threadId, out var retries); - shouldRetry = _status == ConnectionStatus.Connected - && (replacementReload || authoritative || !_historyLoaded.Contains(threadId)) - && retries < MaxHistoryRetries; - if (shouldRetry) - _historyRetryCount[threadId] = retries + 1; - } - - _historyFailureReservedForTesting?.Invoke(); - lock (_gate) - { - if (_disposed || - _historyConnectionVersion != requestConnectionVersion || - GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion) - { - historyOutcome = ChatTelemetryOutcome.Canceled; - historyException = null; - shouldRetry = false; - return; - } - } - - RaiseHistoryNotificationIfCurrent( - new ChatProviderNotification( - ChatProviderNotificationKind.Error, - threadId, - LocalizationHelper.GetString("Chat_Notification_LoadHistoryFailed"), - ex.Message), - threadId, - requestConnectionVersion, - requestHistoryReplacementVersion); - - lock (_gate) - { - if (_disposed || - _historyConnectionVersion != requestConnectionVersion || - GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion) - { - historyOutcome = ChatTelemetryOutcome.Canceled; - historyException = null; - shouldRetry = false; - } - } - - // If still connected and under the retry limit, retry after a - // short delay so the UI auto-recovers when the gateway becomes - // ready to serve history. - if (shouldRetry) - { - _ = ObserveHistoryRetryAsync(_scheduleHistoryRetry( - HistoryRetryDelay, - generationCancellationToken, - async () => - { - await LoadHistoryCoreAsync( - threadId, - force: true, - CancellationToken.None, - expectedConnectionVersion: requestConnectionVersion, - expectedHistoryReplacementVersion: requestHistoryReplacementVersion, - authoritative: authoritative, - replacementReload: replacementReload); - })); - } - } - finally - { - bool rerunReplacement; - bool rerunAuthoritative; - lock (_gate) - { - if (_historyConnectionVersion == requestConnectionVersion) - { - _historyInFlight.Remove(threadId); - rerunReplacement = _replacementHistoryReloadPending.Remove(threadId); - rerunAuthoritative = !rerunReplacement - && _authoritativeHistoryReloadPending.Remove(threadId); - } - else - { - // Generation advance owns clearing pending reload state. - rerunReplacement = false; - rerunAuthoritative = false; - } - } - _telemetry.FinishHistoryLoad(historyOperation, historyOutcome, historyException); - if (rerunReplacement) - { - _ = LoadHistoryCoreAsync( - threadId, - force: true, - CancellationToken.None, - expectedConnectionVersion: requestConnectionVersion, - authoritative: false, - replacementReload: true); - } - else if (rerunAuthoritative) - _ = LoadHistoryAsync(threadId, force: true, authoritative: true); - } - } - - private static async Task ObserveHistoryRetryAsync(Task retryTask) - { - try - { - await retryTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Connection-generation and provider-lifetime cancellation are expected. - } - catch (Exception ex) - { - Logger.Warn($"[ChatHistory] Retry scheduler failed: {ex.GetType().Name}"); - } - } - - private static async Task ObserveCanceledHistoryRequestAsync(Task historyRequest) - { - try - { - await historyRequest.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // The gateway request was canceled with its connection. - } - catch (Exception ex) - { - Logger.Debug($"[ChatHistory] Canceled request completed with {ex.GetType().Name}"); - } - } - - private void PublishHistoryIfCurrent(long requestConnectionVersion) - { - void Deliver() - { - ChatDataSnapshot snapshot; - lock (_gate) - { - if (_disposed || _historyConnectionVersion != requestConnectionVersion) - return; - - snapshot = BuildSnapshotLocked(); - } - - Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot)); - if (snapshot.Threads.Length > 0 || snapshot.AvailableModels.Length > 0) - DebounceSaveLastChatState(snapshot); - } - - if (_post is null) - Deliver(); - else - _post(Deliver); - } - - private void RaiseHistoryNotificationIfCurrent( - ChatProviderNotification notification, - string threadId, - long requestConnectionVersion, - long requestHistoryReplacementVersion) - { - var args = new ChatProviderNotificationEventArgs(notification); - - void Deliver() - { - lock (_gate) - { - if (_disposed || - _historyConnectionVersion != requestConnectionVersion || - GetHistoryReplacementVersionLocked(threadId) != requestHistoryReplacementVersion) - return; - } - - NotificationRequested?.Invoke(this, args); - } - - if (_post is null) - Deliver(); - else - _post(Deliver); - } - - public Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; // Not supported by gateway — no-op. - } - - public Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.CompletedTask; // Not supported by gateway — no-op. - } - - public async Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - // The gateway's sessions.patch schema treats `model` as a non-empty - // string; a blank value here is a no-op rather than a clear. Use - // ClearModelAsync to revert a session to the gateway default. - if (string.IsNullOrWhiteSpace(model)) return; - await TrackModelPatchAsync(threadId, () => _bridge.PatchSessionModelAsync(threadId, model)); - } - - public async Task ClearModelAsync(string threadId, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - // Tri-state clear: removes the session's model override (explicit null) - // so it tracks the gateway/agent default again. - await TrackModelPatchAsync(threadId, () => _bridge.ClearSessionModelAsync(threadId)); - } - - private async Task TrackModelPatchAsync(string threadId, Func patchOperation) - { - var startSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Task? previous; - Task pending; - lock (_gate) - { - _pendingModelPatches.TryGetValue(threadId, out previous); - pending = RunModelPatchAsync(previous, patchOperation, startSignal.Task); - _pendingModelPatches[threadId] = pending; - } - - startSignal.SetResult(); - try - { - await pending; - } - finally - { - lock (_gate) - { - if (_pendingModelPatches.TryGetValue(threadId, out var current) - && ReferenceEquals(current, pending)) - _pendingModelPatches.Remove(threadId); - } - } - } - - private static async Task RunModelPatchAsync(Task? previous, Func patchOperation, Task startSignal) - { - await startSignal; - if (previous is not null) - { - try { await previous; } - catch (Exception ex) - { - Logger.Debug($"ChatDataProvider: continuing model patch after previous patch failed: {ex.Message}"); - } - } - - await patchOperation(); - } + failure = ex; + throw; + } + finally + { + _state.CompleteModelPatch(lease, failure); + } + } private async Task AwaitPendingModelPatchAsync(string threadId, CancellationToken cancellationToken) { - Task? pending; - lock (_gate) - { - _pendingModelPatches.TryGetValue(threadId, out pending); - } - + var pending = _state.GetPendingModelPatch(threadId); if (pending is not null) { try { await pending.WaitAsync(cancellationToken); } @@ -1977,27 +818,8 @@ public async Task EnsureCommandCatalogAsync(CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); - int epoch; - lock (_gate) - { - // Only fetch while connected — the command catalog is a property of - // the live gateway connection. A not-connected caller would just - // land in the catch below. - if (_status != ConnectionStatus.Connected) - return; - // Already loaded (or a fetch is running) → reuse the cached catalog - // rather than hammering commands.list every time the palette opens. - // A reconnect clears _commandCatalog (see OnStatusChanged), so a - // fresh fetch happens after reconnect. - if (_commandsFetchInFlight || _commandCatalog is not null) - return; - _commandsFetchInFlight = true; - // Capture the connection epoch BEFORE the await. If a disconnect (or - // reconnect) happens while ListCommandsAsync is in flight, - // OnStatusChanged bumps the epoch; the late result is then discarded - // rather than resurrecting a stale catalog for the new connection. - epoch = _commandsEpoch; - } + if (!_state.TryBeginCommandCatalogFetch(out var epoch)) + return; CommandCatalog catalog; try @@ -2011,34 +833,13 @@ public async Task EnsureCommandCatalogAsync(CancellationToken cancellationToken catch (Exception ex) { Logger.Warn($"[ChatProvider] EnsureCommandCatalogAsync failed: {ex.Message}"); - var shouldPublishFallback = false; - lock (_gate) - { - // Only publish a fallback if no status change superseded this - // fetch. A failure must still move the UI out of its "loading" - // state; otherwise slash-leading text would keep trapping Enter - // until reconnect. Treat the catalog as temporarily unavailable - // for this connection and let reconnect clear/refetch it. - if (epoch == _commandsEpoch && _status == ConnectionStatus.Connected) - { - _commandsFetchInFlight = false; - _commandCatalog = new CommandCatalog { IsSupported = false }; - shouldPublishFallback = true; - } - } - if (shouldPublishFallback) + if (_state.FailCommandCatalogFetch(epoch)) PublishCommandCatalogIfFresh(epoch); return; } - lock (_gate) - { - // Drop the result if the connection changed during the await. - if (epoch != _commandsEpoch || _status != ConnectionStatus.Connected) - return; - _commandsFetchInFlight = false; - _commandCatalog = catalog; - } + if (!_state.CompleteCommandCatalogFetch(epoch, catalog)) + return; Logger.Info($"[ChatProvider] commands.list: supported={catalog.IsSupported} count={catalog.Commands.Count}"); // Re-validate freshness at UI-thread delivery time rather than // publishing a snapshot captured under the lock above. This closes the @@ -2059,13 +860,11 @@ private void PublishCommandCatalogIfFresh(int epoch) { void Deliver() { - ChatDataSnapshot snapshot; - lock (_gate) - { - if (epoch != _commandsEpoch) return; - snapshot = BuildSnapshotLocked(); - } - Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot)); + var snapshot = _state.SnapshotCommandCatalogIfFresh( + epoch, + ProjectionContext()); + if (snapshot is not null) + Changed?.Invoke(this, new ChatDataChangedEventArgs(snapshot)); } if (_post is null) @@ -2176,57 +975,39 @@ private static ChatPermissionDecision ChatDecisionForApprovalAction(string actio private void ClearPendingPermissionAndPublish(string threadId, string? expectedRequestId = null, ChatPermissionDecision decision = ChatPermissionDecision.Expired) { - ChatDataSnapshot snapshot; - lock (_gate) + var pendingId = _state.PendingPermissionId(threadId); + if (pendingId is null) { - var current = GetOrCreateTimelineLocked(threadId); - if (current.PendingPermission is null) - { - Logger.Info($"[Approval] clear requested but no PendingPermission for thread='{threadId}'"); - return; - } - if (expectedRequestId is not null - && !string.Equals(current.PendingPermission.RequestId, expectedRequestId, System.StringComparison.Ordinal)) - { - Logger.Info($"[Approval] clear skipped — pending is '{current.PendingPermission.RequestId}', expected '{expectedRequestId}' (newer approval superseded)"); - return; - } - Logger.Info($"[Approval] clearing PendingPermission requestId='{current.PendingPermission.RequestId}' on thread='{threadId}' decision={decision}"); - _timelines[threadId] = ChatTimelineReducer.ResolvePermission(current, current.PendingPermission.RequestId, decision); - snapshot = BuildSnapshotLocked(); + Logger.Info($"[Approval] clear requested but no PendingPermission for thread='{threadId}'"); + return; + } + if (expectedRequestId is not null && + !string.Equals(pendingId, expectedRequestId, StringComparison.Ordinal)) + { + Logger.Info($"[Approval] clear skipped — pending is '{pendingId}', expected '{expectedRequestId}' (newer approval superseded)"); + return; } + Logger.Info($"[Approval] clearing PendingPermission requestId='{pendingId}' on thread='{threadId}' decision={decision}"); + var snapshot = _state.ClearPendingPermission( + threadId, + expectedRequestId, + decision, + ProjectionContext()); Publish(snapshot); } public ValueTask DisposeAsync() { - System.Threading.Timer? timerToDispose; - System.Threading.Timer? chatStateTimerToDispose; - CancellationTokenSource historyGenerationToCancel; - lock (_gate) - { - if (_disposed) return ValueTask.CompletedTask; - _disposed = true; - historyGenerationToCancel = AdvanceHistoryGenerationLocked(clearLoaded: false); - _telemetry.FinishAll(ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Disposed); - timerToDispose = _toolMetaSaveTimer; - _toolMetaSaveTimer = null; - _toolMetaSaveVersion++; - chatStateTimerToDispose = _lastChatStateSaveTimer; - _lastChatStateSaveTimer = null; - _queuedMessages.Clear(); - _queuedSendRequests.Clear(); - _queuedDrainScheduledThreads.Clear(); - _queuedMessageIdsByRunId.Clear(); - _terminalRunIdsByThread.Clear(); - _localSentTexts.Clear(); - _locallyInitiatedThreads.Clear(); - _resetSubmittedLocalEchoTexts.Clear(); - } - CancelAndDisposeHistoryGeneration(historyGenerationToCancel); - timerToDispose?.Dispose(); - chatStateTimerToDispose?.Dispose(); - SaveToolMetaCache(); + var transition = _state.DisposeState(); + if (!transition.IsFirstDispose) + return ValueTask.CompletedTask; + _telemetry.FinishAll( + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.Disposed); + _historyLoader.Completed -= OnHistoryLoadCompleted; + _historyLoader.Dispose(); + _metadataStore.Dispose(); + _persistence.Dispose(); _bridge.StatusChanged -= OnStatusChanged; _bridge.SessionsUpdated -= OnSessionsUpdated; _bridge.SessionCommandCompleted -= OnSessionCommandCompleted; @@ -2243,214 +1024,49 @@ public ValueTask DisposeAsync() /// adapter mutations. Returns an empty dictionary if nothing is tracked. /// public IReadOnlyDictionary GetEntryMetadata(string threadId) - { - lock (_gate) - { - return _entryMeta.TryGetValue(threadId, out var m) - ? new Dictionary(m) - : new Dictionary(); - } - } + => _state.GetEntryMetadata(threadId); // ── Event handlers ── private void OnStatusChanged(object? sender, ConnectionStatus status) { - ChatDataSnapshot snapshot; - bool justReconnected; - string[] threadsToInterrupt; - string[] threadsToReset; - CancellationTokenSource? historyGenerationToCancel = null; - lock (_gate) + if (_state.IsDisposed) + return; + var transition = _historyLoader.ApplyStatusAndAdvanceGeneration( + status, + ProjectionContext()); + if (transition.Reconnected || transition.Disconnected) { - if (_disposed) - return; - - justReconnected = status == ConnectionStatus.Connected - && _status != ConnectionStatus.Connected; - // MEDIUM 5: detect Connected → Disconnected/Error transitions so - // we can synthesise a turn-end + status entry on every thread that - // had an in-flight turn (otherwise the UI sits "thinking" forever). - var justDisconnected = status != ConnectionStatus.Connected - && _status == ConnectionStatus.Connected; - _status = status; - - // Reset the sessions-list-received gate whenever we leave the - // Connected state. Any cached sessions belong to the previous - // connection; the UI must treat the composer as not-yet-ready - // until the next sessions.list arrives. - if (status != ConnectionStatus.Connected) - _sessionsListReceived = false; - - // Drop the cached command catalog whenever we leave Connected so a - // reconnect re-fetches commands.list (the catalog can change across - // gateways / agent reconfigurations). Bumping the epoch invalidates - // any commands.list fetch still in flight so its late result is - // discarded instead of resurrecting a stale catalog. - if (status != ConnectionStatus.Connected) - { - _commandsEpoch++; - _commandCatalog = null; - _commandsFetchInFlight = false; - } - - // Reset the approval-dedupe LRU on every transition out of - // Connected. IDs from a prior session must not block a fresh - // approval with a colliding slug from the next connection. - if (justDisconnected) - ResetApprovalDedupe(); - - // On (re)connect, invalidate transcript freshness without fetching - // every session. The selected-thread render path requests the one - // transcript the user is viewing; other sessions remain metadata-only - // until selected. Bumping the version also prevents responses from - // the prior connection from overwriting a newly selected transcript. - if (justReconnected) - { - _telemetry.FinishAll(ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Disconnected); - historyGenerationToCancel = AdvanceHistoryGenerationLocked(clearLoaded: true); - _locallyInitiatedThreads.Clear(); - _localSentTexts.Clear(); - _queuedMessages.Clear(); - _queuedSendRequests.Clear(); - _queuedDrainScheduledThreads.Clear(); - _assistantFallbackPromotedThreads.Clear(); - _queuedMessageIdsByRunId.Clear(); - _terminalRunIdsByThread.Clear(); - _resetSubmittedLocalEchoTexts.Clear(); - _activeRunIds.Clear(); - _activeRunStartSequences.Clear(); - foreach (var threadId in _timelines.Keys.ToArray()) - { - _timelines[threadId] = ChatTimelineReducer.Apply( - _timelines[threadId], - new ChatToolReplayResetEvent()); - } - // Reset keyless-event diagnostic so a fresh reconnect to a - // still-broken gateway surfaces the notification again. - System.Threading.Interlocked.Exchange(ref _keylessEventDiagnosticRaised, 0); - } - if (justDisconnected) - { - historyGenerationToCancel = AdvanceHistoryGenerationLocked(clearLoaded: false); - _telemetry.FinishAll(ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Disconnected); - var list = new List(); - foreach (var (key, tl) in _timelines) - { - if (tl.TurnActive) list.Add(key); - } - threadsToInterrupt = list.ToArray(); - threadsToReset = _timelines.Keys.ToArray(); - foreach (var threadId in threadsToInterrupt) - { - _activeRunIds.Remove(threadId); - _activeRunStartSequences.Remove(threadId); - } - } - else - { - threadsToInterrupt = Array.Empty(); - threadsToReset = Array.Empty(); - } - - snapshot = BuildSnapshotLocked(); + _telemetry.FinishBeforeConnectionGeneration( + transition.HistoryGeneration, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.Disconnected); } - CancelAndDisposeHistoryGeneration(historyGenerationToCancel); - Publish(snapshot); + Publish(transition.Snapshot); - // MEDIUM 5: synthesize the turn-end + status note for any threads - // that were mid-turn when the connection dropped. - var interruptedMsg = LocalizationHelper.GetString("Chat_Notification_ConnectionInterrupted"); - foreach (var threadId in threadsToInterrupt) + var interruptedMessage = LocalizationHelper.GetString( + "Chat_Notification_ConnectionInterrupted"); + foreach (var threadId in transition.InterruptedThreads) { - ApplyEventAndPublish(threadId, new ChatStatusEvent(interruptedMsg, ChatTone.Warning)); + ApplyEventAndPublish( + threadId, + new ChatStatusEvent(interruptedMessage, ChatTone.Warning)); ApplyEventAndPublish(threadId, new ChatTurnEndEvent()); } - if (threadsToReset.Length > 0) - { - lock (_gate) - { - foreach (var threadId in threadsToReset) - { - if (_timelines.TryGetValue(threadId, out var timeline)) - { - _timelines[threadId] = ChatTimelineReducer.Apply( - timeline, - new ChatToolReplayResetEvent()); - } - } - } - } - - } - - private CancellationTokenSource AdvanceHistoryGenerationLocked(bool clearLoaded) - { - var previousCancellation = _historyGenerationCancellation; - _historyConnectionVersion++; - if (!_disposed) - _historyGenerationCancellation = new CancellationTokenSource(); - _historyInFlight.Clear(); - _historyRetryCount.Clear(); - _authoritativeHistoryReloadPending.Clear(); - _replacementHistoryReloadPending.Clear(); - if (clearLoaded) - _historyLoaded.Clear(); - return previousCancellation; - } - - private static void CancelAndDisposeHistoryGeneration(CancellationTokenSource? cancellation) - { - if (cancellation is null) - return; - - try - { - cancellation.Cancel(); - } - finally - { - cancellation.Dispose(); - } + if (transition.Disconnected) + _state.ClearToolReplayState(); } private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) { - ChatDataSnapshot snapshot; - string[] queuedThreadsToDrain; - lock (_gate) - { - var previousUsage = _sessions - .Where(s => !string.IsNullOrEmpty(s.Key)) - .ToDictionary(s => s.Key, s => (s.InputTokens, s.OutputTokens, s.TotalTokens, s.ContextTokens)); - _sessions = sessions ?? Array.Empty(); - SeedSessionIdsFromSessionsLocked(_sessions); - _sessionsListReceived = true; - EnsureTimelinesForSessionsLocked(); - RememberLastSessionStateLocked(); - foreach (var s in _sessions) - { - if (string.IsNullOrEmpty(s.Key)) continue; - var currentUsage = (s.InputTokens, s.OutputTokens, s.TotalTokens, s.ContextTokens); - var usageChanged = !previousUsage.TryGetValue(s.Key, out var prevUsage) - || prevUsage != currentUsage; - if (usageChanged) - SnapshotLatestAssistantUsageLocked(s, ResolveTimelineKeyForSessionLocked(s)); - } - snapshot = BuildSnapshotLocked(); - - if (_status == ConnectionStatus.Connected) - { - queuedThreadsToDrain = _queuedMessages.Keys.ToArray(); - } - else - { - queuedThreadsToDrain = Array.Empty(); - } - } - Publish(snapshot); + if (_state.IsDisposed) + return; + var transition = _state.ApplySessions( + sessions ?? [], + ProjectionContext()); + Publish(transition.Snapshot); - foreach (var threadId in queuedThreadsToDrain) + foreach (var threadId in transition.QueuedThreadsToDrain) { TryDispatchNextQueuedSend(threadId); } @@ -2460,14 +1076,15 @@ internal static bool ShouldPreserveLiveEntryDuringAuthoritativeReload( ChatEntryMetadata? metadata, int maxHistorySequence, DateTimeOffset historyRequestStartedAt) => - metadata is null || - metadata.OpenClawSeq is null || - metadata.OpenClawSeq is { } liveSequence && liveSequence > maxHistorySequence || - metadata.Timestamp is { } liveTimestamp && liveTimestamp >= historyRequestStartedAt || - metadata.IsLocalQueuedSend; + ChatConversationState.ShouldPreserveLiveEntryDuringAuthoritativeReload( + metadata, + maxHistorySequence, + historyRequestStartedAt); private void OnSessionCommandCompleted(object? sender, SessionCommandResult result) { + if (_state.IsDisposed) + return; if (result is not { Ok: true } || string.IsNullOrWhiteSpace(result.Key)) { return; @@ -2487,17 +1104,29 @@ private void OnSessionCommandCompleted(object? sender, SessionCommandResult resu private void ApplySuccessfulReset(string threadId) { - ChatDataSnapshot snapshot; - ResetClearPersistence persistence; - lock (_gate) + var transition = _state.ResetThread( + threadId, + ProjectionContext()); + _historyLoader.ApplyReset( + threadId, + transition.ResetGeneration); + _telemetry.FinishThreadBeforeResetGeneration( + threadId, + transition.ResetGeneration, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.Reset); + Publish(transition.Snapshot); + if (_persistence.ApplyReset( + transition.ThreadId, + transition.ResetGeneration)) { - persistence = ClearThreadHistoryAfterResetLocked(threadId); - snapshot = BuildSnapshotLocked(); + _persistence.SaveAbortedIds(); } - - Publish(snapshot); - PersistClearedResetState(persistence); - AbortSubmittedRunsAfterReset(threadId, persistence.SubmittedRunIds); + _metadataStore.EvictReset( + transition.ThreadId, + transition.OldSessionId, + transition.ResetGeneration); + AbortSubmittedRunsAfterReset(threadId, transition.SubmittedRunIds); } private void AbortSubmittedRunsAfterReset(string threadId, IReadOnlyList runIds) @@ -2527,742 +1156,410 @@ private void AbortSubmittedRunsAfterReset(string threadId, IReadOnlyList private void OnModelsListUpdated(object? sender, ModelsListInfo info) { - ChatDataSnapshot snapshot; - lock (_gate) - { - _modelChoices = ChatModelChoice.FromModelsList(info); - _availableModels = ModelIdsFromChoices(_modelChoices); - snapshot = BuildSnapshotLocked(); - } - Logger.Info($"[ChatBridge] OnModelsListUpdated: count={_availableModels.Length}"); + if (_state.IsDisposed) + return; + var snapshot = _state.ApplyModels(info, ProjectionContext()); + Logger.Info($"[ChatBridge] OnModelsListUpdated: count={snapshot.AvailableModels.Length}"); Publish(snapshot); } - // Selectable wire ids (e.g. "claude-opus-4.5") in gateway order, used by - // the composer to match against SessionInfo.Model. Kept as a parallel - // string[] for back-compat and safe reconnect persistence. - private static string[] ModelIdsFromChoices(IReadOnlyList choices) + private void OnChatMessageReceived(object? sender, ChatMessageInfo message) { - if (choices.Count == 0) return Array.Empty(); - var seen = new HashSet(StringComparer.Ordinal); - var ids = new List(choices.Count); - foreach (var choice in choices) + if (message is null || _state.IsDisposed) + return; + if (string.IsNullOrEmpty(message.SessionKey)) { - if (!choice.IsSelectable) continue; - if (seen.Add(choice.Id)) - ids.Add(choice.Id); + Logger.Warn($"[ChatProvider] Dropping chat message with empty sessionKey (role={message.Role})"); + RaiseKeylessEventDiagnosticOnce(); + return; } - return ids.ToArray(); - } - // Rehydrate minimal choices from a cached id list (reconnect / pre-connect - // path) when richer gateway metadata isn't available yet. - private static IReadOnlyList ChoicesFromIds(string[] ids) - { - if (ids.Length == 0) return Array.Empty(); - var seen = new HashSet(StringComparer.Ordinal); - var list = new List(ids.Length); - foreach (var id in ids) - { - if (string.IsNullOrEmpty(id)) continue; - if (!seen.Add(id)) continue; - list.Add(new ChatModelChoice(id, id)); - } - return list; - } - - private void OnChatMessageReceived(object? sender, ChatMessageInfo message) - { - if (message is null) return; - - // The gateway must include a canonical sessionKey on every chat event. - // If it doesn't, that's a protocol bug — drop the event rather than - // routing it to a literal "main" bucket that can't possibly match the - // optimistic timeline keyed by the canonical key. Surfacing the drop - // here makes future protocol gaps visible instead of silently merging - // into a synthetic key. - if (string.IsNullOrEmpty(message.SessionKey)) - { - Logger.Warn($"[ChatProvider] Dropping chat message with empty sessionKey (role={message.Role})"); - RaiseKeylessEventDiagnosticOnce(); - return; - } - - // Permanent low-volume trace for chat-message arrivals. One line per - // frame the gateway sends, ordered by arrival. Includes a short - // per-process-salted hash so two near-duplicate frames can be told - // apart at a glance when hunting the duplicate-bubble bug (the - // reducer's identical-text safety net only catches BYTE-equal - // dupes; if the two frames differ by a single char the dupe - // survives). The hash is seeded with a random value that rotates - // on every tray restart, so it cannot be reproduced from a guessed - // plaintext outside this process — it is a per-run frame - // discriminator, not a content fingerprint. var traceText = message.Text ?? string.Empty; Logger.Info( $"[ChatTrace] chat.message thread='{message.SessionKey}' role='{message.Role}' " + - $"final={message.IsFinal} len={traceText.Length} h={ChatTraceHash(traceText)}"); - - // Suppress chat messages for threads that were aborted by the user. - // Chat messages don't carry a runId, so we use thread-level suppression. - var msgThreadId = message.SessionKey; - var role = message.Role ?? ""; - var roleLower = role.ToLowerInvariant(); + $"final={message.IsFinal} len={traceText.Length} h={ChatContentFormatting.ChatTraceHash(traceText)}"); + var threadId = message.SessionKey; + var role = message.Role?.ToLowerInvariant() ?? string.Empty; var rawText = message.Text ?? string.Empty; - ChatDataSnapshot? resetLocalEchoSnapshot = null; - var dropAfterReset = false; - var requestRemoteBackfillAfterReset = false; - lock (_gate) + var gate = _state.GateIncomingChatMessage(message, ProjectionContext()); + HandleOpenedLifecycle( + threadId, + gate.OpenedLifecycle, + gate.RuntimeGeneration); + if (gate.Suppressed) { - if (ShouldDropChatMessageAfterResetLocked( - msgThreadId, - roleLower, - rawText, - message.Ts, - out var consumeEchoText, - out var requestRemoteBackfill)) - { - dropAfterReset = true; - requestRemoteBackfillAfterReset = requestRemoteBackfill; - if (consumeEchoText is not null && - _localSentTexts.TryGetValue(msgThreadId, out var resetEchoQueue) && - resetEchoQueue.Count > 0 && - TryConsumeLocalEchoLocked(msgThreadId, resetEchoQueue, consumeEchoText, out var queuedMessageId)) - { - var confirmedMeta = BuildLiveMetaLocked( - msgThreadId, - message.Ts, - message.OpenClawId, - message.OpenClawSeq); - if (ReconcileQueuedMessageEchoLocked(msgThreadId, queuedMessageId, confirmedMeta)) - resetLocalEchoSnapshot = BuildSnapshotLocked(); - } - } - else if (_abortedThreads.Contains(msgThreadId)) - { - Logger.Debug($"[ABORT] Suppressed ChatMessage for threadId='{msgThreadId}' (role={message.Role})"); - return; - } + Logger.Debug($"[ABORT] Suppressed ChatMessage for threadId='{threadId}' (role={message.Role})"); + return; } - if (dropAfterReset) + if (gate.Drop) { - if (resetLocalEchoSnapshot is not null) - { - Publish(resetLocalEchoSnapshot); - } - if (requestRemoteBackfillAfterReset) - _ = FetchRemoteUserMessageAsync(msgThreadId, openResetGateOnSuccess: true); - - Logger.Debug($"[Reset] Dropping stale chat message after reset for threadId='{msgThreadId}' role='{roleLower}'"); + if (gate.Snapshot is not null) + Publish(gate.Snapshot); + if (gate.RequestRemoteBackfill) + _ = FetchRemoteUserMessageAsync(threadId, openResetGateOnSuccess: true); + Logger.Debug($"[Reset] Dropping stale chat message after reset for threadId='{threadId}' role='{role}'"); return; } - if (roleLower == "system" && + if (role == "system" && string.Equals(message.OpenClawKind, "compaction", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(message.Text)) { - ChatEntryMetadata compactionMeta; - lock (_gate) - { - compactionMeta = BuildLiveMetaLocked( - msgThreadId, + ApplyEventAndPublish( + threadId, + new ChatStatusEvent( + ChatContentFormatting.TruncateForChatEntry(message.Text), + ChatTone.Dim), + _state.BuildLiveMetadata( + threadId, message.Ts, message.OpenClawId, message.OpenClawSeq, openClawKind: message.OpenClawKind, compactionTokensBefore: message.CompactionTokensBefore, - compactionTokensAfter: message.CompactionTokensAfter); - } - ApplyEventAndPublish( - msgThreadId, - new ChatStatusEvent(TruncateForChatEntry(message.Text), ChatTone.Dim), - compactionMeta); + compactionTokensAfter: message.CompactionTokensAfter)); return; } - // User messages from the SSE stream. System control notes are rendered - // as dim status entries. Normal user messages: promote echoes of - // locally-sent queued messages into the transcript, show messages from - // other clients (e.g. gateway web UI) so the conversation is coherent. - if (roleLower == "user") + if (role == "user") { - // Approval slash-commands ("/approve allow-once", - // "/approve allow-always", - // "/deny ") are transport, not user prose. If WE sent - // it (matched + consumed from _localSentTexts) suppress the - // echo entirely — RespondToPermissionAsync already cleared - // the banner. If it came from ANOTHER client subscribed to - // this thread, render a dim audit-trail status so the user - // can still see that an approval decision was made elsewhere - // (preserves audit signal). - if (LooksLikeApprovalSlashCommand(rawText)) + if (ChatContentFormatting.LooksLikeApprovalSlashCommand(rawText)) { - var slashEcho = rawText.Trim(); - bool weSentIt = false; - lock (_gate) - { - if (_localSentTexts.TryGetValue(msgThreadId, out var sq) && sq.Count > 0 - && TryConsumeLocalEchoLocked(msgThreadId, sq, slashEcho, out var slashEntryId)) - { - weSentIt = true; - RemoveQueuedMessageLocked(msgThreadId, slashEntryId); - } - } - if (weSentIt) - { - Logger.Debug($"[Approval] suppressed echo of our slash command on thread='{msgThreadId}'"); + var echo = _state.ConsumeLocalEcho( + message, + removeQueuedMessage: true, + ProjectionContext()); + if (echo.Consumed) return; - } - // From another client — render as dim audit status. - ChatEntryMetadata? approvalMeta; - lock (_gate) { approvalMeta = BuildLiveMetaLocked(msgThreadId, message.Ts); } - ApplyEventAndPublish(msgThreadId, - new ChatStatusEvent(slashEcho, ChatTone.Dim), - approvalMeta); + ApplyEventAndPublish( + threadId, + new ChatStatusEvent(rawText.Trim(), ChatTone.Dim), + _state.BuildLiveMetadata(threadId, message.Ts)); return; } - if (NativeToolProjector.LooksLikeSystemControlNote(rawText)) { - if (string.IsNullOrEmpty(message.Text)) return; - var sysThread = message.SessionKey; - ChatEntryMetadata? sysMeta; - lock (_gate) { sysMeta = BuildLiveMetaLocked(sysThread, message.Ts); } - ApplyEventAndPublish(sysThread, - new ChatStatusEvent(TruncateForChatEntry(message.Text), ChatTone.Dim), - sysMeta); - return; - } - - // Check if this is an echo of a locally-sent message. - var echoText = (message.Text ?? "").Trim(); - bool isLocalEcho = false; - ChatDataSnapshot? echoSnapshot = null; - lock (_gate) - { - if (_localSentTexts.TryGetValue(msgThreadId, out var q) && q.Count > 0 - && TryConsumeLocalEchoLocked(msgThreadId, q, echoText, out var echoEntryId)) + if (!string.IsNullOrEmpty(message.Text)) { - isLocalEcho = true; - var confirmedMeta = BuildLiveMetaLocked( - msgThreadId, - message.Ts, - message.OpenClawId, - message.OpenClawSeq); - if (ReconcileQueuedMessageEchoLocked(msgThreadId, echoEntryId, confirmedMeta)) - echoSnapshot = BuildSnapshotLocked(); + ApplyEventAndPublish( + threadId, + new ChatStatusEvent( + ChatContentFormatting.TruncateForChatEntry(message.Text), + ChatTone.Dim), + _state.BuildLiveMetadata(threadId, message.Ts)); } + return; } - if (isLocalEcho) + + var localEcho = _state.ConsumeLocalEcho( + message, + removeQueuedMessage: false, + ProjectionContext()); + if (localEcho.Consumed) { - if (echoSnapshot is not null) - { - Publish(echoSnapshot); - } + if (localEcho.Snapshot is not null) + Publish(localEcho.Snapshot); return; } - - // Not a local echo — show it as a user message from another client. if (!string.IsNullOrEmpty(message.Text)) { - var userText = TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(message.Text)); - ChatEntryMetadata? userMeta; - ChatDataSnapshot? reconciledLocalQueuedSnapshot = null; - lock (_gate) - { - userMeta = BuildLiveMetaLocked( - msgThreadId, - message.Ts, - message.OpenClawId, - message.OpenClawSeq); - if (TryReconcileExistingLocalQueuedUserEchoLocked(msgThreadId, userText, userMeta)) - reconciledLocalQueuedSnapshot = BuildSnapshotLocked(); - } - if (reconciledLocalQueuedSnapshot is not null) + var userText = ChatContentFormatting.TruncateForChatEntry( + ChatMetadataStore.EscapeUntrustedAttachmentMarkerLines(message.Text)); + var reconciled = _state.ReconcileExistingLocalQueuedUser( + message, + userText, + ProjectionContext()); + if (reconciled.Consumed) { - Publish(reconciledLocalQueuedSnapshot); + if (reconciled.Snapshot is not null) + Publish(reconciled.Snapshot); return; } - - ApplyEventAndPublish(msgThreadId, + ApplyEventAndPublish( + threadId, new ChatUserMessageEvent(userText), - userMeta); + _state.BuildLiveMetadata( + threadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq)); } return; } - // ``role=toolresult`` frames are tool-output provenance and need to - // render as a tool chip, the same way history does at lines 372-390 - // (chat rubber-duck MEDIUM 2). - if (roleLower == "toolresult" || roleLower == "tool_result") + if (role is "toolresult" or "tool_result") { - if (string.IsNullOrEmpty(message.Text)) return; - var trThread = message.SessionKey; - ChatEntryMetadata? trMeta; - string? trRunId; - lock (_gate) - { - trMeta = BuildLiveMetaLocked( - trThread, - message.Ts, - message.OpenClawId, - message.OpenClawSeq); - _activeRunIds.TryGetValue(trThread, out trRunId); - } - var capped = TruncateForChatEntry(message.Text); - var kind = NativeToolProjector.ClassifyFlattenedToolOutput(capped); - var label = NativeToolProjector.ExtractFlattenedToolSummary(capped); - _telemetry.ObserveInboundOutput( - trThread, - trRunId, - ChatResponseOutputKind.Tool); - ApplyEventAndPublish( - trThread, - new ChatToolStartEvent( - label, - kind, - IdentityStrength: NativeToolProjector.ClassifyHistoryIdentityStrength(kind)), - trMeta); - ApplyEventAndPublish(trThread, new ChatToolOutputEvent(capped), trMeta); + if (string.IsNullOrEmpty(message.Text)) + return; + var (metadata, runId) = _state.BuildMetadataWithRun(message); + var capped = ChatContentFormatting.TruncateForChatEntry(message.Text); + var mapped = ChatEventMapper.MapFlattenedToolOutput(capped, runId); + _telemetry.ObserveInboundOutput(threadId, runId, ChatResponseOutputKind.Tool); + ApplyEventAndPublish(threadId, mapped.Start, metadata); + ApplyEventAndPublish(threadId, mapped.Output, metadata); return; } - if (roleLower != "assistant") - return; - if (ChatMessageInfo.IsSilentAssistantDirective(roleLower, message.Text)) - return; - if (string.IsNullOrEmpty(message.Text)) - return; - - var threadId = message.SessionKey; - var cappedAssistantText = RepairContentBlockSeams(TruncateForChatEntry(message.Text)); - AssistantQueueFrameDisposition assistantDisposition; - lock (_gate) - { - assistantDisposition = ClassifyAssistantQueueFrameLocked( - threadId, - cappedAssistantText, - message.OpenClawId, - message.OpenClawSeq); - } - if (assistantDisposition != AssistantQueueFrameDisposition.Render) + if (role != "assistant" || + ChatMessageInfo.IsSilentAssistantDirective(role, message.Text) || + string.IsNullOrEmpty(message.Text)) { - Logger.Debug($"[Queue] Dropping retransmitted assistant frame around queued user boundary threadId='{threadId}'"); return; } - PromoteOldestQueuedMessageBeforeAssistantIfNeeded(threadId); - ChatEntryMetadata? meta; - string? telemetryRunId; - var hasUsage = message.InputTokens is not null || message.OutputTokens is not null - || message.ResponseTokens is not null || message.ContextPercent is not null; - lock (_gate) - { - meta = BuildLiveMetaLocked( - threadId, - message.Ts, - message.OpenClawId, - message.OpenClawSeq); - _activeRunIds.TryGetValue(threadId, out telemetryRunId); - // If the gateway included a usage block on this chat event, - // attach it so the assistant footer pills (↑/↓/R/ctx%) can - // render. Mostly arrives on state="final" frames. - if (hasUsage) - { - var session = Array.Find(_sessions, s => s.Key == threadId); - meta = meta with - { - InputTokens = message.InputTokens ?? meta.InputTokens, - OutputTokens = message.OutputTokens ?? meta.OutputTokens, - ResponseTokens = message.ResponseTokens ?? meta.ResponseTokens, - ContextPercent = message.ContextPercent ?? meta.ContextPercent, - ContextTokens = session?.ContextTokens > 0 ? session.ContextTokens : meta.ContextTokens - }; - } - } - - if (!message.IsFinal && IsLateNonFinalAssistantFrame(threadId)) - { - Logger.Warn($"[ChatProvider] Dropping late non-final assistant frame after completed turn for threadId='{threadId}' len={traceText.Length}"); + var assistantText = ChatContentFormatting.RepairContentBlockSeams( + ChatContentFormatting.TruncateForChatEntry(message.Text)); + var preparation = _state.PrepareAssistant( + message, + assistantText, + ProjectionContext()); + if (preparation.PromotionSnapshot is not null) + Publish(preparation.PromotionSnapshot); + if (preparation.Disposition != AssistantQueueFrameDisposition.Render) + return; + if (!message.IsFinal && _state.IsLateNonFinalAssistantFrame(threadId)) return; - } _telemetry.ObserveInboundOutput( threadId, - telemetryRunId, + preparation.ActiveRunId, ChatResponseOutputKind.Assistant); - // Both `state: "delta"` and `state: "final"` carry the cumulative - // assistant text (the gateway's EmbeddedBlockChunker emits completed - // blocks, not token deltas — see spec §"Block Streaming"). Map both - // to ChatMessageEvent so the reducer REPLACES the active assistant - // entry's text. We tag delta frames with IsStreaming:true so the - // reducer's reconcile-into-previous logic only collapses follow-up - // finals into a still-streaming preview — a finalised assistant - // from a completed earlier turn must not be silently overwritten - // by a brand-new turn's reply (e.g. user → reply → tool → reply). - // Final additionally ends the turn. ApplyEventAndPublish( threadId, new ChatMessageEvent( - cappedAssistantText, + assistantText, ReconcilePrevious: true, IsStreaming: !message.IsFinal), - meta); - - if (hasUsage) - SnapshotAssistantUsageContribution(threadId, meta); - - if (message.IsFinal) + preparation.Metadata); + + var hasUsage = message.InputTokens is not null || + message.OutputTokens is not null || + message.ResponseTokens is not null || + message.ContextPercent is not null; + if (hasUsage && + _state.SnapshotAssistantUsageContribution( + threadId, + preparation.Metadata, + ProjectionContext()) is { } usageSnapshot) { - ChatTelemetryTracker.PreparedTurnCompletion? turnCompletion = null; - lock (_gate) - { - if (_activeRunIds.Remove(threadId, out var completedRunId)) - { - turnCompletion = _telemetry.PrepareFinishByRunId( - completedRunId, - ChatTelemetryOutcome.Success, - ChatTurnTelemetryReason.AssistantFinal); - RememberTerminalRunIdLocked(threadId, completedRunId); - _abortedRunIds.Remove(completedRunId); - } - _activeRunStartSequences.Remove(threadId); - _abortedThreads.Remove(threadId); - if (!HasSendingQueuedMessagesLocked(threadId)) - _locallyInitiatedThreads.Remove(threadId); - } - _telemetry.CompletePreparedTurn(turnCompletion); - SnapshotLatestAssistantUsage(threadId); - ApplyEventAndPublish(threadId, new ChatTurnEndEvent()); - RaiseNotification(new ChatProviderNotification( - ChatProviderNotificationKind.TurnComplete, threadId, LocalizationHelper.GetString("Chat_Notification_AssistantReplied"))); - ScheduleQueuedSendDrain(threadId); + Publish(usageSnapshot); } - } - - private bool IsLateNonFinalAssistantFrame(string threadId) - { - lock (_gate) - { - if (!_timelines.TryGetValue(threadId, out var timeline)) - return false; - if (timeline.TurnActive) - return false; - - for (var i = timeline.Entries.Count - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (entry.Kind == ChatTimelineItemKind.User) - return false; - if (entry.Kind == ChatTimelineItemKind.Assistant) - return !entry.IsStreaming; - } - return false; - } + if (!message.IsFinal) + return; + var completedRunId = _state.CompleteAssistantFinal(threadId); + var completion = completedRunId is null + ? null + : _telemetry.PrepareFinishByRunId( + completedRunId, + ChatTelemetryOutcome.Success, + ChatTurnTelemetryReason.AssistantFinal); + _telemetry.CompletePreparedTurn(completion); + if (_state.SnapshotLatestAssistantUsage(threadId, ProjectionContext()) is { } latestUsage) + Publish(latestUsage); + ApplyEventAndPublish(threadId, new ChatTurnEndEvent()); + RaiseNotification(new ChatProviderNotification( + ChatProviderNotificationKind.TurnComplete, + threadId, + LocalizationHelper.GetString("Chat_Notification_AssistantReplied"))); + ScheduleQueuedSendDrain(threadId); } private void OnAgentEventReceived(object? sender, AgentEventInfo evt) { - if (evt is null) return; - // As with chat events, every agent event must carry a canonical - // sessionKey. Drop the event rather than routing to "main" if missing — - // see the rationale in OnChatMessageReceived. + if (evt is null || _state.IsDisposed) + return; if (string.IsNullOrEmpty(evt.SessionKey)) { Logger.Warn($"[ChatProvider] Dropping agent event with empty sessionKey (stream={evt.Stream})"); RaiseKeylessEventDiagnosticOnce(); return; } - var threadId = evt.SessionKey; - var isTerminalRunEvent = IsTerminalRunEvent(evt); - var reloadHistoryAfterResetDrop = false; - var shouldProcessEvent = false; - ChatTerminalEventDropReason? droppedTerminalReason = null; - lock (_gate) - { - if (ShouldDropAgentEventAfterResetLocked(evt, threadId, out reloadHistoryAfterResetDrop)) - { - Logger.Debug($"[Reset] Dropping stale agent event after reset for threadId='{threadId}' stream='{evt.Stream}' runId='{evt.RunId}'"); - } - else if (ShouldDropTerminalAgentEventLocked(evt, threadId, out droppedTerminalReason)) - { - Logger.Debug($"[Queue] Dropping stale terminal agent event for threadId='{threadId}' stream='{evt.Stream}' runId='{evt.RunId}'"); - } - else - { - shouldProcessEvent = true; - } - } - if (!shouldProcessEvent) + var threadId = evt.SessionKey; + var terminal = ChatEventMapper.IsTerminalRunEvent(evt); + var transition = _state.ProcessAgentEvent( + evt, + threadId, + ProjectionContext()); + if (!transition.Process) { - if (droppedTerminalReason.HasValue) - RecordDroppedTerminalEvent(droppedTerminalReason.Value); - if (reloadHistoryAfterResetDrop) + if (transition.DroppedTerminalReason is { } droppedReason) + RecordDroppedTerminalEvent(droppedReason); + if (transition.ReloadHistory) _ = LoadHistoryAsync(threadId, force: true); return; } - // Always update run tracking first (state maintenance must not be skipped). - var deferredAbort = UpdateActiveRunId(evt, threadId); - if (deferredAbort.DroppedTerminalReason.HasValue) - RecordDroppedTerminalEvent(deferredAbort.DroppedTerminalReason.Value); - ClearQueuedMessageOnLocalTurnStart(evt, threadId); - - // Fire deferred chat.abort and persist if pending aborts were queued. - var deferredRunId = deferredAbort.RunId; - var shouldPersist = deferredAbort.Count > 0; - if (deferredRunId is not null || shouldPersist) + HandleOpenedLifecycle( + threadId, + transition.OpenedLifecycle, + transition.RuntimeGeneration); + foreach (var snapshot in transition.Snapshots) + Publish(snapshot); + if (ChatEventMapper.IsLifecycleStart(evt)) { - _ = Task.Run(async () => + _telemetry.ObserveLifecycleStart( + threadId, + evt.RunId, + transition.AllowRemoteTurn, + transition.RuntimeGeneration); + if (!_state.IsRuntimeGenerationCurrent( + threadId, + transition.RuntimeGeneration)) { - if (deferredRunId is not null) - { - try - { - Logger.Info($"[ABORT] Sending deferred chat.abort for runId='{deferredRunId}'"); - await _bridge.SendChatAbortAsync(deferredRunId, threadId); - Logger.Info($"[ABORT] Deferred chat.abort sent successfully"); - } - catch (Exception ex) - { - Logger.Warn($"[ABORT] Deferred chat.abort failed: {ex.Message}"); - } - } - // Always persist — scan history for user messages with missing/truncated responses. - await PersistAbortedMessageIdAsync(threadId); - }); + _telemetry.FinishByRunId( + evt.RunId, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.Superseded); + } } + if (transition.FetchRemoteUser) + _ = FetchRemoteUserMessageAsync(threadId); - // Suppress rendering for aborted runs/threads (but lifecycle events - // already ran above for state cleanup). - var suppressRendering = false; - lock (_gate) + ChatTelemetryTracker.PreparedTurnCompletion? completion = null; + if (transition.CompletionPhase is { } phase) { - if (!string.IsNullOrEmpty(evt.RunId) && _abortedRunIds.Contains(evt.RunId)) - suppressRendering = true; - else if (_abortedThreads.Contains(threadId)) - suppressRendering = true; + completion = _telemetry.PrepareFinishByRunId( + transition.CompletedRunId, + phase == "error" ? ChatTelemetryOutcome.Failure : ChatTelemetryOutcome.Success, + phase == "error" + ? ChatTurnTelemetryReason.LifecycleError + : ChatTurnTelemetryReason.LifecycleEnd); + if (completion is null && !transition.WasAborted) + { + RecordDroppedTerminalEvent( + string.IsNullOrWhiteSpace(transition.CompletedRunId) + ? ChatTerminalEventDropReason.MissingRunId + : ChatTerminalEventDropReason.MismatchedRunId); + } } - if (suppressRendering) + _telemetry.CompletePreparedTurn(completion); + + ScheduleDeferredAbort( + threadId, + transition.DeferredAbortRunId, + transition.DeferredAbortCount, + transition.RuntimeGeneration); + + if (transition.Suppressed) { - if (isTerminalRunEvent) + if (terminal) ScheduleQueuedSendDrain(threadId); return; } - ChatEvent? mapped = MapAgentEvent(evt); + var mapped = transition.MappedEvent; if (mapped is null) { - // Approval lifecycle: clear the composer's Allow/Deny banner when - // the gateway tells us this approval has reached a terminal state - // (whether the dashboard answered first, the run was aborted, or - // it expired). MapApprovalEvent only emits for ``requested``, so - // every other approval phase lands here as a null mapping. - // - // Guardrails: - // • Whitelist terminal phases — we explicitly enumerate the - // phases that mean "approval is done". Anything else (e.g. a - // future ``acknowledged``/``in_progress`` phase the gateway - // might add) must not wipe a live banner. - // • Match by requestId — clear ONLY on a proven positive match - // between (evtSlug, evtApprovalId) and (pendingId, its - // recorded alternate id). The previous "clear unless we can - // prove a mismatch" default would wipe the banner when the - // terminal event arrived with both ids empty, or when ids - // used different precedence on the two ends of the lifecycle - // (slug-only ``requested`` vs approvalId-only ``resolved``). - if (string.Equals(evt.Stream, "approval", System.StringComparison.OrdinalIgnoreCase) - && evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object) - { - var phase = evt.Data.TryGetProperty("phase", out var p) && p.ValueKind == System.Text.Json.JsonValueKind.String - ? (p.GetString() ?? "") - : ""; - if (IsTerminalApprovalPhase(phase)) - { - var evtApprovalId = evt.Data.TryGetProperty("approvalId", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.String - ? (a.GetString() ?? "") - : ""; - var evtSlug = evt.Data.TryGetProperty("approvalSlug", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String - ? (s.GetString() ?? "") - : ""; - var evtDecision = evt.Data.TryGetProperty("decision", out var d) && d.ValueKind == System.Text.Json.JsonValueKind.String - ? (d.GetString() ?? "") - : ""; - - string? pendingId; - lock (_gate) - { - pendingId = GetOrCreateTimelineLocked(threadId).PendingPermission?.RequestId; - } - - if (string.IsNullOrEmpty(pendingId)) - { - // No live banner — nothing to clear, nothing to log loudly. - Logger.Debug($"[Approval] terminal phase='{phase}' for slug='{evtSlug}' approvalId='{evtApprovalId}' — no PendingPermission"); - } - else if (ApprovalIdMatches(pendingId!, evtSlug, evtApprovalId)) - { - // Honor the gateway's actual decision instead of always - // stamping Expired. The resolved echo races the local - // RPC response on the same WebSocket — if Expired wins - // here, ResolvePermission's no-overwrite guard then - // blocks the user's Allow/Denied stamp from landing. - // Phase already passed IsTerminalApprovalPhase; use - // the exact decision when present so allow-always is - // preserved, then fall back to phase mapping. - var resolvedDecision = MapTerminalPhaseToDecision(phase, evtDecision); - ClearPendingPermissionAndPublish(threadId, expectedRequestId: pendingId, decision: resolvedDecision); - } - else - { - // Either the event carried no id (gateway protocol drift) - // or ids didn't match the live banner. Either way we must - // not clear — preserving the banner is the safer default. - Logger.Info($"[Approval] terminal phase='{phase}' slug='{evtSlug}' approvalId='{evtApprovalId}' did not match pending '{pendingId}' — banner preserved"); - } - } - } - if (isTerminalRunEvent) + if (terminal) ScheduleQueuedSendDrain(threadId); return; } - var outputKind = ClassifyInboundOutput(evt, mapped); - if (outputKind.HasValue) - { - _telemetry.ObserveInboundOutput( - threadId, - evt.RunId, - outputKind.Value); - } - - // AgentEventInfo.Ts is a double of unix-epoch ms (per OpenClawGatewayClient). - var tsMs = evt.Ts > 0 ? (long)evt.Ts : 0L; - ChatEntryMetadata? meta; - lock (_gate) { meta = BuildLiveMetaLocked(threadId, tsMs); } - - ApplyEventAndPublish(threadId, mapped, meta); - CacheMappedToolMetadata(threadId, mapped, tsMs); - if (isTerminalRunEvent) + if (ChatEventMapper.ClassifyInboundOutput(evt, mapped) is { } outputKind) + _telemetry.ObserveInboundOutput(threadId, evt.RunId, outputKind); + if (transition.ToolMetadata is { } toolMetadata) + _metadataStore.CacheTool(toolMetadata); + if (terminal) ScheduleQueuedSendDrain(threadId); } - private void CacheMappedToolMetadata(string threadId, ChatEvent mapped, long tsMs) + private void ScheduleDeferredAbort( + string threadId, + string? runId, + int pendingCount, + ChatRuntimeGeneration runtimeGeneration) { - if (mapped is not ChatToolStartEvent and not ChatToolPresentationEvent) + if (runId is null && pendingCount <= 0) return; - var legacyTurn = ResolveToolCacheLegacyTurn(threadId, mapped); - if (mapped is ChatToolStartEvent toolStart && !string.IsNullOrEmpty(toolStart.ToolName)) - { - CacheToolMeta( - threadId, - tsMs, - toolStart.ToolName, - toolStart.Text, - toolStart.ToolCallId, - toolStart.ToolArgs, - toolStart.IdentityStrength, - toolStart.RunId, - legacyTurn); - } - else if (mapped is ChatToolPresentationEvent presentation) - { - CacheToolMeta( - threadId, - tsMs, - presentation.ToolName, - NativeToolProjector.FirstToolDisplayValue(presentation.ToolArgs), - presentation.ParentToolCallId, - presentation.ToolArgs, - presentation.IdentityStrength, - presentation.RunId, - legacyTurn); - } - } - - private long ResolveToolCacheLegacyTurn(string threadId, ChatEvent mapped) - { - var runId = mapped switch + _ = _deferredAbortScheduler(async () => { - ChatToolStartEvent start => start.RunId, - ChatToolPresentationEvent presentation => presentation.RunId, - _ => null - }; - if (!string.IsNullOrWhiteSpace(runId)) - return 0; - - lock (_gate) - { - if (!_timelines.TryGetValue(threadId, out var timeline)) - return ChatTimelineState.Initial().ToolLegacyTurn; - - var toolCallId = mapped switch - { - ChatToolStartEvent start => start.ToolCallId, - ChatToolPresentationEvent presentation => presentation.ParentToolCallId, - _ => null - }; - if (string.IsNullOrWhiteSpace(toolCallId)) - return timeline.ToolLegacyTurn; - - if (mapped is ChatToolPresentationEvent) + if (!_state.IsRuntimeGenerationCurrent( + threadId, + runtimeGeneration)) { - var pendingKey = timeline.PendingToolPresentations?.Keys - .Where(key => key.RunId is null - && string.Equals(key.ToolCallId, toolCallId, StringComparison.Ordinal)) - .OrderByDescending(key => key.LegacyTurn) - .FirstOrDefault(); - if (pendingKey is { ToolCallId.Length: > 0 }) - return pendingKey.Value.LegacyTurn; + return; } - - for (var i = timeline.Entries.Count - 1; i >= 0; i--) + if (runId is not null) { - var entry = timeline.Entries[i]; - if (entry.Kind != ChatTimelineItemKind.ToolCall || entry.ToolRunId is not null) - continue; - if (string.Equals(entry.ToolCallId, toolCallId, StringComparison.Ordinal) - || entry.ToolCorrelationIds?.Contains(toolCallId) == true) + try + { + await _bridge.SendChatAbortAsync(runId, threadId); + } + catch (Exception ex) { - return entry.ToolLegacyTurn; + var rollbackSnapshot = + _state.RollbackAbortAndEndTurnIfCurrent( + threadId, + runId, + runtimeGeneration, + ProjectionContext()); + if (rollbackSnapshot is not null) + { + Logger.Warn( + $"[ABORT] Deferred chat.abort failed, cleared suppression: {ex.Message}"); + RaiseNotification(new ChatProviderNotification( + ChatProviderNotificationKind.Error, + threadId, + LocalizationHelper.GetString( + "Chat_Notification_AbortFailed"), + ex.Message)); + Publish(rollbackSnapshot); + ScheduleQueuedSendDrain(threadId); + } + return; } } - return timeline.ToolLegacyTurn; - } + if (!_state.IsRuntimeGenerationCurrent( + threadId, + runtimeGeneration)) + { + return; + } + await PersistAbortedMessageIdsAsync( + threadId, + runtimeGeneration.ResetGeneration); + }); } - private static ChatResponseOutputKind? ClassifyInboundOutput( - AgentEventInfo evt, - ChatEvent mapped) + private void HandleOpenedLifecycle( + string threadId, + ChatOpenedLifecycleTransition? opened, + ChatRuntimeGeneration runtimeGeneration) { - if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) || - string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase)) - { - return null; - } + if (opened is null) + return; - return mapped switch + _telemetry.ObserveLifecycleStart( + threadId, + opened.Event.RunId, + opened.AllowRemoteTurn, + runtimeGeneration); + if (!_state.IsRuntimeGenerationCurrent( + threadId, + runtimeGeneration)) { - ChatMessageEvent or ChatMessageDeltaEvent => ChatResponseOutputKind.Assistant, - ChatThinkingEvent or ChatReasoningEvent or ChatReasoningDeltaEvent or - ChatIntentEvent => ChatResponseOutputKind.Reasoning, - ChatToolStartEvent or ChatToolOutputEvent or ChatToolErrorEvent or - ChatPermissionRequestEvent => ChatResponseOutputKind.Tool, - ChatStatusEvent or ChatErrorEvent or ChatReasoningEndEvent or - ChatTurnEndEvent or ChatUserMessageEvent => null, - _ => null, - }; + _telemetry.FinishByRunId( + opened.Event.RunId, + ChatTelemetryOutcome.Canceled, + ChatTurnTelemetryReason.Superseded); + return; + } + ScheduleDeferredAbort( + threadId, + opened.DeferredAbortRunId, + opened.DeferredAbortCount, + runtimeGeneration); } private void RaiseKeylessEventDiagnosticOnce() { - if (System.Threading.Interlocked.Exchange(ref _keylessEventDiagnosticRaised, 1) != 0) + if (!_state.TryRaiseKeylessDiagnostic()) return; - var threadId = GetKeylessEventDiagnosticThreadId(); + var threadId = _state.ResolveDefaultThreadId(ProjectionContext()); var title = LocalizationHelper.GetString("Chat_Notification_KeylessEventDropped"); var message = LocalizationHelper.GetString("Chat_Notification_KeylessEventDroppedMessage"); @@ -3276,4070 +1573,226 @@ private void RaiseKeylessEventDiagnosticOnce() ApplyEventAndPublish(threadId, new ChatStatusEvent(message, ChatTone.Warning)); } - private string? GetKeylessEventDiagnosticThreadId() + private void RecordDroppedTerminalEvent(ChatTerminalEventDropReason reason) { - lock (_gate) - { - return ResolveDefaultThreadIdLocked() - ?? _timelines.Keys.FirstOrDefault(k => !string.IsNullOrWhiteSpace(k)); - } + _telemetry.RecordDroppedTerminalEvent(reason); + Logger.Warn( + $"[ChatTelemetry] Dropped terminal chat event because safe run correlation was unavailable " + + $"(reason='{ChatTelemetryTracker.ToTelemetryValue(reason)}')."); + } + + private void TryDispatchNextQueuedSend(string threadId) + { + var start = _state.TryStartNextQueuedSend( + threadId, + requireConnected: true, + ProjectionContext()); + var dispatch = start.Dispatch; + var queueCompletion = dispatch is not null && + dispatch.Request.LifecycleCommand is null + ? _telemetry.PrepareDispatchLocalTurn( + dispatch.Request.Id, + dispatch.Request.SendRunId) + : null; + + if (start.Snapshot is not null) + Publish(start.Snapshot); + if (dispatch is not null) + _ = DispatchQueuedSendAsync( + dispatch, + queueCompletion, + rethrow: false); + else if (start.DelayedRetry is { } delay) + ScheduleQueuedSendDrain(threadId, delay); } - private (string? RunId, int Count, ChatTerminalEventDropReason? DroppedTerminalReason) UpdateActiveRunId( - AgentEventInfo evt, - string threadId) + private void ScheduleQueuedSendDrain(string threadId) + => ScheduleQueuedSendDrain(threadId, ChatSendQueuePolicy.DrainDelay); + + private void ScheduleQueuedSendDrain(string threadId, TimeSpan delay) { - string? deferredAbortRunId = null; - var deferredAbortCount = 0; - ChatTerminalEventDropReason? droppedTerminalReason = null; - ChatTelemetryTracker.PreparedTurnCompletion? turnCompletion = null; - - if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) && - evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object && - evt.Data.TryGetProperty("phase", out var phaseProp)) + if (!_state.TryScheduleQueueDrain(threadId)) + return; + + _ = Task.Run(async () => { - var phase = phaseProp.GetString()?.ToLowerInvariant(); - lock (_gate) + try { - if (phase == "start") - { - _telemetry.ObserveLifecycleStart( - threadId, - evt.RunId, - allowRemoteTurn: !_locallyInitiatedThreads.Contains(threadId) && - !_abortedThreads.Contains(threadId) && - !_pendingAbortCounts.ContainsKey(threadId)); - if (!string.IsNullOrEmpty(evt.RunId)) - { - _activeRunIds[threadId] = evt.RunId; - _activeRunStartSequences[threadId] = ++_lifecycleStartSequence; - - // Detect remote turn: if the turn was NOT locally initiated, - // a remote client (e.g. gateway web UI) sent the message. - // Fetch the last user message from history so it appears in - // the timeline before the assistant response. - if (!_locallyInitiatedThreads.Contains(threadId)) - { - _ = FetchRemoteUserMessageAsync(threadId); - } - - // Deferred abort: if user clicked stop before lifecycle.start, - // fire chat.abort now that we have the runId. - if (_pendingAbortCounts.TryGetValue(threadId, out var pendingCount) && pendingCount > 0) - { - _pendingAbortCounts.Remove(threadId); - _abortedRunIds.Add(evt.RunId); - deferredAbortRunId = evt.RunId; - deferredAbortCount = pendingCount; - Logger.Info($"[ABORT] Deferred abort fired — lifecycle.start arrived with runId='{evt.RunId}' for threadId='{threadId}' (pendingCount={pendingCount})"); - } - } - } - else if (phase == "end" || phase == "error") - { - var wasAborted = !string.IsNullOrWhiteSpace(evt.RunId) && - _abortedRunIds.Contains(evt.RunId); - turnCompletion = _telemetry.PrepareFinishByRunId( - evt.RunId, - phase == "error" ? ChatTelemetryOutcome.Failure : ChatTelemetryOutcome.Success, - phase == "error" - ? ChatTurnTelemetryReason.LifecycleError - : ChatTurnTelemetryReason.LifecycleEnd); - if (turnCompletion is null && !wasAborted) - { - droppedTerminalReason = string.IsNullOrWhiteSpace(evt.RunId) - ? ChatTerminalEventDropReason.MissingRunId - : ChatTerminalEventDropReason.MismatchedRunId; - } - // Clean up: remove aborted runId tracking on terminal events. - if (!string.IsNullOrEmpty(evt.RunId)) - _abortedRunIds.Remove(evt.RunId); - _activeRunIds.Remove(threadId); - _activeRunStartSequences.Remove(threadId); - - // Clear thread-level abort suppression on terminal lifecycle events. - // The turn is over — any remaining abort suppression is no longer needed. - _abortedThreads.Remove(threadId); - // Clear locally-initiated flag only when no locally queued - // follow-up prompts remain for this thread. Multiple rapid - // sends can queue runs behind the current one; treating the - // next lifecycle.start as remote would orphan those queued - // cards and let assistant fallback promote the wrong item. - RemoveQueuedRunMappingByRunIdLocked(threadId, evt.RunId); - if (!HasPendingQueuedMessagesLocked(threadId)) - _locallyInitiatedThreads.Remove(threadId); - - // Edge case: if we have pending aborts but never saw lifecycle.start - // (gateway responded so fast start+end were batched), fire the - // deferred abort now so the persist still runs. - if (_pendingAbortCounts.TryGetValue(threadId, out var lateCount) && lateCount > 0) - { - _pendingAbortCounts.Remove(threadId); - deferredAbortRunId = evt.RunId; // may be null, that's ok — persist doesn't need it - deferredAbortCount = lateCount; - Logger.Info($"[ABORT] Late deferred abort — lifecycle.end arrived with pending aborts for threadId='{threadId}' (pendingCount={lateCount})"); - } - } + await Task.Delay(delay).ConfigureAwait(false); } - } - // Also catch lifecycle via legacy job stream. - else if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) && - evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object && - evt.Data.TryGetProperty("state", out var stateProp)) - { - var state = stateProp.GetString()?.ToLowerInvariant(); - lock (_gate) + finally { - if (state == "done" || state == "error") - { - var wasAborted = !string.IsNullOrWhiteSpace(evt.RunId) && - _abortedRunIds.Contains(evt.RunId); - turnCompletion = _telemetry.PrepareFinishByRunId( - evt.RunId, - state == "error" ? ChatTelemetryOutcome.Failure : ChatTelemetryOutcome.Success, - state == "error" - ? ChatTurnTelemetryReason.LifecycleError - : ChatTurnTelemetryReason.LifecycleEnd); - if (turnCompletion is null && !wasAborted) - { - droppedTerminalReason = string.IsNullOrWhiteSpace(evt.RunId) - ? ChatTerminalEventDropReason.MissingRunId - : ChatTerminalEventDropReason.MismatchedRunId; - } - if (!string.IsNullOrWhiteSpace(evt.RunId)) - { - _abortedRunIds.Remove(evt.RunId); - RemoveQueuedRunMappingByRunIdLocked(threadId, evt.RunId); - } - _activeRunIds.Remove(threadId); - _activeRunStartSequences.Remove(threadId); - } + _state.CompleteQueueDrainSchedule(threadId); } - } - - _telemetry.CompletePreparedTurn(turnCompletion); - return (deferredAbortRunId, deferredAbortCount, droppedTerminalReason); - } - private bool ShouldDropTerminalAgentEventLocked( - AgentEventInfo evt, - string threadId, - out ChatTerminalEventDropReason? droppedTerminalReason) - { - droppedTerminalReason = null; - if (!TryGetTerminalAgentRunId(evt, out var runId)) - return false; - if (string.IsNullOrWhiteSpace(runId)) - { - droppedTerminalReason = ChatTerminalEventDropReason.MissingRunId; - return true; - } - - if (_terminalRunIdsByThread.TryGetValue(threadId, out var terminalRunIds) && - terminalRunIds.Contains(runId, StringComparer.Ordinal)) - { - return true; - } - - if (_activeRunIds.TryGetValue(threadId, out var activeRunId) && - !string.Equals(activeRunId, runId, StringComparison.Ordinal)) - { - droppedTerminalReason = ChatTerminalEventDropReason.MismatchedRunId; - return true; - } - - if (!_activeRunIds.ContainsKey(threadId) && - _queuedMessageIdsByRunId.TryGetValue(threadId, out var queuedRunIds) && - queuedRunIds.Count > 0 && - !queuedRunIds.ContainsKey(runId) && - _timelines.TryGetValue(threadId, out var timeline) && - timeline.TurnActive) - { - droppedTerminalReason = ChatTerminalEventDropReason.MismatchedRunId; - return true; - } - - RememberTerminalRunIdLocked(threadId, runId); - return false; - } - - private void RecordDroppedTerminalEvent(ChatTerminalEventDropReason reason) - { - _telemetry.RecordDroppedTerminalEvent(reason); - Logger.Warn( - $"[ChatTelemetry] Dropped terminal chat event because safe run correlation was unavailable " + - $"(reason='{ChatTelemetryTracker.ToTelemetryValue(reason)}')."); - } - - private void RememberTerminalRunIdLocked(string threadId, string runId) - { - if (!_terminalRunIdsByThread.TryGetValue(threadId, out var terminalRunIds)) - { - terminalRunIds = new List(); - _terminalRunIdsByThread[threadId] = terminalRunIds; - } - - terminalRunIds.RemoveAll(existing => string.Equals(existing, runId, StringComparison.Ordinal)); - terminalRunIds.Add(runId); - if (terminalRunIds.Count > 64) - terminalRunIds.RemoveRange(0, terminalRunIds.Count - 64); - } - - private static bool TryGetTerminalAgentRunId(AgentEventInfo evt, out string runId) - { - runId = evt.RunId ?? string.Empty; - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) - return false; - - if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) && - evt.Data.TryGetProperty("phase", out var phaseProp)) - { - var phase = phaseProp.GetString(); - return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase) || - string.Equals(phase, "error", StringComparison.OrdinalIgnoreCase); - } - - if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) && - evt.Data.TryGetProperty("state", out var stateProp)) - { - var state = stateProp.GetString(); - return string.Equals(state, "done", StringComparison.OrdinalIgnoreCase) || - string.Equals(state, "error", StringComparison.OrdinalIgnoreCase); - } - - return false; - } - - private bool TryConsumeLocalEchoLocked(string threadId, Queue queue, string text, out string queuedMessageId) - { - queuedMessageId = string.Empty; - var now = DateTimeOffset.UtcNow; - while (queue.Count > 0 && now - queue.Peek().SentAt > LocalEchoSuppressionWindow) - queue.Dequeue(); - - if (queue.Count == 0) - { - _localSentTexts.Remove(threadId); - return false; - } - - var matched = false; - string matchedMessageId = string.Empty; - var pendingEchoes = queue.ToArray(); - queue.Clear(); - foreach (var pending in pendingEchoes) - { - if (matched || !string.Equals(pending.Text, text, StringComparison.Ordinal)) - continue; - - queuedMessageId = pending.QueuedMessageId; - matchedMessageId = pending.QueuedMessageId; - matched = true; - } - - var kept = new Queue(pendingEchoes.Length); - if (!matched) - { - foreach (var pending in pendingEchoes) - kept.Enqueue(pending); - StoreLocalEchoQueueLocked(threadId, kept); - return false; - } - - foreach (var pending in pendingEchoes) - { - if (string.Equals(pending.QueuedMessageId, matchedMessageId, StringComparison.Ordinal)) - continue; - - kept.Enqueue(pending); - } - - StoreLocalEchoQueueLocked(threadId, kept); - return true; - } - - private void StoreLocalEchoQueueLocked(string threadId, Queue queue) - { - if (queue.Count == 0) - _localSentTexts.Remove(threadId); - else - _localSentTexts[threadId] = queue; - } - - private bool TryReconcileExistingLocalQueuedUserEchoLocked( - string threadId, - string text, - ChatEntryMetadata confirmedMeta) - { - if (!HasGatewayIdentity(confirmedMeta)) - return false; - if (!_entryMeta.TryGetValue(threadId, out var threadMeta) || - !_timelines.TryGetValue(threadId, out var timeline)) - return false; - - foreach (var entry in timeline.Entries) - { - if (entry.Kind != ChatTimelineItemKind.User) - continue; - if (!string.Equals(entry.Text, text, StringComparison.Ordinal)) - continue; - if (!threadMeta.TryGetValue(entry.Id, out var existing) || !existing.IsLocalQueuedSend) - continue; - if (HasGatewayIdentity(existing)) - continue; - if (!IsFreshLocalQueuedPromotion(existing, confirmedMeta)) - continue; - - threadMeta[entry.Id] = confirmedMeta with - { - IsLocalQueuedSend = false, - LocalQueuedMessageId = existing.LocalQueuedMessageId, - }; - return true; - } - - return false; - } - - private static bool HasGatewayIdentity(ChatEntryMetadata meta) - => !string.IsNullOrEmpty(meta.GatewayMessageId) || meta.OpenClawSeq is not null; - - private static bool IsFreshLocalQueuedPromotion(ChatEntryMetadata existing, ChatEntryMetadata confirmed) - { - if (existing.Timestamp is not { } existingTimestamp) - return false; - if (confirmed.Timestamp is { } confirmedTimestamp) - return (confirmedTimestamp - existingTimestamp).Duration() <= LocalEchoSuppressionWindow; - - return DateTimeOffset.Now - existingTimestamp <= LocalEchoSuppressionWindow; - } - - private void AddQueuedMessageLocked(string threadId, ChatQueuedMessage message) - { - if (!_queuedMessages.TryGetValue(threadId, out var list)) - { - list = new List(); - _queuedMessages[threadId] = list; - } - - list.RemoveAll(existing => existing.Id == message.Id); - list.Add(message); - } - - private void AddQueuedSendRequestLocked(QueuedSendRequest request) - { - if (!_queuedSendRequests.TryGetValue(request.ThreadId, out var list)) - { - list = new List(); - _queuedSendRequests[request.ThreadId] = list; - } - - list.RemoveAll(existing => existing.Id == request.Id); - list.Add(request); - } - - private void RemoveQueuedSendRequestLocked(string threadId, string messageId) - { - if (!_queuedSendRequests.TryGetValue(threadId, out var list)) - return; - - list.RemoveAll(request => request.Id == messageId); - if (list.Count == 0) - _queuedSendRequests.Remove(threadId); - } - - private QueuedSendRequest? FindQueuedSendRequestLocked(string threadId, string messageId) - { - if (!_queuedSendRequests.TryGetValue(threadId, out var list)) - return null; - - return list.FirstOrDefault(request => string.Equals(request.Id, messageId, StringComparison.Ordinal)); - } - - private bool CanSendDirectlyLocked(string threadId) - { - if (_activeRunIds.ContainsKey(threadId)) - return false; - if (_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive) - return false; - return !HasPendingQueuedMessagesLocked(threadId); - } - - private bool CanClearAssistantFallbackPromotionLocked(string threadId) - { - if (HasSendingQueuedMessagesLocked(threadId)) - return false; - if (_activeRunIds.ContainsKey(threadId)) - return false; - return !_timelines.TryGetValue(threadId, out var timeline) || !timeline.TurnActive; - } - - private QueuedSendDispatch StartDirectSendLocked(QueuedSendRequest request) - { - var threadId = request.ThreadId; - var resetVersion = GetResetVersionLocked(threadId); - var startedLifecycleSequence = _resetLifecycleStartSequence; - var startedRunStartSequence = _lifecycleStartSequence; - var current = GetOrCreateTimelineLocked(threadId); - var entryId = $"e{current.NextId}"; - _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, request.DisplayText, request.LocalNonce); - GetOrCreateThreadMetaLocked(threadId)[entryId] = BuildLiveMetaLocked( - threadId, - isLocalQueuedSend: true, - localQueuedMessageId: request.Id); - _sessionIds.TryGetValue(threadId, out var sessionId); - - EnqueueLocalEchoLocked(threadId, request.Text, request.Id); - _locallyInitiatedThreads.Add(threadId); - _assistantFallbackPromotedThreads.Add(threadId); - var queueCompletion = _telemetry.PrepareDispatchLocalTurn(request.Id, request.SendRunId); - return new QueuedSendDispatch( - request, - sessionId, - resetVersion, - startedLifecycleSequence, - startedRunStartSequence, - queueCompletion, - StartedDirectly: true); - } - - private QueuedSendDispatch? TryStartNextQueuedSendLocked( - string threadId, - bool requireConnected, - out TimeSpan? delayedRetry) - { - delayedRetry = null; - if (requireConnected && _status != ConnectionStatus.Connected) - return null; - if (_activeRunIds.ContainsKey(threadId)) - return null; - if (_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive) - return null; - if (HasSendingQueuedMessagesLocked(threadId)) - return null; - if (!_queuedMessages.TryGetValue(threadId, out var queuedMessages)) - return null; - - for (var i = 0; i < queuedMessages.Count; i++) - { - if (queuedMessages[i].SendState != ChatQueuedMessageSendState.Queued) - continue; - - var request = FindQueuedSendRequestLocked(threadId, queuedMessages[i].Id); - if (request is null) - continue; - - var now = DateTimeOffset.UtcNow; - if (request.DeferredAdmissionRetryAfter is { } retryAfter) - { - if (retryAfter > now) - { - delayedRetry = retryAfter - now; - return null; - } - - request = request with { DeferredAdmissionRetryAfter = null }; - AddQueuedSendRequestLocked(request); - } - - // Each dispatched prompt gets one opportunity for assistant-frame - // fallback promotion before its lifecycle/user echo arrives. - _assistantFallbackPromotedThreads.Remove(threadId); - queuedMessages[i] = queuedMessages[i] with { SendState = ChatQueuedMessageSendState.Sending, ErrorText = null }; - var resetVersion = GetResetVersionLocked(threadId); - var startedLifecycleSequence = _resetLifecycleStartSequence; - var startedRunStartSequence = _lifecycleStartSequence; - _sessionIds.TryGetValue(threadId, out var sessionId); - - ChatTelemetryTracker.QueuePhaseCompletion? queueCompletion = null; - if (request.LifecycleCommand is null) - { - _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(GetOrCreateTimelineLocked(threadId)); - EnqueueLocalEchoLocked(threadId, request.Text, request.Id); - _locallyInitiatedThreads.Add(threadId); - queueCompletion = _telemetry.PrepareDispatchLocalTurn(request.Id, request.SendRunId); - } - return new QueuedSendDispatch( - request, - sessionId, - resetVersion, - startedLifecycleSequence, - startedRunStartSequence, - queueCompletion, - StartedDirectly: false); - } - - return null; - } - - private void EnqueueLocalEchoLocked(string threadId, string text, string messageId) - { - RemovePendingLocalEchoLocked(threadId, messageId); - if (!_localSentTexts.TryGetValue(threadId, out var localEchoQueue)) - { - localEchoQueue = new Queue(); - _localSentTexts[threadId] = localEchoQueue; - } - - localEchoQueue.Enqueue(new LocalSentText(text, DateTimeOffset.UtcNow, messageId)); - while (localEchoQueue.Count > 20) - localEchoQueue.Dequeue(); - } - - private void TryDispatchNextQueuedSend(string threadId) - { - ChatDataSnapshot? snapshot = null; - QueuedSendDispatch? dispatch; - TimeSpan? delayedRetry; - lock (_gate) - { - if (_disposed) - return; - dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: true, out delayedRetry); - if (dispatch is not null) - snapshot = BuildSnapshotLocked(); - } - - if (snapshot is not null) - Publish(snapshot); - if (dispatch is not null) - _ = DispatchQueuedSendAsync(dispatch, rethrow: false); - else if (delayedRetry is { } delay) - ScheduleQueuedSendDrain(threadId, delay); - } - - private void ScheduleQueuedSendDrain(string threadId) - => ScheduleQueuedSendDrain(threadId, DeferredQueueDrainDelay); - - private void ScheduleQueuedSendDrain(string threadId, TimeSpan delay) - { - lock (_gate) - { - if (_disposed || !_queuedMessages.ContainsKey(threadId)) - return; - if (!_queuedDrainScheduledThreads.Add(threadId)) - return; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(delay).ConfigureAwait(false); - } - finally - { - lock (_gate) - { - _queuedDrainScheduledThreads.Remove(threadId); - } - } - - try - { - TryDispatchNextQueuedSend(threadId); - } - catch (Exception ex) - { - Logger.Warn($"[Queue] Scheduled queued send drain failed for threadId='{threadId}': {ex.Message}"); - } - }); - } - - private static bool IsDeferredAdmissionStatus(string? status) => - string.Equals(status, "in_flight", StringComparison.OrdinalIgnoreCase); - - private static ChatAdmissionTelemetryStatus MapAdmissionTelemetryStatus(ChatSendResult result) - { - if (IsDeferredAdmissionStatus(result.Status)) - return ChatAdmissionTelemetryStatus.Deferred; - if (result.IsTerminalFailure) - { - return IsCanceledAdmissionStatus(result.Status) - ? ChatAdmissionTelemetryStatus.Canceled - : ChatAdmissionTelemetryStatus.Rejected; - } - if (string.IsNullOrWhiteSpace(result.Status) || - string.Equals(result.Status, "started", StringComparison.OrdinalIgnoreCase)) - { - return ChatAdmissionTelemetryStatus.Accepted; - } - return ChatAdmissionTelemetryStatus.Other; - } - - private static bool IsCanceledAdmissionStatus(string? status) => - string.Equals(status, "aborted", StringComparison.OrdinalIgnoreCase) || - string.Equals(status, "cancelled", StringComparison.OrdinalIgnoreCase) || - string.Equals(status, "canceled", StringComparison.OrdinalIgnoreCase); - - private static TimeSpan DeferredAdmissionRetryDelay(int retryCount) - { - var exponent = Math.Min(Math.Max(retryCount - 1, 0), 5); - var delayMs = DeferredQueueDrainDelay.TotalMilliseconds * (1 << exponent); - return TimeSpan.FromMilliseconds(Math.Min(delayMs, MaxDeferredAdmissionRetryDelay.TotalMilliseconds)); - } - - private void TrackQueuedMessageRunLocked(string threadId, string runId, string messageId) - { - if (!_queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId)) - { - byRunId = new Dictionary(StringComparer.Ordinal); - _queuedMessageIdsByRunId[threadId] = byRunId; - } - - byRunId[runId] = messageId; - } - - private bool RemoveQueuedMessageLocked(string threadId, string messageId) - { - if (!_queuedMessages.TryGetValue(threadId, out var list)) - return false; - - var removed = list.RemoveAll(message => message.Id == messageId) > 0; - if (removed) - { - RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId); - RemoveQueuedSendRequestLocked(threadId, messageId); - } - if (list.Count == 0) - { - _queuedMessages.Remove(threadId); - ClearQueuedDrainScheduleLocked(threadId); - ClearLocallyInitiatedIfIdleLocked(threadId); - } - return removed; - } - - private bool CancelQueuedMessageLocked(string threadId, string messageId) - { - if (!_queuedMessages.TryGetValue(threadId, out var list)) - return false; - - var index = list.FindIndex(message => string.Equals(message.Id, messageId, StringComparison.Ordinal)); - if (index < 0) - return false; - - if (list[index].SendState == ChatQueuedMessageSendState.Sending) - return false; - - list.RemoveAt(index); - RemovePendingLocalEchoLocked(threadId, messageId); - RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId); - RemoveQueuedSendRequestLocked(threadId, messageId); - if (list.Count == 0) - { - _queuedMessages.Remove(threadId); - ClearQueuedDrainScheduleLocked(threadId); - ClearLocallyInitiatedIfIdleLocked(threadId); - } - return true; - } - - private void ClearQueuedDrainScheduleLocked(string threadId) - => _queuedDrainScheduledThreads.Remove(threadId); - - private bool PromoteQueuedMessageLocked( - string threadId, - string messageId, - ChatEntryMetadata? confirmedMeta = null) - { - if (!_queuedMessages.TryGetValue(threadId, out var list)) - return false; - - var index = list.FindIndex(message => message.Id == messageId); - if (index < 0) - return false; - - var queued = list[index]; - var current = GetOrCreateTimelineLocked(threadId); - var entryId = $"e{current.NextId}"; - _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, queued.Text, queued.LocalNonce); - - var hasGatewayIdentity = confirmedMeta is not null && HasGatewayIdentity(confirmedMeta); - var meta = hasGatewayIdentity - ? confirmedMeta! with { IsLocalQueuedSend = false, LocalQueuedMessageId = messageId } - : BuildLiveMetaLocked( - threadId, - isLocalQueuedSend: true, - localQueuedMessageId: messageId); - var threadMeta = GetOrCreateThreadMetaLocked(threadId); - threadMeta[entryId] = meta; - - list.RemoveAt(index); - _assistantFallbackPromotedThreads.Add(threadId); - RemoveQueuedSendRequestLocked(threadId, messageId); - if (list.Count == 0) - { - _queuedMessages.Remove(threadId); - ClearQueuedDrainScheduleLocked(threadId); - } - return true; - } - - private void ClearLocallyInitiatedIfIdleLocked(string threadId) - { - if (_activeRunIds.ContainsKey(threadId)) - return; - if (_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive) - return; - if (HasPendingQueuedMessagesLocked(threadId)) - return; - - _locallyInitiatedThreads.Remove(threadId); - } - - private bool ReconcileQueuedMessageEchoLocked( - string threadId, - string messageId, - ChatEntryMetadata confirmedMeta) - { - if (PromoteQueuedMessageLocked(threadId, messageId, confirmedMeta)) - return true; - if (!HasGatewayIdentity(confirmedMeta) || - !_entryMeta.TryGetValue(threadId, out var threadMeta)) - { - return false; - } - - string? matchedEntryId = null; - foreach (var (entryId, existing) in threadMeta) - { - if (!string.Equals(existing.LocalQueuedMessageId, messageId, StringComparison.Ordinal)) - continue; - matchedEntryId = entryId; - break; - } - - if (matchedEntryId is null) - return false; - - threadMeta[matchedEntryId] = confirmedMeta with - { - IsLocalQueuedSend = false, - LocalQueuedMessageId = messageId, - }; - return true; - } - - private void RemoveQueuedRunMappingByMessageIdLocked(string threadId, string messageId) - { - if (!_queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId)) - return; - - foreach (var runId in byRunId.Where(kvp => kvp.Value == messageId).Select(kvp => kvp.Key).ToArray()) - byRunId.Remove(runId); - - if (byRunId.Count == 0) - _queuedMessageIdsByRunId.Remove(threadId); - } - - private void RemoveQueuedRunMappingByRunIdLocked(string threadId, string runId) - { - if (!_queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId)) - return; - - if (byRunId.TryGetValue(runId, out var messageId)) - { - foreach (var aliasRunId in byRunId.Where(kvp => kvp.Value == messageId).Select(kvp => kvp.Key).ToArray()) - byRunId.Remove(aliasRunId); - } - else - { - byRunId.Remove(runId); - } - - if (byRunId.Count == 0) - _queuedMessageIdsByRunId.Remove(threadId); - } - - private void MarkQueuedMessageFailedLocked(string threadId, string messageId, string error) - { - if (!_queuedMessages.TryGetValue(threadId, out var list)) - return; - - for (var i = 0; i < list.Count; i++) - { - if (list[i].Id == messageId) - { - list[i] = list[i] with - { - SendState = ChatQueuedMessageSendState.Failed, - ErrorText = error - }; - return; - } - } - } - - private bool RequeueDeferredAdmissionLocked(string threadId, string messageId, out TimeSpan retryDelay) - { - retryDelay = DeferredQueueDrainDelay; - var hasActiveRun = _activeRunIds.ContainsKey(threadId); - if (!_queuedMessages.TryGetValue(threadId, out var list)) - return false; - - for (var i = 0; i < list.Count; i++) - { - if (list[i].Id != messageId || - list[i].SendState != ChatQueuedMessageSendState.Sending) - { - continue; - } - - var retryCount = IncrementDeferredAdmissionRetryCountLocked(threadId, messageId); - if (retryCount > MaxDeferredAdmissionRetries) - { - throw new InvalidOperationException( - $"Gateway kept chat.send status in_flight after {MaxDeferredAdmissionRetries} retries."); - } - - list[i] = list[i] with - { - SendState = ChatQueuedMessageSendState.Queued, - ErrorText = null - }; - retryDelay = DeferredAdmissionRetryDelay(retryCount); - SetDeferredAdmissionRetryAfterLocked(threadId, messageId, DateTimeOffset.UtcNow + retryDelay); - _assistantFallbackPromotedThreads.Remove(threadId); - if (!hasActiveRun) - { - _timelines[threadId] = ChatTimelineReducer.Apply( - GetOrCreateTimelineLocked(threadId), - new ChatTurnEndEvent()); - } - return true; - } - - return false; - } - - private void SetDeferredAdmissionRetryAfterLocked(string threadId, string messageId, DateTimeOffset retryAfter) - { - if (!_queuedSendRequests.TryGetValue(threadId, out var requests)) - return; - - for (var i = 0; i < requests.Count; i++) - { - if (!string.Equals(requests[i].Id, messageId, StringComparison.Ordinal)) - continue; - - requests[i] = requests[i] with { DeferredAdmissionRetryAfter = retryAfter }; - return; - } - } - - private int IncrementDeferredAdmissionRetryCountLocked(string threadId, string messageId) - { - if (!_queuedSendRequests.TryGetValue(threadId, out var requests)) - return MaxDeferredAdmissionRetries + 1; - - for (var i = 0; i < requests.Count; i++) - { - if (!string.Equals(requests[i].Id, messageId, StringComparison.Ordinal)) - continue; - - var retryCount = requests[i].DeferredAdmissionRetryCount + 1; - requests[i] = requests[i] with { DeferredAdmissionRetryCount = retryCount }; - return retryCount; - } - - return MaxDeferredAdmissionRetries + 1; - } - - private void ClearQueuedMessageOnLocalTurnStart(AgentEventInfo evt, string threadId) - { - if (!IsLifecycleStart(evt)) - return; - - ChatDataSnapshot? snapshot = null; - lock (_gate) - { - if (TryPromoteQueuedMessageOnLocalTurnStartLocked(evt, threadId)) - snapshot = BuildSnapshotLocked(); - } - - if (snapshot is not null) - { - Publish(snapshot); - } - } - - private bool TryPromoteQueuedMessageOnLocalTurnStartLocked(AgentEventInfo evt, string threadId) - { - if (!_locallyInitiatedThreads.Contains(threadId)) - return false; - - var runId = evt.RunId; - if (!string.IsNullOrEmpty(runId) && - _queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId) && - byRunId.TryGetValue(runId, out var queuedMessageId)) - { - return PromoteQueuedMessageLocked(threadId, queuedMessageId); - } - - if (string.IsNullOrEmpty(runId) && - TryGetSingleSendingQueuedMessageLocked(threadId, out var queued)) - { - return PromoteQueuedMessageLocked(threadId, queued.Id); - } - - return false; - } - - private void RemovePendingLocalEchoLocked(string threadId, string messageId) - { - if (!_localSentTexts.TryGetValue(threadId, out var queue)) - return; - - var kept = new Queue(queue.Count); - while (queue.Count > 0) - { - var pending = queue.Dequeue(); - if (string.Equals(pending.QueuedMessageId, messageId, StringComparison.Ordinal)) - continue; - - kept.Enqueue(pending); - } - - StoreLocalEchoQueueLocked(threadId, kept); - } - - /// - /// Fetch the latest user message from history for a remotely-initiated turn. - /// Called when lifecycle.start arrives for a thread we didn't locally initiate. - /// - private async Task FetchRemoteUserMessageAsync(string threadId, bool openResetGateOnSuccess = false) - { - var telemetryReason = openResetGateOnSuccess - ? ChatBackfillTelemetryReason.ResetReconciliation - : ChatBackfillTelemetryReason.RemoteTurn; - var historyOperation = _telemetry.StartHistoryBackfill(telemetryReason); - var historyOutcome = ChatTelemetryOutcome.Success; - Exception? historyException = null; - long requestResetVersion; - long resetCutoffUtcMs; - lock (_gate) - { - requestResetVersion = GetResetVersionLocked(threadId); - resetCutoffUtcMs = GetResetCutoffUtcMsLocked(threadId); - } - - try - { - var history = await _bridge.RequestChatHistoryAsync(threadId); - if (history?.Messages is null || history.Messages.Count == 0) return; - - // Find the last user message in history. - ChatMessageInfo? lastUser = null; - for (int i = history.Messages.Count - 1; i >= 0; i--) - { - var role = (history.Messages[i].Role ?? "").ToLowerInvariant(); - var hText = history.Messages[i].Text; - if (role == "user" - && !NativeToolProjector.LooksLikeSystemControlNote(hText) - && !LooksLikeApprovalSlashCommand(hText)) - { - lastUser = history.Messages[i]; - break; - } - } - if (lastUser is null || string.IsNullOrEmpty(lastUser.Text)) return; - - ChatDataSnapshot? snapshotToPublish = null; - - // Check if we already have this user message as the last User entry - // in the timeline (avoid duplicates on reconnect/reload). - lock (_gate) - { - if (GetResetVersionLocked(threadId) != requestResetVersion || - IsPreResetTimestampLocked(threadId, lastUser.Ts, resetCutoffUtcMs)) - { - Logger.Info($"[REMOTE] Ignoring stale remote user backfill after reset for threadId='{threadId}'"); - return; - } - - if (_timelines.TryGetValue(threadId, out var tl)) - { - for (int i = tl.Entries.Count - 1; i >= 0; i--) - { - if (tl.Entries[i].Kind == ChatTimelineItemKind.User) - { - if (tl.Entries[i].Text == lastUser.Text) - return; // already displayed - break; - } - } - } - - if (openResetGateOnSuccess) - { - _resetRemoteUserSeen.Add(threadId); - TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null); - } - - var meta = BuildLiveMetaLocked( - threadId, - lastUser.Ts, - lastUser.OpenClawId, - lastUser.OpenClawSeq); - snapshotToPublish = ApplyEventLocked( - threadId, - new ChatUserMessageEvent(TruncateForChatEntry(lastUser.Text)), - meta); - } - - Publish(snapshotToPublish); - Logger.Info($"[REMOTE] Injected remote user message for threadId='{threadId}' len={lastUser.Text.Length}"); - } - catch (Exception ex) - { - historyOutcome = ex is OperationCanceledException - ? ChatTelemetryOutcome.Canceled - : ChatTelemetryOutcome.Failure; - historyException = ex; - Logger.Warn($"[REMOTE] Failed to fetch remote user message for threadId='{threadId}': {ex.Message}"); - } - finally - { - if (openResetGateOnSuccess) - { - lock (_gate) { _resetRemoteBackfillInFlight.Remove(threadId); } - } - _telemetry.FinishHistoryBackfill(historyOperation, historyOutcome, historyException); - } - } - - private ChatEvent? MapAgentEvent(AgentEventInfo evt) - { - var stream = evt.Stream?.ToLowerInvariant(); - if (string.IsNullOrEmpty(stream)) return null; - - switch (stream) - { - case "assistant": - return MapAssistantEvent(evt); - case "reasoning": - return MapReasoningEvent(evt); - case "lifecycle": - return MapLifecycleEvent(evt); - case "tool": - // Spec name; gateway 2026.4.x uses ``item`` (kind=tool) instead. - return MapToolEvent(evt); - case "item": - // Verified live shape: stream="item", data.kind ∈ - // {"tool","command","reasoning","message"}, data.phase ∈ - // {"start","end"}, data.title/itemId/details. We surface - // tool items as chips and ignore the redundant command - // children (their output arrives on ``command_output``). - return MapItemEvent(evt); - case "command_output": - // Shell command stdout/stderr — attach to the active tool - // chip as its ``Tool output`` body. - return MapCommandOutputEvent(evt); - case "job": - return MapJobEvent(evt); - case "approval": - return MapApprovalEvent(evt); - default: - return null; - } - } - - // Whitelist of approval phases that mean "the approval is finished and - // the banner should go away". Anything outside this set is treated as - // an intermediate / unknown phase and leaves the banner alone — that - // way a future gateway phase like ``acknowledged`` or ``in_progress`` - // can't accidentally wipe a live banner. - private static bool IsTerminalApprovalPhase(string phase) - { - if (string.IsNullOrEmpty(phase)) return false; - return string.Equals(phase, "resolved", System.StringComparison.OrdinalIgnoreCase) - || string.Equals(phase, "denied", System.StringComparison.OrdinalIgnoreCase) - || string.Equals(phase, "aborted", System.StringComparison.OrdinalIgnoreCase) - || string.Equals(phase, "canceled", System.StringComparison.OrdinalIgnoreCase) - || string.Equals(phase, "cancelled", System.StringComparison.OrdinalIgnoreCase) - || string.Equals(phase, "expired", System.StringComparison.OrdinalIgnoreCase) - || string.Equals(phase, "timeout", System.StringComparison.OrdinalIgnoreCase) - || string.Equals(phase, "error", System.StringComparison.OrdinalIgnoreCase); - } - - // Map a terminal approval phase (already validated by IsTerminalApprovalPhase) - // to the timeline decision badge. ``resolved`` carries an allow-* decision - // upstream (see OpenClawGatewayClient.HandleExecApprovalEvent), so it maps to - // Allowed. ``denied`` maps to Denied. Every other terminal phase (aborted, - // canceled/cancelled, expired, timeout, error) collapses to Expired — the - // "decided elsewhere or never decided" badge. - private static ChatPermissionDecision MapTerminalPhaseToDecision(string phase, string? decision = null) - { - if (string.Equals(phase, "resolved", System.StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(decision, ChatPermissionActionKeys.AllowAlways, System.StringComparison.OrdinalIgnoreCase)) - return ChatPermissionDecision.AllowedAlways; - if (string.Equals(decision, ChatPermissionActionKeys.Deny, System.StringComparison.OrdinalIgnoreCase)) - return ChatPermissionDecision.Denied; - return ChatPermissionDecision.Allowed; - } - if (string.Equals(phase, "denied", System.StringComparison.OrdinalIgnoreCase)) - return ChatPermissionDecision.Denied; - return ChatPermissionDecision.Expired; - } - - // Approval dedupe: gateway can resend ``requested`` on reconnect/replay. - // Bounded LRU to keep this from growing unbounded across a long session. - // - // Instance-scoped (not static) so the LRU is bound to a single - // provider/connection lifetime. Static state would survive across a - // disconnect+reconnect+new-provider cycle in tests and host scenarios, - // and could silently drop a fresh approval whose ID collides with a - // long-dead one from a prior run. ``ResetApprovalDedupe`` is also - // invoked when we leave the Connected state so the next connection - // starts clean. - private readonly object _approvalSeenLock = new(); - private readonly System.Collections.Generic.LinkedList _approvalSeenOrder = new(); - private readonly System.Collections.Generic.HashSet _approvalSeen - = new(System.StringComparer.Ordinal); - // Capacity is counted by id, not by logical approval. Paired slug/UUID - // approvals consume two entries, so 128 preserves the prior ~64-approval - // dedupe window. - private const int ApprovalSeenCap = 128; - - // Approval id-asymmetry tracking. - // The gateway sometimes emits ``approvalSlug`` only on ``requested`` - // and the full ``approvalId`` only on terminal events (or vice versa). - // We prefer slug on both sides for matching (see ``MapApprovalEvent``) - // but record the alternate identifier here so a terminal event that - // carries only the "other" id can still resolve back to the live - // pending banner. Stored bidirectionally and bounded by ApprovalSeenCap - // via the same trim loop. - private readonly Dictionary _approvalAltIds = new(System.StringComparer.Ordinal); - - // Dedupe accepts both id forms (slug and full approvalId) so the same - // approval doesn't render twice when two upstream paths surface it with - // different ids — e.g. the top-level ``exec.approval.requested`` - // translator emits with the UUID while the agent-stream variant emits - // with the shorter slug. If either form has been seen (or is already - // linked to a seen form), suppress; when both forms are known, record the - // link before suppressing so terminal events in either form can resolve. - private bool MarkApprovalSeen(string requestId, string? altId = null) - { - if (string.IsNullOrEmpty(requestId)) return true; // can't dedupe — render - lock (_approvalSeenLock) - { - RecordApprovalAltIdLocked(requestId, altId); - - if (ApprovalIdSeenLocked(requestId)) return false; - if (IsDistinctApprovalId(requestId, altId) && ApprovalIdSeenLocked(altId!)) - { - return false; - } - - if (_approvalSeen.Add(requestId)) - { - _approvalSeenOrder.AddLast(requestId); - } - - if (IsDistinctApprovalId(requestId, altId) && _approvalSeen.Add(altId!)) - { - _approvalSeenOrder.AddLast(altId!); - } - - while (_approvalSeenOrder.Count > ApprovalSeenCap) - { - var oldest = _approvalSeenOrder.First!.Value; - _approvalSeenOrder.RemoveFirst(); - EvictApprovalSeenIdLocked(oldest); - } - return true; - } - } - - private static bool IsDistinctApprovalId(string requestId, string? altId) - => !string.IsNullOrEmpty(altId) - && !string.Equals(altId, requestId, System.StringComparison.Ordinal); - - private bool ApprovalIdSeenLocked(string approvalId) - { - if (_approvalSeen.Contains(approvalId)) return true; - return _approvalAltIds.TryGetValue(approvalId, out var altId) - && _approvalSeen.Contains(altId); - } - - private void RecordApprovalAltIdLocked(string requestId, string? altId) - { - if (!IsDistinctApprovalId(requestId, altId)) return; - - _approvalAltIds[requestId] = altId!; - _approvalAltIds[altId!] = requestId; - } - - private void EvictApprovalSeenIdLocked(string approvalId) - { - _approvalSeen.Remove(approvalId); - if (!_approvalAltIds.TryGetValue(approvalId, out var altId)) - return; - - _approvalAltIds.Remove(approvalId); - if (_approvalAltIds.TryGetValue(altId, out var reverse) - && string.Equals(reverse, approvalId, System.StringComparison.Ordinal)) - { - _approvalAltIds.Remove(altId); - } - - if (_approvalSeen.Remove(altId)) - { - RemoveApprovalSeenOrderValueLocked(altId); - } - } - - private void RemoveApprovalSeenOrderValueLocked(string approvalId) - { - for (var node = _approvalSeenOrder.First; node is not null; node = node.Next) - { - if (!string.Equals(node.Value, approvalId, System.StringComparison.Ordinal)) - continue; - - _approvalSeenOrder.Remove(node); - return; - } - } - - private void ResetApprovalDedupe() - { - lock (_approvalSeenLock) - { - _approvalSeen.Clear(); - _approvalSeenOrder.Clear(); - _approvalAltIds.Clear(); - } - } - - // Returns true if either of (evtPrimary, evtAlt) matches the pending - // request — checking pendingId directly AND its recorded alternate id - // (see ``_approvalAltIds`` above). All three inputs may be empty; - // empty values never match. - private bool ApprovalIdMatches(string pendingId, string evtPrimary, string evtAlt) - { - if (string.IsNullOrEmpty(pendingId)) return false; - - string? pendingAlt; - lock (_approvalSeenLock) - { - _approvalAltIds.TryGetValue(pendingId, out pendingAlt); - } - - bool Matches(string evt) => - !string.IsNullOrEmpty(evt) - && (string.Equals(evt, pendingId, System.StringComparison.Ordinal) - || (!string.IsNullOrEmpty(pendingAlt) && string.Equals(evt, pendingAlt, System.StringComparison.Ordinal))); - - return Matches(evtPrimary) || Matches(evtAlt); - } - - // Render exec-approval prompts as a Permission-Request event so the - // composer's existing Allow/Deny banner surfaces, matching the - // dashboard modal's Allow once / Deny buttons. We only render on - // phase=``requested``; ``resolved`` clears the banner via the - // dedicated path in OnAgentEventReceived. - // - // Privacy: title/host/command are reflected back into the chat UI - // (the user already sees them in the dashboard); no separate - // telemetry log is emitted from this handler. - private ChatEvent? MapApprovalEvent(AgentEventInfo evt) - { - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - - static string SafeStr(System.Text.Json.JsonElement obj, string name) - => obj.TryGetProperty(name, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String - ? (v.GetString() ?? "") - : ""; - - var phase = SafeStr(evt.Data, "phase"); - if (!string.Equals(phase, "requested", System.StringComparison.OrdinalIgnoreCase)) - return null; - - var approvalId = SafeStr(evt.Data, "approvalId"); - var slug = SafeStr(evt.Data, "approvalSlug"); - var host = SafeStr(evt.Data, "host"); - var command = SafeStr(evt.Data, "command"); - var title = SafeStr(evt.Data, "title"); - var message = SafeStr(evt.Data, "message"); - - // Prefer the short slug (matches dashboard "/approve " format). - // Fall back to full UUID only if slug is missing. - var requestId = !string.IsNullOrEmpty(slug) ? slug : approvalId; - if (string.IsNullOrEmpty(requestId)) return null; - - // The alternate id (the one we didn't pick as requestId). Pass it - // to MarkApprovalSeen so a duplicate emission from the sibling - // upstream path (slug-form vs UUID-form for the same approval) is - // suppressed instead of creating a second timeline entry, which - // would mark the first as Expired via ApplyPermissionRequest. - var altId = !string.IsNullOrEmpty(slug) ? approvalId : slug; - - if (!MarkApprovalSeen(requestId, altId)) - { - Logger.Info($"[Approval] suppressed duplicate requestId={requestId} altId={altId}"); - return null; - } - - // MarkApprovalSeen also records the alternate id, including on the - // duplicate-suppression path, so terminal events in either id form - // can resolve back to this pending banner. - - // PermissionKind is the short tool/category label the composer shows; - // ToolName is the contextual subtitle (host); Detail is the body - // (command + optional message). - var permissionKind = !string.IsNullOrEmpty(title) ? title : "Exec approval"; - var toolName = !string.IsNullOrEmpty(host) ? host : "node"; - - var detail = command; - if (!string.IsNullOrEmpty(message)) - detail = string.IsNullOrEmpty(detail) ? message : message + "\n\n" + detail; - - Logger.Info($"[Approval] emitting ChatPermissionRequestEvent requestId={requestId} kind='{permissionKind}' tool='{toolName}' detail.len={detail.Length}"); - return new ChatPermissionRequestEvent(requestId, permissionKind, toolName, detail, ChatPermissionActionKeys.ExecApprovalDefaults); - } - - private static ChatEvent? MapAssistantEvent(AgentEventInfo evt) - { - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - - // Streaming token deltas: data.delta = "...next chunk..." - if (evt.Data.TryGetProperty("delta", out var deltaProp) && - deltaProp.ValueKind == System.Text.Json.JsonValueKind.String) - { - var delta = deltaProp.GetString(); - if (!string.IsNullOrEmpty(delta)) - return new ChatMessageDeltaEvent(delta); - } - - // NOTE: Cumulative `content`/`text` blocks are intentionally ignored - // here — the gateway also fires a `chat.message` (role=assistant) - // event carrying the same cumulative text, which OnChatMessageReceived - // already maps to ChatMessageEvent. Honoring both paths produced two - // identical assistant bubbles per turn (delta-bubble sealed by - // lifecycle.end, then a fresh bubble from the chat.message arrival). - return null; - } - - private static ChatEvent? MapReasoningEvent(AgentEventInfo evt) - { - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - - if (evt.Data.TryGetProperty("delta", out var deltaProp) && - deltaProp.ValueKind == System.Text.Json.JsonValueKind.String) - { - var delta = deltaProp.GetString(); - if (!string.IsNullOrEmpty(delta)) - { - try { Logger.Trace($"[ReasoningStream] kind=delta len={delta.Length}"); } catch { } - return new ChatReasoningDeltaEvent(delta); - } - } - - var contentText = evt.Data.TryGetProperty("content", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.String - ? c.GetString() - : (evt.Data.TryGetProperty("text", out var t) && t.ValueKind == System.Text.Json.JsonValueKind.String - ? t.GetString() - : null); - if (!string.IsNullOrEmpty(contentText)) - { - try { Logger.Trace($"[ReasoningStream] kind=full len={contentText!.Length}"); } catch { } - return new ChatReasoningEvent(contentText!); - } - - return null; - } - - private static ChatEvent? MapLifecycleEvent(AgentEventInfo evt) - { - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - if (!evt.Data.TryGetProperty("phase", out var phaseProp)) return null; - var phase = phaseProp.GetString()?.ToLowerInvariant(); - - return phase switch - { - "start" => new ChatThinkingEvent(""), - "end" => new ChatTurnEndEvent(), - "error" => new ChatErrorEvent(evt.Summary - ?? (evt.Data.TryGetProperty("message", out var m) ? m.GetString() ?? "Agent error" : "Agent error")), - _ => null - }; - } - - private static ChatEvent? MapToolEvent(AgentEventInfo evt) - { - // Expected payload shape: data.phase ∈ {"start","result","error"}, data.name, data.args - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - - var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : ""; - var identity = NativeToolProjector.ExtractToolIdentity(evt.Data); - var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data); - var label = NativeToolProjector.ExtractToolLabel(evt.Data, toolArgs); - var toolCallId = evt.Data.TryGetProperty("itemId", out var idProp) ? idProp.GetString() - : (evt.Data.TryGetProperty("callId", out var cProp) ? cProp.GetString() : null); - - return phase.ToLowerInvariant() switch - { - "start" => new ChatToolStartEvent( - label, - identity.Name, - ToolArgs: toolArgs, - ToolCallId: toolCallId, - IdentityStrength: identity.Strength, - RunId: evt.RunId), - "result" => new ChatToolOutputEvent( - NativeToolProjector.ExtractToolResultText(evt.Data, fallback: label), - ToolCallId: toolCallId, - RunId: evt.RunId), - "error" => new ChatToolErrorEvent( - NativeToolProjector.ExtractToolErrorText(evt.Data, fallback: label), - ToolCallId: toolCallId, - RunId: evt.RunId), - _ => null - }; - } - - /// - /// Map ``stream: "item"`` agent events (the gateway's actual tool/command - /// lifecycle channel as of 2026.4.x — distinct from the spec's ``"tool"`` - /// stream which has not been observed in the wild). - /// - /// Verified payload shape: - /// - /// { - /// "stream": "item", - /// "data": { - /// "itemId": "tool:call_xxx|fc_yyy", - /// "phase": "start" | "end", - /// "kind": "tool" | "command" | "reasoning" | "message", - /// "title": "exec run command openclaw → ..." - /// } - /// } - /// - /// - /// Tool items create chips. Command children upgrade the parent chip with - /// their specific identity and bounded safe display arguments. - /// - private static ChatEvent? MapItemEvent(AgentEventInfo evt) - { - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - - var kind = evt.Data.TryGetProperty("kind", out var kindProp) ? kindProp.GetString() ?? "" : ""; - var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : ""; - - // ``kind=reasoning`` brackets each distinct thinking pass the model - // performs within a turn (model reasons → tool call → reasons again). - // The reasoning prose itself arrives on ``stream:"reasoning"``; here - // we only need the ``phase=end`` boundary so the timeline reducer can - // close the active reasoning bubble. Without this signal consecutive - // reasoning passes concatenate into a single ever-growing entry, - // because ActiveReasoningId is otherwise only cleared on turn end. - if (string.Equals(kind, "reasoning", StringComparison.OrdinalIgnoreCase)) - { - try { Logger.Trace($"[ReasoningItem] phase={phase}"); } catch { } - return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase) - ? new ChatReasoningEndEvent() - : null; - } - - if (string.Equals(kind, "command", StringComparison.OrdinalIgnoreCase)) - { - var normalizedPhase = phase.ToLowerInvariant(); - if (normalizedPhase is not ("start" or "update")) - return null; - - var parentItemId = NativeToolProjector.ExtractParentToolCallId(evt.Data); - if (string.IsNullOrWhiteSpace(parentItemId)) - return null; - - var childIdentity = NativeToolProjector.ExtractToolIdentity(evt.Data); - var commandArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data); - var childItemId = NativeToolProjector.GetStringProperty(evt.Data, "itemId", "commandItemId", "callId"); - return new ChatToolPresentationEvent( - parentItemId, - childIdentity.Name, - childIdentity.Strength, - commandArgs, - childItemId, - ActivatesTurn: normalizedPhase == "start", - RunId: evt.RunId); - } - - if (!string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase)) - return null; - - var title = NativeToolProjector.GetStringProperty(evt.Data, "title"); - var identity = NativeToolProjector.ExtractToolIdentity(evt.Data); - var toolArgs = NativeToolProjector.ExtractSafeToolDisplayArgs(evt.Data); - var label = NativeToolProjector.FirstToolDisplayValue(toolArgs); - if (string.IsNullOrWhiteSpace(label)) - label = NativeToolProjector.SanitizeToolDisplayValue(title); - string? itemId = NativeToolProjector.GetStringProperty(evt.Data, "itemId", "callId"); - if (string.IsNullOrWhiteSpace(itemId)) - itemId = null; - - return phase.ToLowerInvariant() switch - { - "start" => new ChatToolStartEvent( - label, - identity.Name, - ToolArgs: toolArgs, - ToolCallId: itemId, - IdentityStrength: identity.Strength, - RunId: evt.RunId), - // ``end`` flips the active tool's status to Success even when no - // command_output arrived (e.g. ``read``, ``glob`` — non-shell). - // Use the title as a no-op output so the reducer marks Success. - "end" => new ChatToolOutputEvent(string.Empty, ToolCallId: itemId, RunId: evt.RunId), - "error" => new ChatToolErrorEvent( - NativeToolProjector.SanitizeToolDisplayValue(title), - ToolCallId: itemId, - RunId: evt.RunId), - _ => null - }; - } - - private static JsonObject? ConvertToolArgs(JsonElement? value) - { - if (value is not { ValueKind: JsonValueKind.Object } args) - return null; - return NativeToolProjector.ExtractSafeToolDisplayArgs(args); - } - - private static string ToolLabel(string toolName, JsonObject? args) - { - foreach (var key in new[] { "command", "path", "file_path", "query", "url", "pattern" }) - { - if (args?[key] is JsonValue value - && value.TryGetValue(out var text) - && !string.IsNullOrWhiteSpace(text)) - { - return TruncateToolLabel(text); - } - } - - return toolName; - } - - private static string TruncateToolLabel(string text) - { - if (text.Length <= 80) - return text; - - var length = 77; - if (char.IsHighSurrogate(text[length - 1])) - length--; - return text[..length] + "\u2026"; - } - - /// - /// Map ``stream: "command_output"`` agent events. These carry shell - /// stdout/stderr and may arrive in chunks (phase=delta) and as a final - /// (phase=end) — we attach the text to the currently-active tool chip. - /// - private static ChatEvent? MapCommandOutputEvent(AgentEventInfo evt) - { - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - - var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : ""; - // Only emit on ``end`` — accumulating deltas into the same chip - // would require a new reducer event; the consolidated final - // payload is enough to populate the body in one go. - if (!string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase)) - return null; - - var output = NativeToolProjector.ExtractCommandOutputText(evt.Data); - if (string.IsNullOrEmpty(output)) - return null; - - // command_output events may carry an itemId or parentItemId that - // identifies the parent tool call this output belongs to. - var itemId = evt.Data.TryGetProperty("parentItemId", out var pidProp) ? pidProp.GetString() - : (evt.Data.TryGetProperty("itemId", out var idProp) ? idProp.GetString() : null); - - return new ChatToolOutputEvent(output, ToolCallId: itemId, RunId: evt.RunId); - } - - private static ChatEvent? MapJobEvent(AgentEventInfo evt) - { - if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; - var state = evt.Data.TryGetProperty("state", out var stateProp) ? stateProp.GetString() ?? "" : ""; - return state.ToLowerInvariant() switch - { - "done" => new ChatTurnEndEvent(), - "error" => new ChatErrorEvent(evt.Summary ?? "Agent error"), - _ => null - }; - } - - /// - /// Per-message UTF-8 byte cap applied to ANY chat-bubble payload that - /// flows from the gateway into the timeline (live assistant text, live - /// tool output, live system control notes, history replays, status / - /// reasoning / error entries). Above this size the entry text is - /// truncated at a code-point boundary and a marker is appended. - /// - /// - /// SECURITY (chat rubber-duck MEDIUM 4): very large markdown payloads - /// can hang reducers or rendering work, and a - /// multi-MB string can hang the reducer / virtualized list. 256 KiB is - /// well above any reasonable chat message (a typical book chapter is - /// ~50 KB). Truncation events are logged at Debug level so they - /// don't dominate the operator log under normal use. - /// - internal const int MaxEntryTextBytes = 256 * 1024; - - /// - /// Truncate to at most - /// bytes when encoded as UTF-8 and - /// append a … [N bytes truncated] marker. Slices at a UTF-16 - /// code-unit boundary that doesn't split a surrogate pair, then - /// verifies the byte budget. Returns the input unchanged when it - /// already fits or is null/empty. - /// - internal static string TruncateForChatEntry(string? text) - { - if (string.IsNullOrEmpty(text)) return text ?? string.Empty; - var enc = System.Text.Encoding.UTF8; - // Cheap upper bound: every char is at most 3 UTF-8 bytes for the - // BMP and surrogate pairs encode to 4 bytes / 2 chars (still ≤ 3 - // bytes per char). 4 is the worst case and keeps the cheap path - // safe. If even the worst case fits, we're done. - if ((long)text.Length * 4 <= MaxEntryTextBytes) return text; - var actual = enc.GetByteCount(text); - if (actual <= MaxEntryTextBytes) return text; - - // Binary search for the largest char-count whose UTF-8 byte count - // fits in MaxEntryTextBytes minus a generous margin for the marker. - var marker = string.Format(LocalizationHelper.GetString("Chat_TruncationMarkerFormat"), actual); - int budget = MaxEntryTextBytes - enc.GetByteCount(marker); - if (budget <= 0) budget = MaxEntryTextBytes / 2; - - int lo = 0, hi = text.Length; - while (lo < hi) - { - int mid = (lo + hi + 1) / 2; - // Don't split a surrogate pair: nudge mid back if it lands on - // a low surrogate. - if (mid < text.Length && char.IsLowSurrogate(text[mid])) mid--; - if (mid <= lo) - { - hi = lo; - continue; - } - int bytes = enc.GetByteCount(text.AsSpan(0, mid)); - if (bytes <= budget) lo = mid; - else hi = mid - 1; - } - if (lo > 0 && char.IsHighSurrogate(text[lo - 1])) lo--; - - Logger.Debug($"[ChatTruncate] message {actual} bytes → {lo} chars (~{enc.GetByteCount(text.AsSpan(0, lo))} bytes); cap={MaxEntryTextBytes}"); - return string.Concat(text.AsSpan(0, lo), marker.AsSpan()); - } - - // ── chat.history flattened-tool-output recovery ── - - /// - /// True when text is one of the approval slash-commands we send on the - /// user's behalf (/approve <slug> allow-once, - /// /approve <slug> allow-always, or - /// /deny <slug>). Matches the exact dashboard grammar - /// — not just the prefix — so legitimate user prose like - /// "/approve the design changes" still renders as a normal bubble. - /// - /// - /// Slug shape: hex-ish identifier (letters, digits, dashes, underscores; - /// 4–64 chars). This mirrors what the gateway emits for - /// ``approvalSlug``; we don't anchor on a specific length because the - /// gateway has changed it before. - /// - internal static bool LooksLikeApprovalSlashCommand(string text) - { - if (string.IsNullOrEmpty(text)) return false; - var t = text.Trim(); - return s_approvalSlashCommandRegex.IsMatch(t); - } - - private static readonly System.Text.RegularExpressions.Regex s_approvalSlashCommandRegex = - new(@"^/(?:approve\s+[A-Za-z0-9_-]{4,64}(?:\s+(?:allow-once|allow-always))?|deny\s+[A-Za-z0-9_-]{4,64})\s*$", - System.Text.RegularExpressions.RegexOptions.Compiled); - - // ── Content-block-seam repair ────────────────────────────────────── - // Anthropic Claude returns an assistant turn as an ordered list of - // content blocks ({text}, {tool_use}, {text}, …). The OpenClaw gateway - // strips out the tool_use blocks (they're surfaced as tool chips) but - // currently joins the remaining text blocks into a single - // chat.message.text string WITHOUT inserting any whitespace between - // them. That produces visibly glued seams in the assistant bubble, - // e.g. (real captures from Sonnet 4.5 / Opus 4.7): - // - // "...C:\Windows\System32**The command was blocked..." (** + capital) - // "...with PowerShell:The C:\temp directory doesn't..." (: + capital) - // "...on your Windows node.Looks like there's a..." (. + capital) - // "...the deletion works?Got it - less exploring..." (? + capital) - // - // The proper fix lives in the gateway. Until that ships, this pass - // re-inserts a paragraph break at each high-confidence seam so the - // rendered Markdown bubble reads naturally. Patterns are kept narrow - // to minimize false positives in normal prose: - // - // • Bold-close seam: lowercase/digit, then ``**``, then a capital - // letter — i.e. a heading-style bold span immediately followed by - // new sentence text. Inline emphasis like ``**foo**bar`` is left - // alone (next char is lowercase). - // • Punctuation seam: lowercase/digit, then ``. ! ? :``, then a - // capital letter — i.e. a sentence terminator immediately followed - // by a new sentence. Single-letter abbreviations such as ``U.S.A`` - // are skipped (the lookbehind requires lowercase/digit, so ``S.A`` - // doesn't match). File paths like ``C:\temp`` and URLs like - // ``https://`` don't match either (next char after the punctuation - // is not a capital letter). - // - // Fenced code blocks are skipped entirely so we never inject newlines - // inside JSON/code samples. Inline single-backtick spans are left - // unhandled (false-positive rate inside short inline code is low). - private static readonly System.Text.RegularExpressions.Regex s_seamBoldClose = - new(@"(?<=[a-z0-9])(\*\*)(?=[A-Z])", - System.Text.RegularExpressions.RegexOptions.Compiled); - - // The sentence-punctuation seam matches a sentence-terminator (``. ! ? :``) - // glued to the start of a new sentence (capital + lowercase run). The - // tricky case is distinguishing real sentence seams (``done.Next step``) - // from member-access in code identifiers (``Path.Combine``, - // ``System.IO.File``, ``obj.Method``). Two guards do the work: - // - // • Lookbehind ``[a-z0-9][.!?:]`` — the punctuation must follow a - // lowercase letter or digit. This already rejects ALL-CAPS - // abbreviations like ``U.S.A`` (S before the trailing ``.`` is - // uppercase, so the lookbehind fails). - // - // • Lookahead ``[A-Z][a-z]+(?:[\s,;:!?]|$)`` — the next word must be - // a Pascal-case run (capital + ≥1 lowercase) followed immediately by - // whitespace, sentence punctuation, or end-of-string. That single - // trailing-char constraint is what rejects identifiers: - // ``Path.Combine(a, b)`` → ``Combine`` is followed by ``(`` ✗ - // ``obj.Method()`` → ``Method`` is followed by ``(`` ✗ - // ``System.IO.File.ReadAll…`` → ``Read`` is followed by ``A`` ✗ - // ``db.Server01`` → ``Server`` is followed by ``0`` ✗ - // ``MyVar.OtherVar`` → ``Other`` is followed by ``V`` ✗ - // ``the field is x.Baz`` (EOS) → ``Baz`` is at end-of-string ✗ - // while legitimate seams pass: - // ``PowerShell:The C:\\…`` → ``The`` is followed by `` `` ✓ - // ``done.Next step`` → ``Next`` is followed by `` `` ✓ - // ``All set!Anything else?`` → ``Anything`` is followed by `` `` ✓ - // ``All done!Next, let's…`` → ``Next`` is followed by ``,`` ✓ - // - // Note: we intentionally do NOT include end-of-string as a valid - // "trailing" position. LLMs end explanations with bare identifiers - // (``the field is x.Baz``, ``stored in obj.Foo``) and chat-message - // frames don't always carry a trailing punctuation/newline, so the - // ``$`` alternative would shred those into two paragraphs. Real - // content-block seams always have more prose after them. - // - // The whole pattern is a pair of zero-width assertions, so the - // replacement is a pure ``\n\n`` insert at the seam — no captured - // punctuation to re-emit. - private static readonly System.Text.RegularExpressions.Regex s_seamSentencePunct = - new(@"(?<=[a-z0-9][.!?:])(?=[A-Z][a-z]+[\s,;:!?])", - System.Text.RegularExpressions.RegexOptions.Compiled); - - // SearchValues gives SIMD-accelerated scan without a per-call heap allocation. - private static readonly SearchValues s_seamPunctChars = SearchValues.Create(".!?:"); - - /// - /// Re-insert paragraph breaks at gateway-glued content-block seams in - /// an assistant message. Safe to call on any text — short text, text - /// without seams, and text that is entirely fenced code all pass - /// through unchanged. Fenced code blocks (``` ``` ``` ```) are skipped - /// so JSON/code samples never get whitespace injected inside them. - /// - internal static string RepairContentBlockSeams(string? text) - { - if (string.IsNullOrEmpty(text)) return text ?? string.Empty; - if (text.Length < 4) return text; - - // Fast path: if neither marker is present we can skip entirely. - if (!text.Contains("**", System.StringComparison.Ordinal) && - text.AsSpan().IndexOfAny(s_seamPunctChars) < 0) - { - return text; - } - - // Walk the string, alternating between prose and fenced-code - // segments. Apply seam regexes to prose only. We tolerate - // unclosed fences by treating everything after the dangling - // opener as code (matches Markdown renderer behavior). - var sb = new System.Text.StringBuilder(text.Length + 16); - int i = 0; - while (i < text.Length) - { - int fenceStart = text.IndexOf("```", i, System.StringComparison.Ordinal); - if (fenceStart < 0) - { - sb.Append(RepairProseSegment(text[i..])); - break; - } - - sb.Append(RepairProseSegment(text.Substring(i, fenceStart - i))); - - int fenceEnd = text.IndexOf("```", fenceStart + 3, System.StringComparison.Ordinal); - if (fenceEnd < 0) - { - // Unclosed fence — append the rest verbatim as code. - sb.Append(text, fenceStart, text.Length - fenceStart); - break; - } - - // Append fenced block verbatim (including both fence markers). - sb.Append(text, fenceStart, fenceEnd - fenceStart + 3); - i = fenceEnd + 3; - } - - return sb.ToString(); - } - - private static string RepairProseSegment(string segment) - { - if (string.IsNullOrEmpty(segment)) return segment; - segment = s_seamBoldClose.Replace(segment, "$1\n\n"); - // s_seamSentencePunct is a zero-width assertion (lookbehind + - // lookahead) so the replacement is a pure insert of "\n\n" at - // the seam — no captured punctuation to re-emit. - segment = s_seamSentencePunct.Replace(segment, "\n\n"); - return segment; - } - - // ── [ChatTrace] helpers ───────────────────────────────────────────── - // Per-process random seed for ChatTraceHash. Mixing this into the FNV - // initial state keeps identical-text frames colliding within a single - // tray run (so duplicate-bubble diagnostics still work) while making - // the hash useless as a content fingerprint outside this process: an - // attacker with the log file can no longer rebuild the hash for a - // guessed plaintext, and the value rotates on every tray restart. - private static readonly uint ChatTraceHashSeed = unchecked((uint)System.Security.Cryptography.RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue)); - - // Short FNV-1a-style 32-bit fold of the message text, seeded with a - // per-process random value. Used in trace logs to tell two near- - // duplicate frames apart at a glance without dumping the text itself. - // Not a security hash; not reproducible outside this process. - private static string ChatTraceHash(string text) - { - if (string.IsNullOrEmpty(text)) return "00000000"; - uint h = ChatTraceHashSeed; - for (int i = 0; i < text.Length; i++) - { - h ^= text[i]; - h *= 16777619u; - } - return h.ToString("x8"); - } - - - // ── State helpers ── - - /// - /// Apply to whichever text - /// payload a carries. Returns the input - /// unchanged when there is nothing to truncate or the text already - /// fits. Used by to enforce the - /// per-message size cap on every code path. - /// - /// - /// Coverage: every subtype that carries a - /// caller-supplied text payload is truncated here, including the - /// currently-unused - /// / - /// / - /// shapes — these don't flow through - /// today but covering them now - /// prevents a future caller from bypassing the cap when wiring - /// them up. The / - /// shapes have no untrusted - /// text fields and fall through unchanged. - /// - internal static ChatEvent TruncateChatEvent(ChatEvent evt) => evt switch - { - ChatUserMessageEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatThinkingEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatReasoningEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatReasoningDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatMessageEvent e => e with - { - Text = TruncateForChatEntry(e.Text), - ReasoningText = e.ReasoningText is null ? null : TruncateForChatEntry(e.ReasoningText) - }, - ChatMessageDeltaEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatToolStartEvent e => e with - { - Text = TruncateForChatEntry(e.Text), - ToolName = TruncateForChatEntry(e.ToolName) - }, - ChatToolPresentationEvent e => e with - { - ToolName = TruncateForChatEntry(e.ToolName) - }, - ChatToolOutputEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatToolErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatStatusEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatErrorEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatRestoredEvent e => e with { Text = TruncateForChatEntry(e.Text) }, - ChatRawEvent e => e with { Text = e.Text is null ? null : TruncateForChatEntry(e.Text) }, - ChatModelChangedEvent e => e with { Model = TruncateForChatEntry(e.Model) }, - ChatIntentEvent e => e with { Intent = TruncateForChatEntry(e.Intent) }, - ChatPermissionRequestEvent e => e with - { - PermissionKind = TruncateForChatEntry(e.PermissionKind), - ToolName = TruncateForChatEntry(e.ToolName), - Detail = TruncateForChatEntry(e.Detail) - }, - _ => evt - }; - - private void ApplyEventAndPublish(string threadId, ChatEvent evt, ChatEntryMetadata? meta = null) - { - // Defense-in-depth (chat rubber-duck MEDIUM 4): cap text on every - // event that lands in the timeline. Live history-load and - // OnChatMessageReceived already truncate at the call site, but - // agent-event paths (reasoning deltas, status notes, raw tool - // output, errors) flow through here directly. Keeping the cap - // here too guarantees no untrusted gateway payload bypasses the - // limit. - evt = TruncateChatEvent(evt); - - ChatDataSnapshot snapshot; - lock (_gate) - { - snapshot = ApplyEventLocked(threadId, evt, meta); - } - Publish(snapshot); - } - - private ChatDataSnapshot ApplyEventLocked(string threadId, ChatEvent evt, ChatEntryMetadata? meta) - { - var current = GetOrCreateTimelineLocked(threadId); - var beforeIds = new HashSet(current.Entries.Count); - for (int i = 0; i < current.Entries.Count; i++) beforeIds.Add(current.Entries[i].Id); - - var next = ChatTimelineReducer.Apply(current, evt); - _timelines[threadId] = next; - - // Capture metadata for any newly-created entries. Updates to - // existing entries (e.g. UpsertAssistant on the active assistant) - // intentionally don't overwrite — the original creation timestamp - // for the turn is more useful than the most-recent-delta time. - // EXCEPTION: if the new metadata carries usage tokens (only - // emitted on terminal frames), merge them into the existing entry - // so the footer pills (↑/↓/R/ctx%) light up at end-of-turn. - if (meta is not null) - { - var threadMeta = GetOrCreateThreadMetaLocked(threadId); - var hasUsage = meta.InputTokens is not null || meta.OutputTokens is not null - || meta.ResponseTokens is not null || meta.ContextPercent is not null; - for (int i = 0; i < next.Entries.Count; i++) - { - var id = next.Entries[i].Id; - var isNew = !beforeIds.Contains(id); - if (isNew && !threadMeta.ContainsKey(id)) - { - threadMeta[id] = meta; - } - else if (hasUsage && threadMeta.TryGetValue(id, out var existing) - && (existing.InputTokens is null && existing.OutputTokens is null)) - { - // Merge usage onto the existing assistant entry whose - // text was just upserted by this final delta. - threadMeta[id] = existing with - { - InputTokens = meta.InputTokens ?? existing.InputTokens, - OutputTokens = meta.OutputTokens ?? existing.OutputTokens, - ResponseTokens = meta.ResponseTokens ?? existing.ResponseTokens, - ContextPercent = meta.ContextPercent ?? existing.ContextPercent - }; - } - } - } - - return BuildSnapshotLocked(); - } - - private Dictionary GetOrCreateThreadMetaLocked(string threadId) - { - if (!_entryMeta.TryGetValue(threadId, out var meta)) - { - meta = new Dictionary(); - _entryMeta[threadId] = meta; - } - return meta; - } - - private readonly record struct ResetClearPersistence( - bool SaveAbortedIds, - bool SaveToolMeta, - bool SaveAttachmentMeta, - string[] SubmittedRunIds); - - private long GetResetVersionLocked(string threadId) => - _resetVersions.TryGetValue(threadId, out var version) ? version : 0; - - private long GetHistoryReplacementVersionLocked(string threadId) => - _historyReplacementVersions.TryGetValue(threadId, out var version) ? version : 0; - - private long GetHistoryRevisionLocked(string threadId) => - _historyRevisions.TryGetValue(threadId, out var revision) ? revision : 0; - - private long GetResetCutoffUtcMsLocked(string threadId) => - _resetCutoffUtcMs.TryGetValue(threadId, out var cutoff) ? cutoff : 0; - - private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string threadId) - { - _telemetry.FinishThread(threadId, ChatTelemetryOutcome.Canceled, ChatTurnTelemetryReason.Reset); - var oldSessionId = _sessionIds.TryGetValue(threadId, out var sid) ? sid : null; - var saveToolMeta = false; - var saveAttachmentMeta = false; - var saveAbortedIds = _persistedAbortedIds.Remove(threadId); - - if (!string.IsNullOrEmpty(oldSessionId)) - { - saveToolMeta = _toolMetaCache.Remove(oldSessionId); - saveAttachmentMeta = _attachmentMetaCache.Remove(oldSessionId); - _resetClearedSessionIds[threadId] = oldSessionId; - } - else - { - _resetClearedSessionIds.Remove(threadId); - } - saveToolMeta = _toolMetaCache.Remove(threadId) || saveToolMeta; - saveAttachmentMeta = _attachmentMetaCache.Remove(threadId) || saveAttachmentMeta; - - if (saveToolMeta) - { - _toolMetaCacheDirty = true; - _toolMetaSaveVersion++; - } - - var submittedRunIds = new HashSet(StringComparer.Ordinal); - if (_activeRunIds.TryGetValue(threadId, out var activeRunId) && !string.IsNullOrEmpty(activeRunId)) - submittedRunIds.Add(activeRunId); - if (_queuedMessageIdsByRunId.TryGetValue(threadId, out var queuedRunIds)) - { - foreach (var queuedRunId in queuedRunIds.Keys) - submittedRunIds.Add(queuedRunId); - } - if (_localSentTexts.TryGetValue(threadId, out var localEchoes)) - { - foreach (var localEcho in localEchoes) - AddResetSubmittedLocalEchoTextLocked(threadId, localEcho.Text, localEcho.SentAt); - } - - _resetVersions[threadId] = GetResetVersionLocked(threadId) + 1; - _resetCutoffUtcMs[threadId] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - _resetAwaitingUserMessage.Add(threadId); - _timelines[threadId] = ChatTimelineState.Initial() with { HistoryLoaded = true }; - _entryMeta.Remove(threadId); - _sessionIds.Remove(threadId); - _historyLoaded.Add(threadId); - _historyRetryCount.Remove(threadId); - _activeRunIds.Remove(threadId); - _activeRunStartSequences.Remove(threadId); - _pendingAbortCounts.Remove(threadId); - _abortedThreads.Remove(threadId); - _locallyInitiatedThreads.Remove(threadId); - _localSentTexts.Remove(threadId); - _queuedMessages.Remove(threadId); - _queuedSendRequests.Remove(threadId); - ClearQueuedDrainScheduleLocked(threadId); - _queuedMessageIdsByRunId.Remove(threadId); - _terminalRunIdsByThread.Remove(threadId); - _assistantFallbackPromotedThreads.Remove(threadId); - _resetAcceptedRunIds.Remove(threadId); - _resetLocalSendWithoutRunVersions.Remove(threadId); - _resetLocalSendWithoutRunStartSequences.Remove(threadId); - _resetLocalEchoSequences.Remove(threadId); - _resetPendingLifecycleStarts.Remove(threadId); - _resetRemoteBackfillInFlight.Remove(threadId); - _resetRemoteUserSeen.Remove(threadId); - foreach (var submittedRunId in submittedRunIds) - AddResetIgnoredRunIdLocked(threadId, submittedRunId); - - return new ResetClearPersistence(saveAbortedIds, saveToolMeta, saveAttachmentMeta, submittedRunIds.ToArray()); - } - - private void PersistClearedResetState(ResetClearPersistence persistence) - { - if (persistence.SaveAbortedIds) - SaveAbortedIds(); - if (persistence.SaveToolMeta) - SaveToolMetaCache(); - if (persistence.SaveAttachmentMeta) - SaveAttachmentMetaCache(); - } - - private void AddResetIgnoredRunIdLocked(string threadId, string runId) - { - if (!_resetIgnoredRunIds.TryGetValue(threadId, out var set)) - { - set = new HashSet(StringComparer.Ordinal); - _resetIgnoredRunIds[threadId] = set; - } - set.Add(runId); - } - - private void AddResetSubmittedLocalEchoTextLocked(string threadId, string text, DateTimeOffset sentAt) - { - if (string.IsNullOrWhiteSpace(text)) - return; - - if (!_resetSubmittedLocalEchoTexts.TryGetValue(threadId, out var texts)) - { - texts = new Dictionary>(StringComparer.Ordinal); - _resetSubmittedLocalEchoTexts[threadId] = texts; - } - - var normalized = text.Trim(); - if (!texts.TryGetValue(normalized, out var timestamps)) - { - timestamps = new Queue(); - texts[normalized] = timestamps; - } - timestamps.Enqueue(sentAt); - } - - private bool TryConsumeResetSubmittedLocalEchoTextLocked(string threadId, string text) - { - if (string.IsNullOrWhiteSpace(text) || - !_resetSubmittedLocalEchoTexts.TryGetValue(threadId, out var texts)) - { - return false; - } - - var normalized = text.Trim(); - if (!texts.TryGetValue(normalized, out var timestamps)) - return false; - - var now = DateTimeOffset.UtcNow; - while (timestamps.Count > 0 && now - timestamps.Peek() > LocalEchoSuppressionWindow) - timestamps.Dequeue(); - - if (timestamps.Count == 0) - { - texts.Remove(normalized); - if (texts.Count == 0) - _resetSubmittedLocalEchoTexts.Remove(threadId); - return false; - } - - timestamps.Dequeue(); - if (timestamps.Count == 0) - texts.Remove(normalized); - - if (texts.Count == 0) - _resetSubmittedLocalEchoTexts.Remove(threadId); - return true; - } - - private bool HasPendingLocalEchoTextLocked(string threadId, string text) - { - if (string.IsNullOrWhiteSpace(text) || - !_localSentTexts.TryGetValue(threadId, out var queue) || - queue.Count == 0) - { - return false; - } - - var normalized = text.Trim(); - return queue.Any(pending => string.Equals(pending.Text, normalized, StringComparison.Ordinal)); - } - - private void AddResetAcceptedRunIdLocked(string threadId, string runId) - { - if (!_resetAwaitingUserMessage.Contains(threadId)) - return; - - if (!_resetAcceptedRunIds.TryGetValue(threadId, out var set)) - { - set = new HashSet(StringComparer.Ordinal); - _resetAcceptedRunIds[threadId] = set; - } - set.Add(runId); - TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: runId); - } - - private readonly record struct PendingResetLifecycleStart(AgentEventInfo Event, long Sequence); - - private bool ShouldDropChatMessageAfterResetLocked( - string threadId, - string roleLower, - string rawText, - long tsMs, - out string? consumeEchoText, - out bool requestRemoteBackfill) - { - consumeEchoText = null; - requestRemoteBackfill = false; - var isNormalUserText = roleLower == "user" && - !LooksLikeApprovalSlashCommand(rawText) && - !NativeToolProjector.LooksLikeSystemControlNote(rawText); - - if (isNormalUserText && - !HasPendingLocalEchoTextLocked(threadId, rawText) && - TryConsumeResetSubmittedLocalEchoTextLocked(threadId, rawText)) - { - return true; - } - - if (!_resetAwaitingUserMessage.Contains(threadId)) - { - return IsPreResetTimestampLocked(threadId, tsMs, GetResetCutoffUtcMsLocked(threadId)); - } - - var isFreshUser = isNormalUserText && - !IsPreResetTimestampLocked(threadId, tsMs, GetResetCutoffUtcMsLocked(threadId)); - - if (isFreshUser && - _localSentTexts.TryGetValue(threadId, out var echoQueue) && - echoQueue.Count > 0 && - echoQueue.Any(pending => string.Equals(pending.Text, rawText.Trim(), StringComparison.Ordinal))) - { - consumeEchoText = rawText.Trim(); - _resetLocalEchoSequences[threadId] = _resetLifecycleStartSequence; - if (TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null)) - return false; - } - else if (isFreshUser && tsMs > 0) - { - _resetRemoteUserSeen.Add(threadId); - if (TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null)) - return false; - } - else if (isFreshUser && _resetRemoteBackfillInFlight.Add(threadId)) - { - requestRemoteBackfill = true; - } - - return true; - } - - private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId) - { - ChatDataSnapshot? snapshot = null; - lock (_gate) - { - // This fallback covers the degenerate case where an assistant frame - // arrives before any user echo or lifecycle.start/run mapping. When - // a run is active, lifecycle/ACK correlation owns the handoff; when - // multiple queued prompts exist, positional assistant fallback is - // ambiguous and can create false user boundaries that duplicate the - // assistant bubble. - if (_locallyInitiatedThreads.Contains(threadId) - && TryGetSingleSendingQueuedMessageLocked(threadId, out var queued) - && !_activeRunIds.ContainsKey(threadId) - && !_assistantFallbackPromotedThreads.Contains(threadId) - && PromoteQueuedMessageLocked(threadId, queued.Id)) - { - snapshot = BuildSnapshotLocked(); - } - } - - if (snapshot is not null) - Publish(snapshot); - } - - private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( - string threadId, - string assistantText, - string? gatewayMessageId, - int? openClawSeq) - { - if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) && - IsIdentifiedCompletedAssistantDuplicateLocked( - threadId, - assistantText, - gatewayMessageId, - openClawSeq)) - { - return AssistantQueueFrameDisposition.Drop; - } - - if (string.IsNullOrEmpty(gatewayMessageId) && - openClawSeq is null && - IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked(threadId, assistantText)) - { - return AssistantQueueFrameDisposition.Drop; - } - - if (!_locallyInitiatedThreads.Contains(threadId) || - !TryGetSingleSendingQueuedMessageLocked(threadId, out _) || - _activeRunIds.ContainsKey(threadId) || - _assistantFallbackPromotedThreads.Contains(threadId) || - !_timelines.TryGetValue(threadId, out var timeline)) - { - return AssistantQueueFrameDisposition.Render; - } - - for (var i = timeline.Entries.Count - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (entry.Kind != ChatTimelineItemKind.Assistant) - continue; - if (entry.IsStreaming || !string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) - return AssistantQueueFrameDisposition.Render; - if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null) - // In this queue-boundary window, an identity-less same-text frame cannot be tied - // to the queued prompt; replaying it can attach stale output to the next prompt. - return AssistantQueueFrameDisposition.Drop; - if (!_entryMeta.TryGetValue(threadId, out var threadMeta) || - !threadMeta.TryGetValue(entry.Id, out var existing)) - { - return AssistantQueueFrameDisposition.Render; - } - - var sameGatewayIdentity = - (!string.IsNullOrEmpty(gatewayMessageId) && - string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) || - (openClawSeq is not null && existing.OpenClawSeq == openClawSeq); - return sameGatewayIdentity - ? AssistantQueueFrameDisposition.Drop - : AssistantQueueFrameDisposition.Render; - } - - return AssistantQueueFrameDisposition.Render; - } - - private bool IsIdentitylessAssistantRetransmitAcrossLocalUserBoundaryLocked(string threadId, string assistantText) - { - if (!_locallyInitiatedThreads.Contains(threadId) || - _activeRunIds.ContainsKey(threadId) || - !_timelines.TryGetValue(threadId, out var timeline) || - !_entryMeta.TryGetValue(threadId, out var threadMeta)) - { - return false; - } - - var sawLatestLocalUserBoundary = false; - for (var i = timeline.Entries.Count - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (!sawLatestLocalUserBoundary) - { - if (entry.Kind == ChatTimelineItemKind.Assistant) - return false; - if (entry.Kind == ChatTimelineItemKind.User && - threadMeta.TryGetValue(entry.Id, out var meta) && - meta.IsLocalQueuedSend) - { - sawLatestLocalUserBoundary = true; - } - continue; - } - - if (entry.Kind == ChatTimelineItemKind.Assistant) - return !entry.IsStreaming && string.Equals(entry.Text, assistantText, StringComparison.Ordinal); - if (entry.Kind == ChatTimelineItemKind.User) - return false; - } - - return false; - } - - private bool IsIdentifiedCompletedAssistantDuplicateLocked( - string threadId, - string assistantText, - string? gatewayMessageId, - int? openClawSeq) - { - if (!_timelines.TryGetValue(threadId, out var timeline) || - !_entryMeta.TryGetValue(threadId, out var threadMeta)) - { - return false; - } - - for (var i = timeline.Entries.Count - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (entry.Kind != ChatTimelineItemKind.Assistant || - entry.IsStreaming || - !threadMeta.TryGetValue(entry.Id, out var existing)) - { - continue; - } - - var bothHaveGatewayIds = - !string.IsNullOrEmpty(gatewayMessageId) && - !string.IsNullOrEmpty(existing.GatewayMessageId); - if (bothHaveGatewayIds && - string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) - { - return true; - } - if (!bothHaveGatewayIds && - openClawSeq is not null && - existing.OpenClawSeq == openClawSeq && - string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) - { - if (!string.IsNullOrEmpty(gatewayMessageId) && - string.IsNullOrEmpty(existing.GatewayMessageId)) - { - threadMeta[entry.Id] = existing with { GatewayMessageId = gatewayMessageId }; - } - return true; - } - } - - return false; - } - - private bool HasSendingQueuedMessagesLocked(string threadId) - => _queuedMessages.TryGetValue(threadId, out var queued) && - queued.Any(message => message.SendState == ChatQueuedMessageSendState.Sending); - - private bool HasPendingQueuedMessagesLocked(string threadId) - => _queuedMessages.TryGetValue(threadId, out var queued) && - queued.Any(message => message.SendState is ChatQueuedMessageSendState.Queued or ChatQueuedMessageSendState.Sending); - - private bool TryGetSingleSendingQueuedMessageLocked(string threadId, out ChatQueuedMessage message) - { - message = default!; - if (!_queuedMessages.TryGetValue(threadId, out var queued)) - return false; - - ChatQueuedMessage? found = null; - foreach (var candidate in queued) - { - if (candidate.SendState != ChatQueuedMessageSendState.Sending) - continue; - if (FindQueuedSendRequestLocked(threadId, candidate.Id)?.LifecycleCommand is not null) - continue; - if (found is not null) - return false; - found = candidate; - } - - if (found is null) - return false; - - message = found; - return true; - } - - private bool ShouldDropAgentEventAfterResetLocked(AgentEventInfo evt, string threadId, out bool reloadHistoryAfterDrop) - { - reloadHistoryAfterDrop = false; - if (IsResetIgnoredRunLocked(threadId, evt.RunId, evt, out reloadHistoryAfterDrop)) - return true; - - var eventTsMs = evt.Ts > 0 ? (long)evt.Ts : 0L; - var cutoff = GetResetCutoffUtcMsLocked(threadId); - if (!_resetAwaitingUserMessage.Contains(threadId)) - return IsPreResetTimestampLocked(threadId, eventTsMs, cutoff); - - if (IsAcceptedPostResetLifecycleStartLocked(threadId, evt, _resetLifecycleStartSequence + 1)) - { - OpenResetGateForLifecycleStartLocked(threadId, evt); - return false; - } - - if (IsPreResetTimestampLocked(threadId, eventTsMs, cutoff)) - return true; - - if (IsLifecycleStart(evt)) - BufferResetLifecycleStartLocked(threadId, evt); - - return true; - } - - private bool IsAcceptedPostResetLifecycleStartLocked(string threadId, AgentEventInfo evt, long lifecycleStartSequence) - { - if (!IsLifecycleStart(evt)) - return false; - - if (!string.IsNullOrEmpty(evt.RunId) && - _resetAcceptedRunIds.TryGetValue(threadId, out var acceptedRunIds) && - acceptedRunIds.Contains(evt.RunId)) - { - return true; - } - - if (_resetLocalSendWithoutRunVersions.TryGetValue(threadId, out var localSendVersion) && - localSendVersion == GetResetVersionLocked(threadId) && - _resetLocalSendWithoutRunStartSequences.TryGetValue(threadId, out var localSendStartSequence) && - _resetLocalEchoSequences.TryGetValue(threadId, out var localEchoSequence) && - localEchoSequence >= localSendStartSequence && - lifecycleStartSequence > localSendStartSequence && - evt.Ts > 0 && - !IsPreResetTimestampLocked(threadId, (long)evt.Ts, GetResetCutoffUtcMsLocked(threadId))) - { - return true; - } - - return _resetRemoteUserSeen.Contains(threadId) && - !IsPreResetTimestampLocked(threadId, evt.Ts > 0 ? (long)evt.Ts : 0L, GetResetCutoffUtcMsLocked(threadId)); - } - - private void BufferResetLifecycleStartLocked(string threadId, AgentEventInfo evt) - { - if (!_resetPendingLifecycleStarts.TryGetValue(threadId, out var pending)) - { - pending = new List(); - _resetPendingLifecycleStarts[threadId] = pending; - } - - if (!string.IsNullOrEmpty(evt.RunId) && - pending.Exists(e => string.Equals(e.Event.RunId, evt.RunId, StringComparison.Ordinal))) - { - return; - } - - pending.Add(new PendingResetLifecycleStart(evt, ++_resetLifecycleStartSequence)); - if (pending.Count > 8) - pending.RemoveRange(0, pending.Count - 8); - } - - private bool TryOpenResetGateFromPendingLifecycleLocked(string threadId, string? acceptedRunId) - { - if (!_resetAwaitingUserMessage.Contains(threadId) || - !_resetPendingLifecycleStarts.TryGetValue(threadId, out var pending)) - { - return false; - } - - for (var i = 0; i < pending.Count; i++) - { - var pendingStart = pending[i]; - var evt = pendingStart.Event; - if (acceptedRunId is not null) - { - if (!string.Equals(evt.RunId, acceptedRunId, StringComparison.Ordinal)) - continue; - } - else if (!IsAcceptedPostResetLifecycleStartLocked(threadId, evt, pendingStart.Sequence)) - { - continue; - } - - pending.RemoveAt(i); - OpenResetGateForLifecycleStartLocked(threadId, evt); - return true; - } - - return false; - } - - private void SnapshotLatestAssistantUsage(string threadId) - { - ChatDataSnapshot? snapshot = null; - lock (_gate) - { - var session = ResolveSessionForThreadLocked(threadId); - if (session is null) return; - if (SnapshotLatestAssistantUsageLocked(session, threadId)) - snapshot = BuildSnapshotLocked(); - } - - if (snapshot is not null) - Publish(snapshot); - } - - private void SnapshotAssistantUsageContribution(string threadId, ChatEntryMetadata meta) - { - ChatDataSnapshot? snapshot = null; - lock (_gate) - { - if (SnapshotAssistantUsageContributionLocked(threadId, meta)) - snapshot = BuildSnapshotLocked(); - } - - if (snapshot is not null) - Publish(snapshot); - } - - private bool SnapshotAssistantUsageContributionLocked(string threadId, ChatEntryMetadata meta) - { - var currentUsage = UsageValue(meta); - if (currentUsage is null || currentUsage <= 0) - return false; - - if (!_timelines.TryGetValue(threadId, out var timeline)) - return false; - - var contextTokens = meta.ContextTokens; - if ((contextTokens is null || contextTokens <= 0) - && _sessions.FirstOrDefault(s => string.Equals(s.Key, threadId, StringComparison.Ordinal)) is { ContextTokens: > 0 } session) - { - contextTokens = session.ContextTokens; - } - - for (var i = timeline.Entries.Count - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (entry.Kind != ChatTimelineItemKind.Assistant) - continue; - - var threadMeta = GetOrCreateThreadMetaLocked(threadId); - threadMeta.TryGetValue(entry.Id, out var existing); - var previousUsage = LatestAssistantUsageBeforeLocked(timeline, threadMeta, i); - var candidateUsage = (previousUsage ?? 0) + currentUsage.Value; - var cumulativeUsage = Math.Max(candidateUsage, existing?.ResponseTokens ?? 0); - if (existing?.ResponseTokens == cumulativeUsage - && existing.UsageContributionTokens == currentUsage - && existing.ContextTokens == contextTokens) - { - return false; - } - - threadMeta[entry.Id] = (existing ?? BuildLiveMetaLocked(threadId)) with - { - InputTokens = meta.InputTokens ?? existing?.InputTokens, - OutputTokens = meta.OutputTokens ?? existing?.OutputTokens, - ResponseTokens = cumulativeUsage, - ContextPercent = meta.ContextPercent ?? existing?.ContextPercent, - ContextTokens = contextTokens ?? existing?.ContextTokens, - UsageContributionTokens = currentUsage, - }; - return true; - } - - return false; - } - - private void OpenResetGateForLifecycleStartLocked(string threadId, AgentEventInfo evt) - { - _resetAwaitingUserMessage.Remove(threadId); - _resetRemoteUserSeen.Remove(threadId); - _resetLocalSendWithoutRunVersions.Remove(threadId); - _resetLocalSendWithoutRunStartSequences.Remove(threadId); - _resetLocalEchoSequences.Remove(threadId); - _resetPendingLifecycleStarts.Remove(threadId); - - if (!string.IsNullOrEmpty(evt.RunId)) - { - _activeRunIds[threadId] = evt.RunId; - _activeRunStartSequences[threadId] = ++_lifecycleStartSequence; - if (_resetAcceptedRunIds.TryGetValue(threadId, out var acceptedRunIds)) - { - acceptedRunIds.Remove(evt.RunId); - if (acceptedRunIds.Count == 0) - _resetAcceptedRunIds.Remove(threadId); - } - } - } - - private bool IsResetIgnoredRunLocked(string threadId, string? runId, AgentEventInfo evt, out bool reloadHistoryAfterDrop) - { - reloadHistoryAfterDrop = false; - if (string.IsNullOrEmpty(runId) || - !_resetIgnoredRunIds.TryGetValue(threadId, out var runIds) || - !runIds.Contains(runId)) - { - return false; - } - - if (IsTerminalRunEvent(evt)) - { - runIds.Remove(runId); - if (runIds.Count == 0) - { - _resetIgnoredRunIds.Remove(threadId); - _resetSubmittedLocalEchoTexts.Remove(threadId); - } - reloadHistoryAfterDrop = true; - } - - return true; - } - - private bool IsPreResetTimestampLocked(string threadId, long eventTsMs, long resetCutoffUtcMs) - { - if (eventTsMs <= 0 || resetCutoffUtcMs <= 0) - return false; - - return _resetVersions.ContainsKey(threadId) && - eventTsMs + ResetTimestampToleranceMs <= resetCutoffUtcMs; - } - - private static bool IsLifecycleStart(AgentEventInfo evt) => - string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) && - evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object && - evt.Data.TryGetProperty("phase", out var phaseProp) && - string.Equals(phaseProp.GetString(), "start", StringComparison.OrdinalIgnoreCase); - - private static bool IsTerminalRunEvent(AgentEventInfo evt) - { - if (string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) && - evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object && - evt.Data.TryGetProperty("phase", out var phaseProp)) - { - var phase = phaseProp.GetString(); - return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase) || - string.Equals(phase, "error", StringComparison.OrdinalIgnoreCase); - } - - if (string.Equals(evt.Stream, "job", StringComparison.OrdinalIgnoreCase) && - evt.Data.ValueKind == System.Text.Json.JsonValueKind.Object && - evt.Data.TryGetProperty("state", out var stateProp)) - { - var state = stateProp.GetString(); - return string.Equals(state, "done", StringComparison.OrdinalIgnoreCase) || - string.Equals(state, "error", StringComparison.OrdinalIgnoreCase); - } - - return false; - } - - private static int? LatestAssistantUsageBeforeLocked(ChatTimelineState timeline, Dictionary threadMeta, int beforeIndex) - { - for (var i = beforeIndex - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (entry.Kind != ChatTimelineItemKind.Assistant) - continue; - - if (!threadMeta.TryGetValue(entry.Id, out var meta)) - continue; - - var usage = UsageValue(meta); - if (usage is null) - continue; - - return usage; - } - - return null; - } - - private static int? UsageValue(ChatEntryMetadata meta) - => meta.ResponseTokens - ?? (meta.InputTokens is int input && meta.OutputTokens is int output - ? input + output - : null); - - private bool SnapshotLatestAssistantUsageLocked(SessionInfo session, string? timelineKey = null) - { - if (string.IsNullOrEmpty(session.Key)) return false; - - var usedTokens = session.TotalTokens; - if (usedTokens <= 0) - usedTokens = session.InputTokens + session.OutputTokens; - if (usedTokens <= 0) return false; - - timelineKey ??= session.Key; - if (string.IsNullOrEmpty(timelineKey)) return false; - if (!_timelines.TryGetValue(timelineKey, out var timeline)) return false; - - for (var i = timeline.Entries.Count - 1; i >= 0; i--) - { - var entry = timeline.Entries[i]; - if (entry.Kind != ChatTimelineItemKind.Assistant) continue; - - var threadMeta = GetOrCreateThreadMetaLocked(timelineKey); - threadMeta.TryGetValue(entry.Id, out var existing); - var usageSnapshot = Math.Max(usedTokens, existing?.ResponseTokens ?? 0); - var usageSnapshotTokens = ToIntIfPositive(usageSnapshot); - var contextSnapshot = session.ContextTokens > 0 ? session.ContextTokens : existing?.ContextTokens; - if (existing is not null - && existing.ResponseTokens == usageSnapshotTokens - && existing.ContextTokens == contextSnapshot) - return false; - - threadMeta[entry.Id] = (existing ?? BuildLiveMetaLocked(timelineKey)) with - { - InputTokens = ToIntIfPositive(session.InputTokens), - OutputTokens = ToIntIfPositive(session.OutputTokens), - ResponseTokens = usageSnapshotTokens, - ContextTokens = contextSnapshot, - ContextPercent = existing?.ContextPercent, - UsageContributionTokens = existing?.UsageContributionTokens - }; - return true; - } - - return false; - } - - private SessionInfo? ResolveSessionForThreadLocked(string threadId) - { - var session = Array.Find(_sessions, s => string.Equals(s.Key, threadId, StringComparison.Ordinal)); - if (session is not null) return session; - - if (string.Equals(threadId, "main", StringComparison.Ordinal) - && _bridge.MainSessionKey is { Length: > 0 } mainKey) - { - session = Array.Find(_sessions, s => string.Equals(s.Key, mainKey, StringComparison.Ordinal)); - if (session is not null) return session; - } - - if (string.Equals(threadId, "main", StringComparison.Ordinal)) - return Array.Find(_sessions, s => s.IsMain); - - return null; - } - - private string ResolveTimelineKeyForSessionLocked(SessionInfo session) - { - if (session.IsMain && _timelines.TryGetValue("main", out var mainTimeline) - && mainTimeline.Entries.Count > 0) - { - return "main"; - } - - if (!string.IsNullOrEmpty(session.Key) && _timelines.ContainsKey(session.Key)) - return session.Key; - - if (session.IsMain && _timelines.ContainsKey("main")) - return "main"; - - return session.Key; - } - - private static int? ToIntIfPositive(long value) - => value > 0 && value <= int.MaxValue ? (int)value : null; - - private ChatEntryMetadata BuildLiveMetaLocked( - string threadId, - long? tsMs = null, - string? gatewayMessageId = null, - int? openClawSeq = null, - bool isLocalQueuedSend = false, - string? localQueuedMessageId = null, - string? openClawKind = null, - long? compactionTokensBefore = null, - long? compactionTokensAfter = null) - { - var ts = tsMs is { } v && v > 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(v).ToLocalTime() - : (DateTimeOffset?)DateTimeOffset.Now; - var session = Array.Find(_sessions, s => s.Key == threadId); - return new ChatEntryMetadata( - ts, - session?.Model, - GatewayMessageId: gatewayMessageId, - OpenClawSeq: openClawSeq, - OpenClawKind: openClawKind, - CompactionTokensBefore: compactionTokensBefore, - CompactionTokensAfter: compactionTokensAfter, - IsLocalQueuedSend: isLocalQueuedSend, - LocalQueuedMessageId: localQueuedMessageId); - } - - private static List OrderHistoryMessages(List<(ChatMessageInfo Message, int Index)> messages) - { - if (messages.Count == 0) - return new List(); - - var sequencedCount = messages.Count(item => item.Message.OpenClawSeq is not null); - if (sequencedCount == messages.Count) - { - return messages - .OrderBy(item => item.Message.OpenClawSeq) - .ThenBy(item => item.Index) - .Select(item => item.Message) - .ToList(); - } - - if (sequencedCount == 0) - { - return messages - .OrderBy(item => item.Message.Ts) - .ThenBy(item => item.Index) - .Select(item => item.Message) - .ToList(); - } - - // Mixed old/new rows are already in gateway transcript order. Sorting - // timestamped-but-unsequenced rows against sequenced rows can drag a - // later queued burst (e.g. "t") ahead of the actual transcript start. - return messages - .OrderBy(item => item.Index) - .Select(item => item.Message) - .ToList(); - } - - private static void IncrementCount(Dictionary counts, string key) - => counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1; - - private static bool TryConsumeCount(Dictionary counts, string key) - { - if (!counts.TryGetValue(key, out var count) || count <= 0) - return false; - - if (count == 1) - counts.Remove(key); - else - counts[key] = count - 1; - return true; - } - - private static void ConsumeAnyTimestamp(Dictionary> timestamps, string key) - { - if (timestamps.TryGetValue(key, out var values) && values.Count > 0) - values.RemoveAt(0); - } - - private void SeedSessionIdsFromSessionsLocked(IEnumerable sessions) - { - foreach (var session in sessions) - { - if (!string.IsNullOrWhiteSpace(session.Key) && - !string.IsNullOrWhiteSpace(session.SessionId)) - { - if (_resetClearedSessionIds.TryGetValue(session.Key, out var clearedSessionId) && - string.Equals(clearedSessionId, session.SessionId, StringComparison.Ordinal)) - { - continue; - } - - _sessionIds[session.Key] = session.SessionId!; - _resetClearedSessionIds.Remove(session.Key); - } - } - } - - private ChatTimelineState GetOrCreateTimelineLocked(string threadId) - { - if (!_timelines.TryGetValue(threadId, out var current)) - { - // HistoryLoaded stays false until LoadHistoryAsync rebuilds - // the timeline from the gateway. The UI relies on this flag - // to distinguish "session exists, history still fetching" - // (show reconnecting view) from "session truly empty" - // (show welcome zero-state). - current = ChatTimelineState.Initial(); - _timelines[threadId] = current; - } - return current; - } - - private void EnsureTimelinesForSessionsLocked() - { - foreach (var s in _sessions) - { - if (string.IsNullOrEmpty(s.Key)) continue; - if (!_timelines.ContainsKey(s.Key)) - _timelines[s.Key] = ChatTimelineState.Initial(); - } - } - - private ChatDataSnapshot BuildSnapshotLocked() - { - // Build threads from the gateway's authoritative session list. - // No synthesis based on local timeline keys — the UI's compose target - // is exposed separately via ChatComposeTarget so the renderer can show - // a usable composer even before the first session materializes server- - // side (e.g. fresh install with zero sessions). - var threadList = new List(_sessions.Length + 1); - var threadTitles = SessionTitleFormatter.FormatUnique(_sessions); - for (int i = 0; i < _sessions.Length; i++) - threadList.Add(ToThread(_sessions[i], threadTitles[i])); - - var composeKey = _bridge.MainSessionKey; - var composeAgentId = _sessions - .FirstOrDefault(session => string.Equals(session.Key, composeKey, StringComparison.Ordinal)) is { } mainSession - ? SessionDisplayResolver.Resolve(mainSession).AgentId ?? "main" - : "main"; - var composeReady = _bridge.HasHandshakeSnapshot - && !string.IsNullOrWhiteSpace(composeKey) - && _status == ConnectionStatus.Connected - // Wait until sessions.list has been delivered for this - // connection — otherwise the UI may synthesize a compose-only - // thread (and render the welcome zero-state) in the brief - // window before a returning user's real sessions arrive. - && _sessionsListReceived; - - // If the compose target hasn't materialized as a real session yet but - // already has local pending chat state (because the user sent a message - // before the gateway echoed back sessions.list), surface a synthetic - // thread record so the UI can render the queued card/transcript without - // falling back into the "no thread selected" zero state. The synthetic - // thread's Id is the canonical compose key, so when SessionsUpdated - // eventually arrives with the same key it replaces the synthetic in - // place — no migration, no re-keying. - if (composeReady - && composeKey is { } ck - && _timelines.TryGetValue(ck, out var pendingTl) - && (pendingTl.Entries.Count > 0 - || pendingTl.TurnActive - || (_queuedMessages.TryGetValue(ck, out var pendingQueue) && pendingQueue.Count > 0)) - && !_sessions.Any(s => string.Equals(s.Key, ck, StringComparison.Ordinal))) - { - threadList.Add(new ChatThread - { - Id = ck, - AgentId = composeAgentId, - Title = _lastChatState?.ThreadTitle ?? "OpenClaw Windows Tray", - Model = _lastChatState?.Model, - ModelProvider = _lastChatState?.ModelProvider, - Status = ChatThreadStatus.Running, - Activity = ChatActivity.Idle, - }); - } - - var threads = threadList.ToArray(); - - // Snapshot a defensive copy of the timeline dict. - var timelinesCopy = new Dictionary(_timelines); - var timelineGenerationsCopy = new Dictionary(_resetVersions); - var historyRevisionsCopy = new Dictionary(_historyRevisions); - var queuedMessagesCopy = _queuedMessages.ToDictionary( - kvp => kvp.Key, - kvp => (IReadOnlyList)kvp.Value.ToArray()); - - var defaultThreadId = ResolveDefaultThreadIdLocked(); - - // When the gateway is connected and the handshake completed but no - // session key was advertised, distinguish this from a normal "Connected" - // state so the UI can surface a clear compatibility warning. - var connectionLabel = (_status == ConnectionStatus.Connected - && _bridge.HasHandshakeSnapshot - && string.IsNullOrWhiteSpace(composeKey)) - ? "Incompatible gateway" - : _status switch - { - ConnectionStatus.Connected => "Connected", - ConnectionStatus.Connecting => "Connecting…", - ConnectionStatus.Disconnected => "Disconnected", - ConnectionStatus.Error => "Disconnected — error", - _ => _status.ToString() - }; - - var composeTarget = composeReady - ? new ChatComposeTarget(composeKey, true, composeAgentId) - : ChatComposeTarget.NotReady; - - return new ChatDataSnapshot( - Threads: threads, - Timelines: timelinesCopy, - DefaultThreadId: defaultThreadId, - ConnectionStatus: connectionLabel, - AvailableModels: _availableModels, - ComposeTarget: composeTarget, - ModelChoices: _modelChoices, - // Null until the first commands.list fetch completes so the UI can - // distinguish "loading" from "loaded but empty". IsSupported=false - // surfaces the unsupported state. - AvailableCommands: _commandCatalog?.Commands, - CommandsSupported: _commandCatalog?.IsSupported ?? true, - TimelineGenerations: timelineGenerationsCopy, - HistoryRevisions: historyRevisionsCopy, - QueuedMessagesByThread: queuedMessagesCopy); - } - - private string? ResolveDefaultThreadIdLocked() - { - if (_lastChatState?.DefaultThreadId is { Length: > 0 } rememberedThreadId) - { - if (TryGetSessionLocked(rememberedThreadId, out _) || !_sessionsListReceived) - return rememberedThreadId; - } - - // Prefer the gateway's canonical main session (IsMain on SessionInfo) - // so we never have to guess from a literal like "main". Only fall back - // to the compose target (pre-materialization) or the first available - // session when no main is present. - for (int i = 0; i < _sessions.Length; i++) - { - var s = _sessions[i]; - if (s.IsMain && !string.IsNullOrEmpty(s.Key)) - return s.Key; - } - if (_bridge.HasHandshakeSnapshot - && _bridge.MainSessionKey is { } mk - && !string.IsNullOrWhiteSpace(mk)) - return mk; - if (_sessions.Length > 0 && !string.IsNullOrEmpty(_sessions[0].Key)) - return _sessions[0].Key; - return null; - } - - private void RememberLastSessionStateLocked() - { - if (_sessions.Length == 0) return; - var defaultThreadId = ResolveDefaultThreadIdLocked(); - var session = defaultThreadId is { Length: > 0 } && TryGetSessionLocked(defaultThreadId, out var selected) - ? selected - : _sessions.FirstOrDefault(s => s.IsMain && !string.IsNullOrEmpty(s.Key)) - ?? _sessions.FirstOrDefault(s => !string.IsNullOrEmpty(s.Key)); - if (session is null) return; - - _lastChatState = new LastChatState - { - DefaultThreadId = session.Key, - ThreadTitle = SessionTitleFormatter.Format(session, _sessions), - Model = session.Model, - ModelProvider = session.Provider, - AvailableModels = _availableModels, - }; - } - - private bool TryGetSessionLocked(string threadId, out SessionInfo session) - { - for (int i = 0; i < _sessions.Length; i++) - { - var candidate = _sessions[i]; - if (string.Equals(candidate.Key, threadId, StringComparison.Ordinal)) - { - session = candidate; - return true; - } - } - - session = default!; - return false; - } - - private static ChatThread ToThread(SessionInfo s, string title) - { - var display = SessionDisplayResolver.Resolve(s); - return new ChatThread - { - Id = s.Key ?? string.Empty, - Title = title, - AgentId = display.AgentId, - IsBackground = display.IsBackground, - Status = SessionVisibilityFilter.ToChatThreadStatus(s), - Activity = SessionVisibilityFilter.ToChatThreadActivity(s), - Workspace = s.Channel, - Model = s.Model, - ModelProvider = s.Provider, - ThinkingLevel = s.ThinkingLevel, - InputTokens = s.InputTokens, - OutputTokens = s.OutputTokens, - TotalTokens = s.TotalTokens, - ContextTokens = s.ContextTokens, - CreatedAt = s.StartedAt is { } st ? ToOffset(st) : null, - UpdatedAt = s.UpdatedAt is { } ut ? ToOffset(ut) : null, - }; - } - - private static DateTimeOffset ToOffset(DateTime dt) - { - // SessionInfo.StartedAt/UpdatedAt arrive as DateTimeKind.Local or - // Unspecified depending on the parser path; new DateTimeOffset(local, Zero) - // throws because the offset must match the kind. Treat Unspecified as - // UTC (matches the gateway's wire format), and let the DateTimeOffset(dt) - // single-arg ctor handle Local/Utc using the value's actual offset. - if (dt.Kind == DateTimeKind.Unspecified) - return new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc), TimeSpan.Zero); - return new DateTimeOffset(dt); - } - - // ── Dispatcher marshaling ── - - private void Publish(ChatDataSnapshot snapshot) - { - var args = new ChatDataChangedEventArgs(snapshot); - if (_post is null) - { - Changed?.Invoke(this, args); - } - else - { - _post(() => Changed?.Invoke(this, args)); - } - - // Debounce-save last-known UI state so the next launch can show - // meaningful labels while reconnecting instead of "Main session"/"model". - if (snapshot.Threads.Length > 0 || snapshot.AvailableModels.Length > 0) - DebounceSaveLastChatState(snapshot); - } - - // ── Last-chat-state cache ────────────────────────────────────────── - // Persists the last-known thread title, model, and available models so - // the UI can show them while reconnecting instead of generic placeholders. - - private static readonly string LastChatStateFilePath = Path.Combine( - AppIdentity.ResolveLocalDataDirectory(), "last-chat-state.json"); - - private System.Threading.Timer? _lastChatStateSaveTimer; - private long _lastChatStateSaveVersion; - - internal sealed class LastChatState - { - public string? DefaultThreadId { get; set; } - public string? ThreadTitle { get; set; } - public string? Model { get; set; } - public string? ModelProvider { get; set; } - public string[]? AvailableModels { get; set; } - } - - private LastChatState? _lastChatState; - - internal static LastChatState? LoadLastChatState(string? pathOverride = null) - { - var path = pathOverride ?? LastChatStateFilePath; - try - { - if (!File.Exists(path)) return null; - var json = File.ReadAllText(path); - return System.Text.Json.JsonSerializer.Deserialize(json); - } - catch (Exception ex) - { - Logger.Warn($"Failed to load last chat state from '{path}': {ex.Message}"); - return null; - } - } - - private void DebounceSaveLastChatState(ChatDataSnapshot snapshot) - { - // Find the default thread to capture its title/model - var defaultThread = snapshot.DefaultThreadId is { } dtId - ? Array.Find(snapshot.Threads, t => t.Id == dtId) - : snapshot.Threads.Length > 0 ? snapshot.Threads[0] : null; - - if (defaultThread is null && snapshot.AvailableModels.Length == 0) return; - var previous = _lastChatState; - - var state = new LastChatState - { - DefaultThreadId = snapshot.DefaultThreadId ?? previous?.DefaultThreadId, - ThreadTitle = defaultThread?.Title ?? previous?.ThreadTitle, - Model = defaultThread?.Model ?? previous?.Model, - ModelProvider = defaultThread?.ModelProvider ?? previous?.ModelProvider, - AvailableModels = snapshot.AvailableModels, - }; - - lock (_gate) - { - _lastChatState = state; - _lastChatStateSaveVersion++; - var saveVersion = _lastChatStateSaveVersion; - _lastChatStateSaveTimer?.Dispose(); - var path = _lastChatStateFilePath; - _lastChatStateSaveTimer = new System.Threading.Timer(_ => SaveLastChatStateIfCurrent(state, path, saveVersion), null, _lastChatStateSaveDelay, Timeout.InfiniteTimeSpan); - } - } - - private void SaveLastChatStateIfCurrent(LastChatState state, string path, long saveVersion) - { - lock (_gate) - { - if (saveVersion != _lastChatStateSaveVersion) - return; - - SaveLastChatState(state, path); - _lastChatStateSaveTimer?.Dispose(); - _lastChatStateSaveTimer = null; - } - } - - private static void SaveLastChatState(LastChatState state, string? pathOverride = null) - { - var path = pathOverride ?? LastChatStateFilePath; - try - { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - var json = System.Text.Json.JsonSerializer.Serialize(state); - var tmp = path + ".tmp"; - File.WriteAllText(tmp, json); - File.Move(tmp, path, overwrite: true); - } - catch (Exception ex) { Logger.Debug($"ChatDataProvider: persist LastChatState failed: {ex.Message}"); } - } - - private void RaiseNotification(ChatProviderNotification notification) - { - var args = new ChatProviderNotificationEventArgs(notification); - if (_post is null) - { - NotificationRequested?.Invoke(this, args); - return; - } - _post(() => NotificationRequested?.Invoke(this, args)); - } - - // ── Abort persistence ────────────────────────────────────────────── - - private static readonly string AbortedIdsFilePath = Path.Combine( - AppIdentity.ResolveLocalDataDirectory(), "aborted-messages.json"); - - private static Dictionary> LoadAbortedIds() - { - try - { - if (!File.Exists(AbortedIdsFilePath)) - return new(); - var json = File.ReadAllText(AbortedIdsFilePath); - var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); - if (dict is null) return new(); - var result = new Dictionary>(); - foreach (var (k, v) in dict) - result[k] = new HashSet(v); - return result; - } - catch (Exception ex) - { - Logger.Debug($"Aborted message IDs could not be loaded: {ex.Message}"); - return new(); - } - } - - private void SaveAbortedIds() - { - try - { - Dictionary> snapshot; - lock (_gate) snapshot = new Dictionary>(_persistedAbortedIds); - - var dir = Path.GetDirectoryName(AbortedIdsFilePath); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - - // Convert HashSet to List for JSON serialization - var serializable = new Dictionary>(); - foreach (var (k, v) in snapshot) - serializable[k] = new List(v); - - var json = System.Text.Json.JsonSerializer.Serialize(serializable, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(AbortedIdsFilePath, json); - } - catch (Exception ex) { Logger.Debug($"ChatDataProvider: persist aborted IDs failed: {ex.Message}"); } - } - - // ── Tool metadata persistence ───────────────────────────────────── - - /// Cached tool call metadata entry persisted to disk. - internal sealed class CachedToolMeta - { - public long Ts { get; set; } - public string ToolName { get; set; } = ""; - public string Label { get; set; } = ""; - public string? ToolCallId { get; set; } - public string? RunId { get; set; } - public long LegacyTurn { get; set; } - public JsonObject? ToolArgs { get; set; } - public ChatToolIdentityStrength IdentityStrength { get; set; } = ChatToolIdentityStrength.Heuristic; - } - - /// Attachment display metadata persisted without attachment bytes. - internal sealed class CachedAttachmentMeta - { - public long Ts { get; set; } - public string Text { get; set; } = ""; - public List Attachments { get; set; } = new(); - } - - internal sealed class CachedAttachmentItem - { - public string FileName { get; set; } = ""; - public bool IsImage { get; set; } - } - - private static string DefaultToolMetaCacheFilePath - { - get - { - return Path.Combine(AppIdentity.ResolveLocalDataDirectory(), "tool-metadata.json"); - } - } - - private static string DefaultAttachmentMetaCacheFilePath(string toolMetaCacheFilePath) - { - var dir = Path.GetDirectoryName(toolMetaCacheFilePath); - return Path.Combine( - string.IsNullOrEmpty(dir) - ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - : dir, - "attachment-metadata.json"); - } - - /// Max sessions to keep in the tool metadata cache. - internal const int MaxCachedSessions = 20; - - /// Max tool entries per session in the cache. - internal const int MaxToolEntriesPerSession = 500; - - /// Max attachment-bearing user messages per session in the cache. - internal const int MaxAttachmentEntriesPerSession = 500; - - private static Dictionary> LoadToolMetaCache(string cacheFilePath) - { - try - { - if (!File.Exists(cacheFilePath)) - return new(); - var json = File.ReadAllText(cacheFilePath); - var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); - if (dict is not null) - { - foreach (var entry in dict.Values.SelectMany(entries => entries)) - { - entry.ToolName = NormalizeCachedDisplayText(entry.ToolName); - entry.Label = NormalizeCachedDisplayText(entry.Label); - } - } - return dict ?? new(); - } - catch (Exception ex) - { - Logger.Debug($"Tool metadata cache could not be loaded: {ex.Message}"); - return new(); - } - } - - private static Dictionary> LoadAttachmentMetaCache(string cacheFilePath) - { - try - { - if (!File.Exists(cacheFilePath)) - return new(); - var json = File.ReadAllText(cacheFilePath); - var dict = System.Text.Json.JsonSerializer.Deserialize>>(json); - if (dict is not null) - { - foreach (var entry in dict.Values.SelectMany(entries => entries)) - { - entry.Text = NormalizeCachedDisplayText(entry.Text); - foreach (var attachment in entry.Attachments) - attachment.FileName = NormalizeCachedDisplayText(attachment.FileName); - } - } - return dict ?? new(); - } - catch (Exception ex) - { - Logger.Debug($"Attachment metadata cache could not be loaded: {ex.Message}"); - return new(); - } - } - - private void SaveAttachmentMetaCache() - { - try - { - Dictionary> snapshot; - lock (_gate) - { - snapshot = _attachmentMetaCache.ToDictionary( - kv => kv.Key, - kv => kv.Value.Select(e => new CachedAttachmentMeta - { - Ts = e.Ts, - Text = NormalizeCachedDisplayText(e.Text), - Attachments = e.Attachments.Select(a => new CachedAttachmentItem - { - FileName = NormalizeCachedDisplayText(a.FileName), - IsImage = a.IsImage - }).ToList() - }).ToList(), - StringComparer.Ordinal); - } - - if (snapshot.Count > MaxCachedSessions) - { - var toRemove = snapshot - .OrderBy(kv => kv.Value.Count > 0 ? kv.Value[^1].Ts : 0) - .Take(snapshot.Count - MaxCachedSessions) - .Select(kv => kv.Key) - .ToList(); - foreach (var k in toRemove) snapshot.Remove(k); - } - - var json = System.Text.Json.JsonSerializer.Serialize(snapshot, CacheJsonOptions); - - lock (_attachmentMetaSaveGate) - { - var dir = Path.GetDirectoryName(_attachmentMetaCacheFilePath); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - - var tempPath = _attachmentMetaCacheFilePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try - { - File.WriteAllText(tempPath, json); - File.Move(tempPath, _attachmentMetaCacheFilePath, overwrite: true); - } - finally - { - try - { - if (File.Exists(tempPath)) - File.Delete(tempPath); - } - catch (Exception ex) - { - Logger.Debug($"Attachment metadata temp file cleanup failed: {ex.Message}"); - } - } - } - } - catch (Exception ex) - { - Logger.Debug($"Attachment metadata cache could not be saved: {ex.Message}"); - } - } - - private void CacheAttachmentMeta( - string? sessionId, - string threadId, - string text, - IReadOnlyList attachments, - long tsMs, - long? expectedResetVersion = null) - { - if (attachments.Count == 0) - return; - - var items = attachments - .Where(a => !string.IsNullOrWhiteSpace(a.FileName)) - .Select(a => new CachedAttachmentItem - { - FileName = NormalizeCachedDisplayText(a.FileName), - IsImage = string.Equals(a.Type, "image", StringComparison.OrdinalIgnoreCase) - }) - .ToList(); - if (items.Count == 0) - return; - - lock (_gate) - { - if (_disposed) - return; - - if (expectedResetVersion is { } version && - GetResetVersionLocked(threadId) != version) + try { - return; + TryDispatchNextQueuedSend(threadId); } - - var key = !string.IsNullOrEmpty(sessionId) ? sessionId! : threadId; - if (!_attachmentMetaCache.TryGetValue(key, out var list)) + catch (Exception ex) { - list = new List(); - _attachmentMetaCache[key] = list; + Logger.Warn($"[Queue] Scheduled queued send drain failed for threadId='{threadId}': {ex.Message}"); } - - list.Add(new CachedAttachmentMeta - { - Ts = tsMs, - Text = NormalizeCachedDisplayText(TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(text))), - Attachments = items - }); - - if (list.Count > MaxAttachmentEntriesPerSession) - list.RemoveRange(0, list.Count - MaxAttachmentEntriesPerSession); - } - - SaveAttachmentMetaCache(); - } - - private AttachmentMetaMatcher CreateAttachmentMetaMatcher(string? sessionId, string threadId) - { - var entries = new List(); - lock (_gate) - { - if (!string.IsNullOrEmpty(sessionId) && - _attachmentMetaCache.TryGetValue(sessionId!, out var sessionEntries)) - entries.AddRange(CloneAttachmentMeta(sessionEntries)); - - if (!string.IsNullOrEmpty(threadId) && - (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) && - _attachmentMetaCache.TryGetValue(threadId, out var threadEntries)) - entries.AddRange(CloneAttachmentMeta(threadEntries)); - } - - return new AttachmentMetaMatcher(entries.OrderBy(e => e.Ts).ToList()); + }); } - private static List CloneAttachmentMeta(List entries) => - entries.Select(e => new CachedAttachmentMeta - { - Ts = e.Ts, - Text = NormalizeCachedDisplayText(e.Text), - Attachments = e.Attachments.Select(a => new CachedAttachmentItem - { - FileName = NormalizeCachedDisplayText(a.FileName), - IsImage = a.IsImage - }).ToList() - }).ToList(); - - private sealed class AttachmentMetaMatcher + private async Task FetchRemoteUserMessageAsync(string threadId, bool openResetGateOnSuccess = false) { - private static readonly TimeSpan MatchWindow = TimeSpan.FromHours(24); - private readonly List _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 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 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 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> 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}"); } } - /// - /// Cache a tool call's metadata so it can be recovered when the gateway - /// flattens it during history replay on a future app launch. - /// - 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(); - _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(); - } - - /// - /// Look up cached tool metadata for a session's history reconstruction. - /// Returns a queue of entries sorted by timestamp for sequential consumption. - /// - private Queue? GetCachedToolMetaForSession(string? sessionId, string threadId) - { - if (string.IsNullOrEmpty(sessionId) && string.IsNullOrEmpty(threadId)) return null; - lock (_gate) + catch (Exception ex) { - var entries = new List(); - 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(entries.OrderBy(e => e.Ts)); + Logger.Warn( + $"[ABORT-PERSIST] Failed to persist abort for thread {threadId}: {ex.Message}"); } - return null; - } - - /// - /// 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. - /// - internal static CachedToolMeta? TryMatchCachedTool(Queue? 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(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(out var incomingText)) - { - continue; - } + internal static string RepairContentBlockSeams(string? text) => ChatContentFormatting.RepairContentBlockSeams(text); - if (merged[key] is JsonValue existingValue - && existingValue.TryGetValue(out var existingText) - && !string.Equals(existingText, incomingText, StringComparison.Ordinal)) - { - var combined = existingText + "\n" + incomingText; - merged[key] = combined.Length > 512 ? combined[..509] + "..." : combined; - } - else - { - merged[key] = incomingText; - } - } - } - return merged.Count == 0 ? null : merged; - } + 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); - /// - /// After a successful abort, reload chat.history to capture the __openclaw.id - /// of the aborted user message and persist it for future sessions. - /// - 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(); - 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(); - _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; } } - /// Check if a user message's __openclaw.id is in the persisted aborted set. - 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(), + 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(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>>( + 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 _timelines", provider); + Assert.DoesNotContain("Dictionary> _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 _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 _versions", substates["ChatResetState.cs"]); + Assert.Contains("private long _connectionGeneration", substates["ChatHistoryState.cs"]); + Assert.Contains("private readonly Dictionary _revisions", substates["ChatHistoryState.cs"]); + Assert.Contains("private readonly Dictionary _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(_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? TimelineGenerations = null", models); - Assert.Contains("new Dictionary(_resetVersions)", provider); - Assert.Contains("TimelineGenerations: timelineGenerationsCopy", provider); + Assert.Contains("new Dictionary(_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> _queuedMessages", provider); - Assert.Contains("QueuedMessagesByThread: queuedMessagesCopy", provider); + Assert.Contains("Dictionary> _messages", queueState); + Assert.Contains("_messages.ToDictionary(", queueState); + Assert.Contains("_queue.SnapshotMessages()", state); + Assert.DoesNotContain("Dictionary> _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 @@ + @@ -49,6 +50,21 @@ + + + + + + + + + + + + + + + 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? AgentEventReceived; public event EventHandler? ModelsListUpdated; public bool IsDisposed { get; private set; } + public int DisposeCount { get; private set; } public EventHandler? 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 snapshots, List notifications) @@ -245,12 +250,13 @@ private static (FakeBridge bridge, OpenClawChatDataProvider provider, List, Task>? historyRetryScheduler = null, Action? historyFailureReservedForTesting = null, + Func, Task>? deferredAbortScheduler = null, Action? post = null) { var bridge = new FakeBridge { Sessions = initial ?? Array.Empty() }; var provider = toolMetaCachePath is null && attachmentMetaCachePath is null && lastChatStatePath is null && lastChatStateSaveDelay is null && historyRetryScheduler is null && historyFailureReservedForTesting is null && - 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(); var notifications = new List(); 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( + 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( + 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(() => + 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( + typeof(OpenClawChatDataProvider) + .GetField( + "_state", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .GetValue(provider)); + var queue = Assert.IsType( + typeof(ChatConversationState) + .GetField( + "_queue", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .GetValue(state)); + var lifecycle = Assert.IsType( + 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( + typeof(OpenClawChatDataProvider) + .GetField( + "_state", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .GetValue(provider)); + var reset = Assert.IsType( + typeof(ChatConversationState) + .GetField( + "_reset", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .GetValue(state)); + var queue = Assert.IsType( + 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( + typeof(OpenClawChatDataProvider) + .GetField( + "_state", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .GetValue(provider)); + var reset = Assert.IsType( + typeof(ChatConversationState) + .GetField( + "_reset", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .GetValue(state)); + var queue = Assert.IsType( + typeof(ChatConversationState) + .GetField( + "_queue", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic)! + .GetValue(state)); + var lifecycle = Assert.IsType( + 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? 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? 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>>( + Dictionary>>( 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(); + var snapshots = new List(); + 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>(); + 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>(); + 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( + 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 GetQueuedMessages(ChatDataSnapsh private static ISet 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>(field.GetValue(provider)); + var state = Assert.IsType(stateField?.GetValue(provider)); + var queueField = typeof(ChatConversationState).GetField( + "_queue", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var queue = Assert.IsType(queueField?.GetValue(state)); + var scheduledField = typeof(ChatQueueState).GetField( + "_drainScheduledThreads", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(scheduledField); + return Assert.IsAssignableFrom>(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>>(field.GetValue(provider)); - abortedIds[threadId] = [messageId]; + var persistence = + Assert.IsType(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( + loaderField?.GetValue(provider)); + var retriesField = typeof(ChatHistoryLoader).GetField( + "_retryCounts", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic); + var retries = Assert.IsAssignableFrom( + 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( + 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( + field?.GetValue(provider)); + } + + private static Dictionary + GetHistoryPendingReloads(ChatHistoryLoader loader) + { + var field = typeof(ChatHistoryLoader).GetField( + "_authoritativePending", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic); + return Assert.IsType< + Dictionary>( + field?.GetValue(loader)); + } + + private static Dictionary + GetHistoryRetryEntries(ChatHistoryLoader loader) + { + var field = typeof(ChatHistoryLoader).GetField( + "_retryCounts", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.NonPublic); + return Assert.IsType< + Dictionary>( + 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; /// 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(); - Assert.Null(OpenClawChatDataProvider.TryMatchCachedTool(cache, 1000)); + var cache = new Queue(); + Assert.Null(ChatMetadataStore.TryMatchCachedTool(cache, 1000)); } [Fact] public void TryMatch_SingleEntry_DequeuesAndReturns() { - var cache = new Queue(); + var cache = new Queue(); 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(); + var cache = new Queue(); 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(); + var cache = new Queue(); 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(); + var cache = new Queue(); 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(); + var cache = new Queue(); 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(); + var cache = new Queue(); 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(); + var cache = new Queue(); 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>>(json); + var cache = JsonSerializer.Deserialize>>(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>>(json); + var cache = JsonSerializer.Deserialize>>(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>>(json); + var cache = JsonSerializer.Deserialize>>(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>>( + var cache = JsonSerializer.Deserialize>>( 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>>( + var cache = JsonSerializer.Deserialize>>( File.ReadAllText(cachePath)); Assert.Collection( cache!["main"], @@ -355,7 +344,7 @@ public void CachedToolMeta_LegacyJsonWithoutScopeMigratesToNullRunAndZeroTurn() } """; - var entry = JsonSerializer.Deserialize(json); + var entry = JsonSerializer.Deserialize(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>>( + 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(); + var cache = new Queue(); 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>>( + var cache = JsonSerializer.Deserialize>>( 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>>(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>>( + 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>>( + 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>>(json); + var cache = JsonSerializer.Deserialize>>(json); Assert.NotEqual(initialJson, json); Assert.NotNull(cache);