Skip to content

renderer: split WebSocketManager into per-family event modules; idempotent prompts over the socket - #155

Draft
kai-openswarm wants to merge 6 commits into
openswarm-ai:devfrom
kai-openswarm:c10/b-ws
Draft

renderer: split WebSocketManager into per-family event modules; idempotent prompts over the socket#155
kai-openswarm wants to merge 6 commits into
openswarm-ai:devfrom
kai-openswarm:c10/b-ws

Conversation

@kai-openswarm

Copy link
Copy Markdown

Draft, stacked on #147 (its import-safe config.ts is what lets these tests run under node:test; the extra commits in the diff disappear once #147 merges). First renderer-infra PR of the series; nothing hosted, no product features.

shared/ws/WebSocketManager.ts was 1,052 lines: the transport (connect, resume handshake, heartbeat, reconnect backoff, batched dispatch) and every server→client event family's store mutations in one switch. It is now the transport (472 lines) plus one module per family, each a pure (event, data) → handled function over the store:

Module Family
agentSessionEvents.ts session status / message / branch / approval / queue events
agentStreamEvents.ts stream start / delta / end
dashboardBrowserEvents.ts browser card lifecycle
outputEvents.ts app outputs (incl. the app-tool-grant DOM event)
settingsEvents.ts, workflowEvents.ts as named
eventHandlers.ts / eventHandlerTypes.ts the ordered handler chain
resumeState.ts, ids.ts, types.ts resume bookkeeping, id helpers, the wire types

One behaviour change: sends over a session socket carry a per-logical-prompt idempotency key that survives a transport retry (a resend after a failed send reuses the key; a new prompt gets a new one), so a backend that dedupes on the key never runs a prompt twice. Everything else is a move — the transport mechanisms (rAF/timer batching, drag dam, heartbeat, backoff, resume ack) are unchanged.

Tests (node:test, 8 cases): tokenized hello + queued-frame flush after server:hello; dashboard sockets skipping resume and emitting the reconnect notification; skipped stream events staying suppressed ahead of custom listeners; listener ordering/unsubscribe; replayed stream events dropped until resume ack; message append to an existing session; the two idempotency cases. They seed the auth token the way the preload would and stand in for the two DOM bits the manager reads (requestAnimationFrame, document.body.classList).

Proof: tsc --noEmit clean; node scripts/run-tests.mjs 151/151; packaged macOS app with this renderer: upstream smoke 5/5 + the characterization suite (#146) 19/19 — the chat/tool-ui and resilience specs drive this transport directly.

Not in this PR: the capability-overlay events of a card family that is not in this tree.

… a window, so reducer tests run under node:test

Both modules touched window at import time (port/host derivation, the fetch
interceptor install, the debug handle), so any node:test file that imports a
reducer importing API_BASE died with 'window is not defined' before its first
assertion; fetchSessionsStrip.test.ts has been red that way since the
resilience work landed, unnoticed because nothing runs these tests in CI. In a
renderer (window present) nothing changes: same port/host, same interceptor,
same handle. Without one the module answers with the defaults and installs
nothing.
Nothing ran any of them in CI: the 235-file backend pytest suite, the 22
renderer node:test files and the edge suite were run by hand, one file at a
time, so a regression only surfaced when someone happened to run the right
one. Three small workflows, hosted ubuntu, path-filtered, read-only token:

- backend-tests: pytest on Python 3.13 from the locked requirements, plus a
  completion assertion (junit testcase count == collect-only count) so a test
  process that dies mid-run can never read as green
- frontend-tests: tsc --noEmit + node:test via tsx over src/**/*.test.ts(x),
  through frontend/scripts/run-tests.mjs (the runner the tests already name)
- edge-tests: pytest for openswarm-edge

All three are green on the current tree: 2951 backend tests, 143 frontend
tests across 22 files, 14 edge tests.
Two of five hosted runs of the backend suite stalled at 99% until the job cap
with no summary and no junit: one test blocked forever on a bare
ws.receive_json() (fixed on its own in a separate change). A CI lane should
never depend on every test being unable to hang, so add pytest-timeout to the
dev requirements and run the suite with --timeout=300. On Linux the default
signal method fails just the offending test and the run continues, so the
report and the "every collected test ran" assertion stay meaningful.
test_ws_endpoint_streams_a_full_turn_end_to_end read the socket with a bare
ws.receive_json() in a 40-iteration loop and broke only on the assistant reply.
When the loop ends early for any reason (fewer than 40 events, no reply), the
next receive blocks forever and the whole pytest run stalls at 99% until the
job cap. On hosted runners it does exactly that intermittently, on Linux and
Windows alike: the turn path's configure_provider_env decides whether 9Router
needs reviving from provider evidence earlier tests may leave behind, and that
revival spawns/installs the router behind a module-level asyncio.Lock; the
background turn-label aux call reaches the same machinery. Neither is part of
this test's contract ("SDK and WS auth mocked, everything else real").

Pin both out with monkeypatch, bound every receive at 5s (a regression now
fails this test instead of hanging the runner), and wait for the turn's
completed status before asserting on session.messages so the assertion cannot
race the loop's tail.
Same class as the config/backendConnection change: safeMode.ts read `window`
at import, and dashboardLayoutSlice imports it, so any reducer test that
imports the slice died under node:test before it ran. Guard the read; in a
renderer nothing changes.
…otent prompts over the socket

shared/ws/WebSocketManager.ts was 1,052 lines: the transport (connect, resume
handshake, heartbeat, reconnect backoff, batched dispatch) and every server→
client event family's store mutations in one switch. It is now the transport
(472 lines) plus one module per family, each a pure (event, data) → handled
function over the store:

  agentSessionEvents.ts     session status/message/branch/approval/queue events
  agentStreamEvents.ts      stream start/delta/end
  dashboardBrowserEvents.ts browser card lifecycle
  outputEvents.ts           app outputs (incl. the app-tool-grant DOM event)
  settingsEvents.ts, workflowEvents.ts
  eventHandlers.ts          the ordered handler chain; eventHandlerTypes.ts
  resumeState.ts, ids.ts, types.ts

Behaviour: sends over a session socket carry a per-logical-prompt idempotency
key that survives a transport retry (a resend after a failed send reuses the
key, a new prompt gets a new one), so a backend that dedupes on the key never
runs a prompt twice. Everything else is a move.

Tests (node:test, 8 cases): the tokenized hello + queued-frame flush after
server:hello, dashboard sockets skipping resume and emitting the reconnect
notification, skipped stream events staying suppressed ahead of custom
listeners, listener ordering/unsubscribe, replayed stream events dropped until
resume ack, message append to an existing session, and the two idempotency
cases. The tests seed the auth token the way the preload would and stand in
for the two DOM bits the manager reads.

Not in this change: the capability-overlay events of a card family that is not
in this tree.
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.

1 participant