Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2f3f3db
H-6763: Implement voice interview stage
kostandinang Aug 31, 2026
260731b
Harden voice interview turn recovery
kostandinang Aug 27, 2026
6e9e5f8
Fix voice interview endpointing and corrections
kostandinang Aug 27, 2026
d58055f
Remember voice interview disclosure
kostandinang Aug 27, 2026
de42dc9
Cover text-instead disclosure regression
kostandinang Aug 27, 2026
bf0444a
Simplify voice interview controls
kostandinang Aug 27, 2026
5656a8c
Make compact End control icon-only
kostandinang Aug 27, 2026
780c33c
Update voice interview user guide
kostandinang Aug 27, 2026
c171d2c
Format voice interview files
kostandinang Aug 27, 2026
d54307b
Restore invalid service response guidance
kostandinang Aug 27, 2026
2c027ec
Harden voice interview capture and recovery
kostandinang Aug 27, 2026
a319733
Design Chat and Interview mode switch
kostandinang Aug 27, 2026
51b6c8b
Add Petrinaut interaction mode tabs
kostandinang Aug 27, 2026
a33322d
Wire interaction mode into interview stage context
kostandinang Aug 27, 2026
2e6fa6c
Integrate Chat and Interview modes
kostandinang Aug 27, 2026
479c5b8
Harden Chat and Interview mode switching
kostandinang Aug 27, 2026
ced1965
Connect voice interview to the mode switch
kostandinang Aug 27, 2026
3b8bcd8
Keep voice capture active during partial transcripts
kostandinang Aug 27, 2026
a8259b2
Clarify automatic voice finalization
kostandinang Aug 27, 2026
dbf0a1b
Resolve Chat and Interview final review findings
kostandinang Aug 27, 2026
dcae723
Design minimal voice interview UI
kostandinang Aug 27, 2026
234dd5e
Simplify the full voice interview stage
kostandinang Aug 28, 2026
1260b6a
Format full voice interview stage files
kostandinang Aug 28, 2026
b721402
Strengthen voice disclosure control coverage
kostandinang Aug 28, 2026
9fe803c
Align compact voice states and guidance
kostandinang Aug 28, 2026
516e039
Track voice answer delivery explicitly in the turn snapshot
kostandinang Aug 28, 2026
61a999d
Name the voice recovery problem and match the approved action order
kostandinang Aug 28, 2026
4f43458
Keep microphone metering off the interview render hot path
kostandinang Aug 28, 2026
9cc7e06
Align the voice records and guide with the shipped interview seams
kostandinang Aug 28, 2026
a86c663
Format the voice interview view tests
kostandinang Aug 28, 2026
400c3df
Fix voice mode focus and recovery status
kostandinang Aug 28, 2026
6c4dfe1
Prevent stale interview recovery state
kostandinang Aug 31, 2026
6c87aac
Keep capture closed during transcript settlement
kostandinang Aug 31, 2026
cb055d7
Preserve interview state across failed delivery
kostandinang Aug 31, 2026
7a9d82b
Fix interview branch restack regressions
kostandinang Aug 31, 2026
7a716d2
Keep interview inactive during teardown
kostandinang Sep 1, 2026
93f80d3
Keep the pending ask after a failed correction
kostandinang Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .changeset/stable-composer-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
"@hashintel/petrinaut": patch
---

Add a generic host-rendered AI composer control with stable finalized-text submission,
conversation identity, stop handling, schema-validated interactive-tool text mapping, and an
explicit separate-message target for corrections. Add a queue-aware voice submission path so a
finalized spoken turn is retained while another response settles.
Add generic host-rendered AI composer controls and a persistent interview stage with docked and
detached placements, protected active conversations, keyboard fallback, and one-answer buffering
while the normal chat stream settles. Include stable finalized-text submission, conversation
identity, stop handling, schema-validated interactive-tool text mapping, explicit separate-message
targeting for corrections, and a queue-aware voice submission path. Add the Chat / Interview mode
switch and export `PetrinautAiInteractionMode`, with the selected interaction mode and mode-change
callback available to host-rendered interview stages. `renderComposerControl` remains a supported
public seam for hosts that only need their own control beside the message box, independently of the
interview stage.

Simplify Interview mode with a circular microphone waveform, compact transcript states that
distinguish recording, sending, sent, and undelivered answers, phase-specific icon controls, and
recovery that names the kind of failure before offering reconnect.
69 changes: 69 additions & 0 deletions apps/petrinaut-website/src/main/app/brunch-sweep-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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<typeof completionFailureSchema>;
export type SweepCompletionReport = z.infer<typeof completionReportSchema>;
export type SweepCapture = z.infer<typeof captureSchema>;
Original file line number Diff line number Diff line change
@@ -1,4 +1,130 @@
import { SWEEP_TOOL_NAME } from "@hashintel/brunch-agent-transport-aisdk/client-tools";

import { sweepOutputSchema } from "../brunch-sweep-output";

import type {
SweepCapture,
SweepCompletionFailure,
SweepCompletionReport,
} from "../brunch-sweep-output";
import type { PetrinautAiChatTransport } from "@hashintel/petrinaut/ui";
import type { UIMessageChunk } from "ai";

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: "No settled range to sweep",
detail: "The conversation has no settled user entries.",
};
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<UIMessageChunk>,
): ReadableStream<UIMessageChunk> => {
const toolNamesByCallId = new Map<string, string>();
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);
},
}),
);
};

/**
* Pin Petrinaut's stock transport to one stable conversation id so reload,
Expand All @@ -8,8 +134,18 @@ export const createBrunchPanelTransport = (
transport: PetrinautAiChatTransport,
conversationId: string,
): PetrinautAiChatTransport => ({
reconnectToStream: (options) =>
transport.reconnectToStream({ ...options, chatId: conversationId }),
sendMessages: (options) =>
transport.sendMessages({ ...options, chatId: conversationId }),
reconnectToStream: async (options) => {
const stream = await transport.reconnectToStream({
...options,
chatId: conversationId,
});
return stream === null ? null : decorateBrunchStream(stream);
},
sendMessages: async (options) =>
decorateBrunchStream(
await transport.sendMessages({
...options,
chatId: conversationId,
}),
),
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isValidElement, type ReactNode } from "react";
import { describe, expect, test, vi } from "vitest";

import { VoiceInterviewControl } from "../voice-interview/voice-interview-control";
import { getBrunchVoiceComposerControl } from "./local-storage-demo-app";
import { getBrunchVoiceInterviewStage } from "./local-storage-demo-app";

const defaultTransportOptions = vi.hoisted(() => ({
current: null as unknown,
Expand All @@ -28,16 +28,28 @@ vi.mock("@hashintel/petrinaut/ui", () => ({

describe("local storage demo Brunch voice integration", () => {
test("does not install voice on the generic local chat fallback", () => {
expect(getBrunchVoiceComposerControl(false)).toBeUndefined();
expect(getBrunchVoiceInterviewStage(null)).toBeUndefined();
});

test("installs the app-owned voice control for a configured Brunch transport", () => {
const renderControl = getBrunchVoiceComposerControl(true);
const control = renderControl?.({
const config = { available: true as const, connectionTimeoutMs: 15_000 };
const stage = getBrunchVoiceInterviewStage(config);
const control = stage?.({
canAcceptInterviewAnswer: true,
conversationId: "petrinaut-preview:net-1",
focusComposer: vi.fn(),
interactionMode: "chat",
messages: [],
openSidebar: vi.fn(),
placement: "sidebar",
setActive: vi.fn(),
setInteractionMode: vi.fn(),
status: "ready",
stop: vi.fn(async () => undefined),
submitInterviewAnswer: vi.fn(async () => ({
kind: "message" as const,
messageId: "message-1",
})),
submitText: vi.fn(async () => ({
kind: "message" as const,
messageId: "message-1",
Expand All @@ -52,7 +64,10 @@ describe("local storage demo Brunch voice integration", () => {
if (!isValidElement(control)) {
throw new Error("Expected the configured composer control to render.");
}
expect(control.type).toBe(VoiceInterviewControl);
expect(control).toMatchObject({
props: { config },
type: VoiceInterviewControl,
});
});

test("correlates the existing Brunch transport request", () => {
Expand Down
Loading
Loading