diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index 253fc8ff4d3..076c77319f0 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -173,17 +173,52 @@ try { }; faux.setResponses([ - fauxAssistantMessage( - [ - fauxThinking("Load the modelling runbook skill."), - fauxToolCall( - ACTIVATE_SKILL_TOOL_NAME, - { name: RUNBOOK_SKILL_NAME }, - { id: "tool-skill-1" }, - ), - ], - { stopReason: "toolUse" }, - ), + (context: unknown) => { + const modelRequest = JSON.stringify(context); + const groundingInstruction = + "Before answering any request about this net, the current net, or the existing net—including before beginning an interview—call `getLatestNetDefinition`."; + if (!modelRequest.includes(groundingInstruction)) { + throw new Error( + `ordinary model request omitted grounding instruction: ${groundingInstruction}`, + ); + } + const canvasAvailabilityInstruction = + "Do not say the canvas is unavailable while you can call `getLatestNetDefinition`."; + if (!modelRequest.includes(canvasAvailabilityInstruction)) { + throw new Error( + `ordinary model request omitted canvas availability instruction: ${canvasAvailabilityInstruction}`, + ); + } + if (!modelRequest.includes('"name":"getLatestNetDefinition"')) { + throw new Error( + "ordinary model request omitted getLatestNetDefinition", + ); + } + for (const mutationToolName of [ + "addType", + "addParameter", + "addPlace", + "addTransition", + "addArc", + ]) { + if (modelRequest.includes(`"name":"${mutationToolName}"`)) { + throw new Error( + `ordinary model request mounted mutation tool ${mutationToolName}`, + ); + } + } + return fauxAssistantMessage( + [ + fauxThinking("Load the modelling runbook skill."), + fauxToolCall( + ACTIVATE_SKILL_TOOL_NAME, + { name: RUNBOOK_SKILL_NAME }, + { id: "tool-skill-1" }, + ), + ], + { stopReason: "toolUse" }, + ); + }, fauxAssistantMessage( [ fauxThinking("The job skill routes universal judgment to core."), diff --git a/apps/brunch-agent/test/petrinaut-chat.test.ts b/apps/brunch-agent/test/petrinaut-chat.test.ts index 2ee108a9ac0..322e4cf6a5a 100644 --- a/apps/brunch-agent/test/petrinaut-chat.test.ts +++ b/apps/brunch-agent/test/petrinaut-chat.test.ts @@ -121,16 +121,15 @@ test("the browser transport streams the mounted Flue agent through server and cl expect(result.interviewerToolNames).not.toContain("brunch_ask"); expect(result.interviewerToolNames).not.toContain("sweep"); expect(result.interviewerToolNames).not.toContain("brunch_sweep"); - expect(result.interviewerToolNames).not.toEqual( - expect.arrayContaining([ - "getLatestNetDefinition", - "addType", - "addParameter", - "addPlace", - "addTransition", - "addArc", - ]), - ); + for (const mutationToolName of [ + "addType", + "addParameter", + "addPlace", + "addTransition", + "addArc", + ]) { + expect(result.interviewerToolNames).not.toContain(mutationToolName); + } expect(result.captureIds.length).toBe(1); expect(result.captureExcerpts).toEqual([ "Run the FE-1435 transport probe.", diff --git a/apps/brunch-agent/test/runbook-headless.test.ts b/apps/brunch-agent/test/runbook-headless.test.ts index c9d20996370..5e85f78dc6f 100644 --- a/apps/brunch-agent/test/runbook-headless.test.ts +++ b/apps/brunch-agent/test/runbook-headless.test.ts @@ -64,9 +64,7 @@ test("the built ChatAgent reports only the construct-only evidence it reaches", "references/checks.md", ]); expect(result.validationRejections).toHaveLength(1); - expect(result.validationRejections[0]).toContain( - "expected number to be >0", - ); + expect(result.validationRejections[0]).toContain("weight: must be > 0"); expect(result.emittedFreeFormPnJson).toBe(false); expect(result.emittedUpdatedWorkpiece).toBe(true); expect(result.evidenceLevelHonest).toBe(true); diff --git a/apps/brunch-agent/test/scratch-project-construction.integration.ts b/apps/brunch-agent/test/scratch-project-construction.integration.ts new file mode 100644 index 00000000000..5617efa8a25 --- /dev/null +++ b/apps/brunch-agent/test/scratch-project-construction.integration.ts @@ -0,0 +1,270 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + fauxAssistantMessage, + fauxProvider, + fauxText, + fauxToolCall, +} from "@earendil-works/pi-ai"; +import { setProvider } from "@flue/runtime"; +import { createFlueClient } from "@flue/sdk"; + +import { SCRATCH_PROJECT_CONSTRUCTION_MODE } from "@hashintel/brunch-agent-plugin-sdcpn/flue"; + +import { + CLIENT_TOOL_RESULT_SIGNAL, + isAwaitingClient, +} from "../src/conversation/client-tools.ts"; +import { + agentOwnershipHeaders, + flueConversationIdFrom, +} from "../src/conversation/identity.ts"; +import { + createHeadlessPetrinautClient, + isPetrinautConstructionToolName, +} from "../src/evaluations/runbook/headless-petrinaut-client.ts"; +import { loadBuiltBrunchApplication } from "../src/evaluations/runbook/load-built-application.ts"; +import { CHAT_AGENT_ROUTE } from "../src/http/routes.ts"; + +const chatModelId = "claude-haiku-4-5"; + +process.env.BRUNCH_CHAT_MODEL = chatModelId; +process.env.BRUNCH_DEV_DB_PATH = + process.env.BRUNCH_CHAT_DB_PATH ?? + join(tmpdir(), `brunch-scratch-${crypto.randomUUID()}.db`); + +const faux = fauxProvider({ + provider: "anthropic", + models: [{ id: chatModelId, reasoning: true }], +}); +setProvider(faux.provider); +faux.setResponses([ + (context: unknown) => { + const modelRequest = JSON.stringify(context); + const groundingInstruction = + "Before answering any request about this net, the current net, or the existing net—including before beginning an interview—call `getLatestNetDefinition`."; + if (!modelRequest.includes(groundingInstruction)) { + throw new Error( + `model request omitted grounding instruction: ${groundingInstruction}`, + ); + } + return fauxAssistantMessage( + [ + fauxToolCall( + "activate_skill", + { name: "sdcpn-modelling" }, + { id: "activate-sdcpn" }, + ), + ], + { stopReason: "toolUse" }, + ); + }, + fauxAssistantMessage( + [fauxToolCall("getLatestNetDefinition", {}, { id: "read-empty-scratch" })], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage( + [ + fauxToolCall( + "addPlace", + { + id: "orders_waiting", + name: "OrdersWaiting", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 80, + y: 160, + }, + { id: "add-orders-waiting" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage( + [ + fauxToolCall( + "addPlace", + { + id: "orders_fulfilled", + name: "OrdersFulfilled", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 520, + y: 160, + }, + { id: "add-orders-fulfilled" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage( + [ + fauxToolCall( + "addTransition", + { + id: "fulfill_order", + name: "Fulfill order", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 300, + y: 160, + }, + { id: "add-fulfill-order" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage( + [ + fauxToolCall( + "addArc", + { + transitionId: "fulfill_order", + arcDirection: "input", + placeId: "orders_waiting", + weight: 1, + }, + { id: "connect-orders-waiting" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage( + [ + fauxToolCall( + "addArc", + { + transitionId: "fulfill_order", + arcDirection: "output", + placeId: "orders_fulfilled", + weight: 1, + }, + { id: "connect-orders-fulfilled" }, + ), + ], + { stopReason: "toolUse" }, + ), + fauxAssistantMessage([ + fauxText( + "The order flow is now visible in the open scratch project. I used an immediate predicate transition because no timing assumptions were supplied.", + ), + ]), +]); + +const identity = { + principalKey: "principal-scratch-project", + conversationId: "conversation-scratch-project", +}; +const instanceId = flueConversationIdFrom(identity); +const petrinautClient = createHeadlessPetrinautClient("New Process"); +const application = await loadBuiltBrunchApplication(); + +try { + const appTransport: typeof fetch = async (input, init) => + application.fetch( + input instanceof Request ? input : new Request(input, init), + ); + const client = createFlueClient({ + url: `http://brunch.local/agents/${CHAT_AGENT_ROUTE}/${instanceId}`, + fetch: appTransport, + headers: agentOwnershipHeaders(identity), + }); + const firstAdmission = await client.send({ + initialData: { mode: SCRATCH_PROJECT_CONSTRUCTION_MODE }, + idempotencyKey: "scratch-project:user-1", + message: { + kind: "user", + body: [ + "Build this small process in the open scratch project using sensible defaults.", + "Orders wait to be fulfilled. Fulfillment moves one order from waiting to fulfilled.", + ].join("\n"), + }, + }); + await client.wait(firstAdmission); + + const completedCallIds = new Set(); + const serviceClientCalls = async (clientRound = 0): Promise => { + if (clientRound >= 10) return; + const snapshot = await client.history(); + const pendingCalls = snapshot.messages.flatMap((message) => + message.parts.flatMap((part) => { + if ( + part.type !== "dynamic-tool" || + !isPetrinautConstructionToolName(part.toolName) || + completedCallIds.has(part.toolCallId) || + part.state !== "output-available" || + !isAwaitingClient(part.output) + ) { + return []; + } + return [ + { + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.input, + }, + ]; + }), + ); + if (pendingCalls.length === 0) return; + + const results = await Promise.all( + pendingCalls.map((pendingCall) => petrinautClient.execute(pendingCall)), + ); + for (const result of results) completedCallIds.add(result.toolCallId); + const admission = await client.send({ + idempotencyKey: `scratch-project:tools:${results + .map(({ toolCallId }) => toolCallId) + .join(",")}`, + message: { + kind: "signal", + type: CLIENT_TOOL_RESULT_SIGNAL, + tagName: CLIENT_TOOL_RESULT_SIGNAL, + body: JSON.stringify(results), + }, + }); + await client.wait(admission); + await serviceClientCalls(clientRound + 1); + }; + await serviceClientCalls(); + + const definition = petrinautClient.definition(); + const transition = definition.transitions.find( + ({ id }) => id === "fulfill_order", + ); + const snapshot = await client.history(); + const toolNames = snapshot.messages.flatMap((message) => + message.parts.flatMap((part) => + part.type === "dynamic-tool" ? [part.toolName] : [], + ), + ); + const failedCalls = snapshot.messages.flatMap((message) => + message.parts.flatMap((part) => + part.type === "dynamic-tool" && part.state === "output-error" + ? [part.toolCallId] + : [], + ), + ); + + process.stdout.write( + `SCRATCH_PROJECT_CONSTRUCTION_RESULT ${JSON.stringify({ + completedCallIds: [...completedCallIds], + failedCalls, + inputArcCount: transition?.inputArcs.length ?? 0, + outputArcCount: transition?.outputArcs.length ?? 0, + parseOk: petrinautClient.parse().ok, + placeIds: definition.places.map(({ id }) => id), + toolNames, + transitionIds: definition.transitions.map(({ id }) => id), + })}\n`, + ); +} finally { + petrinautClient.dispose(); + await application.stop(); +} diff --git a/apps/brunch-agent/test/scratch-project-construction.test.ts b/apps/brunch-agent/test/scratch-project-construction.test.ts new file mode 100644 index 00000000000..132984b4af0 --- /dev/null +++ b/apps/brunch-agent/test/scratch-project-construction.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, test } from "vitest"; + +import { runNodeScript } from "./run-node-script"; + +interface ScratchProjectConstructionResult { + readonly completedCallIds: readonly string[]; + readonly failedCalls: readonly string[]; + readonly inputArcCount: number; + readonly outputArcCount: number; + readonly parseOk: boolean; + readonly placeIds: readonly string[]; + readonly toolNames: readonly string[]; + readonly transitionIds: readonly string[]; +} + +test("one concrete request automatically constructs a complete scratch net", async () => { + const dbDirectory = await mkdtemp(join(tmpdir(), "brunch-scratch-")); + const dbPath = join(dbDirectory, "conversations.db"); + + try { + const { exitCode, stdout, stderr } = await runNodeScript( + join(import.meta.dirname, "scratch-project-construction.integration.ts"), + join(import.meta.dirname, "../../.."), + { BRUNCH_CHAT_DB_PATH: dbPath }, + ); + + expect(exitCode, stderr || stdout).toBe(0); + const resultLine = stdout + .split("\n") + .find((line) => line.startsWith("SCRATCH_PROJECT_CONSTRUCTION_RESULT ")); + expect(resultLine, stdout).toBeDefined(); + const result = JSON.parse( + resultLine!.slice("SCRATCH_PROJECT_CONSTRUCTION_RESULT ".length), + ) as ScratchProjectConstructionResult; + + expect(result.parseOk).toBe(true); + expect(result.failedCalls).toEqual([]); + expect(result.placeIds).toEqual(["orders_waiting", "orders_fulfilled"]); + expect(result.transitionIds).toEqual(["fulfill_order"]); + expect(result.inputArcCount).toBe(1); + expect(result.outputArcCount).toBe(1); + expect(result.completedCallIds).toEqual([ + "read-empty-scratch", + "add-orders-waiting", + "add-orders-fulfilled", + "add-fulfill-order", + "connect-orders-waiting", + "connect-orders-fulfilled", + ]); + expect(result.toolNames).toEqual( + expect.arrayContaining([ + "getLatestNetDefinition", + "addPlace", + "addTransition", + "addArc", + ]), + ); + } finally { + await rm(dbDirectory, { recursive: true, force: true }); + } +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-client-tools.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-client-tools.ts index e3226db07ec..3e453393148 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-client-tools.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-client-tools.ts @@ -1,6 +1,23 @@ -import { ASK_TOOL_NAME } from "@hashintel/brunch-agent/client-tools"; import { readPetrinautDocToolName } from "@hashintel/petrinaut-core"; +import type { PetrinautAiToolName } from "@hashintel/petrinaut-core/ai"; + +export const scratchProjectConstructionMode = + "scratch-project-construction" as const; + +export const scratchProjectConstructionInitialData = { + mode: scratchProjectConstructionMode, +} as const; + +export const scratchProjectConstructionToolNames = [ + "getLatestNetDefinition", + "addType", + "addParameter", + "addPlace", + "addTransition", + "addArc", +] as const satisfies readonly PetrinautAiToolName[]; + /** * The one catalog of tools the browser answers on Brunch's behalf. The panel * transport admits their results, the history projection leaves them runnable, @@ -10,5 +27,5 @@ import { readPetrinautDocToolName } from "@hashintel/petrinaut-core"; */ export const brunchClientToolNames: ReadonlySet = new Set([ readPetrinautDocToolName, - ASK_TOOL_NAME, + ...scratchProjectConstructionToolNames, ]); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts index d616fa9fe5b..2dc54438a7c 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.test.ts @@ -6,7 +6,12 @@ import { createBrunchPanelTransport, } from "./brunch-panel-transport"; -import type { AgentSendResult, FlueClient } from "@flue/sdk"; +import type { + AgentSendResult, + ConversationStreamChunk, + FlueClient, +} from "@flue/sdk"; +import type { UIMessageChunk } from "ai"; test("delegates one typed message to the supplied Flue conversation", async () => { const admission: AgentSendResult = { @@ -106,6 +111,203 @@ test("delegates one typed message to the supplied Flue conversation", async () = expect(onAdmission).toHaveBeenCalledWith(admission); }); +test("seeds scratch mode and admits construction calls as browser tools", async () => { + const admission: AgentSendResult = { + streamUrl: "http://brunch.test/stream", + offset: "offset-scratch", + submissionId: "submission-scratch", + uid: "uid-scratch", + }; + const events: readonly ConversationStreamChunk[] = [ + { + type: "message-started", + conversationId: "conversation-scratch", + messageId: "assistant-scratch", + submissionId: admission.submissionId, + turnId: "turn-scratch", + position: { batch: 1, index: 0 }, + }, + { + type: "tool-input", + conversationId: "conversation-scratch", + messageId: "assistant-scratch", + toolCallId: "add-place", + toolName: "addPlace", + input: { + id: "orders_waiting", + name: "OrdersWaiting", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 80, + y: 160, + }, + position: { batch: 1, index: 1 }, + }, + { + type: "tool-output", + conversationId: "conversation-scratch", + toolCallId: "add-place", + output: { awaiting: "client" }, + position: { batch: 1, index: 2 }, + }, + { + type: "submission-settled", + conversationId: "conversation-scratch", + submissionId: admission.submissionId, + outcome: "completed", + position: { batch: 1, index: 3 }, + }, + ]; + const send = vi.fn(async () => admission); + const wait = vi.fn(async (_admission, options) => { + for (const event of events) { + // Preserve the canonical stream order. + await options?.onEvent?.(event); + } + }); + const client = { + send, + wait, + } as Pick as FlueClient; + const initialData = { mode: "scratch-project-construction" }; + const transport = createBrunchPanelTransport( + Promise.resolve(client), + new BrunchPanelConversationTracker(), + { + clientToolNames: new Set(["addPlace"]), + initialData, + } as { + readonly clientToolNames: ReadonlySet; + readonly initialData: unknown; + }, + ); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "conversation-scratch", + messageId: undefined, + messages: [ + { + id: "user-scratch", + role: "user", + parts: [{ type: "text", text: "Model this process." }], + }, + ], + abortSignal: undefined, + }); + const chunks: UIMessageChunk[] = []; + for await (const chunk of stream) chunks.push(chunk); + + expect(send).toHaveBeenCalledWith({ + idempotencyKey: "ai-sdk:user-scratch", + initialData, + message: { kind: "user", body: "Model this process." }, + signal: undefined, + }); + expect( + chunks.find( + (chunk) => + chunk.type === "tool-input-available" && chunk.toolName === "addPlace", + ), + ).toMatchObject({ + type: "tool-input-available", + toolName: "addPlace", + }); + expect( + chunks.find( + (chunk) => + chunk.type === "tool-input-available" && chunk.toolName === "addPlace", + ), + ).not.toHaveProperty("providerExecuted"); +}); + +test("passes host client-tool input mapping to the live transport", async () => { + const admission: AgentSendResult = { + streamUrl: "http://brunch.test/stream", + offset: "offset-mapped", + submissionId: "submission-mapped", + uid: "uid-mapped", + }; + const events: readonly ConversationStreamChunk[] = [ + { + type: "message-started", + conversationId: "conversation-mapped", + messageId: "assistant-mapped", + submissionId: admission.submissionId, + turnId: "turn-mapped", + position: { batch: 1, index: 0 }, + }, + { + type: "tool-input", + conversationId: "conversation-mapped", + messageId: "assistant-mapped", + toolCallId: "add-arc", + toolName: "addArc", + input: { weight: "1" }, + position: { batch: 1, index: 1 }, + }, + { + type: "tool-output", + conversationId: "conversation-mapped", + toolCallId: "add-arc", + output: { awaiting: "client" }, + position: { batch: 1, index: 2 }, + }, + { + type: "submission-settled", + conversationId: "conversation-mapped", + submissionId: admission.submissionId, + outcome: "completed", + position: { batch: 1, index: 3 }, + }, + ]; + const wait = vi.fn(async (_admission, options) => { + for (const event of events) { + // Preserve the canonical stream order. + await options?.onEvent?.(event); + } + }); + const client = { + send: vi.fn(async () => admission), + wait, + } as Pick as FlueClient; + const transport = createBrunchPanelTransport( + Promise.resolve(client), + new BrunchPanelConversationTracker(), + { + clientToolNames: new Set(["addArc"]), + mapClientToolInput: ({ input }) => ({ + ...(input as object), + weight: 1, + }), + }, + ); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "conversation-mapped", + messageId: undefined, + messages: [ + { + id: "user-mapped", + role: "user", + parts: [{ type: "text", text: "Add the confirmed arc." }], + }, + ], + abortSignal: undefined, + }); + const chunks: UIMessageChunk[] = []; + for await (const chunk of stream) chunks.push(chunk); + + expect(chunks).toContainEqual({ + type: "tool-input-available", + toolCallId: "add-arc", + toolName: "addArc", + input: { weight: 1 }, + }); +}); + test("matches client-tool admissions once and supports unsubscribe", () => { const admission: AgentSendResult = { streamUrl: "http://brunch.test/stream", diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts index b43c667f6c6..4cda9725e65 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-panel-transport.ts @@ -310,6 +310,9 @@ export const createBrunchPanelTransport = ( clientPromise: Promise, tracker: BrunchPanelConversationTracker, hooks?: { + readonly clientToolNames?: FlueChatTransportOptions["clientToolNames"]; + readonly initialData?: FlueChatTransportOptions["initialData"]; + readonly mapClientToolInput?: FlueChatTransportOptions["mapClientToolInput"]; readonly onAdmission?: (admission: AgentSendResult) => void; }, ): PetrinautAiChatTransport => ({ @@ -320,8 +323,13 @@ export const createBrunchPanelTransport = ( const client = await clientPromise; const transport = createFlueChatTransport({ client, - clientToolNames: new Set([readPetrinautDocToolName]), + clientToolNames: + hooks?.clientToolNames ?? new Set([readPetrinautDocToolName]), hiddenToolNames: new Set([BRUNCH_QUESTION_TOOL_NAME]), + initialData: hooks?.initialData, + ...(hooks?.mapClientToolInput === undefined + ? {} + : { mapClientToolInput: hooks.mapClientToolInput }), onAdmission: (event) => { tracker.recordAdmission(event); hooks?.onAdmission?.(event.admission); 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 7edbbd16255..d532860eb3b 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 @@ -19,6 +19,7 @@ import { import type { AgentConversationObservationSnapshot, + AgentSendResult, FlueClient, } from "@flue/sdk"; import type { PetrinautNavigationController } from "@hashintel/petrinaut/react"; @@ -70,6 +71,25 @@ vi.mock("@hashintel/petrinaut/ui", () => ({ definePetrinautAiInteractiveTool: (definition: unknown) => definition, })); +/** + * Node supplies its own `localStorage` global that shadows the jsdom one and + * carries no `setItem`, so the demo's storage hooks cannot read a seed from + * it. An in-memory store gives them one. + */ +const stubStorage = () => { + const entries = new Map(); + vi.stubGlobal("localStorage", { + get length() { + return entries.size; + }, + clear: () => entries.clear(), + getItem: (key: string) => entries.get(key) ?? null, + key: (index: number) => [...entries.keys()][index] ?? null, + removeItem: (key: string) => entries.delete(key), + setItem: (key: string, value: string) => entries.set(key, value), + } satisfies Storage); +}; + describe("local storage demo Brunch voice integration", () => { test("does not install voice on the generic local chat fallback", () => { expect(getBrunchVoiceMode(null)).toBeUndefined(); @@ -198,6 +218,65 @@ describe("local storage demo Brunch voice integration", () => { vi.unstubAllGlobals(); }); + test("initializes an empty demo net as a scratch construction conversation", async () => { + stubStorage(); + renderedPetrinaut.aiAssistant = null; + const admission: AgentSendResult = { + streamUrl: "http://brunch.test/stream", + offset: "offset-scratch", + submissionId: "submission-scratch", + uid: "uid-scratch", + }; + const send = vi.fn(async () => admission); + const wait = vi.fn(async () => undefined); + flueClientMock.current = { + send, + wait, + observe: () => ({ + close: vi.fn(), + getSnapshot: () => ({ phase: "absent" }), + refresh: vi.fn(), + subscribe: () => () => undefined, + }), + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ available: false }), + ), + ); + + const rendered = render( + {}} search={{}} />, + ); + await waitFor(() => expect(renderedPetrinaut.aiAssistant).not.toBeNull()); + const aiAssistant = renderedPetrinaut.aiAssistant as PetrinautAiAssistant; + + await aiAssistant.transport.sendMessages({ + trigger: "submit-message", + chatId: aiAssistant.conversationId ?? "missing-conversation", + messageId: undefined, + messages: [ + { + id: "user-scratch", + role: "user", + parts: [{ type: "text", text: "Model this process." }], + }, + ], + abortSignal: undefined, + }); + + expect(send).toHaveBeenCalledWith({ + idempotencyKey: "ai-sdk:user-scratch", + initialData: { mode: "scratch-project-construction" }, + message: { kind: "user", body: "Model this process." }, + signal: undefined, + }); + + rendered.unmount(); + vi.unstubAllGlobals(); + }); + test("keeps durable Flue Stop distinct from local playback cancellation", async () => { renderedPetrinaut.aiAssistant = null; let snapshot: AgentConversationObservationSnapshot = { @@ -327,25 +406,6 @@ describe("local storage demo Brunch voice integration", () => { }); }); -/** - * Node supplies its own `localStorage` global that shadows the jsdom one and - * carries no `setItem`, so the demo's storage hooks cannot read a seed from - * it. An in-memory store gives them one. - */ -const stubStorage = () => { - const entries = new Map(); - vi.stubGlobal("localStorage", { - get length() { - return entries.size; - }, - clear: () => entries.clear(), - getItem: (key: string) => entries.get(key) ?? null, - key: (index: number) => [...entries.keys()][index] ?? null, - removeItem: (key: string) => entries.delete(key), - setItem: (key: string, value: string) => entries.set(key, value), - } satisfies Storage); -}; - const seedStoredNet = () => { stubStorage(); localStorage.setItem( 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 6e666ee7a70..b376f253060 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 @@ -44,6 +44,10 @@ import { type OpenAIVoiceConfig, VoiceInterviewControl, } from "../voice-interview/voice-interview-control"; +import { + brunchClientToolNames, + scratchProjectConstructionInitialData, +} from "./brunch-client-tools"; import { getOrCreateBrunchConversationId } from "./brunch-conversation-id"; import { BrunchPanelConversationTracker, @@ -494,14 +498,25 @@ export const LocalStorageDemoApp = ({ ), [conversationTracker, flueHistory.settlements, openAIVoiceConfig], ); + const brunchInitialData = + activeHandle !== null && isEmptySDCPN(activeHandle.fallbackNet.sdcpn) + ? scratchProjectConstructionInitialData + : undefined; const petrinautAiChatTransport = useMemo( () => flueClientPromise === null ? stockChatTransport : createBrunchPanelTransport(flueClientPromise, conversationTracker, { + clientToolNames: brunchClientToolNames, + initialData: brunchInitialData, onAdmission: flueHistory.refresh, }), - [conversationTracker, flueClientPromise, flueHistory.refresh], + [ + brunchInitialData, + conversationTracker, + flueClientPromise, + flueHistory.refresh, + ], ); const aiAssistant = useMemo( diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.test.ts index c29ad8a16d9..397a5ea50d2 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.test.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.test.ts @@ -83,6 +83,152 @@ test("hydrates through the public Flue observation projection", async () => { expect(harness.observe).toHaveBeenCalledWith({ live: "sse" }); }); +test("projects a pending live-net read as a browser client tool", async () => { + const harness = createObservationHarness({ + conversation: { + conversationId: "conversation-1", + settlements: [], + messages: [ + { + id: "assistant-net-read", + role: "assistant", + purpose: "assistant", + display: "visible", + parts: [ + { + type: "dynamic-tool", + toolCallId: "tool-net-1", + toolName: "getLatestNetDefinition", + state: "output-available", + input: {}, + output: { awaiting: "client" }, + }, + ], + }, + ], + }, + offset: "offset-net-read", + phase: "live", + error: undefined, + }); + const { result } = renderHook(() => + useFlueChatHistory(harness.clientPromise, "conversation-1"), + ); + + await waitFor(() => expect(result.current.ready).toBe(true)); + expect(result.current.messages).toStrictEqual([ + { + id: "assistant-net-read", + role: "assistant", + parts: [ + { + type: "tool-getLatestNetDefinition", + toolCallId: "tool-net-1", + state: "input-available", + input: {}, + }, + ], + }, + ]); +}); + +test("uses the host-supplied client-tool catalog during hydration", async () => { + const harness = createObservationHarness({ + conversation: { + conversationId: "conversation-1", + settlements: [], + messages: [ + { + id: "assistant-net-read", + role: "assistant", + purpose: "assistant", + display: "visible", + parts: [ + { + type: "dynamic-tool", + toolCallId: "tool-net-1", + toolName: "getLatestNetDefinition", + state: "output-available", + input: {}, + output: { awaiting: "client" }, + }, + ], + }, + ], + }, + offset: "offset-net-read", + phase: "live", + error: undefined, + }); + const { result } = renderHook(() => + useFlueChatHistory( + harness.clientPromise, + "conversation-1", + new Set(), + ), + ); + + await waitFor(() => expect(result.current.ready).toBe(true)); + expect(result.current.messages?.[0]?.parts).toStrictEqual([ + { + type: "tool-getLatestNetDefinition", + toolCallId: "tool-net-1", + state: "output-available", + input: {}, + output: { awaiting: "client" }, + providerExecuted: true, + }, + ]); +}); + +test("maps hydrated client-tool input through the host seam", async () => { + const harness = createObservationHarness({ + conversation: { + conversationId: "conversation-1", + settlements: [], + messages: [ + { + id: "assistant-arc", + role: "assistant", + purpose: "assistant", + display: "visible", + parts: [ + { + type: "dynamic-tool", + toolCallId: "tool-arc-1", + toolName: "addArc", + state: "output-available", + input: { weight: "1" }, + output: { awaiting: "client" }, + }, + ], + }, + ], + }, + offset: "offset-arc", + phase: "live", + error: undefined, + }); + const { result } = renderHook(() => + useFlueChatHistory( + harness.clientPromise, + "conversation-1", + new Set(["addArc"]), + ({ input }) => ({ ...(input as object), weight: 1 }), + ), + ); + + await waitFor(() => expect(result.current.ready).toBe(true)); + expect(result.current.messages?.[0]?.parts).toStrictEqual([ + { + type: "tool-addArc", + toolCallId: "tool-arc-1", + state: "input-available", + input: { weight: 1 }, + }, + ]); +}); + test("exposes the canonical settlement index for Voice correlation", async () => { const harness = createObservationHarness({ conversation: { diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.ts index 65b914a5e3d..34fe16e9e38 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/use-flue-chat-history.ts @@ -1,8 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { snapshotToUiMessages } from "@hashintel/brunch-agent-transport-aisdk"; +import { + snapshotToUiMessages, + type SnapshotToUiMessagesOptions, +} from "@hashintel/brunch-agent-transport-aisdk"; import { BRUNCH_QUESTION_TOOL_NAME } from "@hashintel/brunch-agent/question-marker"; -import { readPetrinautDocToolName } from "@hashintel/petrinaut-core"; + +import { brunchClientToolNames } from "./brunch-client-tools"; import type { AgentConversationObservation, @@ -18,17 +22,22 @@ const noSettlements: readonly FlueConversationSettlement[] = []; const projectPetrinautMessages = ( conversation: FlueConversationState, + clientToolNames: ReadonlySet, + mapClientToolInput: SnapshotToUiMessagesOptions["mapClientToolInput"], ): PetrinautAiMessage[] => // The host owns this narrowing: its configured client-tool catalog is the // same catalog Petrinaut's message type exposes. snapshotToUiMessages(conversation, { - clientToolNames: new Set([readPetrinautDocToolName]), + clientToolNames, hiddenToolNames: new Set([BRUNCH_QUESTION_TOOL_NAME]), + ...(mapClientToolInput === undefined ? {} : { mapClientToolInput }), }) as PetrinautAiMessage[]; export const useFlueChatHistory = ( clientPromise: Promise | null, conversationId: string, + clientToolNames: ReadonlySet = brunchClientToolNames, + mapClientToolInput?: SnapshotToUiMessagesOptions["mapClientToolInput"], ): { readonly error: Error | undefined; readonly latestSettlement: FlueConversationSettlement | undefined; @@ -107,7 +116,11 @@ export const useFlueChatHistory = ( ? absent ? [] : undefined - : projectPetrinautMessages(conversation), + : projectPetrinautMessages( + conversation, + clientToolNames, + mapClientToolInput, + ), phase: snapshot?.phase, ready, refresh, diff --git a/apps/petrinaut-website/src/main/app/voice-interview/voice-preview.integration.test.ts b/apps/petrinaut-website/src/main/app/voice-interview/voice-preview.integration.test.ts index b067de20f55..6405163b51c 100644 --- a/apps/petrinaut-website/src/main/app/voice-interview/voice-preview.integration.test.ts +++ b/apps/petrinaut-website/src/main/app/voice-interview/voice-preview.integration.test.ts @@ -695,6 +695,9 @@ describe("controlled voice preview", () => { const transport = createBrunchPanelTransport( Promise.resolve(client), tracker, + { + initialData: { mode: "scratch-project-construction" }, + } as { readonly initialData: unknown }, ); let realtimeListener: | ((event: OpenAIRealtimeSessionEvent) => void) @@ -782,6 +785,7 @@ describe("controlled voice preview", () => { const sendInput = send.mock.calls[0]?.[0]; expect(sendInput).toMatchObject({ idempotencyKey: "ai-sdk:voice-realtime:1:input-item-1:0", + initialData: { mode: "scratch-project-construction" }, message: { kind: "user", body: spokenAnswer }, }); expect(sendInput?.signal).toBeInstanceOf(AbortSignal); diff --git a/libs/@hashintel/brunch-agent/MISSION.md b/libs/@hashintel/brunch-agent/MISSION.md index ed8ff9333e4..08953fec8bd 100644 --- a/libs/@hashintel/brunch-agent/MISSION.md +++ b/libs/@hashintel/brunch-agent/MISSION.md @@ -1,197 +1,185 @@ -# Mission 5 successor — Voice safety and UX parity on the unified Flue route +# H-6763 successor — automatic whole-net preview in a Petrinaut scratch project ## Status -**Live as of 2026-09-04** for [FE-1580](https://linear.app/hash/issue/FE-1580/harden-voice-safety-and-ux-on-the-unified-flue-route) on `kostandin/fe-1580-harden-voice-safety-and-ux-on-the-unified-flue-route`, stacked directly on [PR #9528](https://github.com/hashintel/hash/pull/9528) at the GitHub-verified head `eecbe99e201fd8cb78d9b719e789b6abd373ed1b`. This file is the sole execution authority for the successor branch. The restack adopts the parent's canonical hydration overwrite guard, multi-submission response correlation, settlement-driven durable Stop, aligned live/snapshot projection, queued Voice-input cancellation, and client-tool continuation behavior. The parent remains the authority for defects in those mechanisms; this branch must restack onto further parent fixes rather than repair them. - -The owner selected **half-duplex turn ownership** on 2026-09-03. While canonical assistant audio is pending or playing, the microphone is closed. Ownership transfers away from input as soon as canonical speech is requested, before `response.create` is sent: every accepted unfinished input item becomes stale and provisional transcript state is cleared. Initial automatic speech may begin before Brunch settlement when a new canonical segment is durably completed and correlated to the active Voice submission. The explicit **Your turn** action may cancel that audio immediately, but opens a fresh input turn only after both provider cancellation acknowledgement and Brunch settlement. Automatic duplex barge-in is rejected because assistant playback can become a false user turn. - -On 2026-09-04, the owner approved a non-interactive Brunch-owned question -marker for exact **Repeat question** replay. The marker is a server tool plus a -durable client data part: it identifies exact assistant-authored text but never -suspends for an answer, mounts `brunch_ask`, or creates a second Voice submission -path. For direct-user Voice provenance, the owner selected an upstream Flue -user-metadata contract rather than a local runtime patch or correlated sidecar -signal. The [decision record](docs/evidence/design/mission-5-question-marker-and-provenance-decision-2026-09-04.md) -defines the accepted and rejected mechanics. - -### Turn-ownership decision - -1. **Adopted — half-duplex explicit handoff.** It gives assistant playback exclusive ownership, makes cancellation settlement a visible boundary, and guarantees fresh post-handoff capture. The product cost is one extra **Your turn** action and barrier latency when the user interrupts. -2. **Rejected — automatic duplex barge-in.** It offers the most conversational interruption and avoids an explicit control, but an open microphone can transcribe assistant playback as a user answer, and capture can race an unsettled cancellation. This risk is unacceptable for authoritative completed transcripts. - -The parent now prevents its once-per-conversation canonical hydration from overwriting a locally submitted turn. The real microphone, handoff, Stop, hard-reload, and same-origin witness is therefore executable but remains unproved until the human witness and retained artifact bundle are complete. - -On 2026-09-04, the owner directed PR #9531 to close one bounded launcher -defect exposed while starting that witness. The Brunch panel config now retains -Petrinaut's stock API plugin so `/api/voice/config` and -`/api/voice/realtime-call` remain available beside the unchanged -`/agents/chat/*` Flue proxy. This is an explicit exception to the parent-defect -constraint below; it changes no conversation or Voice-turn semantics. - -On 2026-09-04, the owner directed PR #9531 to close two more bounded Petrinaut -defects: preserve real Flue and browser-tool errors through the transport and -display them in full, and replace deferred Voice-transcript disclosure with -immediate transcript display plus a dock-only collapsed live-session view. -Initial-CTA Voice entry defaults to that compact presentation; before a live -session starts, the consent and microphone-permission card sits above a compact -Voice setup dock, while composer-initiated Voice entry keeps the existing -expanded presentation. Ending Voice from a collapsed dock also closes the AI -panel instead of restoring its expanded text composer; ending from an expanded -dock retains the existing return-to-composer behavior. -These are explicit exceptions to the parent-defect constraint below; they -change no conversation authority, submission path, or Voice-turn semantics, -and local playback cancellation remains separate from durable abort. - -Completed-transcript authority, half-duplex ownership, admission idempotency, -the cancellation barrier, exact full-response replay, and exact marked-question -replay have focused regression coverage, including the interval between a -canonical speech request and output start. **Repeat question** is enabled only -for an approved durable Brunch marker whose exact text appears in finalized -assistant prose from the same message; the final text segment remains invalid -question authority. Proof item 5 is complete -only for the supported client-tool-result path: Flue signals persist each -Voice-origin tool-call id beside its output, and canonical projection -reconstructs multiple surviving origins. Direct spoken user attribution is -blocked because Flue 2.0.3 projects the generated `submissionId` but neither -caller metadata nor the caller idempotency key. The discarded -browser-correlation implementation would have violated the explicit -second-durable-store stop condition. The restacked hydration guard removes the -old parent blocker, but no real witness claim is valid until the retained human -evidence exists. - -The 2026-09-04 corrective verification covers the current 72-file successor -diff against #9528 head `eecbe99e201f`: the four focused race cases pass 4/4 -tests, the filtered production admission-outcome cases pass 3/3 tests, and the -complete seven-workspace Turbo run passes 39/39 tasks and 1,123/1,123 tests, -including 282/282 website tests and the Brunch core package. Architecture -validation passes with 68 layers, 337 edges, 690 files, 69 generated pages, and -38 authored pages. `git diff --check` passes, and the root formatter accepts all -5,527 matched files. The exact commands and dispositions are retained in the -[donor matrix](docs/evidence/implementations/mission-5-voice-safety-parity/donor-behavior-matrix.md#corrective-verification). - -The pinned donor-behavior decision record is the [FE-1580 donor matrix](docs/evidence/implementations/mission-5-voice-safety-parity/donor-behavior-matrix.md). Donor PRs are read-only evidence at their named heads; semantic reimplementation is required, never merge or cherry-pick. +**Live on `kostandin/h-6763-automatic-petrinaut-draft-preview`.** This branch +stacks on the local FE-1580 reconciliation head and inherits its completed- +transcript authority, one shared Flue conversation, admission idempotency, +half-duplex handoff, canonical speech, and durable Stop behavior. This mission +is a bounded parallel implementation intended to reconcile later with +FE-1575 / Mission 6; it must not copy Mission 6's fixture identity, coherent +bundle, or evidence architecture. + +Behavior donor [#9523](https://github.com/hashintel/hash/pull/9523) is pinned at +`f4476e62bbb4509ad19a4ced62a8d8fa00d16826`. Adopt its live-net read and +grounding before answering a request about the current net in every Brunch +conversation, including non-empty documents, plus its classification of that +read as a browser client tool through live transport and hydrated history +projection. Reimplement those behaviors against this branch's shared Flue +route and SDCPN plugin. Keep all mutation tools gated to explicit scratch or +validated construction modes, and reject the donor's older app-local agent +topology. This branch neither supersedes #9523 nor authorizes its retirement. + +Reconciliation source [#9537](https://github.com/hashintel/hash/pull/9537) is +pinned at `ec85958981ccd33270cee3174a39e6fa683f62a1`. Adopt only its generic +host-supplied client-tool catalog and input-mapping seams, applied consistently +to live stream projection and hydrated history. Preserve this branch's +scratch-mode initialization, FE-1580 hidden-tool filtering, admission and +response tracking, complete error reporting, Voice provenance, and deliberate +exclusion of `brunch_ask`. Do not import Mission 6's prepared fixture, +identities, coherent bundle, settled manifest, or evidence architecture. ## Imperative -Make Voice safe and product-complete on the one Flue conversation route established by the parent. Only a completed provider transcription may become a spoken answer; one logical typed or Voice delivery must admit at most one Flue turn; assistant output must yield the microphone through an acknowledged cancellation barrier; exact canonical responses must be replayable; and Voice attribution must survive canonical hydration and reopen. +Let a person describe a small process through the existing typed or Voice +Brunch conversation and see the complete elicited Petri net appear +automatically in the currently open empty Petrinaut scratch project. Rendering +must happen as Brunch emits canonical construction calls; the user must not +need to press a Preview, Publish, or Generate button. -Voice path B is the **only admissible submission shape**: - -```text -Voice completed transcript -→ Voice controller validates one keyed transcript identity -→ panel submitVoiceInputWithAdmission -→ panel submitVoiceInput -→ shared useChat submitText -→ host-supplied Flue ChatTransport -→ client.send({ message, idempotencyKey, signal }) -→ /agents/chat/:instanceId -``` - -Voice may not call `FlueClient.send()` directly and may not own a second mutable transcript. A direct-send fallback would recreate the second admission path this stack exists to remove. - -The parent's claim that Flue 2.0.3 cannot accept caller idempotency is false. The installed `@flue/sdk` 2.0.3 typings expose `AgentPromptOptions.idempotencyKey?: string`, `AgentSendResult.deduplicated?: boolean`, and the 409 `submission_conflict` response with the existing `submissionId` in `FlueApiError.body.error.meta.submissionId`. The invariant is **at most one admitted turn**, not exactly one invocation of `send()`. +Before that construction path, and in ordinary conversations attached to an +existing non-empty document, Brunch must read the live Petrinaut definition +before answering a request about the current net. This closes the visible +H-6763 interview-to-draft loop for one local demo scenario and restores the +current-net grounding behavior adopted from #9523. It does not claim general +workpiece projection, provenance, revision, remote persistence, or arbitrary +SDCPN coverage. ## Throughline -The production throughline is the local Petrinaut Brunch surface driven by `yarn dev:brunch`: - ```text -OpenAI Realtime microphone input -→ semantic VAD marks an input boundary but creates no model response -→ conversation.item.input_audio_transcription.completed -→ keyed transcript authority (connection epoch, item id, content index) -→ half-duplex Voice controller and shared panel submission path B -→ browser AI SDK ChatTransport over the memoized FlueClient -→ idempotent Flue admission on the same-origin /agents/chat/:instanceId proxy -→ agentOwnershipGuard → mounted Brunch ChatAgent -→ durably completed, submission-correlated canonical segments -→ each new finalized segment enters the exact speech queue while remaining work continues -→ settlement releases exact replay, the next input turn, and supported client-tool Voice provenance -→ observe({ live: "sse" }) hydration and reopen +typed input or completed Voice transcript +→ FE-1580 shared panel useChat +→ one browser Flue transport and mounted Brunch ChatAgent +→ request about the current net reads its live definition in every mode +→ scratch-project conversation mode +→ SDCPN modelling skill maintains the elicited account +→ Brunch reads the active empty document +→ Brunch emits canonical Petrinaut construction client tools +→ the existing Petrinaut AI dispatcher validates and applies each mutation +→ the active document handle updates and the canvas renders immediately +→ the demo's existing localStorage mirror persists the resulting net +→ client-tool results resume the same canonical conversation ``` -Realtime exposes no tools, uses `tool_choice: "none"`, and configures semantic VAD with `create_response: false`. Model function-call arguments are ignored even if a provider violates the policy. Provisional transcription is display-only and disappears without submission. OpenAI permits transcription completion for any committed audio item and does not guarantee completion order across turns; this mission deliberately accepts only an item whose matching `speech_started` boundary occurred during the current input turn. A boundaryless or completion-before-boundary item remains rejected rather than gaining authority retroactively. Requesting canonical speech ends that input turn before `response.create`: unfinished accepted items and their provisional display state are invalidated even if their transcription completes before output audio starts. - -Local playback cancellation, local observation cancellation, the HTTP request `AbortSignal`, and durable conversation-wide `FlueClient.abort()` remain separate operations. The first three never masquerade as durable Stop; durable Stop never appears as a Voice transcription or playback failure. +The browser editor remains the mutation authority. OpenAI Realtime remains +only the media/transcription plane and receives no construction tools. ## Proof -This mission closes the Voice safety and UX-parity stratum on the parent's route. It does not establish production identity, remote deployment, structured questions, a live `brunch_ask` capability, response simplification, workpiece mutation, or fixes for the parent's named defects. - -### Product-manager litmus - -**Release note:** Voice now submits only what the microphone actually transcribed, waits for a safe **Your turn** handoff before listening over Brunch, and can replay the exact full response or exact Brunch-marked question. Client-tool Voice origins survive canonical reopen. Restoring the Voice chip on direct spoken user messages remains blocked on an upstream Flue user-metadata contract. - -**Demo script:** run `yarn dev:brunch` and select the Brunch preview. Speak one answer and see exactly one matching user turn. While Brunch is speaking, confirm the microphone remains closed, choose **Your turn**, wait for the handoff, and speak again. After the response and audio settle, use the playback menu to read the full response exactly and repeat only the exact Brunch-marked question; a missing or unmatched marker keeps that action disabled. Start another turn, press durable **Stop** before settlement, and see a stopped turn rather than a Voice error. Hard-reload the settled conversation and confirm the canonical turn remains without resubmission or replay; direct-user Voice-chip restoration additionally waits on the Flue projection seam. - -**Previously impossible:** model-generated function arguments rather than completed audio transcription could become the answer; an accepted transcript could complete after canonical speech was requested but before output started; assistant playback could create a false user turn; cancellation could reopen capture before the provider settled; replay controls and multi-origin client-tool Voice attribution were incomplete. - -**Completion:** the implemented portions close when their tests and focused checks pass. Exact question replay uses the Brunch-owned marker recorded below; direct-user provenance still needs the Flue re-entry seam recorded below. Mission acceptance additionally requires the real microphone, handoff, Stop, hard-reload, and same-origin route witness, plus the comparative Voice latency gate. Mocked or server-only proof cannot substitute for that witness or for real audible-latency samples. - -1. **Completed-transcript authority and half-duplex ownership.** Realtime session configuration has no tools, no model-created semantic-VAD response, and no automatic interruption policy. Only a unique completed transcript can reach the shared panel submission path. Duplicate, empty, failed, unavailable, stale, canonical-speech-overlapping/pre-handoff, playback-overlapping, and over-limit transcripts do not submit and produce the specified passive or recoverable notice. Before sending `response.create`, a canonical speech request invalidates every unfinished accepted item, clears bridge/controller transcript state and provisional UI, and closes the microphone; a completion in the interval before output starts cannot submit or regain authority. **Your turn** may cancel audio immediately but opens only a post-barrier input turn after provider cancellation acknowledgement and Brunch settlement. Oracle: transplanted-first cases in `openai-realtime-session.test.ts`, `realtime-brunch-bridge.test.ts`, `voice-turn-controller.test.ts`, `voice-interview-control.test.tsx`, and `voice-preview.integration.test.ts`. -2. **Idempotent admission.** Typed turns derive a stable key from the AI SDK message id; Voice turns derive it from connection epoch, item id, and content index. A repeated same-payload key converges on the original receipt, including `deduplicated: true`; a 409 `submission_conflict` surfaces the original `submissionId` without admitting another turn. An admission whose outcome cannot be established stays visibly ambiguous and is never automatically retried. Oracle: transport unit tests plus Voice integration tests asserting one admitted submission rather than one `send()` invocation. -3. **Acknowledged cancellation barrier.** `cancelOutput()` resolves only after input/output buffer clears, matching provider acknowledgements, and all targeted response terminal events. The latest mute preference wins while it settles. **Your turn** may request cancellation immediately, but the microphone reopens only after both that provider acknowledgement and correlated Brunch settlement. Audio captured before the handoff cannot submit afterward. Durable Stop remains a stopped Flue turn rather than a Voice failure. Oracle: donor-adapted session/controller race tests and the unsettled-Stop integration case. -4. **Committed canonical speech, full-response replay, and exact question replay.** Initial automatic speech begins from each new, finalized canonical segment as soon as its Flue model step is durably completed and correlated to the active Voice submission; it does not wait for whole-submission settlement. Streaming deltas, unfinished text, reasoning, tool inputs and results, and inferred text are never speech sources. Stable canonical segment ids deduplicate the serialized speech queue and preserve canonical order across client-tool continuations. Hydration seeds existing ids without autoplay; cancellation, pause, Your turn, durable Stop, failure, and abort suppress queued and later continuation speech. `canReadFullResponse`, `readFullResponse()`, and the playback menu retain and enqueue all exact canonical text segments in order without a simplifier. A non-interactive `brunch_mark_question` server tool writes a durable `data-brunch-question` marker containing exact question text and tool-call identity. The selector accepts it only when the same finalized assistant message contains that exact text; there is no final-segment or punctuation fallback. `repeatQuestion()` queues only the accepted marked segment. Both replay actions remain gated until the correlated Brunch response settles, matching Realtime audio is terminal, and input is idle, and remain disabled during submission, capture, cancellation, pause, and errors. The marker never accepts an answer or changes Voice path B. Oracle: transport correlation, live transport, snapshot projection, canonical speech, bridge, controller, panel-host, and production-preview tests proving completed canonical segments can start speech while chat is streaming without admitting any noncanonical source or mounting `brunch_ask`. -5. **Durable Voice provenance.** An assistant message may retain multiple `voiceToolCallIds`; one failed sibling origin does not erase successful origins. Persisted Flue client-tool-result signals support deterministic reconstruction after hydration and reopen. Direct spoken user messages remain Voice-attributed only while live because the canonical snapshot omits their caller origin. Re-entry requires a supported Flue user-message metadata/idempotency projection; browser storage and user-text encoding are rejected. Oracle: snapshot projection and panel partial-failure tests for supported origins, plus the [blocker record](docs/evidence/implementations/mission-5-voice-safety-parity/provenance-blocker.md). -6. **Dormant ask removal.** If still present after restacking, the website does not register `brunchAskInteractiveTool` for Voice and canonical speech does not recognize `"brunch-ask"`. No spoken ask answer can enter a wait state the transport cannot resume. Oracle: registration/canonical-speech negative tests and a repository search showing no mounted Voice `brunch_ask` surface. -7. **Real witness and same-origin route.** A human performs one microphone turn, explicit interruption/handoff, durable Stop on an unsettled turn, and hard reload of a settled turn. The retained network route summary proves the absolute Flue `streamUrl` remains on the same-origin proxy. Oracle: `witness.md`, sanitized `voice-events.jsonl`, `network-routes.json`, canonical `flue-snapshot.json`, `settlements.json`, commit manifest, and hashes under `docs/evidence/implementations/mission-5-voice-safety-parity/`. -8. **Comparative Voice latency.** Ten comparable real-audio trials at pinned donor #9496 head `c7fe8a2e68e8fdc37018b21ec2e9daf4e9ef7c82` and ten at the final restacked candidate use the same machine, browser, microphone/input phrase, model configuration, warm/cold-start policy, and finalized-speech-to-first-audible-canonical-TTS boundary. The candidate median must not regress, and its p95 regression must remain below 20%. The donor runs from an isolated worktree without changing its branch. Oracle: retained raw sanitized samples, calculation method, environment, both commit SHAs, median, and p95; a comparison that cannot be run reliably leaves this proof incomplete. -9. **Focused repository verification and truthful docs.** The requested seven-workspace Turbo command passes, including `@hashintel/brunch-agent`, `@hashintel/brunch-agent-binding-flue`, and `@hashintel/brunch-agent-plugin-sdcpn`. `apps/petrinaut-website/README.md` and `libs/@hashintel/petrinaut/docs/ai-assistant.md` describe half-duplex handoff, exact full-response and marked-question replay, Stop, transcript rejection, and the direct-user attribution limitation. If the published Petrinaut package changes, exactly one patch changeset covers it. Oracle: the command recorded in the PR and changeset inspection. +1. **Existing-net questions use the live document.** An ordinary Brunch + conversation with no construction mode receives a request about a non-empty + current net, calls the browser-executed `getLatestNetDefinition`, and answers + from the returned definition instead of asking the person to attach or + describe it. Mutation tools remain absent. Oracle: a deterministic built + ChatAgent/client-tool integration test plus the local browser witness. +2. **Automatic complete scratch-net construction.** A deterministic + production-path test starts from an empty document, submits one sufficiently + concrete process description, and observes a non-empty connected net with + at least two places, one transition, and both input and output arcs. No + preview/publish/generate action is invoked. Oracle: the Brunch transport, + Petrinaut panel dispatcher, and final canonical definition in the focused + browser integration test. +3. **Immediate canonical rendering seam.** Every accepted construction call is + executed through Petrinaut's existing AI mutation dispatcher against the + active handle; no direct server-to-canvas write or replacement renderer is + introduced. Oracle: panel tests proving canonical validation, mutation + output, and handle state after each tool result. +4. **Voice uses the same route.** Replacing the typed description with one + completed Voice transcript produces the same construction tool/result + sequence through FE-1580 path B, with one admitted spoken turn and no direct + Voice Flue send. Oracle: `voice-preview.integration.test.ts`. +5. **Reload does not duplicate construction.** The local demo persists the + constructed definition through its existing handle subscription; reopening + shows one net and canonical history without resubmitting the spoken turn or + reapplying tool results. Oracle: focused local-storage/history test and a + local browser witness. +6. **Parent behavior remains intact.** FE-1580's transcript authority, + half-duplex handoff, exact replay, admission outcomes, question marker, and + durable Stop suites remain green. Oracle: the existing focused workspace + tests and type/lint/build checks. + +The product witness first asks what an existing non-empty net is about and sees +an answer grounded in its live definition. It then uses a new empty scratch +net, supplies one concrete bounded process, watches the complete small net +appear without another action, and reloads it. ## Constraints -- Preserve the parent's one product route, memoized Flue client, browser `ChatTransport`, shared panel `useChat`, path-B Voice submission, canonical speech selection, durable Stop seam, and SDK observation hydration. Do not rebuild them. -- Transplant relevant regression tests before implementation. Reimplement donor behavior semantically against the current Flue path; donor branches and PRs are never merged, cherry-picked, rebased, rewritten, retargeted, or closed by this implementation. -- Derive one deterministic admission key per logical delivery. Treat `deduplicated` as successful convergence and `submission_conflict` as evidence of the already-admitted submission. Do not automatically retry an ambiguous admission. -- Normalize completed transcripts exactly once in the Realtime bridge with trim plus Unicode whitespace collapse, then enforce the 32,000-code-point bound. The generic panel validates but does not mutate that already-normalized Voice payload. Provisional text remains ephemeral and display-only. -- The half-duplex microphone is closed from the canonical speech request through output, cancellation, pause, error, and submission states. The request invalidates accepted unfinished input before `response.create`; only a **Your turn** handoff completed by both provider cancellation acknowledgement and Brunch settlement can establish fresh post-request capture. A cancellation promise is part of the turn boundary, not a cosmetic animation state. -- Automatic speech may precede settlement only for new, durably completed canonical segments correlated to the active Voice submission. Settlement remains the authority for replay and next-turn release. Never speak deltas, unfinished text, reasoning, tool material, inferred text, hydrated history, or any segment from a failed or aborted submission. -- Brunch canonical text is never summarized, shortened, paraphrased, or regenerated for speech or replay. -- Preserve every surviving Voice origin independently. Provenance must use supported Flue data or deterministic durable correlation; never encode it in visible user text. -- Do not fix the parent's admission/Stop races, stream cancellation, hydration overwrite, client-tool classification, response/submission correlation, CI, title, or body. Restack onto Lu's fixes; report any blocker. -- Keep local playback cancellation, local observation cancellation, HTTP request cancellation, and durable `abort()` distinguishable in code, UI, tests, and evidence. -- No simplifier, interactive or suspending structured questions, live `brunch_ask`, Petri-net generation/mutation, FE-1575 workpiece work, production identity, CORS/remote deployment, or panel `useChat` removal. The approved non-interactive question marker annotates existing assistant prose only; it is not an answer path or affordance. - -### Expected touched paths - -```text -~ apps/petrinaut-website/src/main/app/voice-interview/ transcript authority, half-duplex state, cancellation, replay tests/code -~ apps/petrinaut-website/src/main/app/local-storage-demo/ path-B correlation and dormant ask removal if still present -~ apps/petrinaut-website/src/server/voice/ Realtime policy tests/code -~ libs/@hashintel/brunch-agent/packages/transport-aisdk/ stable idempotency and canonical projection/provenance tests/code -~ libs/@hashintel/petrinaut/src/react/voice-session/ public Voice state required by the panel -~ libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ Your turn, replay menu, durable provenance -~ apps/brunch-agent/petrinaut-local.vite.config.ts retain Petrinaut Voice API handlers beside the Flue proxy -~ apps/brunch-agent/test/local-dev-origins.test.ts real merged-config launcher regression -~ apps/petrinaut-website/README.md operator behavior -~ libs/@hashintel/petrinaut/docs/ai-assistant.md end-user behavior -? .changeset/ one patch changeset if published Petrinaut changes -+ libs/@hashintel/brunch-agent/docs/evidence/implementations/mission-5-voice-safety-parity/ donor matrix and gated witness -``` +- Keep FE-1580 Voice path B as the only spoken submission path. +- Do not make OpenAI Realtime a modelling agent or expose tools to it. +- Do not add a manual Preview, Publish, Generate, or Finish action. +- Mount the read-only live-net tool for every Brunch conversation and call it + before answering a request about the current net. +- Scope automatic construction to a conversation explicitly initialized in + scratch-project mode; do not mount write tools for unrelated conversations. +- Construct only into an empty scratch document. Refuse automatic whole-net + construction when user-authored content already exists. +- Use the existing canonical Petrinaut tool schemas and panel dispatcher. + Brunch must not copy SDCPN field catalogs or mutate document handles. +- Use stable caller-supplied IDs and read the current definition before writes. +- Preserve visible partial progress and tool errors; never label a partial or + rejected sequence as a completed draft. +- The localStorage demo is the only persistence claim. +- Add no fixture manifest, workpiece store, graph database, workflow engine, + direct canvas endpoint, or second conversation authority. +- Follow test-first implementation: each new behavior must fail for the missing + feature before production code is added. ## Fog-line -- **Parent movement.** Lu owns #9528 and may push more commits. Before each implementation phase, compare the GitHub head and restack this branch; an observed parent change is adopted only through restack, never copied into this branch. -- **Conflict normalization.** The installed SDK exposes the 409 contract through `FlueApiError.body: unknown`. Narrow only the documented envelope needed to recover `error.meta.submissionId`; do not create a general error protocol or infer success from prose. -- **Dormant `brunch_ask`.** Remove or gate only the parent surfaces that remain after the next restack. If Lu has already removed them, record the parent commit and make no duplicate change. -- **Question-marker compliance.** The owner selected `brunch_mark_question` plus a durable client data part. The remaining implementation uncertainty is whether the model follows the instruction on every eligible question. Missing or unmatched markers must degrade by leaving **Repeat question** disabled; they never justify inference from final prose. Product proof covers structural correctness, not a universal model-compliance rate. -- **Direct-user Voice provenance.** Flue 2.0.3 and current upstream `main` expose a generated `submissionId` but not caller metadata or `idempotencyKey` on canonical user messages. The owner selected an upstream Flue user-metadata contract. Keep this leaf blocked until a released seam can be adopted; do not patch Flue locally, add a provenance signal admission, add sidecar persistence, or encode origin in user content. -- **Observed timing opportunity.** One pre-change real Voice turn at `f3f5c4ebc7` measured 48.5 ms from finalized transcript to admission, 3,366.6 ms from admission to first canonical text, 0.0 ms from first canonical text to settlement, 0.3 ms from settlement to TTS request, and 628.3 ms from TTS request to the provider audio-start event. The zero measured target gap means this recut would not meaningfully improve that turn; the preceding model/tool/construction latency belongs to #9538 rather than Mission 5. Automated checks cannot establish a latency improvement, and comparable physical microphone-to-audible-TTS trials remain the accepted proof. -- **Human product evidence.** The hydration overwrite guard is present after the restack, so proof leaf 7 may run. Unit/integration tests still cannot substitute for the real microphone/hard-reload witness, and server or synthetic timing cannot substitute for proof leaf 8's first-audible-audio measurements. +- The least supported conversation-initialization seam for scratch-project + mode: first-admission `initialData` or one idempotent preparation signal. +- Whether the installed Flue/provider path preserves all nested inputs needed + by the selected small uncoloured net. The first tracer deliberately avoids + coloured type elements, parameters, scenarios, metrics, and executable code. +- Whether the existing full construction subset needs input normalization + beyond the narrow values exercised by the tracer. +- How the agent should mark completion without adding a second publish + protocol. The default is settlement after the last successful canonical tool + result plus ordinary assistant text; canvas visibility does not wait for it. +- The exact deterministic faux-provider sequence and smallest browser witness + that discriminate a complete connected net from parser-valid empty state. ## Stop or reorient -Stop and report if the work would require direct Voice `send()`, a second transcript or conversation authority, hand-rolled stream offsets/recovery, automatic retry after ambiguous admission, canonical text rewriting, a live structured-question path, or any excluded parent fix. +Stop and report if: + +- scratch mode requires a second Voice send path or second conversation; +- the only route is to mount mutation tools for every ordinary Brunch chat; +- construction requires raw whole-net JSON, copied Petrinaut schemas, or a + direct server-to-handle mutation; +- nested provider input cannot carry the selected simple place, transition, or + arc calls through the supported Flue tool contract; +- the implementation overwrites a non-empty user document; +- repeated or hydrated tool results apply duplicate entities or arcs; +- a manual preview action becomes necessary; or +- the slice expands into Mission 6 coherent-bundle durability or Mission 9 + general traceable projection. -Stop if half-duplex handoff cannot guarantee that pre-handoff audio is rejected and post-barrier audio is fresh, or if provider acknowledgements cannot bound `cancelOutput()` without inventing events. The provenance stop condition has fired for direct spoken user turns: the browser-store implementation was removed and the unsupported leaf is recorded as blocked pending upstream Flue support. For **Repeat question**, stop rather than infer question identity when the approved marker is absent or does not exactly match finalized assistant text. Stop if either replay action can enable before both matching terminal conditions, or if local cancellation invokes durable abort. +## Expected touched paths -Do not manufacture the hard-reload witness or latency samples. If the human/browser environment cannot produce reliable observations, retain an incomplete evidence record and request the missing action explicitly. +```text +libs/@hashintel/brunch-agent/packages/plugin-sdcpn/ + scratch conversation mode, bounded construction mounting, focused tests +libs/@hashintel/brunch-agent/packages/transport-aisdk/ + only the minimum initial-data seam if required +apps/petrinaut-website/src/main/app/local-storage-demo/ + scratch-mode selection and construction client-tool catalog +apps/petrinaut-website/src/main/app/voice-interview/ + Voice-through-the-same-route proof only +apps/brunch-agent/test/ + real Flue/client-tool construction tracer +libs/@hashintel/petrinaut/ + only generic dispatcher tests or a source defect exposed by the tracer +``` ## Deferred -- The real witness, same-origin absolute-`streamUrl` observation, and comparative latency gate require human browser and microphone evidence; they are part of this mission rather than a successor. -- Direct-user Voice attribution after canonical hydration waits on a released upstream Flue caller-metadata projection seam. The owner rejected a local Flue patch and correlated signal sidecar for this mission. -- Donor retirement waits until this replacement is accepted and each donor owner explicitly approves closure. Do not close #9496, #9500, #9507, or #9512 as an implementation side effect, and never close stakeholder-owned H-6763. -- Response preparation/simplification, structured questions, Petri-net work, FE-1575, production identity, CORS/remote deployment, and panel migration away from `useChat` remain in their existing owners or the future mission spine. +FE-1575 / Mission 6 owns the prepared workpiece/document fixture, coherent +bundle identity, and two-tab acceptance. Mission 9 owns automatic projection +from a durable workpiece, nested schema breadth, repeat/change semantics, +derivations, provenance, partial-failure policy, and general whole-net +construction. Before this branch is proposed for merge, compare it with Lu's +latest Mission 6 head and either reconcile the smallest shared mechanisms or +keep it explicitly temporary. diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json index f8ff9d303f2..c0f7b278555 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json @@ -31,6 +31,7 @@ "devDependencies": { "@types/node": "22.18.13", "@typescript/native-preview": "7.0.0-dev.20260511.1", + "@valibot/to-json-schema": "1.7.1", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", "vite": "8.1.0", 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 3c48ef1a56f..c1d52461570 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/flue.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/flue.ts @@ -6,6 +6,8 @@ import { } from "@flue/runtime"; import * as v from "valibot"; +import { getLatestNetDefinitionToolName } from "@hashintel/petrinaut-core/ai"; + import sdcpnAppend from "./prompts/APPEND_SYSTEM.md?raw"; import { SDCPN_MODELLING_SKILL_NAME, @@ -18,10 +20,14 @@ import { } from "./tools/read-petrinaut-doc"; export const VALIDATED_CONSTRUCTION_MODE = "validated-construction"; +export const SCRATCH_PROJECT_CONSTRUCTION_MODE = "scratch-project-construction"; export const sdcpnInitialDataSchema = v.optional( v.object({ - mode: v.literal(VALIDATED_CONSTRUCTION_MODE), + mode: v.picklist([ + VALIDATED_CONSTRUCTION_MODE, + SCRATCH_PROJECT_CONSTRUCTION_MODE, + ]), }), ); @@ -34,15 +40,38 @@ export function useSdcpnPlugin(): void { useInstruction(sdcpnAppend.trim()); useSkill(sdcpnModellingSkill); useTool(readPetrinautDoc); + useInstruction( + ` +Before answering any request about this net, the current net, or the existing net—including before beginning an interview—call \`${getLatestNetDefinitionToolName}\`. +Do not say the canvas is unavailable while you can call \`${getLatestNetDefinitionToolName}\`. +`.trim(), + ); + + if (initialData?.mode === SCRATCH_PROJECT_CONSTRUCTION_MODE) { + useInstruction( + ` +This conversation controls one empty Petrinaut scratch project. Elicit in the person's vocabulary and maintain the workpiece as usual. Once the person has supplied a sufficiently concrete bounded process or asks you to use sensible defaults, automatically read the current net and construct the complete small connected draft through the mounted Petrinaut tools. Do not ask for separate preview, publish, generate, or finish permission, and do not emit net JSON. - if (initialData?.mode === VALIDATED_CONSTRUCTION_MODE) { +Only construct when getLatestNetDefinition confirms that the document is empty. If it contains any place, transition, type, parameter, differential equation, subnet, component instance, scenario, or metric, do not perform automatic whole-net construction; explain that this temporary mode only targets an empty scratch project. Use stable descriptive ids, visible non-overlapping positions, and connect every constructed transition through canonical addArc calls. The canvas updates after each accepted client-tool result, so never claim completion if a call is rejected or remains pending. +`.replace(/^\s+|\s+$/gu, ""), + ); + } else if (initialData?.mode === VALIDATED_CONSTRUCTION_MODE) { useInstruction( ` This is a construct-only headless conversation. Use only the supplied runbook IR as modelling input, do not interview, and build the net through the mounted Petrinaut tools instead of emitting net JSON. `.replace(/^\s+|\s+$/gu, ""), ); - for (const constructionTool of petrinautConstructionTools) { - useTool(constructionTool); + } + + const constructionModeEnabled = + initialData?.mode === SCRATCH_PROJECT_CONSTRUCTION_MODE || + initialData?.mode === VALIDATED_CONSTRUCTION_MODE; + for (const petrinautTool of petrinautConstructionTools) { + if ( + petrinautTool.name === getLatestNetDefinitionToolName || + constructionModeEnabled + ) { + useTool(petrinautTool); } } } diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/tools/petrinaut-construction.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/tools/petrinaut-construction.ts index 9f45f05ad1f..0165b92adc1 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/tools/petrinaut-construction.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/tools/petrinaut-construction.ts @@ -4,6 +4,8 @@ import * as v from "valibot"; import { AWAITING_CLIENT } from "@hashintel/brunch-agent/client-tools"; import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; +import { valibotObjectSchemaFromJsonSchema } from "./petrinaut-construction/json-schema-to-valibot"; + export const PETRINAUT_CONSTRUCTION_TOOL_NAMES = [ "getLatestNetDefinition", "addType", @@ -50,7 +52,7 @@ const canonicalInputFor = (toolName: PetrinautConstructionToolName) => { JSON.stringify(jsonSchema), ].join("\n"), schema: v.pipe( - v.looseObject({}), + valibotObjectSchemaFromJsonSchema(jsonSchema), v.rawTransform((context) => { const parsed = canonicalTool.inputSchema.safeParse( context.dataset.value, diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/tools/petrinaut-construction/json-schema-to-valibot.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/tools/petrinaut-construction/json-schema-to-valibot.ts new file mode 100644 index 00000000000..023cb17bc78 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/tools/petrinaut-construction/json-schema-to-valibot.ts @@ -0,0 +1,202 @@ +import * as v from "valibot"; + +type JsonSchemaObject = { + readonly additionalProperties?: boolean; + readonly anyOf?: readonly unknown[]; + readonly const?: unknown; + readonly description?: string; + readonly enum?: readonly unknown[]; + readonly exclusiveMaximum?: number; + readonly exclusiveMinimum?: number; + readonly items?: unknown; + readonly maximum?: number; + readonly minimum?: number; + readonly minLength?: number; + readonly oneOf?: readonly unknown[]; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly type?: string; +}; + +const schemaObjectFrom = (schema: unknown): JsonSchemaObject => { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { + throw new Error("Canonical Petrinaut JSON Schema must contain an object."); + } + return schema as JsonSchemaObject; +}; + +const literalFrom = (value: unknown): v.Literal => { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + throw new Error( + `Unsupported canonical JSON Schema literal: ${JSON.stringify(value)}`, + ); +}; + +const unionFrom = ( + alternatives: readonly unknown[], +): v.GenericSchema => { + const schemas = alternatives.map(schemaFrom); + const firstSchema = schemas.at(0); + if (firstSchema === undefined) { + throw new Error("Canonical JSON Schema unions must not be empty."); + } + if (schemas.length === 1) return firstSchema; + return v.union(schemas as v.UnionOptions); +}; + +const stringSchemaFrom = (schema: JsonSchemaObject): v.GenericSchema => + schema.minLength === undefined + ? v.string() + : v.pipe(v.string(), v.minLength(schema.minLength)); + +const numberSchemaFrom = ( + schema: JsonSchemaObject, + integer: boolean, +): v.GenericSchema => { + if (schema.minimum !== undefined && schema.exclusiveMinimum !== undefined) { + throw new Error( + "Canonical JSON Schema numbers cannot have two minimum constraints.", + ); + } + if (schema.maximum !== undefined && schema.exclusiveMaximum !== undefined) { + throw new Error( + "Canonical JSON Schema numbers cannot have two maximum constraints.", + ); + } + + const baseSchema = integer ? v.pipe(v.number(), v.integer()) : v.number(); + if (schema.minimum !== undefined && schema.maximum !== undefined) { + return v.pipe( + baseSchema, + v.minValue(schema.minimum), + v.maxValue(schema.maximum), + ); + } + if (schema.minimum !== undefined && schema.exclusiveMaximum !== undefined) { + return v.pipe( + baseSchema, + v.minValue(schema.minimum), + v.ltValue(schema.exclusiveMaximum), + ); + } + if (schema.exclusiveMinimum !== undefined && schema.maximum !== undefined) { + return v.pipe( + baseSchema, + v.gtValue(schema.exclusiveMinimum), + v.maxValue(schema.maximum), + ); + } + if ( + schema.exclusiveMinimum !== undefined && + schema.exclusiveMaximum !== undefined + ) { + return v.pipe( + baseSchema, + v.gtValue(schema.exclusiveMinimum), + v.ltValue(schema.exclusiveMaximum), + ); + } + if (schema.minimum !== undefined) { + return v.pipe(baseSchema, v.minValue(schema.minimum)); + } + if (schema.exclusiveMinimum !== undefined) { + return v.pipe(baseSchema, v.gtValue(schema.exclusiveMinimum)); + } + if (schema.maximum !== undefined) { + return v.pipe(baseSchema, v.maxValue(schema.maximum)); + } + if (schema.exclusiveMaximum !== undefined) { + return v.pipe(baseSchema, v.ltValue(schema.exclusiveMaximum)); + } + return baseSchema; +}; + +const objectSchemaFrom = ( + schema: JsonSchemaObject, +): v.GenericSchema => { + const required = new Set(schema.required ?? []); + const entries: v.ObjectEntries = {}; + for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) { + const entrySchema = schemaFrom(propertySchema); + entries[key] = required.has(key) ? entrySchema : v.optional(entrySchema); + } + return schema.additionalProperties === false + ? v.strictObject(entries) + : v.looseObject(entries); +}; + +const baseSchemaFrom = (schema: JsonSchemaObject): v.GenericSchema => { + if (schema.anyOf !== undefined) return unionFrom(schema.anyOf); + if (schema.oneOf !== undefined) return unionFrom(schema.oneOf); + if (schema.const !== undefined) { + return schema.const === null + ? v.null() + : v.literal(literalFrom(schema.const)); + } + if (schema.enum !== undefined) { + const options = schema.enum.map(literalFrom); + if (options.length === 0) { + throw new Error("Canonical JSON Schema enums must not be empty."); + } + return v.picklist(options as v.PicklistOptions); + } + + switch (schema.type) { + case "array": + return v.array( + schema.items === undefined ? v.unknown() : schemaFrom(schema.items), + ); + case "boolean": + return v.boolean(); + case "integer": + return numberSchemaFrom(schema, true); + case "null": + return v.null(); + case "number": + return numberSchemaFrom(schema, false); + case "object": + return objectSchemaFrom(schema); + case "string": + return stringSchemaFrom(schema); + default: + throw new Error( + `Unsupported canonical JSON Schema type: ${String(schema.type)}`, + ); + } +}; + +const schemaFrom = (jsonSchema: unknown): v.GenericSchema => { + const schema = schemaObjectFrom(jsonSchema); + const baseSchema = baseSchemaFrom(schema); + return schema.description === undefined + ? baseSchema + : v.pipe(baseSchema, v.description(schema.description)); +}; + +/** + * Flue derives the provider tool definition from Valibot, while Petrinaut owns + * its schemas in Zod. Build the provider-facing structure from Petrinaut's + * emitted JSON Schema, then let the canonical Zod parser remain the final + * validation authority. + */ +export const valibotObjectSchemaFromJsonSchema = ( + jsonSchema: unknown, +): v.GenericSchema> => { + const schema = schemaFrom(jsonSchema); + if ( + schema.type !== "object" && + schema.type !== "strict_object" && + schema.type !== "loose_object" + ) { + throw new Error( + "Canonical Petrinaut tool inputs must be top-level objects.", + ); + } + return schema as v.GenericSchema>; +}; diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/construction-tools.test.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/construction-tools.test.ts index 1ca9cb0e53a..b763b7f1f03 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/construction-tools.test.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/construction-tools.test.ts @@ -1,8 +1,10 @@ +import { toJsonSchema } from "@valibot/to-json-schema"; import * as v from "valibot"; import { describe, expect, test } from "vitest"; import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; +import { sdcpnInitialDataSchema } from "../src/flue"; import { PETRINAUT_CONSTRUCTION_TOOL_NAMES, petrinautConstructionTools, @@ -18,6 +20,14 @@ const toolByName = (toolName: string) => { }; describe("Petrinaut construction tools", () => { + test("admits the interactive scratch-project construction mode", () => { + expect( + v.safeParse(sdcpnInitialDataSchema, { + mode: "scratch-project-construction", + }).success, + ).toBe(true); + }); + test("exposes exactly the bounded canonical subset", () => { expect(petrinautConstructionTools.map((tool) => tool.name)).toEqual([ ...PETRINAUT_CONSTRUCTION_TOOL_NAMES, @@ -36,16 +46,86 @@ describe("Petrinaut construction tools", () => { } }); + test("exposes canonical structural fields to the model provider", () => { + const addTypeSchema = toJsonSchema(toolByName("addType").input!, { + errorMode: "ignore", + }); + const addTransitionSchema = toJsonSchema( + toolByName("addTransition").input!, + { errorMode: "ignore" }, + ); + const addPlaceSchema = toJsonSchema(toolByName("addPlace").input!, { + errorMode: "ignore", + }); + const addArcSchema = toJsonSchema(toolByName("addArc").input!, { + errorMode: "ignore", + }); + + expect(addTypeSchema).toMatchObject({ + type: "object", + properties: { + elements: { + type: "array", + items: { + type: "object", + required: ["elementId", "name", "type"], + }, + }, + }, + required: ["id", "name", "iconSlug", "displayColor", "elements"], + }); + expect(addTransitionSchema).toMatchObject({ + type: "object", + properties: { + inputArcs: { type: "array" }, + outputArcs: { type: "array" }, + x: { type: "number" }, + y: { type: "number" }, + }, + required: [ + "id", + "name", + "inputArcs", + "outputArcs", + "lambdaType", + "lambdaCode", + "transitionKernelCode", + "x", + "y", + ], + }); + expect(addPlaceSchema).toMatchObject({ + properties: { + capacity: { + anyOf: [ + { + type: "integer", + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }, + { type: "null" }, + ], + }, + }, + }); + expect(addArcSchema).toMatchObject({ + properties: { + arcDirection: { enum: ["input", "output"] }, + weight: { type: "number", exclusiveMinimum: 0 }, + }, + }); + }); + test("delegates accepted and rejected inputs to Petrinaut's Zod schemas", () => { const addArc = toolByName("addArc"); - const invalidArc = { + const validArc = { transitionId: "transition", - arcDirection: "input", + arcDirection: "output", placeId: "place", - weight: 0, + weight: 1, targetSubnetId: null, }; - const validArc = { ...invalidArc, weight: 1 }; + const invalidArc = { ...validArc, type: "standard" }; expect(v.safeParse(addArc.input!, invalidArc).success).toBe( petrinautAiTools.addArc.inputSchema.safeParse(invalidArc).success, @@ -59,8 +139,8 @@ describe("Petrinaut construction tools", () => { const addType = toolByName("addType"); const invalidElement = { elementId: "speed", - name: "speed", - type: "not-a-type", + name: "not valid", + type: "real", }; const invalidType = { id: "vehicle", @@ -75,7 +155,7 @@ describe("Petrinaut construction tools", () => { expect(result.issues[0].path).toMatchObject([ { input: invalidType, key: "elements", value: invalidType.elements }, { input: invalidType.elements, key: 0, value: invalidElement }, - { input: invalidElement, key: "type", value: "not-a-type" }, + { input: invalidElement, key: "name", value: "not valid" }, ]); }); }); 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 14850dea929..ff30663d55b 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts @@ -6,6 +6,7 @@ import { serializeErrorText } from "./error-text"; import { createFlueUiStream } from "./ui-stream"; import type { + AgentPromptOptions, AgentSendResult, ConversationStreamChunk, DeliveredMessage, @@ -58,6 +59,11 @@ export interface FlueChatTransportOptions { readonly client: FlueClient; readonly clientToolNames: ReadonlySet; readonly hiddenToolNames?: ReadonlySet; + readonly initialData?: AgentPromptOptions["initialData"]; + readonly mapClientToolInput?: (input: { + readonly input: unknown; + readonly toolName: string; + }) => unknown; readonly onAdmission?: (event: { readonly admission: AgentSendResult; readonly kind: "client-tool-result" | "user"; @@ -302,6 +308,7 @@ const streamSubmission = ( submissionId: admission.submissionId, clientToolNames: options.clientToolNames, hiddenToolNames: options.hiddenToolNames, + mapClientToolInput: options.mapClientToolInput, write, }); @@ -422,6 +429,9 @@ export const createFlueChatTransport = < try { admission = await options.client.send({ idempotencyKey, + ...(messageId === undefined && options.initialData !== undefined + ? { initialData: options.initialData } + : {}), message, signal: abortSignal, }); 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 8d27f09af1c..325e4d2c0c9 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/transcript.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/transcript.ts @@ -26,6 +26,10 @@ export type UiHistoryMessage = Omit< export interface SnapshotToUiMessagesOptions { readonly clientToolNames: ReadonlySet; readonly hiddenToolNames?: ReadonlySet; + readonly mapClientToolInput?: (input: { + readonly input: unknown; + readonly toolName: string; + }) => unknown; } const unhandledConversationPart = (part: never): never => { @@ -86,19 +90,26 @@ const clientToolResultsFrom = ( const toolPartFrom = ( part: Extract, - clientToolNames: ReadonlySet, + options: SnapshotToUiMessagesOptions, clientResults: ReadonlyMap, ): UiMessagePart => { - const isClientTool = clientToolNames.has(part.toolName); + const isClientTool = options.clientToolNames.has(part.toolName); const hasClientOutput = clientResults.has(part.toolCallId); + const input = + isClientTool && options.mapClientToolInput !== undefined + ? options.mapClientToolInput({ + input: part.input, + toolName: part.toolName, + }) + : part.input; if (part.state === "output-error") { return { type: `tool-${part.toolName}`, toolCallId: part.toolCallId, state: "output-error", - input: part.input, + input, errorText: part.errorText, - ...(isClientTool ? {} : { providerExecuted: true }), + providerExecuted: true, }; } if (isClientTool && !hasClientOutput) { @@ -106,7 +117,7 @@ const toolPartFrom = ( type: `tool-${part.toolName}`, toolCallId: part.toolCallId, state: "input-available", - input: part.input, + input, }; } const output = isClientTool @@ -119,7 +130,7 @@ const toolPartFrom = ( type: `tool-${part.toolName}`, toolCallId: part.toolCallId, state: "output-available", - input: part.input, + input, output, ...(isClientTool ? {} : { providerExecuted: true }), }; @@ -128,7 +139,7 @@ const toolPartFrom = ( type: `tool-${part.toolName}`, toolCallId: part.toolCallId, state: "input-available", - input: part.input, + input, ...(isClientTool ? {} : { providerExecuted: true }), }; }; @@ -150,7 +161,7 @@ const partsFrom = ( } if (part.type === "dynamic-tool") { if (options.hiddenToolNames?.has(part.toolName) === true) continue; - parts.push(toolPartFrom(part, options.clientToolNames, clientResults)); + parts.push(toolPartFrom(part, options, clientResults)); continue; } if (part.type === "file") { diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/ui-stream.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/ui-stream.ts index 860fb2d4c88..5900083626d 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/ui-stream.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/ui-stream.ts @@ -7,6 +7,10 @@ export interface FlueUiStreamOptions { readonly submissionId: AgentSendResult["submissionId"]; readonly clientToolNames: ReadonlySet; readonly hiddenToolNames?: ReadonlySet; + readonly mapClientToolInput?: (input: { + readonly input: unknown; + readonly toolName: string; + }) => unknown; readonly write: (chunk: UIMessageChunk) => void; } @@ -15,6 +19,11 @@ type StreamingPart = { readonly partId: string; }; +type ToolInputChunk = Extract< + ConversationStreamChunk, + { readonly type: "tool-input" } +>; + const unhandledConversationChunk = (chunk: never): never => { throw new Error( `Unhandled Flue conversation chunk: ${JSON.stringify(chunk)}`, @@ -30,7 +39,16 @@ export const createFlueUiStream = ( let partOrdinal = 0; let streamingPart: StreamingPart | undefined; const hiddenToolCallIds = new Set(); - const pendingClientToolCallIds = new Set(); + const pendingClientToolInputs = new Map(); + const authorizedClientToolCallIds = new Set(); + + const mappedClientToolInput = (chunk: ToolInputChunk): unknown => + options.mapClientToolInput === undefined + ? chunk.input + : options.mapClientToolInput({ + input: chunk.input, + toolName: chunk.toolName, + }); const finishPart = (): void => { if (!streamingPart) return; @@ -84,7 +102,7 @@ export const createFlueUiStream = ( options.write({ type: "finish", finishReason: - pendingClientToolCallIds.size > 0 ? "tool-calls" : "stop", + authorizedClientToolCallIds.size > 0 ? "tool-calls" : "stop", }); break; case "failed": @@ -132,20 +150,36 @@ export const createFlueUiStream = ( return; } const isClientTool = options.clientToolNames.has(chunk.toolName); - if (isClientTool) pendingClientToolCallIds.add(chunk.toolCallId); + if (isClientTool) { + pendingClientToolInputs.set(chunk.toolCallId, chunk); + return; + } options.write({ type: "tool-input-available", toolCallId: chunk.toolCallId, toolName: chunk.toolName, input: chunk.input, - ...(isClientTool ? {} : { providerExecuted: true }), + providerExecuted: true, }); return; } case "tool-output": { if (!accepting || messageId === undefined) return; if (hiddenToolCallIds.has(chunk.toolCallId)) return; - if (pendingClientToolCallIds.has(chunk.toolCallId)) return; + const pendingClientToolInput = pendingClientToolInputs.get( + chunk.toolCallId, + ); + if (pendingClientToolInput !== undefined) { + pendingClientToolInputs.delete(chunk.toolCallId); + authorizedClientToolCallIds.add(chunk.toolCallId); + options.write({ + type: "tool-input-available", + toolCallId: pendingClientToolInput.toolCallId, + toolName: pendingClientToolInput.toolName, + input: mappedClientToolInput(pendingClientToolInput), + }); + return; + } options.write({ type: "tool-output-available", toolCallId: chunk.toolCallId, @@ -157,7 +191,19 @@ export const createFlueUiStream = ( case "tool-output-error": { if (!accepting || messageId === undefined) return; if (hiddenToolCallIds.has(chunk.toolCallId)) return; - if (pendingClientToolCallIds.has(chunk.toolCallId)) return; + const pendingClientToolInput = pendingClientToolInputs.get( + chunk.toolCallId, + ); + if (pendingClientToolInput !== undefined) { + pendingClientToolInputs.delete(chunk.toolCallId); + options.write({ + type: "tool-input-available", + toolCallId: pendingClientToolInput.toolCallId, + toolName: pendingClientToolInput.toolName, + input: mappedClientToolInput(pendingClientToolInput), + providerExecuted: true, + }); + } options.write({ type: "tool-output-error", toolCallId: chunk.toolCallId, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts index b5d6945226d..e9c9f16141a 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/chat-transport.test.ts @@ -129,6 +129,96 @@ test("admits one user message and projects a finite per-turn stream", async () = ]); }); +test("seeds a newly created conversation with configured initial data", async () => { + const { client, send } = clientWith(completedEvents); + const initialData = { mode: "scratch-project-construction" }; + const transportOptions = { + client, + clientToolNames: new Set(["getLatestNetDefinition"]), + initialData, + } as FlueChatTransportOptions & { readonly initialData: unknown }; + const transport = createFlueChatTransport(transportOptions); + + await transport.sendMessages( + sendOptions([ + { + id: "user-scratch", + role: "user", + parts: [{ type: "text", text: "Model this process." }], + }, + ]), + ); + + expect(send).toHaveBeenCalledWith({ + idempotencyKey: "ai-sdk:user-scratch", + initialData, + message: { kind: "user", body: "Model this process." }, + signal: undefined, + }); +}); + +test("maps host client-tool input in the live transport stream", async () => { + const events: readonly ConversationStreamChunk[] = [ + { + type: "message-started", + conversationId: "conversation-1", + messageId: "assistant-1", + submissionId: admission.submissionId, + turnId: "turn-1", + position: position(0), + }, + { + type: "tool-input", + conversationId: "conversation-1", + messageId: "assistant-1", + toolCallId: "arc-1", + toolName: "addArc", + input: { weight: "1" }, + position: position(1), + }, + { + type: "tool-output", + conversationId: "conversation-1", + toolCallId: "arc-1", + output: { awaiting: "client" }, + position: position(2), + }, + { + type: "submission-settled", + conversationId: "conversation-1", + submissionId: admission.submissionId, + outcome: "completed", + position: position(3), + }, + ]; + const { client } = clientWith(events); + const transport = createFlueChatTransport({ + client, + clientToolNames: new Set(["addArc"]), + mapClientToolInput: ({ input }) => ({ + ...(input as object), + weight: 1, + }), + }); + + const stream = await transport.sendMessages( + sendOptions([ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Add the confirmed arc." }], + }, + ]), + ); + + expect(await readChunks(stream)).toContainEqual({ + type: "tool-input-available", + toolCallId: "arc-1", + toolName: "addArc", + input: { weight: 1 }, + }); +}); + test("admits one client-tool result signal and resumes its assistant id", async () => { const { client, send } = clientWith(completedEvents); const transport = createFlueChatTransport({ 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 c41a8de7f0f..742c2979b28 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 @@ -53,6 +53,68 @@ test("leaves an unfinished client tool available to run", () => { ]); }); +test("maps client-tool input before hydrating it", () => { + expect( + snapshotToUiMessages(snapshotWithPendingClientTool, { + ...projectionOptions, + mapClientToolInput: ({ + input, + }: { + readonly input: unknown; + readonly toolName: string; + }) => ({ + ...(input as object), + doc: "canonical-ai-assistant", + }), + }), + ).toEqual([ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-readPetrinautDoc", + toolCallId: "tool-doc-1", + state: "input-available", + input: { doc: "canonical-ai-assistant" }, + }, + ], + }, + ]); +}); + +test("hydrates a server-rejected client tool as provider-executed", () => { + const snapshot: FlueConversationSnapshot = { + ...snapshotWithPendingClientTool, + messages: [ + { + ...snapshotWithPendingClientTool.messages[0]!, + parts: [ + { + type: "dynamic-tool", + toolCallId: "tool-doc-invalid", + toolName: "readPetrinautDoc", + state: "output-error", + input: { doc: 42 }, + errorText: "Expected doc to be a string.", + }, + ], + }, + ], + }; + + expect(snapshotToUiMessages(snapshot, projectionOptions)[0]?.parts).toEqual([ + { + type: "tool-readPetrinautDoc", + toolCallId: "tool-doc-invalid", + state: "output-error", + input: { doc: 42 }, + errorText: "Expected doc to be a string.", + providerExecuted: true, + }, + ]); +}); + test("uses a recorded browser result even when it is null", () => { const snapshot: FlueConversationSnapshot = { ...snapshotWithPendingClientTool, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ui-stream.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ui-stream.test.ts index 999a8ef498a..b841ab14a50 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ui-stream.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ui-stream.test.ts @@ -132,6 +132,98 @@ test("hides an implementation tool while preserving its data marker", () => { ).toBe(false); }); +test("waits for server validation before exposing a client tool", () => { + const written: UIMessageChunk[] = []; + const projector = createFlueUiStream({ + submissionId: "submission-1", + clientToolNames: new Set(["addTransition"]), + write: (chunk) => written.push(chunk), + }); + projector.accept({ + type: "message-started", + conversationId: "conversation-1", + messageId: "message-1", + submissionId: "submission-1", + turnId: "turn-1", + position: position(0), + }); + projector.accept({ + type: "tool-input", + conversationId: "conversation-1", + messageId: "message-1", + toolCallId: "add-transition-1", + toolName: "addTransition", + input: { id: "fulfill_order" }, + position: position(1), + }); + + expect(written.some((chunk) => chunk.type === "tool-input-available")).toBe( + false, + ); + + projector.accept({ + type: "tool-output", + conversationId: "conversation-1", + toolCallId: "add-transition-1", + output: { awaiting: "client" }, + position: position(2), + }); + + expect(written).toContainEqual({ + type: "tool-input-available", + toolCallId: "add-transition-1", + toolName: "addTransition", + input: { id: "fulfill_order" }, + }); +}); + +test("keeps a server-rejected client tool away from the browser dispatcher", () => { + const written = project([ + { + type: "message-started", + conversationId: "conversation-1", + messageId: "message-1", + submissionId: "submission-1", + turnId: "turn-1", + position: position(0), + }, + { + type: "tool-input", + conversationId: "conversation-1", + messageId: "message-1", + toolCallId: "tool-doc-invalid", + toolName: "readPetrinautDoc", + input: { doc: 42 }, + position: position(1), + }, + { + type: "tool-output-error", + conversationId: "conversation-1", + toolCallId: "tool-doc-invalid", + errorText: "Expected doc to be a string.", + position: position(2), + }, + ]); + + expect(written).toEqual( + expect.arrayContaining([ + { + type: "tool-input-available", + toolCallId: "tool-doc-invalid", + toolName: "readPetrinautDoc", + input: { doc: 42 }, + providerExecuted: true, + }, + { + type: "tool-output-error", + toolCallId: "tool-doc-invalid", + errorText: "Expected doc to be a string.", + providerExecuted: true, + }, + ]), + ); +}); + test("ignores observation catch-up chunks in a submission stream", () => { const written = project([ { diff --git a/yarn.lock b/yarn.lock index 8d1c736db42..46e27f21310 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7458,6 +7458,7 @@ __metadata: "@hashintel/petrinaut-core": "workspace:*" "@types/node": "npm:22.18.13" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + "@valibot/to-json-schema": "npm:1.7.1" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1" valibot: "npm:1.4.2" @@ -20144,7 +20145,7 @@ __metadata: languageName: node linkType: hard -"@valibot/to-json-schema@npm:^1.3.0": +"@valibot/to-json-schema@npm:1.7.1, @valibot/to-json-schema@npm:^1.3.0": version: 1.7.1 resolution: "@valibot/to-json-schema@npm:1.7.1" peerDependencies: