Two-hop connection model — socket state and live upstream #27

Closed
opened 2026-07-30 00:03:47 +00:00 by aiko · 4 comments
Owner

What to build

Two hops, honestly drawn. Replaces #11, whose premise changed with ADR-0001.

The hops are now client to go-between and go-between to adapter, where the adapter runs inside the Hermes gateway process. That second hop is a websocket the go-between itself holds (ADR-0003), which changes this issue for the better:

Upstream health is live, not last-known. The old issue was rescoped to "the upstream hop is last-known, stamped with when it was last proven, unknown until the first turn" because nothing reported Hermes' health. That is obsolete: if the adapter's socket is connected then Hermes is reachable by definition, and the go-between knows that continuously without polling anything.

  • Socket hop - open / closed / connecting, plus pairing state (ADR-0004). Client-side.
  • Upstream hop - adapter socket connected or not, pushed by the go-between on connect and on change. Live.

So 5a becomes a genuine live-failure screen rather than a post-mortem, and 3a can report both hops truthfully instead of proving them with a test transmission.

Acceptance criteria

  • The client distinguishes "my socket is down" from "the go-between has no adapter"
  • Upstream state is pushed on connect and on every change, never polled
  • Neither hop is drawn as "unknown" once connected
  • A dropped adapter socket surfaces without waiting for a turn to fail

Reference

ADR-0001, ADR-0003. Design: docs/design/denpa-receiver/README.md 3a, 3c, 5a, 6a-6d.

## What to build Two hops, honestly drawn. Replaces #11, whose premise changed with ADR-0001. The hops are now **client to go-between** and **go-between to adapter**, where the adapter runs inside the Hermes gateway process. That second hop is a websocket the go-between itself holds (ADR-0003), which changes this issue for the better: **Upstream health is live, not last-known.** The old issue was rescoped to "the upstream hop is last-known, stamped with when it was last proven, unknown until the first turn" because nothing reported Hermes' health. That is obsolete: if the adapter's socket is connected then Hermes is reachable by definition, and the go-between knows that continuously without polling anything. - **Socket hop** - open / closed / connecting, plus pairing state (ADR-0004). Client-side. - **Upstream hop** - adapter socket connected or not, pushed by the go-between on connect and on change. Live. So 5a becomes a genuine live-failure screen rather than a post-mortem, and 3a can report both hops truthfully instead of proving them with a test transmission. ## Acceptance criteria - [ ] The client distinguishes "my socket is down" from "the go-between has no adapter" - [ ] Upstream state is pushed on connect and on every change, never polled - [ ] Neither hop is drawn as "unknown" once connected - [ ] A dropped adapter socket surfaces without waiting for a turn to fail ## Reference ADR-0001, ADR-0003. Design: `docs/design/denpa-receiver/README.md` 3a, 3c, 5a, 6a-6d.
Author
Owner

Branch: issue/denpa-27-two-hop-status (denpa-client, pushed to origin, not merged, no PR).

What landed

