diff --git a/.changeset/interview-budget-controls.md b/.changeset/interview-budget-controls.md new file mode 100644 index 00000000000..1116bddeea5 --- /dev/null +++ b/.changeset/interview-budget-controls.md @@ -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. diff --git a/apps/brunch-agent/src/agents/chat-agent/agent.ts b/apps/brunch-agent/src/agents/chat-agent/agent.ts index ecb9a0226b7..fb54dfa8709 100644 --- a/apps/brunch-agent/src/agents/chat-agent/agent.ts +++ b/apps/brunch-agent/src/agents/chat-agent/agent.ts @@ -9,6 +9,7 @@ import { useContextProjection, + useDelivery, useInitialData, useInstruction, useTool, @@ -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"; @@ -56,6 +60,15 @@ const chatModelOptions = { export function ChatAgent({ id }: AgentProps) { const initialData = useInitialData(); + // 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, + ); 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. @@ -77,6 +90,7 @@ export function ChatAgent({ id }: AgentProps) { useSdcpnPlugin( initialData ? { + interviewBudget, authorizeDraft: async (draftCallId: string) => { if (!latestNetReadBefore(await history(), draftCallId)) throw new Error( @@ -100,7 +114,7 @@ export function ChatAgent({ id }: AgentProps) { return { output: result.output, metadata: result.metadata }; }, } - : {}, + : { interviewBudget }, ); if (initialData) { useTool(createWorkpieceReadTool({ currentRevision, readSources })); diff --git a/apps/brunch-agent/src/agents/chat-agent/context-projection.ts b/apps/brunch-agent/src/agents/chat-agent/context-projection.ts index 7c91f94bcac..0aa768f84c4 100644 --- a/apps/brunch-agent/src/agents/chat-agent/context-projection.ts +++ b/apps/brunch-agent/src/agents/chat-agent/context-projection.ts @@ -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"; @@ -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 @@ -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, diff --git a/apps/brunch-agent/test/chat-agent-mounting.test.ts b/apps/brunch-agent/test/chat-agent-mounting.test.ts index 96da0237471..f0c5282515e 100644 --- a/apps/brunch-agent/test/chat-agent-mounting.test.ts +++ b/apps/brunch-agent/test/chat-agent-mounting.test.ts @@ -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, @@ -16,6 +20,7 @@ import { const mounted = vi.hoisted(() => ({ initialData: undefined as unknown, + body: "Four agents", contextProjections: 0, instructions: [] as string[], models: [] as string[], @@ -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), @@ -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; @@ -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( diff --git a/apps/brunch-agent/test/context-projection.test.ts b/apps/brunch-agent/test/context-projection.test.ts index 22fd23613cc..01b3bd4ad5b 100644 --- a/apps/brunch-agent/test/context-projection.test.ts +++ b/apps/brunch-agent/test/context-projection.test.ts @@ -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, @@ -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); +}); diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index f1517243f51..8855f6fbb0f 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -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. diff --git a/apps/petrinaut-website/docs/interview-length.md b/apps/petrinaut-website/docs/interview-length.md new file mode 100644 index 00000000000..0a45ac69929 --- /dev/null +++ b/apps/petrinaut-website/docs/interview-length.md @@ -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. diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx index 8c7d7c33213..819e9646d2b 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.test.tsx @@ -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, @@ -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(); + expect( + screen.queryByRole("checkbox", { name: "Interview length" }), + ).toBeNull(); + view.rerender( + , + ); + expect( + screen.getByRole("checkbox", { name: "Interview length" }), + ).toHaveProperty("disabled", true); + view.rerender(); + 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(); expect(screen.queryByRole("checkbox", { name: "Realtime mode" })).toBeNull(); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx index ee05acc719c..823e1107789 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx @@ -95,10 +95,13 @@ export const AssistantLabsSettings = ({ assistantReady, brunchConfigured, brunchSelected, + interviewBudgetEnabled, + interviewBudgetPreferenceReady, openAIVoiceConfig, realtimeEnabled, realtimePreferenceReady, selectAssistant, + setInterviewBudgetEnabled, setRealtimeEnabled, setVoiceEnabled, voiceEnabled, @@ -107,10 +110,13 @@ export const AssistantLabsSettings = ({ readonly assistantReady: boolean; readonly brunchConfigured: boolean; readonly brunchSelected: boolean; + readonly interviewBudgetEnabled: boolean; + readonly interviewBudgetPreferenceReady: boolean; readonly openAIVoiceConfig: OpenAIVoiceConfig | null | undefined; readonly realtimeEnabled: boolean; readonly realtimePreferenceReady: boolean; readonly selectAssistant: (selection: AssistantSelection) => void; + readonly setInterviewBudgetEnabled: (enabled: boolean) => void; readonly setRealtimeEnabled: (enabled: boolean) => void; readonly setVoiceEnabled: (enabled: boolean) => void; readonly voiceEnabled: boolean; @@ -141,6 +147,15 @@ export const AssistantLabsSettings = ({ onChange={(enabled) => selectAssistant(enabled ? "brunch" : "stock")} value={brunchSelected} /> + {brunchSelected && ( + + )} ({ + id, + submissionId: id, + role: "assistant", + purpose: "assistant", + display: "visible", + parts: [{ type: "text", text, state }], +}); + +test("canonical counting ignores display projection, partial text, tools and hidden messages", () => { + const messages: FlueConversationMessage[] = [ + reply("batched", "How many agents? Which hours?"), + reply("confirmation", "Recorded."), + reply("pending", "What about", "streaming"), + { ...reply("hidden", "Internal note"), display: "hidden" }, + { ...reply("tools", ""), parts: [] }, + ]; + expect(countInterviewReplies({ messages })).toBe(2); + // Projection marks text as done; it must never be the count's input. + expect( + snapshotToUiMessages({ messages }, { clientToolNames: new Set() }).length, + ).toBeGreaterThan(2); + messages[2] = reply("pending", "What about weekends?"); + expect(countInterviewReplies({ messages })).toBe(3); + const reloaded = JSON.parse(JSON.stringify(messages)) as typeof messages; + expect(countInterviewReplies({ messages: reloaded })).toBe(3); +}); + +test("wrap-up does not spend a question when the length is raised after closing", () => { + const messages = Array.from({ length: 6 }, (_, index) => + reply(`question-${index}`, "Recorded."), + ); + messages.push( + { + id: "last-answer", + submissionId: "wrap-up", + role: "user", + purpose: "user", + display: "visible", + parts: [ + { + type: "text", + state: "done", + text: petrinautContextualUserMessageBody({ + userText: "Weekends too.", + diagnosticsContext: "", + submissionContext: { + [interviewBudgetContextKey]: getInterviewBudget( + "standard", + "text", + 6, + ), + }, + }), + }, + ], + }, + reply("wrap-up", "Stated: six agents. Open: arrival rate."), + ); + expect(countInterviewReplies({ messages })).toBe(6); + expect( + getInterviewBudget("thorough", "text", countInterviewReplies({ messages })), + ).toMatchObject({ asked: 6, remaining: 4 }); + // Off, Deep and old conversations without envelopes still count replies; + // prose alone must not decide whether something is a closing turn. + messages.push(reply("continued", "Here is a summary.")); + expect(countInterviewReplies({ messages })).toBe(7); +}); + +test("a closing answer that joins a busy reply makes only the host's later text the wrap-up", () => { + const messages: FlueConversationMessage[] = [ + ...Array.from({ length: 5 }, (_, index) => + reply(`question-${index}`, "Recorded."), + ), + reply("host", "Which hours does the second shift cover?"), + { + id: "last-answer", + submissionId: "closing", + role: "user", + purpose: "user", + display: "visible", + parts: [ + { + type: "text", + state: "done", + text: petrinautContextualUserMessageBody({ + userText: "Weekends too.", + diagnosticsContext: "", + submissionContext: { + [interviewBudgetContextKey]: getInterviewBudget( + "standard", + "text", + 6, + ), + }, + }), + }, + ], + }, + { + ...reply("host-wrap-up", "Stated: six agents. Open: arrival rate."), + submissionId: "host", + }, + ]; + expect(countInterviewReplies({ messages })).toBe(7); + expect( + countInterviewReplies({ + messages, + settlements: [ + { + submissionId: "closing", + outcome: "completed", + answeredBySubmissionId: "host", + }, + ], + }), + ).toBe(6); +}); + +test("the interview is closing only once the latest submission had no questions left", () => { + const answer = ( + submissionId: string, + level: "standard" | "thorough", + ): FlueConversationMessage => ({ + id: `answer-${submissionId}`, + submissionId, + role: "user", + purpose: "user", + display: "visible", + parts: [ + { + type: "text", + state: "done", + text: petrinautContextualUserMessageBody({ + userText: "Weekends too.", + diagnosticsContext: "", + submissionContext: { + [interviewBudgetContextKey]: getInterviewBudget(level, "text", 6), + }, + }), + }, + ], + }); + const messages = Array.from({ length: 6 }, (_, index) => + reply(`question-${index}`, "Which hours?"), + ); + expect(isInterviewClosing({ messages })).toBe(false); + messages.push(answer("closing", "standard")); + expect(isInterviewClosing({ messages })).toBe(true); + messages.push(reply("closing", "Stated: six agents. Open: arrival rate.")); + expect(isInterviewClosing({ messages })).toBe(true); + messages.push(answer("raised", "thorough")); + expect(isInterviewClosing({ messages })).toBe(false); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-interview-budget.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-interview-budget.ts new file mode 100644 index 00000000000..09b2a57d584 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-interview-budget.ts @@ -0,0 +1,78 @@ +import { + interviewBudgetContextKey, + parseInterviewBudget, +} from "@hashintel/brunch-agent-plugin-sdcpn"; +import { parsePetrinautUserMessageBody } from "@hashintel/brunch-agent-transport-aisdk"; + +import type { FlueConversationMessage, FlueConversationState } from "@flue/sdk"; + +const closesInterview = (message: FlueConversationMessage): boolean => + message.parts.some((part) => { + if (part.type !== "text") return false; + const body = parsePetrinautUserMessageBody(part.text); + return ( + parseInterviewBudget( + body.kind === "contextual" + ? body.submissionContext?.[interviewBudgetContextKey] + : undefined, + )?.remaining === 0 + ); + }); + +/** + * Count canonical replies, not AI SDK rendering entries (which can merge steps + * or lose text finality). The submitted allowance identifies wrap-up turns; + * their summaries do not spend another question. No inference from prose. + */ +export const countInterviewReplies = ({ + messages, + settlements = [], +}: Pick & + Partial>): number => { + const answeredBy = new Map( + settlements.flatMap(({ submissionId, answeredBySubmissionId }) => + answeredBySubmissionId === undefined + ? [] + : [[submissionId, answeredBySubmissionId] as const], + ), + ); + // A closing answer that joined a busy response is answered by the host + // submission's later messages; host replies before it were still questions. + const wrapUpSubmissions = new Set(); + let replies = 0; + for (const message of messages) { + if (message.purpose === "user" && message.submissionId !== undefined) { + if (closesInterview(message)) { + wrapUpSubmissions.add(message.submissionId); + const host = answeredBy.get(message.submissionId); + if (host !== undefined) wrapUpSubmissions.add(host); + } + } else if ( + message.role === "assistant" && + message.purpose === "assistant" && + message.display === "visible" && + !wrapUpSubmissions.has(message.submissionId ?? "") && + message.parts.some( + (part) => + part.type === "text" && part.state === "done" && part.text.trim(), + ) + ) { + replies++; + } + } + return replies; +}; + +/** + * Whether the latest submission was sent with no questions remaining, so + * Brunch is wrapping up rather than awaiting an answer to its last question. + */ +export const isInterviewClosing = ({ + messages, +}: Pick): boolean => { + const latest = messages.findLast( + (message) => + message.purpose === "user" && message.submissionId !== undefined, + ); + return latest !== undefined && closesInterview(latest); +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-control.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-control.test.tsx new file mode 100644 index 00000000000..7e2c206d293 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-control.test.tsx @@ -0,0 +1,215 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { type InterviewBudgetLevel } from "../../../shared/interview-budget"; +import { NoopResizeObserver } from "../shared/petrinaut-jsdom"; +import { + InterviewBudgetControl, + InterviewBudgetNote, + InterviewBudgetPill, +} from "./interview-budget-control"; + +import type { PetrinautAiComposerControlContext } from "@hashintel/petrinaut/ui"; + +beforeEach(() => vi.stubGlobal("ResizeObserver", NoopResizeObserver)); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const Harness = () => { + const [level, setLevel] = useState("standard"); + return ; +}; + +test.each([ + ["quick", "Quick · ~5 min"], + ["standard", "Standard · ~10 min"], + ["thorough", "Thorough · ~20 min"], + ["deep", "Deep · No limit"], + ["off", "Off"], +] as const)("keeps the %s note free of budget wording", (level, text) => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: true })), + ); + const { container } = render(); + expect(container.textContent).toBe(`Interview length changed to ${text}`); + expect( + screen.getByText("Interview length changed to").getAttribute("aria-hidden"), + ).toBeNull(); + expect(container.querySelector("svg")?.getAttribute("width")).toBe("12"); +}); + +test("opens a five-stop control with hover descriptions and keyboard-accessible level selection", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { + name: "Interview length: Standard · ~10 min", + }), + ); + const slider = await screen.findByRole("slider", { + name: "Interview length", + }); + expect(screen.getByRole("group", { name: "Interview length" })).toBeTruthy(); + expect(screen.getByText("Interview length")).toBeTruthy(); + expect(screen.getByText("Focus on the main steps.")).toBeTruthy(); + expect(slider.getAttribute("aria-valuetext")).toBe("Standard · ~10 min"); + vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({ + left: 100, + width: 308, + right: 408, + top: 0, + bottom: 32, + height: 32, + x: 100, + y: 0, + toJSON: () => ({}), + }); + fireEvent.mouseMove(slider, { clientX: 394 }); + expect(screen.getByText("Deep:", { selector: "strong" })).toBeTruthy(); + // Previewing a stop must not change the current selection in the header. + expect(slider.getAttribute("aria-valuetext")).toBe("Standard · ~10 min"); + fireEvent.mouseEnter(screen.getByRole("button", { name: "Deep" })); + expect( + screen.getByText(/Keep exploring, with pauses between topics/), + ).toBeTruthy(); + fireEvent.mouseEnter(screen.getByRole("button", { name: "Thorough" })); + expect(screen.getByText(/Explore details and exceptions/)).toBeTruthy(); + fireEvent.change(slider, { target: { value: "1" } }); + expect(slider.getAttribute("aria-valuetext")).toBe("Quick · ~5 min"); + fireEvent.click(screen.getByRole("button", { name: "Off" })); + expect( + screen.getByRole("button", { + name: "Interview length: Off · Usual pacing", + }), + ).toBeTruthy(); + expect(screen.getByText("Use Brunch’s usual pacing.")).toBeTruthy(); +}); + +test("Escape closes the control and returns focus to its trigger", async () => { + render(); + fireEvent.click( + screen.getByRole("button", { + name: "Interview length: Standard · ~10 min", + }), + ); + const slider = await screen.findByRole("slider", { + name: "Interview length", + }); + await waitFor(() => expect(document.activeElement).toBe(slider)); + fireEvent.keyDown(slider, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("slider")).toBeNull()); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { + name: "Interview length: Standard · ~10 min", + }), + ), + ); +}); + +test("pill uses the host count regardless of displayed messages, changes with mode and disappears for Off", async () => { + const context: PetrinautAiComposerControlContext = { + conversationId: "conversation", + status: "ready" as const, + messages: [], + stop: vi.fn(), + submitText: vi.fn(), + }; + const { rerender } = render( + , + ); + expect(screen.getByRole("status").textContent).toBe( + "1 question left · ~2 min", + ); + const trigger = screen.getByRole("status").parentElement; + if (!trigger) throw new Error("Missing estimate tooltip trigger"); + fireEvent.focus(trigger); + const card = await screen.findByRole("tooltip"); + expect(card.textContent).toContain("6 questions · 5 asked · 1 left"); + expect(card.textContent).toContain( + "Each Brunch reply before wrap-up counts as one question.", + ); + expect(card.textContent).toContain("your answer to the final question"); + expect(card.textContent).toContain("open items stay listed"); + rerender( + , + ); + expect(screen.getByRole("status").textContent).toBe("Last question"); + expect(card.textContent).toContain("your answer to this question"); + rerender( + , + ); + expect(screen.getByRole("status").textContent).toBe("Wrapping up"); + expect(card.textContent).toContain("Question limit reached."); + rerender( + , + ); + expect(screen.getByRole("status").textContent).toBe("Question 5 · no limit"); + expect(card.textContent).toContain( + "Brunch offers pauses between topics, with no question limit.", + ); + rerender( + , + ); + expect(screen.queryByRole("status")).toBeNull(); +}); + +test.each(["text", "voice"] as const)( + "reserves a blank row before the interview starts in %s", + (inputMode) => { + render( + , + ); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByText("Standard · ~10 min")).toBeNull(); + expect(document.querySelector("[data-budget-placeholder]")).not.toBeNull(); + }, +); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-control.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-control.tsx new file mode 100644 index 00000000000..fbf80845a0c --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-control.tsx @@ -0,0 +1,583 @@ +import { useEffect, useRef, useState } from "react"; +import { + PiGauge, + PiLightning, + PiMagnifyingGlass, + PiPower, + PiStack, +} from "react-icons/pi"; + +import { BaseTooltip, Button, Popover } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { + getInterviewBudget, + interviewBudgetLabel, + interviewBudgetLevels, + interviewBudgetLevelsConfig, + type InterviewBudgetLevel, +} from "../../../shared/interview-budget"; + +import type { PetrinautAiComposerControlContext } from "@hashintel/petrinaut/ui"; + +const lastStop = interviewBudgetLevels.length - 1; +const stopPercent = (index: number) => (index / lastStop) * 100; + +const icons = { + off: PiPower, + quick: PiLightning, + standard: PiGauge, + thorough: PiMagnifyingGlass, + deep: PiStack, +}; +const levelTheme = { + off: css({ + "--budget-color": "token(colors.neutral.s100)", + "--budget-tint": "token(colors.neutral.a10)", + }), + quick: css({ + "--budget-color": "token(colors.orange.s90)", + "--budget-tint": "token(colors.orange.a20)", + }), + standard: css({ + "--budget-color": "token(colors.blue.s90)", + "--budget-tint": "token(colors.blue.a20)", + }), + thorough: css({ + "--budget-color": "token(colors.green.s90)", + "--budget-tint": "token(colors.green.a20)", + }), + deep: css({ + "--budget-color": "token(colors.purple.s90)", + "--budget-tint": "token(colors.purple.a20)", + }), +}; + +export const InterviewBudgetControl = ({ + level, + onChange, +}: { + level: InterviewBudgetLevel; + onChange: (level: InterviewBudgetLevel) => void; +}) => { + const [open, setOpen] = useState(false); + const [hovered, setHovered] = useState(null); + const triggerRef = useRef(null); + const sliderRef = useRef(null); + // Toggling `disableTooltip` remounts the trigger, so the Popover's own focus + // return lands on a detached button. Restore it after an Escape dismissal. + const refocusTrigger = useRef(false); + useEffect(() => { + if (open || !refocusTrigger.current) return; + refocusTrigger.current = false; + triggerRef.current?.focus(); + }, [open]); + const config = interviewBudgetLevelsConfig[level]; + const CurrentIcon = icons[level]; + const selectedIndex = interviewBudgetLevels.indexOf(level); + const previewLevel = hovered ?? level; + const preview = interviewBudgetLevelsConfig[previewLevel]; + + return ( + + + ))} + +

+ {previewLevel !== level && ( + <> + + {preview.name}: + {" "} + + )} + {preview.description} +

+ + + )} +
+ ); +}; + +export const InterviewBudgetNote = ({ + level, +}: { + level: InterviewBudgetLevel; +}) => { + const ref = useRef(null); + const CurrentIcon = icons[level]; + const config = interviewBudgetLevelsConfig[level]; + useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const animation = ref.current?.animate( + [ + { opacity: 0.6, transform: "translateY(2px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { duration: 180, easing: "ease-out" }, + ); + return () => animation?.cancel(); + }, []); + return ( +
+
+ ); +}; + +export const InterviewBudgetPill = ({ + level, + context, + asked, + closing, +}: { + level: InterviewBudgetLevel; + context: PetrinautAiComposerControlContext; + asked: number; + /** The latest submission was sent with no questions remaining. */ + closing: boolean; +}) => { + const mode = context.inputMode ?? "text"; + const budget = getInterviewBudget(level, mode, asked); + // Keep the same footprint as the estimate, including its bottom padding. + if (!budget || asked === 0) + return ( + + ); +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-preference.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-preference.ts new file mode 100644 index 00000000000..111071944c2 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/interview-budget-preference.ts @@ -0,0 +1,47 @@ +import { + isInterviewBudgetLevel, + type InterviewBudgetLevel, +} from "../../../shared/interview-budget"; +import { readBrowserStorage, writeBrowserStorage } from "./browser-storage"; +import { usePersistedState } from "./use-persisted-state"; + +const interviewBudgetStorageKey = "petrinaut-website:interview-budget"; +const readInterviewBudget = (): InterviewBudgetLevel => { + const value = readBrowserStorage(localStorage, interviewBudgetStorageKey); + return isInterviewBudgetLevel(value) ? value : "standard"; +}; +const writeInterviewBudget = (level: InterviewBudgetLevel): void => + writeBrowserStorage(localStorage, interviewBudgetStorageKey, level); + +const interviewBudgetEnabledStorageKey = + "petrinaut-website:interview-budget-enabled"; +const readInterviewBudgetEnabled = (): boolean => + readBrowserStorage(localStorage, interviewBudgetEnabledStorageKey) === "true"; +const writeInterviewBudgetEnabled = (enabled: boolean): void => + writeBrowserStorage( + localStorage, + interviewBudgetEnabledStorageKey, + String(enabled), + ); + +export const useInterviewBudgetPreference = () => { + const [level, setLevel, levelReady] = usePersistedState( + { + fallback: "standard", + read: readInterviewBudget, + write: writeInterviewBudget, + }, + ); + const [enabled, setEnabled, enabledReady] = usePersistedState({ + fallback: false, + read: readInterviewBudgetEnabled, + write: writeInterviewBudgetEnabled, + }); + return { + level, + setLevel, + enabled, + setEnabled, + ready: levelReady && enabledReady, + }; +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx index e35c523e3fd..ca65f7f99af 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.test.tsx @@ -9,7 +9,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import { isValidElement, type ReactNode } from "react"; +import { Children, isValidElement, type ReactNode } from "react"; import { afterEach, describe, expect, test, vi } from "vitest"; import { brunchTools } from "@hashintel/brunch-agent/constants"; @@ -35,6 +35,7 @@ import { parseAssistantSelection, resolveDefaultAssistantSelection, } from "./assistant-selection"; +import { InterviewBudgetControl } from "./interview-budget-control"; import { LocalStorageDemoApp } from "./local-storage-demo-app"; import { voicePreferenceStorageKey } from "./voice-preference"; @@ -1483,6 +1484,117 @@ describe("assistant selection", () => { await waitFor(() => expect(currentVoiceProvider()).toBe("live")); }); + test.each([ + [undefined, "standard"], + ["thorough", "thorough"], + ["off", "off"], + ] as const)( + "gates interview length separately from saved level %s", + async (savedLevel, expectedLevel) => { + seedStoredNet(); + localStorage.setItem(assistantSelectionStorageKey, "brunch"); + if (savedLevel) + localStorage.setItem("petrinaut-website:interview-budget", savedLevel); + flueClientMock.current = flueHistoryClient(); + vi.stubGlobal("PointerEvent", MouseEvent); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + available: true, + provider: "live", + connectionTimeoutMs: 10_000, + }), + ), + ); + const context = { + conversationId: "labs-test", + messages: [], + status: "ready" as const, + stop: vi.fn(async () => {}), + submitText: vi.fn(), + }; + const expectBudget = (enabled: boolean) => { + const level = enabled ? expectedLevel : "off"; + expect(brunchPanelTransportOptions.current).toEqual( + expect.objectContaining({ interviewBudgetLevel: level }), + ); + const assistant = currentAssistant(); + const composer = assistant.renderComposerControl?.(context); + if (!isValidElement<{ children: ReactNode }>(composer)) + throw new Error("Missing composer controls"); + expect( + Children.toArray(composer.props.children).some( + (child) => + isValidElement(child) && child.type === InterviewBudgetControl, + ), + ).toBe(enabled); + expect(Boolean(assistant.renderComposerStatus?.(context))).toBe( + enabled, + ); + const voice = assistant.renderVoiceMode?.({ + ...context, + inputMode: "voice", + isAiAssistantOpen: true, + canAcceptVoiceInput: true, + registerVoiceModeControls: vi.fn(() => () => {}), + reportVoiceSessionState: vi.fn(), + setInputMode: vi.fn(), + setVoiceActive: vi.fn(), + submitVoiceInput: vi.fn(), + }); + if ( + !isValidElement<{ + interviewBudgetLevel: string; + onInputModeChange?: (mode: "text" | "voice") => void; + }>(voice) + ) + throw new Error("Missing Voice control"); + expect(voice.props.interviewBudgetLevel).toBe(level); + voice.props.onInputModeChange?.("voice"); + expect(brunchPanelTransportTracker.current?.inputMode).toBe("voice"); + voice.props.onInputModeChange?.("text"); + expect(brunchPanelTransportTracker.current?.inputMode).toBe("text"); + }; + const firstView = render( + {}} search={{}} />, + ); + const toggle = await screen.findByRole("checkbox", { + name: "Interview length", + }); + await waitFor(() => + expect(currentAssistant().renderVoiceMode).toBeDefined(), + ); + expect(toggle).toHaveProperty("checked", false); + expectBudget(false); + fireEvent.click(toggle); + await waitFor(() => expectBudget(true)); + expect( + localStorage.getItem("petrinaut-website:interview-budget-enabled"), + ).toBe("true"); + firstView.unmount(); + const restoredView = render( + {}} search={{}} />, + ); + await waitFor(() => expectBudget(true)); + const restoredToggle = screen.getByRole("checkbox", { + name: "Interview length", + }); + expect(restoredToggle).toHaveProperty("checked", true); + fireEvent.click(restoredToggle); + await waitFor(() => expectBudget(false)); + expect( + localStorage.getItem("petrinaut-website:interview-budget-enabled"), + ).toBe("false"); + expect(localStorage.getItem("petrinaut-website:interview-budget")).toBe( + savedLevel ?? null, + ); + restoredView.unmount(); + render( {}} search={{}} />); + await waitFor(() => expectBudget(false)); + }, + ); + test("a stored Brunch choice remains selectable and switching to Stock mounts nothing of Brunch", async () => { seedStoredNet(); localStorage.setItem(assistantSelectionStorageKey, "brunch"); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index d98f33281e7..864cce17e40 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -35,6 +35,10 @@ import { useSharedSearchNavigation, withClearedSharedLocation, } from "../../../examples/use-shared-search-navigation"; +import { + interviewBudgetLevelsConfig, + type InterviewBudgetLevel, +} from "../../../shared/interview-budget"; import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics"; import { BrunchPanelConversationTracker, @@ -95,8 +99,18 @@ import { type AssistantSelection, useAssistantSelection, } from "./assistant-selection"; +import { + countInterviewReplies, + isInterviewClosing, +} from "./brunch-interview-budget"; import { useActiveHandle } from "./documents/use-active-handle"; import { useDocumentController } from "./documents/use-document-controller"; +import { + InterviewBudgetControl, + InterviewBudgetNote, + InterviewBudgetPill, +} from "./interview-budget-control"; +import { useInterviewBudgetPreference } from "./interview-budget-preference"; import { UnsavedChangeNotice } from "./unsaved-change-notice"; import { useRealtimePreference, useVoicePreference } from "./voice-preference"; @@ -159,6 +173,16 @@ const useProcessAgentSession = (input: { * The demo's own palette commands, registered beside Petrinaut's. * Brunch and Stock are selected through the product UI. */ +/** Each message's 1-based position among messages of the same role. */ +const ordinalsByRole = (messages: readonly PetrinautAiMessage[]): number[] => { + const counts = new Map(); + return messages.map((message) => { + const ordinal = (counts.get(message.role) ?? 0) + 1; + counts.set(message.role, ordinal); + return ordinal; + }); +}; + const DemoCommands = ({ brunchSelected, selectAssistant, @@ -210,6 +234,25 @@ export const LocalStorageDemoApp = ({ ready: voicePreferenceReady, setEnabled: setVoiceEnabled, } = useVoicePreference(); + const { + level: selectedInterviewBudgetLevel, + setLevel: setInterviewBudgetLevel, + enabled: interviewBudgetEnabled, + setEnabled: setInterviewBudgetEnabled, + ready: interviewBudgetPreferenceReady, + } = useInterviewBudgetPreference(); + const interviewBudgetLevel = + interviewBudgetEnabled && interviewBudgetPreferenceReady + ? selectedInterviewBudgetLevel + : "off"; + const [budgetNotes, setBudgetNotes] = useState< + { + conversationId: string; + after: { id: string; role: PetrinautAiMessage["role"]; ordinal: number }; + level: InterviewBudgetLevel; + message: PetrinautAiMessage; + }[] + >([]); const { enabled: realtimeEnabled, ready: realtimePreferenceReady, @@ -436,6 +479,12 @@ export const LocalStorageDemoApp = ({ constructionClientTools, dynamicClientToolNames, ); + const interviewRepliesAsked = countInterviewReplies( + flueHistory.snapshot ?? { messages: [] }, + ); + const interviewClosing = isInterviewClosing( + flueHistory.snapshot ?? { messages: [] }, + ); const replayBindingKey = constructionBrowser ? `${constructionBrowser.binding.documentId}:${constructionBrowser.binding.conversationId}` : undefined; @@ -497,6 +546,7 @@ export const LocalStorageDemoApp = ({ flueHistory.settlements, flueHistory.snapshot, mediationHistory, + interviewBudgetLevel, (toolCallId) => mutationApproval.coordinator.approvalState(toolCallId), ), [ @@ -505,6 +555,7 @@ export const LocalStorageDemoApp = ({ flueHistory.settlements, flueHistory.snapshot, mediationHistory, + interviewBudgetLevel, mutationApproval, openAIVoiceConfig, realtimeEnabled, @@ -520,6 +571,8 @@ export const LocalStorageDemoApp = ({ transportClientPromise, conversationTracker, { + interviewBudgetLevel, + interviewRepliesAsked, ...(constructionBrowser ? { initialData: { binding: constructionBrowser.binding } } : {}), @@ -567,6 +620,8 @@ export const LocalStorageDemoApp = ({ flueHistory.refresh, reportBrunchFailure, transportClientPromise, + interviewBudgetLevel, + interviewRepliesAsked, ]); const inBandBrowserTools = useMemo( @@ -644,12 +699,93 @@ export const LocalStorageDemoApp = ({ ? { primaryLabel: "Chat", presentation: "brunch" as const, - mapMessagesForDisplay: mapVoiceMessages, + mapMessagesForDisplay: (messages: PetrinautAiMessage[]) => { + // Canonical history re-identifies an optimistic user message + // once its turn settles, so an anchor whose id is gone falls + // back to its position among messages of the same role. + const ids = new Set(messages.map((message) => message.id)); + const ordinals = ordinalsByRole(messages); + const withNotes = messages.flatMap((message, index) => [ + message, + ...budgetNotes + .filter( + (note) => + note.conversationId === conversationId && + (ids.has(note.after.id) + ? note.after.id === message.id + : note.after.role === message.role && + note.after.ordinal === ordinals[index]), + ) + .map((note) => note.message), + ]); + return mapVoiceMessages?.(withNotes) ?? withNotes; + }, + renderSystemMessage: (message: PetrinautAiMessage) => { + const note = budgetNotes.find( + (entry) => entry.message.id === message.id, + ); + return note ? ( + + ) : undefined; + }, resolveToolPresentation: resolveBrunchToolPresentation, workingLabel: "Working…", renderComposerControl: ( context: PetrinautAiComposerControlContext, - ) => , + ) => ( + <> + + {interviewBudgetEnabled && ( + { + if (level === interviewBudgetLevel) return; + const last = context.messages.at(-1); + const lastOrdinal = ordinalsByRole(context.messages).at( + -1, + ); + if (last && lastOrdinal !== undefined) { + const config = interviewBudgetLevelsConfig[level]; + setBudgetNotes((notes) => [ + ...notes, + { + conversationId: context.conversationId, + level, + after: { + id: last.id, + role: last.role, + ordinal: lastOrdinal, + }, + message: { + id: `interview-budget:${crypto.randomUUID()}`, + role: "system", + parts: [ + { + type: "text", + text: `${config.name} · ${config.guide}`, + }, + ], + }, + }, + ]); + } + setInterviewBudgetLevel(level); + }} + /> + )} + + ), + renderComposerStatus: ( + context: PetrinautAiComposerControlContext, + ) => + interviewBudgetEnabled ? ( + + ) : null, } : {}), ...(conversationId === null ? {} : { conversationId }), @@ -722,6 +858,12 @@ export const LocalStorageDemoApp = ({ }; }, [ aiMessagesByNetId, + budgetNotes, + interviewBudgetEnabled, + interviewBudgetLevel, + interviewRepliesAsked, + interviewClosing, + setInterviewBudgetLevel, mapVoiceMessages, brunchSelected, brunchVoiceMode, @@ -779,10 +921,15 @@ export const LocalStorageDemoApp = ({ assistantReady={assistantSelectionReady} brunchConfigured={brunchPreviewConfig.isBrunchConfigured} brunchSelected={brunchSelected} + interviewBudgetEnabled={interviewBudgetEnabled} + interviewBudgetPreferenceReady={ + interviewBudgetPreferenceReady + } openAIVoiceConfig={openAIVoiceConfig} realtimeEnabled={realtimeEnabled} realtimePreferenceReady={realtimePreferenceReady} selectAssistant={selectAssistant} + setInterviewBudgetEnabled={setInterviewBudgetEnabled} setRealtimeEnabled={setRealtimeEnabled} setVoiceEnabled={setVoiceEnabled} voiceEnabled={brunchSelected && voiceEnabled} diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.test.ts index 7ce709c8c76..a81faf12626 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.test.ts @@ -1,6 +1,13 @@ import { FlueApiError } from "@flue/sdk"; import { expect, test, vi } from "vitest"; +import { + interviewBudgetContextKey, + parseInterviewBudget, +} from "@hashintel/brunch-agent-plugin-sdcpn"; +import { parsePetrinautUserMessageBody } from "@hashintel/brunch-agent-transport-aisdk"; + +import { interviewBudgetLevels } from "../../../../shared/interview-budget"; import { BrunchPanelConversationTracker, createBrunchPanelTransport, @@ -10,6 +17,138 @@ import { canonicalPetrinautClientToolNames } from "./tools/brunch-client-tools"; import type { AgentSendResult, FlueClient } from "@flue/sdk"; +test.each(interviewBudgetLevels)( + "sends current %s budget on each submission, with Off identical to the legacy request", + async (level) => { + const send = vi.fn(async () => { + throw new FlueApiError(503, "test admission unavailable"); + }); + const initialData = { + binding: { + documentId: "document", + conversationId: "conversation", + incarnationId: "incarnation", + }, + }; + for (const source of ["text", "voice"] as const) { + for (const asked of [0, 2, 9]) { + const transport = createBrunchPanelTransport( + Promise.resolve({ send } as unknown as FlueClient), + new BrunchPanelConversationTracker(), + { + initialData, + interviewBudgetLevel: level, + interviewRepliesAsked: asked, + }, + ); + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "conversation", + messageId: undefined, + abortSignal: undefined, + messages: [ + // Rendered messages can merge steps or include a wrap-up. They + // are deliberately different from the host's canonical count. + { + id: "reply", + role: "assistant", + parts: [{ type: "text", text: "Recorded." }], + }, + { + id: "answer", + role: "user", + ...(source === "voice" ? { metadata: { source } } : {}), + parts: [{ type: "text", text: "Four agents" }], + }, + ], + }), + ).rejects.toThrow(); + const request = send.mock.lastCall?.[0]; + if (level === "off") { + expect(JSON.stringify(request)).toBe( + JSON.stringify({ + idempotencyKey: "ai-sdk:user:answer", + message: { kind: "user", body: "Four agents" }, + initialData, + }), + ); + } else { + const caps = + source === "text" + ? { quick: 3, standard: 6, thorough: 10, deep: null } + : { quick: 2, standard: 4, thorough: 7, deep: null }; + const questionCap = caps[level]; + const budget = { + level, + questionCap, + asked, + remaining: + questionCap === null ? null : Math.max(0, questionCap - asked), + }; + expect(request?.initialData).toEqual(initialData); + expect( + parsePetrinautUserMessageBody(request?.message.body ?? ""), + ).toEqual({ + kind: "contextual", + userText: "Four agents", + diagnosticsContext: "", + submissionContext: { [interviewBudgetContextKey]: budget }, + }); + expect(parseInterviewBudget(budget)).toEqual(budget); + } + } + } + }, +); + +test.each([ + ["text sent during Voice", undefined, "voice", 4], + ["queued Voice input sent after switching to text", "voice", "text", 6], +] as const)( + "budgets %s by the panel's input mode, matching the estimate", + async (_case, source, inputMode, questionCap) => { + const send = vi.fn(async () => { + throw new FlueApiError(503, "test admission unavailable"); + }); + const tracker = new BrunchPanelConversationTracker(); + tracker.recordInputMode(inputMode); + const transport = createBrunchPanelTransport( + Promise.resolve({ send } as unknown as FlueClient), + tracker, + { interviewBudgetLevel: "standard", interviewRepliesAsked: 1 }, + ); + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "conversation", + messageId: undefined, + abortSignal: undefined, + messages: [ + { + id: "answer", + role: "user", + ...(source === undefined ? {} : { metadata: { source } }), + parts: [{ type: "text", text: "Four agents" }], + }, + ], + }), + ).rejects.toThrow(); + expect( + parsePetrinautUserMessageBody(send.mock.lastCall?.[0].message.body ?? ""), + ).toMatchObject({ + submissionContext: { + [interviewBudgetContextKey]: { + level: "standard", + questionCap, + asked: 1, + remaining: questionCap - 1, + }, + }, + }); + }, +); + test("publishes Stop immediately and supports unsubscribe", () => { const tracker = new BrunchPanelConversationTracker(); const listener = vi.fn(); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.ts index baddac1a570..5b3b538e4e6 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport.ts @@ -1,29 +1,33 @@ +import { interviewBudgetContextKey } from "@hashintel/brunch-agent-plugin-sdcpn"; import { createFlueChatTransport, FlueChatAdmissionError, } from "@hashintel/brunch-agent-transport-aisdk"; -import { SWEEP_TOOL_NAME } from "@hashintel/brunch-agent/client-tools"; +import { + getInterviewBudget, + type InterviewBudgetLevel, +} from "../../../../shared/interview-budget"; import { canonicalPetrinautClientToolNames } from "./tools/brunch-client-tools"; -import { sweepOutputSchema } from "./tools/brunch-sweep-output"; -import type { - SweepCapture, - SweepCompletionFailure, - SweepCompletionReport, -} from "./tools/brunch-sweep-output"; import type { AgentSendResult, FlueClient, FlueConversationState, } from "@flue/sdk"; +import type { + BrowserContext, + InterviewBudget, +} from "@hashintel/brunch-agent-plugin-sdcpn"; import type { FlueChatResponseMessageCompletedEvent, FlueChatResponseMessageStartedEvent, FlueChatTransportOptions, } from "@hashintel/brunch-agent-transport-aisdk"; -import type { PetrinautAiChatTransport } from "@hashintel/petrinaut/ui"; -import type { UIMessageChunk } from "ai"; +import type { + PetrinautAiChatTransport, + PetrinautAiInputMode, +} from "@hashintel/petrinaut/ui"; export type BrunchPanelAdmission = Parameters< NonNullable @@ -84,6 +88,20 @@ export class BrunchPanelConversationTracker { (event: FlueChatResponseMessageCompletedEvent) => void >(); readonly #stopRequestedListeners = new Set<() => void>(); + #inputMode: PetrinautAiInputMode | undefined; + + /** + * The panel's input surface as reported by Voice, so a text turn sent + * during Voice gets the same allowance as the estimate. Until Voice + * reports, each message's own source decides. + */ + public get inputMode(): PetrinautAiInputMode | undefined { + return this.#inputMode; + } + + public recordInputMode(mode: PetrinautAiInputMode): void { + this.#inputMode = mode; + } public recordAdmission(admission: BrunchPanelAdmission): void { this.#admittedSubmissionIds.add(admission.admission.submissionId); @@ -206,128 +224,15 @@ export class BrunchPanelConversationTracker { } } -const formatFailure = (failure: SweepCompletionFailure): string => { - const location = - failure.nodeId === undefined - ? "" - : ` at ${failure.nodeId}${failure.slot === undefined ? "" : `.${failure.slot}`}`; - const captures = - failure.captureIds.length === 0 - ? "" - : ` Captures: ${failure.captureIds.join(", ")}`; - return `Completion gap [${failure.diagnostic}]${location}: needs ${failure.requirement}; actual ${failure.actual}. ${failure.message}${captures}`; -}; - -const formatCapture = (capture: SweepCapture): string => { - const content = - "value" in capture.content - ? JSON.stringify(capture.content.value) - : `absence: ${capture.content.absence}`; - const provenance = - capture.evidence !== undefined - ? capture.evidence.map((evidence) => `“${evidence.excerpt}”`).join("; ") - : capture.basis === undefined - ? "no provenance" - : `${capture.basis.type}: ${capture.basis.description}`; - const history = [ - capture.alternativeGroup === undefined - ? undefined - : `alternative group ${capture.alternativeGroup}`, - capture.supersedes === undefined - ? undefined - : `supersedes ${capture.supersedes}`, - ].filter((fact) => fact !== undefined); - return `Capture ${capture.id} (${capture.status}; ${capture.epistemicStatus}; confidence ${capture.confidence}): ${content} — ${provenance}${history.length === 0 ? "" : `; ${history.join("; ")}`}`; -}; - -const formatCompletion = (report: SweepCompletionReport): string[] => [ - `Completion: ${report.complete ? "complete" : "incomplete"} · plugin ${report.pluginVersion} · revision ${report.revision}`, - `Completion slice: ${report.sliceNodeIds.join(", ") || "none"}`, - ...report.failures.map(formatFailure), - ...report.outsideSlice.flatMap((node) => [ - `Outside completion slice: ${node.nodeId} (${node.kind}); ${node.open.length} open requirement${node.open.length === 1 ? "" : "s"}`, - ...node.open.map((failure) => `Outside-slice ${formatFailure(failure)}`), - ]), -]; - -const summarizeSweepOutput = ( - output: unknown, -): - | { - readonly title: string; - readonly detail: string; - readonly items?: readonly string[]; - } - | undefined => { - const parsed = sweepOutputSchema.safeParse(output); - if (!parsed.success) return undefined; - - const sweep = parsed.data; - switch (sweep.status) { - case "no-settled-range": - return { - title: "Nothing confirmed to sweep yet", - detail: "None of your messages in this conversation are confirmed yet.", - }; - case "refused": - return { - title: "Sweep refused", - detail: sweep.refusal.message, - items: [`Refusal: ${sweep.refusal.code}`], - }; - case "applied": - return { - title: "Sweep applied", - detail: `${sweep.appliedCaptureIds.length} new capture${sweep.appliedCaptureIds.length === 1 ? "" : "s"} · ${sweep.captures.length} total · ${sweep.completion?.complete === true ? "complete" : "incomplete"}`, - items: [ - ...sweep.captures.map(formatCapture), - ...(sweep.completion === undefined - ? [] - : formatCompletion(sweep.completion)), - ], - }; - } -}; - -const decorateBrunchStream = ( - stream: ReadableStream, -): ReadableStream => { - const toolNamesByCallId = new Map(); - return stream.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - if (chunk.type === "tool-input-available") { - toolNamesByCallId.set(chunk.toolCallId, chunk.toolName); - } - if ( - chunk.type === "tool-output-available" && - toolNamesByCallId.get(chunk.toolCallId) === SWEEP_TOOL_NAME - ) { - const summary = summarizeSweepOutput(chunk.output); - if ( - summary !== undefined && - typeof chunk.output === "object" && - chunk.output !== null - ) { - controller.enqueue({ - ...chunk, - output: { ...chunk.output, ...summary }, - }); - return; - } - } - controller.enqueue(chunk); - }, - }), - ); -}; - /** Adapt one mounted Flue conversation to Petrinaut's AI SDK rendering contract. */ export const createBrunchPanelTransport = ( clientPromise: Promise, tracker: BrunchPanelConversationTracker, options?: { - readonly initialData?: FlueChatTransportOptions["initialData"]; + readonly initialData?: BrowserContext; + readonly interviewBudgetLevel?: InterviewBudgetLevel; + /** The host's canonical count, shared with the estimate above the composer. */ + readonly interviewRepliesAsked?: number; /** Browser tools executed by Petrinaut's static panel registry. */ readonly clientToolNames?: ReadonlySet; readonly dynamicClientToolNames?: FlueChatTransportOptions["dynamicClientToolNames"]; @@ -342,11 +247,26 @@ export const createBrunchPanelTransport = ( tracker.trackSubmission( (async () => { const client = await clientPromise; + const budget = getInterviewBudget( + options?.interviewBudgetLevel ?? "off", + tracker.inputMode ?? + (sendOptions.messages.at(-1)?.metadata?.source === "voice" + ? "voice" + : "text"), + options?.interviewRepliesAsked ?? 0, + ); const transport = createFlueChatTransport({ client, ...(options?.initialData === undefined ? {} : { initialData: options.initialData }), + ...(budget === undefined + ? {} + : { + submissionContext: { + [interviewBudgetContextKey]: budget satisfies InterviewBudget, + }, + }), clientToolNames: options?.clientToolNames ?? canonicalPetrinautClientToolNames, dynamicClientToolNames: options?.dynamicClientToolNames, @@ -362,9 +282,7 @@ export const createBrunchPanelTransport = ( onToolOutputError: options?.onToolOutputError, }); try { - return decorateBrunchStream( - await transport.sendMessages(sendOptions), - ); + return await transport.sendMessages(sendOptions); } catch (error) { const messageId = sendOptions.messageId ?? sendOptions.messages.at(-1)?.id; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-sweep-output.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-sweep-output.ts deleted file mode 100644 index a200704591b..00000000000 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-sweep-output.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { z } from "zod"; - -const completionFailureSchema = z.object({ - diagnostic: z.string(), - nodeId: z.string().optional(), - kind: z.string().optional(), - slot: z.string().optional(), - requirement: z.string(), - actual: z.string(), - message: z.string(), - captureIds: z.array(z.string()), -}); - -const completionReportSchema = z.object({ - complete: z.boolean(), - pluginVersion: z.string(), - revision: z.string(), - failures: z.array(completionFailureSchema), - sliceNodeIds: z.array(z.string()), - outsideSlice: z.array( - z.object({ - nodeId: z.string(), - kind: z.string(), - open: z.array(completionFailureSchema), - }), - ), -}); - -const captureSchema = z.object({ - id: z.string(), - status: z.enum(["active", "superseded", "retracted"]), - epistemicStatus: z.string(), - confidence: z.string(), - content: z.union([ - z.object({ value: z.unknown() }), - z.object({ absence: z.string() }), - ]), - evidence: z.array(z.object({ excerpt: z.string() })).optional(), - basis: z - .object({ - type: z.string(), - description: z.string(), - }) - .optional(), - alternativeGroup: z.string().optional(), - supersedes: z.string().optional(), -}); - -/** Shape of the Brunch sweep client tool's output, as read by the app. */ -export const sweepOutputSchema = z.discriminatedUnion("status", [ - z.object({ status: z.literal("no-settled-range") }), - z.object({ - status: z.literal("refused"), - refusal: z.object({ - code: z.string(), - message: z.string(), - }), - }), - z.object({ - status: z.literal("applied"), - appliedCaptureIds: z.array(z.string()), - captures: z.array(captureSchema), - completion: completionReportSchema.optional(), - }), -]); - -export type SweepCompletionFailure = z.infer; -export type SweepCompletionReport = z.infer; -export type SweepCapture = z.infer; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.tsx b/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.tsx index 65b14993aeb..58696898cf4 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.tsx @@ -3,6 +3,7 @@ import { type OpenAIVoiceConfig, } from "./session/voice-interview-control"; +import type { InterviewBudgetLevel } from "../../../../shared/interview-budget"; import type { BrunchPanelAdmissionTarget, BrunchPanelConversationTracker, @@ -29,6 +30,7 @@ export const getBrunchVoiceMode = ( settlements?: readonly FlueConversationSettlement[], snapshot?: FlueConversationState, mediationHistory?: VoiceMediationHistory, + interviewBudgetLevel?: InterviewBudgetLevel, toolApprovalState?: (toolCallId: string) => ToolApprovalState | null, ): PetrinautAiVoiceMode | undefined => { if (!config) return undefined; @@ -51,12 +53,15 @@ export const getBrunchVoiceMode = ( ); const subscribeToAdmissionFailure = tracker?.subscribeToAdmissionFailure.bind(tracker); + const recordInputMode = tracker?.recordInputMode.bind(tracker); return (context: PetrinautAiVoiceModeContext) => ( ({ appendInstructions: vi.fn(() => true), appendThinking: vi.fn(() => true), speechPending: vi.fn(() => true), + setInterviewBudgetLevel: vi.fn(), setMicrophoneMuted: liveConversationMocks.setMicrophoneMuted, setSpeakerMuted: liveConversationMocks.setSpeakerMuted, setSpeakerVolume: liveConversationMocks.setSpeakerVolume, @@ -1839,6 +1840,70 @@ test.each(["answer", "folded-answer"])( }, ); +test("pacing failures are quiet, recover on acknowledgement and preserve other append warnings", async () => { + const props = context(); + render(); + await start(); + const call = vi.mocked(createLiveConversation).mock.lastCall!; + act(() => + call[0]({ + phase: "connected", + message: null, + interviewBudgetUpdate: "pending", + }), + ); + expect(props.reportVoiceSessionState).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "listening", + notice: "Updating Live pacing…", + warningMessage: null, + }), + ); + act(() => + call[0]({ + phase: "connected", + message: null, + interviewBudgetUpdate: "failed", + }), + ); + expect(props.reportVoiceSessionState).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "listening", + warningMessage: expect.stringContaining( + "Live pacing wasn’t updated.", + ) as unknown, + errorMessage: null, + }), + ); + act(() => call[0]({ phase: "connected", message: null })); + expect(props.reportVoiceSessionState).toHaveBeenLastCalledWith( + expect.objectContaining({ warningMessage: null, notice: null }), + ); + act(() => + call[4]({ + eventId: "answer", + kind: "commentary", + delegationId: null, + status: "rejected", + }), + ); + act(() => + call[0]({ + phase: "connected", + message: null, + interviewBudgetUpdate: "failed", + }), + ); + act(() => call[0]({ phase: "connected", message: null })); + expect(props.reportVoiceSessionState).toHaveBeenLastCalledWith( + expect.objectContaining({ + warningMessage: expect.stringContaining( + "Couldn’t speak the answer.", + ) as unknown, + }), + ); +}); + test.each(["commentary", "instructions"] as const)( "%s pending and accepted appends do not raise errors or replace actual failures", async (kind) => { diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.tsx b/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.tsx index 8615d9a2435..ae77de27df1 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.tsx @@ -23,6 +23,7 @@ import { } from "./live-conversation"; import { LiveSpeechCaptions } from "./live-speech-captions"; +import type { InterviewBudgetLevel } from "../../../../../shared/interview-budget"; import type { VoiceInterviewControl } from "../session/voice-interview-control"; import type { PetrinautAiVoiceModeContext } from "@hashintel/petrinaut/ui"; @@ -40,6 +41,7 @@ type LiveControlsContext = PetrinautAiVoiceModeContext & | "subscribeToStopRequested" | "toolApprovalState" > & { + readonly interviewBudgetLevel?: InterviewBudgetLevel; readonly mediationHistory?: VoiceMediationHistory; readonly acknowledgeDisclosure: () => void; readonly submit: ConstructorParameters< @@ -87,6 +89,7 @@ const createLiveMediation = ( }); export const LiveConversationControl = ({ + interviewBudgetLevel = "off", mediationHistory, acknowledgeDisclosure, inputMode, @@ -140,7 +143,8 @@ export const LiveConversationControl = ({ phase: "idle", message: null, }); - const { phase, activity, message, playbackBlocked } = state; + const { phase, activity, message, playbackBlocked, interviewBudgetUpdate } = + state; const session = useRef | null>( null, ); @@ -368,6 +372,7 @@ export const LiveConversationControl = ({ }, closed: () => captions.close(), }, + interviewBudgetLevel, ); next.setMicrophoneMuted(false); next.setSpeakerMuted(false); @@ -394,7 +399,16 @@ export const LiveConversationControl = ({ setVoiceActive(true); void next.start(); return true; - }, [audioSettingsStore, connectionTimeoutMs, phase, setVoiceActive]); + }, [ + audioSettingsStore, + connectionTimeoutMs, + phase, + setVoiceActive, + interviewBudgetLevel, + ]); + useEffect(() => { + session.current?.setInterviewBudgetLevel(interviewBudgetLevel); + }, [interviewBudgetLevel, state.phase]); useLayoutEffect(() => { if (inputMode !== "voice" || !isAiAssistantOpen) { handledVoiceSelection.current = false; @@ -504,10 +518,18 @@ export const LiveConversationControl = ({ : (activity?.microphoneLevel ?? 0), microphoneMuted: phase === "error" || microphoneMuted, errorMessage: phase === "error" ? message : null, - notice: playbackBlocked ? message : null, + notice: playbackBlocked + ? message + : interviewBudgetUpdate === "pending" + ? "Updating Live pacing…" + : null, speakerMuted, speakerVolume, - warningMessage, + warningMessage: + warningMessage ?? + (interviewBudgetUpdate === "failed" + ? "Live pacing wasn’t updated. Choose another interview length to retry; Brunch’s question limit still applies." + : null), ...(playbackBlocked ? { canRetryPlayback: true } : {}), } : null, @@ -520,6 +542,7 @@ export const LiveConversationControl = ({ sessionPhase, message, playbackBlocked, + interviewBudgetUpdate, activity, status, stopped, diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.test.ts b/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.test.ts index 54b6eaf7c51..6f80eee270c 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.test.ts @@ -200,7 +200,7 @@ const setup = ({ ); const getUserMedia = vi.fn(async () => stream); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); - const fetch = vi.fn(async (url: string) => + const fetch = vi.fn(async (url: string, _init?: RequestInit) => Response.json( url.endsWith("transcription-session") ? { sdp: "v=0\r\no=transcription-answer" } @@ -1516,6 +1516,122 @@ test("telemetry shows activity but silence and late samples never settle or revi expect(fixture.onState).toHaveBeenCalledTimes(calls); }); +test("sends the startup budget once and coalesces quiet session-wide changes", async () => { + const fixture = setup(); + fixture.conversation.setInterviewBudgetLevel("standard"); + await connect(fixture); + const startup = fixture.fetch.mock.calls.find( + ([url]) => url === "/api/voice/live-session", + ); + expect( + new Headers(startup?.[1]?.headers).get("x-petrinaut-interview-budget"), + ).toBe("standard"); + fixture.conversation.setInterviewBudgetLevel("standard"); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(0); + fixture.conversation.setInterviewBudgetLevel("quick"); + fixture.conversation.setInterviewBudgetLevel("deep"); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(1); + expect(JSON.parse(fixture.sent[0][0]!)).toMatchObject({ + type: "session.thinking.append", + delegation_id: null, + }); + expect(fixture.sent[0][0]).toContain("Deep (No limit)"); + fixture.conversation.setInterviewBudgetLevel("off"); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(1); + fixture.emit(0, { + type: "session.thinking.appended", + client_event_id: fixture.onAppendResult.mock.lastCall![0].eventId, + }); + await Promise.resolve(); + expect(JSON.parse(fixture.sent[0][1]!)).toMatchObject({ + type: "session.thinking.append", + delegation_id: null, + }); + expect(fixture.sent[0][1]).toContain("now Off"); +}); + +test.each(["rejected", "local-failure"] as const)( + "a %s pacing update stays retryable without interrupting or replaying speech", + async (failure) => { + const fixture = setup(); + await connect(fixture); + if (failure === "local-failure") { + vi.spyOn(fixture.channels[0], "send").mockImplementationOnce(() => { + throw new Error("send unavailable"); + }); + } + fixture.conversation.setInterviewBudgetLevel("deep"); + await Promise.resolve(); + const pending = fixture.onAppendResult.mock.lastCall![0]; + if (failure === "rejected") { + expect(fixture.onState.mock.lastCall![0]).toMatchObject({ + phase: "connected", + interviewBudgetUpdate: "pending", + }); + // An unacknowledged append must not be replayed by a rerender. + fixture.conversation.setInterviewBudgetLevel("deep"); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(1); + fixture.emit(0, { type: "error", client_event_id: pending.eventId }); + } + await Promise.resolve(); + expect(fixture.onState.mock.lastCall![0]).toMatchObject({ + phase: "connected", + interviewBudgetUpdate: "failed", + }); + const sends = fixture.sent[0].length; + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(sends); + // Explicit retry of the same desired level is allowed after known failure. + fixture.conversation.setInterviewBudgetLevel("deep"); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(sends + 1); + const retry = fixture.onAppendResult.mock.lastCall![0]; + fixture.emit(0, { + type: "session.thinking.appended", + client_event_id: pending.eventId, + }); + expect(fixture.onState.mock.lastCall![0].interviewBudgetUpdate).toBe( + "pending", + ); + fixture.emit(0, { + type: "session.thinking.appended", + client_event_id: retry.eventId, + }); + expect( + fixture.onState.mock.lastCall![0].interviewBudgetUpdate, + ).toBeUndefined(); + fixture.conversation.setInterviewBudgetLevel("deep"); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(sends + 1); + expect( + fixture.sent[0].every((event) => + event.includes("session.thinking.append"), + ), + ).toBe(true); + expect(fixture.audio.pause).not.toHaveBeenCalled(); + }, +); + +test("coalesces changes behind an acknowledgement and keeps the latest selection after rejection", async () => { + const fixture = setup(); + await connect(fixture); + fixture.conversation.setInterviewBudgetLevel("quick"); + await Promise.resolve(); + const first = fixture.onAppendResult.mock.lastCall![0]; + fixture.conversation.setInterviewBudgetLevel("standard"); + fixture.conversation.setInterviewBudgetLevel("thorough"); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(1); + fixture.emit(0, { type: "error", client_event_id: first.eventId }); + await Promise.resolve(); + expect(fixture.sent[0]).toHaveLength(2); + expect(fixture.sent[0][1]).toContain("Thorough"); +}); + test("progress commentary has a null delegation and only the later wrap-up closes the local delegation", async () => { const fixture = setup(); await connect(fixture); diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.ts b/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.ts index a775e16d164..ab2845e34c1 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.ts +++ b/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.ts @@ -1,3 +1,8 @@ +import { + interviewBudgetHeader, + liveInterviewBudgetInstruction, + type InterviewBudgetLevel, +} from "../../../../../shared/interview-budget"; import { voicePreferenceHeader } from "../../../../../shared/voice-settings"; import { logLiveDiagnostic } from "../shared/live-diagnostic"; import { @@ -25,6 +30,8 @@ export interface LiveConversationState { | "error"; readonly message: string | null; readonly playbackBlocked?: boolean; + /** Quiet pacing context, independent of speech and connection health. */ + readonly interviewBudgetUpdate?: "pending" | "failed"; /** Local media activity for the dock, never a turn or playback-completion signal. */ readonly activity?: { readonly microphoneLevel: number; @@ -72,6 +79,7 @@ export const createLiveConversation = ( readonly output: (fragment: LiveTranscriptFragment) => void; readonly closed: () => void; }, + initialBudgetLevel: InterviewBudgetLevel = "off", ) => { const abort = new AbortController(); const sessionId = crypto.randomUUID(); @@ -113,6 +121,16 @@ export const createLiveConversation = ( let speakerMuted = false; let speakerVolume = 1; let voice = "marin"; + let budgetLevel = initialBudgetLevel; + let acknowledgedBudgetLevel = initialBudgetLevel; + let pendingBudgetAppend: + | { eventId: string; level: InterviewBudgetLevel } + | undefined; + let budgetUpdateFailed = false; + let budgetSyncQueued = false; + // Assigned before start() installs event listeners; acknowledgements can + // schedule another append, so these handlers reference each other. + let syncInterviewBudget: () => void; let detachAudioSettings: (() => void) | undefined; let started = false; let playbackBlocked = false; @@ -139,10 +157,32 @@ export const createLiveConversation = ( message: playbackBlocked ? "Audio blocked. Select Play to listen." : null, ...(playbackBlocked ? { playbackBlocked: true } : {}), ...(activity ? { activity } : {}), + ...(pendingBudgetAppend + ? { interviewBudgetUpdate: "pending" } + : budgetUpdateFailed + ? { interviewBudgetUpdate: "failed" } + : {}), }); const reportAppendResult = (result: LiveAppendResult) => { logLiveDiagnostic("append.result", { sessionId, ...result }); + if (pendingBudgetAppend?.eventId === result.eventId) { + const level = pendingBudgetAppend.level; + if (result.status !== "unknown") { + pendingBudgetAppend = undefined; + if (result.status === "accepted") acknowledgedBudgetLevel = level; + budgetUpdateFailed = + result.status !== "accepted" && budgetLevel === level; + // Send a newer selection, never retry the failed/uncertain one. + if (budgetLevel !== level) syncInterviewBudget(); + } + onState( + activeState( + recoveryTimers.size === 0 ? "connected" : "connecting", + lastActivity, + ), + ); + } onAppendResult(result); }; @@ -153,6 +193,7 @@ export const createLiveConversation = ( detachAudioSettings?.(); detachAudioSettings = undefined; pendingAppends.clear(); + pendingBudgetAppend = undefined; openDelegations.clear(); clearTimeout(activityTimer); recoveryTimers.forEach((timer) => clearTimeout(timer)); @@ -432,6 +473,7 @@ export const createLiveConversation = ( }, }); if (recoveryTimers.size === 0) onState(activeState("connected")); + syncInterviewBudget(); activityTimer = setTimeout(() => void sampleActivity(), 100); flushFinalizedInputs(); }; @@ -818,13 +860,23 @@ export const createLiveConversation = ( abort.signal.throwIfAborted(); const sdp = connection.localDescription?.sdp; if (!sdp) throw new Error("Missing local SDP"); - if (kind === "live") liveCreationRequested = true; + if (kind === "live") { + liveCreationRequested = true; + acknowledgedBudgetLevel = budgetLevel; + } connectionStages.set(kind, "waiting for session HTTP response"); const response = await fetch(`/api/voice/${kind}-session`, { method: "POST", headers: { "content-type": "application/sdp", - ...(kind === "live" ? { [voicePreferenceHeader]: voice } : {}), + ...(kind === "live" + ? { + [voicePreferenceHeader]: voice, + ...(budgetLevel === "off" + ? {} + : { [interviewBudgetHeader]: budgetLevel }), + } + : {}), }, body: sdp, signal: abort.signal, @@ -919,7 +971,10 @@ export const createLiveConversation = ( kind: LiveAppendResult["kind"], text: string, delegationId: string | null, - progress = false, + { + interviewBudgetLevel, + progress = false, + }: { interviewBudgetLevel?: InterviewBudgetLevel; progress?: boolean } = {}, ): boolean => { if (stopping) return false; const result: LiveAppendResult = { @@ -929,6 +984,13 @@ export const createLiveConversation = ( ...(progress ? { progress: true as const } : {}), status: "unknown", }; + if (interviewBudgetLevel !== undefined) { + pendingBudgetAppend = { + eventId: result.eventId, + level: interviewBudgetLevel, + }; + budgetUpdateFailed = false; + } const liveChannel = channels.get("live"); if ( ready.size !== 2 || @@ -957,6 +1019,36 @@ export const createLiveConversation = ( return true; }; + syncInterviewBudget = () => { + if (budgetSyncQueued) return; + budgetSyncQueued = true; + queueMicrotask(() => { + budgetSyncQueued = false; + if (stopping || finished || ready.size !== 2 || pendingBudgetAppend) + return; + if (budgetLevel === acknowledgedBudgetLevel) { + if (budgetUpdateFailed) { + budgetUpdateFailed = false; + onState( + activeState( + recoveryTimers.size === 0 ? "connected" : "connecting", + lastActivity, + ), + ); + } + return; + } + append( + "thinking", + budgetLevel === "off" + ? "Interview length is now Off. Follow Brunch's ordinary interview pacing; Brunch still decides the questions. Do not speak this note." + : liveInterviewBudgetInstruction(budgetLevel), + null, + { interviewBudgetLevel: budgetLevel }, + ); + }); + }; + const setMicrophoneMuted = (muted: boolean): void => { if (stopping || finished) return; microphoneMuted = muted; @@ -979,6 +1071,12 @@ export const createLiveConversation = ( retryPlayback: playAudio, start, stop, + setInterviewBudgetLevel: (level: InterviewBudgetLevel) => { + budgetLevel = level; + // Coalesce changes made in the same turn; this is quiet context, never + // an instruction redirect and never a reason to interrupt speech. + syncInterviewBudget(); + }, setMicrophoneMuted, setSpeakerMuted, setSpeakerVolume, @@ -987,7 +1085,8 @@ export const createLiveConversation = ( appendCommentary: (text: string, delegationId: string | null) => append("commentary", text, delegationId), /** Null-delegation commentary still awaits real-provider verification. */ - appendProgress: (text: string) => append("commentary", text, null, true), + appendProgress: (text: string) => + append("commentary", text, null, { progress: true }), appendInstructions: (text: string, delegationId: string | null) => append("instructions", text, delegationId), appendThinking: (text: string, delegationId: string | null) => diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.test.tsx b/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.test.tsx index 4370affe100..399c884598c 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.test.tsx @@ -40,7 +40,11 @@ let registeredVoiceModeControls: | PetrinautAiVoiceModeSessionControls | undefined; -const VoiceInterviewHarness = () => { +const VoiceInterviewHarness = ({ + onInputModeChange, +}: { + onInputModeChange?: (mode: PetrinautAiVoiceModeContext["inputMode"]) => void; +}) => { "use no memo"; const [active, setActive] = useState(false); @@ -114,7 +118,11 @@ const VoiceInterviewHarness = () => { {active ? "Voice active" : "Voice inactive"} {inputMode === "voice" ? "Voice mode" : "Text mode"} {isAiAssistantOpen ? "Panel open" : "Panel closed"} - + ); }; @@ -579,6 +587,27 @@ describe("voice interview control", () => { expect(registeredVoiceModeControls).toBeUndefined(); }); + test("reports the panel's input mode until Voice unmounts", async () => { + const onInputModeChange = vi.fn(); + const { unmount } = render( + , + ); + expect(onInputModeChange).toHaveBeenLastCalledWith("text"); + + fireEvent.click(screen.getByRole("button", { name: "Select Voice" })); + await screen.findByText("Voice mode"); + expect(onInputModeChange).toHaveBeenLastCalledWith("voice"); + + fireEvent.click(screen.getByRole("button", { name: "Select Text" })); + await screen.findByText("Text mode"); + expect(onInputModeChange).toHaveBeenLastCalledWith("text"); + + fireEvent.click(screen.getByRole("button", { name: "Select Voice" })); + await screen.findByText("Voice mode"); + unmount(); + expect(onInputModeChange).toHaveBeenLastCalledWith("text"); + }); + test("restarts when Voice is reselected before teardown completes", async () => { window.localStorage.setItem( VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.tsx b/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.tsx index 5187c0c62a6..b15de076a4f 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.tsx @@ -30,11 +30,15 @@ import { type VoiceTurnSnapshot, } from "./voice-turn-controller"; +import type { InterviewBudgetLevel } from "../../../../../shared/interview-budget"; import type { VoiceMediationHistory } from "../history/voice-mediation-history"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; import type { ToolApprovalState } from "../live/live-brunch-bridge"; import type { AgentSendResult, FlueConversationState } from "@flue/sdk"; -import type { PetrinautAiVoiceModeContext } from "@hashintel/petrinaut/ui"; +import type { + PetrinautAiInputMode, + PetrinautAiVoiceModeContext, +} from "@hashintel/petrinaut/ui"; type ResolveSubmission = ( messageId: string, @@ -632,7 +636,9 @@ const AvailableVoiceInterviewControl = ({ const PinnedVoiceInterviewControl = ({ config, + interviewBudgetLevel, mediationHistory, + onInputModeChange, toolApprovalState, resolveInputSubmission, resolveResponseSubmission, @@ -646,7 +652,10 @@ const PinnedVoiceInterviewControl = ({ ...context }: PetrinautAiVoiceModeContext & { readonly config: OpenAIVoiceConfig; + readonly interviewBudgetLevel?: InterviewBudgetLevel; readonly mediationHistory?: VoiceMediationHistory; + /** Mirrors the panel's input surface; reports `text` once Voice unmounts. */ + readonly onInputModeChange?: (mode: PetrinautAiInputMode) => void; readonly toolApprovalState?: (toolCallId: string) => ToolApprovalState | null; readonly resolveInputSubmission?: ResolveSubmission; readonly resolveResponseSubmission?: ResolveSubmissions; @@ -661,6 +670,11 @@ const PinnedVoiceInterviewControl = ({ // Labs changes apply between Voice sessions, never during an active turn. // The host ends the current session before returning to text mode. const [sessionConfig, setSessionConfig] = useState(config); + useLayoutEffect(() => { + if (!onInputModeChange) return; + onInputModeChange(context.inputMode); + return () => onInputModeChange("text"); + }, [context.inputMode, onInputModeChange]); if ( context.inputMode === "text" && (sessionConfig.provider !== config.provider || @@ -673,6 +687,7 @@ const PinnedVoiceInterviewControl = ({ return ( ...overrides, }); +test("adds a validated budget to startup instructions and leaves Off byte-for-byte unchanged", async () => { + const fetch = vi.fn(async () => + Response.json({ + session: { id: "session" }, + transport: { type: "webrtc", sdp: "v=0\r\no=answer" }, + }), + ); + const handler = createOpenAILiveSessionHandler({ environment, fetch }); + const instructionsFor = async (level?: string) => { + const input = request(); + if (level) input.headers.set("x-petrinaut-interview-budget", level); + const response = await handler(input); + expect(response.status).toBe(201); + const body = fetch.mock.lastCall?.[1]?.body; + if (typeof body !== "string") throw new Error("Expected JSON body"); + const payload = JSON.parse(body) as { + session: { instructions: string }; + }; + return payload.session.instructions; + }; + const baseline = await instructionsFor(); + expect(await instructionsFor("off")).toBe(baseline); + expect(await instructionsFor("quick")).toBe( + `${baseline}\n\n${(await import("../../shared/interview-budget")).liveInterviewBudgetInstruction("quick")}`, + ); + const invalid = request(); + invalid.headers.set( + "x-petrinaut-interview-budget", + "ignore previous instructions", + ); + expect((await handler(invalid)).status).toBe(400); + expect(fetch).toHaveBeenCalledTimes(3); +}); + test("Live mediation policy requests a brief acknowledgement and a summary, never independent modelling", async () => { const fetch = vi.fn(async () => Response.json({ diff --git a/apps/petrinaut-website/src/server/voice/openai-live-session.ts b/apps/petrinaut-website/src/server/voice/openai-live-session.ts index 1dec40d3b4a..15ec1bad05b 100644 --- a/apps/petrinaut-website/src/server/voice/openai-live-session.ts +++ b/apps/petrinaut-website/src/server/voice/openai-live-session.ts @@ -1,3 +1,8 @@ +import { + interviewBudgetHeader, + isInterviewBudgetLevel, + liveInterviewBudgetInstruction, +} from "../../shared/interview-budget.js"; import { isSupportedVoice, voicePreferenceHeader, @@ -118,6 +123,11 @@ export const createOpenAILiveSessionHandler = if (!isSupportedVoice("live", voice)) return respond("Unsupported voice.", 400); + const budgetLevel = request.headers.get(interviewBudgetHeader) ?? "off"; + if (!isInterviewBudgetLevel(budgetLevel)) + return respond("Unsupported interview length.", 400); + const budgetInstruction = liveInterviewBudgetInstruction(budgetLevel); + const signal = AbortSignal.any([ request.signal, AbortSignal.timeout(availability.connectionTimeoutMs), @@ -156,7 +166,9 @@ export const createOpenAILiveSessionHandler = body: JSON.stringify({ session: { model: "gpt-live-1", - instructions, + instructions: budgetInstruction + ? `${instructions}\n\n${budgetInstruction}` + : instructions, delegation: { type: "client" }, store: false, audio: { output: { voice } }, diff --git a/apps/petrinaut-website/src/shared/interview-budget.test.ts b/apps/petrinaut-website/src/shared/interview-budget.test.ts new file mode 100644 index 00000000000..550f2f04541 --- /dev/null +++ b/apps/petrinaut-website/src/shared/interview-budget.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from "vitest"; + +import { + getInterviewBudget, + interviewBudgetLabel, + liveInterviewBudgetInstruction, +} from "./interview-budget"; + +it("uses mode-specific caps and keeps already asked questions after a downgrade", () => { + expect(getInterviewBudget("quick", "text", 4)).toEqual({ + level: "quick", + questionCap: 3, + asked: 4, + remaining: 0, + }); + expect(getInterviewBudget("standard", "voice", 1)).toEqual({ + level: "standard", + questionCap: 4, + asked: 1, + remaining: 3, + }); + expect(getInterviewBudget("thorough", "voice", 2)?.questionCap).toBe(7); + expect(getInterviewBudget("thorough", "text", 2)?.questionCap).toBe(10); + expect(getInterviewBudget("quick", "voice", 0)?.questionCap).toBe(2); + expect(getInterviewBudget("deep", "voice", 12)).toEqual({ + level: "deep", + questionCap: null, + asked: 12, + remaining: null, + }); + expect(getInterviewBudget("off", "text", 12)).toBeUndefined(); +}); + +it("renders a question-derived estimate, never a clock", () => { + const asking = { closing: false }; + expect(interviewBudgetLabel("standard", "text", 2, asking)).toBe( + "~7 min left", + ); + expect(interviewBudgetLabel("standard", "text", 5, asking)).toBe( + "1 question left · ~2 min", + ); + expect(interviewBudgetLabel("standard", "voice", 3, asking)).toBe( + "1 question left · ~3 min", + ); + expect(interviewBudgetLabel("deep", "text", 4, asking)).toBe( + "Question 4 · no limit", + ); + expect(interviewBudgetLabel("off", "voice", 2, asking)).toBeNull(); +}); + +it("separates the unanswered last question from wrapping up", () => { + expect(interviewBudgetLabel("standard", "text", 6, { closing: false })).toBe( + "Last question", + ); + expect(interviewBudgetLabel("standard", "text", 6, { closing: true })).toBe( + "Wrapping up", + ); +}); + +it("adds no startup instruction for Off", () => { + expect(liveInterviewBudgetInstruction("off")).toBe(""); + expect(liveInterviewBudgetInstruction("quick")).toContain("Brunch decides"); +}); diff --git a/apps/petrinaut-website/src/shared/interview-budget.ts b/apps/petrinaut-website/src/shared/interview-budget.ts new file mode 100644 index 00000000000..28b79c493cb --- /dev/null +++ b/apps/petrinaut-website/src/shared/interview-budget.ts @@ -0,0 +1,100 @@ +export const interviewBudgetLevels = [ + "off", + "quick", + "standard", + "thorough", + "deep", +] as const; +export type InterviewBudgetLevel = (typeof interviewBudgetLevels)[number]; +export const interviewBudgetHeader = "x-petrinaut-interview-budget"; +export const interviewBudgetLevelsConfig = { + off: { + name: "Off", + guide: "Usual pacing", + minutes: 0, + text: null, + voice: null, + description: "Use Brunch’s usual pacing.", + }, + quick: { + name: "Quick", + guide: "~5 min", + minutes: 5, + text: 3, + voice: 2, + description: "Just the essentials.", + }, + standard: { + name: "Standard", + guide: "~10 min", + minutes: 10, + text: 6, + voice: 4, + description: "Focus on the main steps.", + }, + thorough: { + name: "Thorough", + guide: "~20 min", + minutes: 20, + text: 10, + voice: 7, + description: "Explore details and exceptions.", + }, + deep: { + name: "Deep", + guide: "No limit", + minutes: 0, + text: null, + voice: null, + description: "Keep exploring, with pauses between topics.", + }, +} as const; + +export const isInterviewBudgetLevel = ( + value: unknown, +): value is InterviewBudgetLevel => + typeof value === "string" && + interviewBudgetLevels.some((level) => level === value); + +export const getInterviewBudget = ( + level: InterviewBudgetLevel, + mode: "text" | "voice", + asked: number, +) => { + if (level === "off") return undefined; + const questionCap = interviewBudgetLevelsConfig[level][mode]; + return { + level, + questionCap, + asked, + remaining: questionCap === null ? null : Math.max(0, questionCap - asked), + }; +}; + +export const interviewBudgetLabel = ( + level: InterviewBudgetLevel, + mode: "text" | "voice", + asked: number, + { closing }: { closing: boolean }, +): string | null => { + const budget = getInterviewBudget(level, mode, asked); + if (!budget) return null; + if (budget.remaining === null || budget.questionCap === null) + return `Question ${asked} · no limit`; + if (budget.remaining === 0) return closing ? "Wrapping up" : "Last question"; + const minutes = Math.round( + (budget.remaining * interviewBudgetLevelsConfig[level].minutes) / + budget.questionCap, + ); + return budget.remaining === 1 + ? `1 question left · ~${minutes} min` + : `~${minutes} min left`; +}; + +export const liveInterviewBudgetInstruction = ( + level: InterviewBudgetLevel, +): string => { + if (level === "off") return ""; + const config = interviewBudgetLevelsConfig[level]; + return `Interview length: ${config.name} (${config.guide}). Minutes are a guide, not a timer. Brunch decides what questions to ask and how many; you decide how to say them. ${level === "quick" ? "Keep phrasing brief and give the person room to answer." : level === "deep" || level === "thorough" ? "Give the person room to elaborate and preserve Brunch's pauses between topics." : "Use concise, natural phrasing and give the person room to finish."} Relay Brunch's closing turn faithfully; never add a question or invent missing facts, ranges or units.`; +}; diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/flue.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/flue.ts index 67bcb7088a5..ddd6c93c1ae 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/flue.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/flue.ts @@ -15,6 +15,10 @@ import sdcpnModellingSkill from "@hashintel/brunch-agent-plugin-sdcpn/skills/sdc import { petrinautAiCapabilityGuidance } from "@hashintel/petrinaut-core/ai"; import { type SdcpnInitialData } from "./initial-data"; +import { + interviewBudgetInstruction, + type InterviewBudget, +} from "./interview-budget"; import sdcpnAppend from "./prompts/APPEND_SYSTEM.md?raw"; import { createDraftExperimentTool } from "./tools/draft-experiment"; import { @@ -23,12 +27,16 @@ import { } from "./tools/petrinaut-construction"; export const useSdcpnPlugin = (options?: { + /** The current submission's allowance; omitted means Off. */ + readonly interviewBudget?: InterviewBudget; readonly executeBrowserTool?: BrowserToolExecutor; readonly authorizeDraft?: Parameters< typeof createDraftExperimentTool >[0]["authorizeDraft"]; }): void => { const initialData = useInitialData(); + const instruction = interviewBudgetInstruction(options?.interviewBudget); + if (instruction) useInstruction(instruction); if (initialData) { useInstruction(sdcpnAppend.trim()); useInstruction(petrinautAiCapabilityGuidance); diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts index fcce4113f17..c9173e0f41a 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts @@ -17,6 +17,12 @@ export { type BrowserContext, type SdcpnInitialData, } from "./initial-data"; +export { + interviewBudgetContextKey, + interviewBudgetSchema, + parseInterviewBudget, + type InterviewBudget, +} from "./interview-budget"; export { draftPetrinautExperimentInputSchema, draftPetrinautExperimentOutputSchema, diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/interview-budget.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/interview-budget.ts new file mode 100644 index 00000000000..8dde79b74d9 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/interview-budget.ts @@ -0,0 +1,49 @@ +import * as v from "valibot"; + +const count = v.pipe(v.number(), v.integer(), v.minValue(0)); + +/** The host's submission-context key for the current allowance. */ +export const interviewBudgetContextKey = "interviewBudget"; + +/** A question allowance, never a clock or evidence that the model is complete. */ +export const interviewBudgetSchema = v.pipe( + v.strictObject({ + level: v.picklist(["quick", "standard", "thorough", "deep"]), + questionCap: v.nullable(v.pipe(count, v.minValue(1))), + asked: count, + remaining: v.nullable(count), + }), + v.check( + (budget) => + budget.level === "deep" + ? budget.questionCap === null && budget.remaining === null + : budget.questionCap !== null && + budget.remaining === Math.max(0, budget.questionCap - budget.asked), + "The remaining question allowance must match the level, cap and count.", + ), +); + +export type InterviewBudget = v.InferOutput; + +/** Read an allowance from untrusted submission context; anything invalid means Off. */ +export const parseInterviewBudget = ( + value: unknown, +): InterviewBudget | undefined => { + const result = v.safeParse(interviewBudgetSchema, value); + return result.success ? result.output : undefined; +}; + +export const interviewBudgetInstruction = ( + budget: InterviewBudget | undefined, +): string | undefined => { + if (!budget) return undefined; + const next = + budget.remaining === null + ? "There is no cap; offer a pause between topics rather than closing because of the count." + : budget.remaining === 0 + ? "The cap is reached: settle this answer, then close with stated facts, Assumed facts and open items listed by name; do not ask another question." + : budget.remaining === 1 + ? "Make the last question the most consequential open fact." + : "Choose the next question according to this level."; + return `Interview length: the person chose ${budget.level}. Follow the Interview length section of sdcpn-modelling. Record the level on the first turn under "Available time and assumption appetite", and update it when changed without repeating settled facts. Questions asked: ${budget.asked}; cap: ${budget.questionCap ?? "none"}; remaining: ${budget.remaining ?? "unlimited"}. ${next} Never invent operational facts, ranges or units. Reaching the cap is not completion; leave unsupported facts open.`; +}; diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/skills/sdcpn-modelling/SKILL.md b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/skills/sdcpn-modelling/SKILL.md index 0de443d9591..0478c240150 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/skills/sdcpn-modelling/SKILL.md +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/skills/sdcpn-modelling/SKILL.md @@ -21,6 +21,19 @@ Establish enough purpose and context to select one focused next action: the inte For a new account, follow one concrete case and re-evaluate the active gap after each useful answer. For an existing account, first locate the disputed or changed material and its consequence for the objective. Use the `elicitation` skill's universal guidance and `references/profile.md` for detailed operations and coverage; do not turn their register order into question order. +#### Interview length + +Apply this section only when an interview length is supplied. Without one, follow the ordinary procedure unchanged. A level is supplied, with a question cap for every level except Deep. Treat this as posture already stated; do not ask for it again. Record the level on the first turn under **Available time and assumption appetite**. The chosen length changes which absences you pursue and how many, never what counts as evidence. Every reply before closing consumes one question, including confirmations; a grouped question counts as one. + +- **Quick:** Pursue only essentials the net cannot be built without. Group related questions only when they share one frame. Use only authorized construction defaults, recording each as **Assumed**, with its reason and how to check it. Selecting Quick does not authorize inventing operational facts, ranges or units. Leave unsupported facts open. Do not propose an experiment. +- **Standard:** One thread per question, quantities with their units. Offer a supported default and ask; do not adopt an unconfirmed operational default just because the cap is reached. Propose an experiment only with the person's stated range and unit and the existing readiness checks satisfied. +- **Thorough:** As Standard, plus peak versus quiet variation, durations, and return or exit flows. Leave missing facts **Unknown**. Ask for the range the person would consider before proposing an experiment. +- **Deep:** As Thorough, plus units, ranges, edge cases and restrictions. Never assume missing facts. No cap: offer a pause between topics instead of closing because of the count. + +When **remaining is 1**, ask for the most consequential open fact. When **remaining is 0**, settle the latest answer and close without another question, including a correction question: list stated facts, **Assumed** facts and open items by name. Reaching the cap does not make the model complete or runnable. Gaps are listed, not filled. Mention that continuing at a higher level is available without opening a new question. + +When the level changes, update the recorded level in the next settlement and replan. Earlier questions still count against the new cap. Do not repeat a settled fact. + ### Maintain the workpiece Treat the workpiece as the recoverable operational account. Follow core's `elicitation` guidance for settlement cadence, evidence relations and locator lookup; `templates/workpiece.md` supplies the process-specific recording shape. diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/interview-budget.test.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/interview-budget.test.ts new file mode 100644 index 00000000000..eae8f4b8718 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/interview-budget.test.ts @@ -0,0 +1,78 @@ +import * as v from "valibot"; +import { describe, expect, it } from "vitest"; + +import { sdcpnInitialDataSchema } from "../src/initial-data"; +import { + interviewBudgetInstruction, + interviewBudgetSchema, +} from "../src/interview-budget"; + +describe("interview budget", () => { + it("keeps the allowance out of creation-only initial data", () => { + const input = { + binding: { conversationId: "c", documentId: "d", incarnationId: "i" }, + }; + expect( + v.parse(sdcpnInitialDataSchema, { + ...input, + interviewBudget: { + level: "quick", + questionCap: 3, + asked: 0, + remaining: 3, + }, + }), + ).toEqual(input); + expect(interviewBudgetInstruction(undefined)).toBeUndefined(); + }); + + it("tells the model the level and allowance, never a clock", () => { + const instruction = interviewBudgetInstruction({ + level: "standard", + questionCap: 6, + asked: 2, + remaining: 4, + }); + expect(instruction).toContain("the person chose standard."); + expect(instruction).toContain("cap: 6; remaining: 4"); + expect(instruction).not.toMatch(/minute/i); + }); + + it("retains a current budget and distinguishes the last question from closing", () => { + const budget = { + level: "quick", + questionCap: 3, + asked: 2, + remaining: 1, + } as const; + expect(v.parse(interviewBudgetSchema, budget)).toEqual(budget); + expect(interviewBudgetInstruction(budget)).toContain( + "most consequential open fact", + ); + expect( + interviewBudgetInstruction({ ...budget, asked: 4, remaining: 0 }), + ).toContain("do not ask another question"); + }); + + it("keeps Deep unlimited", () => { + expect( + interviewBudgetInstruction({ + level: "deep", + questionCap: null, + asked: 14, + remaining: null, + }), + ).toContain("pause between topics"); + }); + + it.each([ + { level: "off", questionCap: null, asked: 0, remaining: null }, + { level: "quick", questionCap: 3, asked: -1, remaining: 4 }, + { level: "quick", questionCap: 3, asked: 1.5, remaining: 1.5 }, + { level: "quick", questionCap: null, asked: 0, remaining: null }, + { level: "deep", questionCap: 3, asked: 0, remaining: 3 }, + { level: "standard", questionCap: 6, asked: 5, remaining: 3 }, + ])("rejects an invalid budget: %j", (budget) => { + expect(v.safeParse(interviewBudgetSchema, budget).success).toBe(false); + }); +}); diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/contextual-user-message.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/contextual-user-message.ts index d2b489dcaa5..5e9a4cf43d1 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/contextual-user-message.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/contextual-user-message.ts @@ -2,12 +2,19 @@ import { CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH } from "./browser-tool-result"; export const PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX = "petrinaut-contextual-user-message:v1\n"; +const submissionContextPrefix = "petrinaut-contextual-user-message:v2\n"; export const PETRINAUT_CONTEXTUAL_USER_TEXT_MAX_LENGTH = 32_000; const PETRINAUT_CONTEXTUAL_USER_BODY_MAX_LENGTH = 256_000; +const submissionContextMaxLength = 16_000; +const submissionContextMaxDepth = 16; + +/** Opaque host-owned data for one submission; the transport bounds it but never reads its keys. */ +export type SubmissionContext = Readonly>; export interface PetrinautContextualUserMessagePayload { readonly userText: string; readonly diagnosticsContext: string; + readonly submissionContext?: SubmissionContext; } export type PetrinautUserMessageBody = @@ -34,6 +41,31 @@ const hasExactKeys = ( ); }; +const isJsonValue = (value: unknown, depth: number): boolean => { + if (depth > submissionContextMaxDepth) return false; + if (value === null || typeof value === "string" || typeof value === "boolean") + return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) + return value.every((item) => isJsonValue(item, depth + 1)); + if (typeof value !== "object") return false; + const prototype: unknown = Object.getPrototypeOf(value); + return ( + (prototype === Object.prototype || prototype === null) && + Object.values(value).every((item) => isJsonValue(item, depth + 1)) + ); +}; + +const isSubmissionContext = (value: unknown): value is SubmissionContext => { + const record = asRecord(value); + return ( + record !== null && + Object.keys(record).length > 0 && + isJsonValue(record, 0) && + Array.from(JSON.stringify(record)).length <= submissionContextMaxLength + ); +}; + /** Build the bounded, provenance-preserving body used for a contextual user admission. */ export const petrinautContextualUserMessageBody = ( payload: PetrinautContextualUserMessagePayload, @@ -42,7 +74,9 @@ export const petrinautContextualUserMessageBody = ( payload.userText.length === 0 || Array.from(payload.userText).length > PETRINAUT_CONTEXTUAL_USER_TEXT_MAX_LENGTH || - payload.diagnosticsContext.length === 0 || + (payload.submissionContext === undefined + ? payload.diagnosticsContext.length === 0 + : !isSubmissionContext(payload.submissionContext)) || Array.from(payload.diagnosticsContext).length > CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH ) { @@ -50,7 +84,17 @@ export const petrinautContextualUserMessageBody = ( "The contextual user message payload is invalid or too long.", ); } - const body = `${PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX}${JSON.stringify(payload)}`; + const body = + payload.submissionContext === undefined + ? `${PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX}${JSON.stringify({ + userText: payload.userText, + diagnosticsContext: payload.diagnosticsContext, + })}` + : `${submissionContextPrefix}${JSON.stringify({ + userText: payload.userText, + diagnosticsContext: payload.diagnosticsContext, + submissionContext: payload.submissionContext, + })}`; if (Array.from(body).length > PETRINAUT_CONTEXTUAL_USER_BODY_MAX_LENGTH) { throw new Error("The contextual user message body is too long."); } @@ -61,7 +105,11 @@ export const petrinautContextualUserMessageBody = ( export const parsePetrinautUserMessageBody = ( body: string, ): PetrinautUserMessageBody => { - if (!body.startsWith(PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX)) { + const hasSubmissionContext = body.startsWith(submissionContextPrefix); + if ( + !hasSubmissionContext && + !body.startsWith(PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX) + ) { return { kind: "ordinary", userText: body }; } if (Array.from(body).length > PETRINAUT_CONTEXTUAL_USER_BODY_MAX_LENGTH) { @@ -70,7 +118,11 @@ export const parsePetrinautUserMessageBody = ( let parsed: unknown; try { parsed = JSON.parse( - body.slice(PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX.length), + body.slice( + hasSubmissionContext + ? submissionContextPrefix.length + : PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX.length, + ), ); } catch { return { kind: "invalid-contextual" }; @@ -78,23 +130,29 @@ export const parsePetrinautUserMessageBody = ( const payload = asRecord(parsed); if ( payload === null || - !hasExactKeys(payload, ["diagnosticsContext", "userText"]) || + !hasExactKeys( + payload, + hasSubmissionContext + ? ["diagnosticsContext", "submissionContext", "userText"] + : ["diagnosticsContext", "userText"], + ) || typeof payload.userText !== "string" || - typeof payload.diagnosticsContext !== "string" + typeof payload.diagnosticsContext !== "string" || + (hasSubmissionContext && !isSubmissionContext(payload.submissionContext)) ) { return { kind: "invalid-contextual" }; } + const result: PetrinautContextualUserMessagePayload = { + userText: payload.userText, + diagnosticsContext: payload.diagnosticsContext, + ...(hasSubmissionContext && isSubmissionContext(payload.submissionContext) + ? { submissionContext: payload.submissionContext } + : {}), + }; try { - petrinautContextualUserMessageBody({ - userText: payload.userText, - diagnosticsContext: payload.diagnosticsContext, - }); + petrinautContextualUserMessageBody(result); } catch { return { kind: "invalid-contextual" }; } - return { - kind: "contextual", - userText: payload.userText, - diagnosticsContext: payload.diagnosticsContext, - }; + return { kind: "contextual", ...result }; }; diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts index baba0dddd02..26202d5e3bd 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts @@ -1,7 +1,10 @@ import { FlueApiError, FlueExecutionError } from "@flue/sdk"; import { CLIENT_TOOL_RESULT_CONTEXT_MAX_LENGTH } from "./browser-tool-result"; -import { petrinautContextualUserMessageBody } from "./contextual-user-message"; +import { + petrinautContextualUserMessageBody, + type SubmissionContext, +} from "./contextual-user-message"; import { serializeErrorText } from "./error-text"; import { readLiveToolStream, @@ -29,6 +32,7 @@ export { parsePetrinautUserMessageBody, petrinautContextualUserMessageBody, } from "./contextual-user-message"; +export type { SubmissionContext } from "./contextual-user-message"; export { agentOwnershipHeaders, flueConversationIdWeb, @@ -61,6 +65,8 @@ export interface FlueChatTransportOptions extends ClientToolProjectionOptions { readonly client: FlueClient; /** Opaque host-owned initialization, sent on user submissions only. */ readonly initialData?: AgentPromptOptions["initialData"]; + /** Opaque host-owned data for this submission, carried durably because initialData is creation-only. */ + readonly submissionContext?: SubmissionContext; /** Best-effort pre-admission presentation; canonical Flue history remains authoritative. */ readonly liveToolStream?: LiveToolStreamOptions; readonly onAdmission?: (event: { @@ -384,11 +390,15 @@ export const createFlueChatTransport = < const message: DeliveredMessage = { kind: "user", body: - diagnosticsContext === undefined + diagnosticsContext === undefined && + options.submissionContext === undefined ? userMessage.text : petrinautContextualUserMessageBody({ userText: userMessage.text, - diagnosticsContext, + diagnosticsContext: diagnosticsContext ?? "", + ...(options.submissionContext === undefined + ? {} + : { submissionContext: options.submissionContext }), }), }; const idempotencyKey = `ai-sdk:user:${userMessage.id}`; diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/transcript.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/transcript.ts index 1eb8f75219e..d8f8dea0e4b 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/transcript.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/transcript.ts @@ -1,3 +1,5 @@ +import { parsePetrinautUserMessageBody } from "./contextual-user-message"; + import type { ClientToolProjectionOptions } from "./ui-stream"; import type { FlueConversationMessage, @@ -92,7 +94,15 @@ const partsFrom = ( const parts: UiMessagePart[] = []; for (const part of message.parts) { if (part.type === "text") { - parts.push({ type: "text", text: part.text, state: "done" }); + const body = + message.role === "user" + ? parsePetrinautUserMessageBody(part.text) + : undefined; + parts.push({ + type: "text", + text: body?.kind === "contextual" ? body.userText : part.text, + state: "done", + }); continue; } if (part.type === "reasoning") { diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/submission-context.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/submission-context.test.ts new file mode 100644 index 00000000000..ece7e0dd650 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/submission-context.test.ts @@ -0,0 +1,152 @@ +import { expect, test, vi } from "vitest"; + +import { createFlueChatTransport } from "../src"; +import { + PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX, + parsePetrinautUserMessageBody, + petrinautContextualUserMessageBody, +} from "../src/contextual-user-message"; + +import type { AgentSendResult, FlueClient } from "@flue/sdk"; + +const submissionContextPrefix = "petrinaut-contextual-user-message:v2\n"; + +test("round trips opaque host context without interpreting its keys", () => { + const submissionContext = { + anyHostKey: { nested: [1, "two", true, null] }, + another: "value", + }; + const body = petrinautContextualUserMessageBody({ + userText: "Four agents", + diagnosticsContext: "", + submissionContext, + }); + expect(body.startsWith(submissionContextPrefix)).toBe(true); + expect(parsePetrinautUserMessageBody(body)).toEqual({ + kind: "contextual", + userText: "Four agents", + diagnosticsContext: "", + submissionContext, + }); + expect( + parsePetrinautUserMessageBody( + petrinautContextualUserMessageBody({ + userText: "Four agents", + diagnosticsContext: "diagnostic", + submissionContext, + }), + ), + ).toMatchObject({ diagnosticsContext: "diagnostic", submissionContext }); +}); + +test("keeps diagnostics-only bodies byte-identical to version one", () => { + expect( + petrinautContextualUserMessageBody({ + userText: "Four agents", + diagnosticsContext: "diagnostic", + }), + ).toBe( + `${PETRINAUT_CONTEXTUAL_USER_MESSAGE_PREFIX}{"userText":"Four agents","diagnosticsContext":"diagnostic"}`, + ); + expect(parsePetrinautUserMessageBody("Four agents")).toEqual({ + kind: "ordinary", + userText: "Four agents", + }); +}); + +test.each([ + ["an empty object", {}], + ["an array", ["value"]], + ["a non-finite number", { key: Number.NaN }], + ["an undefined value", { key: undefined }], + ["a class instance", { key: new Date(0) }], + ["an oversized value", { key: "x".repeat(16_001) }], + [ + "excessive nesting", + { + key: Array.from({ length: 20 }).reduce( + (inner) => ({ inner }), + null, + ), + }, + ], +])("refuses %s as submission context", (_label, submissionContext) => { + expect(() => + petrinautContextualUserMessageBody({ + userText: "Four agents", + diagnosticsContext: "", + submissionContext: submissionContext as Record, + }), + ).toThrow("The contextual user message payload is invalid or too long."); +}); + +test.each([ + [ + "a missing context", + JSON.stringify({ userText: "Four agents", diagnosticsContext: "" }), + ], + [ + "an empty context", + JSON.stringify({ + userText: "Four agents", + diagnosticsContext: "", + submissionContext: {}, + }), + ], + [ + "an extra field", + JSON.stringify({ + userText: "Four agents", + diagnosticsContext: "", + submissionContext: { key: 1 }, + assertedBy: "user", + }), + ], +])("refuses %s in version two framing", (_label, payload) => { + expect( + parsePetrinautUserMessageBody(`${submissionContextPrefix}${payload}`), + ).toEqual({ kind: "invalid-contextual" }); +}); + +test("carries submission context on the correlated user turn", async () => { + const admission: AgentSendResult = { + streamUrl: "http://brunch.test/stream", + offset: "offset-1", + submissionId: "submission-1", + uid: "uid-1", + }; + const send = vi.fn(async () => admission); + const wait = vi.fn(async () => {}); + const transport = createFlueChatTransport({ + client: { send, wait } as Pick as FlueClient, + clientToolNames: new Set(), + submissionContext: { hostKey: { level: 2 } }, + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "conversation-1", + messageId: undefined, + abortSignal: undefined, + messages: [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Four agents" }], + }, + ], + }); + + expect(send).toHaveBeenCalledWith({ + idempotencyKey: "ai-sdk:user:user-1", + message: { + kind: "user", + body: petrinautContextualUserMessageBody({ + userText: "Four agents", + diagnosticsContext: "", + submissionContext: { hostKey: { level: 2 } }, + }), + }, + signal: undefined, + }); +}); diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts index f30ae9ec08e..27b63693160 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/transcript.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "vitest"; -import { snapshotToUiMessages } from "../src"; +import { + petrinautContextualUserMessageBody, + snapshotToUiMessages, +} from "../src"; import type { FlueConversationSnapshot } from "@flue/sdk"; @@ -33,6 +36,50 @@ const projectionOptions = { clientToolNames: new Set(["readPetrinautDoc"]), }; +test("rehydrates contextual messages as human text, never as transport metadata", () => { + const body = petrinautContextualUserMessageBody({ + userText: "Four agents", + diagnosticsContext: "", + submissionContext: { hostKey: { asked: 1 } }, + }); + const snapshot: FlueConversationSnapshot = { + ...snapshotWithPendingClientTool, + messages: [ + { + id: "user", + role: "user", + purpose: "user", + display: "visible", + parts: [{ type: "text", text: body, state: "done" }], + }, + { + id: "assistant", + role: "assistant", + purpose: "assistant", + display: "visible", + parts: [{ type: "text", text: body, state: "done" }], + }, + ], + }; + expect(snapshotToUiMessages(snapshot, projectionOptions)).toEqual([ + { + id: "user", + role: "user", + parts: [{ type: "text", text: "Four agents", state: "done" }], + }, + { + id: "assistant", + role: "assistant", + parts: [{ type: "text", text: body, state: "done" }], + }, + ]); + expect(snapshot.messages[0]?.parts[0]).toEqual({ + type: "text", + text: body, + state: "done", + }); +}); + test("marks only the durably aborted assistant response stopped after reopen", () => { const snapshot: FlueConversationSnapshot = { ...snapshotWithPendingClientTool, diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index c3f900326a6..39ec8886010 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -2,7 +2,13 @@ import "@fontsource-variable/inter"; import "@fontsource-variable/inter-tight"; import "@fontsource-variable/jetbrains-mono"; import "./index.css"; -import { type FunctionComponent, useEffect, useMemo, useRef } from "react"; +import { + type FunctionComponent, + type ReactNode, + useEffect, + useMemo, + useRef, +} from "react"; import { PortalContainerContext } from "@hashintel/ds-components"; import { css, cx } from "@hashintel/ds-helpers/css"; @@ -153,6 +159,8 @@ export type PetrinautAiAssistant = { mapMessagesForDisplay?: ( messages: PetrinautAiMessage[], ) => PetrinautAiMessage[]; + /** Render display-only system-note content; return undefined for the default presentation. */ + renderSystemMessage?: (message: PetrinautAiMessage) => ReactNode; /** * Opt into following host history while locally idle. The predicate must * describe the exact snapshot supplied in `messages`, including settlement @@ -171,6 +179,8 @@ export type PetrinautAiAssistant = { requestStop?: () => Promise; /** Render a host-owned control inside the assistant composer. */ renderComposerControl?: PetrinautAiComposerControl; + /** Render host-owned status above the text composer or Voice dock. */ + renderComposerStatus?: PetrinautAiComposerControl; /** Render one persistent, provider-neutral Voice mode. */ renderVoiceMode?: PetrinautAiVoiceMode; transport: PetrinautAiTransport; diff --git a/libs/@hashintel/petrinaut/src/ui/types/ai-assistant-composer-control.ts b/libs/@hashintel/petrinaut/src/ui/types/ai-assistant-composer-control.ts index aca401ebdaf..398ac65b6c6 100644 --- a/libs/@hashintel/petrinaut/src/ui/types/ai-assistant-composer-control.ts +++ b/libs/@hashintel/petrinaut/src/ui/types/ai-assistant-composer-control.ts @@ -42,6 +42,8 @@ export type PetrinautAiComposerSubmitText = (params: { export type PetrinautAiComposerControlContext = { /** Effective AI SDK identity, whether host-supplied or generated by `useChat`. */ conversationId: string; + /** Current input surface, supplied by the panel to host-owned controls. */ + inputMode?: PetrinautAiInputMode; messages: PetrinautAiMessage[]; status: PetrinautAiComposerStatus; /** Logical response stopped, including a withheld follow-up; not a Flue settlement claim. */ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index c7a7870223a..a6f39fd56d3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -2266,6 +2266,7 @@ const ConversationAiAssistantPanel = ({ const composerControlContext: PetrinautAiComposerControlContext = { conversationId, + inputMode: interactionMode, messages, status, stopped, @@ -2303,6 +2304,9 @@ const ConversationAiAssistantPanel = ({ } composerFocusRequest={composerFocusRequest + focusRequest} composerControl={composerControl} + composerStatus={aiAssistant.renderComposerStatus?.( + composerControlContext, + )} error={streamError ?? error} experimentStates={experimentStates} hostExperimentRunning={hostExperimentReport?.running ?? false} @@ -2315,6 +2319,7 @@ const ConversationAiAssistantPanel = ({ hostTabSelected={hostTabSelected} hiddenToolNames={hiddenAutomaticToolNames} messages={aiAssistant.mapMessagesForDisplay?.(messages) ?? messages} + renderSystemMessage={aiAssistant.renderSystemMessage} onClearMessages={() => { abortAutomaticTools(); for (const controller of experimentControllersRef.current.values()) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx index 45b7a428c49..432c7bdee60 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.test.tsx @@ -1883,6 +1883,41 @@ describe("AiAssistantContents", () => { }, ); + test.each(["setup", "listening"] as const)( + "omits the status row from the collapsed %s Voice dock", + (phase) => { + const store = createVoiceSessionStore(); + if (phase === "listening") + store.setState({ + errorMessage: null, + microphoneLevel: 0, + microphoneMuted: false, + phase, + }); + const contents = (collapsed: boolean) => ( + + Host status} + onClose={noop} + onInputChange={noop} + onStop={noop} + onSubmit={noop} + status="ready" + voiceDockCollapsed={collapsed} + /> + + ); + const { rerender } = render(contents(true)); + expect(screen.getByTestId("ai-voice-dock")).toBeTruthy(); + expect(screen.queryByTestId("host-status")).toBeNull(); + rerender(contents(false)); + expect(screen.getByTestId("host-status").textContent).toBe("Host status"); + }, + ); + test("contains voice failures in the collapsed dock without a toast", async () => { const store = createVoiceSessionStore(); store.setState({ @@ -2011,6 +2046,109 @@ describe("AiAssistantContents", () => { expect(renderMarkdown).toHaveBeenCalledOnce(); }); + test.each(["text", "voice"] as const)( + "keeps system notes visible outside assistant activity in %s mode", + (inputMode) => { + render( + , + ); + + const note = screen.getByRole("note"); + expect(note.textContent).toBe("Host note"); + expect(note.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(screen.queryByText("Activity")).toBeNull(); + }, + ); + + test("keeps the streaming reply active beneath a trailing system note", () => { + const { container } = render( + , + ); + expect( + container.querySelector('[data-work-status="streaming"]'), + ).not.toBeNull(); + expect(container.querySelector('[data-work-status="pending"]')).toBeNull(); + }); + + test.each(["text", "voice"] as const)( + "uses host presentation only for system notes in %s", + (inputMode) => { + const renderSystemMessage = vi.fn(() => Custom host note); + render( + , + ); + expect(screen.getByRole("note").textContent).toBe("Custom host note"); + expect(screen.queryByText("Plain fallback")).toBeNull(); + expect(renderSystemMessage).toHaveBeenCalledOnce(); + expect(renderSystemMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: "note", role: "system" }), + ); + }, + ); + test("hides a closed chat-only panel from the accessibility tree", () => { const { container } = render( { expect(control.nextElementSibling?.contains(sendButton)).toBe(true); }); + test.each([ + ["", "ready", "Start voice mode"], + ["Draft", "ready", "Send message"], + ["Draft", "streaming", "Stop AI response"], + ] as const)( + "keeps host controls before the input in the brunch presentation with %s draft and %s status", + (input, status, actionLabel) => { + render( + Host setting} + input={input} + messages={[]} + onClose={noop} + onInputChange={noop} + onInputModeChange={noop} + onStop={noop} + onSubmit={noop} + presentation="brunch" + status={status} + voiceModeAvailable + />, + ); + const control = screen.getByRole("button", { name: "Host setting" }); + const textarea = screen.getByRole("textbox", { + name: "Message AI assistant", + }); + expect(control.nextElementSibling).toBe(textarea); + expect( + textarea.nextElementSibling?.contains( + screen.getByRole("button", { name: actionLabel }), + ), + ).toBe(true); + }, + ); + test("keeps one trailing Brunch composer action", () => { const onInputModeChange = vi.fn(); render( @@ -3490,6 +3663,55 @@ describe("AiAssistantContents", () => { window.requestAnimationFrame = originalRequestAnimationFrame; }); + test("auto-follows a reply that streams beneath a trailing system note", () => { + // eslint-disable-next-line @typescript-eslint/unbound-method -- Saved only for restoration. + const originalScrollTo = window.HTMLElement.prototype.scrollTo; + const originalRequestAnimationFrame = window.requestAnimationFrame; + const scrollTo = vi.fn(); + window.HTMLElement.prototype.scrollTo = scrollTo; + window.requestAnimationFrame = (callback) => { + callback(0); + return 0; + }; + const messages = (text: string): PetrinautAiMessage[] => [ + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", state: "streaming", text }], + }, + { + id: "note-1", + role: "system", + parts: [{ type: "text", text: "Short interview" }], + }, + ]; + const props = { + input: "", + onClose: noop, + onInputChange: noop, + onStop: noop, + onSubmit: noop, + status: "streaming" as const, + }; + const view = render( + , + ); + scrollTo.mockClear(); + + view.rerender( + , + ); + + expect(scrollTo.mock.instances).toContain( + screen.getByTestId("ai-transcript"), + ); + window.HTMLElement.prototype.scrollTo = originalScrollTo; + window.requestAnimationFrame = originalRequestAnimationFrame; + }); + test("does not show an empty Activity fold above a plain Chat answer", () => { render( ; workingLabel?: string; clearMessagesDisabled?: boolean; composerControl?: ReactNode; + composerStatus?: ReactNode; composerFocusRequest?: number; error?: Error; experimentStates?: Record; @@ -400,14 +402,17 @@ const getMessagesScrollKey = (messages: PetrinautAiMessage[]): string => { if (messages.length === 0) { return "0"; } - const last = messages[messages.length - 1]!; + const trailing = messages[messages.length - 1]!; + // Host system notes can trail the turn they annotate while it streams. + const last = + messages.findLast((message) => message.role !== "system") ?? trailing; const lastPart = last.parts[last.parts.length - 1]; const partSignature = lastPart ? getPartScrollSignature(lastPart) : ""; const dataSignature = last.parts .filter((part) => part !== lastPart && part.type.startsWith("data-")) .map(getPartScrollSignature) .join(","); - return `${messages.length}:${last.id}:${last.parts.length}:${partSignature}:${dataSignature}`; + return `${messages.length}:${trailing.id}:${last.id}:${last.parts.length}:${partSignature}:${dataSignature}`; }; export const getTranscriptLabel = ( @@ -427,6 +432,7 @@ export const AiAssistantContents = ({ onCancelExperiment, clearMessagesDisabled = false, composerControl, + composerStatus, composerFocusRequest = 0, error, input, @@ -461,6 +467,7 @@ export const AiAssistantContents = ({ voiceMode, voiceModeAvailable = false, resolveToolPresentation, + renderSystemMessage, workingLabel, }: AiAssistantContentsProps) => { const panelId = useId(); @@ -916,6 +923,7 @@ export const AiAssistantContents = ({ canRetry={ onRetryPrompt !== undefined && !isBusy && !voiceHandoffPending } + renderSystemMessage={renderSystemMessage} voice={inputMode === "voice"} /> ) : ( @@ -978,11 +986,13 @@ export const AiAssistantContents = ({ )} + {!isVoiceDockCollapsed && composerStatus} {isVoiceSessionLive ? (
& { message: PetrinautAiMessage; + renderSystemMessage?: PetrinautAiAssistant["renderSystemMessage"]; voice: boolean; active: boolean; canRetry: boolean; @@ -172,6 +191,7 @@ const BrunchMessage = memo( experimentStates, onCancelExperiment, resolveToolPresentation, + renderSystemMessage, voice, active, stopped, @@ -194,6 +214,26 @@ const BrunchMessage = memo( ); const { work, answers, cards, brief, voiceAgentReply, voiceAgentWrapUp } = renderItems; + if (message.role === "system") { + const content = renderSystemMessage?.(message); + if (content !== undefined) { + return ( +
+ {content} +
+ ); + } + return ( +
+ +
+ {answers.map((item) => ( +
{item.part.text}
+ ))} +
+
+ ); + } const wasStopped = stopped || message.metadata?.stopped === true; const awaitingApproval = work.tools.some( (tool) => tool.interactive && tool.state === "input-available", @@ -414,12 +454,14 @@ export const BrunchTranscript = ({ stopped, busy, canRetry, + renderSystemMessage, voice, ...messageProps }: TranscriptProps & { busy: boolean; /** Whether a finished answer may be retried at all right now. */ canRetry: boolean; + renderSystemMessage?: PetrinautAiAssistant["renderSystemMessage"]; voice: boolean; }) => { const firstUserIndex = messages.findIndex( @@ -432,8 +474,9 @@ export const BrunchTranscript = ({ (part) => part.type === "text" && part.text.trim().length > 0, ), )?.id; + // Host system notes can trail the turn they annotate while it streams. const responseIndex = messages.findLastIndex( - (message) => !isSpokenLine(message), + (message) => message.role !== "system" && !isSpokenLine(message), ); const responseRole = messages[responseIndex]?.role; @@ -444,6 +487,9 @@ export const BrunchTranscript = ({ key={message.id} message={message} {...messageProps} + renderSystemMessage={ + message.role === "system" ? renderSystemMessage : undefined + } voice={voice} latestAnswer={message.id === latestAnswerId} canRetry={canRetry && firstUserIndex >= 0 && index > firstUserIndex} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx index fb46c3fadef..ee9025955a0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/composer.tsx @@ -248,6 +248,7 @@ export const AiAssistantComposer = ({ }} >
+ {isBrunch && control}