Skip to content

fix(webchat): suppress NO_REPLY token in chat transcript rendering#32183

Merged
Takhoffman merged 4 commits intoopenclaw:mainfrom
ademczuk:fix/webchat-no-reply-leak-clean
Mar 2, 2026
Merged

fix(webchat): suppress NO_REPLY token in chat transcript rendering#32183
Takhoffman merged 4 commits intoopenclaw:mainfrom
ademczuk:fix/webchat-no-reply-leak-clean

Conversation

@ademczuk
Copy link
Contributor

@ademczuk ademczuk commented Mar 2, 2026

Summary

Fixes #32015 — the internal sentinel token NO_REPLY leaks into the Control UI (webchat) as a visible assistant message bubble (rendered as "NO" due to underscore truncation). This happens during silent housekeeping turns such as pre-compaction memory flush.

Root cause: sanitizeChatHistoryMessages() in the gateway chat.history endpoint sanitizes content blocks but never filters out assistant messages whose entire visible text is NO_REPLY. The live streaming path already suppresses these correctly via isSilentReplyText(), but the history/reload path was unguarded.

Two-layer fix:

  1. Gateway API boundary (src/gateway/server-methods/chat.ts):

    • Added extractAssistantTextForSilentCheck() helper that extracts visible text from assistant messages, with entry.text precedence over entry.content to avoid dropping messages that carry real text alongside a stale content: "NO_REPLY"
    • Modified sanitizeChatHistoryMessages() from .map() to for...of loop that filters out assistant messages matching isSilentReplyText()
  2. UI controller defense-in-depth (ui/src/ui/controllers/chat.ts):

    • Added isAssistantSilentReply() and isSilentReplyStream() client-side guards
    • Guarded all 5 message insertion points in handleChatEvent() and loadChatHistory() so NO_REPLY messages are never appended to the chat message list

Also fixes pre-existing CI blockers

  • src/discord/monitor/agent-components.ts: normalizeDiscordAllowList returns {allowAll, ids, names}, not an array — fixed [0] indexing to use ids.values().next().value (TS7053)
  • src/pairing/pairing-store.ts: added non-null assertions for stat after cache-miss guard where resolveAllowFromReadCacheOrMissing guarantees non-null (TS18047)

Related PRs

This is a clean, focused alternative to existing open PRs addressing the same issue. Those PRs bundle unrelated changes or only cover one layer:

This PR covers both gateway and UI layers with no unrelated changes.

Test plan

  • Gateway test: chat.history hides assistant NO_REPLY-only entries — verifies 5-message mix (user, assistant NO_REPLY dropped, assistant real kept, assistant with text="real" + content="NO_REPLY" kept, user NO_REPLY kept)
  • UI tests: 5 new tests covering NO_REPLY filtering across final payloads, stream text, abort events, and user message preservation
  • All existing gateway chat and UI chat controller tests pass unchanged
  • tsc --noEmit and tsc -p tsconfig.plugin-sdk.dts.json — zero TS errors
  • Manual: open Control UI, trigger pre-compaction memory flush, verify no "NO" bubble appears

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle bot added app: web-ui App: web-ui gateway Gateway runtime size: M labels Mar 2, 2026
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57b8170048

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR adds two-layer suppression of the NO_REPLY sentinel token in the webchat transcript: a gateway-side filter in sanitizeChatHistoryMessages() and client-side defense-in-depth guards in handleChatEvent() and loadChatHistory(). The gateway layer is well-implemented and thoroughly tested. However, the UI-side isAssistantSilentReply() has a field-precedence mismatch: it uses extractText()extractRawText(), which checks content before text, while the gateway's extractAssistantTextForSilentCheck() was explicitly written to give text precedence. As a result, messages with { role: "assistant", text: "real reply", content: "NO_REPLY" } that the gateway intentionally preserves will still be silently dropped by the client-side filter on every history load.

  • Logic bug: isAssistantSilentReply() in ui/src/ui/controllers/chat.ts uses extractText() which prioritizes content over text, contradicting the gateway's text-first precedence for the mixed-field edge case the PR explicitly protects.
  • Missing test coverage: No UI test exercises loadChatHistory's new filter, and the mixed text/content: "NO_REPLY" edge case verified in the gateway test has no counterpart on the UI side.

