diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs index b54ca84e..e0e90515 100644 --- a/src/OpenClaw.Chat/ChatModels.cs +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -134,7 +134,14 @@ public record ChatUserMessageEvent(string Text, string? Nonce = null) : ChatEven public record ChatThinkingEvent(string Text) : ChatEvent; public record ChatReasoningEvent(string Text) : ChatEvent; public record ChatReasoningDeltaEvent(string Text) : ChatEvent; -public record ChatMessageEvent(string Text, string? ReasoningText = null, bool ReconcilePrevious = false) : ChatEvent; +/// +/// Closes the current reasoning section so the next reasoning chunk starts a +/// fresh bubble instead of appending/replacing the previous one. Emitted from +/// the gateway's stream:"item", kind:"reasoning", phase:"end" bracket +/// marker that delimits each distinct thinking pass within a single turn. +/// +public record ChatReasoningEndEvent() : ChatEvent; +public record ChatMessageEvent(string Text, string? ReasoningText = null, bool ReconcilePrevious = false, bool IsStreaming = false) : ChatEvent; public record ChatMessageDeltaEvent(string Text) : ChatEvent; public record ChatTurnEndEvent() : ChatEvent; public record ChatIntentEvent(string Intent) : ChatEvent; diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs index 6210e44c..abb9e921 100644 --- a/src/OpenClaw.Chat/ChatTimelineReducer.cs +++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs @@ -12,8 +12,9 @@ public static ChatTimelineState Apply(ChatTimelineState state, ChatEvent evt) ChatThinkingEvent => state with { TurnActive = true }, ChatReasoningEvent e => UpsertReasoning(BeginTurn(state), e.Text, replace: true), ChatReasoningDeltaEvent e => UpsertReasoning(BeginTurn(state), e.Text, replace: false), + ChatReasoningEndEvent => state.ActiveReasoningId is null ? state : state with { ActiveReasoningId = null }, ChatMessageDeltaEvent e => UpsertAssistant(BeginTurn(state), e.Text, replace: false, streaming: true), - ChatMessageEvent e => UpsertAssistant(BeginTurn(state), e.Text, replace: true, streaming: false, e.ReconcilePrevious), + ChatMessageEvent e => UpsertAssistant(BeginTurn(state), e.Text, replace: true, streaming: e.IsStreaming, e.ReconcilePrevious), ChatTurnEndEvent => ApplyTurnEnd(state), ChatIntentEvent e => state with { CurrentIntent = e.Intent }, ChatToolStartEvent e => ApplyToolStart(state, e), @@ -32,6 +33,11 @@ public static ChatTimelineState Apply(ChatTimelineState state, ChatEvent evt) public static ChatTimelineState AddLocalUser(ChatTimelineState state, string text, string nonce) { + // User message = hard turn boundary. Must apply BEFORE appending the + // new user entry so a future reconcile-flagged final can't fast-path- + // overwrite the prior turn's assistant reply. + state = ClearStreamingAtTurnBoundary(state); + var id = $"e{state.NextId}"; var localNonces = state.LocalNonces; if (localNonces.Count >= MaxLocalNonces) @@ -52,6 +58,31 @@ public static ChatTimelineState AddLocalUser(ChatTimelineState state, string tex }; } + // 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 + // still-streaming assistant entry so the next reconcile-flagged final + // cannot silently overwrite the prior turn's reply when ChatTurnEndEvent + // is dropped or delayed by the gateway. + static ChatTimelineState ClearStreamingAtTurnBoundary(ChatTimelineState state) + { + var entries = state.Entries; + for (var i = 0; i < entries.Count; i++) + { + if (entries[i].Kind == ChatTimelineItemKind.Assistant && entries[i].IsStreaming) + { + entries = entries.SetItem(i, entries[i] with { IsStreaming = false }); + } + } + + return state with + { + Entries = entries, + ActiveAssistantId = null, + ActiveReasoningId = null + }; + } + public static ChatTimelineState AddSystem(ChatTimelineState state, string text, ChatTone tone = ChatTone.Info) => PushEntry(state, ChatTimelineItemKind.Status, text, tone); @@ -160,6 +191,9 @@ static ChatTimelineState ApplyUserMessage(ChatTimelineState state, ChatUserMessa return state with { LocalNonces = state.LocalNonces.Remove(nonce) }; } + // User message = hard turn boundary (see ClearStreamingAtTurnBoundary). + state = ClearStreamingAtTurnBoundary(state); + var id = $"e{state.NextId}"; return state with { @@ -310,8 +344,21 @@ static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, b var candidate = state.Entries[li]; if (candidate.Kind == ChatTimelineItemKind.Assistant) { + // ``reconcilePrevious`` only collapses into a still-streaming + // assistant entry (a delta-state chat.message preview that + // hasn't been replaced by its final yet). A finalised + // assistant entry (IsStreaming=false, left behind by a + // prior final chat.message or ApplyTurnEnd) belongs to a + // completed turn and must NOT be overwritten by a new + // turn's reply — doing so silently swaps an older bubble's + // text in place and the new reply appears nowhere (see + // the system.run-denied repro: user → reply → tool → + // tool-output → reply, where the second reply was + // overwriting the first instead of appending). + // The byte-equal duplicate safety net is unconditional so + // genuine duplicate emissions still collapse. var shouldMerge = - reconcilePrevious + (reconcilePrevious && candidate.IsStreaming) || string.Equals(candidate.Text, text, StringComparison.Ordinal); if (!shouldMerge) break; diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index e15b5019..54ab1e4b 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -1308,6 +1308,8 @@ public void Info(string message) public void Debug(string message) => _inner.Debug(message); + public void Trace(string message) => _inner.Trace(message); + public void Warn(string message) { _inner.Warn(message); diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index ce57cd58..d708ce13 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -185,6 +185,11 @@ internal sealed class SetupOpenClawLogger(SetupLogger logger) : IOpenClawLogger { public void Info(string message) => logger.Info($"[WS] {message}"); public void Debug(string message) => logger.Debug($"[WS] {message}"); + // Trace intentionally drops to the default no-op: setup-engine sessions + // are short-lived and don't normally drive agent-event traffic, and there + // is no OPENCLAW_TRAY_TRACE-style opt-in gate available here. Letting the + // interface default (no-op) apply keeps verbose lines out of setup logs. + public void Trace(string message) { } public void Warn(string message) => logger.Warn($"[WS] {message}"); public void Error(string message, Exception? ex = null) => logger.Error($"[WS] {message}{(ex != null ? $": {ex}" : "")}"); } diff --git a/src/OpenClaw.Shared/IOpenClawLogger.cs b/src/OpenClaw.Shared/IOpenClawLogger.cs index 67476036..5a13b34b 100644 --- a/src/OpenClaw.Shared/IOpenClawLogger.cs +++ b/src/OpenClaw.Shared/IOpenClawLogger.cs @@ -10,6 +10,12 @@ public interface IOpenClawLogger void Debug(string message); void Warn(string message); void Error(string message, Exception? ex = null); + + // Verbose diagnostic channel. Default implementation drops the call so + // existing implementers (tests, console logger, etc.) don't need to be + // updated. Implementations that have a backing log file can opt in and + // gate output behind a flag. + void Trace(string message) { } } /// diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index f93b3905..1f43f2f2 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -2366,6 +2366,18 @@ private void HandleAgentEvent(JsonElement root, int rawMessageLength) { var streamHint = payload.TryGetProperty("stream", out var sh) ? sh.GetString() ?? "" : ""; _logger.Debug($"Agent event received: stream={streamHint} len={rawMessageLength}"); + // For item events, also surface kind+phase metadata (no payload + // content) so we can correlate which item kinds flow through. + // Trace-level: useful only when investigating event-shape issues + // and noisy under normal use. + if (string.Equals(streamHint, "item", StringComparison.OrdinalIgnoreCase) && + payload.TryGetProperty("data", out var dataEl) && + dataEl.ValueKind == JsonValueKind.Object) + { + var k = dataEl.TryGetProperty("kind", out var kp) ? kp.GetString() ?? "" : ""; + var ph = dataEl.TryGetProperty("phase", out var pp) ? pp.GetString() ?? "" : ""; + _logger.Trace($"Agent event item: kind={k} phase={ph}"); + } } catch { } diff --git a/src/OpenClaw.Tray.WinUI/AppLogger.cs b/src/OpenClaw.Tray.WinUI/AppLogger.cs index f1a66828..c8006a38 100644 --- a/src/OpenClaw.Tray.WinUI/AppLogger.cs +++ b/src/OpenClaw.Tray.WinUI/AppLogger.cs @@ -10,4 +10,5 @@ internal sealed class AppLogger : IOpenClawLogger public void Warn(string message) => Logger.Warn(message); public void Error(string message, Exception? ex = null) => Logger.Error(ex != null ? $"{message}: {ex.Message}" : message); + public void Trace(string message) => Logger.Trace(message); } diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index de902f1f..7cc1fb53 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -1310,10 +1310,18 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message) // assistant text (the gateway's EmbeddedBlockChunker emits completed // blocks, not token deltas — see spec §"Block Streaming"). Map both // to ChatMessageEvent so the reducer REPLACES the active assistant - // entry's text. Final additionally ends the turn. + // entry's text. We tag delta frames with IsStreaming:true so the + // reducer's reconcile-into-previous logic only collapses follow-up + // finals into a still-streaming preview — a finalised assistant + // from a completed earlier turn must not be silently overwritten + // by a brand-new turn's reply (e.g. user → reply → tool → reply). + // Final additionally ends the turn. ApplyEventAndPublish( threadId, - new ChatMessageEvent(RepairContentBlockSeams(TruncateForChatEntry(message.Text)), ReconcilePrevious: true), + new ChatMessageEvent( + RepairContentBlockSeams(TruncateForChatEntry(message.Text)), + ReconcilePrevious: true, + IsStreaming: !message.IsFinal), meta); if (message.IsFinal) @@ -1895,7 +1903,10 @@ static string SafeStr(System.Text.Json.JsonElement obj, string name) { var delta = deltaProp.GetString(); if (!string.IsNullOrEmpty(delta)) + { + try { Logger.Trace($"[ReasoningStream] kind=delta len={delta.Length}"); } catch { } return new ChatReasoningDeltaEvent(delta); + } } var contentText = evt.Data.TryGetProperty("content", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.String @@ -1904,7 +1915,10 @@ static string SafeStr(System.Text.Json.JsonElement obj, string name) ? t.GetString() : null); if (!string.IsNullOrEmpty(contentText)) + { + try { Logger.Trace($"[ReasoningStream] kind=full len={contentText!.Length}"); } catch { } return new ChatReasoningEvent(contentText!); + } return null; } @@ -1972,10 +1986,26 @@ static string SafeStr(System.Text.Json.JsonElement obj, string name) if (evt.Data.ValueKind != System.Text.Json.JsonValueKind.Object) return null; var kind = evt.Data.TryGetProperty("kind", out var kindProp) ? kindProp.GetString() ?? "" : ""; + var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : ""; + + // ``kind=reasoning`` brackets each distinct thinking pass the model + // performs within a turn (model reasons → tool call → reasons again). + // The reasoning prose itself arrives on ``stream:"reasoning"``; here + // we only need the ``phase=end`` boundary so the timeline reducer can + // close the active reasoning bubble. Without this signal consecutive + // reasoning passes concatenate into a single ever-growing entry, + // because ActiveReasoningId is otherwise only cleared on turn end. + if (string.Equals(kind, "reasoning", StringComparison.OrdinalIgnoreCase)) + { + try { Logger.Trace($"[ReasoningItem] phase={phase}"); } catch { } + return string.Equals(phase, "end", StringComparison.OrdinalIgnoreCase) + ? new ChatReasoningEndEvent() + : null; + } + if (!string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase)) return null; - var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : ""; var title = evt.Data.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "" : ""; var toolName = ExtractToolKindFromTitle(title); var itemId = evt.Data.TryGetProperty("itemId", out var idProp) ? idProp.GetString() : null; diff --git a/src/OpenClaw.Tray.WinUI/Services/Logger.cs b/src/OpenClaw.Tray.WinUI/Services/Logger.cs index f9eb64f6..571ee815 100644 --- a/src/OpenClaw.Tray.WinUI/Services/Logger.cs +++ b/src/OpenClaw.Tray.WinUI/Services/Logger.cs @@ -77,10 +77,19 @@ static Logger() public static string LogFilePath => _logFilePath; + // OPENCLAW_TRAY_TRACE=1 surfaces verbose protocol traces that are normally + // suppressed (e.g. per-event kind/phase dumps from the gateway client). + // Off by default so day-to-day logs aren't drowned in low-signal lines; + // diagnosis sessions can opt in without rebuilding. + private static readonly bool s_traceEnabled = + string.Equals(Environment.GetEnvironmentVariable("OPENCLAW_TRAY_TRACE"), "1", StringComparison.Ordinal) || + string.Equals(Environment.GetEnvironmentVariable("OPENCLAW_TRAY_TRACE"), "true", StringComparison.OrdinalIgnoreCase); + public static void Info(string message) => Log("INFO", message); public static void Warn(string message) => Log("WARN", message); public static void Error(string message) => Log("ERROR", message); public static void Debug(string message) => Log("DEBUG", message); + public static void Trace(string message) { if (s_traceEnabled) Log("TRACE", message); } private static void Log(string level, string message) { diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs index 95771315..ed63b75b 100644 --- a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using OpenClaw.Chat; namespace OpenClaw.Tray.Tests; @@ -39,6 +40,47 @@ public void Error_EndsActiveTurn() Assert.Equal(ChatTone.Error, updated.Entries[0].Tone); } + [Fact] + public void NewTurnFinalAssistant_DoesNotOverwriteFinalizedPreviousAssistant() + { + // Regression: a system.run approval-denied scenario produces a turn + // shape like: + // 1. user prompt + // 2. assistant finalised reply ("I'll check by running ...") + // 3. tool call + tool output + // 4. status entries (approval submitted, denied, etc.) + // 5. NEW turn: final assistant reply ("I can't run that.") + // + // OpenClawChatDataProvider always tags chat.message events with + // ReconcilePrevious=true. Before the fix the reducer scanned + // backwards past the tool / status entries, found the previous + // turn's finalised assistant entry, and silently OVERWROTE its text + // in place — making the new reply invisible and corrupting the + // earlier bubble. After the fix, reconcile only collapses into a + // still-streaming assistant entry, so a finalised assistant from a + // completed turn is left alone and the new reply appears as its + // own bubble. + var state = ChatTimelineReducer.Apply( + ChatTimelineState.Initial(), + new ChatUserMessageEvent("Identify which version of Node, Python, and git are installed.")); + state = ChatTimelineReducer.Apply(state, + new ChatMessageEvent("I'll check by running a small command.", ReconcilePrevious: true)); + state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent()); + state = ChatTimelineReducer.Apply(state, new ChatToolStartEvent("system.run", "system.run")); + state = ChatTimelineReducer.Apply(state, new ChatToolOutputEvent("denied: no matching rule")); + + var updated = ChatTimelineReducer.Apply(state, + new ChatMessageEvent("I can't run that command — it was denied.", ReconcilePrevious: true)); + + var assistantEntries = updated.Entries + .Where(e => e.Kind == ChatTimelineItemKind.Assistant) + .ToList(); + Assert.Equal(2, assistantEntries.Count); + Assert.Equal("I'll check by running a small command.", assistantEntries[0].Text); + Assert.Equal("I can't run that command — it was denied.", assistantEntries[1].Text); + Assert.False(assistantEntries[1].IsStreaming); + } + [Fact] public void FinalAssistant_UpdatesStreamingAssistantAfterTurnEnd() { @@ -54,6 +96,121 @@ public void FinalAssistant_UpdatesStreamingAssistantAfterTurnEnd() Assert.False(updated.Entries[0].IsStreaming); } + [Fact] + public void StaleStreamingPreview_DoesNotMergeAcrossUserBoundary() + { + // Regression for the cross-turn stale-preview class identified by + // both reviewers: a streaming preview that never received its terminal + // frame (network drop / aborted turn) must not be silently overwritten + // by a NEXT turn's reconcile-flagged final once the user sends a new + // prompt. The user message acts as a hard turn boundary that clears + // both ActiveAssistantId and stale IsStreaming on prior entries. + var state = ChatTimelineReducer.Apply( + ChatTimelineState.Initial(), + new ChatUserMessageEvent("first prompt")); + state = ChatTimelineReducer.Apply(state, new ChatMessageDeltaEvent("partial preview")); + state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent()); + + // No final ever arrived for turn 1 — preview is orphaned. + // Now turn 2 begins with a fresh user message and final reply. + state = ChatTimelineReducer.Apply(state, new ChatUserMessageEvent("second prompt")); + var updated = ChatTimelineReducer.Apply(state, + new ChatMessageEvent("turn 2 final", ReconcilePrevious: true)); + + var assistantEntries = updated.Entries + .Where(e => e.Kind == ChatTimelineItemKind.Assistant) + .ToList(); + Assert.Equal(2, assistantEntries.Count); + Assert.Equal("partial preview", assistantEntries[0].Text); + Assert.False(assistantEntries[0].IsStreaming); + Assert.Equal("turn 2 final", assistantEntries[1].Text); + } + + [Fact] + public void UserMessage_AsTurnBoundary_PreventsCrossTurnOverwrite() + { + // Regression for the dropped-ChatTurnEndEvent edge case: if the + // gateway omits chat.turn.end before the next user prompt, the + // reducer must still treat ChatUserMessageEvent as a hard turn + // boundary by clearing ActiveAssistantId. Otherwise the fast-path + // overwrite branch in UpsertAssistant would silently replace the + // previous turn's assistant reply in place. + var state = ChatTimelineReducer.Apply( + ChatTimelineState.Initial(), + new ChatUserMessageEvent("first")); + state = ChatTimelineReducer.Apply(state, new ChatMessageEvent("first reply")); + // Note: NO ChatTurnEndEvent before the next user message. + Assert.NotNull(state.ActiveAssistantId); + + state = ChatTimelineReducer.Apply(state, new ChatUserMessageEvent("second")); + Assert.Null(state.ActiveAssistantId); + + var updated = ChatTimelineReducer.Apply(state, new ChatMessageEvent("second reply")); + + var assistantEntries = updated.Entries + .Where(e => e.Kind == ChatTimelineItemKind.Assistant) + .ToList(); + Assert.Equal(2, assistantEntries.Count); + Assert.Equal("first reply", assistantEntries[0].Text); + Assert.Equal("second reply", assistantEntries[1].Text); + } + + [Fact] + public void AddLocalUser_AsTurnBoundary_PreventsCrossTurnOverwrite() + { + // Same regression as above but exercises the PRODUCTION typed-message + // path (AddLocalUser) rather than gateway-injected ChatUserMessageEvent. + // The tray's text-input box calls AddLocalUser; SSE echoes are usually + // suppressed before they reach ApplyUserMessage. So the cross-turn + // boundary cleanup MUST also live in AddLocalUser. + var state = ChatTimelineReducer.AddLocalUser( + ChatTimelineState.Initial(), + "first", + "nonce-1"); + state = ChatTimelineReducer.Apply(state, new ChatMessageEvent("first reply")); + // Note: NO ChatTurnEndEvent before the next typed message. + Assert.NotNull(state.ActiveAssistantId); + + state = ChatTimelineReducer.AddLocalUser(state, "second", "nonce-2"); + Assert.Null(state.ActiveAssistantId); + + var updated = ChatTimelineReducer.Apply(state, new ChatMessageEvent("second reply")); + + var assistantEntries = updated.Entries + .Where(e => e.Kind == ChatTimelineItemKind.Assistant) + .ToList(); + Assert.Equal(2, assistantEntries.Count); + Assert.Equal("first reply", assistantEntries[0].Text); + Assert.Equal("second reply", assistantEntries[1].Text); + } + + [Fact] + public void AddLocalUser_ClearsStaleStreamingPreviewAcrossTurns() + { + // Stale-streaming regression via the production typed-message path. + // A streaming preview that never received its terminal frame must not + // be silently overwritten when the user types their next prompt. + var state = ChatTimelineReducer.AddLocalUser( + ChatTimelineState.Initial(), + "first prompt", + "nonce-1"); + state = ChatTimelineReducer.Apply(state, new ChatMessageDeltaEvent("partial preview")); + state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent()); + + // No final ever arrived for turn 1 — preview is orphaned but still IsStreaming=true. + state = ChatTimelineReducer.AddLocalUser(state, "second prompt", "nonce-2"); + var updated = ChatTimelineReducer.Apply(state, + new ChatMessageEvent("turn 2 final", ReconcilePrevious: true)); + + var assistantEntries = updated.Entries + .Where(e => e.Kind == ChatTimelineItemKind.Assistant) + .ToList(); + Assert.Equal(2, assistantEntries.Count); + Assert.Equal("partial preview", assistantEntries[0].Text); + Assert.False(assistantEntries[0].IsStreaming); + Assert.Equal("turn 2 final", assistantEntries[1].Text); + } + [Fact] public void DuplicateFinalAssistant_DoesNotCreateSecondEntry() { @@ -416,6 +573,50 @@ public void TurnEnd_ClearsActiveReasoningId() Assert.Null(updated.ActiveReasoningId); } + [Fact] + public void ReasoningEnd_ClearsActiveReasoningIdWithoutEndingTurn() + { + var state = ChatTimelineReducer.Apply( + ChatTimelineState.Initial(), + new ChatReasoningEvent("first pass")); + + Assert.NotNull(state.ActiveReasoningId); + Assert.True(state.TurnActive); + + var updated = ChatTimelineReducer.Apply(state, new ChatReasoningEndEvent()); + + Assert.Null(updated.ActiveReasoningId); + Assert.True(updated.TurnActive); + // The original reasoning entry is preserved (not deleted). + Assert.Single(updated.Entries); + Assert.Equal("first pass", updated.Entries[0].Text); + } + + [Fact] + public void ReasoningEnd_NextReasoningChunkStartsFreshEntry() + { + var s1 = ChatTimelineReducer.Apply( + ChatTimelineState.Initial(), + new ChatReasoningDeltaEvent("thinking about A")); + var s2 = ChatTimelineReducer.Apply(s1, new ChatReasoningEndEvent()); + var s3 = ChatTimelineReducer.Apply(s2, new ChatReasoningDeltaEvent("thinking about B")); + + Assert.Equal(2, s3.Entries.Count); + Assert.Equal("thinking about A", s3.Entries[0].Text); + Assert.Equal("thinking about B", s3.Entries[1].Text); + Assert.Equal(ChatTimelineItemKind.Reasoning, s3.Entries[0].Kind); + Assert.Equal(ChatTimelineItemKind.Reasoning, s3.Entries[1].Kind); + } + + [Fact] + public void ReasoningEnd_NoActiveReasoning_IsNoOp() + { + var initial = ChatTimelineState.Initial(); + var updated = ChatTimelineReducer.Apply(initial, new ChatReasoningEndEvent()); + + Assert.Equal(initial, updated); + } + // ── Intent events ── [Fact] diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index b1c3ae03..dd6ed8b2 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -637,6 +637,50 @@ public async Task AgentEvent_ReasoningDelta_AccumulatesReasoningEntry() Assert.Equal("thinking… step 2.", entry.Text); } + [Fact] + public async Task AgentEvent_ReasoningItemEnd_StartsFreshReasoningBubble() + { + // Regression: when the model reasons → tool → reasons again within + // a single turn, the second reasoning pass must render as its own + // bubble. The gateway brackets each pass with + // stream:"item", kind:"reasoning", phase:"start|end" — without + // honoring the end marker the second pass concatenates into the + // first bubble instead of replacing it. + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + bridge.RaiseAgent(MakeAgentEvent("reasoning", """{"delta":"first pass"}""")); + bridge.RaiseAgent(MakeAgentEvent("item", """{"kind":"reasoning","phase":"end","itemId":"r1"}""")); + bridge.RaiseAgent(MakeAgentEvent("reasoning", """{"delta":"second pass"}""")); + + var timeline = snapshots[^1].Timelines["main"]; + var reasoningEntries = timeline.Entries.Where(e => e.Kind == ChatTimelineItemKind.Reasoning).ToList(); + Assert.Equal(2, reasoningEntries.Count); + Assert.Equal("first pass", reasoningEntries[0].Text); + Assert.Equal("second pass", reasoningEntries[1].Text); + } + + [Fact] + public async Task AgentEvent_ReasoningItemStart_IsIgnored() + { + // Only phase=end closes the bubble; phase=start is informational + // and must not produce a stray timeline entry. + var (bridge, provider, snapshots, _) = CreateProvider(new[] { MainSession() }); + await provider.LoadAsync(); + snapshots.Clear(); + + bridge.RaiseAgent(MakeAgentEvent("lifecycle", """{"phase":"start"}""", runId: "run-1")); + bridge.RaiseAgent(MakeAgentEvent("item", """{"kind":"reasoning","phase":"start","itemId":"r1"}""")); + bridge.RaiseAgent(MakeAgentEvent("reasoning", """{"delta":"only pass"}""")); + + var timeline = snapshots[^1].Timelines["main"]; + var entry = Assert.Single(timeline.Entries); + Assert.Equal(ChatTimelineItemKind.Reasoning, entry.Kind); + Assert.Equal("only pass", entry.Text); + } + [Fact] public async Task StopResponseAsync_WithActiveRun_CallsAbortRpc() {