Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
f4adfde
Authorize Voice safety on the unified Flue route
kostandinang Sep 3, 2026
988482c
Pin unified Voice safety behavior
kostandinang Sep 3, 2026
4883b21
Harden Voice turns on the unified Flue route
kostandinang Sep 3, 2026
0c61215
Forward Voice actions through the panel
kostandinang Sep 3, 2026
ee7fd81
Reject transcripts completed after output starts
kostandinang Sep 3, 2026
957650a
Preserve Flue admission outcomes in Voice mode
kostandinang Sep 3, 2026
ff1c347
Record the Repeat question marker blocker
kostandinang Sep 3, 2026
0591e46
Keep Repeat question unavailable without a marker
kostandinang Sep 3, 2026
4a98039
Enforce the Voice transcript boundary
kostandinang Sep 3, 2026
dc3f671
Prove dormant ask and Voice provenance on reopen
kostandinang Sep 3, 2026
4342d62
Prove durable Stop through the Flue host seam
kostandinang Sep 3, 2026
f3c2852
Reconcile the Voice safety acceptance record
kostandinang Sep 3, 2026
58053ca
Clarify canonical speech turn ownership
kostandinang Sep 4, 2026
81da046
Invalidate input before canonical speech
kostandinang Sep 4, 2026
128d144
Prove Voice admission outcomes through the panel route
kostandinang Sep 4, 2026
161b789
Complete acknowledged Voice turn handoff
kostandinang Sep 4, 2026
8114136
Record Voice race verification
kostandinang Sep 4, 2026
b176777
Authorize exact Voice question markers
kostandinang Sep 4, 2026
9960494
Add exact Brunch question markers
kostandinang Sep 4, 2026
da30f92
Preserve hidden question markers across Flue
kostandinang Sep 4, 2026
8f76002
Select exact Brunch questions for Voice replay
kostandinang Sep 4, 2026
2d71d3c
Replay exact Brunch questions in Voice
kostandinang Sep 4, 2026
8f0d629
Record exact Voice question replay
kostandinang Sep 4, 2026
54ab6ab
Satisfy Voice replay lint checks
kostandinang Sep 4, 2026
b084546
Refresh Mission 5 acceptance records
kostandinang Sep 4, 2026
f7e0be9
Remove Brunch status from the composer
kostandinang Sep 4, 2026
820cce0
Reconcile Voice safety with the latest Flue parent
kostandinang Sep 4, 2026
5ef5619
Remove obsolete status dependencies
kostandinang Sep 4, 2026
96a4b87
Type Brunch question marker mocks
kostandinang Sep 4, 2026
75fa695
Restore the Voice witness and latency authority
kostandinang Sep 4, 2026
63acc34
Prepare the final Voice acceptance evidence
kostandinang Sep 4, 2026
9938283
Adapt Voice host tests to parent navigation
kostandinang Sep 4, 2026
60d41c5
Record the final Voice restack verification
kostandinang Sep 4, 2026
a04e142
Record Voice launcher remediation side quest
kostandinang Sep 4, 2026
011b8bc
Preserve Voice APIs in Brunch launcher
kostandinang Sep 4, 2026
3f3b106
Document compact Voice consent card
kostandinang Sep 4, 2026
cfa0441
Make Voice launcher test CI-hermetic
kostandinang Sep 4, 2026
7a1f746
Plan compact Voice consent card
kostandinang Sep 4, 2026
c7efd7d
Polish Voice consent setup
kostandinang Sep 4, 2026
fa27f58
Handle unavailable microphone checks
kostandinang Sep 4, 2026
8a904c4
Fix Voice verification errors
kostandinang Sep 4, 2026
46b66bc
Format Voice consent implementation plan
kostandinang Sep 4, 2026
d64f809
Remove compact Voice planning artifacts
kostandinang Sep 4, 2026
9d753b2
Prebuild Brunch dependencies before local development
kostandinang Sep 4, 2026
9a1afcf
Authorize bounded Petrinaut error and transcript UX work
kostandinang Sep 4, 2026
60d4913
Show live Voice transcripts and complete errors
kostandinang Sep 4, 2026
1ebdcf1
Authorize compact initial Voice setup
kostandinang Sep 4, 2026
193e4bd
Open initial Voice setup in compact mode
kostandinang Sep 4, 2026
ac66645
Authorize closing collapsed Voice with the panel
kostandinang Sep 4, 2026
f3f5c4e
Close the panel when collapsed Voice ends
kostandinang Sep 4, 2026
a1fb27c
Authorize committed canonical speech before settlement
kostandinang Sep 4, 2026
756ed4f
Start canonical Voice speech before settlement
kostandinang Sep 4, 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.
54 changes: 24 additions & 30 deletions apps/brunch-agent/petrinaut-local.vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* Local FE-1436 panel launcher.
*
* Loads the real hash Petrinaut website config, removes only the website's
* stock `/api/chat` dev handler, and proxies Brunch's mounted Flue route to
* the committed application server. The real panel, wrappers, and editor
* stay untouched; hash's tracked checkout stays clean.
* Loads the real hash Petrinaut website config, including its stock API
* handlers, and proxies Brunch's mounted Flue route to the committed
* application server. The real panel, wrappers, and editor stay untouched;
* hash's tracked checkout stays clean.
*/

import { join, resolve } from "node:path";
Expand All @@ -13,29 +13,28 @@ import {
defineConfig,
loadConfigFromFile,
mergeConfig,
type PluginOption,
type UserConfig,
} from "vite";

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

const withoutIncumbentChatHandler = (
plugins: readonly PluginOption[],
): PluginOption[] =>
plugins.filter((plugin) => {
if (
plugin === false ||
plugin === null ||
plugin === undefined ||
Array.isArray(plugin) ||
typeof plugin !== "object" ||
!("name" in plugin)
) {
return true;
}
return plugin.name !== "petrinaut-api-dev";
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) => {
Expand All @@ -58,14 +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,
plugins: withoutIncumbentChatHandler(loaded.config.plugins ?? []),
},
{
root,
server: petrinautLocalServer(chatOrigin),
},
);
return mergePetrinautPanelConfig({
chatOrigin,
loadedConfig: loaded.config,
root,
});
});
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 @@ -89,7 +89,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 @@ -103,24 +116,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