diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index 23b8cc31b..4de7d2151 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -156,6 +156,21 @@ public record ChatTimelineState( System.Collections.Immutable.ImmutableDictionary.Empty); } +public enum ChatQueuedMessageSendState +{ + Queued, + Sending, + Failed +} + +public record ChatQueuedMessage( + string Id, + string Text, + DateTimeOffset CreatedAt, + string LocalNonce, + ChatQueuedMessageSendState SendState = ChatQueuedMessageSendState.Queued, + string? ErrorText = null); + public record ChatHistoryPage(ChatEvent[] Events, int NextSince, int PrevBefore, bool HasMore); public abstract record ChatEvent; @@ -195,7 +210,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/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/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs index bcd4e76ad..16e9b8f59 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs @@ -44,6 +44,22 @@ 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. +/// +/// +/// 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, @@ -52,4 +68,8 @@ 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, + string? LocalQueuedMessageId = null); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index b8df157b7..52d43c896 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) @@ -114,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 @@ -121,6 +126,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 +137,13 @@ 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> _queuedSendRequests = new(); + 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). // When lifecycle.start arrives for a thread NOT in this set, we know a remote @@ -140,7 +153,26 @@ 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 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 enum AssistantQueueFrameDisposition + { + Render, + Defer, + Drop, + } private sealed record LocalInlineApproval( string ThreadId, string RequestId, @@ -313,74 +345,144 @@ public async Task SendMessageAsync(string threadId, string message, Cancellation : $"{safeUserText}\n{chips}"; } - // 1. Optimistically add the user message + flag turn active. + // 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? sessionId; - long sendResetVersion; - long sendStartedLifecycleSequence; + string messageId; + QueuedSendDispatch? dispatch; lock (_gate) { - sendResetVersion = GetResetVersionLocked(threadId); - sendStartedLifecycleSequence = _resetLifecycleStartSequence; - var current = GetOrCreateTimelineLocked(threadId); - var beforeNextId = current.NextId; - _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, displayText, nonce); - _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)); - // Cap at 20 to avoid unbounded growth. - while (q.Count > 20) q.Dequeue(); + ObjectDisposedException.ThrowIf(_disposed, this); + messageId = $"q{++_queuedMessageSequence}"; + if (CanClearAssistantFallbackPromotionLocked(threadId)) + _assistantFallbackPromotedThreads.Remove(threadId); // Clear abort suppression — the user is starting a new interaction. // Also clear pending abort counts: if the user sends a new message, // any queued aborts from before should not fire against the new turn. _abortedThreads.Remove(threadId); _pendingAbortCounts.Remove(threadId); - _locallyInitiatedThreads.Add(threadId); - // Capture metadata for the just-added user entry. - var meta = BuildLiveMetaLocked(threadId); - var threadMeta = GetOrCreateThreadMetaLocked(threadId); - threadMeta[$"e{beforeNextId}"] = meta; + 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(); } 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) + ? sendResult.Error! + : string.Format( + CultureInfo.CurrentCulture, + LocalizationHelper.GetString("Chat_Error_SendReturnedStatusFormat"), + sendResult.Status); + throw new InvalidOperationException(failure); + } + bool sendStillCurrent; + string? staleRunIdToAbort = null; + ChatDataSnapshot? acceptedRunAlreadyStartedSnapshot = null; lock (_gate) { - sendStillCurrent = GetResetVersionLocked(threadId) == sendResetVersion; + sendStillCurrent = GetResetVersionLocked(threadId) == dispatch.ResetVersion; if (!sendStillCurrent) { if (!string.IsNullOrEmpty(sendResult.RunId)) + { AddResetIgnoredRunIdLocked(threadId, sendResult.RunId!); + staleRunIdToAbort = sendResult.RunId; + } } else if (!string.IsNullOrEmpty(sendResult.RunId)) { + TrackQueuedMessageRunLocked(threadId, sendResult.RunId!, request.Id); AddResetAcceptedRunIdLocked(threadId, sendResult.RunId!); + var runAlreadyStarted = _activeRunIds.TryGetValue(threadId, out var activeRunId) + && _activeRunStartSequences.TryGetValue(threadId, out var activeStartSequence) + && string.Equals(activeRunId, sendResult.RunId, StringComparison.Ordinal) + && activeStartSequence > dispatch.StartedRunStartSequence; + if (runAlreadyStarted && PromoteQueuedMessageLocked(threadId, request.Id)) + { + acceptedRunAlreadyStartedSnapshot = BuildSnapshotLocked(); + } + else if (runAlreadyStarted) + { + RemoveQueuedRunMappingByMessageIdLocked(threadId, request.Id); + } } else if (_resetAwaitingUserMessage.Contains(threadId)) { - _resetLocalSendWithoutRunVersions[threadId] = sendResetVersion; - _resetLocalSendWithoutRunStartSequences[threadId] = sendStartedLifecycleSequence; + _resetLocalSendWithoutRunVersions[threadId] = dispatch.ResetVersion; + _resetLocalSendWithoutRunStartSequences[threadId] = dispatch.StartedLifecycleSequence; TryOpenResetGateFromPendingLifecycleLocked(threadId, acceptedRunId: null); } } + if (acceptedRunAlreadyStartedSnapshot is not null) + { + Publish(acceptedRunAlreadyStartedSnapshot); + ReplayDeferredAssistantMessage(threadId); + } + + 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(sessionId, threadId, trimmed, attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), sendResetVersion); + CacheAttachmentMeta(dispatch.SessionId, threadId, request.Text, request.Attachments!, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), dispatch.ResetVersion); } catch (Exception ex) { @@ -388,14 +490,20 @@ 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); - _locallyInitiatedThreads.Remove(threadId); + RemovePendingLocalEchoLocked(threadId, request.Text); + MarkQueuedMessageFailedLocked(threadId, request.Id, ex.Message); + RemoveQueuedSendRequestLocked(threadId, request.Id); + 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,12 +512,14 @@ 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)); - throw; + TryDispatchNextQueuedSend(threadId); + if (rethrow) + throw; } } @@ -453,10 +563,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; } } @@ -476,6 +591,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. @@ -524,15 +651,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 +706,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 +920,20 @@ 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); foreach (var entry in rebuilt.Entries) { rebuiltMeta.TryGetValue(entry.Id, out var em); + if (!string.IsNullOrEmpty(em?.GatewayMessageId)) + rebuiltMessageIds.Add(em.GatewayMessageId); + if (em?.OpenClawSeq is { } seq) + IncrementCount(rebuiltSequenceCounts, SequenceKey(entry.Kind, seq)); if (em?.Timestamp is { } rts && rts != default) { var key = ContentKey(entry.Kind, entry.Text); @@ -827,6 +962,21 @@ 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; + } // Rule 2: content+timestamp dedup only when BOTH sides // have valid timestamps within 2 seconds. Otherwise @@ -837,10 +987,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 +1029,10 @@ 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)); if (em is not null && !rebuiltMeta.ContainsKey(entryToAdd.Id)) rebuiltMeta[entryToAdd.Id] = em; } @@ -1398,6 +1554,14 @@ public ValueTask DisposeAsync() _lastChatStateSaveTimer = null; pendingLocalApprovals = _localInlineApprovals.Values.ToList(); _localInlineApprovals.Clear(); + _queuedMessages.Clear(); + _queuedSendRequests.Clear(); + _queuedMessageIdsByRunId.Clear(); + _terminalRunIdsByThread.Clear(); + _deferredAssistantMessages.Clear(); + _localSentTexts.Clear(); + _locallyInitiatedThreads.Clear(); + _resetSubmittedLocalEchoTexts.Clear(); } foreach (var approval in pendingLocalApprovals) approval.Response.TrySetResult(ExecApprovalPromptDecision.Deny()); @@ -1492,6 +1656,15 @@ private void OnStatusChanged(object? sender, ConnectionStatus status) _historyInFlight.Clear(); _locallyInitiatedThreads.Clear(); _localSentTexts.Clear(); + _queuedMessages.Clear(); + _queuedSendRequests.Clear(); + _assistantFallbackPromotedThreads.Clear(); + _deferredAssistantMessages.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. @@ -1510,6 +1683,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 { @@ -1609,6 +1787,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) @@ -1694,6 +1898,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 +1911,46 @@ 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)) { - TryConsumeLocalEchoLocked(msgThreadId, resetEchoQueue, consumeEchoText); + var confirmedMeta = BuildLiveMetaLocked( + msgThreadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + if (ReconcileQueuedMessageEchoLocked(msgThreadId, queuedMessageId, confirmedMeta)) + 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); + ReplayDeferredAssistantMessage(msgThreadId); + } + 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 +1969,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 +2004,56 @@ 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; + var confirmedMeta = BuildLiveMetaLocked( + msgThreadId, + message.Ts, + message.OpenClawId, + message.OpenClawSeq); + if (ReconcileQueuedMessageEchoLocked(msgThreadId, echoEntryId, confirmedMeta)) + echoSnapshot = BuildSnapshotLocked(); + } + } + if (isLocalEcho) + { + if (echoSnapshot is not null) + { + Publish(echoSnapshot); + ReplayDeferredAssistantMessage(msgThreadId); } + return; } - if (isLocalEcho) 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 +2067,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 +2091,39 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) return; var threadId = message.SessionKey; - ChatEntryMetadata? meta; var cappedAssistantText = RepairContentBlockSeams(TruncateForChatEntry(message.Text)); + AssistantQueueFrameDisposition assistantDisposition; + lock (_gate) + { + assistantDisposition = ClassifyAssistantQueueFrameLocked( + threadId, + cappedAssistantText, + message.OpenClawId, + message.OpenClawSeq); + if (assistantDisposition == AssistantQueueFrameDisposition.Defer) + _deferredAssistantMessages[threadId] = message; + else + _deferredAssistantMessages.Remove(threadId); + } + if (assistantDisposition != AssistantQueueFrameDisposition.Render) + { + 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; + } + + 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; 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. @@ -1881,10 +2170,23 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) if (message.IsFinal) { + 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()); RaiseNotification(new ChatProviderNotification( ChatProviderNotificationKind.TurnComplete, threadId, LocalizationHelper.GetString("Chat_Notification_AssistantReplied"))); + TryDispatchNextQueuedSend(threadId); } } @@ -1923,18 +2225,35 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) return; } var threadId = evt.SessionKey; + var isTerminalRunEvent = IsTerminalRunEvent(evt); + 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); + ClearQueuedMessageOnLocalTurnStart(evt, threadId); // Fire deferred chat.abort and persist if pending aborts were queued. var deferredRunId = _deferredAbortRunId; @@ -1963,12 +2282,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); @@ -2043,6 +2369,8 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt) } } } + if (isTerminalRunEvent) + TryDispatchNextQueuedSend(threadId); return; } @@ -2059,6 +2387,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() @@ -2107,6 +2437,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 +2465,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,95 +2503,571 @@ 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 ShouldDropTerminalAgentEventLocked(AgentEventInfo evt, string threadId) { - var now = DateTimeOffset.UtcNow; - while (queue.Count > 0 && now - queue.Peek().SentAt > LocalEchoSuppressionWindow) - queue.Dequeue(); + if (!TryGetTerminalAgentRunId(evt, out var runId) || string.IsNullOrWhiteSpace(runId)) + return false; - if (queue.Count == 0) + if (_terminalRunIdsByThread.TryGetValue(threadId, out var terminalRunIds) && + terminalRunIds.Contains(runId, StringComparer.Ordinal)) { - _localSentTexts.Remove(threadId); - return false; + 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; } - if (!string.Equals(queue.Peek().Text, text, StringComparison.Ordinal)) + 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; - queue.Dequeue(); - if (queue.Count == 0) - _localSentTexts.Remove(threadId); - return true; + 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 void RemovePendingLocalEchoLocked(string threadId, string text) + private bool TryConsumeLocalEchoLocked(string threadId, Queue queue, string text, out string queuedMessageId) { - if (!_localSentTexts.TryGetValue(threadId, out var queue)) - return; + queuedMessageId = string.Empty; + var now = DateTimeOffset.UtcNow; + while (queue.Count > 0 && now - queue.Peek().SentAt > LocalEchoSuppressionWindow) + queue.Dequeue(); + + if (queue.Count == 0) + { + _localSentTexts.Remove(threadId); + return false; + } var kept = new Queue(queue.Count); - var removed = false; + var matched = false; while (queue.Count > 0) { var pending = queue.Dequeue(); - if (!removed && string.Equals(pending.Text, text, StringComparison.Ordinal)) + if (!matched && string.Equals(pending.Text, text, StringComparison.Ordinal)) { - removed = true; + queuedMessageId = pending.QueuedMessageId; + matched = true; continue; } + kept.Enqueue(pending); } - if (kept.Count == 0) + if (!matched) + { + StoreLocalEchoQueueLocked(threadId, kept); + return false; + } + + StoreLocalEchoQueueLocked(threadId, kept); + return true; + } + + private void StoreLocalEchoQueueLocked(string threadId, Queue queue) + { + if (queue.Count == 0) _localSentTexts.Remove(threadId); else - _localSentTexts[threadId] = kept; + _localSentTexts[threadId] = queue; } - /// - /// Fetch the latest user message from history for a remotely-initiated turn. - /// Called when lifecycle.start arrives for a thread we didn't locally initiate. - /// - private async Task FetchRemoteUserMessageAsync(string threadId, bool openResetGateOnSuccess = false) + private bool TryReconcileExistingLocalQueuedUserEchoLocked( + string threadId, + string text, + ChatEntryMetadata confirmedMeta) { - long requestResetVersion; - long resetCutoffUtcMs; - lock (_gate) - { - requestResetVersion = GetResetVersionLocked(threadId); - resetCutoffUtcMs = GetResetCutoffUtcMsLocked(threadId); - } + if (!HasGatewayIdentity(confirmedMeta)) + return false; + if (!_entryMeta.TryGetValue(threadId, out var threadMeta) || + !_timelines.TryGetValue(threadId, out var timeline)) + return false; - try + foreach (var entry in timeline.Entries) { - var history = await _bridge.RequestChatHistoryAsync(threadId); - if (history?.Messages is null || history.Messages.Count == 0) return; + 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; - // Find the last user message in history. - ChatMessageInfo? lastUser = null; - for (int i = history.Messages.Count - 1; i >= 0; i--) + threadMeta[entry.Id] = confirmedMeta with { - var role = (history.Messages[i].Role ?? "").ToLowerInvariant(); - var hText = history.Messages[i].Text; - if (role == "user" - && !LooksLikeSystemControlNote(hText) - && !LooksLikeApprovalSlashCommand(hText)) - { - lastUser = history.Messages[i]; - break; - } - } - if (lastUser is null || string.IsNullOrEmpty(lastUser.Text)) return; + IsLocalQueuedSend = false, + LocalQueuedMessageId = existing.LocalQueuedMessageId, + }; + return true; + } - ChatDataSnapshot? snapshotToPublish = null; + return false; + } - // Check if we already have this user message as the last User entry - // in the timeline (avoid duplicates on reconnect/reload). + private static bool HasGatewayIdentity(ChatEntryMetadata meta) + => !string.IsNullOrEmpty(meta.GatewayMessageId) || meta.OpenClawSeq is not null; + + private static bool IsFreshLocalQueuedPromotion(ChatEntryMetadata existing, ChatEntryMetadata confirmed) + { + if (existing.Timestamp is not { } existingTimestamp) + return false; + if (confirmed.Timestamp is { } confirmedTimestamp) + return (confirmedTimestamp - existingTimestamp).Duration() <= LocalEchoSuppressionWindow; + + return DateTimeOffset.Now - existingTimestamp <= LocalEchoSuppressionWindow; + } + + private void AddQueuedMessageLocked(string threadId, ChatQueuedMessage message) + { + if (!_queuedMessages.TryGetValue(threadId, out var list)) + { + list = new List(); + _queuedMessages[threadId] = list; + } + + list.RemoveAll(existing => existing.Id == message.Id); + list.Add(message); + } + + private void AddQueuedSendRequestLocked(QueuedSendRequest request) + { + if (!_queuedSendRequests.TryGetValue(request.ThreadId, out var list)) + { + list = new List(); + _queuedSendRequests[request.ThreadId] = list; + } + + list.RemoveAll(existing => existing.Id == request.Id); + list.Add(request); + } + + private void RemoveQueuedSendRequestLocked(string threadId, string messageId) + { + if (!_queuedSendRequests.TryGetValue(threadId, out var list)) + return; + + list.RemoveAll(request => request.Id == messageId); + if (list.Count == 0) + _queuedSendRequests.Remove(threadId); + } + + private QueuedSendRequest? FindQueuedSendRequestLocked(string threadId, string messageId) + { + if (!_queuedSendRequests.TryGetValue(threadId, out var list)) + return null; + + return list.FirstOrDefault(request => string.Equals(request.Id, messageId, StringComparison.Ordinal)); + } + + private bool CanSendDirectlyLocked(string threadId) + { + if (_activeRunIds.ContainsKey(threadId)) + return false; + if (_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive) + return false; + return !_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, + localQueuedMessageId: request.Id); + _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) + 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; + + // Each dispatched prompt gets one opportunity for assistant-frame + // fallback promotion before its lifecycle/user echo arrives. + _assistantFallbackPromotedThreads.Remove(threadId); + queuedMessages[i] = queuedMessages[i] with { SendState = ChatQueuedMessageSendState.Sending, ErrorText = null }; + var resetVersion = GetResetVersionLocked(threadId); + var startedLifecycleSequence = _resetLifecycleStartSequence; + var startedRunStartSequence = _lifecycleStartSequence; + _timelines[threadId] = ChatTimelineReducer.BeginLocalUserTurn(GetOrCreateTimelineLocked(threadId)); + _sessionIds.TryGetValue(threadId, out var sessionId); + + EnqueueLocalEchoLocked(threadId, request.Text, request.Id); + _locallyInitiatedThreads.Add(threadId); + return new QueuedSendDispatch( + request, + sessionId, + resetVersion, + startedLifecycleSequence, + startedRunStartSequence); + } + + 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; + 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)) + { + byRunId = new Dictionary(StringComparer.Ordinal); + _queuedMessageIdsByRunId[threadId] = byRunId; + } + + byRunId[runId] = messageId; + } + + private bool RemoveQueuedMessageLocked(string threadId, string messageId) + { + if (!_queuedMessages.TryGetValue(threadId, out var list)) + return false; + + var removed = list.RemoveAll(message => message.Id == messageId) > 0; + if (removed) + { + RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId); + RemoveQueuedSendRequestLocked(threadId, messageId); + } + if (list.Count == 0) + _queuedMessages.Remove(threadId); + return removed; + } + + private bool PromoteQueuedMessageLocked( + string threadId, + string messageId, + ChatEntryMetadata? confirmedMeta = null) + { + if (!_queuedMessages.TryGetValue(threadId, out var list)) + return false; + + var index = list.FindIndex(message => message.Id == messageId); + if (index < 0) + return false; + + var queued = list[index]; + var current = GetOrCreateTimelineLocked(threadId); + var entryId = $"e{current.NextId}"; + _timelines[threadId] = ChatTimelineReducer.AddLocalUser(current, queued.Text, queued.LocalNonce); + + var hasGatewayIdentity = confirmedMeta is not null && HasGatewayIdentity(confirmedMeta); + var meta = hasGatewayIdentity + ? confirmedMeta! with { IsLocalQueuedSend = false, LocalQueuedMessageId = messageId } + : BuildLiveMetaLocked( + threadId, + isLocalQueuedSend: true, + localQueuedMessageId: messageId); + var threadMeta = GetOrCreateThreadMetaLocked(threadId); + threadMeta[entryId] = meta; + + list.RemoveAt(index); + _assistantFallbackPromotedThreads.Add(threadId); + RemoveQueuedRunMappingByMessageIdLocked(threadId, messageId); + RemoveQueuedSendRequestLocked(threadId, messageId); + if (list.Count == 0) + _queuedMessages.Remove(threadId); + 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)) + 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); + ReplayDeferredAssistantMessage(threadId); + } + } + + 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)) + return; + + var kept = new Queue(queue.Count); + var removed = false; + while (queue.Count > 0) + { + var pending = queue.Dequeue(); + if (!removed && string.Equals(pending.Text, text, StringComparison.Ordinal)) + { + removed = true; + continue; + } + kept.Enqueue(pending); + } + + if (kept.Count == 0) + _localSentTexts.Remove(threadId); + else + _localSentTexts[threadId] = kept; + } + + /// + /// Fetch the latest user message from history for a remotely-initiated turn. + /// Called when lifecycle.start arrives for a thread we didn't locally initiate. + /// + private async Task FetchRemoteUserMessageAsync(string threadId, bool openResetGateOnSuccess = false) + { + long requestResetVersion; + long resetCutoffUtcMs; + lock (_gate) + { + requestResetVersion = GetResetVersionLocked(threadId); + resetCutoffUtcMs = GetResetCutoffUtcMsLocked(threadId); + } + + try + { + var history = await _bridge.RequestChatHistoryAsync(threadId); + if (history?.Messages is null || history.Messages.Count == 0) return; + + // Find the last user message in history. + ChatMessageInfo? lastUser = null; + for (int i = history.Messages.Count - 1; i >= 0; i--) + { + var role = (history.Messages[i].Role ?? "").ToLowerInvariant(); + var hText = history.Messages[i].Text; + if (role == "user" + && !LooksLikeSystemControlNote(hText) + && !LooksLikeApprovalSlashCommand(hText)) + { + lastUser = history.Messages[i]; + break; + } + } + if (lastUser is null || string.IsNullOrEmpty(lastUser.Text)) return; + + ChatDataSnapshot? snapshotToPublish = null; + + // Check if we already have this user message as the last User entry + // in the timeline (avoid duplicates on reconnect/reload). lock (_gate) { if (GetResetVersionLocked(threadId) != requestResetVersion || @@ -2283,7 +3096,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)), @@ -3511,7 +4328,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; @@ -3545,8 +4363,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(); @@ -3557,10 +4386,17 @@ 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); + _queuedSendRequests.Remove(threadId); + _queuedMessageIdsByRunId.Remove(threadId); + _terminalRunIdsByThread.Remove(threadId); + _assistantFallbackPromotedThreads.Remove(threadId); + _deferredAssistantMessages.Remove(threadId); _resetAcceptedRunIds.Remove(threadId); _resetLocalSendWithoutRunVersions.Remove(threadId); _resetLocalSendWithoutRunStartSequences.Remove(threadId); @@ -3568,8 +4404,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) @@ -3592,6 +4430,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)) @@ -3618,20 +4522,29 @@ 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 && _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,9 +4565,173 @@ private bool ShouldDropChatMessageAfterResetLocked( return true; } - private bool ShouldDropAgentEventAfterResetLocked(AgentEventInfo evt, string threadId) + private void PromoteOldestQueuedMessageBeforeAssistantIfNeeded(string threadId) { - if (IsResetIgnoredRunLocked(threadId, evt.RunId, evt)) + ChatDataSnapshot? snapshot = null; + lock (_gate) + { + // This fallback covers the degenerate case where an assistant frame + // arrives before any user echo or lifecycle.start/run mapping. When + // a run is active, lifecycle/ACK correlation owns the handoff; when + // multiple queued prompts exist, positional assistant fallback is + // ambiguous and can create false user boundaries that duplicate the + // assistant bubble. + if (_locallyInitiatedThreads.Contains(threadId) + && TryGetSingleSendingQueuedMessageLocked(threadId, out var queued) + && !_activeRunIds.ContainsKey(threadId) + && !_assistantFallbackPromotedThreads.Contains(threadId) + && PromoteQueuedMessageLocked(threadId, queued.Id)) + { + snapshot = BuildSnapshotLocked(); + } + } + + if (snapshot is not null) + Publish(snapshot); + } + + private AssistantQueueFrameDisposition ClassifyAssistantQueueFrameLocked( + string threadId, + string assistantText, + string? gatewayMessageId, + int? openClawSeq) + { + if ((!string.IsNullOrEmpty(gatewayMessageId) || openClawSeq is not null) && + IsIdentifiedCompletedAssistantDuplicateLocked( + threadId, + assistantText, + gatewayMessageId, + openClawSeq)) + { + return AssistantQueueFrameDisposition.Drop; + } + + if (!_locallyInitiatedThreads.Contains(threadId) || + !TryGetSingleSendingQueuedMessageLocked(threadId, out _) || + _activeRunIds.ContainsKey(threadId) || + _assistantFallbackPromotedThreads.Contains(threadId) || + !_timelines.TryGetValue(threadId, out var timeline)) + { + return AssistantQueueFrameDisposition.Render; + } + + for (var i = timeline.Entries.Count - 1; i >= 0; i--) + { + var entry = timeline.Entries[i]; + if (entry.Kind != ChatTimelineItemKind.Assistant) + continue; + if (entry.IsStreaming || !string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) + return AssistantQueueFrameDisposition.Render; + if (string.IsNullOrEmpty(gatewayMessageId) && openClawSeq is null) + 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 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 || + !threadMeta.TryGetValue(entry.Id, out var existing)) + { + continue; + } + + var bothHaveGatewayIds = + !string.IsNullOrEmpty(gatewayMessageId) && + !string.IsNullOrEmpty(existing.GatewayMessageId); + if (bothHaveGatewayIds && + string.Equals(existing.GatewayMessageId, gatewayMessageId, StringComparison.Ordinal)) + { + return true; + } + if (!bothHaveGatewayIds && + openClawSeq is not null && + existing.OpenClawSeq == openClawSeq && + string.Equals(entry.Text, assistantText, StringComparison.Ordinal)) + { + if (!string.IsNullOrEmpty(gatewayMessageId) && + string.IsNullOrEmpty(existing.GatewayMessageId)) + { + threadMeta[entry.Id] = existing with { GatewayMessageId = gatewayMessageId }; + } + return true; + } + } + + return false; + } + + private void ReplayDeferredAssistantMessage(string threadId) + { + ChatMessageInfo? deferred; + lock (_gate) + { + if (!_deferredAssistantMessages.Remove(threadId, out deferred)) + return; + } + + OnChatMessageReceived(this, deferred); + } + + 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, out bool reloadHistoryAfterDrop) + { + reloadHistoryAfterDrop = false; + if (IsResetIgnoredRunLocked(threadId, evt.RunId, evt, out reloadHistoryAfterDrop)) return true; var eventTsMs = evt.Ts > 0 ? (long)evt.Ts : 0L; @@ -3843,6 +4920,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); @@ -3852,8 +4930,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)) @@ -3865,7 +4944,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; @@ -4017,13 +5100,79 @@ 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, + string? localQueuedMessageId = null) { var ts = tsMs is { } v && v > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(v).ToLocalTime() : (DateTimeOffset?)DateTimeOffset.Now; var session = Array.Find(_sessions, s => s.Key == threadId); - return new ChatEntryMetadata(ts, session?.Model); + return new ChatEntryMetadata( + ts, + session?.Model, + GatewayMessageId: gatewayMessageId, + OpenClawSeq: openClawSeq, + IsLocalQueuedSend: isLocalQueuedSend, + LocalQueuedMessageId: localQueuedMessageId); + } + + private static List OrderHistoryMessages(List<(ChatMessageInfo Message, int Index)> messages) + { + if (messages.Count == 0) + return new List(); + + var sequencedCount = messages.Count(item => item.Message.OpenClawSeq is not null); + if (sequencedCount == messages.Count) + { + return messages + .OrderBy(item => item.Message.OpenClawSeq) + .ThenBy(item => item.Index) + .Select(item => item.Message) + .ToList(); + } + + if (sequencedCount == 0) + { + return messages + .OrderBy(item => item.Message.Ts) + .ThenBy(item => item.Index) + .Select(item => item.Message) + .ToList(); + } + + // Mixed old/new rows are already in gateway transcript order. Sorting + // timestamped-but-unsequenced rows against sequenced rows can drag a + // later queued burst (e.g. "t") ahead of the actual transcript start. + return messages + .OrderBy(item => item.Index) + .Select(item => item.Message) + .ToList(); + } + + private static void IncrementCount(Dictionary counts, string key) + => counts[key] = counts.TryGetValue(key, out var count) ? count + 1 : 1; + + private static bool TryConsumeCount(Dictionary counts, string key) + { + if (!counts.TryGetValue(key, out var count) || count <= 0) + return false; + + if (count == 1) + counts.Remove(key); + else + counts[key] = count - 1; + return true; + } + + private static void ConsumeAnyTimestamp(Dictionary> timestamps, string key) + { + if (timestamps.TryGetValue(key, out var values) && values.Count > 0) + values.RemoveAt(0); } private void SeedSessionIdsFromSessionsLocked(IEnumerable sessions) @@ -4092,9 +5241,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 +5251,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 +5288,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 +5327,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/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/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..d7a2d831d 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_WhenIdle_RendersTranscriptEntryBeforeAwaitingGateway() { var tcs = new TaskCompletionSource(); var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); @@ -189,9 +189,10 @@ 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); + 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]); @@ -200,6 +201,323 @@ public async Task SendMessageAsync_AddsLocalUserEntryBeforeAwaitingGateway() await sendTask; } + [Fact] + public async Task SendMessageAsync_WhenIdle_DoesNotRenderQueueCardBeforeLifecycleStart() + { + 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"); + + 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); + + tcs.SetResult(); + await sendTask; + + 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")); + + 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 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() + { + 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 queued"); + await provider.SendMessageAsync("main", "second queued"); + Assert.Equal(new[] { "first queued" }, bridge.SentMessages); + + 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 + { + 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.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")); + 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(); + 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 + { + 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.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 => + 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() { @@ -207,15 +525,177 @@ public async Task SendMessageAsync_GatewayThrows_AppendsErrorAndRethrows() bridge.SendBehavior = (_, _, _) => throw new InvalidOperationException("boom"); await provider.LoadAsync(); snapshots.Clear(); - notifications.Clear(); + notifications.Clear(); + + 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); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + 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.User && e.Text == "Hi"); + Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("model unavailable")); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + 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(); + 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]; + 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"); + Assert.Empty(GetQueuedMessages(latest, "main")); + } + + [Fact] + public async Task LifecycleEnd_IgnoresFailedQueuedMessagesWhenClearingLocalInitiatedState() + { + var historyCalls = 0; + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + 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 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")); + + 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("b", 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", "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); + + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + sendGate.SetResult(); + 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 Assert.ThrowsAsync(() => - provider.SendMessageAsync("main", "Hi")); + await provider.SendMessageAsync("main", "a"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-a")); + Assert.Equal(new[] { "a" }, bridge.SentMessages); - var timeline = snapshots[^1].Timelines["main"]; - Assert.Contains(timeline.Entries, e => e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("boom")); - Assert.False(timeline.TurnActive); - Assert.Contains(notifications, n => n.Kind == ChatProviderNotificationKind.Error); + bridge.RaiseStatus(ConnectionStatus.Disconnected); + bridge.RaiseStatus(ConnectionStatus.Connected); + snapshots.Clear(); + + await provider.SendMessageAsync("main", "b"); + + Assert.Equal(new[] { "a", "b" }, bridge.SentMessages); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); + Assert.Contains(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "b"); } [Fact] @@ -397,10 +877,11 @@ 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 (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; @@ -414,15 +895,117 @@ public async Task ChatMessageReceived_UserEcho_Ignored() SessionKey = "main", Role = "user", Text = "hi", - State = "final" + State = "final", + OpenClawId = "gateway-hi", + OpenClawSeq = 7, }); - // The echo should be suppressed — no new snapshot. - Assert.Empty(snapshots); + var timeline = Assert.Single(snapshots).Timelines["main"]; + Assert.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); + 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(); } + [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() { @@ -443,95 +1026,335 @@ await Assert.ThrowsAsync(() => Text = "same text", State = "final" }); - - var timeline = snapshots[^1].Timelines["main"]; - Assert.Equal(2, timeline.Entries.Count(e => - e.Kind == ChatTimelineItemKind.User && e.Text == "same text")); - } - - [Fact] - public async Task AgentEvent_ToolStart_AppendsToolCallEntry() - { - var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); - await provider.LoadAsync(); + + var timeline = snapshots[^1].Timelines["main"]; + Assert.Equal(2, timeline.Entries.Count(e => + e.Kind == ChatTimelineItemKind.User && e.Text == "same text")); + } + + [Fact] + public async Task AgentEvent_ToolStart_AppendsToolCallEntry() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseAgent(MakeAgentEvent("tool", + """{"phase":"start","name":"powershell","args":{"command":"ls"}}""")); + + var timeline = snapshots[^1].Timelines["main"]; + var entry = Assert.Single(timeline.Entries); + Assert.Equal(ChatTimelineItemKind.ToolCall, entry.Kind); + Assert.Equal("powershell", entry.ToolName); + Assert.Equal("ls", entry.Text); + Assert.Equal(ChatToolCallStatus.InProgress, entry.ToolResult); + } + + [Fact] + public async Task AgentEvent_ToolStartThenResult_MarksToolSuccess() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + + bridge.RaiseAgent(MakeAgentEvent("tool", + """{"phase":"start","name":"grep","args":{"pattern":"foo"}}""")); + bridge.RaiseAgent(MakeAgentEvent("tool", + """{"phase":"result","name":"grep","args":{"pattern":"foo"}}""")); + + var timeline = snapshots[^1].Timelines["main"]; + var entry = Assert.Single(timeline.Entries); + Assert.Equal(ChatToolCallStatus.Success, entry.ToolResult); + } + + [Fact] + public async Task AgentEvent_JobError_EmitsErrorEntry() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + var evt = MakeAgentEvent("job", """{"state":"error"}"""); + evt.Summary = "kaboom"; + bridge.RaiseAgent(evt); + + var timeline = snapshots[^1].Timelines["main"]; + Assert.Contains(timeline.Entries, e => + 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() + { + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + // Kick off a turn + _ = provider.SendMessageAsync("main", "hi"); + + bridge.RaiseAgent(MakeAgentEvent("job", """{"state":"done"}""")); + + // Snapshot the timeline directly. + var snap = await provider.LoadAsync(); + Assert.False(snap.Timelines["main"].TurnActive); + } + + [Fact] + public async Task SessionsUpdated_RebuildsThreadsAndSeedsTimelines() + { + var (bridge, provider, snapshots, _) = CreateProvider(); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseSessions(new[] + { + new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" }, + new SessionInfo { Key = "sub:abc", IsMain = false, DisplayName = "Sub" } + }); + + var snap = snapshots[^1]; + Assert.Equal(2, snap.Threads.Length); + Assert.True(snap.Timelines.ContainsKey("main")); + Assert.True(snap.Timelines.ContainsKey("sub:abc")); + 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.RaiseChat(new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "a", + State = "final", + OpenClawId = "gateway-a", + OpenClawSeq = 1, + }); + 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"); + 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" }, + 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(); - bridge.RaiseAgent(MakeAgentEvent("tool", - """{"phase":"start","name":"powershell","args":{"command":"ls"}}""")); + await provider.LoadHistoryAsync("main"); - var timeline = snapshots[^1].Timelines["main"]; - var entry = Assert.Single(timeline.Entries); - Assert.Equal(ChatTimelineItemKind.ToolCall, entry.Kind); - Assert.Equal("powershell", entry.ToolName); - Assert.Equal("ls", entry.Text); - Assert.Equal(ChatToolCallStatus.InProgress, entry.ToolResult); + 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 AgentEvent_ToolStartThenResult_MarksToolSuccess() + public async Task LoadHistoryAsync_KeepsSecondIdenticalQueuedPromptWhenHistoryContainsOneMatch() { var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); await provider.LoadAsync(); - bridge.RaiseAgent(MakeAgentEvent("tool", - """{"phase":"start","name":"grep","args":{"pattern":"foo"}}""")); - bridge.RaiseAgent(MakeAgentEvent("tool", - """{"phase":"result","name":"grep","args":{"pattern":"foo"}}""")); + 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")); - var timeline = snapshots[^1].Timelines["main"]; - var entry = Assert.Single(timeline.Entries); - Assert.Equal(ChatToolCallStatus.Success, entry.ToolResult); - } + 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")); - [Fact] - public async Task AgentEvent_JobError_EmitsErrorEntry() - { - var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); - await provider.LoadAsync(); + 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(); - var evt = MakeAgentEvent("job", """{"state":"error"}"""); - evt.Summary = "kaboom"; - bridge.RaiseAgent(evt); + await provider.LoadHistoryAsync("main"); - var timeline = snapshots[^1].Timelines["main"]; - Assert.Contains(timeline.Entries, e => - e.Kind == ChatTimelineItemKind.Status && e.Text.Contains("kaboom")); + 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 AgentEvent_JobDone_ClearsTurnActive() + 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(); - // Kick off a turn - _ = provider.SendMessageAsync("main", "hi"); - - bridge.RaiseAgent(MakeAgentEvent("job", """{"state":"done"}""")); - - // Snapshot the timeline directly. - var snap = await provider.LoadAsync(); - Assert.False(snap.Timelines["main"].TurnActive); - } - - [Fact] - public async Task SessionsUpdated_RebuildsThreadsAndSeedsTimelines() - { - var (bridge, provider, snapshots, _) = CreateProvider(); - await provider.LoadAsync(); - snapshots.Clear(); - bridge.RaiseSessions(new[] + await provider.SendMessageAsync("main", "same"); + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-same")); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo { - new SessionInfo { Key = "main", IsMain = true, DisplayName = "Main" }, - new SessionInfo { Key = "sub:abc", IsMain = false, DisplayName = "Sub" } + SessionKey = "main", + Messages = new[] + { + new ChatMessageInfo + { + SessionKey = "main", + Role = "user", + Text = "same", + Ts = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds(), + }, + } }); + snapshots.Clear(); - var snap = snapshots[^1]; - Assert.Equal(2, snap.Threads.Length); - Assert.True(snap.Timelines.ContainsKey("main")); - Assert.True(snap.Timelines.ContainsKey("sub:abc")); - Assert.Equal("main", snap.DefaultThreadId); + await provider.LoadHistoryAsync("main"); + + Assert.Equal(2, snapshots[^1].Timelines["main"].Entries.Count(e => + e.Kind == ChatTimelineItemKind.User && e.Text == "same")); } [Fact] @@ -578,6 +1401,19 @@ public async Task SessionResetCompletion_ClearsThreadTimelineAndIgnoresStaleHist await provider.SendMessageAsync("main", "after reset"); + latest = snapshots[^1]; + Assert.Single(latest.Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "after reset"); + Assert.Empty(GetQueuedMessages(latest, "main")); + + 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); @@ -585,10 +1421,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(); @@ -607,9 +1446,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 { @@ -641,8 +1484,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] @@ -744,6 +1761,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.Empty(GetQueuedMessages(latest, "main")); Assert.DoesNotContain(latest.Timelines["main"].Entries, e => e.Text.Contains("stale", StringComparison.Ordinal)); @@ -758,7 +1776,8 @@ 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"); + 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(); @@ -905,27 +1924,85 @@ public async Task SessionResetCompletion_PreResetSendResultAfterResetDoesNotReop }; await provider.LoadAsync(); - var staleSendTask = provider.SendMessageAsync("main", "before reset"); - await sendStarted.Task; + var staleSendTask = provider.SendMessageAsync("main", "before reset"); + await sendStarted.Task; + bridge.RaiseSessionCommandCompleted(new SessionCommandResult + { + Method = "sessions.reset", + Ok = true, + Key = "main" + }); + snapshots.Clear(); + + bridge.SendResults.Enqueue(new ChatSendResult { RunId = "old-run" }); + 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); + bridge.RaiseAgent(MakeAgentEvent("assistant", """{"delta":"stale old run"}""", runId: "old-run")); + + var latest = snapshots.Count > 0 ? snapshots[^1] : await provider.LoadAsync(); + 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.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 { Method = "sessions.reset", Ok = true, Key = "main" }); - snapshots.Clear(); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); - bridge.SendResults.Enqueue(new ChatSendResult { RunId = "old-run" }); - sendGate.SetResult(); - await staleSendTask; + var secondSend = provider.SendMessageAsync("main", "after reset"); + Assert.Single(snapshots[^1].Timelines["main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "after reset"); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); - var staleStart = MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "old-run"); - staleStart.Ts = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds(); - bridge.RaiseAgent(staleStart); - bridge.RaiseAgent(MakeAgentEvent("assistant", """{"delta":"stale old run"}""", runId: "old-run")); + firstGate.SetResult(); + await firstSend; - var latest = snapshots.Count > 0 ? snapshots[^1] : await provider.LoadAsync(); - Assert.Empty(latest.Timelines["main"].Entries); + 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; + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "new-run")); + Assert.Empty(GetQueuedMessages(snapshots[^1], "main")); } [Fact] @@ -1209,6 +2286,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() { @@ -1397,6 +2496,240 @@ 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 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 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"); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-2")); + 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"); + } + + [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 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); + 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 => + e.Kind == ChatTimelineItemKind.Assistant && e.Text == "stable response"); + } + [Fact] public async Task AgentEvent_ReasoningDelta_AccumulatesReasoningEntry() { @@ -1496,6 +2829,71 @@ public async Task StopResponseAsync_AfterLifecycleEnd_NoLongerAborts() Assert.Empty(bridge.AbortedRunIds); } + [Fact] + public async Task StopResponseAsync_WithQueuedFollowUp_WaitsForConfirmedTerminalEvent() + { + 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"); + + 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); + + 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 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() { @@ -2652,6 +4050,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 +4916,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", snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User).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 +4963,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", + snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User).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 +5131,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", snapshots[^1].Timelines["main"].Entries.Single(e => e.Kind == ChatTimelineItemKind.User).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 +5321,14 @@ private static (FakeBridge bridge, OpenClawChatDataProvider provider, List e.Kind == ChatTimelineItemKind.User); + Assert.Single(latest.Timelines["agent:main:main"].Entries, e => + e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); + Assert.Empty(GetQueuedMessages(latest, "agent:main:main")); } [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 +5360,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 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"); await provider.LoadAsync(); @@ -3948,8 +5386,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.Single(timeline.Entries, e => e.Kind == ChatTimelineItemKind.User && e.Text == "hi"); + Assert.Empty(GetQueuedMessages(latest, "agent:main:main")); } [Fact] @@ -4530,6 +5968,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) { }