From d0c2eea72e16bb9b3d3829729adb84c450acc316 Mon Sep 17 00:00:00 2001 From: Caleb Eden <58373773+calebeden@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:11:25 -0700 Subject: [PATCH 01/11] Add queued chat message UI Show pending chat sends in the composer and reconcile them with gateway message identity to avoid duplicate user bubbles. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Chat/ChatModels.cs | 17 +- src/OpenClaw.Chat/ChatTimelineReducer.cs | 6 + src/OpenClaw.Shared/Models.cs | 19 + src/OpenClaw.Shared/OpenClawGatewayClient.cs | 102 ++- .../Chat/ChatEntryMetadata.cs | 17 +- .../Chat/OpenClawChatDataProvider.cs | 597 ++++++++++++-- .../Chat/OpenClawChatRoot.cs | 26 +- .../Chat/OpenClawChatTimeline.cs | 13 +- .../Chat/OpenClawComposer.cs | 140 +++- .../Strings/en-us/Resources.resw | 18 + .../Strings/fr-fr/Resources.resw | 18 + .../Strings/nl-nl/Resources.resw | 18 + .../Strings/zh-cn/Resources.resw | 18 + .../Strings/zh-tw/Resources.resw | 18 + .../OpenClawGatewayClientTests.cs | 115 +++ ...ChatTimelineRenderIdentityContractTests.cs | 46 ++ .../OpenClawChatDataProviderTests.cs | 755 +++++++++++++++++- 17 files changed, 1831 insertions(+), 112 deletions(-) diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index 23b8cc31b..682243ce6 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -156,6 +156,20 @@ public record ChatTimelineState( System.Collections.Immutable.ImmutableDictionary.Empty); } +public enum ChatQueuedMessageSendState +{ + Sending, + Failed +} + +public record ChatQueuedMessage( + string Id, + string Text, + DateTimeOffset CreatedAt, + string LocalNonce, + ChatQueuedMessageSendState SendState = ChatQueuedMessageSendState.Sending, + string? ErrorText = null); + public record ChatHistoryPage(ChatEvent[] Events, int NextSince, int PrevBefore, bool HasMore); public abstract record ChatEvent; @@ -195,7 +209,8 @@ public record ChatDataSnapshot( IReadOnlyList? ModelChoices = null, IReadOnlyList? AvailableCommands = null, bool CommandsSupported = true, - IReadOnlyDictionary? TimelineGenerations = null); + IReadOnlyDictionary? TimelineGenerations = null, + IReadOnlyDictionary>? QueuedMessagesByThread = null); /// /// Describes where the UI may send the next chat message. Distinct from diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs index 94cca1789..6a83a5b1d 100644 --- a/src/OpenClaw.Chat/ChatTimelineReducer.cs +++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs @@ -58,6 +58,12 @@ public static ChatTimelineState AddLocalUser(ChatTimelineState state, string tex }; } + public static ChatTimelineState BeginLocalUserTurn(ChatTimelineState state) + { + state = ClearStreamingAtTurnBoundary(state); + return state with { TurnActive = true }; + } + // Hard turn boundary cleanup shared by both user-message entry points // (AddLocalUser for typed input, ApplyUserMessage for gateway-injected // events). Clears ActiveAssistantId/ActiveReasoningId and demotes any diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index cb3a77cff..07049059f 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1998,7 +1998,26 @@ public sealed class ChatSendResult { public string? RunId { get; init; } public string? SessionKey { get; init; } + public string? Status { get; init; } + public string? Error { get; init; } public bool Cached { get; init; } + + public bool IsTerminalFailure => IsFailureStatus(Status) || !string.IsNullOrWhiteSpace(Error); + + public static bool IsFailureStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + return false; + + return status.Equals("failed", StringComparison.OrdinalIgnoreCase) + || status.Equals("failure", StringComparison.OrdinalIgnoreCase) + || status.Equals("error", StringComparison.OrdinalIgnoreCase) + || status.Equals("rejected", StringComparison.OrdinalIgnoreCase) + || status.Equals("denied", StringComparison.OrdinalIgnoreCase) + || status.Equals("aborted", StringComparison.OrdinalIgnoreCase) + || status.Equals("cancelled", StringComparison.OrdinalIgnoreCase) + || status.Equals("canceled", StringComparison.OrdinalIgnoreCase); + } } // ── Node/Device Pairing ── diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 5d00b6604..1d5c139e6 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -527,16 +527,7 @@ private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sess else if (m.TryGetProperty("ts", out var tsProp2) && tsProp2.ValueKind == JsonValueKind.Number) ts = tsProp2.GetInt64(); - // __openclaw metadata: unique message ID + sequence number - string? openClawId = null; - int? openClawSeq = null; - if (m.TryGetProperty("__openclaw", out var oc) && oc.ValueKind == JsonValueKind.Object) - { - if (oc.TryGetProperty("id", out var ocId)) - openClawId = ocId.GetString(); - if (oc.TryGetProperty("seq", out var ocSeq) && ocSeq.ValueKind == JsonValueKind.Number) - openClawSeq = ocSeq.GetInt32(); - } + var (openClawId, openClawSeq) = ExtractOpenClawMetadata(m); // stopReason on assistant messages (e.g. "stop", "toolUse", possibly "abort") string? stopReason = null; @@ -1980,6 +1971,8 @@ private static ChatSendResult ParseChatSendResult(JsonElement root) { string? runId = null; string? sessionKey = null; + string? status = null; + string? error = null; var cached = false; if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object) @@ -1988,8 +1981,19 @@ private static ChatSendResult ParseChatSendResult(JsonElement root) runId = runIdProp.GetString(); if (payload.TryGetProperty("sessionKey", out var sessionKeyProp)) sessionKey = sessionKeyProp.GetString(); + if (payload.TryGetProperty("status", out var statusProp)) + status = statusProp.GetString(); + if (payload.TryGetProperty("error", out var payloadErrorProp)) + error = TryReadJsonValueAsString(payloadErrorProp); + else if (payload.TryGetProperty("message", out var payloadMessageProp) && ChatSendResult.IsFailureStatus(status)) + error = TryReadJsonValueAsString(payloadMessageProp); } + if (root.TryGetProperty("status", out var rootStatusProp)) + status ??= rootStatusProp.GetString(); + if (root.TryGetProperty("error", out var rootErrorProp)) + error ??= TryReadJsonValueAsString(rootErrorProp); + if (root.TryGetProperty("meta", out var meta) && meta.ValueKind == JsonValueKind.Object && meta.TryGetProperty("cached", out var cachedProp) && @@ -2002,10 +2006,21 @@ private static ChatSendResult ParseChatSendResult(JsonElement root) { RunId = runId, SessionKey = sessionKey, + Status = status, + Error = error, Cached = cached }; } + private static string? TryReadJsonValueAsString(JsonElement value) => + value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => value.ToString(), + JsonValueKind.Object when value.TryGetProperty("message", out var message) => TryReadJsonValueAsString(message), + _ => null + }; + private void HandleRequestError(string? method, JsonElement root) { var message = TryGetErrorMessage(root) ?? "request failed"; @@ -3071,7 +3086,20 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) if (string.IsNullOrEmpty(text)) return; if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return; - EmitChatMessageReceived(sessionKey, role, text, state, tsMs, inTok, outTok, respTok, ctxPct); + var (messageOpenClawId, messageOpenClawSeq) = ExtractOpenClawMetadata(message); + var (payloadOpenClawId, payloadOpenClawSeq) = ExtractOpenClawMetadata(payload); + EmitChatMessageReceived( + sessionKey, + role, + text, + state, + tsMs, + inTok, + outTok, + respTok, + ctxPct, + messageOpenClawId ?? payloadOpenClawId, + messageOpenClawSeq ?? payloadOpenClawSeq); if (role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase)) { @@ -3093,7 +3121,19 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) { if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return; - EmitChatMessageReceived(sessionKey, role, text, state, tsMs, inTok, outTok, respTok, ctxPct); + var (openClawId, openClawSeq) = ExtractOpenClawMetadata(payload); + EmitChatMessageReceived( + sessionKey, + role, + text, + state, + tsMs, + inTok, + outTok, + respTok, + ctxPct, + openClawId, + openClawSeq); if (role == "assistant") { @@ -3155,8 +3195,18 @@ JsonValueKind.Number when v.TryGetInt32(out var i) => i, return (input, output, response, ctx); } - private void EmitChatMessageReceived(string sessionKey, string role, string text, string? state, long tsMs, - int? inputTokens = null, int? outputTokens = null, int? responseTokens = null, int? contextPct = null) + private void EmitChatMessageReceived( + string sessionKey, + string role, + string text, + string? state, + long tsMs, + int? inputTokens = null, + int? outputTokens = null, + int? responseTokens = null, + int? contextPct = null, + string? openClawId = null, + int? openClawSeq = null) { if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return; @@ -3173,7 +3223,9 @@ private void EmitChatMessageReceived(string sessionKey, string role, string text InputTokens = inputTokens, OutputTokens = outputTokens, ResponseTokens = responseTokens, - ContextPercent = contextPct + ContextPercent = contextPct, + OpenClawId = openClawId, + OpenClawSeq = openClawSeq }); } catch (Exception ex) @@ -3182,6 +3234,26 @@ private void EmitChatMessageReceived(string sessionKey, string role, string text } } + private static (string? Id, int? Seq) ExtractOpenClawMetadata(JsonElement node) + { + if (node.ValueKind != JsonValueKind.Object || + !node.TryGetProperty("__openclaw", out var openClaw) || + openClaw.ValueKind != JsonValueKind.Object) + { + return (null, null); + } + + var id = openClaw.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String + ? idProp.GetString() + : null; + var seq = openClaw.TryGetProperty("seq", out var seqProp) && + seqProp.ValueKind == JsonValueKind.Number && + seqProp.TryGetInt32(out var parsedSeq) + ? parsedSeq + : (int?)null; + return (id, seq); + } + private static long ExtractChatTimestampMs(JsonElement node) { if (node.ValueKind != JsonValueKind.Object) diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs index bcd4e76ad..858c5585e 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs @@ -44,6 +44,18 @@ namespace OpenClawTray.Chat; /// Raw token contribution reported for this assistant response before it was /// converted into the displayed cumulative session snapshot. /// +/// +/// Gateway-assigned stable message id from __openclaw.id, when known. +/// Used to reconcile live entries with later chat.history rows. +/// +/// +/// Monotonic per-session sequence from __openclaw.seq, when known. +/// Prefer this over timestamps for transcript ordering and dedupe. +/// +/// +/// True for a locally queued user prompt promoted into the transcript before +/// gateway history has provided its stable id/sequence. +/// public sealed record ChatEntryMetadata( DateTimeOffset? Timestamp, string? Model, @@ -52,4 +64,7 @@ public sealed record ChatEntryMetadata( int? ResponseTokens = null, int? ContextPercent = null, long? ContextTokens = null, - int? UsageContributionTokens = null); + int? UsageContributionTokens = null, + string? GatewayMessageId = null, + int? OpenClawSeq = null, + bool IsLocalQueuedSend = false); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index b8df157b7..1a25898c2 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Collections.Concurrent; +using System.Globalization; using System.IO; using System.Linq; using System.Text.Encodings.Web; @@ -28,6 +29,8 @@ internal static class LocalizationHelper "Chat_Permission_Deny" => "Deny once", "Chat_Permission_CommandApprovalTitle" => "Command approval requested", "Chat_Permission_ResultSubmittedFormat" => "Approval {0} submitted for {1}.", + "Chat_Error_SendReturnedStatusFormat" => "Gateway returned send status '{0}'.", + "Chat_Error_SendFailedFormat" => "Send failed: {0}", _ => resourceKey }; } @@ -97,6 +100,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly Dictionary _timelines = new(); private readonly Dictionary _localInlineApprovals = new(StringComparer.Ordinal); 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) @@ -121,6 +125,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider 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. @@ -131,6 +136,10 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider // 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> _queuedMessageIdsByRunId = new(); + 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 @@ -140,7 +149,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly Dictionary _historyRetryCount = new(); private const int MaxHistoryRetries = 3; private static readonly TimeSpan LocalEchoSuppressionWindow = TimeSpan.FromSeconds(30); - private readonly record struct LocalSentText(string Text, DateTimeOffset SentAt); + private readonly record struct LocalSentText(string Text, DateTimeOffset SentAt, string QueuedMessageId); private sealed record LocalInlineApproval( string ThreadId, string RequestId, @@ -313,18 +322,24 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation : $"{safeUserText}\n{chips}"; } - // 1. Optimistically add the user message + flag turn active. + // 1. Queue the user message locally. It moves into the transcript only + // when the gateway echoes it or starts the accepted run, so the same + // text is never visible in both the composer queue and chat history. ChatDataSnapshot snapshot; string? sessionId; long sendResetVersion; long sendStartedLifecycleSequence; + long sendStartedRunStartSequence; + string queuedMessageId; lock (_gate) { sendResetVersion = GetResetVersionLocked(threadId); sendStartedLifecycleSequence = _resetLifecycleStartSequence; - var current = GetOrCreateTimelineLocked(threadId); - var beforeNextId = current.NextId; - _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, displayText, nonce); + sendStartedRunStartSequence = _lifecycleStartSequence; + queuedMessageId = $"q{++_queuedMessageSequence}"; + if (!HasSendingQueuedMessagesLocked(threadId)) + _assistantFallbackPromotedThreads.Remove(threadId); + _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(GetOrCreateTimelineLocked(threadId)); _sessionIds.TryGetValue(threadId, out sessionId); // Track this text so we can suppress the SSE echo. @@ -333,10 +348,16 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation q = new Queue(); _localSentTexts[threadId] = q; } - q.Enqueue(new LocalSentText(trimmed, DateTimeOffset.UtcNow)); + q.Enqueue(new LocalSentText(trimmed, DateTimeOffset.UtcNow, queuedMessageId)); // Cap at 20 to avoid unbounded growth. while (q.Count > 20) q.Dequeue(); + AddQueuedMessageLocked(threadId, new ChatQueuedMessage( + queuedMessageId, + displayText, + DateTimeOffset.UtcNow, + nonce)); + // 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. @@ -344,11 +365,6 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation _pendingAbortCounts.Remove(threadId); _locallyInitiatedThreads.Add(threadId); - // Capture metadata for the just-added user entry. - var meta = BuildLiveMetaLocked(threadId); - var threadMeta = GetOrCreateThreadMetaLocked(threadId); - threadMeta[$"e{beforeNextId}"] = meta; - snapshot = BuildSnapshotLocked(); } Publish(snapshot); @@ -358,7 +374,19 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation { await AwaitPendingModelPatchAsync(threadId, cancellationToken); var sendResult = await _bridge.SendChatMessageForRunAsync(trimmed, threadId, sessionId, attachments); + if (sendResult.IsTerminalFailure) + { + var failure = !string.IsNullOrWhiteSpace(sendResult.Error) + ? sendResult.Error! + : string.Format( + CultureInfo.CurrentCulture, + LocalizationHelper.GetString("Chat_Error_SendReturnedStatusFormat"), + sendResult.Status); + throw new InvalidOperationException(failure); + } + bool sendStillCurrent; + ChatDataSnapshot? acceptedRunAlreadyStartedSnapshot = null; lock (_gate) { sendStillCurrent = GetResetVersionLocked(threadId) == sendResetVersion; @@ -369,7 +397,16 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation } else if (!string.IsNullOrEmpty(sendResult.RunId)) { + TrackQueuedMessageRunLocked(threadId, sendResult.RunId!, queuedMessageId); AddResetAcceptedRunIdLocked(threadId, sendResult.RunId!); + if (_activeRunIds.TryGetValue(threadId, out var activeRunId) + && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence) + && string.Equals(activeRunId, sendResult.RunId, StringComparison.Ordinal) + && activeStartSequence > sendStartedRunStartSequence + && PromoteQueuedMessageLocked(threadId, queuedMessageId)) + { + acceptedRunAlreadyStartedSnapshot = BuildSnapshotLocked(); + } } else if (_resetAwaitingUserMessage.Contains(threadId)) { @@ -379,6 +416,9 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation } } + if (acceptedRunAlreadyStartedSnapshot is not null) + Publish(acceptedRunAlreadyStartedSnapshot); + if (hasAttachments && sendStillCurrent) CacheAttachmentMeta(sessionId, threadId, trimmed, attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), sendResetVersion); } @@ -392,10 +432,15 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation if (sendStillCurrent) { RemovePendingLocalEchoLocked(threadId, trimmed); - _locallyInitiatedThreads.Remove(threadId); + MarkQueuedMessageFailedLocked(threadId, queuedMessageId, ex.Message); + if (!HasSendingQueuedMessagesLocked(threadId)) + _locallyInitiatedThreads.Remove(threadId); failureSnapshot = ApplyEventLocked( threadId, - TruncateChatEvent(new ChatErrorEvent($"Send failed: {ex.Message}")), + TruncateChatEvent(new ChatErrorEvent(string.Format( + CultureInfo.CurrentCulture, + LocalizationHelper.GetString("Chat_Error_SendFailedFormat"), + ex.Message))), meta: null); failureSnapshot = ApplyEventLocked(threadId, new ChatTurnEndEvent(), meta: null); } @@ -404,8 +449,8 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation if (!sendStillCurrent) return; - // Surface as an error in the timeline + notification — keeps the - // user message visible so they can edit/retry. + // Surface as an error in the timeline + notification, while the + // failed queue card keeps the attempted text visible for retry/edit. Publish(failureSnapshot!); RaiseNotification(new ChatProviderNotification( ChatProviderNotificationKind.Error, threadId, LocalizationHelper.GetString("Chat_Notification_SendFailed"), ex.Message)); @@ -524,15 +569,14 @@ public async Task LoadHistoryAsync(string threadId, bool force = false, Cancella var prior = GetOrCreateTimelineLocked(threadId); var rebuilt = ChatTimelineState.Initial() with { HistoryLoaded = true }; - // Sort by timestamp ascending as a safety net — the gateway is - // expected to return chronological order, but don't trust it. - // Stable secondary sort preserves the original index for ties. - var ordered = history.Messages - .Select((m, i) => (m, i)) - .OrderBy(t => t.m.Ts) - .ThenBy(t => t.i) - .Select(t => t.m) + // 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(); @@ -580,7 +624,9 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr msg.InputTokens, msg.OutputTokens, msg.ResponseTokens, - msg.ContextPercent); + msg.ContextPercent, + GatewayMessageId: msg.OpenClawId, + OpenClawSeq: msg.OpenClawSeq); // Cap per-message text up front so heuristics, logging, // and the reducer all see the same bounded value @@ -792,13 +838,23 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr : 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); + var rebuiltLocalTextCounts = 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)); + IncrementCount(rebuiltLocalTextCounts, ContentKey(entry.Kind, entry.Text)); + if (em?.Timestamp is { } rts && rts != default) { var key = ContentKey(entry.Kind, entry.Text); @@ -827,6 +883,29 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr { 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 (em?.IsLocalQueuedSend == true && + TryConsumeCount(rebuiltLocalTextCounts, ContentKey(entry.Kind, entry.Text))) + { + ConsumeAnyTimestamp(rebuiltContentTimestamps, ContentKey(entry.Kind, entry.Text)); + skippedDup++; + continue; + } // Rule 2: content+timestamp dedup only when BOTH sides // have valid timestamps within 2 seconds. Otherwise @@ -837,10 +916,12 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr { var priorSec = pts.ToUnixTimeSeconds(); var matched = false; - foreach (var rebSec in rebuiltTimes) + for (var rebIndex = 0; rebIndex < rebuiltTimes.Count; rebIndex++) { + var rebSec = rebuiltTimes[rebIndex]; if (Math.Abs(rebSec - priorSec) <= 2) { + rebuiltTimes.RemoveAt(rebIndex); matched = true; break; } @@ -877,6 +958,12 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr 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)); + IncrementCount(rebuiltLocalTextCounts, ContentKey(entryToAdd.Kind, entryToAdd.Text)); + if (em is not null && !rebuiltMeta.ContainsKey(entryToAdd.Id)) rebuiltMeta[entryToAdd.Id] = em; } @@ -1492,6 +1579,9 @@ private void OnStatusChanged(object? sender, ConnectionStatus status) _historyInFlight.Clear(); _locallyInitiatedThreads.Clear(); _localSentTexts.Clear(); + _queuedMessages.Clear(); + _assistantFallbackPromotedThreads.Clear(); + _queuedMessageIdsByRunId.Clear(); _historyRetryCount.Clear(); // Reset keyless-event diagnostic so a fresh reconnect to a // still-broken gateway surfaces the notification again. @@ -1694,6 +1784,9 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) var role = message.Role ?? ""; var roleLower = role.ToLowerInvariant(); var rawText = message.Text ?? string.Empty; + ChatDataSnapshot? resetLocalEchoSnapshot = null; + var dropAfterReset = false; + var requestRemoteBackfillAfterReset = false; lock (_gate) { if (ShouldDropChatMessageAfterResetLocked( @@ -1704,31 +1797,38 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) out var consumeEchoText, out var requestRemoteBackfill)) { + dropAfterReset = true; + requestRemoteBackfillAfterReset = requestRemoteBackfill; if (consumeEchoText is not null && _localSentTexts.TryGetValue(msgThreadId, out var resetEchoQueue) && - resetEchoQueue.Count > 0) + resetEchoQueue.Count > 0 && + TryConsumeLocalEchoLocked(msgThreadId, resetEchoQueue, consumeEchoText, out var queuedMessageId) && + PromoteQueuedMessageLocked(msgThreadId, queuedMessageId)) { - TryConsumeLocalEchoLocked(msgThreadId, resetEchoQueue, consumeEchoText); + resetLocalEchoSnapshot = BuildSnapshotLocked(); } - - if (requestRemoteBackfill) - _ = FetchRemoteUserMessageAsync(msgThreadId, openResetGateOnSuccess: true); - - Logger.Debug($"[Reset] Dropping stale chat message after reset for threadId='{msgThreadId}' role='{roleLower}'"); - return; } - - if (_abortedThreads.Contains(msgThreadId)) + else if (_abortedThreads.Contains(msgThreadId)) { Logger.Debug($"[ABORT] Suppressed ChatMessage for threadId='{msgThreadId}' (role={message.Role})"); return; } } + if (dropAfterReset) + { + 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}'"); + return; + } // User messages from the SSE stream. System control notes are rendered - // as dim status entries. Normal user messages: suppress echoes of - // locally-sent messages (already displayed), show messages from other - // clients (e.g. gateway web UI) so the conversation is coherent. + // 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") { // Approval slash-commands ("/approve allow-once", @@ -1747,9 +1847,10 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) lock (_gate) { if (_localSentTexts.TryGetValue(msgThreadId, out var sq) && sq.Count > 0 - && TryConsumeLocalEchoLocked(msgThreadId, sq, slashEcho)) + && TryConsumeLocalEchoLocked(msgThreadId, sq, slashEcho, out var slashEntryId)) { weSentIt = true; + RemoveQueuedMessageLocked(msgThreadId, slashEntryId); } } if (weSentIt) @@ -1781,23 +1882,48 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) // 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)) + && TryConsumeLocalEchoLocked(msgThreadId, q, echoText, out var echoEntryId)) { isLocalEcho = true; + if (PromoteQueuedMessageLocked(msgThreadId, echoEntryId)) + echoSnapshot = BuildSnapshotLocked(); } } - if (isLocalEcho) return; + if (isLocalEcho) + { + if (echoSnapshot is not null) + Publish(echoSnapshot); + 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; - lock (_gate) { userMeta = BuildLiveMetaLocked(msgThreadId, message.Ts); } + 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) + { + Publish(reconciledLocalQueuedSnapshot); + return; + } + ApplyEventAndPublish(msgThreadId, - new ChatUserMessageEvent(TruncateForChatEntry(EscapeUntrustedAttachmentMarkerLines(message.Text))), + new ChatUserMessageEvent(userText), userMeta); } return; @@ -1811,7 +1937,14 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (string.IsNullOrEmpty(message.Text)) return; var trThread = message.SessionKey; ChatEntryMetadata? trMeta; - lock (_gate) { trMeta = BuildLiveMetaLocked(trThread, message.Ts); } + lock (_gate) + { + trMeta = BuildLiveMetaLocked( + trThread, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + } var capped = TruncateForChatEntry(message.Text); var kind = ClassifyFlattenedToolOutput(capped); var label = ExtractFlattenedToolSummary(capped); @@ -1828,13 +1961,18 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) return; var threadId = message.SessionKey; + PromoteOldestQueuedMessageBeforeAssistantIfNeeded(threadId); ChatEntryMetadata? meta; var cappedAssistantText = RepairContentBlockSeams(TruncateForChatEntry(message.Text)); 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); + meta = BuildLiveMetaLocked( + threadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); // 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. @@ -1935,6 +2073,7 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) // Always update run tracking first (state maintenance must not be skipped). UpdateActiveRunId(evt, threadId); + ClearQueuedMessageOnLocalTurnStart(evt, threadId); // Fire deferred chat.abort and persist if pending aborts were queued. var deferredRunId = _deferredAbortRunId; @@ -2107,6 +2246,7 @@ private void UpdateActiveRunId(AgentEventInfo evt, string threadId) if (phase == "start" && !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. @@ -2134,12 +2274,18 @@ private void UpdateActiveRunId(AgentEventInfo evt, string threadId) 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 — this turn is done. - _locallyInitiatedThreads.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. + if (!HasSendingQueuedMessagesLocked(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 @@ -2166,13 +2312,15 @@ private void UpdateActiveRunId(AgentEventInfo evt, string threadId) { _abortedRunIds.Remove(evt.RunId); _activeRunIds.Remove(threadId); + _activeRunStartSequences.Remove(threadId); } } } } - private bool TryConsumeLocalEchoLocked(string threadId, Queue queue, string text) + 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(); @@ -2183,15 +2331,217 @@ private bool TryConsumeLocalEchoLocked(string threadId, Queue que return false; } - if (!string.Equals(queue.Peek().Text, text, StringComparison.Ordinal)) + var kept = new Queue(queue.Count); + var matched = false; + while (queue.Count > 0) + { + var pending = queue.Dequeue(); + if (!matched && string.Equals(pending.Text, text, StringComparison.Ordinal)) + { + queuedMessageId = pending.QueuedMessageId; + matched = true; + continue; + } + + kept.Enqueue(pending); + } + + if (!matched) + { + StoreLocalEchoQueueLocked(threadId, kept); return false; + } - queue.Dequeue(); + 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 }; + 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 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); + if (list.Count == 0) + _queuedMessages.Remove(threadId); + return removed; + } + + private bool PromoteQueuedMessageLocked(string threadId, string messageId) + { + 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 meta = BuildLiveMetaLocked(threadId, isLocalQueuedSend: true); + var threadMeta = GetOrCreateThreadMetaLocked(threadId); + threadMeta[entryId] = meta; + + list.RemoveAt(index); + _assistantFallbackPromotedThreads.Add(threadId); + RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId); + if (list.Count == 0) + _queuedMessages.Remove(threadId); 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 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 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.Remove(runId, out var queuedMessageId)) + { + var removed = PromoteQueuedMessageLocked(threadId, queuedMessageId); + if (byRunId.Count == 0) + _queuedMessageIdsByRunId.Remove(threadId); + return removed; + } + + if (string.IsNullOrEmpty(runId) && + TryGetSingleSendingQueuedMessageLocked(threadId, out var queued)) + { + return PromoteQueuedMessageLocked(threadId, queued.Id); + } + + return false; + } + private void RemovePendingLocalEchoLocked(string threadId, string text) { if (!_localSentTexts.TryGetValue(threadId, out var queue)) @@ -2283,7 +2633,11 @@ private async Task FetchRemoteUserMessageAsync(string threadId, bool openResetGa TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null); } - var meta = BuildLiveMetaLocked(threadId, lastUser.Ts); + var meta = BuildLiveMetaLocked( + threadId, + lastUser.Ts, + lastUser.OpenClawId, + lastUser.OpenClawSeq); snapshotToPublish = ApplyEventLocked( threadId, new ChatUserMessageEvent(TruncateForChatEntry(lastUser.Text)), @@ -3557,10 +3911,14 @@ private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string 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); + _queuedMessageIdsByRunId.Remove(threadId); + _assistantFallbackPromotedThreads.Remove(threadId); _resetAcceptedRunIds.Remove(threadId); _resetLocalSendWithoutRunVersions.Remove(threadId); _resetLocalSendWithoutRunStartSequences.Remove(threadId); @@ -3631,7 +3989,7 @@ private bool ShouldDropChatMessageAfterResetLocked( if (isFreshUser && _localSentTexts.TryGetValue(threadId, out var echoQueue) && echoQueue.Count > 0 && - string.Equals(echoQueue.Peek().Text, rawText.Trim(), StringComparison.Ordinal)) + echoQueue.Any(pending => string.Equals(pending.Text, rawText.Trim(), StringComparison.Ordinal))) { consumeEchoText = rawText.Trim(); _resetLocalEchoSequences[threadId] = _resetLifecycleStartSequence; @@ -3652,6 +4010,58 @@ private bool ShouldDropChatMessageAfterResetLocked( 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 bool HasSendingQueuedMessagesLocked(string threadId) + => _queuedMessages.TryGetValue(threadId, out var queued) && + queued.Any(message => message.SendState == 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 (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) { if (IsResetIgnoredRunLocked(threadId, evt.RunId, evt)) @@ -3843,6 +4253,7 @@ private void OpenResetGateForLifecycleStartLocked(string threadId, AgentEventInf if (!string.IsNullOrEmpty(evt.RunId)) { _activeRunIds[threadId] = evt.RunId; + _activeRunStartSequences[threadId] = ++_lifecycleStartSequence; if (_resetAcceptedRunIds.TryGetValue(threadId, out var acceptedRunIds)) { acceptedRunIds.Remove(evt.RunId); @@ -4017,13 +4428,77 @@ private string ResolveTimelineKeyForSessionLocked(SessionInfo session) private static int? ToIntIfPositive(long value) => value > 0 && value <= int.MaxValue ? (int)value : null; - private ChatEntryMetadata BuildLiveMetaLocked(string threadId, long? tsMs = null) + private ChatEntryMetadata BuildLiveMetaLocked( + string threadId, + long? tsMs = null, + string? gatewayMessageId = null, + int? openClawSeq = null, + bool isLocalQueuedSend = false) { 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); + return new ChatEntryMetadata( + ts, + session?.Model, + GatewayMessageId: gatewayMessageId, + OpenClawSeq: openClawSeq, + IsLocalQueuedSend: isLocalQueuedSend); + } + + 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) @@ -4092,9 +4567,9 @@ private ChatDataSnapshot BuildSnapshotLocked() && _sessionsListReceived; // If the compose target hasn't materialized as a real session yet but - // already has an optimistic timeline (because the user sent a message + // 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 optimistic bubble without + // 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 @@ -4102,7 +4577,9 @@ private ChatDataSnapshot BuildSnapshotLocked() if (composeReady && composeKey is { } ck && _timelines.TryGetValue(ck, out var pendingTl) - && pendingTl.Entries.Count > 0 + && (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 @@ -4137,6 +4614,9 @@ private ChatDataSnapshot BuildSnapshotLocked() // Snapshot a defensive copy of the timeline dict. var timelinesCopy = new Dictionary(_timelines); var timelineGenerationsCopy = new Dictionary(_resetVersions); + var queuedMessagesCopy = _queuedMessages.ToDictionary( + kvp => kvp.Key, + kvp => (IReadOnlyList)kvp.Value.ToArray()); var defaultThreadId = ResolveDefaultThreadIdLocked(); @@ -4173,7 +4653,8 @@ private ChatDataSnapshot BuildSnapshotLocked() // surfaces the unsupported state. AvailableCommands: _commandCatalog?.Commands, CommandsSupported: _commandCatalog?.IsSupported ?? true, - TimelineGenerations: timelineGenerationsCopy); + TimelineGenerations: timelineGenerationsCopy, + QueuedMessagesByThread: queuedMessagesCopy); } private string? ResolveDefaultThreadIdLocked() diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index a1349aa0f..232846bee 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -118,6 +118,7 @@ public override Element Render() var scrollToBottomToken = UseState(0, threadSafe: true); var showToolCalls = UseState(s_showToolCalls, threadSafe: true); var toolCallsCollapseVersion = UseState(s_toolCallsCollapseVersion, threadSafe: true); + var chatSurfaceHeight = UseState(null, threadSafe: true); // Guards a duplicate suggestion-button click before the snapshot // reflects the optimistic local user entry (which then ordinarily // hides the zero-state buttons via the isEmptyConversation check). @@ -293,6 +294,13 @@ Element BuildLoadingElement() { timelineGeneration = generation; } + var queuedMessages = Array.Empty(); + if (effectiveThread is not null + && snapshot.QueuedMessagesByThread is { } queuedByThread + && queuedByThread.TryGetValue(effectiveThread.Id, out var queuedForThread)) + { + queuedMessages = queuedForThread.ToArray(); + } var entries = (IReadOnlyList)timeline.Entries; var connectedRaw = snapshot.ConnectionStatus; @@ -578,6 +586,7 @@ Element BuildLoadingElement() OnVoiceRequest: _onVoiceRequest, OnAttachClick: _onAttachClick, PendingAttachments: pendingAttachments.Value, + QueuedMessages: queuedMessages, OnAttachmentRemoved: attachment => SetPendingAttachments(RemoveAttachment(pendingAttachmentsRef.Current, attachment)), IsSpeakerMuted: speakerMuted.Value, OnSpeakerToggle: () => @@ -602,17 +611,30 @@ Element BuildLoadingElement() IsCompact: _isCompact, AvailableCommands: snapshot.AvailableCommands, CommandsSupported: snapshot.CommandsSupported, - OnCommandsRequested: () => RunFireAndForget(ct => _provider.EnsureCommandCatalogAsync(ct)))) + OnCommandsRequested: () => RunFireAndForget(ct => _provider.EnsureCommandCatalogAsync(ct)), + AvailableHeight: chatSurfaceHeight.Value)) : (bodyIsSkeleton ? RenderSkeletonComposer() : Empty()); var divider = Empty(); // Composer absorbs the old StatusBar. + void SetChatSurfaceHeight(double height) + { + if (double.IsNaN(height) || double.IsInfinity(height) || height <= 0) + return; + + chatSurfaceHeight.Set(Math.Round(height)); + } + return Grid([GridSize.Star()], [GridSize.Auto, GridSize.Auto, GridSize.Star(), GridSize.Auto], header.Grid(row: 0, column: 0), divider.Grid(row: 1, column: 0), body.Grid(row: 2, column: 0), composer.Grid(row: 3, column: 0) - ); + ).OnMount(root => + { + SetChatSurfaceHeight(root.ActualHeight); + root.SizeChanged += (_, e) => SetChatSurfaceHeight(e.NewSize.Height); + }); } // Cheap allocation-free probe for "does any group contain a session with diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index c5f041a5b..ffcc47817 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -1145,6 +1145,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst // bubbleRadius wraps both so they read as one message. var bubbleChildren = new List(); foreach (var ae in attachmentElements) bubbleChildren.Add(ae); + var entryMeta = MetaFor(entry.Id); if (hasMessage) { // Resolve HC + selection brush once per render method call @@ -1196,7 +1197,6 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst ApplyPlainSelectableInlines(t, messageText); })); } - Element content; if (bubbleChildren.Count > 0) { @@ -1239,7 +1239,6 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst Element footer = Empty(); if (endsBurst && showTimestamps) { - var entryMeta = MetaFor(entry.Id); var timeStr = FormatTime(entryMeta?.Timestamp); var rightInset = showUserAvatar ? (36 + bubbleSideMargin) : 0; rightInset += (int)bubblePadding.Right; @@ -1300,6 +1299,7 @@ Element RenderAssistantEntry( // collapsed multi-step summary) is rendered INSIDE the bubble's // content area — directly below the assistant text with a small // top gap — so it visually reads as a child of the bubble. + var assistantEntryMeta = MetaFor(entry.Id); Element bubbleContent = overrideBubbleContent ?? SafeMarkdownText(entry.Text); if (nestedTool != null) { @@ -1332,12 +1332,11 @@ Element RenderAssistantEntry( Element footer = Empty(); if (endsBurst && showTimestamps && !suppressFooter) { - var entryMeta = MetaFor(entry.Id); - var timeStr = FormatTime(entryMeta?.Timestamp); - var modelStr = entryMeta?.Model ?? defaultModel; + var timeStr = FormatTime(assistantEntryMeta?.Timestamp); + var modelStr = assistantEntryMeta?.Model ?? defaultModel; footer = BuildAssistantFooter(assistantSender, timeStr, modelStr, - entryMeta?.InputTokens, entryMeta?.OutputTokens, - entryMeta?.ResponseTokens, entryMeta?.ContextPercent, + assistantEntryMeta?.InputTokens, assistantEntryMeta?.OutputTokens, + assistantEntryMeta?.ResponseTokens, assistantEntryMeta?.ContextPercent, chatStampFg, entry.Id, entry.Text ?? "", entry.Id == latestAssistantEntryId ? Props.DefaultUsageSummary : null); var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0; diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 683898762..e47b3961c 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -10,6 +10,7 @@ using OpenClawTray.FunctionalUI.Core; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using Windows.UI; @@ -58,6 +59,7 @@ public record OpenClawComposerProps( Func>? OnVoiceRequest = null, Action? OnAttachClick = null, IReadOnlyList? PendingAttachments = null, + IReadOnlyList? QueuedMessages = null, Action? OnAttachmentRemoved = null, bool IsSpeakerMuted = false, Action? OnSpeakerToggle = null, @@ -73,10 +75,17 @@ public record OpenClawComposerProps( Action? OnModelCleared = null, IReadOnlyList? AvailableCommands = null, bool CommandsSupported = true, - Action? OnCommandsRequested = null); + Action? OnCommandsRequested = null, + double? AvailableHeight = null); public sealed class OpenClawComposer : Component { + private const double CompactQueuedMessagesMaxHeight = 144; + private const double ExpandedQueuedMessagesFallbackMaxHeight = 220; + private const double ExpandedQueuedMessagesMinHeight = 144; + private const double ExpandedQueuedMessagesMaxHeight = 280; + private const double ExpandedQueuedMessagesHeightRatio = 0.28; + // Distinct reference-equality sentinel used as the ComboBoxItem.Tag for the // "Default" (clear model override) row, so it can never collide with a real // model id string. Selecting it routes to OnModelCleared (tri-state clear) @@ -142,6 +151,7 @@ public override Element Render() // only when an attachment is added or removed (not on every render). var attachmentImagesRef = UseRef>(new()); var pendingAttachments = Props.PendingAttachments ?? Array.Empty(); + var queuedMessages = Props.QueuedMessages ?? Array.Empty(); var imageCache = attachmentImagesRef.Current; foreach (var cachedAttachment in imageCache.Keys.ToArray()) { @@ -902,6 +912,111 @@ Element BuildAttachmentPreview(ChatAttachment att) // Composer "card" — wraps the attachment preview (if any) and the // textbox in a single bordered container so the preview reads as // content inside the chat input rather than a separate row. + Element RenderQueuedMessages() + { + if (queuedMessages.Count == 0) + return Empty(); + + var cardBg = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["CardBackgroundFillColorDefaultBrush"]; + var cardBorder = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["ControlStrokeColorDefaultBrush"]; + var labelFg = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorSecondaryBrush"]; + var textFg = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["TextFillColorPrimaryBrush"]; + var failureFg = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["SystemFillColorCriticalBrush"]; + var queuedCountText = string.Format( + CultureInfo.CurrentCulture, + LocalizationHelper.GetString("Chat_Composer_QueuedCountFormat"), + queuedMessages.Count); + + Element RenderQueuedCard(ChatQueuedMessage message) + { + var failed = message.SendState == ChatQueuedMessageSendState.Failed; + var automationName = string.Format( + CultureInfo.CurrentCulture, + LocalizationHelper.GetString(failed + ? "Chat_Composer_QueuedMessageFailedAutomationFormat" + : "Chat_Composer_QueuedMessageAutomationFormat"), + message.Text); + + Element stateLabel = Empty(); + Element details = Empty(); + if (failed) + { + stateLabel = TextBlock(LocalizationHelper.GetString("Chat_Composer_QueuedMessageFailed")) + .Set(t => + { + t.FontSize = 12; + t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold; + t.Foreground = failureFg; + }); + + if (!string.IsNullOrWhiteSpace(message.ErrorText)) + { + details = TextBlock(message.ErrorText!) + .Set(t => + { + t.TextWrapping = TextWrapping.Wrap; + t.FontSize = 12; + t.Foreground = failureFg; + }); + } + } + + return Border( + VStack(6, + stateLabel, + TextBlock(message.Text) + .Set(t => + { + t.TextWrapping = TextWrapping.Wrap; + t.IsTextSelectionEnabled = true; + t.Foreground = textFg; + }), + details + ) + ) + .Background(cardBg) + .CornerRadius(8) + .Padding(10, 8, 10, 8) + .HAlign(HorizontalAlignment.Stretch) + .AutomationName(automationName) + .WithKey($"composer-queued:{Props.ChannelId ?? "none"}:{message.Id}") + .Set(b => + { + b.BorderBrush = cardBorder; + b.BorderThickness = new Thickness(1); + }); + } + + var queuedList = ScrollView(VStack(8, queuedMessages.Select(RenderQueuedCard).ToArray())) + .Set(sv => + { + sv.MaxHeight = ComputeQueuedMessagesMaxHeight(Props.IsCompact, Props.AvailableHeight); + sv.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + sv.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + sv.HorizontalScrollMode = ScrollMode.Disabled; + sv.HorizontalContentAlignment = HorizontalAlignment.Stretch; + }); + + return Border( + VStack(8, + TextBlock(queuedCountText) + .Set(t => + { + t.FontSize = 13; + t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold; + t.Foreground = labelFg; + }), + queuedList + ) + ) + .Padding(0, 0, 0, 0) + .AutomationName(queuedCountText) + .LiveRegion(Microsoft.UI.Xaml.Automation.Peers.AutomationLiveSetting.Polite) + .WithKey($"composer-queued-section:{Props.ChannelId ?? "none"}"); + } + + var queuedPanel = RenderQueuedMessages(); + var composerInput = Border( VStack(0, attachmentPreview, textbox) ).Set(b => @@ -1215,8 +1330,11 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = // history records every approval (and its decided/expired badge) // in chronological order. See OpenClawChatTimeline.RenderPermissionEntry. - var actionsRow = Grid([GridSize.Star(), GridSize.Auto], [GridSize.Auto], - Empty().Grid(row: 0, column: 0), + var bottomToolbar = Grid([GridSize.Star(), GridSize.Auto], [GridSize.Auto], + dropdownsRow + .Margin(0, 0, 12, 0) + .HAlign(HorizontalAlignment.Stretch) + .Grid(row: 0, column: 0), (FlexRow(attachBtn, voiceCancelBtn, voiceBtn, speakerBtn, toolToggleBtn, actionBtn, stopBtn) with { ColumnGap = 4 }) .HAlign(HorizontalAlignment.Right) @@ -1226,7 +1344,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = // ── Optional working banner above the composer ── Element workingBanner2 = workingBanner; - var composerCore = VStack(8, dropdownsRow, composerInput, voiceIndicator, actionsRow.Margin(0, -8, 0, -4)); + var composerCore = VStack(8, queuedPanel, composerInput, voiceIndicator, bottomToolbar.Margin(0, -8, 0, -4)); // Drive the floating slash-menu popup after the tree builds so it anchors // above the (already mounted) textbox without shifting any controls. @@ -1246,6 +1364,20 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = ); } + private static double ComputeQueuedMessagesMaxHeight(bool isCompact, double? availableHeight) + { + if (isCompact) + return CompactQueuedMessagesMaxHeight; + + if (availableHeight is not { } height || double.IsNaN(height) || double.IsInfinity(height) || height <= 0) + return ExpandedQueuedMessagesFallbackMaxHeight; + + return Math.Clamp( + Math.Round(height * ExpandedQueuedMessagesHeightRatio), + ExpandedQueuedMessagesMinHeight, + ExpandedQueuedMessagesMaxHeight); + } + private const int SlashMenuMaxItems = 8; /// diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index c402039e9..a332f9b8b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -3050,6 +3050,18 @@ Use one of these options: Assistant is working… + + Queued ({0}) + + + Queued message: {0} + + + Queued message failed: {0} + + + Failed + Copy message @@ -3312,6 +3324,12 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo Send failed + + Gateway returned send status '{0}'. + + + Send failed: {0} + Abort failed diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index e66a9ff84..daacb46bb 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -3003,6 +3003,18 @@ Utilisez l'une de ces options : L'assistant travaille… + + En file d’attente ({0}) + + + Message en file d’attente : {0} + + + Échec du message en file d’attente : {0} + + + Échec + Copier le message @@ -3265,6 +3277,12 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Échec de l'envoi + + La passerelle a renvoyé l’état d’envoi « {0} ». + + + Échec de l’envoi : {0} + Échec de l'abandon diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 81077321d..027effb4e 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -3004,6 +3004,18 @@ Gebruik een van deze opties: Assistent is bezig… + + In wachtrij ({0}) + + + Bericht in wachtrij: {0} + + + Bericht in wachtrij mislukt: {0} + + + Mislukt + Bericht kopiëren @@ -3266,6 +3278,12 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Verzenden mislukt + + Gateway heeft verzendstatus '{0}' geretourneerd. + + + Verzenden mislukt: {0} + Afbreken mislukt diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 02418c712..cb67066e2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -3003,6 +3003,18 @@ 助手正在工作… + + 已排队 ({0}) + + + 已排队消息:{0} + + + 已排队消息失败:{0} + + + 失败 + 复制消息 @@ -3265,6 +3277,12 @@ 发送失败 + + 网关返回发送状态“{0}”。 + + + 发送失败:{0} + 中止失败 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index e774c42e9..daf97d636 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -3003,6 +3003,18 @@ 助理處理中… + + 已排入佇列 ({0}) + + + 已排入佇列的訊息:{0} + + + 已排入佇列的訊息失敗:{0} + + + 失敗 + 複製訊息 @@ -3265,6 +3277,12 @@ 傳送失敗 + + 閘道傳回傳送狀態「{0}」。 + + + 傳送失敗:{0} + 中止失敗 diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 1008e1560..06eab7cd6 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -133,6 +133,15 @@ public ChatHistoryInfo ParseChatHistoryPayload(string payloadJson, string sessio return (ChatHistoryInfo)method!.Invoke(null, new object[] { document.RootElement.Clone(), sessionKey })!; } + public ChatSendResult ParseChatSendResponse(string responseJson) + { + using var document = JsonDocument.Parse(responseJson); + var method = typeof(OpenClawGatewayClient).GetMethod( + "ParseChatSendResult", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + return (ChatSendResult)method!.Invoke(null, new object[] { document.RootElement.Clone() })!; + } + public long ExtractChatTimestampMs(string payloadJson) { using var document = JsonDocument.Parse(payloadJson); @@ -821,6 +830,38 @@ public void ProcessRawMessage_SessionMessageWithStringContent_EmitsChatMessage() Assert.Equal(1781631273567, received.Ts); } + [Fact] + public void ProcessRawMessage_SessionMessageWithOpenClawMetadata_EmitsMessageIdentity() + { + var helper = new GatewayClientTestHelper(); + ChatMessageInfo? received = null; + helper.Client.ChatMessageReceived += (_, message) => received = message; + + helper.ProcessRawMessage(""" + { + "type": "event", + "event": "session.message", + "payload": { + "sessionKey": "main", + "message": { + "role": "user", + "content": "queued spam message", + "timestamp": 1781631273567, + "__openclaw": { + "id": "msg-user-a", + "seq": 42 + } + }, + "state": "final" + } + } + """); + + Assert.NotNull(received); + Assert.Equal("msg-user-a", received!.OpenClawId); + Assert.Equal(42, received.OpenClawSeq); + } + [Fact] public void ProcessRawMessage_SessionMessageWithContentBlocks_EmitsChatMessage() { @@ -929,6 +970,32 @@ public void ParseChatHistoryPayload_AssistantNoReply_DropsTranscriptEntry() Assert.Equal(["before", "visible reply"], history.Messages.Select(m => m.Text).ToArray()); } + [Fact] + public void ParseChatHistoryPayload_OpenClawMetadata_PreservesMessageIdentity() + { + var helper = new GatewayClientTestHelper(); + + var history = helper.ParseChatHistoryPayload(""" + { + "messages": [ + { + "role": "user", + "content": "a", + "timestamp": 1, + "__openclaw": { + "id": "msg-history-a", + "seq": 7 + } + } + ] + } + """); + + var message = Assert.Single(history.Messages); + Assert.Equal("msg-history-a", message.OpenClawId); + Assert.Equal(7, message.OpenClawSeq); + } + [Fact] public void ProcessRawMessage_SessionMessageWithMalformedMessage_DropsFrame() { @@ -1226,6 +1293,54 @@ public async Task PendingChatSend_CompletesOnSuccessfulResponse() Assert.Equal("run-1", result.RunId); } + [Fact] + public void ParseChatSendResponse_ReadsQueueAckStatus() + { + var helper = new GatewayClientTestHelper(); + + var result = helper.ParseChatSendResponse(""" + { + "type": "res", + "id": "chat-1", + "ok": true, + "payload": { + "runId": "run-1", + "sessionKey": "main", + "status": "started" + }, + "meta": { "cached": true } + } + """); + + Assert.Equal("run-1", result.RunId); + Assert.Equal("main", result.SessionKey); + Assert.Equal("started", result.Status); + Assert.True(result.Cached); + Assert.False(result.IsTerminalFailure); + } + + [Fact] + public void ParseChatSendResponse_ReadsTerminalFailureStatus() + { + var helper = new GatewayClientTestHelper(); + + var result = helper.ParseChatSendResponse(""" + { + "type": "res", + "id": "chat-1", + "ok": true, + "payload": { + "status": "failed", + "error": { "message": "model unavailable" } + } + } + """); + + Assert.Equal("failed", result.Status); + Assert.Equal("model unavailable", result.Error); + Assert.True(result.IsTerminalFailure); + } + [Fact] public async Task PendingChatSend_FailsOnErrorResponse() { diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs index 473a197c6..a55adaa9a 100644 --- a/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatTimelineRenderIdentityContractTests.cs @@ -43,6 +43,52 @@ public void TimelineGeneration_FlowsFromProviderSnapshotToTimelineProps() Assert.Contains("TimelineGeneration: timelineGeneration", root); } + [Fact] + public void QueuedMessages_RenderInComposerAboveInput() + { + var models = Read("src", "OpenClaw.Chat", "ChatModels.cs"); + var provider = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatDataProvider.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("snapshot.QueuedMessagesByThread", root); + Assert.Contains("QueuedMessages: queuedMessages", root); + Assert.Contains("IReadOnlyList? QueuedMessages = null", composer); + Assert.Contains("AvailableHeight: chatSurfaceHeight.Value", root); + Assert.Contains("queuedPanel", composer); + Assert.Contains("composerInput", composer); + Assert.Contains("RenderQueuedMessages", composer); + Assert.Contains("Chat_Composer_QueuedCountFormat", composer); + Assert.Contains("Chat_Composer_QueuedMessageAutomationFormat", composer); + Assert.Contains("Chat_Composer_QueuedMessageFailedAutomationFormat", composer); + Assert.Contains("Chat_Composer_QueuedMessageFailed", composer); + Assert.Contains("composer-queued-section:", composer); + Assert.Contains("ComputeQueuedMessagesMaxHeight(Props.IsCompact, Props.AvailableHeight)", composer); + Assert.Contains("chatSurfaceHeight.Set(Math.Round(height))", root); + Assert.DoesNotContain("FormatQueuedTime", composer); + Assert.DoesNotContain("\"Sending\"", composer); + Assert.DoesNotContain("QueuedMessages", timeline); + Assert.DoesNotContain("queued-section:", timeline); + Assert.DoesNotContain("Steer", composer); + } + + [Fact] + public void Timeline_DoesNotRenderTemporaryDebugMetadata() + { + var timeline = Read("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatTimeline.cs"); + + Assert.DoesNotContain("BuildDebugMetadata", timeline); + Assert.DoesNotContain("DEBUG kind=", timeline); + Assert.DoesNotContain("rowGen=", timeline); + Assert.DoesNotContain("localQueued=", timeline); + Assert.DoesNotContain("textHash=", timeline); + } + [Fact] public void ResetClearPath_BumpsTimelineGenerationBeforeReusingEntryIds() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 14e4d3810..d1c051a76 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -175,7 +175,7 @@ public async Task LoadAsync_CarriesSessionModelProviderToThreads() } [Fact] - public async Task SendMessageAsync_AddsLocalUserEntryBeforeAwaitingGateway() + public async Task SendMessageAsync_QueuesWithoutTranscriptEntryBeforeAwaitingGateway() { var tcs = new TaskCompletionSource(); var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); @@ -189,9 +189,11 @@ public async Task SendMessageAsync_AddsLocalUserEntryBeforeAwaitingGateway() Assert.Single(snapshots); var timeline = snapshots[0].Timelines["main"]; Assert.True(timeline.TurnActive); - Assert.Single(timeline.Entries); - Assert.Equal(ChatTimelineItemKind.User, timeline.Entries[0].Kind); - Assert.Equal("Hello", timeline.Entries[0].Text); + Assert.Empty(timeline.Entries); + var queued = GetQueuedMessages(snapshots[0], "main"); + var queuedMessage = Assert.Single(queued); + Assert.Equal("Hello", queuedMessage.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, queuedMessage.SendState); Assert.Single(bridge.SentMessages); Assert.Equal("Hello", bridge.SentMessages[0]); Assert.Equal("main", bridge.SentSessionKeys[0]); @@ -200,6 +202,222 @@ public async Task SendMessageAsync_AddsLocalUserEntryBeforeAwaitingGateway() await sendTask; } + [Fact] + public async Task SendMessageAsync_KeepsQueuedMessageAfterAckUntilLifecycleStart() + { + var tcs = new TaskCompletionSource(); + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendBehavior = (_, _, _) => tcs.Task; + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + await provider.LoadAsync(); + snapshots.Clear(); + + var sendTask = provider.SendMessageAsync("main", "Hello queue"); + + var pending = Assert.Single(GetQueuedMessages(snapshots[0], "main")); + Assert.Equal("Hello queue", pending.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, pending.SendState); + + tcs.SetResult(); + await sendTask; + + var acked = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("Hello queue", acked.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, acked.SendState); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + var entry = Assert.Single(snapshots[^1].Timelines["main"].Entries); + Assert.Equal(ChatTimelineItemKind.User, entry.Kind); + Assert.Equal("Hello queue", entry.Text); + AssertNoQueuedTranscriptDuplicate(snapshots, "main", "Hello queue"); + } + + [Fact] + public async Task SendMessageAsync_AckDoesNotQueueStaleSnapshotThatCanResurrectClearedQueuedMessage() + { + var bridge = new FakeBridge { Sessions = new[] { MainSession() } }; + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + var posted = new List(); + var provider = new OpenClawChatDataProvider(bridge, post: posted.Add); + var snapshots = new List(); + provider.Changed += (_, e) => snapshots.Add(e.Snapshot); + await provider.LoadAsync(); + posted.Clear(); + + await provider.SendMessageAsync("main", "queued prompt"); + Assert.Single(posted); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + + foreach (var action in posted) + action(); + + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "queued prompt"); + } + + [Fact] + public async Task LifecycleStart_DoesNotClearNextQueuedMessageWhenEarlierEchoAlreadyClearedItsCard() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "first queued"); + await provider.SendMessageAsync("main", "second queued"); + + Assert.Collection( + GetQueuedMessages(snapshots[^1], "main"), + first => Assert.Equal("first queued", first.Text), + second => Assert.Equal("second queued", second.Text)); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "first queued", + State = "final" + }); + + var queuedAfterEcho = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second queued", queuedAfterEcho.Text); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "first queued"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + + var queuedAfterFirstStart = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second queued", queuedAfterFirstStart.Text); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); + + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second queued"); + AssertNoQueuedTranscriptDuplicate(snapshots, "main", "first queued"); + AssertNoQueuedTranscriptDuplicate(snapshots, "main", "second queued"); + } + + [Fact] + public async Task AssistantFrames_DuringActiveLocalRun_DoNotPromoteLaterQueuedMessages() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-3", Status = "started" }); + await provider.LoadAsync(); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "a"); + await provider.SendMessageAsync("main", "b"); + await provider.SendMessageAsync("main", "c"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "Sounds good — send them through.", + State = "final" + }); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "Sounds good — send them through.", + State = "final" + }); + + var latest = snapshots[^1]; + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "a"); + Assert.DoesNotContain(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text is "b" or "c"); + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "Sounds good — send them through."); + Assert.Collection( + GetQueuedMessages(latest, "main"), + queued => Assert.Equal("b", queued.Text), + queued => Assert.Equal("c", queued.Text)); + } + + [Fact] + public async Task ChatSendAck_ForAlreadyActiveRun_DoesNotPromoteLaterQueuedMessages() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-active", Status = "started" }); + await provider.SendMessageAsync("main", "r"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-active")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "r"); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-active", Status = "started" }); + await provider.SendMessageAsync("main", "s"); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-active", Status = "started" }); + await provider.SendMessageAsync("main", "t"); + + var latest = snapshots[^1]; + Assert.DoesNotContain(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text is "s" or "t"); + Assert.Collection( + GetQueuedMessages(latest, "main"), + queued => Assert.Equal("s", queued.Text), + queued => Assert.Equal("t", queued.Text)); + } + + [Fact] + public async Task LifecycleEnd_KeepsLocalInitiatedStateWhenQueuedMessagesRemain() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "first"); + await provider.SendMessageAsync("main", "second"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "first run response", + State = "final" + }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-1")); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "first run response", + State = "final" + }); + + Assert.Equal("second", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + Assert.DoesNotContain(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second"); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "first run response"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); + + var latest = snapshots[^1]; + Assert.Empty(GetQueuedMessages(latest, "main")); + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "first"); + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second"); + } + [Fact] public async Task SendMessageAsync_GatewayThrows_AppendsErrorAndRethrows() { @@ -215,9 +433,133 @@ await Assert.ThrowsAsync(() => var timeline = snapshots[^1].Timelines["main"]; Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("boom")); Assert.False(timeline.TurnActive); + var failedQueued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal(ChatQueuedMessageSendState.Failed, failedQueued.SendState); + Assert.Contains("boom", failedQueued.ErrorText); + Assert.Contains(notifications, n => n.Kind == ChatProviderNotificationKind.Error); + } + + [Fact] + public async Task SendMessageAsync_TerminalAckStatus_AppendsErrorAndKeepsFailedQueuedMessage() + { + var (bridge, provider, snapshots, notifications) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { Status = "failed", Error = "model unavailable" }); + await provider.LoadAsync(); + snapshots.Clear(); + notifications.Clear(); + + await Assert.ThrowsAsync(() => + provider.SendMessageAsync("main", "Hi")); + + var timeline = snapshots[^1].Timelines["main"]; + Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("model unavailable")); + var failedQueued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("Hi", failedQueued.Text); + Assert.Equal(ChatQueuedMessageSendState.Failed, failedQueued.SendState); + Assert.Equal("model unavailable", failedQueued.ErrorText); Assert.Contains(notifications, n => n.Kind == ChatProviderNotificationKind.Error); } + [Fact] + public async Task SendMessageAsync_FailedFirstSendDoesNotOrphanLaterQueuedSend() + { + var firstGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sendCount = 0; + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendBehavior = (_, _, _) => + { + sendCount++; + return sendCount == 1 ? firstGate.Task : secondGate.Task; + }; + bridge.SendResults.Enqueue(new ChatSendResult { Status = "failed", Error = "first failed" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); + await provider.LoadAsync(); + + var firstSend = provider.SendMessageAsync("main", "a"); + var secondSend = provider.SendMessageAsync("main", "b"); + firstGate.SetResult(); + await Assert.ThrowsAsync(() => firstSend); + + secondGate.SetResult(); + await secondSend; + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b")); + + var latest = snapshots[^1]; + Assert.Contains(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "b"); + var queued = GetQueuedMessages(latest, "main"); + var failed = Assert.Single(queued); + Assert.Equal("a", failed.Text); + Assert.Equal(ChatQueuedMessageSendState.Failed, failed.SendState); + } + + [Fact] + public async Task LifecycleEnd_IgnoresFailedQueuedMessagesWhenClearingLocalInitiatedState() + { + var historyCalls = 0; + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { Status = "failed", Error = "first failed" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); + await provider.LoadAsync(); + + await Assert.ThrowsAsync(() => + provider.SendMessageAsync("main", "a")); + await provider.SendMessageAsync("main", "b"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b")); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-b")); + + var failed = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("a", failed.Text); + Assert.Equal(ChatQueuedMessageSendState.Failed, failed.SendState); + + bridge.HistoryBehavior = _ => + { + historyCalls++; + return Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "remote after failed card", + Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + OpenClawSeq = 30 + } + } + }); + }; + snapshots.Clear(); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "remote-run")); + + for (var i = 0; i < 20 && historyCalls == 0; i++) + await Task.Delay(10); + + Assert.True(historyCalls > 0); + } + + [Fact] + public async Task Status_ReconnectClearsUncorrelatedQueuedMessagesBeforeHistoryReload() + { + var sendGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendBehavior = (_, _, _) => sendGate.Task; + await provider.LoadAsync(); + + var sendTask = provider.SendMessageAsync("main", "pending across reconnect"); + Assert.Equal("pending across reconnect", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + + bridge.RaiseStatus(ConnectionStatus.Connected); + + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + sendGate.SetResult(); + await sendTask; + } + [Fact] public async Task SendMessageAsync_RejectsEmptyMessage() { @@ -400,7 +742,8 @@ public async Task ChatMessageReceived_DeltaAfterFinalAssistantBeforeLifecycleEnd public async Task ChatMessageReceived_UserEcho_Ignored() { // After sending a message locally, the SSE echo of that same text - // should be suppressed (already displayed by SendMessageAsync). + // should be suppressed as a gateway echo while atomically moving the + // queued card into the transcript. var tcs = new TaskCompletionSource(); var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); bridge.SendBehavior = (_, _, _) => tcs.Task; @@ -417,12 +760,106 @@ public async Task ChatMessageReceived_UserEcho_Ignored() State = "final" }); - // The echo should be suppressed — no new snapshot. - Assert.Empty(snapshots); + var timeline = snapshots[^1].Timelines["main"]; + Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); tcs.SetResult(); } + [Fact] + public async Task ChatMessageReceived_UserEcho_ReconcilesExistingQueuedPromotionAfterPendingEchoExpires() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-d", Status = "started" }); + await provider.SendMessageAsync("main", "d"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-d")); + + var promoted = Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "d"); + var beforeMeta = provider.GetEntryMetadata("main"); + Assert.True(beforeMeta[promoted.Id].IsLocalQueuedSend); + Assert.Null(beforeMeta[promoted.Id].OpenClawSeq); + snapshots.Clear(); + + // A non-identified local echo can consume the pending echo queue after + // lifecycle.start has already promoted the queued bubble. The later + // gateway-confirmed row still needs to reconcile onto that bubble. + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "d", + State = "final" + }); + Assert.Empty(snapshots); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "d", + State = "final", + Ts = DateTimeOffset.UtcNow.AddSeconds(-7).ToUnixTimeMilliseconds(), + OpenClawId = "f3ed25d3", + OpenClawSeq = 9 + }); + + var timeline = snapshots[^1].Timelines["main"]; + var user = Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "d"); + var afterMeta = provider.GetEntryMetadata("main"); + Assert.Equal("f3ed25d3", afterMeta[user.Id].GatewayMessageId); + Assert.Equal(9, afterMeta[user.Id].OpenClawSeq); + Assert.False(afterMeta[user.Id].IsLocalQueuedSend); + } + + [Fact] + public async Task ChatMessageReceived_UserEcho_DoesNotReconcileStaleIdenticalConfirmedRemoteMessage() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-same", Status = "started" }); + await provider.SendMessageAsync("main", "same"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-same")); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "same", + State = "final" + }); + snapshots.Clear(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "same", + State = "final", + Ts = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds(), + OpenClawId = "remote-later", + OpenClawSeq = 50 + }); + + var users = snapshots[^1].Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.User && e.Text == "same") + .ToArray(); + Assert.Equal(2, users.Length); + + var afterMeta = provider.GetEntryMetadata("main"); + Assert.Contains(users, user => + afterMeta[user.Id].IsLocalQueuedSend && + afterMeta[user.Id].OpenClawSeq is null); + Assert.Contains(users, user => + afterMeta[user.Id].GatewayMessageId == "remote-later" && + afterMeta[user.Id].OpenClawSeq == 50 && + !afterMeta[user.Id].IsLocalQueuedSend); + } + [Fact] public async Task SendMessageAsync_WhenGatewayThrows_DoesNotSuppressFutureRemoteUserEcho() { @@ -445,7 +882,7 @@ await Assert.ThrowsAsync(() => }); var timeline = snapshots[^1].Timelines["main"]; - Assert.Equal(2, timeline.Entries.Count(e => + Assert.Equal(1, timeline.Entries.Count(e => e.Kind == ChatTimelineItemKind.User && e.Text == "same text")); } @@ -534,6 +971,154 @@ public async Task SessionsUpdated_RebuildsThreadsAndSeedsTimelines() Assert.Equal("main", snap.DefaultThreadId); } + [Fact] + public async Task LoadHistoryAsync_OrdersMessagesByOpenClawSequenceBeforeTimestamp() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "c", Ts = 1_000, OpenClawSeq = 3 }, + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "a", Ts = 3_000, OpenClawSeq = 1 }, + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "b", Ts = 2_000, OpenClawSeq = 2 }, + } + }); + await provider.LoadAsync(); + snapshots.Clear(); + + await provider.LoadHistoryAsync("main"); + + var entries = snapshots[^1].Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.User) + .Select(e => e.Text) + .ToArray(); + Assert.Equal(new[] { "a", "b", "c" }, entries); + } + + [Fact] + public async Task LoadHistoryAsync_KeepsGatewayOrderWhenOnlySomeRowsHaveOpenClawSequence() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "r", Ts = 1_000 }, + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "s", Ts = 2_000 }, + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "t", Ts = 500, OpenClawSeq = 1 }, + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "u", Ts = 600, OpenClawSeq = 2 }, + } + }); + await provider.LoadAsync(); + snapshots.Clear(); + + await provider.LoadHistoryAsync("main"); + + var entries = snapshots[^1].Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.User) + .Select(e => e.Text) + .ToArray(); + Assert.Equal(new[] { "r", "s", "t", "u" }, entries); + } + + [Fact] + public async Task LoadHistoryAsync_DedupesQueuedLocalPromotionsWhenHistoryTimestampDiffers() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-a", Status = "started" }); + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); + await provider.SendMessageAsync("main", "b"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b")); + + Assert.Equal( + new[] { "a", "b" }, + snapshots[^1].Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.User) + .Select(e => e.Text) + .ToArray()); + + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "a", + Ts = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds(), + OpenClawSeq = 1 + }, + new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "b", + Ts = DateTimeOffset.UtcNow.AddMinutes(-4).ToUnixTimeMilliseconds(), + OpenClawSeq = 2 + }, + } + }); + snapshots.Clear(); + + await provider.LoadHistoryAsync("main"); + + var userTexts = snapshots[^1].Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.User) + .Select(e => e.Text) + .ToArray(); + Assert.Equal(new[] { "a", "b" }, userTexts); + } + + [Fact] + public async Task LoadHistoryAsync_KeepsSecondIdenticalQueuedPromptWhenHistoryContainsOneMatch() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-same-1", Status = "started" }); + await provider.SendMessageAsync("main", "same"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-same-1")); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-same-2", Status = "started" }); + await provider.SendMessageAsync("main", "same"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-same-2")); + + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "same", + Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + OpenClawSeq = 1 + }, + } + }); + snapshots.Clear(); + + await provider.LoadHistoryAsync("main"); + + var userTexts = snapshots[^1].Timelines["main"].Entries + .Where(e => e.Kind == ChatTimelineItemKind.User) + .Select(e => e.Text) + .ToArray(); + Assert.Equal(new[] { "same", "same" }, userTexts); + } + [Fact] public async Task SessionResetCompletion_ClearsThreadTimelineAndIgnoresStaleHistory() { @@ -578,6 +1163,18 @@ public async Task SessionResetCompletion_ClearsThreadTimelineAndIgnoresStaleHist await provider.SendMessageAsync("main", "after reset"); + latest = snapshots[^1]; + Assert.Empty(latest.Timelines["main"].Entries); + Assert.Equal("after reset", Assert.Single(GetQueuedMessages(latest, "main")).Text); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "after reset", + State = "final" + }); + latest = snapshots[^1]; var entry = Assert.Single(latest.Timelines["main"].Entries); Assert.Equal(ChatTimelineItemKind.User, entry.Kind); @@ -742,8 +1339,7 @@ public async Task SessionResetCompletion_LocalSendDoesNotReopenGateForStaleChatF }); var latest = await provider.LoadAsync(); - Assert.Single(latest.Timelines["main"].Entries, e => - e.Kind == ChatTimelineItemKind.User && e.Text == "after reset local"); + Assert.Equal("after reset local", Assert.Single(GetQueuedMessages(latest, "main")).Text); Assert.DoesNotContain(latest.Timelines["main"].Entries, e => e.Text.Contains("stale", StringComparison.Ordinal)); @@ -928,6 +1524,58 @@ public async Task SessionResetCompletion_PreResetSendResultAfterResetDoesNotReop Assert.Empty(latest.Timelines["main"].Entries); } + [Fact] + public async Task SessionResetCompletion_PreResetSendAckDoesNotClearNewQueuedMessage() + { + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sendCount = 0; + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendBehavior = (_, _, _) => + { + sendCount++; + if (sendCount == 1) + { + firstStarted.TrySetResult(); + return firstGate.Task; + } + return secondGate.Task; + }; + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "old-run", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "new-run", Status = "started" }); + await provider.LoadAsync(); + snapshots.Clear(); + + var firstSend = provider.SendMessageAsync("main", "before reset"); + await firstStarted.Task; + Assert.Equal("before reset", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + + bridge.RaiseSessionCommandCompleted(new SessionCommandResult + { + Method = "sessions.reset", + Ok = true, + Key = "main" + }); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + + var secondSend = provider.SendMessageAsync("main", "after reset"); + Assert.Equal("after reset", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + + firstGate.SetResult(); + await firstSend; + + var queuedAfterStaleAck = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("after reset", queuedAfterStaleAck.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, queuedAfterStaleAck.SendState); + + secondGate.SetResult(); + await secondSend; + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "new-run")); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + } + [Fact] public async Task SessionResetCompletion_PostResetSendWithoutRunIdCanOpenOnFreshLifecycleStart() { @@ -2652,6 +3300,13 @@ public async Task SendMessageAsync_AssignsTimestampToLocalUserEntry() var before = DateTimeOffset.Now.AddSeconds(-1); await provider.SendMessageAsync("main", "hi"); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "hi", + State = "final" + }); var after = DateTimeOffset.Now.AddSeconds(1); var snap = await provider.LoadAsync(); @@ -3511,6 +4166,16 @@ public async Task SendMessageAsync_WithAttachment_SendsThroughInterface() var sentAttachment = Assert.Single(bridge.SentAttachments); Assert.NotNull(sentAttachment); Assert.Same(attachment, sentAttachment![0]); + Assert.Contains("test.txt", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "Check this", + State = "final" + }); + // The display text in the timeline should include the attachment indicator var timeline = snapshots[^1].Timelines["main"]; var userEntry = timeline.Entries.Last(e => e.Kind == ChatTimelineItemKind.User); @@ -3548,6 +4213,17 @@ public async Task SendMessageAsync_WithMultipleAttachments_SendsAndRendersAllMar sentAttachments!, a => Assert.Same(fileAttachment, a), a => Assert.Same(imageAttachment, a)); + Assert.Equal( + "See both\n\u200B📎 notes.txt\n\u200B🖼️ diagram.png", + Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "See both", + State = "final" + }); var timeline = snapshots[^1].Timelines["main"]; var userEntry = timeline.Entries.Last(e => e.Kind == ChatTimelineItemKind.User); @@ -3705,13 +4381,23 @@ public async Task AttachmentMetadata_DoesNotRehydratePastedMarkerTextWithoutSide [Fact] public async Task SendMessageAsync_WithoutAttachment_EscapesPastedMarkerText() { - var (_, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); await provider.LoadAsync(); snapshots.Clear(); await provider.SendMessageAsync("main", "\u200B📎 spoof.txt"); - var userEntry = snapshots[0].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User); + Assert.Equal("📎 spoof.txt", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "\u200B📎 spoof.txt", + State = "final" + }); + + var userEntry = snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User); Assert.Equal("📎 spoof.txt", userEntry.Text); } @@ -3885,14 +4571,14 @@ private static (FakeBridge bridge, OpenClawChatDataProvider provider, List e.Kind == ChatTimelineItemKind.User); + Assert.Empty(latest.Timelines["agent:main:main"].Entries); + Assert.Equal("hi", Assert.Single(GetQueuedMessages(latest, "agent:main:main")).Text); } [Fact] public async Task SendMessageAsync_FreshInstall_SnapshotExposesComposeOnlyTimeline() { // Before the first SessionsUpdated arrives, the gateway-side session - // doesn't exist yet, so Threads is empty. But the optimistic user - // bubble must still be reachable to the UI: it's stored under the + // doesn't exist yet, so Threads is empty. But the queued user message + // must still be reachable to the UI: it's stored under the // compose-target key. The UI then synthesizes a compose-only thread // (matching the canonical key) so the timeline can render. var (_, provider, snapshots) = CreateConnectedProvider("agent:main:main"); @@ -3922,18 +4609,18 @@ public async Task SendMessageAsync_FreshInstall_SnapshotExposesComposeOnlyTimeli var latest = snapshots[^1]; // BuildSnapshotLocked surfaces a synthetic ChatThread when the - // compose key has optimistic entries but isn't materialized yet. + // compose key has queued/active local state but isn't materialized yet. Assert.Single(latest.Threads); Assert.Equal("agent:main:main", latest.Threads[0].Id); Assert.Equal("agent:main:main", latest.ComposeTarget.SessionKey); } [Fact] - public async Task SessionsUpdated_AfterFirstSend_PreservesOptimisticTimeline() + public async Task SessionsUpdated_AfterFirstSend_PreservesQueuedLocalState() { // The critical assertion: when the gateway materializes the session - // and emits SessionsUpdated with the canonical key, the optimistic - // entry that was written under that exact key SURVIVES (no second + // and emits SessionsUpdated with the canonical key, the queued local + // state that was written under that exact key SURVIVES (no second // empty timeline gets created on top of it). var (bridge, provider, snapshots) = CreateConnectedProvider("agent:main:main"); await provider.LoadAsync(); @@ -3948,8 +4635,8 @@ public async Task SessionsUpdated_AfterFirstSend_PreservesOptimisticTimeline() Assert.Single(latest.Threads); Assert.Equal("agent:main:main", latest.Threads[0].Id); var timeline = latest.Timelines["agent:main:main"]; - Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User); - Assert.Equal("hi", timeline.Entries.First(e => e.Kind == ChatTimelineItemKind.User).Text); + Assert.Empty(timeline.Entries); + Assert.Equal("hi", Assert.Single(GetQueuedMessages(latest, "agent:main:main")).Text); } [Fact] @@ -4530,6 +5217,26 @@ public void LoadLastChatState_WithCorruptedJson_ReturnsNull() Assert.Null(state); } + private static IReadOnlyList GetQueuedMessages(ChatDataSnapshot snapshot, string threadId) + => snapshot.QueuedMessagesByThread is not null && + snapshot.QueuedMessagesByThread.TryGetValue(threadId, out var queued) + ? queued + : Array.Empty(); + + private static void AssertNoQueuedTranscriptDuplicate( + IEnumerable snapshots, + string threadId, + string text) + { + foreach (var snapshot in snapshots) + { + var hasQueued = GetQueuedMessages(snapshot, threadId).Any(message => message.Text == text); + var hasTranscript = snapshot.Timelines.TryGetValue(threadId, out var timeline) + && timeline.Entries.Any(entry => entry.Kind == ChatTimelineItemKind.User && entry.Text == text); + Assert.False(hasQueued && hasTranscript, $"'{text}' was visible in both queue and transcript."); + } + } + private sealed class TestLogger : OpenClaw.Shared.IOpenClawLogger { public void Debug(string message) { } From 044a2e2c419a36381de242150329bf577df8f30d Mon Sep 17 00:00:00 2001 From: Caleb Eden <58373773+calebeden@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:54:26 -0700 Subject: [PATCH 02/11] Add client-side chat queue parity Queue follow-up chat prompts in memory until the active agent turn completes, then dispatch one queued prompt at a time. Suppress stale reset/shutdown/reconnect drains and reload authoritative history after aborted pre-reset submitted runs terminate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Chat/ChatModels.cs | 3 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 14 +- .../Chat/OpenClawChatDataProvider.cs | 466 ++++++++++++++++-- .../AppRefactorContractTests.cs | 2 +- .../OpenClawChatDataProviderTests.cs | 451 ++++++++++++++++- 5 files changed, 874 insertions(+), 62 deletions(-) diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index 682243ce6..4de7d2151 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -158,6 +158,7 @@ public record ChatTimelineState( public enum ChatQueuedMessageSendState { + Queued, Sending, Failed } @@ -167,7 +168,7 @@ public record ChatQueuedMessage( string Text, DateTimeOffset CreatedAt, string LocalNonce, - ChatQueuedMessageSendState SendState = ChatQueuedMessageSendState.Sending, + ChatQueuedMessageSendState SendState = ChatQueuedMessageSendState.Queued, string? ErrorText = null); public record ChatHistoryPage(ChatEvent[] Events, int NextSince, int PrevBefore, bool HasMore); diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 87d5df5e7..9f25e29e6 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4394,6 +4394,14 @@ private async Task ExitApplicationAsync() _globalHotkey = null; }); + // Stop chat first so provider event handlers cannot drain client-only + // queued prompts while the gateway connection is shutting down. + SafeShutdownStep("chat coordinator", () => + { + _chatCoordinator?.Dispose(); + _chatCoordinator = null; + }); + // Dispose runtime services var connectionManager = _connectionManager; if (connectionManager != null) @@ -4405,12 +4413,6 @@ await SafeShutdownStepAsync("gateway client", async () => _connectionManager = null; } - SafeShutdownStep("chat coordinator", () => - { - _chatCoordinator?.Dispose(); - _chatCoordinator = null; - }); - var nodeService = _nodeService; if (nodeService != null) { diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 1a25898c2..20394b7b9 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -118,6 +118,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider 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 @@ -137,7 +138,9 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider // 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 _assistantFallbackPromotedThreads = new(StringComparer.Ordinal); private long _queuedMessageSequence; private int _keylessEventDiagnosticRaised; @@ -150,6 +153,19 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private const int MaxHistoryRetries = 3; private static readonly TimeSpan LocalEchoSuppressionWindow = TimeSpan.FromSeconds(30); private readonly record struct LocalSentText(string Text, DateTimeOffset SentAt, string QueuedMessageId); + private sealed record QueuedSendRequest( + string Id, + string ThreadId, + string Text, + string DisplayText, + string LocalNonce, + IReadOnlyList? Attachments); + private sealed record QueuedSendDispatch( + QueuedSendRequest Request, + string? SessionId, + long ResetVersion, + long StartedLifecycleSequence, + long StartedRunStartSequence); private sealed record LocalInlineApproval( string ThreadId, string RequestId, @@ -322,58 +338,65 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation : $"{safeUserText}\n{chips}"; } - // 1. Queue the user message locally. It moves into the transcript only - // when the gateway echoes it or starts the accepted run, so the same - // text is never visible in both the composer queue and chat history. + // 1. Queue the user message locally. If an agent turn is already + // active, this remains client-only until the turn ends; otherwise the + // oldest queued item is submitted immediately. ChatDataSnapshot snapshot; - string? sessionId; - long sendResetVersion; - long sendStartedLifecycleSequence; - long sendStartedRunStartSequence; string queuedMessageId; + QueuedSendDispatch? dispatch; lock (_gate) { - sendResetVersion = GetResetVersionLocked(threadId); - sendStartedLifecycleSequence = _resetLifecycleStartSequence; - sendStartedRunStartSequence = _lifecycleStartSequence; + ObjectDisposedException.ThrowIf(_disposed, this); queuedMessageId = $"q{++_queuedMessageSequence}"; if (!HasSendingQueuedMessagesLocked(threadId)) _assistantFallbackPromotedThreads.Remove(threadId); - _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(GetOrCreateTimelineLocked(threadId)); - _sessionIds.TryGetValue(threadId, out sessionId); - - // Track this text so we can suppress the SSE echo. - if (!_localSentTexts.TryGetValue(threadId, out var q)) - { - q = new Queue(); - _localSentTexts[threadId] = q; - } - q.Enqueue(new LocalSentText(trimmed, DateTimeOffset.UtcNow, queuedMessageId)); - // Cap at 20 to avoid unbounded growth. - while (q.Count > 20) q.Dequeue(); AddQueuedMessageLocked(threadId, new ChatQueuedMessage( queuedMessageId, displayText, DateTimeOffset.UtcNow, nonce)); + AddQueuedSendRequestLocked(new QueuedSendRequest( + queuedMessageId, + threadId, + trimmed, + displayText, + nonce, + attachments?.ToArray())); // 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); - _locallyInitiatedThreads.Add(threadId); + dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: false); snapshot = BuildSnapshotLocked(); } Publish(snapshot); - // 2. Send to gateway. + if (dispatch is not null) + await DispatchQueuedSendAsync(dispatch, rethrow: true, cancellationToken); + } + + private async Task DispatchQueuedSendAsync( + QueuedSendDispatch dispatch, + bool rethrow, + CancellationToken cancellationToken = default) + { + var request = dispatch.Request; + var threadId = request.ThreadId; + var hasAttachments = request.Attachments is { Count: > 0 }; + try { await AwaitPendingModelPatchAsync(threadId, cancellationToken); - var sendResult = await _bridge.SendChatMessageForRunAsync(trimmed, threadId, sessionId, attachments); + lock (_gate) + { + if (_disposed) + return; + } + var sendResult = await _bridge.SendChatMessageForRunAsync(request.Text, threadId, dispatch.SessionId, request.Attachments); if (sendResult.IsTerminalFailure) { var failure = !string.IsNullOrWhiteSpace(sendResult.Error) @@ -389,7 +412,7 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation ChatDataSnapshot? acceptedRunAlreadyStartedSnapshot = null; lock (_gate) { - sendStillCurrent = GetResetVersionLocked(threadId) == sendResetVersion; + sendStillCurrent = GetResetVersionLocked(threadId) == dispatch.ResetVersion; if (!sendStillCurrent) { if (!string.IsNullOrEmpty(sendResult.RunId)) @@ -397,21 +420,21 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation } else if (!string.IsNullOrEmpty(sendResult.RunId)) { - TrackQueuedMessageRunLocked(threadId, sendResult.RunId!, queuedMessageId); + TrackQueuedMessageRunLocked(threadId, sendResult.RunId!, request.Id); AddResetAcceptedRunIdLocked(threadId, sendResult.RunId!); if (_activeRunIds.TryGetValue(threadId, out var activeRunId) && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence) && string.Equals(activeRunId, sendResult.RunId, StringComparison.Ordinal) - && activeStartSequence > sendStartedRunStartSequence - && PromoteQueuedMessageLocked(threadId, queuedMessageId)) + && activeStartSequence > dispatch.StartedRunStartSequence + && PromoteQueuedMessageLocked(threadId, request.Id)) { acceptedRunAlreadyStartedSnapshot = BuildSnapshotLocked(); } } else if (_resetAwaitingUserMessage.Contains(threadId)) { - _resetLocalSendWithoutRunVersions[threadId] = sendResetVersion; - _resetLocalSendWithoutRunStartSequences[threadId] = sendStartedLifecycleSequence; + _resetLocalSendWithoutRunVersions[threadId] = dispatch.ResetVersion; + _resetLocalSendWithoutRunStartSequences[threadId] = dispatch.StartedLifecycleSequence; TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null); } } @@ -420,7 +443,7 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation Publish(acceptedRunAlreadyStartedSnapshot); if (hasAttachments && sendStillCurrent) - CacheAttachmentMeta(sessionId, threadId, trimmed, attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), sendResetVersion); + CacheAttachmentMeta(dispatch.SessionId, threadId, request.Text, request.Attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), dispatch.ResetVersion); } catch (Exception ex) { @@ -428,11 +451,12 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation ChatDataSnapshot? failureSnapshot = null; lock (_gate) { - sendStillCurrent = GetResetVersionLocked(threadId) == sendResetVersion; + sendStillCurrent = GetResetVersionLocked(threadId) == dispatch.ResetVersion; if (sendStillCurrent) { - RemovePendingLocalEchoLocked(threadId, trimmed); - MarkQueuedMessageFailedLocked(threadId, queuedMessageId, ex.Message); + RemovePendingLocalEchoLocked(threadId, request.Text); + MarkQueuedMessageFailedLocked(threadId, request.Id, ex.Message); + RemoveQueuedSendRequestLocked(threadId, request.Id); if (!HasSendingQueuedMessagesLocked(threadId)) _locallyInitiatedThreads.Remove(threadId); failureSnapshot = ApplyEventLocked( @@ -454,7 +478,9 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation Publish(failureSnapshot!); RaiseNotification(new ChatProviderNotification( ChatProviderNotificationKind.Error, threadId, LocalizationHelper.GetString("Chat_Notification_SendFailed"), ex.Message)); - throw; + TryDispatchNextQueuedSend(threadId); + if (rethrow) + throw; } } @@ -498,10 +524,15 @@ public async Task StopResponseAsync(string threadId, CancellationToken cancellat { _abortedThreads.Remove(threadId); _abortedRunIds.Remove(runId); + _activeRunIds.Remove(threadId); + _activeRunStartSequences.Remove(threadId); + if (!HasSendingQueuedMessagesLocked(threadId)) + _locallyInitiatedThreads.Remove(threadId); } Logger.Warn($"[ABORT] chat.abort failed, cleared suppression: {ex.Message}"); RaiseNotification(new ChatProviderNotification( ChatProviderNotificationKind.Error, threadId, LocalizationHelper.GetString("Chat_Notification_AbortFailed"), ex.Message)); + ApplyEventAndPublish(threadId, new ChatTurnEndEvent()); return; } } @@ -521,6 +552,18 @@ 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); + } + // Always clear local "turn active" state — the gateway will emit a // lifecycle.end if the abort succeeds, but we want the UI to reflect // the user's intent immediately. @@ -1485,6 +1528,13 @@ public ValueTask DisposeAsync() _lastChatStateSaveTimer = null; pendingLocalApprovals = _localInlineApprovals.Values.ToList(); _localInlineApprovals.Clear(); + _queuedMessages.Clear(); + _queuedSendRequests.Clear(); + _queuedMessageIdsByRunId.Clear(); + _terminalRunIdsByThread.Clear(); + _localSentTexts.Clear(); + _locallyInitiatedThreads.Clear(); + _resetSubmittedLocalEchoTexts.Clear(); } foreach (var approval in pendingLocalApprovals) approval.Response.TrySetResult(ExecApprovalPromptDecision.Deny()); @@ -1580,8 +1630,13 @@ private void OnStatusChanged(object? sender, ConnectionStatus status) _locallyInitiatedThreads.Clear(); _localSentTexts.Clear(); _queuedMessages.Clear(); + _queuedSendRequests.Clear(); _assistantFallbackPromotedThreads.Clear(); _queuedMessageIdsByRunId.Clear(); + _terminalRunIdsByThread.Clear(); + _resetSubmittedLocalEchoTexts.Clear(); + _activeRunIds.Clear(); + _activeRunStartSequences.Clear(); _historyRetryCount.Clear(); // Reset keyless-event diagnostic so a fresh reconnect to a // still-broken gateway surfaces the notification again. @@ -1600,6 +1655,11 @@ private void OnStatusChanged(object? sender, ConnectionStatus status) if (tl.TurnActive) list.Add(key); } threadsToInterrupt = list.ToArray(); + foreach (var threadId in threadsToInterrupt) + { + _activeRunIds.Remove(threadId); + _activeRunStartSequences.Remove(threadId); + } } else { @@ -1699,6 +1759,32 @@ private void OnSessionCommandCompleted(object? sender, SessionCommandResult resu Publish(snapshot); PersistClearedResetState(persistence); + AbortSubmittedRunsAfterReset(result.Key, persistence.SubmittedRunIds); + } + + private void AbortSubmittedRunsAfterReset(string threadId, IReadOnlyList runIds) + { + if (runIds.Count == 0) + return; + + _ = Task.Run(async () => + { + foreach (var runId in runIds) + { + if (string.IsNullOrWhiteSpace(runId)) + continue; + + try + { + Logger.Info($"[Reset] Sending chat.abort for pre-reset submitted runId='{runId}' threadId='{threadId}'"); + await _bridge.SendChatAbortAsync(runId, threadId).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Warn($"[Reset] chat.abort failed for pre-reset runId='{runId}' threadId='{threadId}': {ex.Message}"); + } + } + }); } private void OnModelsListUpdated(object? sender, ModelsListInfo info) @@ -2062,14 +2148,29 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) } var threadId = evt.SessionKey; + var reloadHistoryAfterResetDrop = false; + var shouldProcessEvent = false; lock (_gate) { - if (ShouldDropAgentEventAfterResetLocked(evt, threadId)) + if (ShouldDropAgentEventAfterResetLocked(evt, threadId, out reloadHistoryAfterResetDrop)) { Logger.Debug($"[Reset] Dropping stale agent event after reset for threadId='{threadId}' stream='{evt.Stream}' runId='{evt.RunId}'"); - return; + } + else if (ShouldDropTerminalAgentEventLocked(evt, threadId)) + { + Logger.Debug($"[Queue] Dropping stale terminal agent event for threadId='{threadId}' stream='{evt.Stream}' runId='{evt.RunId}'"); + } + else + { + shouldProcessEvent = true; } } + if (!shouldProcessEvent) + { + if (reloadHistoryAfterResetDrop) + _ = LoadHistoryAsync(threadId, force: true); + return; + } // Always update run tracking first (state maintenance must not be skipped). UpdateActiveRunId(evt, threadId); @@ -2318,6 +2419,76 @@ private void UpdateActiveRunId(AgentEventInfo evt, string threadId) } } + private bool ShouldDropTerminalAgentEventLocked(AgentEventInfo evt, string threadId) + { + if (!TryGetTerminalAgentRunId(evt, out var runId) || string.IsNullOrWhiteSpace(runId)) + return false; + + 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)) + { + 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) + { + return true; + } + + RememberTerminalRunIdLocked(threadId, runId); + return false; + } + + 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; @@ -2420,6 +2591,103 @@ private void AddQueuedMessageLocked(string threadId, ChatQueuedMessage message) 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 QueuedSendDispatch? TryStartNextQueuedSendLocked(string threadId, bool requireConnected) + { + 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 (!_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; + + queuedMessages[i] = queuedMessages[i] with { SendState = ChatQueuedMessageSendState.Sending, ErrorText = null }; + var resetVersion = GetResetVersionLocked(threadId); + var startedLifecycleSequence = _resetLifecycleStartSequence; + var startedRunStartSequence = _lifecycleStartSequence; + _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(GetOrCreateTimelineLocked(threadId)); + _sessionIds.TryGetValue(threadId, out var sessionId); + + if (!_localSentTexts.TryGetValue(threadId, out var localEchoQueue)) + { + localEchoQueue = new Queue(); + _localSentTexts[threadId] = localEchoQueue; + } + localEchoQueue.Enqueue(new LocalSentText(request.Text, DateTimeOffset.UtcNow, request.Id)); + while (localEchoQueue.Count > 20) + localEchoQueue.Dequeue(); + + _locallyInitiatedThreads.Add(threadId); + return new QueuedSendDispatch( + request, + sessionId, + resetVersion, + startedLifecycleSequence, + startedRunStartSequence); + } + + return null; + } + + private void TryDispatchNextQueuedSend(string threadId) + { + ChatDataSnapshot? snapshot = null; + QueuedSendDispatch? dispatch; + lock (_gate) + { + if (_disposed) + return; + dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: true); + if (dispatch is not null) + snapshot = BuildSnapshotLocked(); + } + + if (snapshot is not null) + Publish(snapshot); + if (dispatch is not null) + _ = DispatchQueuedSendAsync(dispatch, rethrow: false); + } + private void TrackQueuedMessageRunLocked(string threadId, string runId, string messageId) { if (!_queuedMessageIdsByRunId.TryGetValue(threadId, out var byRunId)) @@ -2438,7 +2706,10 @@ private bool RemoveQueuedMessageLocked(string threadId, string messageId) var removed = list.RemoveAll(message => message.Id == messageId) > 0; if (removed) + { RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId); + RemoveQueuedSendRequestLocked(threadId, messageId); + } if (list.Count == 0) _queuedMessages.Remove(threadId); return removed; @@ -2465,6 +2736,7 @@ private bool PromoteQueuedMessageLocked(string threadId, string messageId) list.RemoveAt(index); _assistantFallbackPromotedThreads.Add(threadId); RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId); + RemoveQueuedSendRequestLocked(threadId, messageId); if (list.Count == 0) _queuedMessages.Remove(threadId); return true; @@ -3797,11 +4069,14 @@ private void ApplyEventAndPublish(string threadId, ChatEvent evt, ChatEntryMetad evt = TruncateChatEvent(evt); ChatDataSnapshot snapshot; + var shouldDrainQueuedSend = evt is ChatTurnEndEvent or ChatErrorEvent; lock (_gate) { snapshot = ApplyEventLocked(threadId, evt, meta); } Publish(snapshot); + if (shouldDrainQueuedSend) + TryDispatchNextQueuedSend(threadId); } private ChatDataSnapshot ApplyEventLocked(string threadId, ChatEvent evt, ChatEntryMetadata? meta) @@ -3865,7 +4140,8 @@ private Dictionary GetOrCreateThreadMetaLocked(string private readonly record struct ResetClearPersistence( bool SaveAbortedIds, bool SaveToolMeta, - bool SaveAttachmentMeta); + bool SaveAttachmentMeta, + string[] SubmittedRunIds); private long GetResetVersionLocked(string threadId) => _resetVersions.TryGetValue(threadId, out var version) ? version : 0; @@ -3899,8 +4175,19 @@ private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string threadId _toolMetaSaveVersion++; } + var submittedRunIds = new HashSet(StringComparer.Ordinal); if (_activeRunIds.TryGetValue(threadId, out var activeRunId) && !string.IsNullOrEmpty(activeRunId)) - AddResetIgnoredRunIdLocked(threadId, 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(); @@ -3917,7 +4204,9 @@ private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string threadId _locallyInitiatedThreads.Remove(threadId); _localSentTexts.Remove(threadId); _queuedMessages.Remove(threadId); + _queuedSendRequests.Remove(threadId); _queuedMessageIdsByRunId.Remove(threadId); + _terminalRunIdsByThread.Remove(threadId); _assistantFallbackPromotedThreads.Remove(threadId); _resetAcceptedRunIds.Remove(threadId); _resetLocalSendWithoutRunVersions.Remove(threadId); @@ -3926,8 +4215,10 @@ private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string threadId _resetPendingLifecycleStarts.Remove(threadId); _resetRemoteBackfillInFlight.Remove(threadId); _resetRemoteUserSeen.Remove(threadId); + foreach (var submittedRunId in submittedRunIds) + AddResetIgnoredRunIdLocked(threadId, submittedRunId); - return new ResetClearPersistence(saveAbortedIds, saveToolMeta, saveAttachmentMeta); + return new ResetClearPersistence(saveAbortedIds, saveToolMeta, saveAttachmentMeta, submittedRunIds.ToArray()); } private void PersistClearedResetState(ResetClearPersistence persistence) @@ -3950,6 +4241,72 @@ private void AddResetIgnoredRunIdLocked(string threadId, string runId) 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)) @@ -3976,14 +4333,23 @@ private bool ShouldDropChatMessageAfterResetLocked( { consumeEchoText = null; requestRemoteBackfill = false; + var isNormalUserText = roleLower == "user" && + !LooksLikeApprovalSlashCommand(rawText) && + !LooksLikeSystemControlNote(rawText); + + if (isNormalUserText && + !HasPendingLocalEchoTextLocked(threadId, rawText) && + TryConsumeResetSubmittedLocalEchoTextLocked(threadId, rawText)) + { + return true; + } + if (!_resetAwaitingUserMessage.Contains(threadId)) { return IsPreResetTimestampLocked(threadId, tsMs, GetResetCutoffUtcMsLocked(threadId)); } - var isFreshUser = roleLower == "user" && - !LooksLikeApprovalSlashCommand(rawText) && - !LooksLikeSystemControlNote(rawText) && + var isFreshUser = isNormalUserText && !IsPreResetTimestampLocked(threadId, tsMs, GetResetCutoffUtcMsLocked(threadId)); if (isFreshUser && @@ -4062,9 +4428,10 @@ private bool TryGetSingleSendingQueuedMessageLocked(string threadId, out ChatQue return true; } - private bool ShouldDropAgentEventAfterResetLocked(AgentEventInfo evt, string threadId) + private bool ShouldDropAgentEventAfterResetLocked(AgentEventInfo evt, string threadId, out bool reloadHistoryAfterDrop) { - if (IsResetIgnoredRunLocked(threadId, evt.RunId, evt)) + reloadHistoryAfterDrop = false; + if (IsResetIgnoredRunLocked(threadId, evt.RunId, evt, out reloadHistoryAfterDrop)) return true; var eventTsMs = evt.Ts > 0 ? (long)evt.Ts : 0L; @@ -4263,8 +4630,9 @@ private void OpenResetGateForLifecycleStartLocked(string threadId, AgentEventInf } } - private bool IsResetIgnoredRunLocked(string threadId, string? runId, AgentEventInfo evt) + 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)) @@ -4276,7 +4644,11 @@ private bool IsResetIgnoredRunLocked(string threadId, string? runId, AgentEventI { runIds.Remove(runId); if (runIds.Count == 0) + { _resetIgnoredRunIds.Remove(threadId); + _resetSubmittedLocalEchoTexts.Remove(threadId); + } + reloadHistoryAfterDrop = true; } return true; diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index a976c6bfb..049094935 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -315,9 +315,9 @@ public void Shutdown_Order_PreservesAwaitedTeardownBeforeExit() method, "_deepLinkCts.Cancel()", "global hotkey", + "chat coordinator", "gateway client", "connectionManager.DisposeAsync()", - "chat coordinator", "node service", "nodeService.DisposeAsync()", "standalone voice service", diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index d1c051a76..80e07e63a 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -259,6 +259,90 @@ public async Task SendMessageAsync_AckDoesNotQueueStaleSnapshotThatCanResurrectC e.Kind == ChatTimelineItemKind.User && e.Text == "queued prompt"); } + [Fact] + public async Task SendMessageAsync_DuringActiveTurn_QueuesFollowUpsLocallyUntilTurnEnds() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + + Assert.Equal(new[] { "first" }, bridge.SentMessages); + Assert.Collection( + GetQueuedMessages(snapshots[^1], "main"), + queued => + { + Assert.Equal("second", queued.Text); + Assert.Equal(ChatQueuedMessageSendState.Queued, queued.SendState); + }); + Assert.DoesNotContain(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second"); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "first response", + State = "final" + }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-1")); + + Assert.Equal(new[] { "first", "second" }, bridge.SentMessages); + var sending = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second", sending.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, sending.SendState); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); + + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second"); + } + + [Fact] + public async Task AgentEvent_DuplicateTerminalForCompletedRun_DoesNotDispatchAdditionalQueuedMessage() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-3", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "b"); + await provider.SendMessageAsync("main", "c"); + + Assert.Equal(new[] { "a" }, bridge.SentMessages); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-1")); + Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); + + bridge.RaiseAgent(MakeAgentEvent("job", """{"state":"done"}""", runId: "run-1")); + + Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); + Assert.Collection( + GetQueuedMessages(snapshots[^1], "main"), + sending => + { + Assert.Equal("b", sending.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, sending.SendState); + }, + queued => + { + Assert.Equal("c", queued.Text); + Assert.Equal(ChatQueuedMessageSendState.Queued, queued.SendState); + }); + } + [Fact] public async Task LifecycleStart_DoesNotClearNextQueuedMessageWhenEarlierEchoAlreadyClearedItsCard() { @@ -266,15 +350,25 @@ public async Task LifecycleStart_DoesNotClearNextQueuedMessageWhenEarlierEchoAlr bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); snapshots.Clear(); await provider.SendMessageAsync("main", "first queued"); await provider.SendMessageAsync("main", "second queued"); + Assert.Equal(new[] { "first queued" }, bridge.SentMessages); Assert.Collection( GetQueuedMessages(snapshots[^1], "main"), - first => Assert.Equal("first queued", first.Text), - second => Assert.Equal("second queued", second.Text)); + first => + { + Assert.Equal("first queued", first.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, first.SendState); + }, + second => + { + Assert.Equal("second queued", second.Text); + Assert.Equal(ChatQueuedMessageSendState.Queued, second.SendState); + }); bridge.RaiseChat(new ChatMessageInfo { @@ -294,6 +388,16 @@ public async Task LifecycleStart_DoesNotClearNextQueuedMessageWhenEarlierEchoAlr var queuedAfterFirstStart = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); Assert.Equal("second queued", queuedAfterFirstStart.Text); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "first response", + State = "final" + }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-1")); + Assert.Equal(new[] { "first queued", "second queued" }, bridge.SentMessages); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); @@ -380,10 +484,12 @@ public async Task LifecycleEnd_KeepsLocalInitiatedStateWhenQueuedMessagesRemain( bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); snapshots.Clear(); await provider.SendMessageAsync("main", "first"); await provider.SendMessageAsync("main", "second"); + Assert.Equal(new[] { "first" }, bridge.SentMessages); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); bridge.RaiseChat(new ChatMessageInfo @@ -403,6 +509,7 @@ public async Task LifecycleEnd_KeepsLocalInitiatedStateWhenQueuedMessagesRemain( }); Assert.Equal("second", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + Assert.Equal(new[] { "first", "second" }, bridge.SentMessages); Assert.DoesNotContain(snapshots[^1].Timelines["main"].Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "second"); Assert.Single(snapshots[^1].Timelines["main"].Entries, e => @@ -475,14 +582,19 @@ public async Task SendMessageAsync_FailedFirstSendDoesNotOrphanLaterQueuedSend() bridge.SendResults.Enqueue(new ChatSendResult { Status = "failed", Error = "first failed" }); bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); var firstSend = provider.SendMessageAsync("main", "a"); var secondSend = provider.SendMessageAsync("main", "b"); firstGate.SetResult(); await Assert.ThrowsAsync(() => firstSend); + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); secondGate.SetResult(); await secondSend; + for (var i = 0; i < 20 && bridge.SendResults.Count > 0; i++) + await Task.Delay(10); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b")); var latest = snapshots[^1]; @@ -560,6 +672,32 @@ public async Task Status_ReconnectClearsUncorrelatedQueuedMessagesBeforeHistoryR await sendTask; } + [Fact] + public async Task Status_ReconnectAfterActiveRun_DoesNotStrandFutureSendBehindStaleRunId() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-a", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); + Assert.Equal(new[] { "a" }, bridge.SentMessages); + + bridge.RaiseStatus(ConnectionStatus.Disconnected); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "b"); + + Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); + var queued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("b", queued.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); + } + [Fact] public async Task SendMessageAsync_RejectsEmptyMessage() { @@ -936,6 +1074,33 @@ public async Task AgentEvent_JobError_EmitsErrorEntry() e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("kaboom")); } + [Fact] + public async Task AgentEvent_JobError_DispatchesNextQueuedMessage() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + + var evt = MakeAgentEvent("job", """{"state":"error"}""", runId: "run-1"); + evt.Summary = "kaboom"; + bridge.RaiseAgent(evt); + + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); + + Assert.Equal(new[] { "first", "second" }, bridge.SentMessages); + var queued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second", queued.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); + } + [Fact] public async Task AgentEvent_JobDone_ClearsTurnActive() { @@ -1033,6 +1198,14 @@ public async Task LoadHistoryAsync_DedupesQueuedLocalPromotionsWhenHistoryTimest bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-a", Status = "started" }); await provider.SendMessageAsync("main", "a"); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "a response", + State = "final" + }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-a")); bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); await provider.SendMessageAsync("main", "b"); @@ -1088,6 +1261,14 @@ public async Task LoadHistoryAsync_KeepsSecondIdenticalQueuedPromptWhenHistoryCo bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-same-1", Status = "started" }); await provider.SendMessageAsync("main", "same"); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-same-1")); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "first same response", + State = "final" + }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-same-1")); bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-same-2", Status = "started" }); await provider.SendMessageAsync("main", "same"); @@ -1182,10 +1363,13 @@ public async Task SessionResetCompletion_ClearsThreadTimelineAndIgnoresStaleHist } [Fact] - public async Task SessionResetCompletion_DropsLateLiveEventsUntilNewUserMessage() + public async Task SessionResetCompletion_DropsLiveEventsFromPreResetSubmittedRun() { var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + var historyCalls = 0; + bridge.HistoryBehavior = _ => { historyCalls++; return Task.FromResult(new ChatHistoryInfo { SessionKey = "main" }); }; await provider.LoadAsync(); + historyCalls = 0; bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "old-run")); snapshots.Clear(); @@ -1204,9 +1388,13 @@ public async Task SessionResetCompletion_DropsLateLiveEventsUntilNewUserMessage( State = "final", Text = "stale chat" }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "old-run")); + for (var i = 0; i < 20 && historyCalls == 0; i++) + await Task.Delay(10); var latest = snapshots[^1]; Assert.Empty(latest.Timelines["main"].Entries); + Assert.True(historyCalls > 0); bridge.RaiseChat(new ChatMessageInfo { @@ -1238,8 +1426,182 @@ public async Task SessionResetCompletion_DropsLateLiveEventsUntilNewUserMessage( e.Kind == ChatTimelineItemKind.User && e.Text == "new remote message"); Assert.Contains(latest.Timelines["main"].Entries, e => e.Kind == ChatTimelineItemKind.Assistant && e.Text == "new response"); - Assert.DoesNotContain(latest.Timelines["main"].Entries, e => - e.Text.Contains("stale", StringComparison.Ordinal)); + } + + [Fact] + public async Task SessionResetCompletion_AbortsAndSuppressesLiveFramesFromPreResetSubmittedQueuedSend() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + var historyCalls = 0; + bridge.HistoryBehavior = _ => { historyCalls++; return Task.FromResult(new ChatHistoryInfo { SessionKey = "main" }); }; + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-a", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-fresh", Status = "started" }); + await provider.LoadAsync(); + historyCalls = 0; + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); + await provider.SendMessageAsync("main", "b"); + await provider.SendMessageAsync("main", "c"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-a")); + + Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); + + bridge.RaiseSessionCommandCompleted(new SessionCommandResult + { + Method = "sessions.reset", + Ok = true, + Key = "main" + }); + + var resetSnapshot = snapshots[^1]; + Assert.Empty(resetSnapshot.Timelines["main"].Entries); + Assert.Empty(GetQueuedMessages(resetSnapshot, "main")); + + var staleTs = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds(); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "b", + Ts = staleTs + }); + var staleStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b"); + staleStart.Ts = staleTs; + bridge.RaiseAgent(staleStart); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + State = "final", + Text = "stale response", + Ts = staleTs + }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-b")); + + var latest = snapshots[^1]; + Assert.Empty(latest.Timelines["main"].Entries); + Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); + for (var i = 0; i < 20 && !bridge.AbortedRunIds.Contains("run-b"); i++) + await Task.Delay(10); + Assert.Contains("run-b", bridge.AbortedRunIds); + Assert.DoesNotContain("run-c", bridge.AbortedRunIds); + for (var i = 0; i < 20 && historyCalls == 0; i++) + await Task.Delay(10); + Assert.True(historyCalls > 0); + + await provider.SendMessageAsync("main", "fresh"); + var freshStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-fresh"); + freshStart.Ts = DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds(); + bridge.RaiseAgent(freshStart); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + State = "final", + Text = "fresh response", + Ts = DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds() + }); + + latest = snapshots[^1]; + Assert.Contains(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "fresh response"); + } + + [Fact] + public async Task SessionResetCompletion_ShowsPersistedSubmittedRunFromForcedHistoryReload() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + var historyCalls = 0; + var includePersisted = false; + bridge.HistoryBehavior = _ => + { + historyCalls++; + return Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = includePersisted + ? new[] + { + new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "b", + State = "final" + }, + new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "persisted response", + State = "final" + } + } + : Array.Empty() + }); + }; + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-a", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); + await provider.LoadAsync(); + historyCalls = 0; + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); + await provider.SendMessageAsync("main", "b"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-a")); + Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); + + bridge.RaiseSessionCommandCompleted(new SessionCommandResult + { + Method = "sessions.reset", + Ok = true, + Key = "main" + }); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "b", + Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds() + }); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + State = "final", + Text = "live stale response", + Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds() + }); + + Assert.Empty(snapshots[^1].Timelines["main"].Entries); + + includePersisted = true; + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-b")); + for (var i = 0; i < 20; i++) + { + if (historyCalls > 0 && + snapshots[^1].Timelines["main"].Entries.Any(e => + e.Kind == ChatTimelineItemKind.Assistant && + e.Text == "persisted response")) + { + break; + } + await Task.Delay(10); + } + + var latest = snapshots[^1]; + Assert.Contains(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "b"); + Assert.Contains(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "persisted response"); + Assert.DoesNotContain(latest.Timelines["main"].Entries, e => e.Text == "live stale response"); } [Fact] @@ -1353,8 +1715,10 @@ public async Task SessionResetCompletion_LocalSendDoesNotReopenGateForStaleChatF }); latest = await provider.LoadAsync(); - Assert.Single(latest.Timelines["main"].Entries, e => - e.Kind == ChatTimelineItemKind.User && e.Text == "second after reset"); + Assert.Collection( + GetQueuedMessages(latest, "main"), + first => Assert.Equal("after reset local", first.Text), + second => Assert.Equal("second after reset", second.Text)); var freshStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "new-run"); freshStart.Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds(); @@ -1857,6 +2221,28 @@ public async Task DisposeAsync_UnsubscribesAndStopsRaisingChanged() Assert.True(bridge.IsDisposed); } + [Fact] + public async Task DisposeAsync_WithQueuedFollowUp_DoesNotDrainNextMessage() + { + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "b"); + + Assert.Equal(new[] { "a" }, bridge.SentMessages); + + await provider.DisposeAsync(); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-1")); + + Assert.Equal(new[] { "a" }, bridge.SentMessages); + Assert.True(bridge.IsDisposed); + } + [Fact] public async Task LoadAsync_FreshInstall_NoSessions_ExposesNotReadyComposeTarget() { @@ -2045,6 +2431,31 @@ public async Task AgentEvent_LifecycleError_AppendsErrorStatusEntry() e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("model unreachable")); } + [Fact] + public async Task AgentEvent_LifecycleError_DispatchesNextQueuedMessage() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"error","message":"model unreachable"}""", runId: "run-1")); + + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); + + Assert.Equal(new[] { "first", "second" }, bridge.SentMessages); + var queued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second", queued.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); + } + [Fact] public async Task AgentEvent_ReasoningDelta_AccumulatesReasoningEntry() { @@ -2144,6 +2555,32 @@ public async Task StopResponseAsync_AfterLifecycleEnd_NoLongerAborts() Assert.Empty(bridge.AbortedRunIds); } + [Fact] + public async Task StopResponseAsync_WithQueuedFollowUp_DispatchesNextMessage() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + + await provider.StopResponseAsync("main"); + + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); + + Assert.Contains("run-1", bridge.AbortedRunIds); + Assert.Equal(new[] { "first", "second" }, bridge.SentMessages); + var queued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second", queued.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); + } + [Fact] public async Task LoadHistoryAsync_FoldsTranscriptIntoTimeline() { From 7564afa85517be57db3290c07c8d19a5df531586 Mon Sep 17 00:00:00 2001 From: Caleb Eden <58373773+calebeden@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:24:05 -0700 Subject: [PATCH 03/11] Render idle chat sends directly Avoid briefly showing the first idle user message as a queued card. Keep busy follow-up prompts in the client queue while preserving direct-send echo suppression and history reconciliation metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Chat/OpenClawChatDataProvider.cs | 122 +++++++++++---- .../OpenClawChatDataProviderTests.cs | 147 ++++++++++-------- 2 files changed, 168 insertions(+), 101 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 20394b7b9..8377fbccb 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -338,38 +338,46 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation : $"{safeUserText}\n{chips}"; } - // 1. Queue the user message locally. If an agent turn is already - // active, this remains client-only until the turn ends; otherwise the - // oldest queued item is submitted immediately. + // 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 queuedMessageId; + string messageId; QueuedSendDispatch? dispatch; lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); - queuedMessageId = $"q{++_queuedMessageSequence}"; - if (!HasSendingQueuedMessagesLocked(threadId)) + messageId = $"q{++_queuedMessageSequence}"; + if (CanClearAssistantFallbackPromotionLocked(threadId)) _assistantFallbackPromotedThreads.Remove(threadId); - AddQueuedMessageLocked(threadId, new ChatQueuedMessage( - queuedMessageId, - displayText, - DateTimeOffset.UtcNow, - nonce)); - AddQueuedSendRequestLocked(new QueuedSendRequest( - queuedMessageId, - threadId, - trimmed, - displayText, - nonce, - attachments?.ToArray())); - // 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); - dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: false); + + var request = new QueuedSendRequest( + messageId, + threadId, + trimmed, + displayText, + nonce, + attachments?.ToArray()); + + if (CanSendDirectlyLocked(threadId)) + { + dispatch = StartDirectSendLocked(request); + } + else + { + AddQueuedMessageLocked(threadId, new ChatQueuedMessage( + messageId, + displayText, + DateTimeOffset.UtcNow, + nonce)); + AddQueuedSendRequestLocked(request); + dispatch = TryStartNextQueuedSendLocked(threadId, requireConnected: false); + } snapshot = BuildSnapshotLocked(); } @@ -422,14 +430,18 @@ private async Task DispatchQueuedSendAsync( { TrackQueuedMessageRunLocked(threadId, sendResult.RunId!, request.Id); AddResetAcceptedRunIdLocked(threadId, sendResult.RunId!); - if (_activeRunIds.TryGetValue(threadId, out var activeRunId) + var runAlreadyStarted = _activeRunIds.TryGetValue(threadId, out var activeRunId) && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence) && string.Equals(activeRunId, sendResult.RunId, StringComparison.Ordinal) - && activeStartSequence > dispatch.StartedRunStartSequence - && PromoteQueuedMessageLocked(threadId, request.Id)) + && activeStartSequence > dispatch.StartedRunStartSequence; + if (runAlreadyStarted && PromoteQueuedMessageLocked(threadId, request.Id)) { acceptedRunAlreadyStartedSnapshot = BuildSnapshotLocked(); } + else if (runAlreadyStarted) + { + RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id); + } } else if (_resetAwaitingUserMessage.Contains(threadId)) { @@ -2621,6 +2633,47 @@ private void RemoveQueuedSendRequestLocked(string threadId, string messageId) 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 !_queuedMessages.TryGetValue(threadId, out var queuedMessages) || queuedMessages.Count == 0; + } + + 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); + _sessionIds.TryGetValue(threadId, out var sessionId); + + EnqueueLocalEchoLocked(threadId, request.Text, request.Id); + _locallyInitiatedThreads.Add(threadId); + _assistantFallbackPromotedThreads.Add(threadId); + return new QueuedSendDispatch( + request, + sessionId, + resetVersion, + startedLifecycleSequence, + startedRunStartSequence); + } + private QueuedSendDispatch? TryStartNextQueuedSendLocked(string threadId, bool requireConnected) { if (requireConnected && _status != ConnectionStatus.Connected) @@ -2648,15 +2701,7 @@ private void RemoveQueuedSendRequestLocked(string threadId, string messageId) _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(GetOrCreateTimelineLocked(threadId)); _sessionIds.TryGetValue(threadId, out var sessionId); - if (!_localSentTexts.TryGetValue(threadId, out var localEchoQueue)) - { - localEchoQueue = new Queue(); - _localSentTexts[threadId] = localEchoQueue; - } - localEchoQueue.Enqueue(new LocalSentText(request.Text, DateTimeOffset.UtcNow, request.Id)); - while (localEchoQueue.Count > 20) - localEchoQueue.Dequeue(); - + EnqueueLocalEchoLocked(threadId, request.Text, request.Id); _locallyInitiatedThreads.Add(threadId); return new QueuedSendDispatch( request, @@ -2669,6 +2714,19 @@ private void RemoveQueuedSendRequestLocked(string threadId, string messageId) return null; } + private void EnqueueLocalEchoLocked(string threadId, string text, string 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; diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 80e07e63a..ca3cd1c2f 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -175,7 +175,7 @@ public async Task LoadAsync_CarriesSessionModelProviderToThreads() } [Fact] - public async Task SendMessageAsync_QueuesWithoutTranscriptEntryBeforeAwaitingGateway() + public async Task SendMessageAsync_WhenIdle_RendersTranscriptEntryBeforeAwaitingGateway() { var tcs = new TaskCompletionSource(); var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); @@ -189,11 +189,10 @@ public async Task SendMessageAsync_QueuesWithoutTranscriptEntryBeforeAwaitingGat Assert.Single(snapshots); var timeline = snapshots[0].Timelines["main"]; Assert.True(timeline.TurnActive); - Assert.Empty(timeline.Entries); - var queued = GetQueuedMessages(snapshots[0], "main"); - var queuedMessage = Assert.Single(queued); - Assert.Equal("Hello", queuedMessage.Text); - Assert.Equal(ChatQueuedMessageSendState.Sending, queuedMessage.SendState); + var entry = Assert.Single(timeline.Entries); + Assert.Equal(ChatTimelineItemKind.User, entry.Kind); + Assert.Equal("Hello", entry.Text); + Assert.Empty(GetQueuedMessages(snapshots[0], "main")); Assert.Single(bridge.SentMessages); Assert.Equal("Hello", bridge.SentMessages[0]); Assert.Equal("main", bridge.SentSessionKeys[0]); @@ -203,7 +202,7 @@ public async Task SendMessageAsync_QueuesWithoutTranscriptEntryBeforeAwaitingGat } [Fact] - public async Task SendMessageAsync_KeepsQueuedMessageAfterAckUntilLifecycleStart() + public async Task SendMessageAsync_WhenIdle_DoesNotRenderQueueCardBeforeLifecycleStart() { var tcs = new TaskCompletionSource(); var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); @@ -214,16 +213,17 @@ public async Task SendMessageAsync_KeepsQueuedMessageAfterAckUntilLifecycleStart var sendTask = provider.SendMessageAsync("main", "Hello queue"); - var pending = Assert.Single(GetQueuedMessages(snapshots[0], "main")); + Assert.Empty(GetQueuedMessages(snapshots[0], "main")); + var pending = Assert.Single(snapshots[0].Timelines["main"].Entries); + Assert.Equal(ChatTimelineItemKind.User, pending.Kind); Assert.Equal("Hello queue", pending.Text); - Assert.Equal(ChatQueuedMessageSendState.Sending, pending.SendState); tcs.SetResult(); await sendTask; - var acked = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); - Assert.Equal("Hello queue", acked.Text); - Assert.Equal(ChatQueuedMessageSendState.Sending, acked.SendState); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "Hello queue"); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); @@ -357,18 +357,11 @@ public async Task LifecycleStart_DoesNotClearNextQueuedMessageWhenEarlierEchoAlr await provider.SendMessageAsync("main", "second queued"); Assert.Equal(new[] { "first queued" }, bridge.SentMessages); - Assert.Collection( - GetQueuedMessages(snapshots[^1], "main"), - first => - { - Assert.Equal("first queued", first.Text); - Assert.Equal(ChatQueuedMessageSendState.Sending, first.SendState); - }, - second => - { - Assert.Equal("second queued", second.Text); - Assert.Equal(ChatQueuedMessageSendState.Queued, second.SendState); - }); + var queuedBeforeEcho = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second queued", queuedBeforeEcho.Text); + Assert.Equal(ChatQueuedMessageSendState.Queued, queuedBeforeEcho.SendState); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "first queued"); bridge.RaiseChat(new ChatMessageInfo { @@ -538,11 +531,10 @@ await Assert.ThrowsAsync(() => provider.SendMessageAsync("main", "Hi")); var timeline = snapshots[^1].Timelines["main"]; + Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "Hi"); Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("boom")); Assert.False(timeline.TurnActive); - var failedQueued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); - Assert.Equal(ChatQueuedMessageSendState.Failed, failedQueued.SendState); - Assert.Contains("boom", failedQueued.ErrorText); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); Assert.Contains(notifications, n => n.Kind == ChatProviderNotificationKind.Error); } @@ -559,11 +551,9 @@ await Assert.ThrowsAsync(() => provider.SendMessageAsync("main", "Hi")); var timeline = snapshots[^1].Timelines["main"]; + Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "Hi"); Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("model unavailable")); - var failedQueued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); - Assert.Equal("Hi", failedQueued.Text); - Assert.Equal(ChatQueuedMessageSendState.Failed, failedQueued.SendState); - Assert.Equal("model unavailable", failedQueued.ErrorText); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); Assert.Contains(notifications, n => n.Kind == ChatProviderNotificationKind.Error); } @@ -598,12 +588,11 @@ public async Task SendMessageAsync_FailedFirstSendDoesNotOrphanLaterQueuedSend() bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b")); var latest = snapshots[^1]; + Assert.Contains(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "a"); Assert.Contains(latest.Timelines["main"].Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "b"); - var queued = GetQueuedMessages(latest, "main"); - var failed = Assert.Single(queued); - Assert.Equal("a", failed.Text); - Assert.Equal(ChatQueuedMessageSendState.Failed, failed.SendState); + Assert.Empty(GetQueuedMessages(latest, "main")); } [Fact] @@ -611,18 +600,28 @@ public async Task LifecycleEnd_IgnoresFailedQueuedMessagesWhenClearingLocalIniti { var historyCalls = 0; var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); - bridge.SendResults.Enqueue(new ChatSendResult { Status = "failed", Error = "first failed" }); - bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-a", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { Status = "failed", Error = "second failed" }); await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); - await Assert.ThrowsAsync(() => - provider.SendMessageAsync("main", "a")); + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); await provider.SendMessageAsync("main", "b"); - bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b")); - bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-b")); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-a")); + for (var i = 0; i < 20; i++) + { + if (GetQueuedMessages(snapshots[^1], "main").Any(q => + q.Text == "b" && + q.SendState == ChatQueuedMessageSendState.Failed)) + { + break; + } + await Task.Delay(10); + } var failed = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); - Assert.Equal("a", failed.Text); + Assert.Equal("b", failed.Text); Assert.Equal(ChatQueuedMessageSendState.Failed, failed.SendState); bridge.HistoryBehavior = _ => @@ -662,7 +661,8 @@ public async Task Status_ReconnectClearsUncorrelatedQueuedMessagesBeforeHistoryR bridge.SendBehavior = (_, _, _) => sendGate.Task; await provider.LoadAsync(); - var sendTask = provider.SendMessageAsync("main", "pending across reconnect"); + var sendTask = provider.SendMessageAsync("main", "active across reconnect"); + await provider.SendMessageAsync("main", "pending across reconnect"); Assert.Equal("pending across reconnect", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); bridge.RaiseStatus(ConnectionStatus.Connected); @@ -693,9 +693,9 @@ public async Task Status_ReconnectAfterActiveRun_DoesNotStrandFutureSendBehindSt await provider.SendMessageAsync("main", "b"); Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); - var queued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); - Assert.Equal("b", queued.Text); - Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Contains(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "b"); } [Fact] @@ -888,6 +888,7 @@ public async Task ChatMessageReceived_UserEcho_Ignored() await provider.LoadAsync(); _ = provider.SendMessageAsync("main", "hi"); + var beforeEcho = snapshots[^1]; snapshots.Clear(); // clear the snapshot from SendMessageAsync bridge.RaiseChat(new ChatMessageInfo @@ -898,9 +899,10 @@ public async Task ChatMessageReceived_UserEcho_Ignored() State = "final" }); - var timeline = snapshots[^1].Timelines["main"]; + Assert.Empty(snapshots); + var timeline = beforeEcho.Timelines["main"]; Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); - Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Empty(GetQueuedMessages(beforeEcho, "main")); tcs.SetResult(); } @@ -1020,7 +1022,7 @@ await Assert.ThrowsAsync(() => }); var timeline = snapshots[^1].Timelines["main"]; - Assert.Equal(1, timeline.Entries.Count(e => + Assert.Equal(2, timeline.Entries.Count(e => e.Kind == ChatTimelineItemKind.User && e.Text == "same text")); } @@ -1345,8 +1347,9 @@ public async Task SessionResetCompletion_ClearsThreadTimelineAndIgnoresStaleHist await provider.SendMessageAsync("main", "after reset"); latest = snapshots[^1]; - Assert.Empty(latest.Timelines["main"].Entries); - Assert.Equal("after reset", Assert.Single(GetQueuedMessages(latest, "main")).Text); + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "after reset"); + Assert.Empty(GetQueuedMessages(latest, "main")); bridge.RaiseChat(new ChatMessageInfo { @@ -1701,7 +1704,9 @@ public async Task SessionResetCompletion_LocalSendDoesNotReopenGateForStaleChatF }); var latest = await provider.LoadAsync(); - Assert.Equal("after reset local", Assert.Single(GetQueuedMessages(latest, "main")).Text); + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "after reset local"); + Assert.Empty(GetQueuedMessages(latest, "main")); Assert.DoesNotContain(latest.Timelines["main"].Entries, e => e.Text.Contains("stale", StringComparison.Ordinal)); @@ -1715,10 +1720,9 @@ public async Task SessionResetCompletion_LocalSendDoesNotReopenGateForStaleChatF }); latest = await provider.LoadAsync(); - Assert.Collection( - GetQueuedMessages(latest, "main"), - first => Assert.Equal("after reset local", first.Text), - second => Assert.Equal("second after reset", second.Text)); + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "after reset local"); + Assert.Equal("second after reset", Assert.Single(GetQueuedMessages(latest, "main")).Text); var freshStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "new-run"); freshStart.Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds(); @@ -1913,7 +1917,9 @@ public async Task SessionResetCompletion_PreResetSendAckDoesNotClearNewQueuedMes var firstSend = provider.SendMessageAsync("main", "before reset"); await firstStarted.Task; - Assert.Equal("before reset", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "before reset"); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); bridge.RaiseSessionCommandCompleted(new SessionCommandResult { @@ -1924,14 +1930,16 @@ public async Task SessionResetCompletion_PreResetSendAckDoesNotClearNewQueuedMes Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); var secondSend = provider.SendMessageAsync("main", "after reset"); - Assert.Equal("after reset", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "after reset"); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); firstGate.SetResult(); await firstSend; - var queuedAfterStaleAck = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); - Assert.Equal("after reset", queuedAfterStaleAck.Text); - Assert.Equal(ChatQueuedMessageSendState.Sending, queuedAfterStaleAck.SendState); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "after reset"); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); secondGate.SetResult(); await secondSend; @@ -4603,7 +4611,7 @@ public async Task SendMessageAsync_WithAttachment_SendsThroughInterface() var sentAttachment = Assert.Single(bridge.SentAttachments); Assert.NotNull(sentAttachment); Assert.Same(attachment, sentAttachment![0]); - Assert.Contains("test.txt", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + Assert.Contains("test.txt", snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User).Text); bridge.RaiseChat(new ChatMessageInfo { @@ -4652,7 +4660,7 @@ public async Task SendMessageAsync_WithMultipleAttachments_SendsAndRendersAllMar a => Assert.Same(imageAttachment, a)); Assert.Equal( "See both\n\u200B📎 notes.txt\n\u200B🖼️ diagram.png", - Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User).Text); bridge.RaiseChat(new ChatMessageInfo { @@ -4824,7 +4832,7 @@ public async Task SendMessageAsync_WithoutAttachment_EscapesPastedMarkerText() await provider.SendMessageAsync("main", "\u200B📎 spoof.txt"); - Assert.Equal("📎 spoof.txt", Assert.Single(GetQueuedMessages(snapshots[^1], "main")).Text); + Assert.Equal("📎 spoof.txt", snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User).Text); bridge.RaiseChat(new ChatMessageInfo { @@ -5027,8 +5035,9 @@ public async Task SendMessageAsync_FreshInstall_QueuedStateKeyedByCanonicalSessi Assert.True(latest.Timelines.ContainsKey("agent:main:main")); Assert.False(latest.Timelines.ContainsKey("main"), "The provider must never key timelines by the literal 'main' alias."); - Assert.Empty(latest.Timelines["agent:main:main"].Entries); - Assert.Equal("hi", Assert.Single(GetQueuedMessages(latest, "agent:main:main")).Text); + Assert.Single(latest.Timelines["agent:main:main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); + Assert.Empty(GetQueuedMessages(latest, "agent:main:main")); } [Fact] @@ -5056,7 +5065,7 @@ public async Task SendMessageAsync_FreshInstall_SnapshotExposesComposeOnlyTimeli public async Task SessionsUpdated_AfterFirstSend_PreservesQueuedLocalState() { // The critical assertion: when the gateway materializes the session - // and emits SessionsUpdated with the canonical key, the queued local + // and emits SessionsUpdated with the canonical key, the direct local // state that was written under that exact key SURVIVES (no second // empty timeline gets created on top of it). var (bridge, provider, snapshots) = CreateConnectedProvider("agent:main:main"); @@ -5072,8 +5081,8 @@ public async Task SessionsUpdated_AfterFirstSend_PreservesQueuedLocalState() Assert.Single(latest.Threads); Assert.Equal("agent:main:main", latest.Threads[0].Id); var timeline = latest.Timelines["agent:main:main"]; - Assert.Empty(timeline.Entries); - Assert.Equal("hi", Assert.Single(GetQueuedMessages(latest, "agent:main:main")).Text); + Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); + Assert.Empty(GetQueuedMessages(latest, "agent:main:main")); } [Fact] From a1601f23c80b0aab51a457b8c05aafb0f1416a80 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 06:39:47 +0100 Subject: [PATCH 04/11] fix: harden queued chat lifecycle --- .../Chat/ChatEntryMetadata.cs | 7 +- .../Chat/OpenClawChatDataProvider.cs | 187 +++++++++++++++--- .../OpenClawChatDataProviderTests.cs | 110 ++++++++++- 3 files changed, 264 insertions(+), 40 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs index 858c5585e..16e9b8f59 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs @@ -56,6 +56,10 @@ namespace OpenClawTray.Chat; /// True for a locally queued user prompt promoted into the transcript before /// gateway history has provided its stable id/sequence. /// +/// +/// Stable client-side id for a local send. Used to attach a later gateway +/// identity to the exact optimistic transcript row without text matching. +/// public sealed record ChatEntryMetadata( DateTimeOffset? Timestamp, string? Model, @@ -67,4 +71,5 @@ public sealed record ChatEntryMetadata( int? UsageContributionTokens = null, string? GatewayMessageId = null, int? OpenClawSeq = null, - bool IsLocalQueuedSend = false); + bool IsLocalQueuedSend = false, + string? LocalQueuedMessageId = null); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 8377fbccb..1d7d2f5a7 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -417,6 +417,7 @@ private async Task DispatchQueuedSendAsync( } bool sendStillCurrent; + string? staleRunIdToAbort = null; ChatDataSnapshot? acceptedRunAlreadyStartedSnapshot = null; lock (_gate) { @@ -424,7 +425,10 @@ private async Task DispatchQueuedSendAsync( if (!sendStillCurrent) { if (!string.IsNullOrEmpty(sendResult.RunId)) + { AddResetIgnoredRunIdLocked(threadId, sendResult.RunId!); + staleRunIdToAbort = sendResult.RunId; + } } else if (!string.IsNullOrEmpty(sendResult.RunId)) { @@ -454,6 +458,19 @@ private async Task DispatchQueuedSendAsync( if (acceptedRunAlreadyStartedSnapshot is not null) Publish(acceptedRunAlreadyStartedSnapshot); + if (staleRunIdToAbort is not null) + { + try + { + Logger.Info($"[Reset] Aborting late pre-reset send runId='{staleRunIdToAbort}' threadId='{threadId}'"); + await _bridge.SendChatAbortAsync(staleRunIdToAbort, threadId); + } + catch (Exception abortEx) + { + Logger.Warn($"[Reset] Failed to abort late pre-reset send runId='{staleRunIdToAbort}': {abortEx.Message}"); + } + } + if (hasAttachments && sendStillCurrent) CacheAttachmentMeta(dispatch.SessionId, threadId, request.Text, request.Attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), dispatch.ResetVersion); } @@ -900,7 +917,6 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr var rebuiltContentTimestamps = new Dictionary>(StringComparer.Ordinal); var rebuiltMessageIds = new HashSet(StringComparer.Ordinal); var rebuiltSequenceCounts = new Dictionary(StringComparer.Ordinal); - var rebuiltLocalTextCounts = new Dictionary(StringComparer.Ordinal); foreach (var entry in rebuilt.Entries) { rebuiltMeta.TryGetValue(entry.Id, out var em); @@ -908,8 +924,6 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr rebuiltMessageIds.Add(em.GatewayMessageId); if (em?.OpenClawSeq is { } seq) IncrementCount(rebuiltSequenceCounts, SequenceKey(entry.Kind, seq)); - IncrementCount(rebuiltLocalTextCounts, ContentKey(entry.Kind, entry.Text)); - if (em?.Timestamp is { } rts && rts != default) { var key = ContentKey(entry.Kind, entry.Text); @@ -954,14 +968,6 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr continue; } - if (em?.IsLocalQueuedSend == true && - TryConsumeCount(rebuiltLocalTextCounts, ContentKey(entry.Kind, entry.Text))) - { - ConsumeAnyTimestamp(rebuiltContentTimestamps, ContentKey(entry.Kind, entry.Text)); - skippedDup++; - 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 @@ -1017,8 +1023,6 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr rebuiltMessageIds.Add(em.GatewayMessageId); if (em?.OpenClawSeq is { } addSeq) IncrementCount(rebuiltSequenceCounts, SequenceKey(entryToAdd.Kind, addSeq)); - IncrementCount(rebuiltLocalTextCounts, ContentKey(entryToAdd.Kind, entryToAdd.Text)); - if (em is not null && !rebuiltMeta.ContainsKey(entryToAdd.Id)) rebuiltMeta[entryToAdd.Id] = em; } @@ -1900,10 +1904,15 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (consumeEchoText is not null && _localSentTexts.TryGetValue(msgThreadId, out var resetEchoQueue) && resetEchoQueue.Count > 0 && - TryConsumeLocalEchoLocked(msgThreadId, resetEchoQueue, consumeEchoText, out var queuedMessageId) && - PromoteQueuedMessageLocked(msgThreadId, queuedMessageId)) + TryConsumeLocalEchoLocked(msgThreadId, resetEchoQueue, consumeEchoText, out var queuedMessageId)) { - resetLocalEchoSnapshot = BuildSnapshotLocked(); + var confirmedMeta = BuildLiveMetaLocked( + msgThreadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + if (ReconcileQueuedMessageEchoLocked(msgThreadId, queuedMessageId, confirmedMeta)) + resetLocalEchoSnapshot = BuildSnapshotLocked(); } } else if (_abortedThreads.Contains(msgThreadId)) @@ -1987,7 +1996,12 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) && TryConsumeLocalEchoLocked(msgThreadId, q, echoText, out var echoEntryId)) { isLocalEcho = true; - if (PromoteQueuedMessageLocked(msgThreadId, echoEntryId)) + var confirmedMeta = BuildLiveMetaLocked( + msgThreadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + if (ReconcileQueuedMessageEchoLocked(msgThreadId, echoEntryId, confirmedMeta)) echoSnapshot = BuildSnapshotLocked(); } } @@ -2059,9 +2073,13 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) return; var threadId = message.SessionKey; - PromoteOldestQueuedMessageBeforeAssistantIfNeeded(threadId); - ChatEntryMetadata? meta; var cappedAssistantText = RepairContentBlockSeams(TruncateForChatEntry(message.Text)); + PromoteOldestQueuedMessageBeforeAssistantIfNeeded( + threadId, + cappedAssistantText, + message.OpenClawId, + message.OpenClawSeq); + ChatEntryMetadata? meta; var hasUsage = message.InputTokens is not null || message.OutputTokens is not null || message.ResponseTokens is not null || message.ContextPercent is not null; lock (_gate) @@ -2159,6 +2177,7 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) return; } var threadId = evt.SessionKey; + var isTerminalRunEvent = IsTerminalRunEvent(evt); var reloadHistoryAfterResetDrop = false; var shouldProcessEvent = false; @@ -2215,12 +2234,19 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) // Suppress rendering for aborted runs/threads (but lifecycle events // already ran above for state cleanup). + var suppressRendering = false; lock (_gate) { if (!string.IsNullOrEmpty(evt.RunId) && _abortedRunIds.Contains(evt.RunId)) - return; - if (_abortedThreads.Contains(threadId)) - return; + suppressRendering = true; + else if (_abortedThreads.Contains(threadId)) + suppressRendering = true; + } + if (suppressRendering) + { + if (isTerminalRunEvent) + TryDispatchNextQueuedSend(threadId); + return; } ChatEvent? mapped = MapAgentEvent(evt); @@ -2295,6 +2321,8 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) } } } + if (isTerminalRunEvent) + TryDispatchNextQueuedSend(threadId); return; } @@ -2311,6 +2339,8 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) lock (_gate) { meta = BuildLiveMetaLocked(threadId, tsMs); } ApplyEventAndPublish(threadId, mapped, meta); + if (isTerminalRunEvent) + TryDispatchNextQueuedSend(threadId); } private void RaiseKeylessEventDiagnosticOnce() @@ -2571,7 +2601,11 @@ private bool TryReconcileExistingLocalQueuedUserEchoLocked( if (!IsFreshLocalQueuedPromotion(existing, confirmedMeta)) continue; - threadMeta[entry.Id] = confirmedMeta with { IsLocalQueuedSend = false }; + threadMeta[entry.Id] = confirmedMeta with + { + IsLocalQueuedSend = false, + LocalQueuedMessageId = existing.LocalQueuedMessageId, + }; return true; } @@ -2660,7 +2694,10 @@ private QueuedSendDispatch StartDirectSendLocked(QueuedSendRequest request) 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); + GetOrCreateThreadMetaLocked(threadId)[entryId] = BuildLiveMetaLocked( + threadId, + isLocalQueuedSend: true, + localQueuedMessageId: request.Id); _sessionIds.TryGetValue(threadId, out var sessionId); EnqueueLocalEchoLocked(threadId, request.Text, request.Id); @@ -2694,6 +2731,9 @@ private QueuedSendDispatch StartDirectSendLocked(QueuedSendRequest request) if (request is null) continue; + // 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; @@ -2773,7 +2813,10 @@ private bool RemoveQueuedMessageLocked(string threadId, string messageId) return removed; } - private bool PromoteQueuedMessageLocked(string threadId, string messageId) + private bool PromoteQueuedMessageLocked( + string threadId, + string messageId, + ChatEntryMetadata? confirmedMeta = null) { if (!_queuedMessages.TryGetValue(threadId, out var list)) return false; @@ -2787,7 +2830,13 @@ private bool PromoteQueuedMessageLocked(string threadId, string messageId) var entryId = $"e{current.NextId}"; _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, queued.Text, queued.LocalNonce); - var meta = BuildLiveMetaLocked(threadId, isLocalQueuedSend: true); + 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; @@ -2800,6 +2849,39 @@ private bool PromoteQueuedMessageLocked(string threadId, string messageId) return true; } + 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)) @@ -4127,14 +4209,11 @@ private void ApplyEventAndPublish(string threadId, ChatEvent evt, ChatEntryMetad evt = TruncateChatEvent(evt); ChatDataSnapshot snapshot; - var shouldDrainQueuedSend = evt is ChatTurnEndEvent or ChatErrorEvent; lock (_gate) { snapshot = ApplyEventLocked(threadId, evt, meta); } Publish(snapshot); - if (shouldDrainQueuedSend) - TryDispatchNextQueuedSend(threadId); } private ChatDataSnapshot ApplyEventLocked(string threadId, ChatEvent evt, ChatEntryMetadata? meta) @@ -4434,7 +4513,11 @@ private bool ShouldDropChatMessageAfterResetLocked( return true; } - private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId) + private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded( + string threadId, + string assistantText, + string? gatewayMessageId, + int? openClawSeq) { ChatDataSnapshot? snapshot = null; lock (_gate) @@ -4449,6 +4532,11 @@ private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId) && TryGetSingleSendingQueuedMessageLocked(threadId, out var queued) && !_activeRunIds.ContainsKey(threadId) && !_assistantFallbackPromotedThreads.Contains(threadId) + && !IsDuplicateCompletedAssistantLocked( + threadId, + assistantText, + gatewayMessageId, + openClawSeq) && PromoteQueuedMessageLocked(threadId, queued.Id)) { snapshot = BuildSnapshotLocked(); @@ -4459,6 +4547,39 @@ private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId) Publish(snapshot); } + private bool IsDuplicateCompletedAssistantLocked( + string threadId, + string assistantText, + string? gatewayMessageId, + int? openClawSeq) + { + if (!_timelines.TryGetValue(threadId, 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; + if (entry.IsStreaming || !string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) + return false; + + if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null) + return true; + if (!_entryMeta.TryGetValue(threadId, out var threadMeta) || + !threadMeta.TryGetValue(entry.Id, out var existing)) + { + return false; + } + + return (!string.IsNullOrEmpty(gatewayMessageId) && + string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) || + (openClawSeq is not null && existing.OpenClawSeq == openClawSeq); + } + + return false; + } + private bool HasSendingQueuedMessagesLocked(string threadId) => _queuedMessages.TryGetValue(threadId, out var queued) && queued.Any(message => message.SendState == ChatQueuedMessageSendState.Sending); @@ -4863,7 +4984,8 @@ private ChatEntryMetadata BuildLiveMetaLocked( long? tsMs = null, string? gatewayMessageId = null, int? openClawSeq = null, - bool isLocalQueuedSend = false) + bool isLocalQueuedSend = false, + string? localQueuedMessageId = null) { var ts = tsMs is { } v && v > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(v).ToLocalTime() @@ -4874,7 +4996,8 @@ private ChatEntryMetadata BuildLiveMetaLocked( session?.Model, GatewayMessageId: gatewayMessageId, OpenClawSeq: openClawSeq, - IsLocalQueuedSend: isLocalQueuedSend); + IsLocalQueuedSend: isLocalQueuedSend, + LocalQueuedMessageId: localQueuedMessageId); } private static List OrderHistoryMessages(List<(ChatMessageInfo Message, int Index)> messages) diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index ca3cd1c2f..1e52e0d2a 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -877,7 +877,7 @@ public async Task ChatMessageReceived_DeltaAfterFinalAssistantBeforeLifecycleEnd } [Fact] - public async Task ChatMessageReceived_UserEcho_Ignored() + public async Task ChatMessageReceived_UserEcho_AttachesGatewayIdentityToExactLocalRow() { // After sending a message locally, the SSE echo of that same text // should be suppressed as a gateway echo while atomically moving the @@ -888,7 +888,6 @@ public async Task ChatMessageReceived_UserEcho_Ignored() await provider.LoadAsync(); _ = provider.SendMessageAsync("main", "hi"); - var beforeEcho = snapshots[^1]; snapshots.Clear(); // clear the snapshot from SendMessageAsync bridge.RaiseChat(new ChatMessageInfo @@ -896,13 +895,20 @@ public async Task ChatMessageReceived_UserEcho_Ignored() SessionKey = "main", Role = "user", Text = "hi", - State = "final" + State = "final", + OpenClawId = "gateway-hi", + OpenClawSeq = 7, }); - Assert.Empty(snapshots); - var timeline = beforeEcho.Timelines["main"]; + var timeline = Assert.Single(snapshots).Timelines["main"]; Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); - Assert.Empty(GetQueuedMessages(beforeEcho, "main")); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + var user = Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User); + var meta = provider.GetEntryMetadata("main")[user.Id]; + Assert.Equal("gateway-hi", meta.GatewayMessageId); + Assert.Equal(7, meta.OpenClawSeq); + Assert.False(meta.IsLocalQueuedSend); + Assert.NotNull(meta.LocalQueuedMessageId); tcs.SetResult(); } @@ -1201,6 +1207,15 @@ public async Task LoadHistoryAsync_DedupesQueuedLocalPromotionsWhenHistoryTimest await provider.SendMessageAsync("main", "a"); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "a", + State = "final", + OpenClawId = "gateway-a", + OpenClawSeq = 1, + }); + bridge.RaiseChat(new ChatMessageInfo { SessionKey = "main", Role = "assistant", @@ -1212,6 +1227,15 @@ public async Task LoadHistoryAsync_DedupesQueuedLocalPromotionsWhenHistoryTimest bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-b", Status = "started" }); await provider.SendMessageAsync("main", "b"); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-b")); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "b", + State = "final", + OpenClawId = "gateway-b", + OpenClawSeq = 2, + }); Assert.Equal( new[] { "a", "b" }, @@ -1302,6 +1326,37 @@ public async Task LoadHistoryAsync_KeepsSecondIdenticalQueuedPromptWhenHistoryCo Assert.Equal(new[] { "same", "same" }, userTexts); } + [Fact] + public async Task LoadHistoryAsync_DoesNotDropFreshIdenticalLocalPromptForStaleHistoryRow() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-same", Status = "started" }); + await provider.LoadAsync(); + + await provider.SendMessageAsync("main", "same"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-same")); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "same", + Ts = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds(), + }, + } + }); + snapshots.Clear(); + + await provider.LoadHistoryAsync("main"); + + Assert.Equal(2, snapshots[^1].Timelines["main"].Entries.Count(e => + e.Kind == ChatTimelineItemKind.User && e.Text == "same")); + } + [Fact] public async Task SessionResetCompletion_ClearsThreadTimelineAndIgnoresStaleHistory() { @@ -1883,6 +1938,8 @@ public async Task SessionResetCompletion_PreResetSendResultAfterResetDoesNotReop sendGate.SetResult(); await staleSendTask; + Assert.Contains("old-run", bridge.AbortedRunIds); + var staleStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "old-run"); staleStart.Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds(); bridge.RaiseAgent(staleStart); @@ -2564,7 +2621,7 @@ public async Task StopResponseAsync_AfterLifecycleEnd_NoLongerAborts() } [Fact] - public async Task StopResponseAsync_WithQueuedFollowUp_DispatchesNextMessage() + public async Task StopResponseAsync_WithQueuedFollowUp_WaitsForConfirmedTerminalEvent() { var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); @@ -2579,6 +2636,11 @@ public async Task StopResponseAsync_WithQueuedFollowUp_DispatchesNextMessage() await provider.StopResponseAsync("main"); + Assert.Equal(new[] { "first" }, bridge.SentMessages); + var waiting = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal(ChatQueuedMessageSendState.Queued, waiting.SendState); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-1")); for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) await Task.Delay(10); @@ -2589,6 +2651,40 @@ public async Task StopResponseAsync_WithQueuedFollowUp_DispatchesNextMessage() Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); } + [Fact] + public async Task QueuedDispatch_AssistantBeforeLifecycle_PromotesEachPromptOnce() + { + var secondSendGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sendCount = 0; + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + bridge.SendBehavior = (_, _, _) => ++sendCount == 2 ? secondSendGate.Task : Task.CompletedTask; + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"end"}""", runId: "run-1")); + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "second response", + State = "delta", + }); + + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second"); + + secondSendGate.SetResult(); + } + [Fact] public async Task LoadHistoryAsync_FoldsTranscriptIntoTimeline() { From d36b6b27a123653102ee8dd1c66f6ee2ccc7b95d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 06:47:36 +0100 Subject: [PATCH 05/11] fix: preserve queued response ordering --- .../Chat/OpenClawChatDataProvider.cs | 95 +++++++++++++------ .../OpenClawChatDataProviderTests.cs | 74 +++++++++++++++ 2 files changed, 139 insertions(+), 30 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 1d7d2f5a7..02a3215af 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -142,6 +142,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider private readonly Dictionary> _queuedMessageIdsByRunId = new(); private readonly Dictionary> _terminalRunIdsByThread = new(); private readonly HashSet _assistantFallbackPromotedThreads = new(StringComparer.Ordinal); + private readonly Dictionary _deferredAssistantMessages = new(StringComparer.Ordinal); private long _queuedMessageSequence; private int _keylessEventDiagnosticRaised; // Threads where we locally initiated the current turn (via SendMessageAsync). @@ -456,7 +457,10 @@ private async Task DispatchQueuedSendAsync( } if (acceptedRunAlreadyStartedSnapshot is not null) + { Publish(acceptedRunAlreadyStartedSnapshot); + ReplayDeferredAssistantMessage(threadId); + } if (staleRunIdToAbort is not null) { @@ -1548,6 +1552,7 @@ public ValueTask DisposeAsync() _queuedSendRequests.Clear(); _queuedMessageIdsByRunId.Clear(); _terminalRunIdsByThread.Clear(); + _deferredAssistantMessages.Clear(); _localSentTexts.Clear(); _locallyInitiatedThreads.Clear(); _resetSubmittedLocalEchoTexts.Clear(); @@ -1648,6 +1653,7 @@ private void OnStatusChanged(object? sender, ConnectionStatus status) _queuedMessages.Clear(); _queuedSendRequests.Clear(); _assistantFallbackPromotedThreads.Clear(); + _deferredAssistantMessages.Clear(); _queuedMessageIdsByRunId.Clear(); _terminalRunIdsByThread.Clear(); _resetSubmittedLocalEchoTexts.Clear(); @@ -1924,7 +1930,10 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (dropAfterReset) { if (resetLocalEchoSnapshot is not null) + { Publish(resetLocalEchoSnapshot); + ReplayDeferredAssistantMessage(msgThreadId); + } if (requestRemoteBackfillAfterReset) _ = FetchRemoteUserMessageAsync(msgThreadId, openResetGateOnSuccess: true); @@ -2008,7 +2017,10 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (isLocalEcho) { if (echoSnapshot is not null) + { Publish(echoSnapshot); + ReplayDeferredAssistantMessage(msgThreadId); + } return; } @@ -2074,11 +2086,26 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) var threadId = message.SessionKey; var cappedAssistantText = RepairContentBlockSeams(TruncateForChatEntry(message.Text)); - PromoteOldestQueuedMessageBeforeAssistantIfNeeded( - threadId, - cappedAssistantText, - message.OpenClawId, - message.OpenClawSeq); + bool deferAssistantMessage; + lock (_gate) + { + deferAssistantMessage = ShouldDeferAmbiguousAssistantMessageLocked( + threadId, + cappedAssistantText, + message.OpenClawId, + message.OpenClawSeq); + if (deferAssistantMessage) + _deferredAssistantMessages[threadId] = message; + else + _deferredAssistantMessages.Remove(threadId); + } + if (deferAssistantMessage) + { + Logger.Debug($"[Queue] Deferring identity-less assistant frame until the queued user boundary is confirmed threadId='{threadId}'"); + return; + } + + PromoteOldestQueuedMessageBeforeAssistantIfNeeded(threadId); ChatEntryMetadata? meta; var hasUsage = message.InputTokens is not null || message.OutputTokens is not null || message.ResponseTokens is not null || message.ContextPercent is not null; @@ -2135,10 +2162,17 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (message.IsFinal) { + lock (_gate) + { + if (_activeRunIds.Remove(threadId, out var completedRunId)) + RememberTerminalRunIdLocked(threadId, completedRunId); + _activeRunStartSequences.Remove(threadId); + } SnapshotLatestAssistantUsage(threadId); ApplyEventAndPublish(threadId, new ChatTurnEndEvent()); RaiseNotification(new ChatProviderNotification( ChatProviderNotificationKind.TurnComplete, threadId, LocalizationHelper.GetString("Chat_Notification_AssistantReplied"))); + TryDispatchNextQueuedSend(threadId); } } @@ -2926,7 +2960,10 @@ private void ClearQueuedMessageOnLocalTurnStart(AgentEventInfo evt, string threa } if (snapshot is not null) + { Publish(snapshot); + ReplayDeferredAssistantMessage(threadId); + } } private bool TryPromoteQueuedMessageOnLocalTurnStartLocked(AgentEventInfo evt, string threadId) @@ -4345,6 +4382,7 @@ private ResetClearPersistence ClearThreadHistoryAfterResetLocked(string threadId _queuedMessageIdsByRunId.Remove(threadId); _terminalRunIdsByThread.Remove(threadId); _assistantFallbackPromotedThreads.Remove(threadId); + _deferredAssistantMessages.Remove(threadId); _resetAcceptedRunIds.Remove(threadId); _resetLocalSendWithoutRunVersions.Remove(threadId); _resetLocalSendWithoutRunStartSequences.Remove(threadId); @@ -4513,11 +4551,7 @@ private bool ShouldDropChatMessageAfterResetLocked( return true; } - private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded( - string threadId, - string assistantText, - string? gatewayMessageId, - int? openClawSeq) + private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId) { ChatDataSnapshot? snapshot = null; lock (_gate) @@ -4532,11 +4566,6 @@ private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded( && TryGetSingleSendingQueuedMessageLocked(threadId, out var queued) && !_activeRunIds.ContainsKey(threadId) && !_assistantFallbackPromotedThreads.Contains(threadId) - && !IsDuplicateCompletedAssistantLocked( - threadId, - assistantText, - gatewayMessageId, - openClawSeq) && PromoteQueuedMessageLocked(threadId, queued.Id)) { snapshot = BuildSnapshotLocked(); @@ -4547,37 +4576,43 @@ private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded( Publish(snapshot); } - private bool IsDuplicateCompletedAssistantLocked( + private bool ShouldDeferAmbiguousAssistantMessageLocked( string threadId, string assistantText, string? gatewayMessageId, int? openClawSeq) { - if (!_timelines.TryGetValue(threadId, out var timeline)) + if (!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null || + !_locallyInitiatedThreads.Contains(threadId) || + !TryGetSingleSendingQueuedMessageLocked(threadId, out _) || + _activeRunIds.ContainsKey(threadId) || + _assistantFallbackPromotedThreads.Contains(threadId) || + !_timelines.TryGetValue(threadId, 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; - if (entry.IsStreaming || !string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) - return false; + return !entry.IsStreaming && string.Equals(entry.Text, assistantText, StringComparison.Ordinal); + } - if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null) - return true; - if (!_entryMeta.TryGetValue(threadId, out var threadMeta) || - !threadMeta.TryGetValue(entry.Id, out var existing)) - { - return false; - } + return false; + } - return (!string.IsNullOrEmpty(gatewayMessageId) && - string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) || - (openClawSeq is not null && existing.OpenClawSeq == openClawSeq); + private void ReplayDeferredAssistantMessage(string threadId) + { + ChatMessageInfo? deferred; + lock (_gate) + { + if (!_deferredAssistantMessages.Remove(threadId, out deferred)) + return; } - return false; + OnChatMessageReceived(this, deferred); } private bool HasSendingQueuedMessagesLocked(string threadId) diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 1e52e0d2a..23476dde1 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -2521,6 +2521,80 @@ public async Task AgentEvent_LifecycleError_DispatchesNextQueuedMessage() Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); } + [Fact] + public async Task AssistantFinal_DispatchesNextQueuedMessageWithoutLifecycleEnd() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "first response", + State = "final", + }); + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); + + Assert.Equal(new[] { "first", "second" }, bridge.SentMessages); + var queued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); + } + + [Fact] + public async Task IdentitylessIdenticalAssistant_DefersUntilQueuedUserBoundaryIsConfirmed() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "OK", + State = "final", + }); + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "OK", + State = "final", + }); + + Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "OK"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); + + var entries = snapshots[^1].Timelines["main"].Entries; + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal( + new[] { "first", "OK", "second", "OK" }, + entries + .Where(e => e.Kind is ChatTimelineItemKind.User or ChatTimelineItemKind.Assistant) + .Select(e => e.Text) + .ToArray()); + } + [Fact] public async Task AgentEvent_ReasoningDelta_AccumulatesReasoningEntry() { From ad142600813459a81c08bf58ff110df32377a39f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 06:51:48 +0100 Subject: [PATCH 06/11] fix: finalize queued chat state safely --- .../Chat/OpenClawChatDataProvider.cs | 51 ++++++++++--- .../OpenClawChatDataProviderTests.cs | 72 +++++++++++++++++++ 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 02a3215af..4d3c0d075 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -167,6 +167,12 @@ private sealed record QueuedSendDispatch( long ResetVersion, long StartedLifecycleSequence, long StartedRunStartSequence); + private enum AssistantQueueFrameDisposition + { + Render, + Defer, + Drop, + } private sealed record LocalInlineApproval( string ThreadId, string RequestId, @@ -2086,22 +2092,24 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) var threadId = message.SessionKey; var cappedAssistantText = RepairContentBlockSeams(TruncateForChatEntry(message.Text)); - bool deferAssistantMessage; + AssistantQueueFrameDisposition assistantDisposition; lock (_gate) { - deferAssistantMessage = ShouldDeferAmbiguousAssistantMessageLocked( + assistantDisposition = ClassifyAssistantQueueFrameLocked( threadId, cappedAssistantText, message.OpenClawId, message.OpenClawSeq); - if (deferAssistantMessage) + if (assistantDisposition == AssistantQueueFrameDisposition.Defer) _deferredAssistantMessages[threadId] = message; else _deferredAssistantMessages.Remove(threadId); } - if (deferAssistantMessage) + if (assistantDisposition != AssistantQueueFrameDisposition.Render) { - Logger.Debug($"[Queue] Deferring identity-less assistant frame until the queued user boundary is confirmed threadId='{threadId}'"); + Logger.Debug(assistantDisposition == AssistantQueueFrameDisposition.Defer + ? $"[Queue] Deferring identity-less assistant frame until the queued user boundary is confirmed threadId='{threadId}'" + : $"[Queue] Dropping retransmitted assistant frame before queued user boundary threadId='{threadId}'"); return; } @@ -2165,8 +2173,14 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) lock (_gate) { if (_activeRunIds.Remove(threadId, out var completedRunId)) + { RememberTerminalRunIdLocked(threadId, completedRunId); + _abortedRunIds.Remove(completedRunId); + } _activeRunStartSequences.Remove(threadId); + _abortedThreads.Remove(threadId); + if (!HasSendingQueuedMessagesLocked(threadId)) + _locallyInitiatedThreads.Remove(threadId); } SnapshotLatestAssistantUsage(threadId); ApplyEventAndPublish(threadId, new ChatTurnEndEvent()); @@ -4576,20 +4590,19 @@ private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId) Publish(snapshot); } - private bool ShouldDeferAmbiguousAssistantMessageLocked( + private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( string threadId, string assistantText, string? gatewayMessageId, int? openClawSeq) { - if (!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null || - !_locallyInitiatedThreads.Contains(threadId) || + if (!_locallyInitiatedThreads.Contains(threadId) || !TryGetSingleSendingQueuedMessageLocked(threadId, out _) || _activeRunIds.ContainsKey(threadId) || _assistantFallbackPromotedThreads.Contains(threadId) || !_timelines.TryGetValue(threadId, out var timeline)) { - return false; + return AssistantQueueFrameDisposition.Render; } for (var i = timeline.Entries.Count - 1; i >= 0; i--) @@ -4597,10 +4610,26 @@ private bool ShouldDeferAmbiguousAssistantMessageLocked( var entry = timeline.Entries[i]; if (entry.Kind != ChatTimelineItemKind.Assistant) continue; - return !entry.IsStreaming && string.Equals(entry.Text, assistantText, StringComparison.Ordinal); + if (entry.IsStreaming || !string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) + return AssistantQueueFrameDisposition.Render; + if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null) + return AssistantQueueFrameDisposition.Defer; + 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 false; + return AssistantQueueFrameDisposition.Render; } private void ReplayDeferredAssistantMessage(string threadId) diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 23476dde1..90ec61ae4 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -2595,6 +2595,78 @@ public async Task IdentitylessIdenticalAssistant_DefersUntilQueuedUserBoundaryIs .ToArray()); } + [Fact] + public async Task IdentifiedDuplicateAssistant_DoesNotPromoteNextQueuedMessage() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-1", Status = "started" }); + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-2", Status = "started" }); + await provider.LoadAsync(); + bridge.RaiseStatus(ConnectionStatus.Connected); + + await provider.SendMessageAsync("main", "first"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + await provider.SendMessageAsync("main", "second"); + var firstFinal = new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "OK", + State = "final", + OpenClawId = "assistant-1", + OpenClawSeq = 10, + }; + bridge.RaiseChat(firstFinal); + for (var i = 0; i < 20 && bridge.SentMessages.Count < 2; i++) + await Task.Delay(10); + + bridge.RaiseChat(firstFinal); + + var queued = Assert.Single(GetQueuedMessages(snapshots[^1], "main")); + Assert.Equal("second", queued.Text); + Assert.Equal(ChatQueuedMessageSendState.Sending, queued.SendState); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "OK"); + Assert.DoesNotContain(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second"); + } + + [Fact] + public async Task AssistantFinal_ClearsLocalTurnStateForNextRemoteLifecycleStart() + { + var historyCalls = 0; + var (bridge, provider, _, _) = CreateProvider(new[] { MainSession() }); + bridge.HistoryBehavior = _ => + { + historyCalls++; + return Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo { SessionKey = "main", Role = "user", Text = "remote prompt" }, + }, + }); + }; + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "run-local", Status = "started" }); + await provider.LoadAsync(); + + await provider.SendMessageAsync("main", "local prompt"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-local")); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "local response", + State = "final", + }); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-remote")); + for (var i = 0; i < 20 && historyCalls == 0; i++) + await Task.Delay(10); + + Assert.True(historyCalls > 0); + } + [Fact] public async Task AgentEvent_ReasoningDelta_AccumulatesReasoningEntry() { From 33ce8f959e7e129060258299b9e150367d4a2a89 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 06:54:45 +0100 Subject: [PATCH 07/11] fix: reject late assistant retransmits --- .../Chat/OpenClawChatDataProvider.cs | 41 +++++++++++++++++++ .../OpenClawChatDataProviderTests.cs | 10 +++++ 2 files changed, 51 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 4d3c0d075..7536116bd 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -4596,6 +4596,16 @@ private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( string? gatewayMessageId, int? openClawSeq) { + if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) && + IsIdentifiedCompletedAssistantDuplicateLocked( + threadId, + assistantText, + gatewayMessageId, + openClawSeq)) + { + return AssistantQueueFrameDisposition.Drop; + } + if (!_locallyInitiatedThreads.Contains(threadId) || !TryGetSingleSendingQueuedMessageLocked(threadId, out _) || _activeRunIds.ContainsKey(threadId) || @@ -4632,6 +4642,37 @@ private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( return AssistantQueueFrameDisposition.Render; } + 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 || + !string.Equals(entry.Text, assistantText, StringComparison.Ordinal) || + !threadMeta.TryGetValue(entry.Id, out var existing)) + { + continue; + } + + return (!string.IsNullOrEmpty(gatewayMessageId) && + string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) || + (openClawSeq is not null && existing.OpenClawSeq == openClawSeq); + } + + return false; + } + private void ReplayDeferredAssistantMessage(string threadId) { ChatMessageInfo? deferred; diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 90ec61ae4..9be0316ba 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -2629,6 +2629,16 @@ public async Task IdentifiedDuplicateAssistant_DoesNotPromoteNextQueuedMessage() e.Kind == ChatTimelineItemKind.Assistant && e.Text == "OK"); Assert.DoesNotContain(snapshots[^1].Timelines["main"].Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "second"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); + bridge.RaiseChat(firstFinal); + + Assert.True(snapshots[^1].Timelines["main"].TurnActive); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "OK"); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "second"); } [Fact] From 0a8b4aa1cc05da24609bec7eb45436938a9c7530 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 06:58:08 +0100 Subject: [PATCH 08/11] fix: match assistant retransmits by identity --- .../Chat/OpenClawChatDataProvider.cs | 12 ++++++------ .../OpenClawChatDataProviderTests.cs | 12 +++++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 7536116bd..35fe486a7 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -4599,7 +4599,6 @@ private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) && IsIdentifiedCompletedAssistantDuplicateLocked( threadId, - assistantText, gatewayMessageId, openClawSeq)) { @@ -4644,7 +4643,6 @@ private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( private bool IsIdentifiedCompletedAssistantDuplicateLocked( string threadId, - string assistantText, string? gatewayMessageId, int? openClawSeq) { @@ -4659,15 +4657,17 @@ private bool IsIdentifiedCompletedAssistantDuplicateLocked( var entry = timeline.Entries[i]; if (entry.Kind != ChatTimelineItemKind.Assistant || entry.IsStreaming || - !string.Equals(entry.Text, assistantText, StringComparison.Ordinal) || !threadMeta.TryGetValue(entry.Id, out var existing)) { continue; } - return (!string.IsNullOrEmpty(gatewayMessageId) && - string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) || - (openClawSeq is not null && existing.OpenClawSeq == openClawSeq); + if ((!string.IsNullOrEmpty(gatewayMessageId) && + string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) || + (openClawSeq is not null && existing.OpenClawSeq == openClawSeq)) + { + return true; + } } return false; diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 9be0316ba..457d73c4c 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -2631,12 +2631,22 @@ public async Task IdentifiedDuplicateAssistant_DoesNotPromoteNextQueuedMessage() e.Kind == ChatTimelineItemKind.User && e.Text == "second"); bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); - bridge.RaiseChat(firstFinal); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "cumulative prefix\nOK", + State = "final", + OpenClawId = "assistant-1", + OpenClawSeq = 10, + }); Assert.True(snapshots[^1].Timelines["main"].TurnActive); Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); Assert.Single(snapshots[^1].Timelines["main"].Entries, e => e.Kind == ChatTimelineItemKind.Assistant && e.Text == "OK"); + Assert.DoesNotContain(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text.Contains("cumulative prefix")); Assert.Single(snapshots[^1].Timelines["main"].Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "second"); } From 03a44a684926a3d78a13247f1cf184d3a7782654 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 07:00:32 +0100 Subject: [PATCH 09/11] fix: scope assistant sequence dedupe --- .../Chat/OpenClawChatDataProvider.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 35fe486a7..b036f1f9e 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -4599,6 +4599,7 @@ private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) && IsIdentifiedCompletedAssistantDuplicateLocked( threadId, + assistantText, gatewayMessageId, openClawSeq)) { @@ -4643,6 +4644,7 @@ private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( private bool IsIdentifiedCompletedAssistantDuplicateLocked( string threadId, + string assistantText, string? gatewayMessageId, int? openClawSeq) { @@ -4662,9 +4664,15 @@ private bool IsIdentifiedCompletedAssistantDuplicateLocked( continue; } - if ((!string.IsNullOrEmpty(gatewayMessageId) && - string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) || - (openClawSeq is not null && existing.OpenClawSeq == openClawSeq)) + if (!string.IsNullOrEmpty(gatewayMessageId) && + string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) + { + return true; + } + if (string.IsNullOrEmpty(gatewayMessageId) && + openClawSeq is not null && + existing.OpenClawSeq == openClawSeq && + string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) { return true; } From 3769ce5c7551f0c999d878ca277cce7e9ab698e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 07:02:50 +0100 Subject: [PATCH 10/11] fix: dedupe upgraded assistant identities --- .../Chat/OpenClawChatDataProvider.cs | 7 ++-- .../OpenClawChatDataProviderTests.cs | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index b036f1f9e..0398ddf61 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -4664,12 +4664,15 @@ private bool IsIdentifiedCompletedAssistantDuplicateLocked( continue; } - if (!string.IsNullOrEmpty(gatewayMessageId) && + var bothHaveGatewayIds = + !string.IsNullOrEmpty(gatewayMessageId) && + !string.IsNullOrEmpty(existing.GatewayMessageId); + if (bothHaveGatewayIds && string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) { return true; } - if (string.IsNullOrEmpty(gatewayMessageId) && + if (!bothHaveGatewayIds && openClawSeq is not null && existing.OpenClawSeq == openClawSeq && string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 457d73c4c..14705f137 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -2687,6 +2687,38 @@ public async Task AssistantFinal_ClearsLocalTurnStateForNextRemoteLifecycleStart Assert.True(historyCalls > 0); } + [Fact] + public async Task AssistantRetransmit_WithNewGatewayIdStillMatchesPriorSequenceOnlyFrame() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "stable response", + State = "final", + OpenClawSeq = 10, + }); + snapshots.Clear(); + + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "stable response", + State = "final", + OpenClawId = "message-10", + OpenClawSeq = 10, + }); + + Assert.Empty(snapshots); + var current = await provider.LoadAsync(); + Assert.Single(current.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "stable response"); + } + [Fact] public async Task AgentEvent_ReasoningDelta_AccumulatesReasoningEntry() { From 807aa554397f8fa5f532084b7de2003dbbe139e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 07:05:02 +0100 Subject: [PATCH 11/11] fix: persist upgraded assistant identity --- .../Chat/OpenClawChatDataProvider.cs | 5 +++++ .../OpenClawChatDataProviderTests.cs | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 0398ddf61..52d43c896 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -4677,6 +4677,11 @@ 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; } } diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index 14705f137..d7a2d831d 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -2713,6 +2713,17 @@ public async Task AssistantRetransmit_WithNewGatewayIdStillMatchesPriorSequenceO OpenClawSeq = 10, }); + Assert.Empty(snapshots); + bridge.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "assistant", + Text = "cumulative prefix\nstable response", + State = "final", + OpenClawId = "message-10", + OpenClawSeq = 10, + }); + Assert.Empty(snapshots); var current = await provider.LoadAsync(); Assert.Single(current.Timelines["main"].Entries, e =>