Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
40ce639
Authorize Voice safety on the unified Flue route
kostandinang Sep 3, 2026
46616ca
Pin unified Voice safety behavior
kostandinang Sep 3, 2026
0c31c7a
Harden Voice turns on the unified Flue route
kostandinang Sep 3, 2026
eb05c5d
Forward Voice actions through the panel
kostandinang Sep 3, 2026
6ae1a96
Reject transcripts completed after output starts
kostandinang Sep 3, 2026
8ef874f
Preserve Flue admission outcomes in Voice mode
kostandinang Sep 3, 2026
bc42f47
Record the Repeat question marker blocker
kostandinang Sep 3, 2026
c4269a0
Keep Repeat question unavailable without a marker
kostandinang Sep 3, 2026
8818610
Enforce the Voice transcript boundary
kostandinang Sep 3, 2026
6180466
Prove dormant ask and Voice provenance on reopen
kostandinang Sep 3, 2026
9e74e7f
Prove durable Stop through the Flue host seam
kostandinang Sep 3, 2026
81433bc
Reconcile the Voice safety acceptance record
kostandinang Sep 3, 2026
409df13
Clarify canonical speech turn ownership
kostandinang Sep 4, 2026
012333d
Invalidate input before canonical speech
kostandinang Sep 4, 2026
3707aee
Prove Voice admission outcomes through the panel route
kostandinang Sep 4, 2026
8b3c0ae
Complete acknowledged Voice turn handoff
kostandinang Sep 4, 2026
27211aa
Record Voice race verification
kostandinang Sep 4, 2026
b5b28aa
Authorize exact Voice question markers
kostandinang Sep 4, 2026
037ed6c
Add exact Brunch question markers
kostandinang Sep 4, 2026
9c6b75f
Preserve hidden question markers across Flue
kostandinang Sep 4, 2026
d1df44a
Select exact Brunch questions for Voice replay
kostandinang Sep 4, 2026
a6fbe2c
Replay exact Brunch questions in Voice
kostandinang Sep 4, 2026
1723a10
Record exact Voice question replay
kostandinang Sep 4, 2026
1e7e162
Satisfy Voice replay lint checks
kostandinang Sep 4, 2026
6826f9b
Refresh Mission 5 acceptance records
kostandinang Sep 4, 2026
c662091
Remove Brunch status from the composer
kostandinang Sep 4, 2026
6aee82d
Reconcile Voice safety with the latest Flue parent
kostandinang Sep 4, 2026
2753ece
Remove obsolete status dependencies
kostandinang Sep 4, 2026
d75bba0
Type Brunch question marker mocks
kostandinang Sep 4, 2026
062e8e6
Restore the Voice witness and latency authority
kostandinang Sep 4, 2026
7651791
Prepare the final Voice acceptance evidence
kostandinang Sep 4, 2026
1c78352
Adapt Voice host tests to parent navigation
kostandinang Sep 4, 2026
c7fbc8d
Record the final Voice restack verification
kostandinang Sep 4, 2026
75af66c
Record Voice launcher remediation side quest
kostandinang Sep 4, 2026
6cfac4d
Preserve Voice APIs in Brunch launcher
kostandinang Sep 4, 2026
d72629f
Document compact Voice consent card
kostandinang Sep 4, 2026
a35b2b3
Make Voice launcher test CI-hermetic
kostandinang Sep 4, 2026
22b4183
Plan compact Voice consent card
kostandinang Sep 4, 2026
50dbe2f
Polish Voice consent setup
kostandinang Sep 4, 2026
e04fa7a
Handle unavailable microphone checks
kostandinang Sep 4, 2026
64b9b7b
Fix Voice verification errors
kostandinang Sep 4, 2026
a69da9f
Format Voice consent implementation plan
kostandinang Sep 4, 2026
4071bc8
Remove compact Voice planning artifacts
kostandinang Sep 4, 2026
17a7d0d
Prebuild Brunch dependencies before local development
kostandinang Sep 4, 2026
063e569
Authorize bounded Petrinaut error and transcript UX work
kostandinang Sep 4, 2026
bf4e253
Show live Voice transcripts and complete errors
kostandinang Sep 4, 2026
3ff5167
Authorize compact initial Voice setup
kostandinang Sep 4, 2026
bf10fcc
Open initial Voice setup in compact mode
kostandinang Sep 4, 2026
ade84c2
Authorize closing collapsed Voice with the panel
kostandinang Sep 4, 2026
b3ffe1b
Close the panel when collapsed Voice ends
kostandinang Sep 4, 2026
13b9bc2
Authorize committed canonical speech before settlement
kostandinang Sep 4, 2026
c7af66f
Start canonical Voice speech before settlement
kostandinang Sep 4, 2026
ea8cde7
Document Voice turn settlement reconciliation
kostandinang Sep 6, 2026
01e99fa
Reconcile Voice response settlement with playback
kostandinang Sep 6, 2026
b1dedc8
Use submitting input and unconditional speech-cancelled restore
kostandinang Sep 6, 2026
56ea1d7
Keep capture closed for queued Voice speech
kostandinang Sep 6, 2026
c6307a7
Remove generated superpowers docs from Voice settlement PR
kostandinang Sep 6, 2026
aea071f
Preserve queued Voice output ownership
kostandinang Sep 6, 2026
c937778
Format Voice output ownership guard
kostandinang Sep 6, 2026
8ac0247
Preserve Voice ownership through cancellation
kostandinang Sep 6, 2026
2c94940
Avoid waiting for idle Voice output clear
kostandinang Sep 6, 2026
db8184b
Prevent repeated Voice output cancellation
kostandinang Sep 7, 2026
b66869f
Resolve Voice panel restack imports
kostandinang Sep 7, 2026
be56a18
Refresh Voice restack evidence
kostandinang Sep 7, 2026
9415e1b
Release silent Voice turn ownership
kostandinang Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/flue-voice-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@hashintel/petrinaut": patch
---

