Skip to content
Merged
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
31 changes: 24 additions & 7 deletions src/OpenClaw.Chat/ChatTimelineReducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,20 +189,37 @@ static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, b
}
}

if (replace && reconcilePrevious && state.Entries.Count > 0)
// A final ChatMessageEvent that arrives without an ActiveAssistantId
// must, in certain cases, reconcile into the most recent Assistant
// entry instead of creating a duplicate bubble:
//
// • `reconcilePrevious` flag: explicit opt-in from the provider,
// used when the gateway emits the final message AFTER tool entries
// have been appended (text → tool → tool output → final text). The
// flag lets the reducer collapse the streaming preview into the
// final text even though the immediate last entry is a ToolCall.
//
// • Identical-text safety net: if the most recent Assistant entry
// (within the same turn — i.e. before any User boundary) has
// byte-equal text to the incoming message, collapse them
// regardless of any flag. This catches duplicate ChatMessageEvent
// emissions from the gateway (see the duplicate-bubble screenshot
// bug where the same final text was rendered twice in a row).
if (replace && state.Entries.Count > 0)
{
// Scan backward for the most recent Assistant entry — not just
// the very last one. The gateway can emit a final message
// event AFTER tool entries have been appended (text → tool →
// tool output → final text), in which case the immediate last
// entry is a ToolCall. Without this scan, the final message
// would create a brand-new assistant entry that duplicates
// the streaming text the user already saw before the tool ran.
// the very last one (see reasons above).
for (var li = state.Entries.Count - 1; li >= 0; li--)
{
var candidate = state.Entries[li];
if (candidate.Kind == ChatTimelineItemKind.Assistant)
{
var shouldMerge =
reconcilePrevious
|| string.Equals(candidate.Text, text, StringComparison.Ordinal);
if (!shouldMerge)
break;

return state with
{
Entries = state.Entries.SetItem(li, candidate with
Expand Down
18 changes: 10 additions & 8 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1061,12 +1061,14 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
return Empty();

// Avatar shown only on the FIRST entry of a contiguous agent-side
// run. Continuation entries get no spacer — they align flush with
// the tool burst cards above (which also sit at the left inset),
// so the agent column reads as a single vertical edge.
Element leftSlot = !showAssistAvatar || !showAvatar
// run. Continuation entries reserve a 36×36 spacer so the bubble's
// left edge stays aligned with the first entry (matches the user
// burst path above and the tool burst path below).
Element leftSlot = !showAssistAvatar
? Empty()
: AssistantAvatar().VAlign(VerticalAlignment.Top);
: (showAvatar
? AssistantAvatar().VAlign(VerticalAlignment.Top)
: Border(Empty()).Size(36, 36));

// Assistant bubble — subtle gray with primary text. Radius/Padding
// come from ChatExplorationState (BubbleCornerRadius + PaddingDensity).
Expand Down Expand Up @@ -1103,7 +1105,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
var bubbleRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar ? bubbleSideMargin : 0, 0),
card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);

Expand All @@ -1117,7 +1119,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
entryMeta?.InputTokens, entryMeta?.OutputTokens,
entryMeta?.ResponseTokens, entryMeta?.ContextPercent,
chatStampFg, entry.Id, entry.Text ?? "");
var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0;
var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0;
leftInset += (int)bubblePadding.Left;
footer = footer.Margin(leftInset, 2, 0, 0);
}
Expand Down Expand Up @@ -1913,7 +1915,7 @@ string Truncate(string s, int max)
var burstRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar ? bubbleSideMargin : 0, 0),
listCard.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);

Expand Down
58 changes: 58 additions & 0 deletions tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,64 @@ public void DuplicateFinalAssistant_DoesNotCreateSecondEntry()
Assert.Equal("final", updated.Entries[0].Text);
}

[Fact]
public void DuplicateFinalAssistant_IdenticalText_DedupesWithoutReconcileFlag()
{
// Reproduces the duplicate-bubble screenshot bug: gateway re-emits
// the exact same final message after a turn end without setting the
// ReconcilePrevious flag. The reducer must collapse identical-text
// duplicates as a safety net so the UI doesn't render the same
// assistant text twice in a row.
var state = ChatTimelineReducer.Apply(
ChatTimelineState.Initial(),
new ChatMessageEvent("I don't see a pending approval."));
state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());
Assert.False(state.TurnActive);

var updated = ChatTimelineReducer.Apply(
state,
new ChatMessageEvent("I don't see a pending approval."));

Assert.Single(updated.Entries);
Assert.Equal("I don't see a pending approval.", updated.Entries[0].Text);
}

[Fact]
public void DuplicateFinalAssistant_DoesNotReactivatePreviousAssistant()
{
var state = ChatTimelineReducer.Apply(
ChatTimelineState.Initial(),
new ChatMessageEvent("previous"));
state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());
state = ChatTimelineReducer.Apply(state, new ChatMessageEvent("previous"));
state = ChatTimelineReducer.Apply(state, new ChatUserMessageEvent("next request"));

var updated = ChatTimelineReducer.Apply(state, new ChatMessageDeltaEvent("next response"));

Assert.Equal(3, updated.Entries.Count);
Assert.Equal("previous", updated.Entries[0].Text);
Assert.Equal(ChatTimelineItemKind.User, updated.Entries[1].Kind);
Assert.Equal("next response", updated.Entries[2].Text);
}

[Fact]
public void SubsequentAssistant_DifferentText_AfterTurnEnd_CreatesNewEntry()
{
// Guard against over-aggressive dedupe: a genuinely new assistant
// message in a later turn (different text, no reconcile flag, turn
// already ended) must NOT be merged into the previous entry.
var state = ChatTimelineReducer.Apply(
ChatTimelineState.Initial(),
new ChatMessageEvent("first"));
state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());

var updated = ChatTimelineReducer.Apply(state, new ChatMessageEvent("second"));

Assert.Equal(2, updated.Entries.Count);
Assert.Equal("first", updated.Entries[0].Text);
Assert.Equal("second", updated.Entries[1].Text);
}

[Fact]
public void AddLocalUser_CapsTrackedNonces()
{
Expand Down
Loading