Skip to content

fix(tui): suppress duplicate assistant message after sessions.changed event#96979

Closed
chenyangjun-xy wants to merge 1 commit into
openclaw:mainfrom
chenyangjun-xy:fix/tui-duplicate-message-sessions-changed
Closed

fix(tui): suppress duplicate assistant message after sessions.changed event#96979
chenyangjun-xy wants to merge 1 commit into
openclaw:mainfrom
chenyangjun-xy:fix/tui-duplicate-message-sessions-changed

Conversation

@chenyangjun-xy

@chenyangjun-xy chenyangjun-xy commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

handleSessionsChangedEvent in src/tui/tui-event-handlers.ts triggers loadHistory() when a sessions.changed event with reason: "new" fires. loadHistory() clears the chat log and replays all messages as static history entries. However, if a chat run was actively streaming at the time the event fired, the streaming final event can arrive after the history replay has already rendered the same message as a static entry. handleChatEvent unconditionally calls finalizeAssistant, which appends the message a second time — producing a duplicate in the chat log.

The race is:

User sends a message; the agent starts streaming (activeChatRunId / sessionRuns populated)
A sessions.changed "new" event fires (e.g. session file created/recreated on disk)
handleSessionsChangedEvent calls clearTrackedRunState() and queues loadHistory()
loadHistory() clears the chat log, replays the in-flight message as a static entry, removing the streaming component
The original streaming run's final event arrives → finalizeAssistant appends a second copy of the message
The same duplicate pattern could also affect delta and aborted/error states arriving late.

Why This Change Was Made

The fix introduces a surrenderedToHistoryRunIds set that tracks run IDs handed off to a pending loadHistory() call. When handleSessionsChangedEvent receives a "new" event, it captures every tracked run ID (activeChatRunId, pendingChatRunId, sessionRuns, finalizedRuns) into this set. handleChatEvent then checks this set before processing chat events:

delta: suppressed entirely — the history replay already rendered the message as static
final: tracking state is finalized (activity indicator, watchdog, pending-run cleanup), but finalizeAssistant is not called — avoiding the duplicate render
aborted/error: cleaned up without appending duplicate history entries
A critical edge case is when loadHistory() restores a run as an in-flight streaming component (via inFlightRun). In that case, chatLog.hasStreamingRun() returns true, and the handler falls through to normal processing — the streaming component needs live delta/final events to reach its final state. The hasStreamingRun method was added to ChatLog as a clean query for this check.

A 15-second cleanup timer (unref'd so it doesn't keep the process alive) prevents the set from leaking run IDs indefinitely.

User Impact

TUI users no longer see duplicate messages when a sessions.changed event fires mid-streaming. Normal chat flows are unaffected.

Evidence

No new test file was added for this fix. The existing test suite in src/tui/tui-event-handlers.test.ts was extended to stub hasStreamingRun on the mock chat log.

What was not tested: live TUI sessions with real sessions.changed events during streaming.

@clawsweeper

clawsweeper Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close: the duplicate TUI assistant-reply bug is now better owned by the open, clean, proof-positive sibling PR, and this branch still has a concrete message-loss edge when history re-adopts an empty in-flight run.

Canonical path: Use #96980 as the canonical fix path; if maintainers reject it, revive a narrow TUI fix that proves both duplicate suppression and no dropped late finals.

So I’m closing this here and keeping the remaining discussion on #96980.

Review details

Best possible solution:

Use #96980 as the canonical fix path; if maintainers reject it, revive a narrow TUI fix that proves both duplicate suppression and no dropped late finals.

Do we have a high-confidence way to reproduce the issue?

Yes at source level: current main reloads history on matching sessions.changed events, history replay renders assistant rows without run IDs, and live finals render with run IDs. I did not run a live Telegram/TUI reproduction in this read-only review.

Is this the best way to solve the issue?

No. This event-handler-only approach is not the best fix because it treats the absence of a streaming component as proof that history already displayed the answer, while current loadHistory() can re-adopt an empty in-flight run without creating that component.

Security review:

Security review cleared: No concrete security or supply-chain concern was found; the diff is limited to TUI rendering/session-state code and test mock types.

AGENTS.md: found and applied where relevant.

