diff --git a/.changeset/conversation-permissions.md b/.changeset/conversation-permissions.md new file mode 100644 index 00000000000..02e7adb0a67 --- /dev/null +++ b/.changeset/conversation-permissions.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Allow hosts to show interactive tool controls only for matching tool calls, such as a destructive change waiting for approval. diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-conversation-id.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-conversation-id.ts index f1832ce1f6e..4219bafccd7 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-conversation-id.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-conversation-id.ts @@ -17,6 +17,30 @@ interface ConversationStorage { setItem(key: string, value: string): void; } +/** Change only the pointer; old conversation history and the model are retained. */ +export const replaceBrunchConversationId = ( + netId: string, + conversationId: string, + storage: ConversationStorage = window.localStorage, +): void => { + let stored: Record = {}; + try { + stored = JSON.parse( + storage.getItem(conversationStorageKey) ?? "{}", + ) as Record; + } catch { + // A broken pointer cache must not prevent starting a new conversation. + } + try { + storage.setItem( + conversationStorageKey, + JSON.stringify({ ...stored, [netId]: conversationId }), + ); + } catch { + // The host retains the new id for this page load. + } +}; + export const getOrCreateBrunchConversationId = ( netId: string, storage: ConversationStorage = window.localStorage, diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-mutation-approval.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-mutation-approval.test.tsx new file mode 100644 index 00000000000..fe5fa665da3 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-mutation-approval.test.tsx @@ -0,0 +1,401 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { canonicalContent } from "@hashintel/brunch-agent-plugin-sdcpn"; + +import { + createBrunchMutationAdmission, + createBrunchMutationApprovalCoordinator, + createBrunchMutationApprovalInteractiveTools, + createBrunchMutationApprovalWidget, +} from "./brunch-mutation-approval"; +import { createInBandBrowserCalls } from "./in-band-browser-call"; + +import type { FlueClient } from "@flue/sdk"; + +vi.hoisted(() => { + window.matchMedia = (media) => ({ + media, + matches: false, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, + }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const destructiveInput = { + items: [ + { type: "place", id: "queue" }, + { type: "differentialEquation", id: "decay" }, + ], +}; + +describe("Brunch destructive edit approval", () => { + test("covers every destructive canonical tool and no constructive one", () => { + const toolNames = createBrunchMutationApprovalInteractiveTools( + createBrunchMutationApprovalCoordinator(), + ).map(({ toolName }) => toolName); + expect(toolNames).toContain("deleteItemsByIds"); + expect(toolNames).toContain("removeArc"); + expect(toolNames).not.toContain("addPlace"); + expect(toolNames).not.toContain("updatePlace"); + }); + + test("renders itemized removals and resolves Allow without submitting a tool result", async () => { + const coordinator = createBrunchMutationApprovalCoordinator(); + const decision = coordinator.request({ + toolCallId: "delete-1", + toolName: "deleteItemsByIds", + signal: new AbortController().signal, + }); + const ApprovalWidget = createBrunchMutationApprovalWidget( + coordinator, + "deleteItemsByIds", + ); + const submit = vi.fn(); + render( + , + ); + + expect(screen.getByText(/Remove place.*queue/u)).not.toBeNull(); + expect( + screen.getByText(/Remove differential equation.*decay/u), + ).not.toBeNull(); + expect( + screen.getByText(/associated arcs or references may also be removed/iu), + ).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Allow" })); + + await expect(decision).resolves.toEqual({ decision: "allow" }); + expect(submit).not.toHaveBeenCalled(); + }); + + test("Always allow is scoped to one coordinator and abort revokes stale UI authority", async () => { + const coordinator = createBrunchMutationApprovalCoordinator(); + const first = coordinator.request({ + toolCallId: "delete-1", + toolName: "deleteItemsByIds", + signal: new AbortController().signal, + }); + coordinator.resolve("delete-1", "always-allow"); + await expect(first).resolves.toEqual({ decision: "allow" }); + await expect( + coordinator.request({ + toolCallId: "delete-2", + toolName: "deleteItemsByIds", + signal: new AbortController().signal, + }), + ).resolves.toEqual({ decision: "allow" }); + + const fresh = createBrunchMutationApprovalCoordinator(); + const controller = new AbortController(); + const pending = fresh.request({ + toolCallId: "delete-3", + toolName: "deleteItemsByIds", + signal: controller.signal, + }); + controller.abort(); + await expect(pending).resolves.toEqual({ + decision: "deny", + reason: "The destructive edit was stopped before approval.", + }); + expect(fresh.resolve("delete-3", "allow")).toBe(false); + expect(fresh.hasPending("delete-3")).toBe(false); + }); + + test("closing stops waiting approvals and reopening accepts new ones", async () => { + const coordinator = createBrunchMutationApprovalCoordinator(); + const waiting = coordinator.request({ + toolCallId: "delete-1", + toolName: "deleteItemsByIds", + signal: new AbortController().signal, + }); + coordinator.close(); + await expect(waiting).resolves.toEqual({ + decision: "deny", + reason: "The destructive edit was stopped before approval.", + }); + await expect( + coordinator.request({ + toolCallId: "delete-2", + toolName: "deleteItemsByIds", + signal: new AbortController().signal, + }), + ).resolves.toMatchObject({ decision: "deny" }); + + coordinator.open(); + void coordinator.request({ + toolCallId: "delete-3", + toolName: "deleteItemsByIds", + signal: new AbortController().signal, + }); + expect(coordinator.hasPending("delete-3")).toBe(true); + }); + + test("changes its snapshot for every pending call, including calls of the same tool", () => { + const coordinator = createBrunchMutationApprovalCoordinator(); + const idle = coordinator.getVersion(); + void coordinator.request({ + toolCallId: "remove-1", + toolName: "removePlace", + signal: new AbortController().signal, + }); + const waiting = coordinator.getVersion(); + expect(waiting).not.toBe(idle); + expect(coordinator.getVersion()).toBe(waiting); + void coordinator.request({ + toolCallId: "remove-2", + toolName: "removePlace", + signal: new AbortController().signal, + }); + const bothWaiting = coordinator.getVersion(); + expect(bothWaiting).not.toBe(waiting); + coordinator.resolve("remove-1", "deny"); + expect(coordinator.getVersion()).not.toBe(bothWaiting); + expect(coordinator.hasPending("remove-2")).toBe(true); + coordinator.close(); + }); + + test("a malformed call is refused before it can wait for an approval that never renders", async () => { + const coordinator = createBrunchMutationApprovalCoordinator(); + const request = vi.spyOn(coordinator, "request"); + await expect( + createBrunchMutationAdmission(coordinator)({ + toolCallId: "remove-1", + toolName: "removePlace", + input: {}, + signal: new AbortController().signal, + }), + ).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); + }); + + test("historical rendering cannot create approval authority", () => { + const coordinator = createBrunchMutationApprovalCoordinator(); + const ApprovalWidget = createBrunchMutationApprovalWidget( + coordinator, + "deleteItemsByIds", + ); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Allow" })); + expect(coordinator.hasPending("historical-call")).toBe(false); + expect(coordinator.resolve("historical-call", "allow")).toBe(false); + }); +}); + +describe("Brunch destructive edit approval on in-band browser calls", () => { + const binding = { + conversationId: "conversation", + documentId: "document", + incarnationId: "incarnation", + }; + + const issuedCalls = (toolName: string, input: unknown) => { + const posted: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url, init) => { + if (init?.method === "POST") { + posted.push( + typeof init.body === "string" ? JSON.parse(init.body) : init.body, + ); + return new Response(null, { status: 200 }); + } + return Response.json({ + capability: "capability", + binding: canonicalContent(binding), + toolName, + input, + }); + }), + ); + const coordinator = createBrunchMutationApprovalCoordinator(); + const prepareInput = vi.fn(); + const calls = createInBandBrowserCalls({ + client: Promise.resolve({ + url: "http://brunch.local/agents/chat/instance", + } as FlueClient), + principalKey: "principal", + binding, + metadataFor: async () => undefined, + prepareInput, + admit: createBrunchMutationAdmission(coordinator), + }); + return { calls, coordinator, posted, prepareInput }; + }; + + test("a denied removal settles as not applied without running", async () => { + const input = { placeId: "queue" }; + const { calls, coordinator, posted } = issuedCalls("removePlace", input); + const execute = vi.fn(async () => ({ applied: true })); + const run = calls.run( + { + toolCallId: "remove-1", + toolName: "removePlace", + input, + signal: new AbortController().signal, + }, + execute, + ); + await vi.waitFor(() => + expect(coordinator.hasPending("remove-1")).toBe(true), + ); + coordinator.resolve("remove-1", "deny"); + + await expect(run).resolves.toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + expect(posted).toEqual([ + expect.objectContaining({ + output: { + applied: false, + reason: "The user denied this destructive edit.", + }, + }), + ]); + }); + + test("an allowed removal runs once and reports its own result", async () => { + const input = { placeId: "queue" }; + const { calls, coordinator, posted } = issuedCalls("removePlace", input); + const execute = vi.fn(async () => ({ + applied: true, + title: "Removed place", + })); + const run = calls.run( + { + toolCallId: "remove-1", + toolName: "removePlace", + input, + signal: new AbortController().signal, + }, + execute, + ); + await vi.waitFor(() => + expect(coordinator.hasPending("remove-1")).toBe(true), + ); + coordinator.resolve("remove-1", "allow"); + + await run; + expect(execute).toHaveBeenCalledOnce(); + expect(posted).toEqual([ + expect.objectContaining({ + output: { applied: true, title: "Removed place" }, + }), + ]); + }); + + test("the host records a removal's starting revision only once it is allowed", async () => { + const input = { placeId: "queue" }; + const denied = issuedCalls("removePlace", input); + const deniedRun = denied.calls.run( + { + toolCallId: "remove-1", + toolName: "removePlace", + input, + signal: new AbortController().signal, + }, + vi.fn(async () => ({ applied: true })), + ); + await vi.waitFor(() => + expect(denied.coordinator.hasPending("remove-1")).toBe(true), + ); + expect(denied.prepareInput).not.toHaveBeenCalled(); + denied.coordinator.resolve("remove-1", "deny"); + await deniedRun; + expect(denied.prepareInput).not.toHaveBeenCalled(); + + const allowed = issuedCalls("removePlace", input); + const allowedRun = allowed.calls.run( + { + toolCallId: "remove-2", + toolName: "removePlace", + input, + signal: new AbortController().signal, + }, + vi.fn(async () => ({ applied: true })), + ); + await vi.waitFor(() => + expect(allowed.coordinator.hasPending("remove-2")).toBe(true), + ); + expect(allowed.prepareInput).not.toHaveBeenCalled(); + allowed.coordinator.resolve("remove-2", "allow"); + await allowedRun; + expect(allowed.prepareInput).toHaveBeenCalledOnce(); + }); + + test("a constructive call runs without asking", async () => { + const input = { + id: "place", + name: "Place", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; + const { calls, coordinator } = issuedCalls("addPlace", input); + const request = vi.spyOn(coordinator, "request"); + const execute = vi.fn(async () => ({ applied: true })); + + await calls.run( + { + toolCallId: "add-1", + toolName: "addPlace", + input, + signal: new AbortController().signal, + }, + execute, + ); + expect(request).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledOnce(); + }); + + test("Stop before approval reports nothing and never runs", async () => { + const input = { placeId: "queue" }; + const { calls, coordinator, posted } = issuedCalls("removePlace", input); + const controller = new AbortController(); + const execute = vi.fn(async () => ({ applied: true })); + const run = calls.run( + { + toolCallId: "remove-1", + toolName: "removePlace", + input, + signal: controller.signal, + }, + execute, + ); + await vi.waitFor(() => + expect(coordinator.hasPending("remove-1")).toBe(true), + ); + controller.abort(); + + await run; + expect(execute).not.toHaveBeenCalled(); + expect(posted).toEqual([]); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-mutation-approval.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-mutation-approval.tsx new file mode 100644 index 00000000000..f1c304fbe94 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-mutation-approval.tsx @@ -0,0 +1,312 @@ +import { Button } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; +import { + mutationActionInputSchemas, + type PetrinautAiMutationToolName, +} from "@hashintel/petrinaut-core"; +import { + definePetrinautAiInteractiveTool, + type PetrinautAiInteractiveTool, + type PetrinautAiInteractiveToolWidgetProps, +} from "@hashintel/petrinaut/ui"; + +import type { InBandBrowserCallAdmission } from "./in-band-browser-call"; + +type BrunchMutationApprovalChoice = "allow" | "always-allow" | "deny"; + +type BrunchMutationApprovalDecision = + | { readonly decision: "allow" } + | { readonly decision: "deny"; readonly reason: string }; + +type PendingApproval = { + readonly resolve: (decision: BrunchMutationApprovalDecision) => void; + readonly removeAbortListener: () => void; +}; + +export interface BrunchMutationApprovalCoordinator { + request(params: { + readonly toolCallId: string; + readonly toolName: string; + readonly signal: AbortSignal; + }): Promise; + resolve(toolCallId: string, choice: BrunchMutationApprovalChoice): boolean; + hasPending(toolCallId: string): boolean; + /** Changes whenever a call enters or leaves the approval gate. */ + getVersion: () => number; + subscribe: (listener: () => void) => () => void; + /** Settles waiting approvals as stopped and refuses new ones until reopened. */ + close(): void; + /** React Strict Mode closes and reopens the coordinator of a mounted conversation. */ + open(): void; +} + +const stoppedReason = "The destructive edit was stopped before approval."; +const deniedReason = "The user denied this destructive edit."; + +/** One ephemeral approval authority. Create once per mounted conversation. */ +export const createBrunchMutationApprovalCoordinator = + (): BrunchMutationApprovalCoordinator => { + const pending = new Map(); + const listeners = new Set<() => void>(); + let alwaysAllow = false; + let closed = false; + let version = 0; + const notify = () => { + version += 1; + listeners.forEach((listener) => listener()); + }; + const settle = ( + toolCallId: string, + decision: BrunchMutationApprovalDecision, + ) => { + const approval = pending.get(toolCallId); + if (!approval) return false; + pending.delete(toolCallId); + approval.removeAbortListener(); + approval.resolve(decision); + notify(); + return true; + }; + + return { + request: ({ toolCallId, signal }) => { + if (closed || signal.aborted) + return Promise.resolve({ decision: "deny", reason: stoppedReason }); + if (alwaysAllow) return Promise.resolve({ decision: "allow" }); + return new Promise((resolve) => { + const onAbort = () => { + settle(toolCallId, { decision: "deny", reason: stoppedReason }); + }; + signal.addEventListener("abort", onAbort, { once: true }); + pending.set(toolCallId, { + resolve, + removeAbortListener: () => + signal.removeEventListener("abort", onAbort), + }); + notify(); + }); + }, + resolve: (toolCallId, choice) => { + if (!pending.has(toolCallId) || closed) return false; + if (choice === "always-allow") alwaysAllow = true; + return settle( + toolCallId, + choice === "deny" + ? { decision: "deny", reason: deniedReason } + : { decision: "allow" }, + ); + }, + hasPending: (toolCallId) => pending.has(toolCallId), + getVersion: () => version, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + close: () => { + closed = true; + alwaysAllow = false; + for (const toolCallId of pending.keys()) + settle(toolCallId, { decision: "deny", reason: stoppedReason }); + }, + open: () => { + closed = false; + }, + }; + }; + +const destructiveToolNames = [ + "removePlace", + "removeTransition", + "removeArc", + "removeType", + "removeTypeElement", + "removeDifferentialEquation", + "removeParameter", + "removeScenario", + "removeMetric", + "removeSubnet", + "removeComponentInstance", + "deleteItemsByIds", +] as const satisfies readonly PetrinautAiMutationToolName[]; + +type DestructiveToolName = (typeof destructiveToolNames)[number]; + +const requiresBrunchMutationApproval = ( + toolName: string, +): toolName is DestructiveToolName => + (destructiveToolNames as readonly string[]).includes(toolName); + +const spacedWords = (camelCase: string) => + camelCase.replace(/[A-Z]/gu, (letter) => ` ${letter.toLowerCase()}`); + +const removalDescriptions = ( + toolName: DestructiveToolName, + input: unknown, +): readonly string[] => { + switch (toolName) { + case "removePlace": + return [ + `Remove place — ${mutationActionInputSchemas.removePlace.parse(input).placeId}`, + ]; + case "removeTransition": + return [ + `Remove transition — ${mutationActionInputSchemas.removeTransition.parse(input).transitionId}`, + ]; + case "removeArc": { + const { arcDirection, endpoint, placeId, transitionId } = + mutationActionInputSchemas.removeArc.parse(input); + const target = + placeId ?? + (endpoint?.kind === "componentPort" + ? `${endpoint.componentInstanceId} / ${endpoint.portPlaceId}` + : endpoint?.placeId); + return [`Remove ${arcDirection} arc — ${transitionId} ↔ ${target}`]; + } + case "removeType": + return [ + `Remove type — ${mutationActionInputSchemas.removeType.parse(input).typeId}`, + ]; + case "removeTypeElement": { + const { elementId, typeId } = + mutationActionInputSchemas.removeTypeElement.parse(input); + return [`Remove type element — ${typeId} / ${elementId}`]; + } + case "removeDifferentialEquation": + return [ + `Remove differential equation — ${mutationActionInputSchemas.removeDifferentialEquation.parse(input).equationId}`, + ]; + case "removeParameter": + return [ + `Remove parameter — ${mutationActionInputSchemas.removeParameter.parse(input).parameterId}`, + ]; + case "removeScenario": + return [ + `Remove scenario — ${mutationActionInputSchemas.removeScenario.parse(input).scenarioId}`, + ]; + case "removeMetric": + return [ + `Remove metric — ${mutationActionInputSchemas.removeMetric.parse(input).metricId}`, + ]; + case "removeSubnet": + return [ + `Remove subnet — ${mutationActionInputSchemas.removeSubnet.parse(input).subnetId}`, + ]; + case "removeComponentInstance": + return [ + `Remove component instance — ${mutationActionInputSchemas.removeComponentInstance.parse(input).instanceId}`, + ]; + case "deleteItemsByIds": + return mutationActionInputSchemas.deleteItemsByIds + .parse(input) + .items.map(({ id, type }) => `Remove ${spacedWords(type)} — ${id}`); + } +}; + +/** Later calls stay queued behind a waiting approval; a denial settles as not applied. */ +export const createBrunchMutationAdmission = + (approval: BrunchMutationApprovalCoordinator): InBandBrowserCallAdmission => + async ({ toolCallId, toolName, input, signal }) => { + if (!requiresBrunchMutationApproval(toolName)) return { admitted: true }; + // An approval row renders only for inputs its schema accepts. + mutationActionInputSchemas[toolName].parse(input); + const decision = await approval.request({ toolCallId, toolName, signal }); + return decision.decision === "allow" + ? { admitted: true } + : { + admitted: false, + output: { applied: false, reason: decision.reason }, + }; + }; + +const containerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", + padding: "3", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "neutral.a20", + borderRadius: "lg", + backgroundColor: "neutral.s00", +}); +const actionsStyle = css({ display: "flex", gap: "2", flexWrap: "wrap" }); + +type WidgetProps = PetrinautAiInteractiveToolWidgetProps; + +export const createBrunchMutationApprovalWidget = ( + coordinator: BrunchMutationApprovalCoordinator, + toolName: DestructiveToolName, +) => { + const Widget = ({ input, toolCallId }: WidgetProps) => { + return ( +
+ Allow these destructive edits? +
    + {removalDescriptions(toolName, input).map((description) => ( +
  • {description}
  • + ))} +
+

+ Associated arcs or references may also be removed. Always allow + applies to destructive edits in this conversation only, until you + leave or reload. +

+
+ + + +
+
+ ); + }; + return Widget; +}; + +const passthrough = { parse: (value: unknown) => value }; + +export const createBrunchMutationApprovalInteractiveTools = ( + coordinator: BrunchMutationApprovalCoordinator, +): readonly PetrinautAiInteractiveTool[] => + destructiveToolNames.map((toolName) => + definePetrinautAiInteractiveTool({ + toolName, + inputSchema: { + parse: (value) => mutationActionInputSchemas[toolName].parse(value), + }, + outputSchema: passthrough, + // Earlier rows of the same tool keep their normal presentation. + shouldHandle: ({ toolCallId }) => coordinator.hasPending(toolCallId), + component: createBrunchMutationApprovalWidget(coordinator, toolName), + }), + ); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/in-band-browser-call.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/in-band-browser-call.ts index d415004f8f2..7a83ad998ce 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/in-band-browser-call.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/in-band-browser-call.ts @@ -23,6 +23,20 @@ const issuedInputSchemas: Readonly< [brunchTools.draftPetrinautExperiment]: draftPetrinautExperimentInputSchema, }; +/** + * Decides, after the claim and before `prepareInput`, whether a call may + * start. A refused call settles with the given output and never starts. + */ +export type InBandBrowserCallAdmission = (call: { + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; + readonly signal: AbortSignal; +}) => Promise< + | { readonly admitted: true } + | { readonly admitted: false; readonly output: unknown } +>; + /** The callback crosses the same single-owner HTTP process that is running the Flue tool. */ export const createInBandBrowserCalls = (input: { readonly client: Promise; @@ -37,6 +51,7 @@ export const createInBandBrowserCalls = (input: { toolName: string; input: unknown; }) => void; + readonly admit?: InBandBrowserCallAdmission; }) => { const claim = async (call: { readonly toolCallId: string; @@ -183,6 +198,11 @@ export const createInBandBrowserCalls = (input: { let started = false; try { if (call.signal.aborted) return; + const admission = (await input.admit?.(call)) ?? { admitted: true }; + if (!admission.admitted) { + await issued.submit(admission.output); + return; + } input.prepareInput({ toolCallId: call.toolCallId, toolName: call.toolName, diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.canonical-state-change.integration.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.canonical-state-change.integration.test.tsx index a1f568333fb..b05d551b6cd 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.canonical-state-change.integration.test.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.canonical-state-change.integration.test.tsx @@ -260,6 +260,14 @@ test("real panel scenario and metric add/update/remove calls produce persisted r target: { value: "Create a baseline scenario and throughput metric." }, }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); + // The first removal asks once; Always allow covers the second in this conversation. + fireEvent.click( + await screen.findByRole( + "button", + { name: "Always allow" }, + { timeout: 15_000 }, + ), + ); expect( await screen.findByText( "All six scenario and metric tools returned.", 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 507299c5878..c97dc742dac 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 @@ -74,6 +74,9 @@ const flueClientMock = vi.hoisted(() => ({ current: null as unknown })); const flueClientOptions = vi.hoisted(() => ({ current: null as unknown })); const renderedPetrinaut = vi.hoisted(() => ({ aiAssistant: null as unknown })); const renderedAssistants = vi.hoisted(() => [] as PetrinautAiAssistant[]); +const mutationApprovalCoordinators = vi.hoisted( + () => [] as { close: () => void }[], +); vi.mock("@flue/sdk", () => ({ createFlueClient: (options: unknown) => { flueClientOptions.current = options; @@ -89,6 +92,20 @@ vi.mock("./brunch-preview-config", () => ({ resolveBrunchPreviewConfig: () => brunchPreviewConfig, })); +vi.mock("./brunch-mutation-approval", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createBrunchMutationApprovalCoordinator: () => { + const coordinator = actual.createBrunchMutationApprovalCoordinator(); + vi.spyOn(coordinator, "close"); + mutationApprovalCoordinators.push(coordinator); + return coordinator; + }, + }; +}); + const editorProps = vi.hoisted(() => ({ current: null as { aiAssistant?: unknown; @@ -403,7 +420,12 @@ describe("local storage demo Brunch voice integration", () => { expect(aiAssistant.executeMutation).toBeUndefined(); expect( aiAssistant.interactiveTools?.map(({ toolName }) => toolName), - ).toEqual([brunchTools.draftPetrinautExperiment]); + ).toEqual( + expect.arrayContaining([ + brunchTools.draftPetrinautExperiment, + "removePlace", + ]), + ); expect(aiAssistant.resolveToolPresentation).toBeTypeOf("function"); expect(aiAssistant.workingLabel).toBe("Brunch is working"); expect( @@ -430,7 +452,10 @@ describe("local storage demo Brunch voice integration", () => { ], ); + const mutationApprovalCoordinator = mutationApprovalCoordinators.at(-1); + expect(mutationApprovalCoordinator).toBeDefined(); rendered.unmount(); + expect(mutationApprovalCoordinator?.close).toHaveBeenCalledOnce(); vi.unstubAllGlobals(); }); @@ -1157,6 +1182,132 @@ describe("local storage demo Brunch controls", () => { brunchPreviewConfig.isBrunchConfigured = true; }); + test("clearing ordinary Brunch starts a persisted fresh conversation without replacing the model", async () => { + seedStoredNet("clear-incarnation"); + localStorage.setItem(assistantSelectionStorageKey, "brunch"); + flueClientMock.current = { + history: async () => ({ + conversation: { settlements: [], messages: [] }, + offset: "0", + }), + observe: () => ({ + close: vi.fn(), + getSnapshot: () => ({ phase: "absent" }), + refresh: vi.fn(), + subscribe: () => () => {}, + }), + }; + const view = render( + {}} search={{}} />, + ); + await waitFor(() => expect(editorProps.current?.aiAssistant).toBeDefined()); + const first = editorProps.current?.aiAssistant as PetrinautAiAssistant; + const originalId = first.conversationId; + const handle = editorProps.current?.handle; + expect(first.canClearMessages).toBe(true); + act(() => first.onClearMessages?.()); + const next = editorProps.current?.aiAssistant as PetrinautAiAssistant; + expect(next.conversationId).not.toBe(originalId); + expect(next.conversationId).toContain( + "brunch-construction-v1:clear-incarnation:", + ); + expect(next.automaticTools).not.toBe(first.automaticTools); + expect(editorProps.current?.handle).toBe(handle); + const nextId = next.conversationId; + view.unmount(); + render( {}} search={{}} />); + await waitFor(() => + expect( + (editorProps.current?.aiAssistant as PetrinautAiAssistant | undefined) + ?.conversationId, + ).toBe(nextId), + ); + }); + + test("a destructive edit waiting for approval settles when the conversation is replaced", async () => { + seedStoredNet("pending-incarnation"); + localStorage.setItem(assistantSelectionStorageKey, "brunch"); + flueClientMock.current = { + url: "http://brunch.local/agents/chat/instance", + history: async () => ({ + conversation: { settlements: [], messages: [] }, + offset: "0", + }), + observe: () => ({ + close: vi.fn(), + getSnapshot: () => ({ phase: "absent" }), + refresh: vi.fn(), + subscribe: () => () => {}, + }), + }; + const posted: unknown[] = []; + const claimed = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async (url, init) => { + const target = new URL(url instanceof Request ? url.url : url); + if (!target.pathname.includes("/browser-calls/remove-1")) + return new Response(null, { status: 404 }); + if (init?.method === "POST") { + posted.push( + typeof init.body === "string" ? JSON.parse(init.body) : init.body, + ); + return new Response(null, { status: 200 }); + } + claimed(); + return Response.json({ + capability: "capability", + binding: target.searchParams.get("binding"), + toolName: "removePlace", + input: { placeId: "queue" }, + }); + }), + ); + try { + render( {}} search={{}} />); + await waitFor(() => + expect( + (editorProps.current?.aiAssistant as PetrinautAiAssistant | undefined) + ?.inBandBrowserTools, + ).toBeDefined(), + ); + const assistant = editorProps.current + ?.aiAssistant as PetrinautAiAssistant; + const initialTools = assistant.interactiveTools; + const execute = vi.fn(async () => ({ applied: true })); + const run = assistant.inBandBrowserTools?.run( + { + toolCallId: "remove-1", + toolName: "removePlace", + input: { placeId: "queue" }, + signal: new AbortController().signal, + }, + execute, + ); + const interactiveTools = () => + (editorProps.current?.aiAssistant as PetrinautAiAssistant | undefined) + ?.interactiveTools; + await waitFor(() => expect(claimed).toHaveBeenCalled()); + await waitFor(() => expect(interactiveTools()).not.toBe(initialTools)); + const waitingTools = interactiveTools(); + act(() => assistant.onClearMessages?.()); + + await run; + expect(interactiveTools()).not.toBe(waitingTools); + expect(execute).not.toHaveBeenCalled(); + expect(posted).toEqual([ + expect.objectContaining({ + output: { + applied: false, + reason: "The destructive edit was stopped before approval.", + }, + }), + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + test.each(["metaKey", "ctrlKey"])( "reserves %s + Shift + K for the assistant and keeps plain K for the palette", (modifier) => { @@ -1244,7 +1395,12 @@ describe("local storage demo Brunch controls", () => { ); expect( aiAssistant.interactiveTools?.map(({ toolName }) => toolName), - ).toEqual([brunchTools.draftPetrinautExperiment]); + ).toEqual( + expect.arrayContaining([ + brunchTools.draftPetrinautExperiment, + "removePlace", + ]), + ); expect(transportOptions.mapClientToolInput).toEqual(expect.any(Function)); // Every configured Brunch browser tool, the draft included, settles in band. expect(aiAssistant.inBandBrowserTools?.has(createExperimentToolName)).toBe( 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 c7be2851338..d7666b91685 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 @@ -16,6 +16,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import { @@ -73,12 +74,19 @@ import { import { brunchPetrinautClientToolNames } from "./brunch-client-tools"; import { brunchEvaluationConversationIdFrom, + getOrCreateBrunchConversationId, ordinaryConstructionConversationIdFrom, + replaceBrunchConversationId, } from "./brunch-conversation-id"; import { createBrunchDraftExperimentInteractiveTool, resolveDraftAuthorityFromHistory, } from "./brunch-draft-experiment-interactive-tool"; +import { + createBrunchMutationAdmission, + createBrunchMutationApprovalCoordinator, + createBrunchMutationApprovalInteractiveTools, +} from "./brunch-mutation-approval"; import { BrunchPanelConversationTracker, type BrunchPanelAdmissionTarget, @@ -610,10 +618,22 @@ export const LocalStorageDemoApp = ({ documentId: currentDocument.documentId, title, }); - const baseConstructionConversationId = - currentDocument === null || !brunchSelected - ? undefined - : ordinaryConstructionConversationIdFrom(currentDocument.incarnationId); + const [freshConversationIds, setFreshConversationIds] = useState< + Record + >({}); + const incarnationId = currentDocument?.incarnationId; + const baseConstructionConversationId = useMemo(() => { + if (!brunchSelected || incarnationId === undefined) return undefined; + const initialId = ordinaryConstructionConversationIdFrom(incarnationId); + return ( + freshConversationIds[incarnationId] ?? + getOrCreateBrunchConversationId( + initialId, + window.localStorage, + () => initialId, + ) + ); + }, [brunchSelected, freshConversationIds, incarnationId]); const fixtureProcessAgentConfiguration = useMemo< FixtureProcessAgentConfiguration | undefined >( @@ -640,6 +660,43 @@ export const LocalStorageDemoApp = ({ [baseProcessAgentBinding], ); const conversationId = processAgentBinding?.conversationId ?? null; + // Each binding gets its own non-persisted approval authority. + const mutationApproval = useMemo( + () => ({ + binding: processAgentBinding, + coordinator: createBrunchMutationApprovalCoordinator(), + }), + [processAgentBinding], + ); + // The panel stays mounted when the binding changes, so a replaced authority + // must settle the approvals still waiting on it. + useEffect(() => { + const { coordinator } = mutationApproval; + coordinator.open(); + return () => coordinator.close(); + }, [mutationApproval]); + const allMutationApprovalTools = useMemo( + () => + createBrunchMutationApprovalInteractiveTools( + mutationApproval.coordinator, + ), + [mutationApproval], + ); + // A registered widget replaces the tool's row, so only calls still waiting + // for a decision render as approvals. Refresh the registry when call identities + // change; shouldHandle is the single gate, including for same-name calls. + const approvalVersion = useSyncExternalStore( + mutationApproval.coordinator.subscribe, + mutationApproval.coordinator.getVersion, + mutationApproval.coordinator.getVersion, + ); + const mutationApprovalTools = useMemo( + () => ({ + version: approvalVersion, + tools: [...allMutationApprovalTools], + }), + [allMutationApprovalTools, approvalVersion], + ).tools; const processAgentSession = useProcessAgentSession({ binding: processAgentBinding, brunchSelected, @@ -830,9 +887,15 @@ export const LocalStorageDemoApp = ({ prepareInput: (call) => { canonicalHostTools?.mapClientToolInput(call); }, + admit: createBrunchMutationAdmission(mutationApproval.coordinator), }) : undefined, - [constructionBrowser, flueClientPromise, canonicalHostTools], + [ + constructionBrowser, + flueClientPromise, + canonicalHostTools, + mutationApproval, + ], ); const draftInteractiveTool = useMemo( @@ -889,13 +952,16 @@ export const LocalStorageDemoApp = ({ } : {}), ...(conversationId === null ? {} : { conversationId }), - canClearMessages: flueClientPromise === null, + canClearMessages: true, // These exact-name tools override the static registry only while a // document binding is attached. Every other canonical capability remains // on Petrinaut's registry. inBandBrowserTools, automaticTools: [...(canonicalHostTools?.tools ?? [])], - interactiveTools: draftInteractiveTool ? [draftInteractiveTool] : [], + interactiveTools: [ + ...(inBandBrowserTools ? mutationApprovalTools : []), + ...(draftInteractiveTool ? [draftInteractiveTool] : []), + ], transport: petrinautAiChatTransport, ...(flueClientPromise === null ? {} @@ -926,6 +992,17 @@ export const LocalStorageDemoApp = ({ })); }, onClearMessages: () => { + if (flueClientPromise !== null && incarnationId !== undefined) { + const initialId = + ordinaryConstructionConversationIdFrom(incarnationId); + const nextId = `${initialId}:${crypto.randomUUID()}`; + replaceBrunchConversationId(initialId, nextId); + setFreshConversationIds((current) => ({ + ...current, + [incarnationId]: nextId, + })); + return; + } if (!currentNetId || flueClientPromise !== null) { return; } @@ -949,6 +1026,8 @@ export const LocalStorageDemoApp = ({ canonicalHostTools, inBandBrowserTools, draftInteractiveTool, + incarnationId, + mutationApprovalTools, constructionBrowser, conversationTracker, conversationId, diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 23bb25120ee..0c8b72cd4d7 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -63,6 +63,8 @@ Timing is shown when supplied or observed during this session; unavailable tool durations show a dash. Disclosure icons are neutral; status dots distinguish pending, completed, and failed tools. +Before Brunch removes model elements, an approval lists the requested removals. Associated arcs or references may also be removed. **Allow** applies that removal; **Deny** withholds that call and tells Brunch nothing was changed. Brunch's later calls in the same response wait until you answer, then continue to run. **Always allow** permits later removals only in the current mounted conversation, until you leave or reload. It does not grant permission for another conversation or browser session. Stop cancels a pending approval. Auto-layout asks separately; see `applyAutoLayout` below. + When the host supplies them, Voice also shows a collapsed brief directly under your message, an immediate spoken-agent reply before the work, and a wrap-up after the produced cards. The brief says **Preparing for Brunch** while its fields are being prepared, **Sending to Brunch** once the fields are ready but not yet accepted, and **Sent to Brunch** after acceptance. Expand a prepared brief to see **Prepared from what you said** and its right-aligned fields. These optional parts are absent in hosts that do not provide them. In Chat, a small neutral voice-bars icon marks user messages sent using Voice; typed messages have no icon. In Voice, those per-message icons are hidden. Hosts that provide live input captions can show your words while you speak. This partial text is display-only: it does not submit work or start preparing a brief. The finalized transcript replaces it in the same bubble before preparation starts. New spoken words and status labels fade in; reduced-motion preferences disable these effects. @@ -287,7 +289,7 @@ Voice ends when the panel closes. If Realtime Voice is interrupted, allow microphone access or check the connection, then select **Reconnect voice mode**. **Clear AI chat** is unavailable while a Voice session is active. -The delete button appears in the top right of the panel once the conversation contains messages. When no interview is active and the host permits clearing, **Clear AI chat** wipes the local conversation, stops any in-flight stream, and tells the host app to forget the messages if it persists them. Hosts with canonical history may disable this control. The Brunch panel disables it because clearing only the browser view would not delete Flue history and the conversation would return on rehydration. +The delete button appears in the top right once the conversation contains messages. When Voice is inactive, **Clear AI chat** in ordinary Brunch starts a fresh conversation and resets conversation-only approvals. It preserves the model and the old saved history; it is not a history-deletion action. Reopening the page returns to the new conversation. Other hosts can clear local messages or disable this control. An interrupted Voice session shows a gentle red waveform without a visible status label. Recovery controls remain available and screen readers still announce the interruption. Open **Voice issues** for a short title and explanation. **Copy details** becomes **Copied** after success; **Dismiss** clears the displayed issues without ending Voice. diff --git a/libs/@hashintel/petrinaut/src/ui/types/ai-interactive-tool.ts b/libs/@hashintel/petrinaut/src/ui/types/ai-interactive-tool.ts index 95dcd01f97d..7850f0fab4f 100644 --- a/libs/@hashintel/petrinaut/src/ui/types/ai-interactive-tool.ts +++ b/libs/@hashintel/petrinaut/src/ui/types/ai-interactive-tool.ts @@ -49,6 +49,16 @@ export type PetrinautAiInteractiveToolDefinition = { inputSchema: PetrinautAiInteractiveToolSchema; /** Runtime contract for the widget's submitted output. */ outputSchema: PetrinautAiInteractiveToolSchema; + /** + * Render an interaction only for matching call identities. Defaults to all. + * This runs during render without parsing the input; when it + * depends on host state, rebuild `interactiveTools` as that state changes. + * + * Declining only suppresses the widget for tools the host executes itself + * (`inBandBrowserTools`). Any other registered tool is completed solely by + * its widget, so a declined call fails rather than waiting for a result. + */ + shouldHandle?: (call: { toolCallId: string }) => boolean; /** * Optionally map text submitted through the assistant composer to this * tool's output. Petrinaut validates both the pending input and mapped @@ -66,6 +76,7 @@ type ErasedInteractiveToolDefinition = { placement?: "work" | "card"; parseInput: (value: unknown) => unknown; parseOutput: (value: unknown) => unknown; + shouldHandle?: (call: { toolCallId: string }) => boolean; fromComposerText?: (params: { input: unknown; text: string }) => unknown; component: ComponentType< PetrinautAiInteractiveToolWidgetProps @@ -88,6 +99,7 @@ export const definePetrinautAiInteractiveTool = ( definition: PetrinautAiInteractiveToolDefinition, ): PetrinautAiInteractiveTool => { const fromComposerText = definition.fromComposerText; + const shouldHandle = definition.shouldHandle; return { toolName: definition.toolName, @@ -96,6 +108,7 @@ export const definePetrinautAiInteractiveTool = ( placement: definition.placement, parseInput: (value) => definition.inputSchema.parse(value), parseOutput: (value) => definition.outputSchema.parse(value), + shouldHandle, fromComposerText: fromComposerText ? ({ input, text }) => definition.outputSchema.parse( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx index 2dd23c6f5fc..d848155e3dd 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.tsx @@ -982,8 +982,7 @@ const ConversationAiAssistantPanel = ({ } if (!aiAssistant.inBandBrowserTools?.has(toolCall.toolName)) { resolveDynamicInteractiveTool( - toolCall.toolName, - toolCall.input, + toolCall, aiAssistant.interactiveTools ?? [], ); return; @@ -1163,7 +1162,10 @@ const ConversationAiAssistantPanel = ({ toolCall.input, ); if ( - getInteractiveTool(toolName, commandInput, aiAssistant.interactiveTools) + getInteractiveTool( + { toolName, toolCallId: toolCall.toolCallId, input: commandInput }, + aiAssistant.interactiveTools, + ) ) { return; } @@ -1870,11 +1872,7 @@ const ConversationAiAssistantPanel = ({ continue; } - const definition = getInteractiveTool( - part.toolName, - part.input, - interactiveTools, - ); + const definition = getInteractiveTool(part, interactiveTools); if (!definition?.fromComposerText) { continue; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx index 0421ba0a028..a86afc06fb2 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx @@ -1050,11 +1050,7 @@ export const AiAssistantContents = ({ message.parts.some((part) => { if (part.type !== "dynamic-tool" || part.state !== "input-available") return false; - const tool = getInteractiveTool( - part.toolName, - part.input, - interactiveTools, - ); + const tool = getInteractiveTool(part, interactiveTools); return tool !== undefined && tool.placement !== "card"; }), ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx index fff411e2545..59acbc78f0a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/tool-list.tsx @@ -449,8 +449,15 @@ export const toToolRenderItem = ( } : defaultSummary; + const id = + typeof part.toolCallId === "string" + ? part.toolCallId + : `${message.id}-${part.type}`; const interactiveDefinition = hasInteractiveToolInput(state) - ? getInteractiveTool(toolName, part.input, interactiveTools) + ? getInteractiveTool( + { toolName, toolCallId: id, input: part.input }, + interactiveTools, + ) : undefined; const interactive = interactiveDefinition ? { @@ -461,10 +468,7 @@ export const toToolRenderItem = ( : undefined; return { - id: - typeof part.toolCallId === "string" - ? part.toolCallId - : `${message.id}-${part.type}`, + id, state, input: part.input, output: part.output, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx index c974dea2657..bc4b7efa196 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx @@ -96,12 +96,16 @@ describe("ApplyAutoLayoutWidget", () => { describe("applyAutoLayoutInteractiveTool.shouldHandle", () => { test("returns true only when askUserFirst is explicitly true", () => { + const call = { toolCallId: "layout-1" }; expect( - applyAutoLayoutInteractiveTool.shouldHandle({ askUserFirst: true }), + applyAutoLayoutInteractiveTool.shouldHandle({ askUserFirst: true }, call), ).toBe(true); expect( - applyAutoLayoutInteractiveTool.shouldHandle({ askUserFirst: false }), + applyAutoLayoutInteractiveTool.shouldHandle( + { askUserFirst: false }, + call, + ), ).toBe(false); - expect(applyAutoLayoutInteractiveTool.shouldHandle({})).toBe(false); + expect(applyAutoLayoutInteractiveTool.shouldHandle({}, call)).toBe(false); }); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.test.tsx index 4204e6a3a34..943c303d1d1 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.test.tsx @@ -23,11 +23,81 @@ const hostTool = definePetrinautAiInteractiveTool({ component: () => null, }); +const toolCall = (toolName: string, input: unknown) => ({ + toolName, + toolCallId: `${toolName}-call`, + input, +}); + describe("interactive tool registry", () => { + test("selects host calls without parsing their input during lookup", () => { + const parse = vi.fn((input: unknown) => input); + const conditional = definePetrinautAiInteractiveTool({ + toolName: "mutate", + inputSchema: { parse }, + outputSchema: { parse: (output: unknown) => output }, + shouldHandle: ({ toolCallId }) => toolCallId === "mutate-call", + component: () => null, + }); + expect( + getInteractiveTool(toolCall("mutate", { destructive: false }), [ + conditional, + ]), + ).toBeDefined(); + expect( + getInteractiveTool(toolCall("mutate", { destructive: true }), [ + conditional, + ]), + ).toBeDefined(); + expect(parse).not.toHaveBeenCalled(); + }); + + test("lets a host predicate tell apart calls with identical inputs", () => { + const waiting = definePetrinautAiInteractiveTool({ + toolName: "mutate", + inputSchema: { parse: (input: unknown) => input }, + outputSchema: { parse: (output: unknown) => output }, + shouldHandle: ({ toolCallId }) => toolCallId === "waiting", + component: () => null, + }); + const input = { placeId: "queue" }; + expect( + getInteractiveTool({ toolName: "mutate", toolCallId: "waiting", input }, [ + waiting, + ]), + ).toBeDefined(); + expect( + getInteractiveTool({ toolName: "mutate", toolCallId: "earlier", input }, [ + waiting, + ]), + ).toBeUndefined(); + }); + + test("validates selected calls when resolving them, not during lookup", () => { + const conditional = definePetrinautAiInteractiveTool({ + toolName: "confirmRelease", + inputSchema: { + parse: (): { question: string } => { + throw new Error("Expected a question"); + }, + }, + outputSchema: { parse: (output: unknown) => output }, + shouldHandle: () => true, + component: () => null, + }); + expect( + getInteractiveTool(toolCall("confirmRelease", {}), [conditional]), + ).toBeDefined(); + expect(() => + resolveDynamicInteractiveTool(toolCall("confirmRelease", {}), [ + conditional, + ]), + ).toThrow("Expected a question"); + }); + test("resolves and validates a registered dynamic host tool", () => { const definition = resolveDynamicInteractiveTool( - "confirmRelease", - { question: "Ship this change?" }, + toolCall("confirmRelease", { question: "Ship this change?" }), [hostTool], ); @@ -73,8 +143,7 @@ describe("interactive tool registry", () => { component: () => null, }); const definition = resolveDynamicInteractiveTool( - "answerQuestion", - { question: "Which environment?" }, + toolCall("answerQuestion", { question: "Which environment?" }), [mappedTool], ); @@ -100,8 +169,7 @@ describe("interactive tool registry", () => { component: () => null, }); const invalidDefinition = resolveDynamicInteractiveTool( - "invalidAnswer", - {}, + toolCall("invalidAnswer", {}), [invalidOutputTool], ); @@ -112,8 +180,7 @@ describe("interactive tool registry", () => { test("does not map composer text when the host omits the mapper", () => { const definition = resolveDynamicInteractiveTool( - "confirmRelease", - { question: "Ship this change?" }, + toolCall("confirmRelease", { question: "Ship this change?" }), [hostTool], ); @@ -122,16 +189,37 @@ describe("interactive tool registry", () => { test("rejects an unregistered dynamic tool by name", () => { expect(() => - resolveDynamicInteractiveTool("missingHostTool", {}, [hostTool]), + resolveDynamicInteractiveTool(toolCall("missingHostTool", {}), [ + hostTool, + ]), ).toThrow("Unknown AI tool: missingHostTool"); }); + test("reports a registered host tool that declines a call as declined", () => { + const declining = definePetrinautAiInteractiveTool({ + toolName: "confirmRelease", + inputSchema: { parse: (input: unknown) => input }, + outputSchema: { parse: (output: unknown) => output }, + shouldHandle: () => false, + component: () => null, + }); + expect(() => + resolveDynamicInteractiveTool(toolCall("confirmRelease", {}), [ + declining, + ]), + ).toThrow( + "AI tool confirmRelease was declined by the host for call confirmRelease-call", + ); + }); + test("preserves the built-in applyAutoLayout branching behavior", () => { expect( - getInteractiveTool("applyAutoLayout", { askUserFirst: true }, [hostTool]), + getInteractiveTool(toolCall("applyAutoLayout", { askUserFirst: true }), [ + hostTool, + ]), ).toBeDefined(); expect( - getInteractiveTool("applyAutoLayout", { askUserFirst: false }, [ + getInteractiveTool(toolCall("applyAutoLayout", { askUserFirst: false }), [ hostTool, ]), ).toBeUndefined(); @@ -146,7 +234,7 @@ describe("interactive tool registry", () => { }); expect(() => - getInteractiveTool("applyAutoLayout", { askUserFirst: true }, [ + getInteractiveTool(toolCall("applyAutoLayout", { askUserFirst: true }), [ conflictingTool, ]), ).toThrow("conflicts with a built-in tool"); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts index 379f34da8e5..b4d188dbdf8 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts @@ -3,7 +3,7 @@ import { applyAutoLayoutInteractiveTool } from "./apply-auto-layout-widget"; import type { PetrinautAiInteractiveTool } from "../../../../../types/ai-interactive-tool"; import type { AiToolOutput } from "../tool-summaries"; -import type { InteractiveToolDefinition } from "./types"; +import type { InteractiveToolCall, InteractiveToolDefinition } from "./types"; /** * Registry of AI tools that require an inline chat widget for user input. @@ -27,8 +27,7 @@ export const interactiveTools: Record< }; export const getInteractiveTool = ( - toolName: string, - input: unknown, + { toolName, toolCallId, input }: InteractiveToolCall, hostTools: readonly PetrinautAiInteractiveTool[] = [], ): InteractiveToolDefinition | undefined => { const builtInDescriptor = interactiveTools[toolName]; @@ -60,7 +59,8 @@ export const getInteractiveTool = ( ? { toolName: hostDefinition.toolName, placement: hostDefinition.placement, - shouldHandle: () => true, + shouldHandle: (_input, call) => + hostDefinition.shouldHandle?.(call) ?? true, parseInput: hostDefinition.parseInput, parseOutput: hostDefinition.parseOutput, fromComposerText: hostDefinition.fromComposerText, @@ -70,24 +70,27 @@ export const getInteractiveTool = ( if (!descriptor) { return undefined; } - return descriptor.shouldHandle(input) ? descriptor : undefined; + return descriptor.shouldHandle(input, { toolCallId }) + ? descriptor + : undefined; }; /** Resolve a dynamic call only when the host explicitly registered its name. */ export const resolveDynamicInteractiveTool = ( - toolName: string, - input: unknown, + call: InteractiveToolCall, hostTools: readonly PetrinautAiInteractiveTool[], ): InteractiveToolDefinition => { - if (!hostTools.some((tool) => tool.toolName === toolName)) { - throw new Error(`Unknown AI tool: ${toolName}`); + if (!hostTools.some((tool) => tool.toolName === call.toolName)) { + throw new Error(`Unknown AI tool: ${call.toolName}`); } - const descriptor = getInteractiveTool(toolName, input, hostTools); + const descriptor = getInteractiveTool(call, hostTools); if (!descriptor) { - throw new Error(`Unknown AI tool: ${toolName}`); + throw new Error( + `AI tool ${call.toolName} was declined by the host for call ${call.toolCallId}`, + ); } - descriptor.parseInput(input); + descriptor.parseInput(call.input); return descriptor; }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts index 598ef0cb0f3..5cea8c33d02 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts @@ -9,6 +9,13 @@ import type { ComponentType } from "react"; export type InteractiveToolWidgetProps = PetrinautAiInteractiveToolWidgetProps; +/** A tool call as the interactive-tool registry sees it. */ +export type InteractiveToolCall = { + toolName: string; + toolCallId: string; + input: unknown; +}; + /** * Descriptor for an AI tool that requires synchronous user input rendered * inline in the chat. The registry maps tool names to a definition; the panel @@ -25,7 +32,7 @@ export type InteractiveToolDefinition = { * input shape (e.g. `applyAutoLayout` is interactive only when * `askUserFirst: true`). */ - shouldHandle: (input: unknown) => boolean; + shouldHandle: (input: unknown, call: { toolCallId: string }) => boolean; /** Parse the raw input into the widget's typed input. */ parseInput: (raw: unknown) => Input; /** Parse the widget's output before submitting it to the AI SDK. */