Skip to content

fix(ui): hide async exec system events and heartbeat acks from chat transcript#69366

Closed
srinivaspavan9 wants to merge 1 commit into
openclaw:mainfrom
srinivaspavan9:fix/async-message-leak
Closed

fix(ui): hide async exec system events and heartbeat acks from chat transcript#69366
srinivaspavan9 wants to merge 1 commit into
openclaw:mainfrom
srinivaspavan9:fix/async-message-leak

Conversation

@srinivaspavan9

@srinivaspavan9 srinivaspavan9 commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: When an async exec command completes, the Control UI rendered two internal runtime messages as visible chat bubbles: (1) a fake "You" bubble containing System (untrusted): [...] Exec completed... and (2) a pure HEARTBEAT_OK assistant ack bubble.
  • Why it matters: Users see confusing noise in their chat transcript — messages that look like they sent something they didn't, and cryptic ack tokens from the agent.
  • What changed: Added detection for exec-event injection messages in heartbeat-filter.ts, created a browser-safe heartbeat-client.ts reimplementation for the UI bundle (avoids pulling in Node.js built-ins via src/utils.ts), extended shouldHideHistoryMessage in chat.ts to hide both message types, and added HEARTBEAT_OK stripping to the renderer in grouped-render.ts.
  • What did NOT change: Heartbeat runner logic, exec runtime, delivery routing, session storage — this is display-layer only.

Change Type

  • Bug fix

Scope

  • UI / DX

Linked Issue/PR

Root Cause

  • Root cause: The heartbeat runner injects exec completion events as user-role messages directly into the session (prefixed System (untrusted): [...] Exec completed...). The Control UI had no filter for these, so they rendered as if the user had sent them. Similarly, pure HEARTBEAT_OK assistant acks had no stripping logic, so they appeared as assistant bubbles.
  • Missing detection / guardrail: shouldHideHistoryMessage only filtered isAssistantSilentReply and synthetic transcript repair tool results — no awareness of heartbeat-injected user messages or HEARTBEAT_OK acks.
  • Contributing context: The System (untrusted): prefix is unique to heartbeat-injected messages (inbound sanitization rewrites it for real user input), so it is a safe and reliable signal to filter on.

Regression Test Plan

  • Coverage level: [x] Unit test
  • Target test: src/auto-reply/heartbeat-filter.test.ts
  • Scenario: isExecEventInjectionMessage correctly matches all three exec event variants (completed, failed, finished), both string and array content, and does not match regular user messages or heartbeat prompts.
  • Why this is the smallest reliable guardrail: directly tests the detection predicate at the source — the same function gating the hide decision in the UI.

User-visible / Behavior Changes

  • System (untrusted): [...] Exec completed/failed/finished ... messages no longer appear as user chat bubbles in the Control UI.
  • Pure HEARTBEAT_OK assistant acks no longer appear as assistant chat bubbles.
  • Relay messages that happen to append HEARTBEAT_OK (e.g. "Command finished... HEARTBEAT_OK") have the token stripped and show only the human-readable content.

Diagram

Before:
async exec completes → heartbeat injects user message → Control UI renders it as "You: System (untrusted): Exec completed..."
heartbeat agent acks  → Control UI renders it as assistant bubble: "HEARTBEAT_OK"

After:
async exec completes → heartbeat injects user message → hidden by shouldHideHistoryMessage
heartbeat agent acks  → hidden by shouldHideHistoryMessage / token stripped by renderer

Security Impact

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: Arch Linux
  • Model: gpt-5.4
  • Integration/channel: Control UI (webchat)
  • OpenClaw version: 2026.4.15 (041266a), Linux arm64

Steps

  1. Start a session in the Control UI
  2. Send: run this shell command asynchronously: sleep 3 && echo hello
  3. Wait for the exec to complete

Expected

No System (untrusted): user bubble. No HEARTBEAT_OK assistant bubble.