What I checked:

  • Repository policy read: Root AGENTS.md and the scoped TUI AGENTS.md were read fully; the TUI proof guidance and ClawSweeper whole-path review policy shaped the review. (AGENTS.md:14, 08d15ec32db3)
  • Current source: session-change reload path: Current main handles matching sessions.changed new/reset events by clearing tracked run state and calling loadHistory(), which is the reload path involved in the duplicate-message report. (src/tui/tui-event-handlers.ts:750, 08d15ec32db3)
  • Current source: history replay loses run identity: loadHistory() replays assistant history with chatLog.finalizeAssistant(text) and no runId, so replayed rows cannot match later runId-keyed live finals. (src/tui/tui-session-actions.ts:503, 08d15ec32db3)
  • Current source: empty in-flight restore is valid: Current main adopts record.inFlightRun.runId even when inFlightRun.text is empty, and only creates a ChatLog streaming component when text exists. (src/tui/tui-session-actions.ts:541, 08d15ec32db3)
  • PR head message-loss edge: At PR head, a surrendered run with no ChatLog streaming component returns before finalizeAssistant, so the valid empty in-flight restore path can drop the only displayable late final. (src/tui/tui-event-handlers.ts:648, 54d264483256)
  • Canonical sibling proof: The open sibling PR fix(tui): deduplicate assistant messages across sessions.changed reload #96980 targets the same linked duplicate-message bug, is clean, and its body includes focused tests plus after-fix Gateway/TUI terminal proof showing one visible assistant reply. (38edf6bf81c0)

Likely related people:

  • vincentkoc: Authored and merged the active-session sessions.changed refresh path in fix(tui): refresh after external session reset #93562, which this duplicate-message work follows up on. (role: recent TUI session-change contributor and merger; confidence: high; commits: 9b49387ad8c2, 5bb580e11d47; files: src/tui/tui-event-handlers.ts, src/tui/tui.ts, src/tui/gateway-chat.ts)
  • SebTardif: Authored the merged displayable-final reload guard in fix(tui): skip history reload when final event has displayable output #88004, the closest prior fix for destructive TUI history reload races. (role: adjacent final/history reload fix author; confidence: medium; commits: 9a1b95c1e63a, f2f4f0112bfb; files: src/tui/tui-event-handlers.ts, src/tui/tui-event-handlers.test.ts)
  • lisitan: Earlier merged ChatLog work added runId-based duplicate assistant suppression in the same component family that the canonical sibling extends. (role: adjacent ChatLog dedupe contributor; confidence: medium; commits: f3c00fce1578; files: src/tui/components/chat-log.ts, src/tui/components/chat-log.test.ts)
  • Gio Della-Libera: Current-line blame for the central TUI event-handler, session-action, and ChatLog lines points at a broad refactor commit, so this is a recent-line history signal rather than the original feature source. (role: recent current-line refactor author; confidence: low; commits: cb4244fe15e3; files: src/tui/tui-event-handlers.ts, src/tui/tui-session-actions.ts, src/tui/components/chat-log.ts)

Codex review notes: model internal, reasoning high; reviewed against 08d15ec32db3.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. labels Jun 26, 2026
@chenyangjun-xy
chenyangjun-xy force-pushed the fix/tui-duplicate-message-sessions-changed branch from d3381e9 to ed14933 Compare June 26, 2026 08:29
@clawsweeper clawsweeper Bot added the merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. label Jun 26, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the scripts Repository scripts label Jun 27, 2026
@clawsweeper clawsweeper Bot added merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. and removed merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. labels Jun 27, 2026
@openclaw-barnacle openclaw-barnacle Bot added size: S triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. and removed size: M triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. labels Jun 28, 2026
When sessions.changed new fires mid-stream, loadHistory() clears
the chat log and replays the message as a static entry. If the final
chat event arrives after that replay, finalizeAssistant would append
a duplicate.

Add a surrenderedToHistoryRunIds tracker with three defense layers:
1. Proximity gating — capture all tracked runIds before loadHistory()
2. 15s TTL cleanup — auto-expire to avoid permanent blocking
3. Mid-stream gating — hasStreamingRun() check to distinguish
   in-flight restores from static replays
