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()
{