fix(chat): exempt a note breadcrumb from the turn-start voice and status effects - #4366
Conversation
|
👋 Hi! This PR is currently in draft status. Workflow runs won't be auto-approved until it's marked as ready for review. When you're ready, click "Ready for review" and the workflows will be approved on the next cycle automatically. |
2 similar comments
|
👋 Hi! This PR is currently in draft status. Workflow runs won't be auto-approved until it's marked as ready for review. When you're ready, click "Ready for review" and the workflows will be approved on the next cycle automatically. |
|
👋 Hi! This PR is currently in draft status. Workflow runs won't be auto-approved until it's marked as ready for review. When you're ready, click "Ready for review" and the workflows will be approved on the next cycle automatically. |
18e7c57 to
ac69aba
Compare
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
UX Review (Fable 5, fork) — ✅ PASSUX-level review of The diff is a narrow behavioral fix: a passive UX-Verdict: PASS Notes no longer hijack speech or pin a phantom "Thinking…" on the slot; the fix is invisible-when-right, with both directions pinned by tests. [UX-REVIEWED] b882205 |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, scoped guard — but its wire contract lives in an unmerged PR and rides a field this codebase has already found unreliable. Watch
[DESIGN-REVIEWED] b882205 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe diff is small and self-contained. The recording ( No findings. [OPUS-REVIEWED] b882205 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All evidence gathered. Key facts: First-Principles-Verdict: CONCERNS The guard defends against a frame nothing in this tree emits — grep What this change shipsIntent: keep a posted note from cutting text-to-speech and stranding a "Thinking…" spinner. Framed as a FIX, but at base no producer of these frames exists, so it is an ADDITION staged ahead of one.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] b882205 |
ac69aba to
0569f38
Compare
0569f38 to
5fe9825
Compare
63d724c to
64349eb
Compare
64349eb to
740b208
Compare
740b208 to
1e78057
Compare
1e78057 to
553e089
Compare
553e089 to
32f49d0
Compare
…tus effects A note (role=inject, cls=reconcile-note) starts no turn, so nothing follows to undo the stopped speech or the "Thinking..." status; the recency bump is left in place.
Problem / Motivation
A note breadcrumb arrives over the websocket as a
chat_messagewithrole: 'inject'and aclscarryingreconcile-note. It records a visible row on the transcript, but unlike a queued prompt it does not start an agent turn — so nochat_doneframe ever follows it.Two effects in the
chat_messagehandler inwebsite/src/hooks/useWebSocket.tsfire for every inbound prompt role (user,inject,subagent) on the assumption that a turn is beginning, and both are meant to be undone by turn completion:stopVoice()runs, the voice progress ref is cleared and the synthesis chain is reset. This is right for a real prompt — you interrupted the agent, so stop reading its previous reply aloud.setSlotStatusDetail. Only turn completion clears it.Applied to a note, both are wrong, and nothing arrives to repair either one.
Why it matters
This becomes user-visible the moment something starts emitting those rows. #3248 (
POST /api/chat/slots/{slot}/note) is the change that does exactly that: its visible half appendsrole="inject"with thereconcile-noteclass. Once it merges, every note posted while the agent is being read aloud would cut text-to-speech off mid-sentence, and would leave a "Thinking…" status pinned to the slot indefinitely — a spinner for a turn that was never running. The guard needs to be in place before or alongside #3248 rather than after it, so there is no window where notes strand the UI.What changed
Three files.
website/src/lib/noteContract.ts(new) owns the wire contract:RECONCILE_NOTE_CLSholds the class name, andisReconcileNote(cls)answers whether a frame carries it. The token lives in exactly one place in this tree, so there is one spelling to change rather than several to keep in sync.website/src/hooks/useWebSocket.tsgates the two turn-scoped effects on that predicate:Why membership and not string equality.
clson a chat message is a space-separated class list —msg msg-aandmsg msg-a crew-replyare ordinary values, and other consumers in the tree already match a single class with a whitespace-bounded test rather than comparing the whole string. To be clear about the strength of this: #3248's producer currently emits the token on its own, so exact equality would match today too. Membership is defensive rather than load-bearing — it means the guard keeps working if the token is later emitted alongside other classes, which would otherwise silently disable it while every test here still passed.isReconcileNotesplits on whitespace, so a longer class name that merely contains the token (reconcile-note-draft) is correctly treated as a different class.Deliberately not changed: the slot-recency bump a few lines above already includes
injectand is left exactly as it is. A note is activity on the slot and should move it up the ordering, and it stays a visible transcript row. The exemption is scoped strictly to the two effects that depend on a turn completing.Tests
website/src/test/useWebSocket.passiveNote.test.ts— 6 tests, using the existingrenderHook+ mock-websocket + real-store harness already used by the otheruseWebSocket.*tests. Every frame is built fromRECONCILE_NOTE_CLSrather than a retyped string, so a frame can never drift from the value the guard accepts.stopVoice()is a hook-local callback with nothing to spy on, so it is observed through the one store write it makes:setVoicePlaying(false). Inside thechat_messagecase that dispatch has no other caller, and thesseChatMessagereducer never writesvoicePlaying— so atrue -> falseflip is attributable tostopVoice()having run. The status effect is read directly offslotStatusDetail[slot].kind.What the tests pin:
role: 'inject'with no class still stops speech and still setsthinking— the behaviour that was already correct and must not regress;msg reconcile-note) is still treated as a note;msg reconcile-note-draft) is not treated as a note, so the widening is membership and not substring matching;Since the frames build from the constant, the membership rule was verified by perturbing the guard directly: with the constant in place but the comparison left as exact equality, the wrapped-class test fails with
expected false to be true(speech was cut), and it passes once the predicate tests membership. The substring case stays failing-as-expected in both, which is what rules out an over-broadincludes.Suite on this branch: 20 test files, 207 tests, 0 failures across the touched file and every
useWebSocket.*suite (the hook is shared, so that scope includes the thinking-status and auto-speak suites).tsc --noEmitclean. ESLint on the touched files reports 4 warnings, all pre-existing — the identical set appears on themainbaseline (no-consolex3 and onereact-hooks/exhaustive-deps).Note on an earlier revision of this description
An earlier version of this description argued for keeping the class name written inline at the call site and repeated in the test frames, on the grounds that repeating it acted as a tripwire. That reasoning has been superseded and the paragraph making it is gone: the change now centralises the token in
noteContract.tsand the tests import it, with an explicit assertion on the literal value serving the tripwire role instead. That earlier text also described a single-file change with three tests, which no longer matches what ships. Anything still reading as an argument against the shared constant is stale wording, not the current design.