自発送信 — she speaks first #32

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

What to build

自発送信. Replaces #22.

Per ADR-0007 Hermes owns all triggering; the client has no timers and no rate limiting. Two trigger shapes exist and both are attributed: cron:<job_id> and webhook:<route>. Idle nudges are out of scope - Hermes has no such trigger.

Beyond cron, the send_message tool can target Denpa from any context, so an unprompted turn is not always scheduled. Do not assume a job id exists for every one.

Presentation - when the avatar is visible she simply speaks. The break-in band is for when she is not on screen: a parked pal, a background tab. There is no separate proactive rendering when you can see her.

Queueing (ADR-0006) - a proactive turn waits for a turn boundary and drains on frontend-playback-complete, with a timeout of twice the audio length. The invocation line draws a pending count. Coalescing is per job id, so twelve overnight firings surface once, marked 12x.

An unattributed turn is possible: a standalone hermes cron tick with the gateway down carries no id. Degrade to a generic break-in, never drop the turn.

Acceptance criteria

  • A real cron turn renders naming its trigger
  • A send_message-initiated turn renders without a job id and is not mistaken for an error
  • Nothing arrives mid-sentence
  • The pending count is drawn and accurate
  • Twelve missed firings of one job surface as one break-in marked 12x
  • A visible avatar means no break-in band

Reference

ADR-0005, 0006, 0007.

## What to build 自発送信. Replaces #22. Per ADR-0007 Hermes owns all triggering; the client has no timers and no rate limiting. Two trigger shapes exist and both are attributed: `cron:<job_id>` and `webhook:<route>`. Idle nudges are out of scope - Hermes has no such trigger. Beyond cron, the `send_message` tool can target Denpa from any context, so an unprompted turn is **not** always scheduled. Do not assume a job id exists for every one. **Presentation** - when the avatar is visible she simply speaks. The break-in band is for when she is not on screen: a parked pal, a background tab. There is no separate proactive rendering when you can see her. **Queueing** (ADR-0006) - a proactive turn waits for a turn boundary and drains on `frontend-playback-complete`, with a timeout of twice the audio length. The invocation line draws a pending count. Coalescing is per job id, so twelve overnight firings surface once, marked `12x`. An unattributed turn is possible: a standalone `hermes cron tick` with the gateway down carries no id. Degrade to a generic break-in, never drop the turn. ## Acceptance criteria - [ ] A real cron turn renders naming its trigger - [ ] A `send_message`-initiated turn renders without a job id and is not mistaken for an error - [ ] Nothing arrives mid-sentence - [ ] The pending count is drawn and accurate - [ ] Twelve missed firings of one job surface as one break-in marked 12x - [ ] A visible avatar means no break-in band ## Reference ADR-0005, 0006, 0007.
Author
Owner

Branch: issue/denpa-32-proactive-break-in (off main at 8237457)

What landed

  • services/unprompted.ts — the whole decision surface as pure functions: decode, coalescing per job id, the break-in gate, the queue. ADR-0006 applied to presentation: a break-in waits for a turn boundary, never lands mid-sentence, never lands while the operator has the key down or is typing, and a suspended approval outranks it (one is waiting on a human, the other is her choosing to talk).
  • components/receiver/break-in-band.tsx — § 1a's acid band, pinned to the top of the stage below the 画面共有 indicator so a live-capture warning is never covered. Announces itself (role="status") because the whole point is that you were not looking.
  • faceplate-spec.tsUNPROMPTED_TREATMENT (自発 / UNPROMPTED, solid acid, inbound), beside the table rather than in it, the same call #19 made for 承認待ち. 自発 is a cause, not an AiStateEnum member.
  • invocation-line.tsturnCause(aiState, latest) (replacing the duplicated causeOf in both pal containers) plus the pending count, since the pal has no band.
  • context/unprompted-context.tsx — one owner, one queue, one subscription, mounted inside ApprovalProvider so the gate can see an ask.

Tests — 560 passing in 40 files (main was 511 in 37)

Coalescing (twelve firings of one job → one break-in, 12x), two jobs staying apart, unattributed turns never coalescing, the gate refusing on each of its three inputs, one band at a time with the rest counted, send_message-shaped turns rendering without a job id and asserted not to borrow error vocabulary, trigger validation (cron: / webhook: only, capped length, nonsense counts clamped), and a source-level wiring test in the shape of approval-wiring.test.ts — including that frontend-playback-complete still leaves the client, which is ADR-0006's only real client obligation.

The wire — this cannot fully work yet