Confidence Score: 3/5

  • Safe to merge for the majority of cases, but contains a field-precedence bug in the UI-side filter that will silently drop valid messages in the mixed text/content edge case.
  • The gateway fix is correct and well-tested. The UI-side isAssistantSilentReply function produces incorrect results for messages with both a real text field and a stale content: "NO_REPLY" — it drops them where it should not. This is a specific edge case but one the PR explicitly calls out as important to preserve, and it is undetected by the new tests since loadChatHistory filtering has no test coverage.
  • ui/src/ui/controllers/chat.tsisAssistantSilentReply has a content-over-text precedence order that contradicts the gateway layer and the PR's stated intent for mixed-field messages.

Last reviewed commit: 57b8170

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

@ademczuk ademczuk force-pushed the fix/webchat-no-reply-leak-clean branch from 57b8170 to 2521af4 Compare March 2, 2026 21:46
@openclaw-barnacle openclaw-barnacle bot added the channel: discord Channel integration: discord label Mar 2, 2026
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

return "final";

P2 Badge Return null after dropping foreign-run silent finals

When a final event from a different run is filtered out as silent, this branch returns "final" instead of null. ui/src/ui/app-gateway.ts treats any "final" state as terminal and calls handleTerminalChatEvent(), which resets tool-stream state and flushes queued chat work, so a background NO_REPLY completion can disturb an active foreground run. The silent foreign-run path should behave like other ignored foreign-run events and return null after dropping the message.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +234 to +236
if (text !== undefined && isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
changed = true;
continue;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Backfill history window after filtering silent entries

This drop condition runs after chat.history has already sliced to the most recent limit transcript rows, so filtered NO_REPLY housekeeping entries are removed without replacement. In sessions where recent rows are mostly silent tokens, users can receive far fewer messages (or an empty history) even though older visible messages exist just outside the slice. The history query should backfill older rows after filtering so the requested window still represents the latest visible conversation.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. This is a valid theoretical concern but out of scope for this bugfix. In practice, NO_REPLY entries are rare — they only appear during housekeeping turns like pre-compaction memory flush. The limit: 200 history window is large enough that losing a handful of silent entries will not meaningfully reduce visible message count. Backfilling after filtering would require a loop or over-fetch strategy that adds complexity disproportionate to the risk. Tracked as a potential enhancement.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cde7ebe9f2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +25 to +26
const text = extractText(message);
return typeof text === "string" && isSilentReplyStream(text);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve mixed-content assistant messages in silent filter

isAssistantSilentReply() falls back to extractText(message), and extractText only aggregates text blocks while ignoring non-text blocks. That means an assistant message like content: [{type:"text",text:"NO_REPLY"}, {type:"image", ...}] is misclassified as a silent token and dropped by both loadChatHistory() and handleChatEvent(), which hides user-visible image/tool output. This is a regression introduced by the new client-side filter, especially because the gateway history path now keeps these mixed-content assistant entries.

Useful? React with 👍 / 👎.

ademczuk and others added 4 commits March 2, 2026 16:37
…iring-store

- agent-components.ts: normalizeDiscordAllowList returns {allowAll, ids, names},
  not an array — use ids.values().next().value instead of [0] indexing
- pairing-store.ts: add non-null assertions for stat after cache-miss guard
  (resolveAllowFromReadCacheOrMissing returns early when stat is null)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Filter assistant NO_REPLY-only entries from chat.history responses at
the gateway API boundary and add client-side defense-in-depth guards in
the UI chat controller so internal silent tokens never render as visible
chat bubbles.

Two-layer fix:
1. Gateway: extractAssistantTextForSilentCheck + isSilentReplyText
   filter in sanitizeChatHistoryMessages (entry.text takes precedence
   over entry.content to avoid dropping messages with real text)
2. UI: isAssistantSilentReply + isSilentReplyStream guards on all 5
   message insertion points in handleChatEvent and loadChatHistory

Fixes openclaw#32015

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Takhoffman
Copy link
Contributor

PR #32183 - fix(webchat): suppress NO_REPLY token in chat transcript rendering (#32183)

Merged and verified.

  • Merge commit: 0743463
  • Verified commands:
    • pnpm install --frozen-lockfile
    • pnpm build
    • pnpm check
    • pnpm test:macmini
  • Follow-up integration work included UI delta/history NO_REPLY suppression coverage, gateway mixed-content NO_REPLY history boundary coverage, and changelog contributor credit for overlapping NO_REPLY family PRs.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

return "final";

P2 Badge Keep cross-run silent finals from signaling completion

When a final event arrives for a different runId and its assistant payload is filtered as silent (NO_REPLY), this branch still returns "final" instead of null. In ui/src/ui/app-gateway.ts, any "final" state triggers resetToolStream(...) and queue flushing, so a background/sibling silent run can clear tool-stream UI for the active run even though no message was appended. This regression is introduced by the new silent-filter condition and should follow the same return null behavior as other cross-run finals that don't need terminal side effects.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

dawi369 pushed a commit to dawi369/davis that referenced this pull request Mar 3, 2026
…penclaw#32183)

* fix(types): resolve pre-existing TS errors in agent-components and pairing-store

- agent-components.ts: normalizeDiscordAllowList returns {allowAll, ids, names},
  not an array — use ids.values().next().value instead of [0] indexing
- pairing-store.ts: add non-null assertions for stat after cache-miss guard
  (resolveAllowFromReadCacheOrMissing returns early when stat is null)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(webchat): suppress NO_REPLY token in chat transcript rendering

Filter assistant NO_REPLY-only entries from chat.history responses at
the gateway API boundary and add client-side defense-in-depth guards in
the UI chat controller so internal silent tokens never render as visible
chat bubbles.

Two-layer fix:
1. Gateway: extractAssistantTextForSilentCheck + isSilentReplyText
   filter in sanitizeChatHistoryMessages (entry.text takes precedence
   over entry.content to avoid dropping messages with real text)
2. UI: isAssistantSilentReply + isSilentReplyStream guards on all 5
   message insertion points in handleChatEvent and loadChatHistory

Fixes openclaw#32015

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(webchat): align isAssistantSilentReply text/content precedence with gateway

* webchat: tighten NO_REPLY transcript and delta filtering

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
OWALabuy pushed a commit to kcinzgg/openclaw that referenced this pull request Mar 4, 2026
…penclaw#32183)

* fix(types): resolve pre-existing TS errors in agent-components and pairing-store

- agent-components.ts: normalizeDiscordAllowList returns {allowAll, ids, names},
  not an array — use ids.values().next().value instead of [0] indexing
- pairing-store.ts: add non-null assertions for stat after cache-miss guard
  (resolveAllowFromReadCacheOrMissing returns early when stat is null)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(webchat): suppress NO_REPLY token in chat transcript rendering

Filter assistant NO_REPLY-only entries from chat.history responses at
the gateway API boundary and add client-side defense-in-depth guards in
the UI chat controller so internal silent tokens never render as visible
chat bubbles.

Two-layer fix:
1. Gateway: extractAssistantTextForSilentCheck + isSilentReplyText
   filter in sanitizeChatHistoryMessages (entry.text takes precedence
   over entry.content to avoid dropping messages with real text)
2. UI: isAssistantSilentReply + isSilentReplyStream guards on all 5
   message insertion points in handleChatEvent and loadChatHistory

Fixes openclaw#32015

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(webchat): align isAssistantSilentReply text/content precedence with gateway

* webchat: tighten NO_REPLY transcript and delta filtering

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
@ademczuk ademczuk deleted the fix/webchat-no-reply-leak-clean branch March 6, 2026 07:46
zooqueen pushed a commit to hanzoai/bot that referenced this pull request Mar 6, 2026
…penclaw#32183)

* fix(types): resolve pre-existing TS errors in agent-components and pairing-store

- agent-components.ts: normalizeDiscordAllowList returns {allowAll, ids, names},
  not an array — use ids.values().next().value instead of [0] indexing
- pairing-store.ts: add non-null assertions for stat after cache-miss guard
  (resolveAllowFromReadCacheOrMissing returns early when stat is null)


* fix(webchat): suppress NO_REPLY token in chat transcript rendering

Filter assistant NO_REPLY-only entries from chat.history responses at
the gateway API boundary and add client-side defense-in-depth guards in
the UI chat controller so internal silent tokens never render as visible
chat bubbles.

Two-layer fix:
1. Gateway: extractAssistantTextForSilentCheck + isSilentReplyText
   filter in sanitizeChatHistoryMessages (entry.text takes precedence
   over entry.content to avoid dropping messages with real text)
2. UI: isAssistantSilentReply + isSilentReplyStream guards on all 5
   message insertion points in handleChatEvent and loadChatHistory

Fixes openclaw#32015


* fix(webchat): align isAssistantSilentReply text/content precedence with gateway

* webchat: tighten NO_REPLY transcript and delta filtering

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui channel: discord Channel integration: discord gateway Gateway runtime size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Webchat renders NO_REPLY as visible “NO” (silent token leak)

2 participants