@chenyangjun-xy
chenyangjun-xy force-pushed the fix/tui-duplicate-message-sessions-changed branch from dd13dcb to 54d2644 Compare June 28, 2026 09:42
@chenyangjun-xy

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper applied the proposed close for this PR.

  • Action: closed this PR.
  • Close reason: duplicate or superseded.
  • Evidence: durable ClawSweeper review.
  • Coverage proof: PR B clearly carries PR A's core useful intent for the same duplicate assistant-message bug and is identified by PR A's durable review as the better canonical, proof-positive place to land and review that work; PR A's remaining concerns are about its flawed implementation, not separate useful scope that needs independent review. Covering PR: fix(tui): deduplicate assistant messages across sessions.changed reload #96980.

@clawsweeper clawsweeper Bot closed this Jun 28, 2026
xialonglee added a commit to xialonglee/openclaw that referenced this pull request Jun 29, 2026
…racking

Drop the lastHistoryAssistant text-matching dedupe in ChatLog
(posed P1 false-suppression risk). Instead, track active runIds
surrendered to loadHistory() on sessions.changed 'new' — late
events for surrendered runs are suppressed by exact runId match.

Add ChatLog.hasStreamingRun() so the event handler can detect
when loadHistory restored a surrendered run as in-flight streaming.

Retain chatFinalizedRuns / chatFinalizedRunsWithDisplay from the
original fix for post-reload delta/error dedup of finalized runs.

Incorporates the precise surrender-tracking approach from closed
PRs openclaw#96979/openclaw#96986 while keeping the scoped ChatLog API minimal.
xialonglee added a commit to xialonglee/openclaw that referenced this pull request Jun 29, 2026
…e final

Covers the rank-up scenario from openclaw#96979: surrendered run restored
by loadHistory as empty streaming, late displayable final passes
through surrender check and renders to chatLog.
xialonglee added a commit to xialonglee/openclaw that referenced this pull request Jun 29, 2026
…path

