Skip to content

fix(tui): avoid duplicate assistant reply after history replay#96986

Closed
snowzlmbot wants to merge 1 commit into
openclaw:mainfrom
snowzlmbot:fix/96967-tui-session-change-dedupe
Closed

fix(tui): avoid duplicate assistant reply after history replay#96986
snowzlmbot wants to merge 1 commit into
openclaw:mainfrom
snowzlmbot:fix/96967-tui-session-change-dedupe

Conversation

@snowzlmbot

@snowzlmbot snowzlmbot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Closes #96967

Summary

  • Deduplicates the narrow TUI ordering where an assistant message replayed from history is immediately followed by the matching live final event for the same visible text.
  • Keeps the existing sessions.changed new/reset reload behavior intact so external session refreshes still rebuild the current session.
  • Adds focused ChatLog unit coverage for the replay/live duplicate case and the adjacent cases that must still render duplicate text.

What Problem This Solves

Fixes an issue where users viewing the TUI could see the same assistant reply twice when a Telegram-originated turn caused history replay and the live final message to meet in the current chat log.

Root Cause

History replay renders assistant messages without a run id, while live final events render with a run id. The existing run-id dedupe cannot relate those two paths, so a replayed assistant row and its matching live final can be appended as separate visible rows when the events arrive adjacent to each other.

Why This Change Was Made

The fix is scoped to ChatLog: it records only the most recent no-run-id assistant row from history replay and suppresses a live final with a run id only when it immediately follows that replayed row, has identical text, and arrives within a short TTL. The marker is cleared when other content is appended, the log is cleared, or the tracked component is pruned, so intentional repeated messages remain visible.

This avoids a broader event-handler change and preserves the sessions.changed reload path needed for external session new/reset updates.

User Impact

TUI users should no longer see a duplicated assistant reply for the reported replay/live-final interleaving. Legitimate repeated assistant output remains visible when separated by user content or when both messages are live finals.

Validation / Pre-checks

  • git diff --check — passed as a local pre-check.
  • git diff --cached --check — passed before committing.
  • Targeted Vitest command requested for this PR was not run locally because dependencies are not installed in this checkout (node_modules is absent), and no dependency install was performed for the local pre-check.
  • GitHub Actions validation is expected to run on this PR after creation.

Evidence

Focused tests added in src/tui/components/chat-log.test.ts cover:

  • suppressing an adjacent no-run-id history replay followed by a matching live final with a run id;
  • allowing the same text when a user row separates replay and live final;
  • allowing two same-text live finals when neither came from history replay.

Evidence source map

  • Issue report: [Bug]: TUI displays assistant reply twice when triggered via Telegram channel #96967 describes duplicate TUI assistant output after a Telegram-triggered turn.
  • History replay source: src/tui/tui-session-actions.ts rebuilds assistant history via chatLog.finalizeAssistant(text) without a run id.
  • Live final source: src/tui/tui-event-handlers.ts final chat events render via chatLog.finalizeAssistant(finalText, evt.runId).
  • Fix surface: src/tui/components/chat-log.ts now dedupes only the adjacent history-replay/live-final pair.
  • Regression tests: src/tui/components/chat-log.test.ts covers the suppression and allowed-duplicate boundaries.

Risk / Rollback / Docs

  • Risk: low-to-medium. The change affects assistant row append behavior, but only for identical adjacent replay/live final text within the short TTL.
  • Rollback: revert this commit to restore the previous append behavior.
  • Docs: no user-facing docs changed; this is a TUI rendering fix.

Follow-up proof added after ClawSweeper review

Terminal behavior proof: sessions.changed replay + live final leaves one visible assistant row

A local, dependency-free Node 24 harness was run against this branch at 6acf71618376655a80a6f2f1104a256cd3e14c20. The harness loads the PR's src/tui/components/chat-log.ts source into a temporary module with minimal rendering stubs only, then exercises the same TUI boundary ordering described in the issue:

  1. history replay path (tui-session-actions.ts) calls chatLog.finalizeAssistant(text) with no run id;
  2. live final path (tui-event-handlers.ts) calls chatLog.finalizeAssistant(text, runId) with a run id;
  3. visible rendered assistant rows are counted after both events.

Output:

source=src/tui/components/chat-log.ts sha=6acf71618376655a80a6f2f1104a256cd3e14c20
scenario=sessions.changed history replay (no run id) immediately followed by live final (run id)
visible assistant rows after history replay: 1
visible assistant rows after live final: 1
chatLog.children after replay+live final: 1
render="redacted assistant final from replay/live ordering"
control rows when user separates replay/live: 2
control rows for two live finals: 2
PASS exactly one visible assistant row for replay+live final, while intentional duplicates remain visible

