Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
eb5d970
Cut Mission 5 direct Voice transport
lunelson Sep 3, 2026
b5e52cd
Align future drafts with live Mission 5
lunelson Sep 3, 2026
e85f0c2
Clarify Mission 5 direct transport proof
lunelson Sep 3, 2026
1bb9b37
Recut Mission 5 around one Flue conversation route
lunelson Sep 3, 2026
98369e1
Align future planning record with the recut Mission 5 route
lunelson Sep 3, 2026
f04ce44
Adopt the product-manager litmus in live Mission 5
lunelson Sep 3, 2026
6452803
Point the spine litmus at the live Mission 5 surface
lunelson Sep 3, 2026
4f8608b
Admit Voice submission through the panel's Flue transport
lunelson Sep 3, 2026
4b00f94
Expose the Flue browser chat transport
lunelson Sep 3, 2026
355b0da
Wire the Petrinaut panel to the Flue route
lunelson Sep 3, 2026
309e958
Remove the legacy Petrinaut chat route
lunelson Sep 3, 2026
f432e14
Complete the Voice lifecycle over Flue
lunelson Sep 3, 2026
8b9af62
Document the unified Brunch conversation lifecycle
lunelson Sep 3, 2026
9d1d3f9
Harden Mission 5 review fixes for hydration, TTS, and Clear
lunelson Sep 3, 2026
d874c67
Fix Mission 5 verification blockers
lunelson Sep 3, 2026
3c9c9f7
Record Mission 5 review verification
lunelson Sep 3, 2026
6a9b661
Record Voice latency from Flue admission
lunelson Sep 3, 2026
962ee63
Refresh Mission 5 evidence after admission timing
lunelson Sep 3, 2026
0a72d51
Wait for Flue admission before accepting Voice tool results
lunelson Sep 3, 2026
a5017f1
Refresh Mission 5 evidence for Voice tool admission
lunelson Sep 3, 2026
fb0b84f
Align Brunch integration references with the Flue route
lunelson Sep 3, 2026
cf5b9f0
Format conversation identity sources
lunelson Sep 4, 2026
b77bd9a
Correlate Stop with its Flue submission and align the two conversatio…
lunelson Sep 4, 2026
1fb395b
Correlate Voice replies by every submission that wrote a message
lunelson Sep 4, 2026
ede4cf4
Pin the Mission 5 witness to the current implementation
lunelson Sep 4, 2026
b52bcdf
Preserve Petrinaut Voice APIs in the Brunch preview
lunelson Sep 4, 2026
97ef469
Pin the Mission 5 witness to the Voice preview fix
lunelson Sep 4, 2026
671ad64
Carry Voice resume defects into Mission 6
lunelson Sep 4, 2026
1a74282
Keep a turn busy across its client-tool follow-up and let Stop withho…
lunelson Sep 7, 2026
974d777
Reset assistant state with conversation identity
lunelson Sep 7, 2026
55f497f
Keep browser tool continuations causal
lunelson 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
5 changes: 5 additions & 0 deletions .changeset/durable-brunch-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Let hosts make the assistant's Stop action durable before Petrinaut cancels its local response stream, while preserving local-only cancellation for hosts that do not provide the new stop request.
5 changes: 5 additions & 0 deletions .changeset/voice-input-withdrawal-and-stop-correlation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Let hosts withdraw a retained voice input through an `AbortSignal`, keep a late durable Stop result from cancelling a newer turn, replace all conversation-owned assistant state when the conversation identity changes, hydrate host-owned history only once it carries every locally streamed reply, keep the composer status busy across the automatic follow-up to a client-tool step, and let a Stop pressed during that step withhold the follow-up.
42 changes: 15 additions & 27 deletions apps/brunch-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,21 @@ From the repository root, make `ANTHROPIC_API_KEY` available in the environment
yarn dev:brunch
```

The first step builds the Petrinaut libraries the panel imports (`dist/` and design-system
codegen). Then it starts the Brunch server at `http://127.0.0.1:4321` and the real Petrinaut
website at `http://127.0.0.1:4915`. The website proxies `/api/chat` to Brunch. The panel talks to one Flue chat agent composed from the context-independent core prompt in `@hashintel/brunch-agent/flue`, the SDCPN/Petrinaut instructions, modelling runbook skill, and `readPetrinautDoc` client tool in `@hashintel/brunch-agent-plugin-sdcpn`, and app-owned deployment/transport material. The skill is activated via `activate_skill`, with supporting resources disclosed via `read_skill_resource`; the app's only model-facing diagnostic tool is `ping`. There is no generalized elicitation loop, sweep tool, or `brunch_ask` on this path. Capture is a harness-side pipe: an explicit settled range of Flue history is applied into a JSON store beside the conversation database, not by the interviewer.
The first step builds the Petrinaut libraries the panel imports (`dist/` and design-system codegen). Then it starts the Brunch server at `http://127.0.0.1:4321` and the real Petrinaut website at `http://127.0.0.1:4915`. The website proxies `/agents/chat/*` to Brunch without changing the request origin or Flue protocol. The typed panel and Voice mode talk to one Flue chat agent composed from the context-independent core prompt in `@hashintel/brunch-agent/flue`, the SDCPN/Petrinaut instructions, modelling runbook skill, and `readPetrinautDoc` client tool in `@hashintel/brunch-agent-plugin-sdcpn`, and app-owned deployment material. The skill is activated via `activate_skill`, with supporting resources disclosed via `read_skill_resource`; the app's only model-facing diagnostic tool is `ping`. There is no generalized elicitation loop, sweep tool, or `brunch_ask` on this path. Capture is a harness-side pipe: an explicit settled range of Flue history is applied into a JSON store beside the conversation database, not by the interviewer.