Add half-duplex Voice handoff, exact full-response replay, recoverable transcript notices, and multi-origin Voice attribution for client-tool results.
Voice transcripts now appear live with a dock-only collapse mode, while assistant and tool failures retain and display their complete details in persistent, copyable notifications and inline tool cards.
Voice selected from the first-run prompt now opens with its consent and microphone card above a compact setup dock, while Voice started from the composer retains the full panel. Collapse controls retain ghost-button styling in every state.
Ending Voice from the compact dock closes the AI panel instead of reopening its text composer.
28 changes: 25 additions & 3 deletions apps/brunch-agent/petrinaut-local.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,34 @@

import { join, resolve } from "node:path";

import { defineConfig, loadConfigFromFile, mergeConfig } from "vite";
import {
defineConfig,
loadConfigFromFile,
mergeConfig,
type UserConfig,
} from "vite";

import {
defaultChatOrigin,
petrinautLocalServer,
} from "./src/http/local-origins.ts";

interface PetrinautPanelConfigOptions {
readonly chatOrigin: string;
readonly loadedConfig: UserConfig;
readonly root: string;
}

export const mergePetrinautPanelConfig = ({
chatOrigin,
loadedConfig,
root,
}: PetrinautPanelConfigOptions): UserConfig =>
mergeConfig(loadedConfig, {
root,
server: petrinautLocalServer(chatOrigin),
});

