FE-1574: Let Voice and the typed panel share one canonical Brunch conversation - #9528
FE-1574: Let Voice and the typed panel share one canonical Brunch conversation#9528lunelson wants to merge 125 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
b0cdf33 to
07ed83f
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Update the living Petrinaut integration spec and topology reference to name the guarded Flue conversation mount, browser ChatTransport projection, and current transport-package dependency boundary. Narrow the Mission 5 route scan claim to its actual production paths while preserving superseded /api/chat references as historical provenance.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n projections Review of PR #9528 read the ten bot findings as samples of three faults. Stop was not bound to the turn it targeted: a Stop during an in-flight admission now waits for that admission before the conversation-wide abort, a durable Stop result that lands after a newer turn started is ignored, and a Voice turn whose submission settles aborted closes its Realtime call without speaking, driven by Flue's settlement index because the AI SDK reports ready between a step and its automatic client-tool follow-up. The live-stream and snapshot projections encoded the same rules twice: the snapshot keeps providerExecuted on a running server tool and folds a client-tool continuation into the assistant message it resumed; the transport reports the resumed assistant id and the tracker keeps the originating submission for it; one client-tool catalog feeds the panel transport and the history projection; host history hydrates only once it carries every locally streamed reply. A retained Voice input can be withdrawn through an AbortSignal, a consumer stream cancel no longer writes to a closed controller, and first-canonical-text latency is recorded when the first completed text block appears. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A client-tool continuation is projected onto the assistant message it resumes, so one message can be written by several submissions, and either side may have admitted the continuation: the panel after a readPetrinautDoc call, or Voice when it answers a pending brunch_ask. Keeping only the first submission served one direction and broke the other. The tracker now records every submission per message, segments carry that set, and the bridge matches by membership while excluding segments that predate the answer. The Stop generation guard also covers the rejection path, so a durable Stop that fails after a newer turn started no longer records an error on that turn. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6ca81b7 to
eecbe99
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit eecbe99. Configure here.
| const [result] = await Promise.race([ | ||
| Promise.all([submitVoiceInput(input), admissionObserved]), | ||
| cancelled, | ||
| ]); |
There was a problem hiding this comment.
Abort race leaves unhandled rejection
Medium Severity
submitVoiceInputWithAdmission races Promise.all against the input AbortSignal. When abort wins, the composer promise can still reject afterward, and that rejection is no longer awaited. Withdrawing a queued Voice answer therefore surfaces an unhandled rejection even though the helper already failed the caller.
Reviewed by Cursor Bugbot for commit eecbe99. Configure here.
| } | ||
| hydratedConversationIdRef.current = conversationId; | ||
| setMessages(aiAssistant.messages); | ||
| }, [aiAssistant.messages, conversationId, messages, setMessages, status]); |
There was a problem hiding this comment.
Empty snapshot can latch hydration
Medium Severity
The panel treats a host message list of [] as complete history and latches hydratedConversationIdRef. useFlueChatHistory publishes that empty list whenever observation is absent. If Flue later becomes live with the real conversation, hydration never runs again, so reopen can show a blank panel instead of the canonical transcript.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit eecbe99. Configure here.