src/renderer/src/services/two-hop.ts — the model. Two hops derived from the only two facts this client holds: its own socket phase, and the last report the go-between pushed. twoHopStatus(socket, report) returns a local and an upstream HopState (each with a ✓/✕/!/— mark, the design's own JP label and a one-line reason), the station word, and the § 7e pal fault.

Wiring: websocket-handler.tsx handles the pushed message and clears the report on any socket move; WebSocketContext publishes upstream; use-receiver-chrome.ts feeds the station bar's identity; PalContainer and OverlayPalContainer feed #18/#23's existing upstream-down fault. No second status vocabulary — the station word and the 7e treatment table already existed and are consumed as they are.

Visible behaviour: the station word is 圏外 when my own socket is down, 受信不能 when my socket is fine and the go-between holds no adapter, 同調中 while connecting or while upstream has not been reported yet, and 受信中 only when both hops are reached. Upstream-down also puts her into the 7e treatment (不通, dead meter, 上流が落ちてる).

The wire — and the part that does not exist

This is the client half only. The issue body and the brief both say #21 added an upstream_status message. It did not. There is no upstream string anywhere in the go-between's src/open_llm_vtuber/. The fact exists there — AdapterHandler.attached, adapter_handler.py:47-49 — but it is never sent to a viewer: _send_initial_messages (websocket_handler.py:118-142) pushes full-text, set-model-and-conf and control: start-mic, and nothing else.

So I defined the client's end of it: {"type": "upstream-status", "attached": <bool>, "detail"?: <str>} on /client-ws, pushed on viewer connect and on every adapter attach/detach. Nothing polls and nothing requests it. A message that does not carry a boolean attached is read as no report, never as detached.

Until the go-between sends it, hop two draws as unknown (, まだ報告がない), which is calm rather than a fault. That keeps the client honest but leaves acceptance criteria 2 and 4 unreachable, and criterion 3 true only from the first report onward. The go-between change is small — push the current adapter.attached in _send_initial_messages, and broadcast on attach and in serve's finally on detach — but it is ollvt-hermes-bridge work, so it needs its own issue rather than a client-side guess. Recorded in the questions ledger.

Tests

26 new tests in src/renderer/src/services/two-hop.test.tsx. Suite: 301 passed in 28 files (baseline 275 in 27). Red first: with the model present but the wiring reverted to main, exactly the 4 wiring tests failed; with the model absent the file failed to resolve. Typecheck adds no error in any file this branch touches.

Covered: the two failures are drawn differently and never collapse into one word; a detached report is dropped the moment my socket goes, so a failure is never latched across a reconnect; unknown, connecting and down are distinct marks and distinct station words; upstreamReportFrom refuses a missing or non-boolean flag; the station bar renders 受信不能 and not 受信中; and the wiring tests assert the pushed case exists, setInterval does not, and two-hop.ts contains no denpa. string — hop one's frame vocabulary is the go-between's business, not this client's.

Deliberately left out

  • § 5a's full failure screen, § 3a's 二段とも同調 verify block and § 3c's 局 panel. None of those surfaces exists in the client yet; the model exposes both hops as data for whoever builds them.
  • Pairing state on hop one (· 端末承認済み). The client sends no stable device_id and no client message carries approval state — the go-between's own #21 note says the viewer route authenticates nobody.
  • auth-refused, no-model and mic-lost still have no producer and stay absent.

Not verified

No rendered output was inspected and no live turn was driven end to end. The upstream half of the model has never seen a real message, because none is sent yet. The required cavecrew-reviewer pass did not run — no agent-spawn tool is available to a subagent in this harness — so assume one pair of eyes on this, not two.

Branch: `issue/denpa-27-two-hop-status` (denpa-client, pushed to `origin`, not merged, no PR). ## What landed `src/renderer/src/services/two-hop.ts` — the model. Two hops derived from the only two facts this client holds: its own socket phase, and the last report the go-between pushed. `twoHopStatus(socket, report)` returns a `local` and an `upstream` `HopState` (each with a `✓/✕/!/—` mark, the design's own JP label and a one-line reason), the station word, and the § 7e pal fault. Wiring: `websocket-handler.tsx` handles the pushed message and clears the report on any socket move; `WebSocketContext` publishes `upstream`; `use-receiver-chrome.ts` feeds the station bar's `identity`; `PalContainer` and `OverlayPalContainer` feed `#18`/`#23`'s existing `upstream-down` fault. No second status vocabulary — the station word and the 7e treatment table already existed and are consumed as they are. Visible behaviour: the station word is `圏外` when my own socket is down, `受信不能` when my socket is fine and the go-between holds no adapter, `同調中` while connecting *or* while upstream has not been reported yet, and `受信中` only when both hops are reached. Upstream-down also puts her into the 7e treatment (`不通`, dead meter, `上流が落ちてる`). ## The wire — and the part that does not exist **This is the client half only.** The issue body and the brief both say `#21` added an `upstream_status` message. It did not. There is no `upstream` string anywhere in the go-between's `src/open_llm_vtuber/`. The fact exists there — `AdapterHandler.attached`, `adapter_handler.py:47-49` — but it is never sent to a viewer: `_send_initial_messages` (`websocket_handler.py:118-142`) pushes `full-text`, `set-model-and-conf` and `control: start-mic`, and nothing else. So I defined the client's end of it: `{"type": "upstream-status", "attached": <bool>, "detail"?: <str>}` on `/client-ws`, pushed on viewer connect and on every adapter attach/detach. Nothing polls and nothing requests it. A message that does not carry a boolean `attached` is read as *no report*, never as detached. Until the go-between sends it, hop two draws as **unknown** (`—`, `まだ報告がない`), which is calm rather than a fault. That keeps the client honest but leaves acceptance criteria 2 and 4 unreachable, and criterion 3 true only from the first report onward. The go-between change is small — push the current `adapter.attached` in `_send_initial_messages`, and broadcast on attach and in `serve`'s `finally` on detach — but it is `ollvt-hermes-bridge` work, so it needs its own issue rather than a client-side guess. Recorded in the questions ledger. ## Tests 26 new tests in `src/renderer/src/services/two-hop.test.tsx`. Suite: **301 passed in 28 files** (baseline 275 in 27). Red first: with the model present but the wiring reverted to `main`, exactly the 4 wiring tests failed; with the model absent the file failed to resolve. Typecheck adds no error in any file this branch touches. Covered: the two failures are drawn differently and never collapse into one word; a detached report is dropped the moment my socket goes, so a failure is never latched across a reconnect; unknown, connecting and down are distinct marks and distinct station words; `upstreamReportFrom` refuses a missing or non-boolean flag; the station bar renders `受信不能` and not `受信中`; and the wiring tests assert the pushed case exists, `setInterval` does not, and `two-hop.ts` contains no `denpa.` string — hop one's frame vocabulary is the go-between's business, not this client's. ## Deliberately left out - § 5a's full failure screen, § 3a's `二段とも同調` verify block and § 3c's 局 panel. None of those surfaces exists in the client yet; the model exposes both hops as data for whoever builds them. - Pairing state on hop one (`· 端末承認済み`). The client sends no stable `device_id` and no client message carries approval state — the go-between's own `#21` note says the viewer route authenticates nobody. - `auth-refused`, `no-model` and `mic-lost` still have no producer and stay absent. ## Not verified No rendered output was inspected and no live turn was driven end to end. The upstream half of the model has never seen a real message, because none is sent yet. The required `cavecrew-reviewer` pass did not run — no agent-spawn tool is available to a subagent in this harness — so assume one pair of eyes on this, not two.
Author
Owner

Review pass on issue/denpa-27-two-hop-status. Five of six findings fixed; the sixth was fixed differently because the suggested fix is not reachable in this toolchain.

This issue is now two branches, and they merge together or not at all.

  • aiko/denpaissue/denpa-27-two-hop-status (client)
  • aiko/ollvt-hermes-bridgeissue/denpa-27-upstream-status-push (go-between)

Review's blocker was that the client answered an upstream-status message no producer sent, so the whole upstream half — parser, model, fault feed — handled a message that never arrives, under a name the client had invented alone. Re-verified before acting: no upstream string anywhere in src/open_llm_vtuber/, AdapterHandler.attached (adapter_handler.py:47-49) read by nothing, and the three connect messages at websocket_handler.py:118-142 carrying no hop-two field. Review offered either landing the producer or deleting the client half; deleting it abandons criteria 2 and 4, which only the go-between can satisfy, so the producer landed: broadcast_upstream_status, a push in _send_initial_messages, and announce-on-attach / announce-on-detach in AdapterHandler.serve. Both ends name the same message now. Go-between tests 117 -> 123.

The rest:

  • An unreported hop two no longer downgrades the station word. It read 同調中, which was a regression against main's constant 受信中 — and against a go-between too old to report, it would have stuck there forever and never reached 受信中 again. Now 受信中 whenever our own socket is open; the honest per-hop truth stays in the chain, where unknown keeps its grey .
  • A dead client socket now draws on the pal. § 7e lists four faults and none is § 6a, because § 6a is a full-screen surface — but the pal has no station bar, no such surface exists in the client yet, and pet and overlay mode are exactly where a dead socket had nothing to show it. New local-down treatment: § 7e's shape (不通, dead meter, acid edge, なぜ) with § 6a's own headline どこにも繋がらない, deliberately not 上流が落ちてる — with our socket shut upstream is unverifiable, not down, and blaming it sends the operator to the wrong machine.
  • A malformed push no longer un-proves a known-good hop.
  • The station word is announcedrole="status", aria-live="polite", and an aria-label naming it 受信状態, now that it tracks the connection instead of being a constant.
  • The grep-the-source tests are gone. Review asked for a rendered WebSocketHandler with a stubbed wsService; not reachable — vitest.config.ts is environment: 'node' with no jsdom and no testing-library, so effects never run and there is nothing to assert on, and adding either is a new dependency the brief rules out. Instead the whole of what an arriving message does to hop two is the pure upstreamAfterMessage(previous, message) that the handler calls, covered by nine direct tests. What remains of the source-scanning is four mount checks, the same shape as the pre-existing chrome-is-mounted.test.ts.

Every fix was checked by mutating it back and confirming exactly the intended test fails.

Deliberately left out: the § 5a / § 6a full failure screens, § 3a's verify block and § 3c's 局 panel — the model exposes both hops as data but no component draws a chain yet. Pairing state is still not part of the socket hop. The station word renders in one colour for all four states; § 5a never specifies the word's colour, so picking one would be design invention.

Client: 312 passed in 28 files (275 baseline). Typecheck 586 error lines, none in any file this branch touches. Assumptions in .denpa-work/QUESTIONS.md.

Review pass on `issue/denpa-27-two-hop-status`. Five of six findings fixed; the sixth was fixed differently because the suggested fix is not reachable in this toolchain. **This issue is now two branches, and they merge together or not at all.** - `aiko/denpa` — `issue/denpa-27-two-hop-status` (client) - `aiko/ollvt-hermes-bridge` — `issue/denpa-27-upstream-status-push` (go-between) Review's blocker was that the client answered an `upstream-status` message no producer sent, so the whole upstream half — parser, model, fault feed — handled a message that never arrives, under a name the client had invented alone. Re-verified before acting: no `upstream` string anywhere in `src/open_llm_vtuber/`, `AdapterHandler.attached` (`adapter_handler.py:47-49`) read by nothing, and the three connect messages at `websocket_handler.py:118-142` carrying no hop-two field. Review offered either landing the producer or deleting the client half; deleting it abandons criteria 2 and 4, which only the go-between can satisfy, so the producer landed: `broadcast_upstream_status`, a push in `_send_initial_messages`, and announce-on-attach / announce-on-detach in `AdapterHandler.serve`. Both ends name the same message now. Go-between tests 117 -> 123. The rest: - **An unreported hop two no longer downgrades the station word.** It read 同調中, which was a regression against main's constant 受信中 — and against a go-between too old to report, it would have stuck there forever and never reached 受信中 again. Now 受信中 whenever our own socket is open; the honest per-hop truth stays in the chain, where unknown keeps its grey `—`. - **A dead client socket now draws on the pal.** § 7e lists four faults and none is § 6a, because § 6a is a full-screen surface — but the pal has no station bar, no such surface exists in the client yet, and pet and overlay mode are exactly where a dead socket had nothing to show it. New `local-down` treatment: § 7e's shape (不通, dead meter, acid edge, なぜ) with § 6a's own headline `どこにも繋がらない`, deliberately not 上流が落ちてる — with our socket shut upstream is unverifiable, not down, and blaming it sends the operator to the wrong machine. - **A malformed push no longer un-proves a known-good hop.** - **The station word is announced** — `role="status"`, `aria-live="polite"`, and an `aria-label` naming it 受信状態, now that it tracks the connection instead of being a constant. - **The grep-the-source tests are gone.** Review asked for a rendered `WebSocketHandler` with a stubbed `wsService`; not reachable — `vitest.config.ts` is `environment: 'node'` with no jsdom and no testing-library, so effects never run and there is nothing to assert on, and adding either is a new dependency the brief rules out. Instead the whole of what an arriving message does to hop two is the pure `upstreamAfterMessage(previous, message)` that the handler calls, covered by nine direct tests. What remains of the source-scanning is four mount checks, the same shape as the pre-existing `chrome-is-mounted.test.ts`. Every fix was checked by mutating it back and confirming exactly the intended test fails. **Deliberately left out:** the § 5a / § 6a full failure screens, § 3a's verify block and § 3c's 局 panel — the model exposes both hops as data but no component draws a chain yet. Pairing state is still not part of the socket hop. The station word renders in one colour for all four states; § 5a never specifies the word's colour, so picking one would be design invention. Client: **312 passed in 28 files** (275 baseline). Typecheck 586 error lines, none in any file this branch touches. Assumptions in `.denpa-work/QUESTIONS.md`.
Author
Owner

Branches

  • issue/denpa-27-two-hop-status on aiko/denpa (client) — pushed.
  • issue/denpa-27-upstream-status-push on aiko/ollvt-hermes-bridge (go-between) — pushed. This second branch was not planned; see "The blocker" below for why it exists.

What was built

A two-hop connection model for the client, plus the go-between producer that feeds it.

Client (src/renderer/src/services/two-hop.ts, new): hop one is our own websocket to the go-between, read off the socket phase strings wsService.onStateChange already publishes and websocket-handler.tsx already stores as wsState (websocket-handler.tsx:280-284); anything that is not OPEN or CONNECTING is closed. Hop two is the go-between's socket to the adapter, learned from a pushed upstream-status client message. upstreamAfterMessage is the pure reducer the handler calls, stationStatusFor picks the station word, palFaultFor picks the § 7e pal treatment, and HopState / HOP_GLYPH / HOP_MARK_COLOR_TOKEN publish both hops as data.

Wired into: websocket-handler.tsx (the message case and the socket-transition discard), websocket-context.tsx, websocket-service.tsx (message shape), use-receiver-chrome.tsstation-bar.tsx (the station word), and both pal-container.tsx and overlay-pal-container.tsx (the fault).

Go-between: UPSTREAM_STATUS_TYPE + broadcast_upstream_status, a push from _send_initial_messages, and announce-on-attach / announce-on-detach in AdapterHandler.serve.

All the Japanese strings come from the design doc, none were invented: 受信不能 / 圏外 (docs/design/denpa-receiver/README.md:312), ✓ cyan / ✕ magenta / ! acid / — grey (:315), アダプタ接続中 / アダプタ未接続 (:220), ✕ 中継局 socket 開けない / — 上流 確認できない (:353), 上流が落ちてる (:499), どこにも繋がらない from § 6a. README.md:396 is the requirement that WebSocketContext distinguish the two hops.

The blocker, and what I did about it

Review flagged as a blocker that upstream-status had no producer anywhere — a client-invented wire. I verified the gap myself rather than taking it on trust: no upstream string anywhere in denpa/src/open_llm_vtuber/**.py; AdapterHandler.attached (adapter_handler.py:47-49, set at :75 on accept, cleared at :83-84 in the finally) is read by nothing except send_up (:88); the three messages this client is actually sent on connect — {"type":"full-text","text":"Connection established"}, set-model-and-conf, {"type":"control","text":"start-mic"} (websocket_handler.py:118-142) — carry no hop-two field at all.

So the claim in this issue and in the agent brief that #21 added an upstream_status message is false. The go-between holds the upstream fact and never forwards it. I did not build on that claim.

For context on why hop two is a real, continuously-held socket rather than a poll: the adapter is the websocket client and dials out (hermes_plugin/denpa/protocol.py:29, DEFAULT_WS_URL = "ws://127.0.0.1:12393/adapter-ws", per ADR-0003). The two routes are separate endpoints and the client never sees the adapter one — /client-ws at routes.py:32, /adapter-ws at routes.py:49-52 — which is why this client must not parse denpa.* frames itself. #21's notice handling (adapter_handler.py:113-119, mapping protocol.FRAME_NOTICE to {"type": "notice", "text": text}) is the evidence that hop-one frames get translated by the go-between and never reach the client raw.

Review offered two exits: land the producer, or delete the client's upstream half and mark criteria 2 and 4 blocked. I took the first, because the second abandons most of this issue. The go-between branch above is that producer. Both ends now name the same message, in the same change set.

Tests

Client: 312 passed in 28 files. Baseline on main is 275 in 27 files — the brief's "26 files" is a miscount, the 275 matches exactly. So +1 file, +37 tests.

 RUN  v4.1.10 F:/Projects/denpa-repos/denpa-client/.claude/worktrees/wf_98677da4-b38-1
 Test Files  28 passed (28)
      Tests  312 passed (312)
   Duration  5.72s

Go-between: 123 passed, baseline measured directly on denpa main with the new file ignored is 117. Clean +6. ruff check and ruff format clean on the three touched files.

123 passed, 4 warnings in 21.63s

What the tests cover: the socket-phase → hop-one mapping including the never-opened case; upstreamAfterMessage across a well-formed attach, a well-formed detach, a missing attached, a non-boolean attached, and an unrelated message type; the discard of a stored report on every socket transition; stationStatusFor for each hop pairing including the healthy-path regression below; palFaultFor for all five § 7e treatments; the glyph and colour tables; and on the go-between, the push on connect, the announce on attach, and the announce in the detach finally.

Every fix was verified by mutating it back and watching exactly the intended test fail — stationStatusFor reverted, the palFaultFor local branch removed, the ?? previous removed, the aria attributes removed, the local-down headline swapped. The new go-between test file failed collection on UPSTREAM_STATUS_TYPE before the producer existed.

Typecheck: 586 error TS lines, zero in any file this branch touches (grepped for two-hop, station-bar, pal-fault, pal-container, overlay-pal, websocket-handler, websocket-context, use-receiver-chrome, desktop-pal). 586 matches the baseline recorded for this branch. The brief says 585 for main; I could not measure main in this worktree to attribute the one-line delta, because main is checked out in the primary worktree. The only four non-vendored errors are in files this branch does not touch (chat-history-panel.tsx, live2d-config-context.tsx, use-sidebar.ts, use-audio-task.ts). One new error I introduced in two-hop.test.tsx was found and fixed before committing.

Review findings — all addressed

Two blockers, four majors, three minors. Fixed:

  1. Self-defined wire with no producer — landed the producer (above).
  2. stationStatusFor showed 同調中 forever — with no producer, an unknown upstream downgraded every healthy OPEN socket, a user-visible regression against main's 受信中 (which came from DEFAULT_IDENTITY, station-bar.tsx:35) and a direct violation of criterion 3. Now local ok + upstream unknown returns RECEIVING. Per-hop honesty stays in the chain data, where unknown keeps its grey dash.
  3. Malformed push clobbered a known-good report — fixed structurally, not with a guard: the handler now calls the pure upstreamAfterMessage, which returns the previous report unchanged for anything that does not carry a boolean attached.
  4. Local-down was invisible in pet and overlay mode — those surfaces have no station bar, so a dead client socket produced no visible change at all. Added a fifth § 7e treatment, local-down, reusing 7e's shape (不通, dead meter, acid edge, なぜ) with § 6a's headline どこにも繋がらない rather than 上流が落ちてる — with our own socket shut, upstream is unverifiable, not down.
  5. Station word became a live readout with no aria — added role="status", aria-live="polite", and an aria-label (STATION_STATUS_LABEL = 受信状態).
  6. Grep-the-source tests were the only wiring coverage — the fs.readFileSync behaviour assertions are deleted, replaced with nine direct tests of the real reducer the handler calls, including the malformed-push case. Four mount-only checks remain, same shape as the pre-existing chrome-is-mounted.test.ts.
  7. Tautological assertions dropped; the unused exports and the MessageEvent widening narrowed.

Skipped, with justification:

  • Review's literal mechanism for finding 6 — "render WebSocketHandler with a stubbed wsService subject and assert context changes" — is not reachable here. vitest.config.ts sets environment: 'node'; there is no jsdom and no testing-library, so a rendered component never runs its effects and there is nothing to assert on. Adding either is a new dependency the brief rules out, and node_modules is a junction so installing is not available anyway. I fixed the underlying complaint instead, by moving all of the wiring's logic out of the component into a pure function, so the tests exercise the real code path rather than source text. Ledgered.
  • The station word renders in one colour (color="carrier") for all four states. § 5a specifies a dead meter and a status word but never the word's colour, so picking one is design invention. Ledgered with the upgrade path — bind it to HOP_MARK_COLOR_TOKEN the way the chain glyphs already are.

Deliberately left out

  • § 5a's full live-failure screen, § 3a's 二段とも同調 verify block, § 3c's panel. None of those surfaces exists in the client. The model publishes both hops as data and no component draws a chain.
  • Pairing state on hop one (· 端末承認済み). The client sends no stable device_id and no client message carries approval state.
  • The other three § 7e faults (auth-refused, no-model, mic-lost) still have no producer, so they stay absent rather than becoming disabled affordances.
  • caveman:cavecrew-reviewer did not run. No Task/agent-spawn tool exists in a subagent's toolset here, so the required review gate is unmet. The review findings above came from the orchestrating pass, not from that agent.

Could not verify at all

  • Anything about how this looks. I cannot see rendered output. Unverified: that 受信不能 / 圏外 / 同調中 read as distinct at 10px; that the 不通 treatment actually lands on the pal in a running app.
  • The upstream half end to end. I could not drive a live turn through the gateway, so the go-between producer and the client parser have never met over a real socket. Each is tested against its own side only, and the client parser has only ever been exercised against hand-built objects.
  • The typecheck baseline on main, per above.

Assumptions

  • The message is {"type": "upstream-status", "attached": <bool>, "detail"?: <str>} on /client-ws, pushed on viewer connect and on every adapter attach/detach — never polled, never requested. Both ends now agree on this name because I wrote both, but it is still a name I chose. Renaming touches UPSTREAM_STATUS_TYPE in two-hop.ts:49, the case in websocket-handler.tsx, and the go-between constant. Nothing else.
  • A message with no boolean attached is no report, never detached. Silence must not be drawn as failure.
  • A closed or connecting socket forces hop two back to unknown regardless of the last report, and the stored report is discarded on every socket transition. The adapter reconnects on its own bounded backoff and the gateway has its own watcher, so a latched failure would outlive the fault.
  • A socket that never opened claims no pal fault beyond the new local-down one; § 6a (socket won't open) is a full-screen fault, not a pal one, and that screen is still undrawn. The station word says 圏外.
  • The station bar's identity.status is the right surface, not a new one. #10's faceplate is keyed on AiState (faceplate-spec.ts:81) and carries no connection axis; the station word already defaulted to 受信中 from chrome-spec.ts:16, so it is the existing connection vocabulary.
  • 同調中 is declared in two-hop.ts rather than imported, because faceplate-spec.ts holds it as an inline literal in the LOADING treatment and exports no constant. Same word, two declarations.
  • Both pal containers get the fault (desktop #18 and Android overlay #23) — a dropped adapter is equally true on either.

Acceptance criteria

1 and 3 are met, 3 from the first report onward. 2 and 4 are now reachable, because the producer exists — but neither has been observed working over a real socket, only in tests on each side. Treat them as implemented and unproven rather than done.

Process note

This worktree was preserved from an earlier interrupted attempt at this same issue and already contained most of the client implementation uncommitted. I read all of it, verified every wire claim it rested on myself (and that is how the false upstream_status claim surfaced), added the wiring tests, and demonstrated red by reverting the wiring files to main — four wiring tests failed with the model present, and the whole file failed to resolve with the model removed. I did not watch the original 22 model tests go red before they were written. Everything added in this pass was written test-first.

Ledger entries are in .denpa-work/QUESTIONS.md.

## Branches - **`issue/denpa-27-two-hop-status`** on `aiko/denpa` (client) — pushed. - **`issue/denpa-27-upstream-status-push`** on `aiko/ollvt-hermes-bridge` (go-between) — pushed. This second branch was not planned; see "The blocker" below for why it exists. ## What was built A two-hop connection model for the client, plus the go-between producer that feeds it. Client (`src/renderer/src/services/two-hop.ts`, new): hop one is our own websocket to the go-between, read off the socket phase strings `wsService.onStateChange` already publishes and `websocket-handler.tsx` already stores as `wsState` (`websocket-handler.tsx:280-284`); anything that is not OPEN or CONNECTING is closed. Hop two is the go-between's socket to the adapter, learned from a pushed `upstream-status` client message. `upstreamAfterMessage` is the pure reducer the handler calls, `stationStatusFor` picks the station word, `palFaultFor` picks the § 7e pal treatment, and `HopState` / `HOP_GLYPH` / `HOP_MARK_COLOR_TOKEN` publish both hops as data. Wired into: `websocket-handler.tsx` (the message case and the socket-transition discard), `websocket-context.tsx`, `websocket-service.tsx` (message shape), `use-receiver-chrome.ts` → `station-bar.tsx` (the station word), and both `pal-container.tsx` and `overlay-pal-container.tsx` (the fault). Go-between: `UPSTREAM_STATUS_TYPE` + `broadcast_upstream_status`, a push from `_send_initial_messages`, and announce-on-attach / announce-on-detach in `AdapterHandler.serve`. All the Japanese strings come from the design doc, none were invented: `受信不能` / `圏外` (`docs/design/denpa-receiver/README.md:312`), `✓ cyan / ✕ magenta / ! acid / — grey` (:315), `アダプタ接続中` / `アダプタ未接続` (:220), `✕ 中継局 socket 開けない` / `— 上流 確認できない` (:353), `上流が落ちてる` (:499), `どこにも繋がらない` from § 6a. `README.md:396` is the requirement that WebSocketContext distinguish the two hops. ## The blocker, and what I did about it Review flagged as a blocker that `upstream-status` had **no producer anywhere** — a client-invented wire. I verified the gap myself rather than taking it on trust: no `upstream` string anywhere in `denpa/src/open_llm_vtuber/**.py`; `AdapterHandler.attached` (`adapter_handler.py:47-49`, set at `:75` on accept, cleared at `:83-84` in the `finally`) is read by nothing except `send_up` (`:88`); the three messages this client is actually sent on connect — `{"type":"full-text","text":"Connection established"}`, `set-model-and-conf`, `{"type":"control","text":"start-mic"}` (`websocket_handler.py:118-142`) — carry no hop-two field at all. **So the claim in this issue and in the agent brief that `#21` added an `upstream_status` message is false.** The go-between holds the upstream fact and never forwards it. I did not build on that claim. For context on why hop two is a real, continuously-held socket rather than a poll: the adapter is the websocket client and dials out (`hermes_plugin/denpa/protocol.py:29`, `DEFAULT_WS_URL = "ws://127.0.0.1:12393/adapter-ws"`, per ADR-0003). The two routes are separate endpoints and the client never sees the adapter one — `/client-ws` at `routes.py:32`, `/adapter-ws` at `routes.py:49-52` — which is why this client must not parse `denpa.*` frames itself. `#21`'s `notice` handling (`adapter_handler.py:113-119`, mapping `protocol.FRAME_NOTICE` to `{"type": "notice", "text": text}`) is the evidence that hop-one frames get translated by the go-between and never reach the client raw. Review offered two exits: land the producer, or delete the client's upstream half and mark criteria 2 and 4 blocked. I took the first, because the second abandons most of this issue. The go-between branch above is that producer. Both ends now name the same message, in the same change set. ## Tests Client: **312 passed in 28 files.** Baseline on `main` is 275 in 27 files — the brief's "26 files" is a miscount, the 275 matches exactly. So +1 file, +37 tests. ``` RUN v4.1.10 F:/Projects/denpa-repos/denpa-client/.claude/worktrees/wf_98677da4-b38-1 Test Files 28 passed (28) Tests 312 passed (312) Duration 5.72s ``` Go-between: **123 passed**, baseline measured directly on `denpa` `main` with the new file ignored is 117. Clean +6. `ruff check` and `ruff format` clean on the three touched files. ``` 123 passed, 4 warnings in 21.63s ``` What the tests cover: the socket-phase → hop-one mapping including the never-opened case; `upstreamAfterMessage` across a well-formed attach, a well-formed detach, a missing `attached`, a non-boolean `attached`, and an unrelated message type; the discard of a stored report on every socket transition; `stationStatusFor` for each hop pairing including the healthy-path regression below; `palFaultFor` for all five § 7e treatments; the glyph and colour tables; and on the go-between, the push on connect, the announce on attach, and the announce in the detach `finally`. Every fix was verified by mutating it back and watching exactly the intended test fail — `stationStatusFor` reverted, the `palFaultFor` local branch removed, the `?? previous` removed, the aria attributes removed, the `local-down` headline swapped. The new go-between test file failed collection on `UPSTREAM_STATUS_TYPE` before the producer existed. Typecheck: 586 `error TS` lines, **zero in any file this branch touches** (grepped for two-hop, station-bar, pal-fault, pal-container, overlay-pal, websocket-handler, websocket-context, use-receiver-chrome, desktop-pal). 586 matches the baseline recorded for this branch. The brief says 585 for `main`; I could not measure `main` in this worktree to attribute the one-line delta, because `main` is checked out in the primary worktree. The only four non-vendored errors are in files this branch does not touch (`chat-history-panel.tsx`, `live2d-config-context.tsx`, `use-sidebar.ts`, `use-audio-task.ts`). One new error I introduced in `two-hop.test.tsx` was found and fixed before committing. ## Review findings — all addressed Two blockers, four majors, three minors. Fixed: 1. **Self-defined wire with no producer** — landed the producer (above). 2. **`stationStatusFor` showed `同調中` forever** — with no producer, an unknown upstream downgraded every healthy OPEN socket, a user-visible regression against `main`'s `受信中` (which came from `DEFAULT_IDENTITY`, `station-bar.tsx:35`) and a direct violation of criterion 3. Now local `ok` + upstream `unknown` returns RECEIVING. Per-hop honesty stays in the chain data, where unknown keeps its grey dash. 3. **Malformed push clobbered a known-good report** — fixed structurally, not with a guard: the handler now calls the pure `upstreamAfterMessage`, which returns the previous report unchanged for anything that does not carry a boolean `attached`. 4. **Local-down was invisible in pet and overlay mode** — those surfaces have no station bar, so a dead client socket produced no visible change at all. Added a fifth § 7e treatment, `local-down`, reusing 7e's shape (`不通`, dead meter, acid edge, なぜ) with § 6a's headline `どこにも繋がらない` rather than `上流が落ちてる` — with our own socket shut, upstream is unverifiable, not down. 5. **Station word became a live readout with no aria** — added `role="status"`, `aria-live="polite"`, and an `aria-label` (`STATION_STATUS_LABEL` = `受信状態`). 6. **Grep-the-source tests were the only wiring coverage** — the `fs.readFileSync` behaviour assertions are deleted, replaced with nine direct tests of the real reducer the handler calls, including the malformed-push case. Four mount-only checks remain, same shape as the pre-existing `chrome-is-mounted.test.ts`. 7. Tautological assertions dropped; the unused exports and the `MessageEvent` widening narrowed. **Skipped, with justification:** - Review's literal mechanism for finding 6 — "render `WebSocketHandler` with a stubbed `wsService` subject and assert context changes" — is not reachable here. `vitest.config.ts` sets `environment: 'node'`; there is no jsdom and no testing-library, so a rendered component never runs its effects and there is nothing to assert on. Adding either is a new dependency the brief rules out, and `node_modules` is a junction so installing is not available anyway. I fixed the underlying complaint instead, by moving all of the wiring's logic out of the component into a pure function, so the tests exercise the real code path rather than source text. Ledgered. - The station word renders in one colour (`color="carrier"`) for all four states. § 5a specifies a dead meter and a status word but never the word's colour, so picking one is design invention. Ledgered with the upgrade path — bind it to `HOP_MARK_COLOR_TOKEN` the way the chain glyphs already are. ## Deliberately left out - **§ 5a's full live-failure screen, § 3a's `二段とも同調` verify block, § 3c's `局` panel.** None of those surfaces exists in the client. The model publishes both hops as data and no component draws a chain. - **Pairing state on hop one (`· 端末承認済み`).** The client sends no stable `device_id` and no client message carries approval state. - **The other three § 7e faults** (auth-refused, no-model, mic-lost) still have no producer, so they stay absent rather than becoming disabled affordances. - **`caveman:cavecrew-reviewer` did not run.** No Task/agent-spawn tool exists in a subagent's toolset here, so the required review gate is unmet. The review findings above came from the orchestrating pass, not from that agent. ## Could not verify at all - **Anything about how this looks.** I cannot see rendered output. Unverified: that `受信不能` / `圏外` / `同調中` read as distinct at 10px; that the `不通` treatment actually lands on the pal in a running app. - **The upstream half end to end.** I could not drive a live turn through the gateway, so the go-between producer and the client parser have never met over a real socket. Each is tested against its own side only, and the client parser has only ever been exercised against hand-built objects. - **The typecheck baseline on `main`**, per above. ## Assumptions - The message is `{"type": "upstream-status", "attached": <bool>, "detail"?: <str>}` on `/client-ws`, pushed on viewer connect and on every adapter attach/detach — never polled, never requested. Both ends now agree on this name because I wrote both, but it is still a name I chose. Renaming touches `UPSTREAM_STATUS_TYPE` in `two-hop.ts:49`, the case in `websocket-handler.tsx`, and the go-between constant. Nothing else. - A message with no boolean `attached` is *no report*, never detached. Silence must not be drawn as failure. - A closed or connecting socket forces hop two back to `unknown` regardless of the last report, and the stored report is discarded on every socket transition. The adapter reconnects on its own bounded backoff and the gateway has its own watcher, so a latched failure would outlive the fault. - A socket that never opened claims no *pal* fault beyond the new `local-down` one; § 6a (socket won't open) is a full-screen fault, not a pal one, and that screen is still undrawn. The station word says `圏外`. - The station bar's `identity.status` is the right surface, not a new one. `#10`'s faceplate is keyed on AiState (`faceplate-spec.ts:81`) and carries no connection axis; the station word already defaulted to `受信中` from `chrome-spec.ts:16`, so it is the existing connection vocabulary. - `同調中` is declared in `two-hop.ts` rather than imported, because `faceplate-spec.ts` holds it as an inline literal in the LOADING treatment and exports no constant. Same word, two declarations. - Both pal containers get the fault (desktop `#18` and Android overlay `#23`) — a dropped adapter is equally true on either. ## Acceptance criteria 1 and 3 are met, 3 from the first report onward. 2 and 4 are now reachable, because the producer exists — but neither has been observed working over a real socket, only in tests on each side. Treat them as implemented and unproven rather than done. ## Process note This worktree was preserved from an earlier interrupted attempt at this same issue and already contained most of the client implementation uncommitted. I read all of it, verified every wire claim it rested on myself (and that is how the false `upstream_status` claim surfaced), added the wiring tests, and demonstrated red by reverting the wiring files to `main` — four wiring tests failed with the model present, and the whole file failed to resolve with the model removed. I did not watch the original 22 model tests go red before they were written. Everything added in this pass was written test-first. Ledger entries are in `.denpa-work/QUESTIONS.md`.
Author
Owner

Shipped. Merged to main at 0e91bd9 (phase C1).

Closed as part of reconciling the board with main: the autonomous run's policy was not to auto-close, so shipped work stayed open and the tracker read as though nothing had landed. That policy is now reversed — issues close when they land.

Shipped. Merged to `main` at [`0e91bd9`](https://git.aiko.works/aiko/denpa/commit/0e91bd9) (phase C1). Closed as part of reconciling the board with `main`: the autonomous run's policy was not to auto-close, so shipped work stayed open and the tracker read as though nothing had landed. That policy is now reversed — issues close when they land.
aiko closed this issue 2026-07-30 13:32:55 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiko/denpa#27
No description provided.