Nothing in the go-between attributes a pushed turn. denpa.speak carries text alone (hermes_plugin/denpa/protocol.py:45), handle_frame forwards only that (adapter_handler.py:350-352), enqueue_turn queues a bare string (websocket_handler.py:243-251). So the message decoded here is one I named, the way #27 named upstream-status: {"type": "unprompted-turn", "trigger": "cron:<job_id>"|"webhook:<route>"|null, "count"?: int}. Until a producer exists no break-in is ever drawn and the invocation line stays 送信/待機 — acceptance criteria 1, 2, 4 and 5 need go-between work.

protocol.FRAME_TURN_COMPLETE goes up to the adapter with duration_ms and gate (websocket_handler.py:288-294); it never reaches a viewer. No second 2 x audio length timer was added client-side — a timer that can disagree with the authoritative one is worse than none.

Left out / assumed

  • "A visible avatar means no break-in band" conflicts with § 1a, which pins the band inside the stage where she is visible. Reconciled on document.hidden at arrival: visible → names its trigger, no band ever; hidden → queues a band that is there when you come back.
  • No toast surface owns the design's 自発 2件 · 留守中に話してた reconnect variant, so it is not built.
  • Nothing expires a break-in. ADR-0007's staleness bound is derived from Hermes' scheduler grace, which is upstream data, and the client may hold no timer.
  • denpa#35: the gate reads AiStateEnum.LISTENING, not MicVAD — Rust capture must keep setting LISTENING when it opens the mic.

Nine entries in .denpa-work/QUESTIONS.md. npm run typecheck reports the same 585 pre-existing errors, none naming a file this branch touches.

**Branch:** `issue/denpa-32-proactive-break-in` (off `main` at `8237457`) ### What landed - `services/unprompted.ts` — the whole decision surface as pure functions: decode, coalescing per job id, the break-in gate, the queue. ADR-0006 applied to presentation: a break-in waits for a turn boundary, never lands mid-sentence, never lands while the operator has the key down or is typing, and **a suspended approval outranks it** (one is waiting on a human, the other is her choosing to talk). - `components/receiver/break-in-band.tsx` — § 1a's acid band, pinned to the top of the stage *below* the 画面共有 indicator so a live-capture warning is never covered. Announces itself (`role="status"`) because the whole point is that you were not looking. - `faceplate-spec.ts` — `UNPROMPTED_TREATMENT` (自発 / UNPROMPTED, solid acid, `inbound`), beside the table rather than in it, the same call `#19` made for 承認待ち. 自発 is a cause, not an `AiStateEnum` member. - `invocation-line.ts` — `turnCause(aiState, latest)` (replacing the duplicated `causeOf` in both pal containers) plus the pending count, since the pal has no band. - `context/unprompted-context.tsx` — one owner, one queue, one subscription, mounted inside `ApprovalProvider` so the gate can see an ask. ### Tests — 560 passing in 40 files (`main` was 511 in 37) Coalescing (twelve firings of one job → one break-in, `12x`), two jobs staying apart, unattributed turns never coalescing, the gate refusing on each of its three inputs, one band at a time with the rest counted, `send_message`-shaped turns rendering without a job id and asserted *not* to borrow error vocabulary, trigger validation (`cron:` / `webhook:` only, capped length, nonsense counts clamped), and a source-level wiring test in the shape of `approval-wiring.test.ts` — including that `frontend-playback-complete` still leaves the client, which is ADR-0006's only real client obligation. ### The wire — this cannot fully work yet Nothing in the go-between attributes a pushed turn. `denpa.speak` carries `text` alone (`hermes_plugin/denpa/protocol.py:45`), `handle_frame` forwards only that (`adapter_handler.py:350-352`), `enqueue_turn` queues a bare string (`websocket_handler.py:243-251`). So the message decoded here is one I named, the way `#27` named `upstream-status`: `{"type": "unprompted-turn", "trigger": "cron:<job_id>"|"webhook:<route>"|null, "count"?: int}`. **Until a producer exists no break-in is ever drawn and the invocation line stays 送信/待機** — acceptance criteria 1, 2, 4 and 5 need go-between work. `protocol.FRAME_TURN_COMPLETE` goes *up* to the adapter with `duration_ms` and `gate` (`websocket_handler.py:288-294`); it never reaches a viewer. No second `2 x audio length` timer was added client-side — a timer that can disagree with the authoritative one is worse than none. ### Left out / assumed - **"A visible avatar means no break-in band"** conflicts with § 1a, which pins the band inside the stage where she is visible. Reconciled on `document.hidden` at arrival: visible → names its trigger, no band ever; hidden → queues a band that is there when you come back. - No toast surface owns the design's `自発 2件 · 留守中に話してた` reconnect variant, so it is not built. - Nothing expires a break-in. ADR-0007's staleness bound is derived from Hermes' scheduler grace, which is upstream data, and the client may hold no timer. - `denpa#35`: the gate reads `AiStateEnum.LISTENING`, not `MicVAD` — Rust capture must keep setting `LISTENING` when it opens the mic. Nine entries in `.denpa-work/QUESTIONS.md`. `npm run typecheck` reports the same 585 pre-existing errors, none naming a file this branch touches.
Author
Owner

Review-fix pass on issue/denpa-32-proactive-break-in (client) plus a companion branch on the go-between, issue/denpa-32-unprompted-attribution in aiko/ollvt-hermes-bridge. Both must merge together — the client half is unreachable without the wire half.

The blocker: unprompted-turn had no producer. Confirmed: denpa.speak carried text alone, and enqueue_turn forwarded only that. The reviewer's fix was to land the attribution upstream first, so that is what happened — and it turned out to be derivable without touching read-only hermes-agent:

  • Hermes hands a platform adapter no attribution. DeliveryRouter._deliver_to_platform drops job_id (gateway/delivery.py:365) and send()'s metadata carries only thread/user ids (gateway/run.py:7511-7520).
  • The job id survives in exactly one place: the header cron.wrap_response writes around a cron delivery (cron/scheduler.py:1497-1506, default on). The adapter parses it, emits trigger on the speak frame, and strips the wrapper — she was otherwise reading (job_id: morning.brief) and the "to stop or manage this job" footer aloud.
  • The go-between carries the trigger on the turn queue and sends {"type": "unprompted-turn", "trigger": "cron:<job_id>"} to the client immediately before that turn's audio, which is where a break-in band has to be told.
  • Webhook routes cannot be attributed at allgateway/platforms/webhook.py:1406-1412 never names the route. Those stay trigger: null, which the band already draws as a nameless break-in. Recorded in the ledger.
  • count left the wire: each firing is its own delivery, so coalescing is what the surface does to what it is holding (ADR-0007), not something upstream sends.

The other findings, all fixed:

  • A dismissal now disarms the queue until the next closed gate. Dismissing with her idle re-drained in the same tick, so user input swapped one band for the next instead of clearing it.
  • An ask that arrives second outranks a standing band: the queue takes it down and re-queues it (unpromptedSuspended), and the draw refuses to show both. The render test fails against the old draw.
  • The dismissal moved into useTextInput.handleSend, the one choke point every typed send goes through. Two callers of it never dismissed, so her reply to a typed message was drawn as 自発 attributed to whatever cron job ran last.
  • An over-long trigger is refused rather than sliced: a slice reads as a job that exists, and cutting UTF-16 units can halve a surrogate pair.

One finding skipped. The claim that avatarVisible: !document.hidden makes the pal's count dead rested on the pal and overlay being "separate always-present webviews". They are not — App.tsx:112-134 renders the chrome, the desktop pal and the Android overlay from one webview under useMode, with a single UnpromptedProvider above the branch. A queue built while the window sat in the tray survives the switch to the pal, so the count is reachable. A parked or click-through pal is deliberately not treated as unattended: she is on screen, so she simply speaks.

Tests: client npm test — 568 passed in 40 files. Go-between — 172 passed, ruff check clean. Assumptions and the two unbuildable pieces are in .denpa-work/QUESTIONS.md.

Review-fix pass on `issue/denpa-32-proactive-break-in` (client) plus a companion branch on the go-between, `issue/denpa-32-unprompted-attribution` in `aiko/ollvt-hermes-bridge`. **Both must merge together** — the client half is unreachable without the wire half. **The blocker: `unprompted-turn` had no producer.** Confirmed: `denpa.speak` carried `text` alone, and `enqueue_turn` forwarded only that. The reviewer's fix was to land the attribution upstream first, so that is what happened — and it turned out to be derivable without touching read-only `hermes-agent`: - Hermes hands a platform adapter no attribution. `DeliveryRouter._deliver_to_platform` drops `job_id` (`gateway/delivery.py:365`) and `send()`'s `metadata` carries only thread/user ids (`gateway/run.py:7511-7520`). - The job id survives in exactly one place: the header `cron.wrap_response` writes around a cron delivery (`cron/scheduler.py:1497-1506`, default on). The adapter parses it, emits `trigger` on the speak frame, and strips the wrapper — she was otherwise reading `(job_id: morning.brief)` and the "to stop or manage this job" footer aloud. - The go-between carries the trigger on the turn queue and sends `{"type": "unprompted-turn", "trigger": "cron:<job_id>"}` to the client immediately before that turn's audio, which is where a break-in band has to be told. - **Webhook routes cannot be attributed at all** — `gateway/platforms/webhook.py:1406-1412` never names the route. Those stay `trigger: null`, which the band already draws as a nameless break-in. Recorded in the ledger. - `count` left the wire: each firing is its own delivery, so coalescing is what the surface does to what it is holding (ADR-0007), not something upstream sends. **The other findings, all fixed:** - A dismissal now disarms the queue until the next closed gate. Dismissing with her idle re-drained in the same tick, so user input swapped one band for the next instead of clearing it. - An ask that arrives second outranks a standing band: the queue takes it down and re-queues it (`unpromptedSuspended`), and the draw refuses to show both. The render test fails against the old draw. - The dismissal moved into `useTextInput.handleSend`, the one choke point every typed send goes through. Two callers of it never dismissed, so her reply to a typed message was drawn as `自発` attributed to whatever cron job ran last. - An over-long trigger is refused rather than sliced: a slice reads as a job that exists, and cutting UTF-16 units can halve a surrogate pair. **One finding skipped.** The claim that `avatarVisible: !document.hidden` makes the pal's `件` count dead rested on the pal and overlay being "separate always-present webviews". They are not — `App.tsx:112-134` renders the chrome, the desktop pal and the Android overlay from one webview under `useMode`, with a single `UnpromptedProvider` above the branch. A queue built while the window sat in the tray survives the switch to the pal, so the count is reachable. A parked or click-through pal is deliberately not treated as unattended: she is on screen, so she simply speaks. **Tests:** client `npm test` — 568 passed in 40 files. Go-between — 172 passed, `ruff check` clean. Assumptions and the two unbuildable pieces are in `.denpa-work/QUESTIONS.md`.
Author
Owner

Proactive break-in — landed on two branches

Client: issue/denpa-32-proactive-break-in in aiko/denpa, pushed.
Go-between: issue/denpa-32-unprompted-attribution in aiko/ollvt-hermes-bridge, pushed. The client half is unreachable without it — read them together.

What was built

Client side: a break-in queue (services/unprompted.ts) with a reducer that gates on the turn boundary, an UnpromptedProvider mounted once above the mode branch in App.tsx, useUnprompted in hooks/receiver/, a BreakInBand at the top of the 1a stage (rendered after the 画面共有 indicator so a live-capture warning is never covered), a 自発 treatment beside faceplateTreatments, and turnCause on the invocation line for both pals, which replaced a duplicated causeOf. Trigger attribution renders as the job name; coalescing is per job id and draws 12x.

Go-between side: the adapter reads the job id out of the header Hermes' cron.wrap_response puts around a cron delivery, strips the wrapper so she stops reading the job id aloud, and emits trigger on denpa.speak. The go-between carries it on the turn queue and sends {"type": "unprompted-turn", "trigger": "cron:<job_id>"} to the client immediately before that turn's audio (src/open_llm_vtuber/websocket_handler.py:265-275).

Tests

Client: 568 passed in 40 files (main at 8237457 was 511 in 37). New coverage is the reducer's gate (queue, drain, coalesce-per-job-id, dismissal, the armed rule), trigger parsing and refusal, renderToStaticMarkup assertions on the band and the invocation line, and source-reading wiring tests in the established shape of approval-wiring.test.ts. Go-between: 172 passed, ruff clean; 5 new adapter tests for header parsing and wrapper stripping, 2 for the pushed turn. Typecheck delta is zero — grepping npm run typecheck output for every touched path returns nothing against the documented 585-error baseline.

Test Files  40 passed (40)
     Tests  568 passed (568)
  Duration  7.54s

Wire claims, each verified against source

  • denpa.speak carried text and nothing else before this branch — no trigger, no count: denpa/hermes_plugin/denpa/protocol.py:45 and its only consumer at denpa/src/open_llm_vtuber/adapter_handler.py:350-352.
  • enqueue_turn queued a bare string per device, so nothing about a turn's cause survived to the client: websocket_handler.py:243-251.
  • No proactive message existed in the client-facing vocabulary. Enumerated by grepping every "type": literal outside denpa.* in src/open_llm_vtuber/: notice, user-input-transcription, image, typing, error, control, full-text, backend-synth-complete, force-new-message, audio, set-model-and-conf, config-switched, config-files, background-files, heartbeat-ack, upstream-status, plus the three ask request types. unprompted-turn is a name this branch defines.
  • job_id survives into a platform's send() in exactly one place — the header from hermes-agent/cron/scheduler.py:1497-1506. gateway/delivery.py:365 drops it, and send()'s metadata is thread and user ids only. That is why attribution is parsed out of the message text rather than read from a field.
  • Webhook routes cannot be attributed: gateway/platforms/webhook.py:1406-1412 never names the route. They stay trigger: null and draw as a nameless break-in.
  • protocol.FRAME_TURN_COMPLETE is adapter-bound (up), carries duration_ms and gate, and is never forwarded to a viewer: protocol.py:62, websocket_handler.py:288-294.
  • The drain gate ADR-0006 names is client-reported, and the go-between waits on it with a timeout of twice the audio length: conversations/conversation_utils.py:129-143.
  • The client already meets that obligation — hooks/utils/use-audio-task.ts:228 sends frontend-playback-complete after the queue empties. It is accepted but unhandled on the viewer route, explicitly exempted from the unknown-message warning: websocket_handler.py:357.
  • Trigger attribution as a two-tag union with idle nudges out of scope, 12x coalescing, and the staleness bound as Hermes' _compute_grace_seconds: docs/adr/0007-hermes-schedules-the-client-reads-and-mutes.md:18-24, :26-29, :30-32.
  • Approval asks arrive as approval-request and are answered by choice with approval_id, ownership keyed on client_uid: adapter_handler.py:59-63, :303-326. Used for the precedence rule.
  • The client never sends device_id; a body-supplied one is ignored at the router: websocket_handler.py:349-353.

Deliberately left out

No ai-speak-signal and no client-side trigger of any kind — the hook sends nothing, and a wiring test asserts the absence of both sendMessage and that string. No client timer or schedule (ADR-0007), including no client-side "twice the audio length" fallback: the authoritative one lives in finalize_conversation_turn, and a second timer that can disagree with it is worse than none. count was removed from the wire — each cron firing is its own delivery, so coalescing is the client's. The design's dark-reconnect acid toast (自発 2件 · 留守中に話してた with a 読む action) is not built; no toast surface owns it. Nothing expires a break-in, because ADR-0007's staleness bound derives from upstream scheduler grace. 局's read-and-mute cron list is a different issue and untouched. No band on either pal, per the design — the pals get the 自発 cause and a pending count on the bar instead. Nothing in the mic/audio path was restructured.

Could not be verified

Every visual claim is an assertion about markup from renderToStaticMarkup, not about pixels: the band's inbound entrance, its position under the 画面共有 indicator and its acid contrast are asserted as CSS and markup, never observed. The suite has no jsdom and no testing-library, so no effect and no event can be driven — the subscription, the drain effect, the LISTENING dismissal and the provider's mount-once rule are guarded by source-reading tests that prove the plumbing exists, not that it fires. Screen-reader announcement is asserted as role="status"/aria-live, untested with an actual AT. document.hidden behaviour inside the Tauri WebView2 window (does minimising or occluding flip it?) is assumed, not measured. No live end-to-end turn was driven: a real cron firing rendering its trigger, and twelve firings surfacing as 12x, are verified only at the reducer, adapter and component level.

I did not run the caveman:cavecrew-reviewer agent the global instructions ask for — no Task tool in this shape. A separate review pass ran against the client branch and its findings were addressed (below).

Assumptions

  • The client-facing message name and shape are mine: {"type": "unprompted-turn", "trigger": "cron:<job_id>"|null}, following #27's precedent for upstream-status. Both halves are on my branches, so they agree; if the wire should look different, it is one constant and one field read.
  • The issue said the band lives in the transmission band, "which #19 just changed". The source says otherwise: the transmission band is pinned to the foot of the stage and now carries the approval card, while design § 1a pins the break-in band to the top edge. It is a separate component in the top stack.
  • The issue's "a visible avatar means no break-in band" contradicts design § 1a, which pins the band inside the 1a stage where she is visible. Resolved on document.hidden at arrival time: visible → the turn names its trigger on the invocation line and no band queues; hidden → a band queues and is there when you return. One flag reverses this if the design wins.
  • ADR-0006's queue is Hermes'. The client does not queue turns, it queues break-ins. speaking leaving 送信中 (the audio queue emptying) stands in for playback-complete rather than a second timer.
  • A suspended approval outranks a break-in, wired through the real useApproval() from #19.
  • An unattributed turn is never coalesced with another — a missing trigger is not an id, and merging two would claim one job fired twice.
  • An unrecognised trigger tag degrades to a generic break-in rather than dropping the turn. An over-length trigger is refused outright; the viewer socket is LAN-reachable under ADR-0004, so what the band can be made to draw is bounded.
  • 自発 is a treatment, not a seventh AiStateEnum member, mirroring #19's APPROVAL_TREATMENT.
  • Two markings were chosen: 12x (ADR-0007 and the issue) and the pending count as 2件, the design's own dark-reconnect idiom rather than #19's あと…件待ち. An unattributed break-in draws no trailing text rather than an invented 不明.
  • The band is an announcement, not a control, so there is deliberately no key on it that could be keyboard-unreachable the way #19's 許可 was. Dismissal comes from input the surfaces already handle, plus the mic opening (LISTENING), without which a cron turn from earlier in the session would be named as the cause of a turn the operator spoke.
  • Baseline note for whoever measures next: this worktree started detached at 0e91bd9 while main was already at 8237457. Measuring before git checkout -b … main gives 352 in 30 files, not the real 511 in 37.

Nine entries appended to .denpa-work/QUESTIONS.md.

For denpa#35

Nothing mic-related was restructured. The one coupling is that the gate reads AiStateEnum.LISTENING rather than MicVAD, so Rust capture must keep setting LISTENING when it opens the mic. Recorded in the ledger.

Review findings

Six were fixed: the missing producer (blocker — resolved by landing the upstream branch rather than by defining a client-only name), a dismissal that re-drained in the same tick (added an armed flag; two new tests), approval precedence enforced only at the drain and not the draw (added unpromptedSuspended and showBreakIn; the new render test was confirmed to fail against the old draw), latest surviving a text send (dismissal moved into useTextInput.handleSend, the single choke point, and the redundant per-caller wrappers came off), truncate-before-validate on the trigger (now refused, including an emoji job id), and the two cosmetic findings on the unused MeterKind member and the duplicated constant.

One was skipped: avatarVisible: !document.hidden makes the pal count dead. The premise is false — the pal and the Android overlay are not separate webviews. App.tsx:112-134 renders chrome, desktop pal and overlay from one webview under useMode, with a single UnpromptedProvider above the branch (App.tsx:153-160), so a queue built while the window sat in the tray survives a mode switch and the count is reachable. A parked or click-through pal is deliberately not treated as unattended: she is on screen, so she speaks, which is the design's rule. Documented at the call site and in the ledger with what would change if the parked pal really should break in.

The source-formatting assertions the reviewer flagged (toContain('dismiss(); handleSend();') and siblings) are still there. They are brittle by construction and only exist because the suite cannot drive an effect; the real fix is jsdom, which is out of scope here.

Honest status

Everything reported green was observed. Nothing failed. The gap a human should weigh before merging is that this issue's acceptance criteria are about behaviour during a real proactive turn, and no real proactive turn was driven end to end — the two halves are tested separately and are believed to meet in the middle.