A headless Mission 3 drive (simulated expert, same `ChatAgent` door):

```sh
yarn workspace @apps/brunch-agent runbook:headless
```

`ANTHROPIC_API_KEY` is required. `BRUNCH_CHAT_MODEL` selects the interviewer
(default `claude-sonnet-4-5` for this script only). Artifacts write under
`libs/@hashintel/brunch-agent/docs/evidence/evaluations/vestera-runbook-headless/`
unless `BRUNCH_RUNBOOK_OUTPUT_DIR` is set.
`ANTHROPIC_API_KEY` is required. `BRUNCH_CHAT_MODEL` selects the interviewer (default `claude-sonnet-4-5` for this script only). Artifacts write under `libs/@hashintel/brunch-agent/docs/evidence/evaluations/vestera-runbook-headless/` unless `BRUNCH_RUNBOOK_OUTPUT_DIR` is set.

Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. `BRUNCH_DEV_DB_PATH`
overrides that local path. Capture envelopes for one Flue conversation sit beside that sqlite
file, named by the hashed instance id (`<instanceId>.json`). The hermetic `/api/chat` test uses
`BRUNCH_CHAT_DB_PATH` and writes the capture file in that same directory. Flue history is the
conversation log; the capture store is not a second transcript. The browser may cache messages
but reload hydrates from `GET /api/chat?id=`.
Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. `BRUNCH_DEV_DB_PATH` overrides that local path. Capture envelopes for one Flue conversation sit beside that sqlite file, named by the hashed instance id (`<instanceId>.json`). The hermetic browser-transport test uses `BRUNCH_CHAT_DB_PATH` and writes the capture file in that same directory. Flue history is the conversation log; the capture store is not a second transcript. The panel rehydrates from the SDK's canonical conversation observation and does not resubmit or replay settled turns.

The mounted Flue URL `/agents/chat/:id` requires the same principal and conversation identity (`x-brunch-principal` and `x-brunch-conversation`) as `/api/chat`; the path id is the hash of those, not a bearer token.
The mounted Flue URL `/agents/chat/:instanceId` requires the principal and logical conversation identity in `x-brunch-principal` and `x-brunch-conversation`. The path id is the hash of those values, not a bearer token or trusted authentication.

Print a human-readable transcript of one conversation from that same Flue history (server already
running):
Print a human-readable transcript of one conversation from that same Flue history (server already running):

```sh
yarn workspace @apps/brunch-agent transcript -- --principal <key> --id <conversationId>
Expand Down Expand Up @@ -110,17 +99,16 @@ yarn workspace @apps/brunch-agent smoke:deployment
BRUNCH_SMOKE_MODE=history yarn workspace @apps/brunch-agent smoke:deployment
```

## Voice dock
## Panel and Voice conversation route

A second input modality joins the same chat door. It is not a voice route and does not own
provider audio or session state.
Voice is a second input modality over the panel's conversation. It is not a Voice route and does not own provider audio or durable conversation state.