This avoids external messaging and does not mutate any active user/Gateway/Telegram/TUI configuration. A true native Telegram/TUI visual capture would require maintainer-controlled Mantis/Telegram desktop infrastructure; the useful maintainer trigger remains:

@openclaw-mantis visual task: verify openclaw-tui shows exactly one assistant reply after a Telegram-originated message triggers sessions.changed history replay.

CI / check evidence

Local targeted test attempt

$ node scripts/run-vitest.mjs src/tui/components/chat-log.test.ts src/tui/tui-event-handlers.test.ts src/tui/tui-session-actions.test.ts
Error: [vitest] node_modules is missing; Vitest cannot be resolved.
Install dependencies before running scripts/run-vitest.mjs:
  pnpm install --frozen-lockfile
...
code: 'OPENCLAW_MISSING_VITEST'

No dependency install was performed.

OpenGrep changed paths diagnosis

The failed OpenGrep PR-diff check did not reach repository scanning. It failed while installing OpenGrep, before the changed-path scan step:

OPENGREP_VERSION: v1.22.0
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
Warning: cosign is required for --verify-signatures but is not installed. Skipping signature validation.
Error: Failed to fetch available versions from GitHub.
Process completed with exit code 1.
No files were found with the provided path: .opengrep-out/precise.sarif. No artifacts will be uploaded.

That appears to be an OpenGrep install/network workflow failure, not an actionable finding in this PR's two changed TUI files.

@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.

Keep open: this is a plausible small TUI ChatLog fix, but the contributor proof is still a dependency-free ChatLog harness rather than a real TUI/Gateway/Telegram run, and sibling PRs still target the same open bug.

Root-cause cluster
Relationship: fixed_by_candidate
Canonical: #96967
Summary: This PR is a candidate fix for the open TUI duplicate-assistant issue; sibling PRs target the same issue but no canonical fix has merged.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Canonical path: Close this PR as superseded by #93562.

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

Review details

Best possible solution:

Close this PR as superseded by #93562.

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

Yes at source level: current main has a no-run-id history replay path and a run-id live final path that can append identical assistant text. I did not establish a live Telegram/TUI reproduction in this read-only review.

Is this the best way to solve the issue?

Unclear: ChatLog is a plausible narrow owner boundary for visible row dedupe, but sibling PRs propose alternatives and no maintainer has selected the canonical patch yet.

Security review:

Security review cleared: The diff only changes in-process TUI ChatLog state and tests, with no dependency, workflow, secret, package, or code-execution surface changes.

AGENTS.md: found and applied where relevant.

What I checked:

  • linked superseding PR: fix(tui): refresh after external session reset #93562 (fix(tui): refresh after external session reset) is merged at 2026-06-16T08:10:38Z.
  • cluster evidence: the durable review links that PR in the work cluster or recommended risk path.
  • no human follow-up: live comments and timeline hydrated by apply contain no non-automation activity after the ClawSweeper review.

Likely related people:

  • vincentkoc: Authored and merged the active-session sessions.changed refresh path that this duplicate-message report follows up on. (role: recent TUI session-change contributor and merger; confidence: high; commits: 9b49387ad8c2; files: src/tui/tui-event-handlers.ts, src/tui/tui.ts, src/tui/gateway-chat.ts)
  • SebTardif: Authored the merged displayable-final history reload guard, the closest adjacent fix for destructive TUI history reload races. (role: adjacent final/history reload fix author; confidence: medium; commits: 9a1b95c1e63a; files: src/tui/tui-event-handlers.ts, src/tui/tui-event-handlers.test.ts)
  • RomneyDa: Recent merged work stabilized optimistic user messages across history reloads and runId reassignment near this ChatLog/session reconciliation behavior. (role: recent TUI history/reload contributor; confidence: medium; commits: ac9a219692aa; files: src/tui/tui-event-handlers.ts, src/tui/tui-session-actions.ts, src/tui/components/chat-log.ts)
  • lisitan: Earlier merged ChatLog work added run-id duplicate assistant message suppression in the same component this PR extends. (role: adjacent ChatLog dedupe contributor; confidence: medium; commits: f3c00fce1578; files: src/tui/components/chat-log.ts, src/tui/components/chat-log.test.ts)

Codex review notes: model internal, reasoning high; reviewed against 1cd6f81a46ae.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. 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. labels Jun 26, 2026
@snowzlmbot

Copy link
Copy Markdown
Contributor Author

Updated the PR body and branch after review: history replay now uses an explicit ChatLog API, and the regression coverage exercises the loadHistory + live final event-handler path with a real ChatLog.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 26, 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.

@snowzlmbot snowzlmbot closed this Jun 26, 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.
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
…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 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. P2 Normal backlog priority with limited blast radius. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. 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.

[Bug]: TUI displays assistant reply twice when triggered via Telegram channel

1 participant