Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
195739f
Respect per-turn interview question budgets in Brunch
kostandinang Oct 1, 2026
6c288e7
Add interview budget controls to Chat and Voice
kostandinang Oct 1, 2026
4585e20
Match the interview budget control to its prototype
kostandinang Oct 1, 2026
174376c
Remove divider above the interview budget description
kostandinang Oct 1, 2026
00ba547
Reserve the interview budget status row to avoid layout shift
kostandinang Oct 1, 2026
aa101ab
Shorten interview budget copy in the popover, estimate card and trans…
kostandinang Oct 1, 2026
6c4a5a0
Return focus to the interview budget trigger after Escape
kostandinang Oct 1, 2026
1d95818
Keep budget notes made while Brunch is replying
kostandinang Oct 1, 2026
6120e86
Simplify interview level notes in the chat transcript
kostandinang Oct 1, 2026
f6e634e
Refine interview level notes and preserve blank dock status space
kostandinang Oct 2, 2026
0d64b87
Remove redundant wording from Off transcript notes
kostandinang Oct 2, 2026
b73b98f
Remove the selected interview level underline
kostandinang Oct 2, 2026
fb29464
Restore the compact Voice dock height
kostandinang Oct 2, 2026
68b1d80
Rename the interview control to Conversation depth
kostandinang Oct 2, 2026
3cc0a8f
Simplify the interview control label to Detail level
kostandinang Oct 2, 2026
377d149
Label the interview control Interview length
kostandinang Oct 2, 2026
76c58b4
Simplify interview length descriptions
kostandinang Oct 2, 2026
17664c3
Gate interview length behind Brunch Labs
kostandinang Oct 2, 2026
ed2e2d0
Move interview length to the leading composer edge
kostandinang Oct 2, 2026
ae12bda
Clarify interview length descriptions and progress copy
kostandinang Oct 2, 2026
b64d924
Clarify interview length feedback and accessible context
kostandinang Oct 2, 2026
8f29c5f
Use the Interview length term in model instructions and skill guidance
kostandinang Oct 2, 2026
517d818
Fix canonical interview counting and Live pacing acknowledgement
kostandinang Oct 2, 2026
1be64e3
Carry interview length as opaque submission context
kostandinang Oct 2, 2026
1e1e88b
Move the interview length guide to the website docs
kostandinang Oct 2, 2026
8ab6070
Describe only the generic assistant slots in the changeset
kostandinang Oct 2, 2026
4a8dbfd
Keep host feature wording out of the assistant system-note default an…
kostandinang Oct 2, 2026
14a024d
Treat a reply that absorbed a closing answer as the interview wrap-up
kostandinang Oct 2, 2026
0cbadd4
Keep following a streaming reply beneath a trailing system note
kostandinang Oct 2, 2026
e3753f2
Count host questions asked before a joined closing answer
kostandinang Oct 2, 2026
81ec80f
Merge branch 'main' into ka/fe-1828-interview-budget
kostandinang Oct 5, 2026
fd4eb54
Budget Brunch replies by the panel's input mode, matching the estimate
kostandinang Oct 5, 2026
ff1d17b
Merge remote-tracking branch 'origin/main' into ka/fe-1828-interview-…
kostandinang Oct 7, 2026
0d56c27
Match the reworded Live answer warning in the pacing test
kostandinang Oct 7, 2026
b25edb6
Merge remote-tracking branch 'origin/main' into ka/fe-1828-interview-…
kostandinang Oct 7, 2026
e5b0e4f
Remove the sweep output schema left unused by the transport
kostandinang Oct 7, 2026
b3742bc
Move the interview length preference out of the Voice preference module
kostandinang Oct 7, 2026
55b259e
Derive interview length stop positions from the number of levels
kostandinang Oct 7, 2026
8a3fc4b
Check the submitted interview allowance against the plugin's type
kostandinang Oct 7, 2026
284e2ab
Show Last question until the final answer, then Wrapping up
kostandinang Oct 7, 2026
2fa0261
Pass Live append options by name instead of position
kostandinang Oct 7, 2026
e6a3f83
Compute interview length note positions with one helper
kostandinang Oct 7, 2026
cd5410c
Drop an assertion about retired interview length copy
kostandinang Oct 7, 2026
be747d1
Keep submission context out of the Brunch model's view of user turns
kostandinang Oct 7, 2026
dacbbdb
Merge remote-tracking branch 'origin/main' into ka/fe-1828-interview-…
kostandinang Oct 8, 2026
29c9198
Merge remote-tracking branch 'origin/main' into ka/fe-1828-interview-…
kostandinang Oct 9, 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/interview-budget-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add `renderComposerStatus` for host-owned status above the composer or expanded Voice dock, without changing the collapsed dock's height, and `renderSystemMessage` for host-owned system-note content. Pass the current `inputMode` to composer controls, keep composer controls available in the live Voice dock, and display system messages as visible notes in Chat and Voice. With `presentation: "brunch"`, composer controls sit at the leading edge of the composer and Voice dock, separate from submission and session actions.
16 changes: 15 additions & 1 deletion apps/brunch-agent/src/agents/chat-agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import {
useContextProjection,
useDelivery,
useInitialData,
useInstruction,
useTool,
Expand All @@ -20,10 +21,13 @@ import { createFlueClient } from "@flue/sdk";
import { createWorkpieceReadTool } from "@hashintel/brunch-agent";
import {
canonicalContent,
interviewBudgetContextKey,
parseInterviewBudget,
sdcpnInitialDataSchema,
type SdcpnInitialData,
} from "@hashintel/brunch-agent-plugin-sdcpn";
import { useSdcpnPlugin } from "@hashintel/brunch-agent-plugin-sdcpn/flue";
import { parsePetrinautUserMessageBody } from "@hashintel/brunch-agent-transport-aisdk";
import { useBrunchAgent } from "@hashintel/brunch-agent/flue";
import { getLatestNetDefinitionToolName } from "@hashintel/petrinaut-core";

Expand Down Expand Up @@ -56,6 +60,15 @@ const chatModelOptions = {

export function ChatAgent({ id }: AgentProps) {
const initialData = useInitialData<SdcpnInitialData>();
// Flue initialData is immutable birth data. The durable current delivery
// carries the changing allowance; an absent or malformed one means Off.
const delivery = useDelivery();
const body = parsePetrinautUserMessageBody(delivery.body);
const interviewBudget = parseInterviewBudget(
body.kind === "contextual"
? body.submissionContext?.[interviewBudgetContextKey]
: undefined,
);
Comment thread
cursor[bot] marked this conversation as resolved.
useContextProjection(projectBrunchContext);
// Agent-local acquisition of this already-authorized instance's public history.
// Reuse the existing router and storage; no listener, companion log or private records.
Expand All @@ -77,6 +90,7 @@ export function ChatAgent({ id }: AgentProps) {
useSdcpnPlugin(
initialData
? {
interviewBudget,
authorizeDraft: async (draftCallId: string) => {
if (!latestNetReadBefore(await history(), draftCallId))
throw new Error(
Expand All @@ -100,7 +114,7 @@ export function ChatAgent({ id }: AgentProps) {
return { output: result.output, metadata: result.metadata };
},
}
: {},
: { interviewBudget },
);
if (initialData) {
useTool(createWorkpieceReadTool({ currentRevision, readSources }));
Expand Down
45 changes: 44 additions & 1 deletion apps/brunch-agent/src/agents/chat-agent/context-projection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createHash } from "node:crypto";

import { brunchTools } from "@hashintel/brunch-agent";
import {
parsePetrinautUserMessageBody,
petrinautContextualUserMessageBody,
} from "@hashintel/brunch-agent-transport-aisdk";

import { inBandBrowserToolNames } from "./tool-catalogue.ts";

Expand Down Expand Up @@ -299,6 +303,44 @@ const compactToolCallArguments = (
};
};

/**
* Submission context is host data the agent reads from the delivery and
* restates in its instructions. The model sees the body the turn would have
* had without it: the person's text, or the diagnostics envelope.
*/
const withoutSubmissionContext = (text: string): string => {
const body = parsePetrinautUserMessageBody(text);
if (body.kind !== "contextual" || body.submissionContext === undefined)
return text;
return body.diagnosticsContext === ""
? body.userText
: petrinautContextualUserMessageBody({
userText: body.userText,
diagnosticsContext: body.diagnosticsContext,
});
};

const projectUserBody = (
entry: ContextProjectionEntry,
): ContextProjectionEntry => {
const message = entry.message;
if (message.role !== "user") return entry;
return {
...entry,
message:
typeof message.content === "string"
? { ...message, content: withoutSubmissionContext(message.content) }
: {
...message,
content: message.content.map((part) =>
part.type === "text"
? { ...part, text: withoutSubmissionContext(part.text) }
: part,
),
},
};
};

/**
* The model cites conversation sources by Flue message id, so each true-user
* entry carries its own id as a leading line. Signals are rendered as user
Expand Down Expand Up @@ -368,7 +410,8 @@ export const createBrunchContextProjection = (
}

return entries.map((entry, entryIndex) => {
if (entry.message.role === "user") return prefixUserMessageId(entry);
if (entry.message.role === "user")
return prefixUserMessageId(projectUserBody(entry));
const withProjectedArguments = projectArguments
? compactToolCallArguments(
entry,
Expand Down
66 changes: 65 additions & 1 deletion apps/brunch-agent/test/chat-agent-mounting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import * as v from "valibot";
import { afterEach, beforeEach, expect, test, vi } from "vitest";

import { brunchTools } from "@hashintel/brunch-agent";
import { sdcpnInitialDataSchema } from "@hashintel/brunch-agent-plugin-sdcpn";
import {
interviewBudgetContextKey,
sdcpnInitialDataSchema,
} from "@hashintel/brunch-agent-plugin-sdcpn";
import { petrinautContextualUserMessageBody } from "@hashintel/brunch-agent-transport-aisdk";
import {
petrinautAiCapabilityGuidance,
petrinautAiTools,
Expand All @@ -16,6 +20,7 @@ import {

const mounted = vi.hoisted(() => ({
initialData: undefined as unknown,
body: "Four agents",
contextProjections: 0,
instructions: [] as string[],
models: [] as string[],
Expand All @@ -28,6 +33,7 @@ vi.mock("@flue/runtime", async (importOriginal) => ({
mounted.contextProjections += 1;
},
useInitialData: () => mounted.initialData,
useDelivery: () => ({ kind: "user", body: mounted.body }),
useInstruction: (instruction: string) =>
mounted.instructions.push(instruction),
useModel: (model: string) => mounted.models.push(model),
Expand Down Expand Up @@ -58,6 +64,7 @@ beforeEach(() => {
vi.stubEnv("BRUNCH_CHAT_MODEL", "claude-sonnet-4-6");
vi.stubEnv("NODE_ENV", "test");
mounted.initialData = undefined;
mounted.body = "Four agents";
mounted.contextProjections = 0;
mounted.instructions.length = 0;
mounted.models.length = 0;
Expand Down Expand Up @@ -106,6 +113,63 @@ test("the agent admits a document binding or no initial data", () => {
expect(v.parse(sdcpnInitialDataSchema, undefined)).toBeUndefined();
});

test("only the current submission's budget reaches the prompt and Off or a malformed budget restores exactly the baseline", async () => {
mounted.initialData = bound;
const { ChatAgent: renderChatAgent } =
await import("../src/agents/chat-agent/agent.ts");
const baseline = renderChatAgent({ id: "budget" });
const baselineInstructions = [...mounted.instructions];
mounted.initialData = {
...bound,
interviewBudget: {
level: "standard",
questionCap: 6,
asked: 0,
remaining: 6,
},
};
for (const remaining of [1, 0]) {
mounted.instructions.length = 0;
mounted.body = petrinautContextualUserMessageBody({
userText: "Four agents",
diagnosticsContext: "",
submissionContext: {
[interviewBudgetContextKey]: {
level: "quick",
questionCap: 3,
asked: 3 - remaining,
remaining,
},
},
});
expect(renderChatAgent({ id: "budget" })).toBe(baseline);
const budgetInstructions = mounted.instructions.filter(
(instruction) => !baselineInstructions.includes(instruction),
);
expect(budgetInstructions).toHaveLength(1);
expect(budgetInstructions[0]).toContain(`remaining: ${remaining}`);
expect(budgetInstructions[0]).toContain(
remaining
? "most consequential open fact"
: "do not ask another question",
);
}
mounted.instructions.length = 0;
mounted.body = "Four agents";
expect(renderChatAgent({ id: "budget" })).toBe(baseline);
expect(mounted.instructions).toEqual(baselineInstructions);
mounted.instructions.length = 0;
mounted.body = petrinautContextualUserMessageBody({
userText: "Four agents",
diagnosticsContext: "",
submissionContext: {
[interviewBudgetContextKey]: { level: "quick", asked: -1 },
},
});
expect(renderChatAgent({ id: "budget" })).toBe(baseline);
expect(mounted.instructions).toEqual(baselineInstructions);
});

test("the Brunch catalogue classifies every canonical tool", () => {
const canonicalNames = Object.keys(petrinautAiTools);
expect(canonicalPetrinautToolCatalogue.map(({ name }) => name)).toEqual(
Expand Down
61 changes: 61 additions & 0 deletions apps/brunch-agent/test/context-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { createHash } from "node:crypto";

import { expect, test } from "vitest";

import { interviewBudgetContextKey } from "@hashintel/brunch-agent-plugin-sdcpn";
import { petrinautContextualUserMessageBody } from "@hashintel/brunch-agent-transport-aisdk";

import {
createBrunchContextProjection,
projectBrunchContext,
Expand Down Expand Up @@ -610,3 +613,61 @@ test("prefixes true-user entries with their message id and touches no other role
expect(projected[2]).toEqual(input[2]);
expect(projected[3]).toEqual(input[3]);
});

test("shows the model each user turn as it would read without submission context", () => {
const submissionContext = {
[interviewBudgetContextKey]: {
level: "quick",
questionCap: 3,
asked: 1,
remaining: 2,
},
};
const diagnosed = petrinautContextualUserMessageBody({
userText: "Two suppliers.",
diagnosticsContext: "Selected: Supplier A",
});
const input: ContextProjectionEntry[] = [
{
id: "budgeted",
message: {
role: "user",
content: petrinautContextualUserMessageBody({
userText: "We hold stock.",
diagnosticsContext: "",
submissionContext,
}),
},
},
{
id: "budgeted-diagnosed",
message: {
role: "user",
content: [
{
type: "text",
text: petrinautContextualUserMessageBody({
userText: "Two suppliers.",
diagnosticsContext: "Selected: Supplier A",
submissionContext,
}),
},
],
},
},
{ id: "diagnosed", message: { role: "user", content: diagnosed } },
];
const projected = projectBrunchContext(input);
expect(projected.map(({ message }) => message)).toEqual([
{ role: "user", content: "[message budgeted]\nWe hold stock." },
{
role: "user",
content: [
{ type: "text", text: "[message budgeted-diagnosed]" },
{ type: "text", text: diagnosed },
],
},
{ role: "user", content: `[message diagnosed]\n${diagnosed}` },
]);
expect(JSON.stringify(projected)).not.toContain(interviewBudgetContextKey);
});
2 changes: 2 additions & 0 deletions apps/petrinaut-website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Petrinaut's stock assistant is the AI panel fallback. Under **User settings β†’

When Voice is enabled for Brunch, Labs also shows **Realtime mode**, off by default. Leave it off to use Live; turn it on to use Realtime. This choice is saved under `petrinaut-website:realtime-enabled` and applies to the next Voice session. Changing it does not interrupt active audio: end Voice and start it again to switch providers.

With Brunch selected, Labs also shows **Interview length**, off by default, for choosing how many questions Brunch asks before wrapping up. The toggle and the chosen level are saved under `petrinaut-website:interview-budget-enabled` and `petrinaut-website:interview-budget`. See [Interview length](docs/interview-length.md).

A Brunch-focused deployment or test launch may set `VITE_PETRINAUT_DEFAULT_ASSISTANT=brunch`; explicit browser-local assistant choices remain authoritative, so changing the launch fallback does not migrate existing users. With `VITE_BRUNCH_CHAT_ENDPOINT` configured, the command palette (⌘K) continues to offer **Use Brunch** and, once switched, **Use the stock Petrinaut assistant**. With the stock assistant selected, the panel talks to `/api/chat` with the stock tool surface, keeps its messages in the local store, and creates no Flue client, mounts no Brunch tools and shows no Workpiece pane or Voice; Brunch's conversation lives in Flue history and is untouched. Switching back restores it. Without a configured endpoint, the Labs control remains visible but disabled, the stock assistant is the only one, and no command is offered.

Voice is available only when Brunch is selected, the browser-local Voice preference is enabled, and the existing server capability check reports Voice available. Enabling the preference does not start microphone capture or a provider session.
Expand Down
23 changes: 23 additions & 0 deletions apps/petrinaut-website/docs/interview-length.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Interview length

On the Petrinaut website, open **User settings β†’ Labs**, select **Use Brunch**, and enable **Interview length**. This experiment starts disabled: no length control or estimate appears, and Brunch uses its ordinary interview behaviour. Turning it off preserves your saved level for next time.

## Choose a length

When enabled, the round icon at the left of the message input opens **Interview length** above it. Send or Voice stays at the right. The same control appears at the left of the Voice dock without increasing its height. Choose **Off**, **Quick Β· ~5 min**, **Standard Β· ~10 min** (the default), **Thorough Β· ~20 min**, or **Deep Β· no limit**. Click a stop name, drag the rail, or focus it and use the arrow keys. The browser remembers the level and whether the experiment is enabled.

## Follow the estimate

After the first reply, a quiet status sits at the right above the composer or Voice controls in the expanded conversation. It estimates time from questions left; it is not a countdown. The row stays blank before the first reply and when Off is selected, but keeps its space so the input controls do not move. The collapsed Voice dock omits this row entirely to stay compact; expand the conversation to see the estimate. Hover or focus the estimate for a card with the question count and closing behaviour.

Quick, Standard and Thorough allow respectively 3, 6 and 10 replies in text, or 2, 4 and 7 in Voice. Each Brunch reply counts as one question, including a grouped question or a confirmation-only reply. The estimate progresses to **1 question left**, then **Last question** while Brunch's final question awaits your answer, then **Wrapping up** once you have answered it. These labels reflect the count and your submissions, not whether Brunch is currently speaking or the model is complete. Deep shows the running count and offers pauses between topics; Off uses Brunch's usual pacing.

## Change the length or reach the cap

Changing the level adds a compact note with the level's icon and colour, such as **Quick Β· ~5 min**, to this session's transcript. It appears with a brief, subtle animation unless reduced motion is enabled. Earlier questions still count towards the new cap; Brunch applies the change on the next submission.

At the cap, Brunch records the latest answer and closes with stated facts, labelled assumptions and open items. A cap does not mean the model is complete or runnable: missing facts, ranges and units remain open rather than being invented. You can choose a higher level to continue. The closing summary does not use another question: raising Standard to Thorough after six text replies and their wrap-up leaves four questions, including after reloading.

## Voice

Live adjusts its voice pacing quietly when you change the level. If that update fails, a warning appears without interrupting speech; choose another length to try again. Brunch's question limit still applies independently of Live's pacing.
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ const defaultProps = {
assistantReady: true,
brunchConfigured: true,
brunchSelected: false,
interviewBudgetEnabled: false,
interviewBudgetPreferenceReady: true,
openAIVoiceConfig: voiceConfig,
realtimeEnabled: false,
realtimePreferenceReady: true,
selectAssistant: vi.fn(),
setInterviewBudgetEnabled: vi.fn(),
setRealtimeEnabled: vi.fn(),
setVoiceEnabled: vi.fn(),
voiceEnabled: false,
Expand Down Expand Up @@ -83,6 +86,31 @@ test("selects Brunch, enables Voice, and explains when Voice is unavailable", as
).toBeDefined();
});

test("shows Interview length only within Brunch and waits for its preference", async () => {
const view = render(<AssistantLabsSettings {...defaultProps} />);
expect(
screen.queryByRole("checkbox", { name: "Interview length" }),
).toBeNull();
view.rerender(
<AssistantLabsSettings
{...defaultProps}
brunchSelected
interviewBudgetPreferenceReady={false}
/>,
);
expect(
screen.getByRole("checkbox", { name: "Interview length" }),
).toHaveProperty("disabled", true);
view.rerender(<AssistantLabsSettings {...defaultProps} brunchSelected />);
const toggle = screen.getByRole("checkbox", { name: "Interview length" });
expect(toggle).toHaveProperty("checked", false);
expect(toggle).toHaveProperty("disabled", false);
fireEvent.click(toggle);
await waitFor(() =>
expect(defaultProps.setInterviewBudgetEnabled).toHaveBeenCalledWith(true),
);
});

test("shows Realtime only for enabled Brunch Voice and waits for capability", async () => {
const view = render(<AssistantLabsSettings {...defaultProps} voiceEnabled />);
expect(screen.queryByRole("checkbox", { name: "Realtime mode" })).toBeNull();
Expand Down
Loading
Loading