fix: EventBus loopback elimination + per-agent resource read truncation - #405
Conversation
…ections (#391) * fix(opencode): eliminate EventBus loopback causing duplicate SSE projections The OpenCodeEventBridge republished protocol projections (MessageUpdatedEvent, PartUpdatedEvent, etc.) back into the same EventBus that carries native agent events. This created a feedback loop: native events → event bridge → broadcast → EventBus republish → SSE delivery, causing duplicate renders in attached OpenCode TUI clients (issue #380). Architecture change — direct-wire SSE: - state.broadcast_event() now fans projections directly to per-connection SSE subscriber queues instead of republishing to EventBus - global_routes._event_generator reads from state.event_subscribers queues (no EventBus subscription, no CustomEvent unwrapping) - Reconnect replay via state.replay_projections() using Last-Event-ID - Deleted event_bridge.py (the loopback republisher) EventBus source isolation (defense-in-depth): - EventEnvelope gains source_hint field; publish() accepts source_hint - subscribe() accepts exclude_source param (filters both live fanout and replay) - ProtocolEventConsumerMixin hooks: _get_subscription_replay() and _get_subscription_exclude_source() (defaults: replay=True, exclude=None) - OpenCode overrides: replay=False, exclude_source={"opencode_event_bridge"} Session consumer replay alignment: - OpenCode session-level consumers now use replay=False (matching the global SSE endpoint's first-connect policy), preventing stale events from being redelivered on consumer startup Testing: - 7082 tests passed (full suite), ruff/mypy clean - New e2e test: test_attach_existing_session_first_prompt_renders_once - New unit tests: EventBus source_hint/exclude_source (4 tests) - Rewritten integration tests for direct-wire SSE model Note: A residual TUI-side duplication may still be visible in opencode attach mode due to the TUI's local echo (createUserMessage) not matching the server-generated message ID in the SSE event. This is tracked as an opencode TUI bug (anomalyco/opencode#14372, #24773, #29478) with upstream fix PR #31945 still unmerged. The server-side fix in this commit eliminates the EventBus loopback path; the remaining duplication is purely client-side. * fix(opencode): address review — QueueFull policy, typed session-id extraction, mock alignment Review-driven fixes on PR #391 (direct-wire SSE loopback elimination): BLOCKER: broadcast_event now handles asyncio.QueueFull per-subscriber with the same drop-oldest policy as EventBus._enqueue, so a stalled SSE client can no longer abort fanout to every other subscriber. Adds structured warning logging on overflow plus a debug fanout log (telemetry on the delivery critical path). New regression test: test_broadcast_event_drop_oldest_on_queue_full. MAJOR: ServerState.extract_session_id now delegates to the typed global_routes._extract_session_id (match-based, no getattr) instead of the getattr probe that read info.id for MessageUpdatedEvent — buffering message.updated under per-message keys and leaking memory. Typed variant reads props.info.session_id as documented. Test mocks in test_global_event.py / test_sse_compliance.py aligned with production: deque(maxlen=100) buffers, drop-oldest overflow, typed extractor — the queue-full behavior is no longer suppressed out of the green suite. MINOR: replay_projections replays merged buffers in global event_id order (monotonic SSE ids for reconnecting clients) and stops with a warning on QueueFull instead of silently dropping a suffix. MINOR: _get_subscription_exclude_source documented as currently inert (its only producer was deleted with the loopback bridge; kept as defense-in-depth, exercised by unit tests). Nits: stale event_bridge docstrings/comment/names updated across test_event_pipeline_e2e.py and conftest.py; corrected the pre-existing dedup-set claim in src/wolfharness/AGENTS.md (the set is a private ACPEventConverter field, not on SessionController); changelog trailing newline; ADR eventbus-replay.md annotated as superseded by the direct-wire design (PR #391). Verified: ruff clean, mypy strict clean (686 files), 326 affected tests pass.
…align (#394) * feat(resource): per-agent max_text_chars config + kb_diag_agent YAML align ResourceConfig now accepts max_text_chars (default 10000, min 100) in agent YAML config. NativeAgent creates a per-agent ResourceCapability with the agent's max_text_chars instead of sharing the pool-level instance. ResourceCapability.__init__ gains max_text_chars parameter (backward compatible). Truncation suffix improved with guidance directing the model to use narrower URIs or paginated read tools. kb_diag_agent.yaml aligned with live knowledge_diag server v3.4.4: - Enabled search_kb (removed from disabled_tools) - Added get_doc_toc and read_chapter_page tool-schema-overlap rewrites - Added search_kb rewrite with methods (FULL/FAST/WIKI) param docs - Updated existing tool descriptions to reference page-based workflow Supersedes PR #393. * feat(mcp): resource subscribe-on-read wiring in McpServerCap Best-effort subscribe to resource URIs after successful read_resource() calls, enabling notifications/resources/updated for resources the agent has read. Tracked subscriptions are re-established on reconnect and cleaned up on disconnect. No-op for servers with subscribe:false (like knowledge_diag v3.4.4) — subscribe fails silently, read proceeds normally. Activates automatically when server enables subscription support. * fix(mcp): address PR #394 review — wire max_text_chars, fix broken test, consolidate truncation - Wire self._max_text_chars into read_mcp_resource (was hardcoded _DEFAULT_READ_TEXT_LIMIT) and use _truncate_text helper with guidance suffix - Remove dead _truncate_text static method from ResourceCapability (zero callers, old suffix format) - Add constructor validation: max_text_chars < 100 raises ValueError - Consolidate default constant: _DEFAULT_MAX_TEXT_CHARS in resource_resolver.py, aliased in resource_capability.py - Fix broken test assertion in test_resource_resolution.py to match new guidance suffix format - Add tests: max_text_chars validation, read_mcp_resource truncation with per-agent limit, suffix guidance text - Correct changelog: limit was previously hardcoded, not a pre-existing constructor param - Update capabilities/AGENTS.md: ResourceCapability is per-agent constructed, not registered at SESSION scope
Review: PR #405 — EventBus loopback elimination + per-agent resource read truncationSolid, well-tested replay of #391/#394. The EventBus Important
Minor
Nit
Verified non-issues
|
|
Posted the synthesized review at #405 (comment) Verdict: solid replay, approve-with-fixes. The EventBus Important
Docs (from the docs specialist)
Minor/Nit: dead mock classes left behind, Verified non-issues: fanout atomicity/ordering, |

Summary
Two framework-level fixes previously merged as #391 and #394, replayed cleanly on top of
mainwith all business-specific config removed.1. EventBus loopback elimination (originally #391, issue #380)
wolfharness serve-opencode+opencode attachrendered the first user message twice in the TUI. Root cause: the OpenCode server republished its own protocol projections back into the EventBus, creating a feedback loop.ServerState.broadcast_event()delivers OpenCode projections straight to per-connection SSE subscriber queues.OpenCodeEventBridge(which republished into the EventBus) is deleted.EventBus.publish()acceptssource_hint;subscribe()acceptsexclude_source, preventing a producer from re-consuming its own output.replay=False, matching SSE first-connect policy.ServerStatekeepLast-Event-IDconditional replay working.2. Per-agent resource read truncation + subscribe-on-read (originally #394)
ResourceConfig.max_text_chars: configurable per-agent truncation limit (default 10,000, min 100) via YAML.NativeAgentnow constructs a per-agentResourceCapabilityinstance instead of sharing the pool-level one.McpServerCapbest-effort subscribes to resource URIs afterread_resource(), enablingnotifications/resources/updated. Tracked subscriptions auto-re-establish after reconnect and clean up on disconnect.Test plan
uv run pytest -m unitpassesuv run pytest -m "e2e and not slow"passesuv run ruff check src/passesuv run ruff format --check src/passes