Actual (before fix)

A fake "You" bubble appears: System (untrusted): [timestamp] Exec completed (session, code 0) :: hello
A HEARTBEAT_OK assistant bubble may also appear.

Evidence

  • Failing test/log before + passing after — 3 unit tests added to heartbeat-filter.test.ts
  • Screenshot/recording — see below
  • Before the Fix you can see the internal message being leaked into the user message bubble
beforeFix
  • After the fix you dont see that happening
afterFix

Human Verification

  • Verified: multiple async exec commands (instant, delayed, concurrent, failing) — no System (untrusted) user bubbles, no HEARTBEAT_OK assistant bubbles in any scenario
  • Verified: relay messages with appended HEARTBEAT_OK show only the human-readable content
  • Verified: pnpm build && pnpm check && pnpm test all green (798 tests, 68 test files)
  • Did NOT verify: non-webchat channels (fix is UI display-layer only, does not affect channel delivery)

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: EXEC_INJECTION_PREFIX_RE pattern too broad — could accidentally hide legitimate user messages.
    • Mitigation: The System (untrusted): prefix is actively rewritten by inbound sanitization for real user input, so it cannot appear in genuine messages. Pattern is tightly anchored to that prefix plus a timestamp.
  • Risk: Browser-safe heartbeat-client.ts drifts from heartbeat-filter.ts / heartbeat.ts source of truth.
    • Mitigation: The file has a prominent comment explaining why it exists and what it mirrors. The divergence is intentional and bounded — only the UI-needed subset is reimplemented.

@greptile-apps

greptile-apps Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR hides async exec system events and HEARTBEAT_OK acks from the chat transcript by adding isExecEventInjectionMessage to heartbeat-filter.ts, extending shouldHideHistoryMessage in chat.ts, and stripping the token in grouped-render.ts. The approach is well-structured and the detection predicate is correctly anchored to the unforgeable System (untrusted): prefix.

  • The renderer in grouped-render.ts calls stripHeartbeatToken(markdownBase, { mode: \"heartbeat\" }) without maxAckChars, defaulting to 300. This means relay messages whose remaining text after token stripping is ≤ 300 chars (e.g. \"You have 3 unread urgent emails. HEARTBEAT_OK\") pass shouldHideHistoryMessage (which uses ackMaxChars: 0) but are then silently dropped by the renderer — suppressing real content the user should see.

Confidence Score: 4/5

PR is close to correct but has one P1 inconsistency in the renderer that can silently drop real relay content.

The detection and hide logic in chat.ts is correct. The grouped-render.ts strip call uses the wrong default maxAckChars (300) compared to the 0 used everywhere else, causing relay messages under 300 chars to be invisibly suppressed despite passing the history filter.

ui/src/ui/chat/grouped-render.ts — the stripHeartbeatToken call at line 1214 needs maxAckChars: 0

Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/grouped-render.ts
Line: 1214-1215

Comment:
**Short relay messages silently suppressed by 300-char default**

`mode: "heartbeat"` with no `maxAckChars` defaults to `DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300`, meaning any assistant message whose remaining text after stripping `HEARTBEAT_OK` is ≤ 300 chars will yield `shouldSkip: true` here. `shouldHideHistoryMessage` uses `ackMaxChars: 0` (only suppresses truly empty-after-strip messages), so a relay message like `"You have 3 unread urgent emails. HEARTBEAT_OK"` (32 chars post-strip) passes the history filter but is then silently dropped in the renderer — contradicting the stated PR goal of showing relay messages' human-readable content.

```suggestion
          const s = stripHeartbeatToken(markdownBase, { mode: "heartbeat", maxAckChars: 0 });
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(ui): hide async exec system events a..." | Re-trigger Greptile

Comment thread ui/src/ui/chat/grouped-render.ts Outdated

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

Copy link
Copy Markdown

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: 7c8cb2c71a

ℹ️ 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 thread ui/src/ui/chat/grouped-render.ts Outdated
Comment thread ui/src/ui/chat/heartbeat-client.ts
@srinivaspavan9 srinivaspavan9 force-pushed the fix/async-message-leak branch from 7c8cb2c to 0d89817 Compare April 22, 2026 01:14

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

Copy link
Copy Markdown

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: 0d898177df

ℹ️ 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 +16 to +17
const EXEC_INJECTION_PREFIX_RE =
/^System \(untrusted\): \[.+?\] Exec (completed|failed|finished)\b/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not hide user messages by exec-event text pattern alone

This regex is treated as a definitive signal for internal exec injections, but shouldHideHistoryMessage applies it to all user history entries and inbound sanitization already rewrites line-leading System: to System (untrusted): (src/auto-reply/reply/inbound-text.ts), so a legitimate user message that starts with System: [..] Exec completed/failed/finished ... will be transformed to this shape and then silently removed from the UI transcript on history load. That is user-visible data loss and needs an additional non-user-controllable discriminator before filtering.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a relly good comment, the current fix only acts like a mere bandaid. I have a new commit which fixes the main root cause

@prtags

prtags Bot commented Apr 23, 2026

Copy link
Copy Markdown

Related work from PRtags group shining-insect-tzpr

Title: Open PR duplicate: Control UI async exec/system transcript leak

Number Title
#67036 fix(ui): filter leaked control ui transcript rows
#68518 fix(ui): filter system event messages from chat transcript (#68508)
#69366* fix(ui): hide async exec system events and heartbeat acks from chat transcript

* This PR

@steipete

Copy link
Copy Markdown
Contributor

Closing this as implemented after Codex review.

Current main already covers the requested behavior through a better root-cause fix plus visible-history filtering: 8cc38c1b86f41803447f4c7d962aa9465d5c8ae8 keeps exec-event System: lines out of transcript writes, and 3f63ba8fd808f46566aabbca194fa54c2d6b4871 suppresses heartbeat/history artifacts in the Gateway and Control UI. This PR is redundant.

What I checked:

  • System events no longer enter transcript writes: buildReplyPromptBodies prepends systemEventBlocks only to the live prompt bodies, but builds transcriptCommandBody from transcriptBody without those blocks. That removes the fake user-bubble root cause instead of hiding it later in the UI. (src/auto-reply/reply/prompt-prelude.ts:23, 8cc38c1b86f4)
  • Regression test proves transcript exclusion: The current test asserts commandBody and followupRun.prompt include System: [t] Initial event., while transcriptCommandBody and followupRun.transcriptPrompt do not. That directly covers the leaked async-exec system event discussed in this PR. (src/auto-reply/reply/get-reply-run.media-only.test.ts:834, 8cc38c1b86f4)
  • Heartbeat acks are normalized for chat output: normalizeHeartbeatChatFinalText strips HEARTBEAT_OK from heartbeat chat output and suppresses pure acks. The paired test shows pure ack-like output is dropped while real alert text after HEARTBEAT_OK is preserved. (src/gateway/server-chat.ts:68, 8cc38c1b86f4)
  • Visible history is filtered before pagination: buildSessionHistorySnapshot now runs stripEnvelopeFromMessages(...) and filterVisibleSessionHistoryMessages(...) before history pagination. The current test leaves only the real assistant message after heartbeat prompt/ack rows are removed from visible history. (src/gateway/session-history-state.ts:201, 3f63ba8fd808)
  • Control UI history loader matches the new filtering: shouldHideHistoryMessage now filters assistant heartbeat acks and empty internal-only user messages, and the current Control UI test expects only visible answer to remain after history load. (ui/src/ui/controllers/chat.ts:129, 3f63ba8fd808)
  • Main changelog already records the user-facing fix: The unreleased changelog entry on main says Control UI/WebChat now hides heartbeat prompts, HEARTBEAT_OK acknowledgments, and internal-only runtime-context turns from visible chat history. (CHANGELOG.md:77, 3f63ba8fd808)

So I’m closing this as already implemented rather than keeping a duplicate issue open.

Codex Review notes: reviewed against 80b6da72f5f0; fix evidence: commit 3f63ba8fd808.

@steipete steipete closed this Apr 25, 2026
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 6, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 9, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 11, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 16, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 20, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 26, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to
`SystemEvent`, defaulting to `'user-facing'` (full backward-compat for
every existing caller). Internal-audience events are wrapped at the
consumer integration point (`drainFormattedSystemEvents` in
session-system-events.ts) in the existing
`INTERNAL_RUNTIME_CONTEXT_BEGIN/END` delimiters with the canonical
header lines from `formatAgentInternalEventsForPrompt`. The agent
runtime sees the content as runtime context; user-facing surfaces
strip it via the already-installed `stripInternalRuntimeContext`
consumers (`sanitize-user-facing-text.ts`,
`memory-host-sdk/host/session-files.ts`, `agents/internal-events.ts`,
history readers, Control UI extract).

Mirrors the producer-side runtime-context delimiter pattern landed in
`e918e5f75c` (openclaw#71761) and `6e985a421d` (webchat runtime context). No
new persistence shape, no consumer-side filter (avoids the
audit-bypass concern flagged on openclaw#69217), no display-layer per-surface
patch (avoids the rejection pattern from openclaw#69366).

Heartbeat-path additions: `selectGenericSystemEvents` keeps
audience: "internal" events queued when the drain is invoked from a
heartbeat reply (whose prompt envelope discards
`systemEventBlocks`), so the wrapped runtime-context block is not
silently consumed before the next regular reply turn that actually
carries it through to the model. The heartbeat exec/cron-prompt
selectors also skip internal events so they don't get classified as
user-facing relay payloads.

`compactSystemEvent` bypasses heartbeat-noise filters
("reason periodic", "Read HEARTBEAT.md", "heartbeat poll/wake") for
audience: "internal" events — those go through the wrap-on-drain path
and never reach a user-facing surface, so filtering them after
consumption would silently drop the event (same no-consumer hole
class as the exec-shape filter).

Migrated one producer (the canonical justification for the lane):
- `queueCronAwarenessSystemEvent` in
  `src/cron/isolated-agent/delivery-dispatch.ts`. This event is
  queued onto the main session only after the isolated agent run has
  already delivered to the user via its own channel (gated on
  `delivered === true` and `shouldQueueCronAwareness`). The main
  session needs hidden runtime context so the next turn knows the
  cron landed, not a user-visible `System: ...` bubble.

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive.
Events with a positive user delivery contract (exec completion via
`notifyOnExit`, cron payloads, heartbeat acks) are not migrated —
those have heartbeat-driven explicit-relay paths
(`buildExecEventPrompt` / `buildCronEventPrompt`) plus tactical
producer-side skips like `bd60df3e53`. Marking them internal would
suppress delivery on regular reply turns where the model is
instructed to keep wrapped content private.

Tests cover: default audience, round-trip preservation, audience
equality (consumeSystemEventEntries prefix-match respects audience),
wrap-on-drain shape with canonical header, mixed
user-facing+internal ordering, the user-facing-only and
internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, adversarial delimiter-token escape,
heartbeat-path audience: "internal" queueing semantics, and
exec-shaped internal events bypassing the text-shape filter.

Addresses part of openclaw#69492 (system-event-shape
consumer leakage; umbrella openclaw#69208 Track B). Leaves the
user-deliverable noise question (exec completion, cron payloads,
heartbeat acks) open for case-by-case follow-ups matching the
maintainer's revealed pattern (`bd60df3e53`, `3f63ba8fd808`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Control UI renders async exec system events in the visible chat transcript

2 participants