Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/// <summary>
/// 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 <c>stream:"item", kind:"reasoning", phase:"end"</c> bracket
/// marker that delimits each distinct thinking pass within a single turn.
/// </summary>
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;
Expand Down
51 changes: 49 additions & 2 deletions src/OpenClaw.Chat/ChatTimelineReducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -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);

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/OpenClaw.Connection/GatewayConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/OpenClaw.SetupEngine/SetupSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}" : "")}");
}
Expand Down
6 changes: 6 additions & 0 deletions src/OpenClaw.Shared/IOpenClawLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
}

/// <summary>
Expand Down
12 changes: 12 additions & 0 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { }

Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Tray.WinUI/AppLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
36 changes: 33 additions & 3 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/OpenClaw.Tray.WinUI/Services/Logger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading