自発送信 — she speaks first #32
Labels
No labels
needs-info
needs-triage
ready-for-agent
ready-for-human
v2
wayfinder:grilling
wayfinder:map
wayfinder:prototype
wayfinder:research
wayfinder:task
wayfinder:ticket
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
aiko/denpa#32
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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>andwebhook:<route>. Idle nudges are out of scope - Hermes has no such trigger.Beyond cron, the
send_messagetool 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, marked12x.An unattributed turn is possible: a standalone
hermes cron tickwith the gateway down carries no id. Degrade to a generic break-in, never drop the turn.Acceptance criteria
send_message-initiated turn renders without a job id and is not mistaken for an errorReference
ADR-0005, 0006, 0007.
Branch:
issue/denpa-32-proactive-break-in(offmainat8237457)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#19made for 承認待ち. 自発 is a cause, not anAiStateEnummember.invocation-line.ts—turnCause(aiState, latest)(replacing the duplicatedcauseOfin both pal containers) plus the pending count, since the pal has no band.context/unprompted-context.tsx— one owner, one queue, one subscription, mounted insideApprovalProviderso the gate can see an ask.Tests — 560 passing in 40 files (
mainwas 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 ofapproval-wiring.test.ts— including thatfrontend-playback-completestill 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.speakcarriestextalone (hermes_plugin/denpa/protocol.py:45),handle_frameforwards only that (adapter_handler.py:350-352),enqueue_turnqueues a bare string (websocket_handler.py:243-251). So the message decoded here is one I named, the way#27namedupstream-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_COMPLETEgoes up to the adapter withduration_msandgate(websocket_handler.py:288-294); it never reaches a viewer. No second2 x audio lengthtimer was added client-side — a timer that can disagree with the authoritative one is worse than none.Left out / assumed
document.hiddenat arrival: visible → names its trigger, no band ever; hidden → queues a band that is there when you come back.自発 2件 · 留守中に話してたreconnect variant, so it is not built.denpa#35: the gate readsAiStateEnum.LISTENING, notMicVAD— Rust capture must keep settingLISTENINGwhen it opens the mic.Nine entries in
.denpa-work/QUESTIONS.md.npm run typecheckreports the same 585 pre-existing errors, none naming a file this branch touches.Review-fix pass on
issue/denpa-32-proactive-break-in(client) plus a companion branch on the go-between,issue/denpa-32-unprompted-attributioninaiko/ollvt-hermes-bridge. Both must merge together — the client half is unreachable without the wire half.The blocker:
unprompted-turnhad no producer. Confirmed:denpa.speakcarriedtextalone, andenqueue_turnforwarded 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-onlyhermes-agent:DeliveryRouter._deliver_to_platformdropsjob_id(gateway/delivery.py:365) andsend()'smetadatacarries only thread/user ids (gateway/run.py:7511-7520).cron.wrap_responsewrites around a cron delivery (cron/scheduler.py:1497-1506, default on). The adapter parses it, emitstriggeron 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.{"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.gateway/platforms/webhook.py:1406-1412never names the route. Those staytrigger: null, which the band already draws as a nameless break-in. Recorded in the ledger.countleft 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:
unpromptedSuspended), and the draw refuses to show both. The render test fails against the old draw.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.One finding skipped. The claim that
avatarVisible: !document.hiddenmakes the pal's件count dead rested on the pal and overlay being "separate always-present webviews". They are not —App.tsx:112-134renders the chrome, the desktop pal and the Android overlay from one webview underuseMode, with a singleUnpromptedProviderabove 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 checkclean. Assumptions and the two unbuildable pieces are in.denpa-work/QUESTIONS.md.Proactive break-in — landed on two branches
Client:
issue/denpa-32-proactive-break-ininaiko/denpa, pushed.Go-between:
issue/denpa-32-unprompted-attributioninaiko/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, anUnpromptedProvidermounted once above the mode branch inApp.tsx,useUnpromptedinhooks/receiver/, aBreakInBandat the top of the 1a stage (rendered after the 画面共有 indicator so a live-capture warning is never covered), a 自発 treatment besidefaceplateTreatments, andturnCauseon the invocation line for both pals, which replaced a duplicatedcauseOf. Trigger attribution renders as the job name; coalescing is per job id and draws12x.Go-between side: the adapter reads the job id out of the header Hermes'
cron.wrap_responseputs around a cron delivery, strips the wrapper so she stops reading the job id aloud, and emitstriggerondenpa.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
8237457was 511 in 37). New coverage is the reducer's gate (queue, drain, coalesce-per-job-id, dismissal, the armed rule), trigger parsing and refusal,renderToStaticMarkupassertions on the band and the invocation line, and source-reading wiring tests in the established shape ofapproval-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 — greppingnpm run typecheckoutput for every touched path returns nothing against the documented 585-error baseline.Wire claims, each verified against source
denpa.speakcarriedtextand nothing else before this branch — no trigger, no count:denpa/hermes_plugin/denpa/protocol.py:45and its only consumer atdenpa/src/open_llm_vtuber/adapter_handler.py:350-352.enqueue_turnqueued a bare string per device, so nothing about a turn's cause survived to the client:websocket_handler.py:243-251."type":literal outsidedenpa.*insrc/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-turnis a name this branch defines.job_idsurvives into a platform'ssend()in exactly one place — the header fromhermes-agent/cron/scheduler.py:1497-1506.gateway/delivery.py:365drops it, andsend()'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.gateway/platforms/webhook.py:1406-1412never names the route. They staytrigger: nulland draw as a nameless break-in.protocol.FRAME_TURN_COMPLETEis adapter-bound (up), carriesduration_msandgate, and is never forwarded to a viewer:protocol.py:62,websocket_handler.py:288-294.conversations/conversation_utils.py:129-143.hooks/utils/use-audio-task.ts:228sendsfrontend-playback-completeafter the queue empties. It is accepted but unhandled on the viewer route, explicitly exempted from the unknown-message warning:websocket_handler.py:357.12xcoalescing, 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-requestand are answered by choice withapproval_id, ownership keyed onclient_uid:adapter_handler.py:59-63,:303-326. Used for the precedence rule.device_id; a body-supplied one is ignored at the router:websocket_handler.py:349-353.Deliberately left out
No
ai-speak-signaland no client-side trigger of any kind — the hook sends nothing, and a wiring test asserts the absence of bothsendMessageand that string. No client timer or schedule (ADR-0007), including no client-side "twice the audio length" fallback: the authoritative one lives infinalize_conversation_turn, and a second timer that can disagree with it is worse than none.countwas 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'sinboundentrance, 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 asrole="status"/aria-live, untested with an actual AT.document.hiddenbehaviour 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 as12x, are verified only at the reducer, adapter and component level.I did not run the
caveman:cavecrew-revieweragent 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
{"type": "unprompted-turn", "trigger": "cron:<job_id>"|null}, following #27's precedent forupstream-status. Both halves are on my branches, so they agree; if the wire should look different, it is one constant and one field read.document.hiddenat 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.speakingleaving 送信中 (the audio queue emptying) stands in for playback-complete rather than a second timer.useApproval()from #19.AiStateEnummember, mirroring #19'sAPPROVAL_TREATMENT.12x(ADR-0007 and the issue) and the pending count as2件, the design's own dark-reconnect idiom rather than #19'sあと…件待ち. An unattributed break-in draws no trailing text rather than an invented 不明.LISTENING), without which a cron turn from earlier in the session would be named as the cause of a turn the operator spoke.0e91bd9whilemainwas already at8237457. Measuring beforegit checkout -b … maingives 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.LISTENINGrather thanMicVAD, so Rust capture must keep settingLISTENINGwhen 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
armedflag; two new tests), approval precedence enforced only at the drain and not the draw (addedunpromptedSuspendedandshowBreakIn; the new render test was confirmed to fail against the old draw),latestsurviving a text send (dismissal moved intouseTextInput.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 unusedMeterKindmember and the duplicated件constant.One was skipped:
avatarVisible: !document.hiddenmakes the pal count dead. The premise is false — the pal and the Android overlay are not separate webviews.App.tsx:112-134renders chrome, desktop pal and overlay from one webview underuseMode, with a singleUnpromptedProviderabove 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.
Shipped. Merged to
mainat8504a2b(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.