Skip to content

refactor(runtime): replace per-session event bus with transcript bus#693

Merged
steins-persona merged 11 commits intomainfrom
worktree-issue-684-transcript-bus
May 4, 2026
Merged

refactor(runtime): replace per-session event bus with transcript bus#693
steins-persona merged 11 commits intomainfrom
worktree-issue-684-transcript-bus

Conversation

@steins-z
Copy link
Copy Markdown
Contributor

@steins-z steins-z commented May 3, 2026

Summary

  • Removes AgentRuntime's per-session SessionEventBus and the auto fan-out inside runtime.run, eliminating the double-delivery foot-gun described in runtime.run double-delivery: subscribers + iterator both receive every event #684.
  • Introduces a SessionStore-level transcript bus (subscribe(sessionId, listener)) emitting per-message updates after persistence. Mirrors openclaw's monkey-patch on SessionManager.appendMessage: one hook covers all writes (transport user input + SDK assistant/tool messages). Multiple listeners per session are supported.
  • Adds --session <key> to TUI: loads history snapshot + subscribes to the new GET /api/sessions/:agent/:key/stream SSE 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 /sessions and /attach slash commands tracked in #698.

Architecture

Layers are now clean and aligned with openclaw:

  • Driver path (Discord, HTTP send): runAgent accepts onEvent?: (e: AgentEvent) => void for raw token-level events. Drivers render their own UI off this callback.
  • Observer path (TUI subscribe, future WebUI): 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)
  • Manual: run daemon + Discord transport, send messages from Discord, attach TUI via isotopes tui --session <discord-session-key>, verify message + tool-call updates appear live
  • Manual: confirm two TUIs (or TUI + curl) on the same /stream both receive events
  • Manual: confirm TUI's own send through --session enters the session and persists (Discord side won't render but next history scroll on Discord shows it)

🤖 Generated with Claude Code

…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>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 3, 2026

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 61.04% 5349 / 8763
🔵 Statements 61.04% 5349 / 8763
🔵 Functions 84.73% 472 / 557
🔵 Branches 87.45% 1715 / 1961
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/test-helpers.ts 46% 100% 50% 46% 7-35
src/agent/runtime-adapter.ts 76.38% 60.86% 100% 76.38% 36-41, 60, 74-75, 93-98, 100-101
src/agent/runtime.ts 60.18% 87.95% 56.66% 60.18% 132-133, 142-143, 146-156, 173-174, 178-179, 183-188, 191-194, 197-198, 201-203, 214-226, 231-236, 239-248, 251-313, 376-377, 399-400, 410, 412-413, 422-423, 445-446, 449-450, 463
src/agent/runners/pi/session-store.ts 93.44% 91.11% 93.75% 93.44% 54, 123-124, 134-135, 149-151, 187-188, 300-301, 321-322, 330-331
src/legacy/cli.ts 0% 100% 100% 0% 5-743
src/legacy/plugins/discord/discord.ts 61.47% 70% 65.85% 61.47% 70-72, 80-82, 90-108, 115-128, 132-133, 240-241, 245, 252-267, 310-312, 340-342, 385-396, 409, 412-417, 471-473, 489-496, 503-505, 516, 518-530, 565, 582, 590-591, 598-600, 602-611, 637-643, 648-653, 667-668, 686-690, 737-738, 743-757, 769-778, 780-788, 799-800, 802-803, 805-809, 811-819, 826-829, 835-836, 843-849, 897, 917-919, 931-946, 953-991, 994-1026
src/legacy/plugins/http/sessions.ts 19.26% 69.56% 50% 19.26% 45-48, 51-58, 61-77, 89-112, 119-142, 154-174, 181-200, 207-248, 259-261, 265-267, 297-298, 312-428, 435-460, 467-479, 486-513
src/legacy/tui/ChatScreen.tsx 20.04% 85% 75% 20.04% 86-88, 101-465
src/legacy/tui/api.ts 50% 91.89% 88.23% 50% 7, 114-220
src/legacy/tui/index.tsx 0% 100% 100% 0% 2-21
src/legacy/tui/types.ts 100% 100% 100% 100%
src/sessions/types.ts 100% 100% 100% 100%
Generated in workflow #1087 for commit 230a4e2 by the Vitest Coverage Report Action

steins-z and others added 5 commits May 4, 2026 19:06
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>
steins-z and others added 2 commits May 4, 2026 21:22
/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 steins-persona merged commit 1be243b into main May 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime.run double-delivery: subscribers + iterator both receive every event

2 participants