🌟 What is the purpose of this PR?
Make the mounted Flue conversation route the only product door into a Brunch conversation, and make Voice a faithful audio projection of that one canonical conversation. In the Petrinaut Brunch panel you can type or speak to Brunch in one conversation; what you hear is exactly what Brunch wrote; Stop asks Brunch to stop rather than only hiding its answer; and reopening the panel shows the same conversation without re-sending or replaying anything.
The production throughline is the local Petrinaut surface under
yarn dev:brunch: the typed panel'suseChatand the Voice control both reach one browser Flue client at the same-origin-proxied/agents/chat/:instanceIdroute, behind the single ownership guard, into the mountedChatAgent. The former Brunch/api/chathandler, projector, and proxy are deleted.What the proof establishes: one typed turn and one finalized spoken turn each cross the Flue route exactly once, the typed turn returns as one finite AI SDK stream, the Voice turn returns as visible text and TTS of the same canonical words, Stop uses Flue's durable abort, and reopen rehydrates from Flue observation. What it does not claim: trusted production authentication or authorization, remote deployment, structured
brunch_askquestions (that capability stays suspended), Petrinaut mutation tools, or product acceptance. Acceptance waits on the human Voice witness in proof leaf 8.🔗 Related links
libs/@hashintel/brunch-agent/MISSION.mddocs/evidence/implementations/mission-5-direct-voice-flue/README.md🚫 Blocked by
mainincludes Mission 4's commits until FE-1563: Establish Brunch elicitation skill boundaries #9468 merges or this PR is rebased onto it.🔍 What does this change?
@hashintel/brunch-agent-transport-aisdkbecomes the browser-side adapter over public@flue/sdk: aChatTransportthat admits onesend()per turn, projects thewait()event stream intoUIMessageChunks, and projects Flue history snapshots into AI SDK messages. It no longer contains a server handler./api/chat; the local launcher proxies/agents/chat/*instead. Ownership is derived from principal plus logical conversation id in one guard.requestStop.submissionId, and speaks exactly the canonical text.requestStopso Stop can become durable before the local stream is cancelled (changeset:.changeset/durable-brunch-stop.md).flue.tsentrypoints; YAML plugin machinery is removed.🏗️ Agent notes
Mission authority is
libs/@hashintel/brunch-agent/MISSION.md(live as of 2026-09-03). The six sections, condensed:Imperative. One Flue route is the only product door into a Brunch conversation; Voice is an audio projection of that conversation. Now, because Mission 4 fixed the canonical agent composition while two transports still reached the same conversation.
Throughline. Petrinaut typed panel (
useChatwith a host-supplied browserChatTransport) and Petrinaut microphone (OpenAI Realtime, one validated finalizedcontinue_interviewanswer) → one browsercreateFlueClient()per principal + logical conversation id → onesend()at the same-origin-proxied/agents/chat/:instanceId→agentOwnershipGuard→createAgentRouter(ChatAgent)withuseBrunchAgent()+useSdcpnPlugin()→wait(admission, { onEvent })for the finite per-turn stream,observe({ live: "sse" })for canonical state and reopen → parts correlated bysubmissionId→ panel projector terminated onsubmission-settled; Voice speaks canonical completed text unchanged.Proof. Nine leaves with named oracles: (1) typed panel over the browser transport, (2) direct finalized admission, (3) no server-side AI SDK door, (4) canonical visible and TTS output, (5) cancellation and abort stay distinct, (6) reopen resumes without replay, (7) architecture and boundary integrity, (8) real Voice witness and retained proof bundle, (9) focused repository verification and truthful docs. Leaves 1 to 7 and 9 have automated evidence recorded in the evidence README; leaf 8 is open.
Constraints. Flue history is the sole canonical record;
@flue/sdkis used directly with no re-specified offsets, retries, or recovery; one product route; the panel stays onuseChat; only the validated finalized answer is submitted and never automatically retried; Brunch owns response content; the four cancellation kinds stay distinguishable; the local principal is a discriminator, not authentication; the transport package depends only onaiand@flue/sdk;brunch_askis not mounted.Fog-line. How Voice enters Flue (shape B, via the panel transport, is what shipped); the home of the identity and header contract; whether
wait()alone gives a clean per-turn stream or reconnect needsobserve(); whetherreconnectToStreamresumes; which parts of #9496/#9507/#9512 to port; speech selection for multi-block responses; whether one Stop affordance can express local interruption and durable abort; whether the same-origin proxy carries every SDK route.Stop or reorient. Stop on a second conversation authority, any non-Flue turn route, provisional STT submission, automatic retry of ambiguous admission, canonical text rewritten by another model, hand-rolled recovery, restored stub agent, or activated
brunch_ask; stop if client-tool resume semantics need a server helper; stop if local cancellation aborts durable work or Stop only cancels the browser request.Deferred items are recorded in
MISSION.md§Deferred andMISSION.next.md.Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
This PR:
@hashintel/petrinaut:.changeset/durable-brunch-stop.mdand.changeset/voice-input-withdrawal-and-stop-correlation.md; the@hashintel/brunch-agent*packages are private)📜 Does this require a change to the docs?
The changes in this PR:
libs/@hashintel/petrinaut/docs/ai-assistant.md, Brunch evidence and mission records)🕸️ Does this require a change to the Turbo Graph?
The changes in this PR:
turbo.json's have been updated to reflect this (plugin packages gainedbuild,lint:*, andtest:unitscripts;apps/brunch-agent, core, and plugin-dafnyturbo.jsonupdated)The ten review-bot findings on this PR were read as samples of three underlying faults and all are addressed in the review-fix commit, with a failing-first test for each:
send()was still in flight now waits for that admission before Flue's conversation-wide abort, soaborted: falseno longer reads as "already settled" while the admitted turn keeps running. A durable Stop result or failure that lands after a newer turn started is ignored. A Voice turn whose submission settlesabortedcloses its Realtime function call without speaking, driven by Flue's settlement index rather than chat status, because the AI SDK reportsreadybetween a step and its automatic client-tool follow-up.providerExecutedon a server tool that is still running and folds a client-tool continuation back into the assistant message it resumed, matching the live stream. The transport reports the resumed assistant id to the host, and the tracker records every submission that wrote a message, so Voice correlates by membership and speaks a reply whether the continuation was admitted by the panel after areadPetrinautDoccall or by Voice answering a pendingbrunch_ask. One catalog (brunch-client-tools.ts) now feeds the panel transport and the history projection, and a test asserts every registered interactive widget answers a tool in it. Host-owned history is hydrated only once the snapshot carries every locally streamed reply.AbortSignal; Petrinaut drops it from the queue and rejects it when the signal fires.stream.cancel()no longer enqueues on a closed controller, andfirst-canonical-textlatency is recorded when the first completed canonical text block appears rather than at settlement.brunch_askremains suspended on the server. Its browser paths are now consistent but carried as latent code, not as a claim.🐾 Next steps
🛡 What tests cover this?
libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts,transcript.test.ts,ui-stream.test.tsapps/petrinaut-website/src/main/app/voice-interview/realtime-brunch-bridge.test.ts,voice-interview-control.test.tsx,voice-turn-controller.test.ts,canonical-speech.test.tsapps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts,local-storage-demo-app.test.tsxlibs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsxapps/brunch-agentboundary and ownership testsyarn exec turbo run lint:tsc lint:eslint test:unit build --filter @apps/brunch-agent --filter @apps/petrinaut-website --filter @hashintel/petrinaut --filter @hashintel/brunch-agent --filter @hashintel/brunch-agent-plugin-sdcpn --filter @hashintel/brunch-agent-transport-aisdk❓ How to test this?
yarn dev:brunchand open the Petrinaut Brunch preview.📹 Demo
Pending the proof leaf 8 witness recording.