fix(tui): suppress duplicate assistant message after sessions.changed event#96979
fix(tui): suppress duplicate assistant message after sessions.changed event#96979chenyangjun-xy wants to merge 1 commit into
Conversation
|
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 detailsBest 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 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 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:
Likely related people:
Codex review notes: model internal, reasoning high; reviewed against 08d15ec32db3. |
d3381e9 to
ed14933
Compare
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
dd13dcb to
54d2644
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
ClawSweeper applied the proposed close for this PR.
|
…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.
…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.
…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).
…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.
…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.
…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).
…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.
…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.
…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).
…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]>
…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]>
…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]>
…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]>
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.