export default defineConfig(async (environment) => {
const websiteRoot = process.env.PETRINAUT_WEBSITE_ROOT;
if (!websiteRoot) {
Expand All @@ -36,8 +57,9 @@ export default defineConfig(async (environment) => {
throw new Error(`Could not load Petrinaut's Vite config from ${root}.`);

const chatOrigin = process.env.BRUNCH_CHAT_ORIGIN ?? defaultChatOrigin;
return mergeConfig(loaded.config, {
return mergePetrinautPanelConfig({
chatOrigin,
loadedConfig: loaded.config,
root,
server: petrinautLocalServer(chatOrigin),
});
});
19 changes: 18 additions & 1 deletion apps/brunch-agent/test/local-dev-origins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";

import { expect, test } from "vitest";

import { mergePetrinautPanelConfig } from "../petrinaut-local.vite.config.ts";
import {
defaultChatOrigin,
localChatListen,
Expand All @@ -21,7 +22,7 @@ test("one documented root command starts the Brunch server and Petrinaut panel",
};

expect(rootPackage.scripts["dev:brunch"]).toBe(
"CARGO_TERM_PROGRESS_WHEN=never turbo run build --filter '@apps/petrinaut-website^...' && npm-run-all --parallel dev:brunch:server dev:brunch:panel",
"CARGO_TERM_PROGRESS_WHEN=never turbo run build --filter '@apps/brunch-agent^...' --filter '@apps/petrinaut-website^...' && npm-run-all --parallel dev:brunch:server dev:brunch:panel",
);
expect(rootPackage.scripts["dev:brunch:server"]).toBe(
"yarn workspace @apps/brunch-agent dev",
Expand Down Expand Up @@ -64,3 +65,19 @@ test("petrinaut:dev proxies the mounted Flue conversation route", () => {
'VITE_BRUNCH_CHAT_ENDPOINT ??= "/agents/chat"',
);
});

test("petrinaut:dev retains the website API handlers needed by Voice", () => {
const config = mergePetrinautPanelConfig({
chatOrigin: defaultChatOrigin,
loadedConfig: {
plugins: [{ name: "petrinaut-api-dev" }],
},
root: "/test/petrinaut-website",
});

expect(config.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "petrinaut-api-dev" }),
]),
);
});
6 changes: 6 additions & 0 deletions apps/brunch-agent/test/petrinaut-chat-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface PetrinautChatResult {
readonly resumedStatus: number;
readonly resumedText: string;
readonly resumedFinish: UIMessageChunk | undefined;
readonly questionMarkerLive: unknown;
readonly questionMarkerHistory: unknown;
readonly questionToolVisibleLive: boolean;
readonly questionToolVisibleHistory: boolean;
readonly historyUserEntryCount: number;
readonly historyClientToolResultCount: number;
readonly historyGetStatus: number;
Expand Down Expand Up @@ -51,5 +55,7 @@ export interface PetrinautChatResult {
export interface PetrinautResumeResult {
readonly historyGetStatus: number;
readonly historyUserText: string;
readonly questionMarkerHistory: unknown;
readonly questionToolVisibleHistory: boolean;
readonly transcript: string;
}
61 changes: 59 additions & 2 deletions apps/brunch-agent/test/petrinaut-chat.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
snapshotToUiMessages,
} from "@hashintel/brunch-agent-transport-aisdk";
import { ELICITATION_SKILL_NAME } from "@hashintel/brunch-agent/flue";
import {
BRUNCH_QUESTION_DATA_NAME,
BRUNCH_QUESTION_TOOL_NAME,
} from "@hashintel/brunch-agent/question-marker";

import { PING_TOOL_NAME } from "../src/agents/chat-agent/tools/ping.ts";
import { applyCaptureSweep } from "../src/capture/apply-sweep.ts";
Expand All @@ -43,6 +47,7 @@ const ACTIVATE_SKILL_TOOL_NAME = "activate_skill";
const CHAT_MODEL_ID = "claude-haiku-4-5";
const RUNBOOK_SKILL_NAME = "sdcpn-modelling";
const READ_SKILL_RESOURCE_TOOL_NAME = "read_skill_resource";
const question = "Which documentation page should we inspect next?";

const principalKey = "principal-mission-1";
const conversationId = "conversation-mission-1";
Expand Down Expand Up @@ -79,6 +84,35 @@ const userTextFromHistory = (
.map((part) => part.text)
.join("");

const questionMarkerFromHistory = (
messages: ReturnType<typeof snapshotToUiMessages>,
): unknown => {
const marker = messages
.flatMap((message) => message.parts)
.find(
(part) =>
part.type === `data-${BRUNCH_QUESTION_DATA_NAME}` && "data" in part,
);
return marker !== undefined && "data" in marker ? marker.data : undefined;
};

const questionMarkerFromChunks = (
chunks: readonly UIMessageChunk[],
): unknown => {
const marker = chunks.find(
(chunk) =>
chunk.type === `data-${BRUNCH_QUESTION_DATA_NAME}` && "data" in chunk,
);
return marker !== undefined && "data" in marker ? marker.data : undefined;
};

const questionToolVisibleInHistory = (
messages: ReturnType<typeof snapshotToUiMessages>,
): boolean =>
messages
.flatMap((message) => message.parts)
.some((part) => part.type === `tool-${BRUNCH_QUESTION_TOOL_NAME}`);

const faux = fauxProvider({
provider: "anthropic",
models: [{ id: CHAT_MODEL_ID, reasoning: true }],
Expand All @@ -98,19 +132,24 @@ try {
const panelTransport = createFlueChatTransport({
client: historyClient,
clientToolNames,
hiddenToolNames: new Set([BRUNCH_QUESTION_TOOL_NAME]),
});
const projectHistory = (
snapshot: Awaited<ReturnType<typeof historyClient.history>>,
) =>
snapshotToUiMessages(snapshot, {
clientToolNames,
hiddenToolNames: new Set([BRUNCH_QUESTION_TOOL_NAME]),
});

if (process.env.BRUNCH_RESUME_PHASE === "1") {
const snapshot = await historyClient.history();
const historyMessages = projectHistory(snapshot);
const result: PetrinautResumeResult = {
historyGetStatus: 200,
historyUserText: userTextFromHistory(projectHistory(snapshot)),
historyUserText: userTextFromHistory(historyMessages),
questionMarkerHistory: questionMarkerFromHistory(historyMessages),
questionToolVisibleHistory: questionToolVisibleInHistory(historyMessages),
transcript: formatFlueTranscript(snapshot),
};
process.stdout.write(`PETRINAUT_RESUME_RESULT ${JSON.stringify(result)}\n`);
Expand Down Expand Up @@ -196,9 +235,19 @@ try {
],
{ stopReason: "toolUse" },
),
fauxAssistantMessage(
[
fauxToolCall(
BRUNCH_QUESTION_TOOL_NAME,
{ question },
{ id: "tool-question-1" },
),
],
{ stopReason: "toolUse" },
),
fauxAssistantMessage([
fauxText(
"The guide says the assistant can read its own documentation pages.",
`The guide says the assistant can read its own documentation pages. ${question}`,
),
]),
fauxAssistantMessage([
Expand Down Expand Up @@ -400,6 +449,14 @@ try {
.map((chunk) => chunk.delta)
.join(""),
resumedFinish: resumedChunks.at(-1),
questionMarkerLive: questionMarkerFromChunks(resumedChunks),
questionMarkerHistory: questionMarkerFromHistory(historyMessages),
questionToolVisibleLive: resumedChunks.some(
(chunk) =>
chunk.type === "tool-input-available" &&
chunk.toolName === BRUNCH_QUESTION_TOOL_NAME,
),
questionToolVisibleHistory: questionToolVisibleInHistory(historyMessages),
historyUserEntryCount: userEntryIds.length,
historyClientToolResultCount: clientToolResultCount,
historyGetStatus: 200,
Expand Down
17 changes: 17 additions & 0 deletions apps/brunch-agent/test/petrinaut-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ test("the browser transport streams the mounted Flue agent through server and cl
type: "finish",
finishReason: "stop",
});
expect(result.questionMarkerLive).toEqual({
question: "Which documentation page should we inspect next?",
toolCallId: "tool-question-1",
});
expect(result.questionToolVisibleLive).toBe(false);
expect(result.questionMarkerHistory).toEqual({
question: "Which documentation page should we inspect next?",
toolCallId: "tool-question-1",
});
expect(result.questionToolVisibleHistory).toBe(false);
expect(result.historyUserEntryCount).toBe(1);
expect(result.historyClientToolResultCount).toBe(1);

Expand Down Expand Up @@ -107,6 +117,7 @@ test("the browser transport streams the mounted Flue agent through server and cl
expect(result.interviewerToolNames).toContain("read_skill_resource");
expect(result.interviewerToolNames).toContain("ping");
expect(result.interviewerToolNames).toContain("readPetrinautDoc");
expect(result.interviewerToolNames).toContain("brunch_mark_question");
expect(result.interviewerToolNames).not.toContain("brunch_ask");
expect(result.interviewerToolNames).not.toContain("sweep");
expect(result.interviewerToolNames).not.toContain("brunch_sweep");
Expand Down Expand Up @@ -151,10 +162,16 @@ test("the browser transport streams the mounted Flue agent through server and cl
expect(resumeResult.historyUserText).toContain(
"Run the FE-1435 transport probe.",
);
expect(resumeResult.questionMarkerHistory).toEqual({
question: "Which documentation page should we inspect next?",
toolCallId: "tool-question-1",
});
expect(resumeResult.questionToolVisibleHistory).toBe(false);
expect(resumeResult.transcript).toContain("tool ping");
expect(resumeResult.transcript).toContain("tool readPetrinautDoc");
expect(resumeResult.transcript).toContain("tool activate_skill");
expect(resumeResult.transcript).toContain("tool read_skill_resource");
expect(resumeResult.transcript).toContain("tool brunch_mark_question");
} finally {
await rm(dbDirectory, { recursive: true, force: true });
}
Expand Down
60 changes: 46 additions & 14 deletions apps/petrinaut-website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,20 @@ disclosure before requesting microphone access. The disclosure also provides a
microphone check and is remembered in browser storage only after Voice mode
starts.

When Brunch is selected, typed and finalized spoken turns both enter the same mounted Flue conversation route. **Stop** requests a durable Brunch abort before the panel cancels its local response stream. Closing or speaking over Voice playback only stops local media; it does not alter canonical conversation history. Reopening the same net restores its observed Flue conversation without resubmitting a turn or replaying settled audio.
When Brunch is selected, typed turns and completed Voice transcripts both enter
the same mounted Flue conversation route. Each logical turn carries a stable
delivery key so a replayed request converges on the existing admission instead
of creating another turn. If admission cannot be confirmed, the UI reports the
ambiguity and does not retry automatically. **Stop** requests a durable Brunch
abort before the panel cancels its local response stream. Local playback
cancellation remains separate and does not alter canonical history. Canonical
Flue history is the source used when the same net is reopened. Automated
coverage guards a locally submitted turn from an older hydration snapshot and
does not resubmit turns or replay settled audio. The real hard-reload witness is
still pending, so reload parity is not yet claimed for this preview.
Voice-origin client-tool results retain their markers in Flue history. Direct
spoken user turns remain canonical text, but Flue 2.0.3 does not yet expose the
caller delivery metadata needed to restore their Voice chip after reopening.

An active session stays at the end of the transcript. Its compact divider shows
a waveform and **Connecting**, **Listening**, **Speaking**, **Paused**, or a
Expand All @@ -111,24 +124,43 @@ The text composer remains available. Sending typed text ends Voice mode first,
then submits the draft exactly once through the same conversation; a failed
handoff restores the draft. Closing the assistant pauses capture and speech
before hiding it. Reopening preserves the mounted session in **Paused** state.
**Pause** and **End voice mode** live under **Voice mode actions**, while
**Resume** or **Reconnect** appears as the primary action when applicable.
The dock exposes **Your turn** while canonical audio owns the turn. That action
clears pending input and output, waits for the provider's matching
acknowledgements and response terminal event, and only then opens the
microphone for fresh capture. Its playback menu offers **Repeat question** and
**Read full response**. Full-response replay becomes available once the matching
response and audio output have both finished, enqueues all exact retained
canonical segments in order, and is disabled during capture, submission,
cancellation, pause, and errors. **Repeat question** has the same safety gates
and replays only exact question text carrying Brunch's non-interactive marker;
if the marker is missing, malformed, or does not match finalized prose, the
action stays disabled rather than guessing from the final segment.

The browser sends its SDP offer to this app; the server initializes a trusted
`gpt-realtime-2` audio-input/audio-output session through OpenAI's unified
Realtime call endpoint. The provider key, model, instructions, tools, language,
and vocabulary policy stay server-side. The session uses semantic VAD with low
eagerness so natural thinking pauses are less likely to end an answer early.

Realtime is the disposable media plane: it carries continuous microphone and remote audio, detects complete turns, and handles barge-in. Brunch remains the control plane and sole authority for questions, captures, state, completion, and durable history. The browser bridge accepts only the configured `continue_interview` function, validates and serializes its arguments, rejects duplicate or stale calls, and submits the answer through Petrinaut's shared composer path with pending-question correlation.
Realtime call endpoint. The provider key, model, instructions, language, and
vocabulary policy stay server-side. Realtime exposes no tools, uses
`tool_choice: "none"`, and configures semantic VAD to detect an input boundary
without creating a model response.

Realtime is the disposable media plane: it carries microphone and remote audio,
detects complete turns, and transcribes input. Brunch remains the control plane
and sole authority for questions, captures, state, completion, and durable
history. The bridge accepts only
`conversation.item.input_audio_transcription.completed` as an answer, ignores
model function arguments, and submits the normalized transcript through
Petrinaut's shared composer path. Connection epoch, item id, and content index
form its stable identity. Duplicate, empty, failed, unavailable, and over-limit
transcripts never submit; recoverable failures leave a not-heard or too-long
notice in the dock. Provisional transcription remains display-only.

The bridge waits for the correlated Brunch turn before returning canonical
speech segments to Realtime. It then requests audio with tools disabled and
instructs Realtime to speak only those segments. Generated audio is not a
verbatim record: canonical Brunch text remains visible and authoritative. The
microphone stays active while the interviewer speaks and while Brunch is
working. Speaking over assistant audio interrupts playback automatically;
WebRTC truncates provider-side unheard audio without changing Brunch history.
speech segments to Realtime. It instructs Realtime to speak only those
segments. Generated audio is not a verbatim recording: canonical Brunch text
remains visible and authoritative. Voice is half-duplex: the physical
microphone is closed while the interviewer speaks, while Brunch is working, and
through cancellation. Audio captured before a **Your turn** handoff is
discarded and cannot become a later answer.

The local Brunch preview reaches the mounted route through its same-origin, protocol-preserving proxy; this does not establish remote authentication or public ingress. Denying microphone permission leaves the text composer available and submits nothing to Brunch. When Voice mode cannot continue, the inline recovery state distinguishes microphone, connection, and other Voice failures, explains the next action, and offers **Reconnect** where appropriate. Sanitized error codes and diagnostic references remain collapsed under **Technical details**.

Expand Down
Loading
Loading