openclaw - 💡(How to fix) Fix gateway: add a suppressed marker to sessions.changed when a silent-reply broadcast is dropped

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When an agent emits the silent-reply token (SILENT_REPLY_TOKEN = "NO_REPLY", per plugin-sdk/src/auto-reply/tokens.d.ts), the gateway's broadcast pipeline drops the session.message event. Subscribed clients get no clear signal that the run completed with a suppressed reply — the agent appears unresponsive even though the run finished normally server-side.

In handleTranscriptUpdateBroadcast, projectChatDisplayMessage returns falsy when shouldDropAssistantHistoryMessage drops the assistant message, and the if (message) params.broadcastToConnIds("session.message", ...) guard then skips the message broadcast.

Note: the very next statement broadcasts sessions.changed with phase: "message" unconditionally to session-event subscribers — so a frame does still go out on a suppressed run. The problem is that frame is byte-for-byte indistinguishable from a sessions.changed for a real message. There is no marker telling a subscriber "the message for this update was suppressed."

This is correct design overall — the silent-reply token is how an agent opts out of a low-merit prompt. The gap is just the missing marker.

Error Message

Note: the suppression has an exception path — assistant messages that include any non-text content block (e.g. a thinking block) bypass the drop. So the same agent sometimes broadcasts NO_REPLY as visible text and sometimes goes fully silent, depending on whether the model emitted reasoning. That inconsistency makes a client-side heuristic unreliable.

Root Cause

A client can subscribe to the agent lifecycle stream, arm a short timer on lifecycle.end when no finalized assistant text has been seen for that run, then synthesize a placeholder "silent reply" marker. Real replies must cancel the timer and retract already-synthesized markers, because the gap between lifecycle.end and the final session.message can exceed the timer on slower model runs (observed up to ~5.5s).

Fix Action

Fix / Workaround

Current client-side workaround

The current client-side workaround is: subscribe to the agent lifecycle stream, arm a ~3.5s timer on lifecycle.end when no finalized assistant text was seen, synthesize a placeholder marker, then cancel the timer and retract an already-synthesized marker if a real reply lands late (the lifecycle.end → final session.message gap can exceed the timer on slower model runs — observed up to ~5.5s). That is a fragile race every client has to re-implement. A one-field addition to a broadcast that already fires removes all of it.

Code Example

params.broadcastToConnIds("sessions.changed", {
    sessionKey,
    phase: "message",
    ts: Date.now(),
+   ...(message ? {} : { suppressed: true, suppressedReason }),
    ...(typeof update.messageId === "string" ? { messageId: update.messageId } : {}),
    ...(typeof messageSeq === "number" ? { messageSeq } : {}),
    ...sessionSnapshot
  }, sessionEventConnIds, { dropIfSlow: true });
RAW_BUFFERClick to expand / collapse

Summary

When an agent emits the silent-reply token (SILENT_REPLY_TOKEN = "NO_REPLY", per plugin-sdk/src/auto-reply/tokens.d.ts), the gateway's broadcast pipeline drops the session.message event. Subscribed clients get no clear signal that the run completed with a suppressed reply — the agent appears unresponsive even though the run finished normally server-side.

In handleTranscriptUpdateBroadcast, projectChatDisplayMessage returns falsy when shouldDropAssistantHistoryMessage drops the assistant message, and the if (message) params.broadcastToConnIds("session.message", ...) guard then skips the message broadcast.

Note: the very next statement broadcasts sessions.changed with phase: "message" unconditionally to session-event subscribers — so a frame does still go out on a suppressed run. The problem is that frame is byte-for-byte indistinguishable from a sessions.changed for a real message. There is no marker telling a subscriber "the message for this update was suppressed."

This is correct design overall — the silent-reply token is how an agent opts out of a low-merit prompt. The gap is just the missing marker.

Repro

  1. Open a chat session with any agent on a fresh session.
  2. Send a prompt the agent's identity/instruction files judge as low-merit (e.g. "ping").
  3. The run trajectory shows: lifecycle.start → assistant NO_REPLYlifecycle.end. The final session.message for the assistant turn is dropped by the broadcast filter.
  4. A subscribed client sees lifecycle.end with no preceding finalized assistant message.

Note: the suppression has an exception path — assistant messages that include any non-text content block (e.g. a thinking block) bypass the drop. So the same agent sometimes broadcasts NO_REPLY as visible text and sometimes goes fully silent, depending on whether the model emitted reasoning. That inconsistency makes a client-side heuristic unreliable.

Current client-side workaround

A client can subscribe to the agent lifecycle stream, arm a short timer on lifecycle.end when no finalized assistant text has been seen for that run, then synthesize a placeholder "silent reply" marker. Real replies must cancel the timer and retract already-synthesized markers, because the gap between lifecycle.end and the final session.message can exceed the timer on slower model runs (observed up to ~5.5s).

This works but is a timing-sensitive race that every client has to re-implement.

Proposed change

Since sessions.changed with phase: "message" already fires unconditionally at that point, no new event type is needed — just add a marker when the message was dropped. In handleTranscriptUpdateBroadcast, when projectChatDisplayMessage returns falsy, include suppressed: true (and optionally suppressedReason: "silent_reply" | "heartbeat") on the existing sessions.changed payload:

  params.broadcastToConnIds("sessions.changed", {
    sessionKey,
    phase: "message",
    ts: Date.now(),
+   ...(message ? {} : { suppressed: true, suppressedReason }),
    ...(typeof update.messageId === "string" ? { messageId: update.messageId } : {}),
    ...(typeof messageSeq === "number" ? { messageSeq } : {}),
    ...sessionSnapshot
  }, sessionEventConnIds, { dropIfSlow: true });

Subscribers already listening to sessions.changed could then treat phase:"message" + suppressed:true as a first-class "run completed silently" signal — no new subscription, no new event type.

Why upstream rather than client-side

The current client-side workaround is: subscribe to the agent lifecycle stream, arm a ~3.5s timer on lifecycle.end when no finalized assistant text was seen, synthesize a placeholder marker, then cancel the timer and retract an already-synthesized marker if a real reply lands late (the lifecycle.end → final session.message gap can exceed the timer on slower model runs — observed up to ~5.5s). That is a fragile race every client has to re-implement. A one-field addition to a broadcast that already fires removes all of it.

Relevant source

  • dist/server-session-events-*.jshandleTranscriptUpdateBroadcast: the if (message) guard around the session.message broadcast, and the unconditional sessions.changed phase:"message" broadcast immediately after.
  • dist/chat-display-projection-*.jsshouldDropAssistantHistoryMessage (drops assistant messages whose text is a suppressed control reply with no non-text content).
  • dist/plugin-sdk/src/auto-reply/tokens.d.tsSILENT_REPLY_TOKEN, HEARTBEAT_TOKEN.

Environment: OpenClaw 2026.5.7, Windows, gateway in local mode.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING