refactor(runtime): replace per-session event bus with transcript bus#693
Merged
steins-persona merged 11 commits intomainfrom May 4, 2026
Merged
refactor(runtime): replace per-session event bus with transcript bus#693steins-persona merged 11 commits intomainfrom
steins-persona merged 11 commits intomainfrom
Conversation
…684) Eliminates the double-delivery foot-gun by removing AgentRuntime's SessionEventBus + auto fan-out, and introducing a SessionStore-level transcript bus that emits per-message after persistence. Layering (mirrors openclaw): - runtime.run yields raw AgentEvent only — no more emitSessionEvent - Drivers (Discord, HTTP) get raw events via runAgent's new onEvent callback - Observers attach to SessionStore via attach(sessionId, listener) and receive completed messages, fed by a monkey-patch on SessionManager.appendMessage (covers all writes — user input from transports, assistant + tool messages from the SDK) TUI gains --session <key>: load history snapshot + subscribe to the new GET /api/sessions/:agent/:key/stream SSE endpoint, so a TUI viewer can follow a session driven from a different transport (e.g. Discord) in real time at message granularity. Single-attach: second viewer on the same sessionId gets HTTP 409. Compaction edge case (TUI snapshot drift after SDK compaction) is deferred and tracked in #691. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Trim WHAT-explaining and task-referencing comments per project guidelines. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Replace single-attach (with 409 on conflict) with multi-listener fan-out. The single-subscriber constraint had no structural reason — transcript bus emits already-persisted messages with deterministic ordering, so multi-listener carries no double-delivery risk — and it would block common observer setups (TUI + WebUI on the same session). Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Consolidate the three "open + installTranscriptEmitter" call sites into one private helper. Structurally guarantees a SessionManager from this store is patched exactly once — no risk of double-emit if a future refactor adds another manager-creation path. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
- HTTP /stream: writeHead first, send ': connected\\n\\n' to defeat proxy buffering on quiet sessions (was waiting up to 25s for first heartbeat). - TUI api.attachStream: split on /\\r?\\n/ per SSE spec; log JSON parse errors to stderr instead of swallowing. - TUI ChatScreen --session mode: skip local user echo and inline streaming preview. attachStream is the single source of truth, so rendering both paths produced duplicates of the user's own messages and the assistant response. - session-store: comment why PiMessage→AgentMessage cast is safe. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Aligns with conventional pub/sub vocabulary (RxJS, EventEmitter, addEventListener) and pairs cleanly with the unsubscribe function returned. 'attach' implied a 1:1 mounting relationship that no longer fits the multi-listener semantics. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Discord (and other transports going through buildSessionKey directly)
store session keys without an agentId prefix. The HTTP resolver was
unconditionally re-prefixing with agentId, so attaching to a Discord
session via /messages or /stream returned 404.
Try the url-supplied key as-is first, then fall back to the
HTTP-API-created form '${agentId}:${urlKey}'.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
/new deletes the current sessionKey and replaces with tui:main. In attach mode that destroys someone else's session (e.g. a Discord one) and silently breaks the attachStream subscription. Refuse the command with an explanatory system message instead. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
activeSessions Map only contained sessions registered via the HTTP create/resume path. Discord (and other transport-driven) sessions were never registered, so POST /message returned 404 for them. Look up via the store on miss and register on demand. /steer and /abort have the same shape of bug for transport sessions but require a different lifecycle integration; tracked separately. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
steins-persona
approved these changes
May 4, 2026
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AgentRuntime's per-sessionSessionEventBusand the auto fan-out insideruntime.run, eliminating the double-delivery foot-gun described in runtime.run double-delivery: subscribers + iterator both receive every event #684.SessionStore-level transcript bus (subscribe(sessionId, listener)) emitting per-message updates after persistence. Mirrors openclaw's monkey-patch onSessionManager.appendMessage: one hook covers all writes (transport user input + SDK assistant/tool messages). Multiple listeners per session are supported.--session <key>to TUI: loads history snapshot + subscribes to the newGET /api/sessions/:agent/:key/streamSSE endpoint. A TUI viewer can now follow a session driven from a different transport (e.g. Discord) at message granularity.Closes #684. Compaction edge case (TUI snapshot drift after SDK compaction) deferred and tracked in #691. In-TUI
/sessionsand/attachslash commands tracked in #698.Architecture
Layers are now clean and aligned with openclaw:
runAgentacceptsonEvent?: (e: AgentEvent) => voidfor raw token-level events. Drivers render their own UI off this callback.store.subscribe(sessionId, listener)receives completed messages from the transcript bus. Multiple subscribers per session — TUI + WebUI + admin panel can all follow the same session concurrently.No more "yield AND emit the same
AgentEvent" — driver events and observer events have different shapes, different timing, different consumers. Foot-gun gone structurally.Test plan
pnpm ci— lint + typecheck + 925 tests pass (+7 new transcript-bus tests covering subscribe/multi-subscribe/unsubscribe/listener isolation/error swallow/SDK-side write capture)isotopes tui --session <discord-session-key>, verify message + tool-call updates appear live/streamboth receive events--sessionenters the session and persists (Discord side won't render but next history scroll on Discord shows it)🤖 Generated with Claude Code