## Proactive break-in — landed on two branches **Client:** `issue/denpa-32-proactive-break-in` in `aiko/denpa`, pushed. **Go-between:** `issue/denpa-32-unprompted-attribution` in `aiko/ollvt-hermes-bridge`, pushed. The client half is unreachable without it — read them together. ### What was built Client side: a break-in queue (`services/unprompted.ts`) with a reducer that gates on the turn boundary, an `UnpromptedProvider` mounted once above the mode branch in `App.tsx`, `useUnprompted` in `hooks/receiver/`, a `BreakInBand` at the top of the 1a stage (rendered after the 画面共有 indicator so a live-capture warning is never covered), a 自発 treatment beside `faceplateTreatments`, and `turnCause` on the invocation line for both pals, which replaced a duplicated `causeOf`. Trigger attribution renders as the job name; coalescing is per job id and draws `12x`. Go-between side: the adapter reads the job id out of the header Hermes' `cron.wrap_response` puts around a cron delivery, strips the wrapper so she stops reading the job id aloud, and emits `trigger` on `denpa.speak`. The go-between carries it on the turn queue and sends `{"type": "unprompted-turn", "trigger": "cron:<job_id>"}` to the client immediately before that turn's audio (`src/open_llm_vtuber/websocket_handler.py:265-275`). ### Tests Client: 568 passed in 40 files (main at `8237457` was 511 in 37). New coverage is the reducer's gate (queue, drain, coalesce-per-job-id, dismissal, the armed rule), trigger parsing and refusal, `renderToStaticMarkup` assertions on the band and the invocation line, and source-reading wiring tests in the established shape of `approval-wiring.test.ts`. Go-between: 172 passed, ruff clean; 5 new adapter tests for header parsing and wrapper stripping, 2 for the pushed turn. Typecheck delta is zero — grepping `npm run typecheck` output for every touched path returns nothing against the documented 585-error baseline. ``` Test Files 40 passed (40) Tests 568 passed (568) Duration 7.54s ``` ### Wire claims, each verified against source - `denpa.speak` carried `text` and nothing else before this branch — no trigger, no count: `denpa/hermes_plugin/denpa/protocol.py:45` and its only consumer at `denpa/src/open_llm_vtuber/adapter_handler.py:350-352`. - `enqueue_turn` queued a bare string per device, so nothing about a turn's cause survived to the client: `websocket_handler.py:243-251`. - No proactive message existed in the client-facing vocabulary. Enumerated by grepping every `"type":` literal outside `denpa.*` in `src/open_llm_vtuber/`: notice, user-input-transcription, image, typing, error, control, full-text, backend-synth-complete, force-new-message, audio, set-model-and-conf, config-switched, config-files, background-files, heartbeat-ack, upstream-status, plus the three ask request types. `unprompted-turn` is a name this branch defines. - `job_id` survives into a platform's `send()` in exactly one place — the header from `hermes-agent/cron/scheduler.py:1497-1506`. `gateway/delivery.py:365` drops it, and `send()`'s metadata is thread and user ids only. That is why attribution is parsed out of the message text rather than read from a field. - Webhook routes cannot be attributed: `gateway/platforms/webhook.py:1406-1412` never names the route. They stay `trigger: null` and draw as a nameless break-in. - `protocol.FRAME_TURN_COMPLETE` is adapter-bound (up), carries `duration_ms` and `gate`, and is never forwarded to a viewer: `protocol.py:62`, `websocket_handler.py:288-294`. - The drain gate ADR-0006 names is client-reported, and the go-between waits on it with a timeout of twice the audio length: `conversations/conversation_utils.py:129-143`. - The client already meets that obligation — `hooks/utils/use-audio-task.ts:228` sends `frontend-playback-complete` after the queue empties. It is accepted but unhandled on the viewer route, explicitly exempted from the unknown-message warning: `websocket_handler.py:357`. - Trigger attribution as a two-tag union with idle nudges out of scope, `12x` coalescing, and the staleness bound as Hermes' `_compute_grace_seconds`: `docs/adr/0007-hermes-schedules-the-client-reads-and-mutes.md:18-24`, `:26-29`, `:30-32`. - Approval asks arrive as `approval-request` and are answered by choice with `approval_id`, ownership keyed on `client_uid`: `adapter_handler.py:59-63`, `:303-326`. Used for the precedence rule. - The client never sends `device_id`; a body-supplied one is ignored at the router: `websocket_handler.py:349-353`. ### Deliberately left out No `ai-speak-signal` and no client-side trigger of any kind — the hook sends nothing, and a wiring test asserts the absence of both `sendMessage` and that string. No client timer or schedule (ADR-0007), including no client-side "twice the audio length" fallback: the authoritative one lives in `finalize_conversation_turn`, and a second timer that can disagree with it is worse than none. `count` was removed from the wire — each cron firing is its own delivery, so coalescing is the client's. The design's dark-reconnect acid toast (`自発 2件 · 留守中に話してた` with a 読む action) is not built; no toast surface owns it. Nothing expires a break-in, because ADR-0007's staleness bound derives from upstream scheduler grace. 局's read-and-mute cron list is a different issue and untouched. No band on either pal, per the design — the pals get the 自発 cause and a pending count on the bar instead. Nothing in the mic/audio path was restructured. ### Could not be verified Every visual claim is an assertion about markup from `renderToStaticMarkup`, not about pixels: the band's `inbound` entrance, its position under the 画面共有 indicator and its acid contrast are asserted as CSS and markup, never observed. The suite has no jsdom and no testing-library, so no effect and no event can be driven — the subscription, the drain effect, the LISTENING dismissal and the provider's mount-once rule are guarded by source-reading tests that prove the plumbing exists, not that it fires. Screen-reader announcement is asserted as `role="status"`/`aria-live`, untested with an actual AT. `document.hidden` behaviour inside the Tauri WebView2 window (does minimising or occluding flip it?) is assumed, not measured. No live end-to-end turn was driven: a real cron firing rendering its trigger, and twelve firings surfacing as `12x`, are verified only at the reducer, adapter and component level. I did not run the `caveman:cavecrew-reviewer` agent the global instructions ask for — no Task tool in this shape. A separate review pass ran against the client branch and its findings were addressed (below). ### Assumptions - **The client-facing message name and shape are mine:** `{"type": "unprompted-turn", "trigger": "cron:<job_id>"|null}`, following #27's precedent for `upstream-status`. Both halves are on my branches, so they agree; if the wire should look different, it is one constant and one field read. - The issue said the band lives in the transmission band, "which #19 just changed". The source says otherwise: the transmission band is pinned to the foot of the stage and now carries the approval card, while design § 1a pins the break-in band to the top edge. It is a separate component in the top stack. - The issue's "a visible avatar means no break-in band" contradicts design § 1a, which pins the band inside the 1a stage where she is visible. Resolved on `document.hidden` at arrival time: visible → the turn names its trigger on the invocation line and no band queues; hidden → a band queues and is there when you return. One flag reverses this if the design wins. - ADR-0006's queue is Hermes'. The client does not queue turns, it queues break-ins. `speaking` leaving 送信中 (the audio queue emptying) stands in for playback-complete rather than a second timer. - A suspended approval outranks a break-in, wired through the real `useApproval()` from #19. - An unattributed turn is never coalesced with another — a missing trigger is not an id, and merging two would claim one job fired twice. - An unrecognised trigger tag degrades to a generic break-in rather than dropping the turn. An over-length trigger is refused outright; the viewer socket is LAN-reachable under ADR-0004, so what the band can be made to draw is bounded. - 自発 is a treatment, not a seventh `AiStateEnum` member, mirroring #19's `APPROVAL_TREATMENT`. - Two markings were chosen: `12x` (ADR-0007 and the issue) and the pending count as `2件`, the design's own dark-reconnect idiom rather than #19's `あと…件待ち`. An unattributed break-in draws no trailing text rather than an invented 不明. - The band is an announcement, not a control, so there is deliberately no key on it that could be keyboard-unreachable the way #19's 許可 was. Dismissal comes from input the surfaces already handle, plus the mic opening (`LISTENING`), without which a cron turn from earlier in the session would be named as the cause of a turn the operator spoke. - Baseline note for whoever measures next: this worktree started detached at `0e91bd9` while `main` was already at `8237457`. Measuring before `git checkout -b … main` gives 352 in 30 files, not the real 511 in 37. Nine entries appended to `.denpa-work/QUESTIONS.md`. ### For denpa#35 Nothing mic-related was restructured. The one coupling is that the gate reads `AiStateEnum.LISTENING` rather than `MicVAD`, so Rust capture must keep setting `LISTENING` when it opens the mic. Recorded in the ledger. ### Review findings Six were fixed: the missing producer (blocker — resolved by landing the upstream branch rather than by defining a client-only name), a dismissal that re-drained in the same tick (added an `armed` flag; two new tests), approval precedence enforced only at the drain and not the draw (added `unpromptedSuspended` and `showBreakIn`; the new render test was confirmed to fail against the old draw), `latest` surviving a text send (dismissal moved into `useTextInput.handleSend`, the single choke point, and the redundant per-caller wrappers came off), truncate-before-validate on the trigger (now refused, including an emoji job id), and the two cosmetic findings on the unused `MeterKind` member and the duplicated `件` constant. **One was skipped:** `avatarVisible: !document.hidden` makes the pal count dead. The premise is false — the pal and the Android overlay are not separate webviews. `App.tsx:112-134` renders chrome, desktop pal and overlay from one webview under `useMode`, with a single `UnpromptedProvider` above the branch (`App.tsx:153-160`), so a queue built while the window sat in the tray survives a mode switch and the `件` count is reachable. A parked or click-through pal is deliberately not treated as unattended: she is on screen, so she speaks, which is the design's rule. Documented at the call site and in the ledger with what would change if the parked pal really should break in. The source-formatting assertions the reviewer flagged (`toContain('dismiss(); handleSend();')` and siblings) are still there. They are brittle by construction and only exist because the suite cannot drive an effect; the real fix is jsdom, which is out of scope here. ### Honest status Everything reported green was observed. Nothing failed. The gap a human should weigh before merging is that this issue's acceptance criteria are about behaviour during a real proactive turn, and no real proactive turn was driven end to end — the two halves are tested separately and are believed to meet in the middle.
aiko referenced this issue from a commit 2026-07-30 10:01:36 +00:00
Author
Owner

Shipped. Merged to main at 8504a2b (phase C3).

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 [`8504a2b`](https://git.aiko.works/aiko/denpa/commit/8504a2b) (phase C3). 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:58 +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#32
No description provided.