| | |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | `POST /api/chat` (and `GET /api/chat?id=` to hydrate) |
| Identity | `x-brunch-principal` plus body `id` (the conversation id). The server hashes those into the Flue instance id. |
| Initial turn | JSON `{ id, trigger: "submit-message", messages }` whose last user text part is the utterance. |
| Client-tool follow-up | Same POST, with `messageId` of the assistant message and completed client-tool parts (`providerExecuted` not true). Correlated by `toolCallId`. |
| Response | AI SDK UI-message stream (SSE). |
| | |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| URL | `/agents/chat/:instanceId`, called through the public Flue browser client and the same-origin local proxy |
| Identity | `x-brunch-principal` plus `x-brunch-conversation`; the server verifies that their hash matches the mounted instance id |
| Initial turn | One `FlueClient.send()` carrying `{ kind: "user", body }` |
| Client-tool follow-up | One `FlueClient.send()` carrying the `client-tool-result` signal for completed client-tool parts, correlated by `toolCallId` |
| Response | `FlueClient.wait()` chunks projected into one finite AI SDK UI-message stream; observation/history provides canonical rehydration |

`OPTIONS /api/chat` is the CORS preflight for that same contract.
Typed and finalized spoken turns use this same route. The panel's explicit **Stop** requests a conversation-wide Flue abort before cancelling its local stream. Local Voice interruption stops playback only and leaves canonical history unchanged.
2 changes: 1 addition & 1 deletion apps/brunch-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@apps/brunch-agent",
"version": "0.0.0-private",
"private": true,
"description": "Remote Brunch server, local development loop, and Petrinaut /api/chat door.",
"description": "Remote Brunch server and local Petrinaut development loop.",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
Expand Down
47 changes: 10 additions & 37 deletions apps/brunch-agent/petrinaut-local.vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,28 @@
/**
* Local FE-1436 panel launcher.
*
* Loads the real hash Petrinaut website config, removes only its incumbent
* `/api/chat` dev handler, and sends that same-origin route to brunch's
* committed application server. The real panel, wrappers, and editor stay untouched;
* hash's tracked checkout stays clean.
* Loads the real hash Petrinaut website config and proxies Brunch's mounted
* Flue route to the committed application server. The real panel, API
* handlers, wrappers, and editor stay untouched; hash's tracked checkout
* stays clean.
*/

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

import {
defineConfig,
loadConfigFromFile,
mergeConfig,
type PluginOption,
} from "vite";
import { defineConfig, loadConfigFromFile, mergeConfig } 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";
});

export default defineConfig(async (environment) => {
const websiteRoot = process.env.PETRINAUT_WEBSITE_ROOT;
if (!websiteRoot) {
throw new Error("PETRINAUT_WEBSITE_ROOT is required.");
}
const root = resolve(websiteRoot);
process.env.VITE_BRUNCH_CHAT_ENDPOINT ??= "/agents/chat";
// Babel resolves the React compiler plugin from the launched project's cwd,
// not from the imported config file. Match a native hash launch before the
// plugin begins transforming the real panel source.
Expand All @@ -57,14 +36,8 @@ 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 mergeConfig(loaded.config, {
root,
server: petrinautLocalServer(chatOrigin),
});
});
23 changes: 2 additions & 21 deletions apps/brunch-agent/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
/**
* The app's route map β€” one plain Flue chat agent plus Petrinaut's /api/chat door.
*
* Both doors require principal + conversation id. `/api/chat` takes the principal
* header and body `id`; `/agents/chat/:id` takes the same principal plus
* `x-brunch-conversation` and admits the request only when those re-derive the
* path id. The Flue instance id is derived, not a bearer token.
*/
/** The app's route map β€” one ownership-guarded Flue conversation door. */

import "./telemetry-bootstrap.ts";
import { readFile } from "node:fs/promises";
Expand All @@ -17,26 +10,14 @@ import { ChatAgent } from "./agents/chat-agent/agent.ts";
import { healthHandler } from "./health.ts";
import { assetHandler } from "./http/assets.ts";
import { agentOwnershipGuard } from "./http/ownership.ts";
import { createPetrinautChatHandler } from "./http/petrinaut-chat.ts";
import {
CHAT_AGENT_ROUTE,
HEALTH_ROUTE,
PETRINAUT_CHAT_ROUTE,
} from "./http/routes.ts";
import { CHAT_AGENT_ROUTE, HEALTH_ROUTE } from "./http/routes.ts";

const app = new Hono();
const appTransport: typeof fetch = async (input, init) =>
app.fetch(input instanceof Request ? input : new Request(input, init));
const petrinautChatHandler = createPetrinautChatHandler(appTransport);

const chatAgentMount = `/agents/${CHAT_AGENT_ROUTE}`;
app.use(`${chatAgentMount}/*`, agentOwnershipGuard(`${chatAgentMount}/`));
app.route(chatAgentMount, createAgentRouter(ChatAgent));

app.on(["GET", "POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) =>
petrinautChatHandler(c.req.raw),
);

app.get(HEALTH_ROUTE, healthHandler);

const uiRoot = new URL(
Expand Down
4 changes: 2 additions & 2 deletions apps/brunch-agent/src/conversation/client-tools.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/** Flue-side client-tool signal contract: awaiting sentinel, result signal, tool names. */

import { READ_PETRINAUT_DOC_TOOL_NAME } from "@hashintel/brunch-agent-plugin-sdcpn/flue";
import { CLIENT_TOOL_RESULT_SIGNAL } from "@hashintel/brunch-agent-transport-aisdk";
import { AWAITING_CLIENT } from "@hashintel/brunch-agent/client-tools";

export { AWAITING_CLIENT };

export const CLIENT_TOOL_RESULT_SIGNAL = "client-tool-result";
export { CLIENT_TOOL_RESULT_SIGNAL };

export const clientToolNames: ReadonlySet<string> = new Set([
READ_PETRINAUT_DOC_TOOL_NAME,
Expand Down
14 changes: 0 additions & 14 deletions apps/brunch-agent/src/conversation/identity-web.ts

This file was deleted.

35 changes: 10 additions & 25 deletions apps/brunch-agent/src/conversation/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,32 @@
import { createHash, timingSafeEqual } from "node:crypto";

import {
BRUNCH_CONVERSATION_HEADER,
BRUNCH_PRINCIPAL_HEADER,
identityPayload,
} from "./payload.ts";

import type { ConversationIdentity } from "@hashintel/brunch-agent-transport-aisdk";
type ConversationIdentity,
} from "@hashintel/brunch-agent-transport-aisdk";

export {
agentOwnershipHeaders,
BRUNCH_CONVERSATION_HEADER,
BRUNCH_PRINCIPAL_HEADER,
LOCAL_UI_PRINCIPAL,
} from "./payload.ts";
} from "@hashintel/brunch-agent-transport-aisdk";
export { LOCAL_UI_PRINCIPAL } from "./payload.ts";
export type { ConversationIdentity };

export const flueConversationId = (
principalKey: string,
conversationId: string,
): string =>
createHash("sha256")
.update(identityPayload(principalKey, conversationId))
.digest("hex");
export const flueConversationId = (identity: ConversationIdentity): string =>
createHash("sha256").update(identityPayload(identity)).digest("hex");

export const flueConversationIdFrom = (
identity: ConversationIdentity,
): string => flueConversationId(identity.principalKey, identity.conversationId);
): string => flueConversationId(identity);

export const ownsFlueInstance = (
principalKey: string,
conversationId: string,
identity: ConversationIdentity,
instanceId: string,
): boolean => {
const expected = flueConversationId(principalKey, conversationId);
const expected = flueConversationId(identity);
const expectedBytes = Buffer.from(expected);
const presentedBytes = Buffer.from(instanceId);
if (expectedBytes.length !== presentedBytes.length) return false;
return timingSafeEqual(expectedBytes, presentedBytes);
};

export const agentOwnershipHeaders = (
identity: ConversationIdentity,
): Record<string, string> => ({
[BRUNCH_PRINCIPAL_HEADER]: identity.principalKey,
[BRUNCH_CONVERSATION_HEADER]: identity.conversationId,
});
27 changes: 0 additions & 27 deletions apps/brunch-agent/src/conversation/payload.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,2 @@
/** Identity headers and payload encoding shared by Node and the local Flue UI. */

export { BRUNCH_PRINCIPAL_HEADER } from "@hashintel/brunch-agent-transport-aisdk/headers";

export const BRUNCH_CONVERSATION_HEADER = "x-brunch-conversation";

/** Principal for the stock Flue UI at `/`. Not a second ownership rule. */
export const LOCAL_UI_PRINCIPAL = "local";

export const identityPayload = (
principalKey: string,
conversationId: string,
): Uint8Array => {
const encoder = new TextEncoder();
const principalBytes = encoder.encode(principalKey);
const conversationBytes = encoder.encode(conversationId);
const payload = new Uint8Array(
principalBytes.length + 1 + conversationBytes.length,
);
payload.set(principalBytes, 0);
payload[principalBytes.length] = 0;
payload.set(conversationBytes, principalBytes.length + 1);
return payload;
};

export const hexFromDigest = (digest: ArrayBuffer): string =>
[...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
Loading
Loading