Reset also calls clearTrackedRunState() + loadHistory() with the same
sessionKey, so late chat events for runs from the old session window
can race with the history replay the same way as 'new'. Surrender
sessionRuns and chatFinalizedRuns on both reasons (openclaw#96979 P1 rank-up).
steipete pushed a commit to xialonglee/openclaw that referenced this pull request Jul 7, 2026
…racking

Drop the lastHistoryAssistant text-matching dedupe in ChatLog
(posed P1 false-suppression risk). Instead, track active runIds
surrendered to loadHistory() on sessions.changed 'new' — late
events for surrendered runs are suppressed by exact runId match.

Add ChatLog.hasStreamingRun() so the event handler can detect
when loadHistory restored a surrendered run as in-flight streaming.

Retain chatFinalizedRuns / chatFinalizedRunsWithDisplay from the
original fix for post-reload delta/error dedup of finalized runs.

Incorporates the precise surrender-tracking approach from closed
PRs openclaw#96979/openclaw#96986 while keeping the scoped ChatLog API minimal.
steipete pushed a commit to xialonglee/openclaw that referenced this pull request Jul 7, 2026
…e final

Covers the rank-up scenario from openclaw#96979: surrendered run restored
by loadHistory as empty streaming, late displayable final passes
through surrender check and renders to chatLog.
steipete pushed a commit to xialonglee/openclaw that referenced this pull request Jul 7, 2026
…path

Reset also calls clearTrackedRunState() + loadHistory() with the same
sessionKey, so late chat events for runs from the old session window
can race with the history replay the same way as 'new'. Surrender
sessionRuns and chatFinalizedRuns on both reasons (openclaw#96979 P1 rank-up).
steipete pushed a commit to xialonglee/openclaw that referenced this pull request Jul 7, 2026
…racking

Drop the lastHistoryAssistant text-matching dedupe in ChatLog
(posed P1 false-suppression risk). Instead, track active runIds
surrendered to loadHistory() on sessions.changed 'new' — late
events for surrendered runs are suppressed by exact runId match.

Add ChatLog.hasStreamingRun() so the event handler can detect
when loadHistory restored a surrendered run as in-flight streaming.

Retain chatFinalizedRuns / chatFinalizedRunsWithDisplay from the
original fix for post-reload delta/error dedup of finalized runs.

Incorporates the precise surrender-tracking approach from closed
PRs openclaw#96979/openclaw#96986 while keeping the scoped ChatLog API minimal.
steipete pushed a commit to xialonglee/openclaw that referenced this pull request Jul 7, 2026
…e final

Covers the rank-up scenario from openclaw#96979: surrendered run restored
by loadHistory as empty streaming, late displayable final passes
through surrender check and renders to chatLog.
steipete pushed a commit to xialonglee/openclaw that referenced this pull request Jul 7, 2026
…path

Reset also calls clearTrackedRunState() + loadHistory() with the same
sessionKey, so late chat events for runs from the old session window
can race with the history replay the same way as 'new'. Surrender
sessionRuns and chatFinalizedRuns on both reasons (openclaw#96979 P1 rank-up).
steipete added a commit that referenced this pull request Jul 7, 2026
…ad (#96980)

* fix(tui): suppress adjacent history-replay/live-final duplicate in ChatLog (#96967)

When sessions.changed triggers loadHistory(), assistant rows are replayed
via finalizeAssistant(text) without a runId. A late live final with the
same text and a runId can land immediately afterward, producing a visible
duplicate assistant reply.

Track the most recent history-replay assistant component in ChatLog and
suppress a following live final when the text matches and no other
component was appended in between. This is simpler and more targeted
than the prior event-handler approach because it operates at the
rendering layer where the duplicate is actually visible.

Fixes #96967

* fix(tui): bound replay-final dedupe window in ChatLog

* fix(tui): bound replay-final dedupe marker with TTL expiration

* fix(tui): satisfy no-promise-executor-return lint rule in chat-log test

* fix(tui): replace ChatLog text heuristic with runId-based surrender tracking

Drop the lastHistoryAssistant text-matching dedupe in ChatLog
(posed P1 false-suppression risk). Instead, track active runIds
surrendered to loadHistory() on sessions.changed 'new' — late
events for surrendered runs are suppressed by exact runId match.

Add ChatLog.hasStreamingRun() so the event handler can detect
when loadHistory restored a surrendered run as in-flight streaming.

Retain chatFinalizedRuns / chatFinalizedRunsWithDisplay from the
original fix for post-reload delta/error dedup of finalized runs.

Incorporates the precise surrender-tracking approach from closed
PRs #96979/#96986 while keeping the scoped ChatLog API minimal.

* fix(tui): render displayable late final for surrendered non-streaming run

When a sessions.changed 'new' surrenders an in-flight run and
loadHistory does not restore it as streaming, a late displayable
final must still be rendered — otherwise the user never sees the
assistant reply. Only suppress non-displayable finals (empty
message, no errorMessage).

Add focused test for non-displayable final suppression.

* test(tui): add coverage for empty in-flight restore + late displayable final

Covers the rank-up scenario from #96979: surrendered run restored
by loadHistory as empty streaming, late displayable final passes
through surrender check and renders to chatLog.

* fix(tui): suppress late finals for surrendered finalized runs to prevent history-replay duplicate

When sessions.changed 'new' fires, now surrender chatFinalizedRuns entries
too (as 'finalized'), not just sessionRuns ('in-flight'). This prevents
the finalized-before-reload history-replay duplicate: a late final for an
already-displayed run gets suppressed because the history-replay static
row covers the visible content.

In-flight surrendered runs still render displayable finals because the
user may not have seen the streaming text before the reload.

SurrenderedToHistoryRunIds is now Map<string,'in-flight'|'finalized'>
so the surrender check can branch on source. Move surrender check before
the finalizedRuns/chatFinalizedRuns guard so surrendered finalized runs
are routed correctly.

Add focused tests for history-replayed-visible (suppress) and
history-replay-missing (render) late finals.

* fix(tui): extend surrender coverage to sessions.changed reset reload path

Reset also calls clearTrackedRunState() + loadHistory() with the same
sessionKey, so late chat events for runs from the old session window
can race with the history replay the same way as 'new'. Surrender
sessionRuns and chatFinalizedRuns on both reasons (#96979 P1 rank-up).

* fix(tui): keep surrendered finalized marker after late delta to block later final

For finalized surrendered runs, a late delta must not clear the
surrender marker — otherwise a subsequent late final would slip
through and produce a duplicate. Only in-flight surrendered runs
clear the marker on delta (their final needs to render).

* fix(tui): gate session history reloads on persistence

Co-authored-by: xialonglee <[email protected]>

* docs(changelog): note TUI external-run dedupe

* test(tui): type history load harness results

* test(tui): defer history results without async mocks

* chore(changelog): defer TUI note to release

---------

Co-authored-by: Peter Steinberger <[email protected]>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 7, 2026
…ad (openclaw#96980)

* fix(tui): suppress adjacent history-replay/live-final duplicate in ChatLog (openclaw#96967)

When sessions.changed triggers loadHistory(), assistant rows are replayed
via finalizeAssistant(text) without a runId. A late live final with the
same text and a runId can land immediately afterward, producing a visible
duplicate assistant reply.

Track the most recent history-replay assistant component in ChatLog and
suppress a following live final when the text matches and no other
component was appended in between. This is simpler and more targeted
than the prior event-handler approach because it operates at the
rendering layer where the duplicate is actually visible.

Fixes openclaw#96967

* fix(tui): bound replay-final dedupe window in ChatLog

* fix(tui): bound replay-final dedupe marker with TTL expiration

* fix(tui): satisfy no-promise-executor-return lint rule in chat-log test

* fix(tui): replace ChatLog text heuristic with runId-based surrender tracking

Drop the lastHistoryAssistant text-matching dedupe in ChatLog
(posed P1 false-suppression risk). Instead, track active runIds
surrendered to loadHistory() on sessions.changed 'new' — late
events for surrendered runs are suppressed by exact runId match.

Add ChatLog.hasStreamingRun() so the event handler can detect
when loadHistory restored a surrendered run as in-flight streaming.

Retain chatFinalizedRuns / chatFinalizedRunsWithDisplay from the
original fix for post-reload delta/error dedup of finalized runs.

Incorporates the precise surrender-tracking approach from closed
PRs openclaw#96979/openclaw#96986 while keeping the scoped ChatLog API minimal.

* fix(tui): render displayable late final for surrendered non-streaming run

When a sessions.changed 'new' surrenders an in-flight run and
loadHistory does not restore it as streaming, a late displayable
final must still be rendered — otherwise the user never sees the
assistant reply. Only suppress non-displayable finals (empty
message, no errorMessage).

Add focused test for non-displayable final suppression.

* test(tui): add coverage for empty in-flight restore + late displayable final

Covers the rank-up scenario from openclaw#96979: surrendered run restored
by loadHistory as empty streaming, late displayable final passes
through surrender check and renders to chatLog.

* fix(tui): suppress late finals for surrendered finalized runs to prevent history-replay duplicate

When sessions.changed 'new' fires, now surrender chatFinalizedRuns entries
too (as 'finalized'), not just sessionRuns ('in-flight'). This prevents
the finalized-before-reload history-replay duplicate: a late final for an
already-displayed run gets suppressed because the history-replay static
row covers the visible content.

In-flight surrendered runs still render displayable finals because the
user may not have seen the streaming text before the reload.

SurrenderedToHistoryRunIds is now Map<string,'in-flight'|'finalized'>
so the surrender check can branch on source. Move surrender check before
the finalizedRuns/chatFinalizedRuns guard so surrendered finalized runs
are routed correctly.

Add focused tests for history-replayed-visible (suppress) and
history-replay-missing (render) late finals.

* fix(tui): extend surrender coverage to sessions.changed reset reload path

Reset also calls clearTrackedRunState() + loadHistory() with the same
sessionKey, so late chat events for runs from the old session window
can race with the history replay the same way as 'new'. Surrender
sessionRuns and chatFinalizedRuns on both reasons (openclaw#96979 P1 rank-up).

* fix(tui): keep surrendered finalized marker after late delta to block later final

For finalized surrendered runs, a late delta must not clear the
surrender marker — otherwise a subsequent late final would slip
through and produce a duplicate. Only in-flight surrendered runs
clear the marker on delta (their final needs to render).

* fix(tui): gate session history reloads on persistence

Co-authored-by: xialonglee <[email protected]>

* docs(changelog): note TUI external-run dedupe

* test(tui): type history load harness results

* test(tui): defer history results without async mocks

* chore(changelog): defer TUI note to release

---------

Co-authored-by: Peter Steinberger <[email protected]>
sheyanmin pushed a commit to sheyanmin/openclaw that referenced this pull request Jul 8, 2026
…ad (openclaw#96980)

* fix(tui): suppress adjacent history-replay/live-final duplicate in ChatLog (openclaw#96967)

When sessions.changed triggers loadHistory(), assistant rows are replayed
via finalizeAssistant(text) without a runId. A late live final with the
same text and a runId can land immediately afterward, producing a visible
duplicate assistant reply.

Track the most recent history-replay assistant component in ChatLog and
suppress a following live final when the text matches and no other
component was appended in between. This is simpler and more targeted
than the prior event-handler approach because it operates at the
rendering layer where the duplicate is actually visible.

Fixes openclaw#96967

* fix(tui): bound replay-final dedupe window in ChatLog

* fix(tui): bound replay-final dedupe marker with TTL expiration

* fix(tui): satisfy no-promise-executor-return lint rule in chat-log test

* fix(tui): replace ChatLog text heuristic with runId-based surrender tracking

Drop the lastHistoryAssistant text-matching dedupe in ChatLog
(posed P1 false-suppression risk). Instead, track active runIds
surrendered to loadHistory() on sessions.changed 'new' — late
events for surrendered runs are suppressed by exact runId match.

Add ChatLog.hasStreamingRun() so the event handler can detect
when loadHistory restored a surrendered run as in-flight streaming.

Retain chatFinalizedRuns / chatFinalizedRunsWithDisplay from the
original fix for post-reload delta/error dedup of finalized runs.

Incorporates the precise surrender-tracking approach from closed
PRs openclaw#96979/openclaw#96986 while keeping the scoped ChatLog API minimal.

* fix(tui): render displayable late final for surrendered non-streaming run

When a sessions.changed 'new' surrenders an in-flight run and
loadHistory does not restore it as streaming, a late displayable
final must still be rendered — otherwise the user never sees the
assistant reply. Only suppress non-displayable finals (empty
message, no errorMessage).

Add focused test for non-displayable final suppression.

* test(tui): add coverage for empty in-flight restore + late displayable final

Covers the rank-up scenario from openclaw#96979: surrendered run restored
by loadHistory as empty streaming, late displayable final passes
through surrender check and renders to chatLog.

* fix(tui): suppress late finals for surrendered finalized runs to prevent history-replay duplicate

When sessions.changed 'new' fires, now surrender chatFinalizedRuns entries
too (as 'finalized'), not just sessionRuns ('in-flight'). This prevents
the finalized-before-reload history-replay duplicate: a late final for an
already-displayed run gets suppressed because the history-replay static
row covers the visible content.

In-flight surrendered runs still render displayable finals because the
user may not have seen the streaming text before the reload.

SurrenderedToHistoryRunIds is now Map<string,'in-flight'|'finalized'>
so the surrender check can branch on source. Move surrender check before
the finalizedRuns/chatFinalizedRuns guard so surrendered finalized runs
are routed correctly.

Add focused tests for history-replayed-visible (suppress) and
history-replay-missing (render) late finals.

* fix(tui): extend surrender coverage to sessions.changed reset reload path

Reset also calls clearTrackedRunState() + loadHistory() with the same
sessionKey, so late chat events for runs from the old session window
can race with the history replay the same way as 'new'. Surrender
sessionRuns and chatFinalizedRuns on both reasons (openclaw#96979 P1 rank-up).

* fix(tui): keep surrendered finalized marker after late delta to block later final

For finalized surrendered runs, a late delta must not clear the
surrender marker — otherwise a subsequent late final would slip
through and produce a duplicate. Only in-flight surrendered runs
clear the marker on delta (their final needs to render).

* fix(tui): gate session history reloads on persistence

Co-authored-by: xialonglee <[email protected]>

* docs(changelog): note TUI external-run dedupe

* test(tui): type history load harness results

* test(tui): defer history results without async mocks

* chore(changelog): defer TUI note to release

---------

Co-authored-by: Peter Steinberger <[email protected]>
giodl73-repo pushed a commit to giodl73-repo/openclaw that referenced this pull request Jul 8, 2026
…ad (openclaw#96980)

* fix(tui): suppress adjacent history-replay/live-final duplicate in ChatLog (openclaw#96967)

When sessions.changed triggers loadHistory(), assistant rows are replayed
via finalizeAssistant(text) without a runId. A late live final with the
same text and a runId can land immediately afterward, producing a visible
duplicate assistant reply.

Track the most recent history-replay assistant component in ChatLog and
suppress a following live final when the text matches and no other
component was appended in between. This is simpler and more targeted
than the prior event-handler approach because it operates at the
rendering layer where the duplicate is actually visible.

Fixes openclaw#96967

* fix(tui): bound replay-final dedupe window in ChatLog

* fix(tui): bound replay-final dedupe marker with TTL expiration

* fix(tui): satisfy no-promise-executor-return lint rule in chat-log test

* fix(tui): replace ChatLog text heuristic with runId-based surrender tracking

Drop the lastHistoryAssistant text-matching dedupe in ChatLog
(posed P1 false-suppression risk). Instead, track active runIds
surrendered to loadHistory() on sessions.changed 'new' — late
events for surrendered runs are suppressed by exact runId match.

Add ChatLog.hasStreamingRun() so the event handler can detect
when loadHistory restored a surrendered run as in-flight streaming.

Retain chatFinalizedRuns / chatFinalizedRunsWithDisplay from the
original fix for post-reload delta/error dedup of finalized runs.

Incorporates the precise surrender-tracking approach from closed
PRs openclaw#96979/openclaw#96986 while keeping the scoped ChatLog API minimal.

* fix(tui): render displayable late final for surrendered non-streaming run

When a sessions.changed 'new' surrenders an in-flight run and
loadHistory does not restore it as streaming, a late displayable
final must still be rendered — otherwise the user never sees the
assistant reply. Only suppress non-displayable finals (empty
message, no errorMessage).

Add focused test for non-displayable final suppression.

* test(tui): add coverage for empty in-flight restore + late displayable final

Covers the rank-up scenario from openclaw#96979: surrendered run restored
by loadHistory as empty streaming, late displayable final passes
through surrender check and renders to chatLog.

* fix(tui): suppress late finals for surrendered finalized runs to prevent history-replay duplicate

When sessions.changed 'new' fires, now surrender chatFinalizedRuns entries
too (as 'finalized'), not just sessionRuns ('in-flight'). This prevents
the finalized-before-reload history-replay duplicate: a late final for an
already-displayed run gets suppressed because the history-replay static
row covers the visible content.

In-flight surrendered runs still render displayable finals because the
user may not have seen the streaming text before the reload.

SurrenderedToHistoryRunIds is now Map<string,'in-flight'|'finalized'>
so the surrender check can branch on source. Move surrender check before
the finalizedRuns/chatFinalizedRuns guard so surrendered finalized runs
are routed correctly.

Add focused tests for history-replayed-visible (suppress) and
history-replay-missing (render) late finals.

* fix(tui): extend surrender coverage to sessions.changed reset reload path

Reset also calls clearTrackedRunState() + loadHistory() with the same
sessionKey, so late chat events for runs from the old session window
can race with the history replay the same way as 'new'. Surrender
sessionRuns and chatFinalizedRuns on both reasons (openclaw#96979 P1 rank-up).

* fix(tui): keep surrendered finalized marker after late delta to block later final

For finalized surrendered runs, a late delta must not clear the
surrender marker — otherwise a subsequent late final would slip
through and produce a duplicate. Only in-flight surrendered runs
clear the marker on delta (their final needs to render).

* fix(tui): gate session history reloads on persistence

Co-authored-by: xialonglee <[email protected]>

* docs(changelog): note TUI external-run dedupe

* test(tui): type history load harness results

* test(tui): defer history results without async mocks

* chore(changelog): defer TUI note to release

---------

Co-authored-by: Peter Steinberger <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. P2 Normal backlog priority with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: S status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant