Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .changeset/stable-composer-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"@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 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. Add the Chat / Interview mode switch and export
`PetrinautAiInteractionMode`, with the selected interaction mode and mode-change callback available
to host-rendered interview stages.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const captureSchema = z.object({
supersedes: z.string().optional(),
});

const sweepOutputSchema = z.discriminatedUnion("status", [
export const sweepOutputSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("no-settled-range") }),
z.object({
status: z.literal("refused"),
Expand Down
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 @@ -48,7 +60,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
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@ import {
DefaultChatTransport,
Petrinaut,
type PetrinautAiChatTransport,
type PetrinautAiComposerControl,
type PetrinautAiComposerControlContext,
type PetrinautAiInterviewStage,
type PetrinautAiInterviewStageContext,
type PetrinautAiMessage,
WalkthroughProvider,
} from "@hashintel/petrinaut/ui";

import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics";
import { useSentryFeedbackAction } from "../sentry-feedback-button";
import { VoiceInterviewControl } from "../voice-interview/voice-interview-control";
import {
loadOpenAIVoiceConfig,
type OpenAIVoiceConfig,
VoiceInterviewControl,
} from "../voice-interview/voice-interview-control";
import { brunchAskInteractiveTool } from "./brunch-ask-interactive-tool";
import { createBrunchPanelTransport } from "./brunch-panel-transport";
import {
Expand Down Expand Up @@ -88,18 +92,14 @@ const brunchPreviewConfig = resolveBrunchPreviewConfig(
import.meta.env.VITE_BRUNCH_CHAT_ENDPOINT,
);

const renderBrunchVoiceComposerControl = (
context: PetrinautAiComposerControlContext,
) => <VoiceInterviewControl {...context} />;

export const getBrunchVoiceComposerControl = (
isBrunchConfigured: boolean,
): PetrinautAiComposerControl | undefined =>
isBrunchConfigured ? renderBrunchVoiceComposerControl : undefined;

const brunchVoiceComposerControl = getBrunchVoiceComposerControl(
brunchPreviewConfig.isBrunchConfigured,
);
export const getBrunchVoiceInterviewStage = (
config: OpenAIVoiceConfig | null | undefined,
): PetrinautAiInterviewStage | undefined =>
config
? (context: PetrinautAiInterviewStageContext) => (
<VoiceInterviewControl {...context} config={config} />
)
: undefined;

const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle =>
createJsonDocHandle({
Expand Down Expand Up @@ -152,11 +152,38 @@ const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({
*/
export const LocalStorageDemoApp = () => {
const sentryFeedbackAction = useSentryFeedbackAction();
const [openAIVoiceConfig, setOpenAIVoiceConfig] =
useState<OpenAIVoiceConfig | null>();
const { aiMessagesByNetId, setAiMessagesByNetId } =
useLocalStorageAiMessages();
const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs();
const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs);

useEffect(() => {
if (!brunchPreviewConfig.isBrunchConfigured) {
// eslint-disable-next-line react-hooks-js/set-state-in-effect -- Resolve the loading sentinel when voice is not configured.
setOpenAIVoiceConfig(null);
return;
}

const abortController = new AbortController();
void loadOpenAIVoiceConfig(
globalThis.fetch.bind(globalThis),
abortController.signal,
).then((config) => {
if (!abortController.signal.aborted) {
setOpenAIVoiceConfig(config);
}
});

return () => abortController.abort();
}, []);

const brunchVoiceInterviewStage = useMemo(
() => getBrunchVoiceInterviewStage(openAIVoiceConfig),
[openAIVoiceConfig],
);

// Pick the most recently modified net
const mostRecentlyModifiedNet =
Object.values(storedSDCPNsForDisplay).sort(
Expand Down Expand Up @@ -320,13 +347,18 @@ export const LocalStorageDemoApp = () => {
return next;
});
},
...(brunchVoiceComposerControl
...(brunchVoiceInterviewStage
? {
renderComposerControl: brunchVoiceComposerControl,
renderInterviewStage: brunchVoiceInterviewStage,
}
: {}),
}),
[aiMessagesByNetId, currentNetId, setAiMessagesByNetId],
[
aiMessagesByNetId,
brunchVoiceInterviewStage,
currentNetId,
setAiMessagesByNetId,
],
);

if (!currentNet) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";

import { SWEEP_TOOL_NAME } from "@hashintel/brunch-agent-transport-aisdk/client-tools";

import { selectInterviewCoverage } from "./interview-coverage";

import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui";

describe("interview coverage", () => {
test("uses only authoritative completion results for covered and open topics", () => {
const messages = [
{
id: "assistant-sweep",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolCallId: "sweep-1",
toolName: SWEEP_TOOL_NAME,
state: "output-available",
input: {},
output: {
status: "applied",
appliedCaptureIds: ["capture-owner"],
captures: [
{
id: "capture-owner",
status: "active",
epistemicStatus: "explicit",
confidence: "high",
content: {
value: {
type: "slot-asserted",
kind: "activity",
node: "approval",
slot: "who performs it",
precision: "named",
assertion: { value: "shift lead" },
},
},
},
{
id: "capture-old",
status: "superseded",
epistemicStatus: "explicit",
confidence: "high",
content: {
value: {
type: "slot-asserted",
kind: "activity",
node: "approval",
slot: "how long it takes",
assertion: { value: "one hour" },
},
},
},
],
completion: {
complete: false,
pluginVersion: "sdcpn/1",
revision: "revision-1",
failures: [
{
diagnostic: "unaddressed",
nodeId: "activity:approval",
kind: "activity",
slot: "how long it takes",
requirement: "spread",
actual: "not mentioned",
message: "Duration is still unknown.",
captureIds: [],
},
],
sliceNodeIds: ["activity:approval", "activity:dispatch"],
outsideSlice: [],
},
},
},
],
},
] as unknown as PetrinautAiMessage[];

expect(selectInterviewCoverage(messages)).toEqual({
complete: false,
covered: ["dispatch"],
stillExploring: ["approval β€” how long it takes"],
});
});

test("omits coverage when no validated completion report exists", () => {
expect(selectInterviewCoverage([])).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { SWEEP_TOOL_NAME } from "@hashintel/brunch-agent-transport-aisdk/client-tools";

import { sweepOutputSchema } from "../local-storage-demo/brunch-panel-transport";

import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui";

export interface InterviewCoverage {
readonly complete: boolean;
readonly covered: readonly string[];
readonly stillExploring: readonly string[];
}

const unique = (items: string[]): string[] => [...new Set(items)];

const nodeLabel = (nodeId: string): string => {
const separator = nodeId.indexOf(":");
return separator === -1 ? nodeId : nodeId.slice(separator + 1);
};

export const selectInterviewCoverage = (
messages: PetrinautAiMessage[],
): InterviewCoverage | null => {
for (const message of messages.toReversed()) {
for (const part of message.parts.toReversed()) {
if (
part.type !== "dynamic-tool" ||
part.toolName !== SWEEP_TOOL_NAME ||
part.state !== "output-available"
) {
continue;
}
const parsed = sweepOutputSchema.safeParse(part.output);
if (
!parsed.success ||
parsed.data.status !== "applied" ||
parsed.data.completion === undefined
) {
continue;
}

const { completion } = parsed.data;
const nodesWithFailures = new Set(
completion.failures.flatMap((failure) =>
failure.nodeId === undefined ? [] : [failure.nodeId],
),
);
const covered = completion.sliceNodeIds
.filter((nodeId) => !nodesWithFailures.has(nodeId))
.map(nodeLabel);
const stillExploring = unique(
completion.failures.map((failure) =>
failure.nodeId === undefined
? failure.message
: `${nodeLabel(failure.nodeId)} β€” ${failure.slot ?? failure.message}`,
),
);

return {
complete: completion.complete,
covered,
stillExploring,
};
}
}
return null;
};
Loading
Loading