From 0163d473fb70431fbc92ecd95128a7f9fc62378c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 11 Sep 2026 16:07:59 +0200 Subject: [PATCH 1/6] Run experiments from Petrinaut AI chat --- .changeset/experiment-ai-tool.md | 5 + .changeset/small-experiments-chat.md | 5 + libs/@hashintel/petrinaut-core/src/ai.ts | 7 + libs/@hashintel/petrinaut-core/src/index.ts | 1 + .../@hashintel/petrinaut/docs/ai-assistant.md | 39 +- libs/@hashintel/petrinaut/docs/experiments.md | 20 +- .../src/ui/views/Editor/editor-view.test.tsx | 130 ++++++ .../src/ui/views/Editor/editor-view.tsx | 35 +- .../Editor/panels/ai-assistant-panel.test.tsx | 256 ++++++++++- .../Editor/panels/ai-assistant-panel.tsx | 90 +++- .../panels/ai-assistant-panel/README.md | 14 + .../ai-assistant-contents.tsx | 23 + .../experiment-card.test.tsx | 116 +++++ .../ai-assistant-contents/experiment-card.tsx | 432 ++++++++++++++++++ .../get-message-render-items.ts | 8 + .../format-diagnostics-for-ai.test.ts | 4 +- .../format-diagnostics-for-ai.ts | 2 +- .../Editor/panels/ai-assistant-panel/types.ts | 7 + .../content/ai/experiment-chat.mdx | 27 ++ .../content/diagrams/ai-experiment-chat.d2 | 12 + .../experiments/ai-created-experiments.mdx | 7 +- 21 files changed, 1188 insertions(+), 52 deletions(-) create mode 100644 .changeset/experiment-ai-tool.md create mode 100644 .changeset/small-experiments-chat.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx create mode 100644 libs/@local/petrinaut-arch-docs/content/ai/experiment-chat.mdx create mode 100644 libs/@local/petrinaut-arch-docs/content/diagrams/ai-experiment-chat.d2 diff --git a/.changeset/experiment-ai-tool.md b/.changeset/experiment-ai-tool.md new file mode 100644 index 00000000000..0778715ee05 --- /dev/null +++ b/.changeset/experiment-ai-tool.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Register the createExperiment tool with its validated request schema for AI integrations. diff --git a/.changeset/small-experiments-chat.md b/.changeset/small-experiments-chat.md new file mode 100644 index 00000000000..a01f78913ff --- /dev/null +++ b/.changeset/small-experiments-chat.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Run experiments and optimizations from AI chat with progress, cancellation, and a link to metric distributions. Keep the conversation open while inspecting experiments. diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 6fbc5884c9e..5e546df1092 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -4,6 +4,7 @@ import { mutationActionInputSchemas, type MutationActionName, } from "./action-schemas"; +import { petrinautExperimentRequestSchema } from "./ai/experiments"; import { aiCommandActionInputSchemas, type AiCommandActionName, @@ -93,6 +94,7 @@ export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; export const getNetCompilationErrorsToolName = "getNetCompilationErrors"; export const setNetTitleToolName = "setNetTitle"; export const readPetrinautDocToolName = "readPetrinautDoc"; +export const createExperimentToolName = "createExperiment"; export const petrinautDocNames = [ "drawing-a-net", @@ -182,6 +184,7 @@ export const petrinautAiToolInputSchemas = { [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, [setNetTitleToolName]: setNetTitleToolInputSchema, [readPetrinautDocToolName]: readPetrinautDocToolInputSchema, + [createExperimentToolName]: petrinautExperimentRequestSchema, }; export const petrinautAiMutationTools = createToolBundle( @@ -211,6 +214,10 @@ export const petrinautAiTools = { description: getSchemaDescription(readPetrinautDocToolInputSchema), inputSchema: readPetrinautDocToolInputSchema, }, + [createExperimentToolName]: { + description: getSchemaDescription(petrinautExperimentRequestSchema), + inputSchema: petrinautExperimentRequestSchema, + }, } satisfies PetrinautAiTools; export type PetrinautAiToolName = keyof typeof petrinautAiTools; diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 03f5ae7b12f..5794c6d02b9 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -202,6 +202,7 @@ export { colorSchema, componentInstanceSchema, createPetrinautAiWritableCallbacks, + createExperimentToolName, differentialEquationSchema, getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index b828d8223ff..4ba938580e0 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -1,6 +1,6 @@ # AI Assistant -Petrinaut has an in-app AI assistant that can build a net from a natural-language description, review or revise an existing one, read TypeScript compilation diagnostics, and consult its own user-guide pages to answer "how do I ..." questions. The host application controls whether the assistant is available -- it is enabled on [demo.petrinaut.org](https://demo.petrinaut.org) and in [HASH](https://hash.ai) and may or may not be enabled in other Petrinaut embeds. +Petrinaut has an in-app AI assistant that can build a net from a natural-language description, review or revise an existing one, read TypeScript compilation diagnostics, run experiments, and consult its own user-guide pages to answer "how do I ..." questions. The host application controls whether the assistant and its tools are available -- it is enabled on [demo.petrinaut.org](https://demo.petrinaut.org) and in [HASH](https://hash.ai) and may or may not be enabled in other Petrinaut embeds. ## Opening the panel @@ -10,7 +10,13 @@ There are two entry points: 2. **File → New → Build with Brunch**. When the host enables its experimental Brunch demo mode, shows net-management controls, and provides an assistant, this creates a fresh empty net, opens the assistant, and offers two chips before the first message: **Interview first** (elicit before inventing missing detail or drawing the net) or **Quick preview** (fill reasonable gaps, mark them as provisional, and wait for assent). **Start blank** creates the same empty net without opening the assistant or showing the first-run prompt. Without that host flag, **New** retains its ordinary direct blank-net behavior. 3. **First-run prompt**. When you load Petrinaut against an empty net, a centred prompt appears. Type a description and its trailing action becomes **Send**; select it to open the panel with your message already in flight. When the host provides Voice mode, the empty prompt instead shows a waveform action titled **Start voice mode**. It opens the same assistant without creating an empty text message. Dismiss the prompt with the **X**, by clicking outside it, or by pressing **Escape**; it is hidden for the rest of the session once dismissed. -The assistant panel only renders in **Edit** mode. Switching to **Simulate** mode hides it; switch back to **Edit** to continue the conversation. The panel resizes by dragging its left edge. Text and voice share the **AI** transcript. Some hosts add a second tab, such as **Workpiece**, for a saved document. Select a tab to switch views, or use the left/right arrow keys while a tab is focused. Switching does not end a response, clear your draft or interrupt Voice; the composer and active controls remain available. +The assistant panel stays open when you switch to **Simulate** or **Notebook** +mode, so you can inspect an experiment while its chat result arrives. The panel +resizes by dragging its left edge. Text and voice share the **AI** transcript. +Some hosts add a second tab, such as **Workpiece**, for a saved document. +Select a tab to switch views, or use the left/right arrow keys while a tab is +focused. Switching does not end a response, clear your draft or interrupt Voice; +the composer and active controls remain available. ## The conversation @@ -168,6 +174,30 @@ For example, the Petrinaut website's worked-model route displays its template ti After applying changes, the assistant may automatically check TypeScript compile diagnostics (you'll see a **Checked net compilation errors** card) and fix problems on its own before continuing. +## Experiments from chat + +When your host enables experiment tools, ask the assistant to run a saved +[scenario](scenarios.md) and measure one or more saved metrics. +For example: "Run 100 simulations of this scenario and show the completed +orders metric." The assistant can also search numeric scenario parameter +ranges to minimize or maximize a metric. + +The experiment appears in a compact card with its status, run count, and +results. Simulation cards use blue; optimization cards use purple and glow +while running. Select **View +experiment** to inspect metric distributions in the Experiments drawer. The +heatmap shows how values spread across runs; click a time step to see its +histogram. Select **Cancel** +to stop its work. The assistant receives the +result when the requested work finishes; an optimization includes the final +runs at its best parameter values. + +While an AI experiment runs, its compute-changing controls are locked. You +can inspect its charts or cancel it. After completion you can explore its +parameters again; the result already recorded in chat stays unchanged. +Experiments run in your current browser session, so keep the page open until +they finish. See [Experiments](experiments.md#experiments-created-by-the-assistant). + ## Read-only behaviour Whether the assistant can change the net depends on the editor state: @@ -182,8 +212,9 @@ The composer stays open in all of these cases, so you can still ask questions, r The assistant can request a fresh TypeScript check of the current net and use the returned errors to revise its code. An unchanged set of errors still counts as a completed check. If checking fails, the assistant receives an -error. The bottom **Diagnostics** tab continues to show diagnostics for the -code you are editing. +error. Experiment creation also checks its selected +scenario and metrics before running. The bottom **Diagnostics** tab continues +to show diagnostics for the code you are editing. ## Host configuration diff --git a/libs/@hashintel/petrinaut/docs/experiments.md b/libs/@hashintel/petrinaut/docs/experiments.md index c2e53c562f1..d0aac65fa4f 100644 --- a/libs/@hashintel/petrinaut/docs/experiments.md +++ b/libs/@hashintel/petrinaut/docs/experiments.md @@ -11,19 +11,23 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo 3. Fill in the configuration (see below). 4. Click **Run** -- **Create sweep** when a value is swept, **Optimize** when the in-browser optimizer will search it. The button reads **Starting** (or **Creating**) while the experiment starts. -### Experiments started by an integration +### Experiments created by the assistant -An application embedding Petrinaut can start an experiment with a saved -scenario and metrics, including a search over numeric parameter ranges. -These experiments appear in the Experiments list. +If your host enables experiment tools, the [AI assistant](ai-assistant.md#experiments-from-chat) +can run a saved scenario with saved metrics and fixed parameter values. It +can also optimize numeric parameter ranges. These requests create ordinary +experiments in this list and show a progress card in chat. Each request supports up to 100,000 simulation runs, or 100,000 final runs at the best parameter values after optimization. -While the request runs, parameter changes and removal are locked. You can -inspect the charts. Controls become available when the request finishes. -Later exploration leaves the result returned to the application unchanged. +While the assistant's request runs, parameter changes and removal are locked. +You can inspect the charts and cancel the experiment. When it finishes, the +assistant receives its results and the controls become available again. +Later exploration does not change the result already recorded in chat. + An optimization finishes after its search and the final runs at its best -parameter values. Keep the browser page open until the request finishes. +parameter values. Keep the browser page open until the request finishes; +experiments are not restored after a reload. ### Configuration diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx new file mode 100644 index 00000000000..c6b8324a96f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx @@ -0,0 +1,130 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from "@testing-library/react"; +import { use, useEffect } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + EditorContext, + type EditorGlobalMode, +} from "../../../react/state/editor-context"; +import { UserSettingsContext } from "../../../react/state/user-settings-context"; +import { EditorView } from "./editor-view"; + +import type { PetrinautAiAssistant } from "../../petrinaut"; +import type { UIMessageChunk } from "ai"; + +const lifecycle = vi.hoisted(() => ({ + mount: vi.fn(), + cancelPendingRequest: vi.fn(), +})); + +vi.mock("../../../react", () => ({ + usePetrinautCommands: () => ({ applyAutoLayout: vi.fn() }), +})); +vi.mock("../../../react/state/use-selection-cleanup", () => ({ + useSelectionCleanup: () => {}, +})); +vi.mock("../../../react/state/use-is-read-only", () => ({ + useIsReadOnly: () => false, +})); +vi.mock("./panels/ai-assistant-panel", () => ({ + AiAssistantPanel: () => { + useEffect(() => { + lifecycle.mount(); + return lifecycle.cancelPendingRequest; + }, []); + return
Pending experiment
; + }, +})); +vi.mock("./panels/SimulateView/simulate-view", () => ({ + SimulateView: () => ( +
Experiment results
+ ), +})); +vi.mock("../Notebook/notebook-view", () => ({ + NotebookView: () =>
, +})); +vi.mock("../SDCPN/sdcpn-view", () => ({ SDCPNView: () => null })); +vi.mock("./panels/LeftSideBar/panel", () => ({ LeftSideBar: () => null })); +vi.mock("./panels/PropertiesPanel/panel", () => ({ + PropertiesPanel: () => null, +})); +vi.mock("./panels/BottomPanel/panel", () => ({ BottomPanel: () => null })); +vi.mock("./components/BottomBar/bottom-bar", () => ({ BottomBar: () => null })); +vi.mock("./components/TopBar/top-bar", () => ({ TopBar: () => null })); +vi.mock("./components/ai-cta-modal", () => ({ AiCtaModal: () => null })); +vi.mock("./components/import-error-dialog", () => ({ + ImportErrorDialog: () => null, +})); +vi.mock("../../components/walkthrough/walkthrough-dialog", () => ({ + WalkthroughDialog: () => null, +})); +vi.mock("./simulation-creation-drawer", () => ({ + SimulationCreationDrawer: () => null, +})); +vi.mock("./use-editor-commands", () => ({ EditorCommands: () => null })); + +const aiAssistant: PetrinautAiAssistant = { + transport: { + reconnectToStream: () => Promise.resolve(null), + sendMessages: () => + Promise.resolve( + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + ), + }, +}; + +const EditorAtMode = ({ mode }: { mode: EditorGlobalMode }) => { + const editor = use(EditorContext); + const settings = use(UserSettingsContext); + return ( + + + + + + ); +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("EditorView assistant lifecycle", () => { + test("keeps the pending assistant mounted when opening experiment results and changing modes", () => { + const { rerender, unmount } = render(); + const assistant = screen.getByRole("region", { name: "AI assistant" }); + expect(lifecycle.mount).toHaveBeenCalledTimes(1); + + rerender(); + expect(screen.getByRole("region", { name: "Experiments" })).toBeTruthy(); + expect(screen.getByRole("region", { name: "AI assistant" })).toBe( + assistant, + ); + + rerender(); + expect(screen.getByRole("region", { name: "Notebook" })).toBeTruthy(); + expect(screen.getByRole("region", { name: "AI assistant" })).toBe( + assistant, + ); + + rerender(); + expect(screen.getByRole("region", { name: "AI assistant" })).toBe( + assistant, + ); + expect(lifecycle.mount).toHaveBeenCalledTimes(1); + expect(lifecycle.cancelPendingRequest).not.toHaveBeenCalled(); + + unmount(); + expect(lifecycle.cancelPendingRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 4f1318d4597..5552eb3a813 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -100,6 +100,7 @@ const formatRelativeTime = (isoTimestamp: string): string => { // overflow, scrollIntoView can still scroll it programmatically — pushing the // TopBar out of view. const rowContainerStyle = css({ + position: "relative", flex: "[1]", minHeight: "[0]", userSelect: "none", @@ -573,25 +574,25 @@ export const EditorView = ({ onCursorModeChange={setCursorMode} hasAiAssistant={aiAssistant !== undefined} /> - - {aiAssistant && ( - - setPendingAiAssistantMessage(null) - } - onInitialInteractionModeConsumed={() => - setPendingAiInteractionMode(null) - } - /> - )} )} + + {aiAssistant && ( + + setPendingAiAssistantMessage(null) + } + onInitialInteractionModeConsumed={() => + setPendingAiInteractionMode(null) + } + /> + )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx index 81cd533b4bf..47e3b9ce281 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx @@ -16,12 +16,16 @@ import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { DEFAULT_PETRINAUT_EXTENSIONS, createJsonDocHandle, + type PetrinautExperimentHost, + type PetrinautExperimentRequest, + type PetrinautExperimentResult, createPetrinaut, getLatestNetDefinitionToolName, setNetTitleToolName, type SDCPN, } from "@hashintel/petrinaut-core"; +import { AiExperimentsContext } from "../../../../react/ai-experiments/context"; import { type ErrorTracker, ErrorTrackerContext, @@ -236,6 +240,7 @@ const renderTestPanel = ({ total: 0, errorCount: 0, }), + experimentHost, }: { aiAssistant: PetrinautAiAssistant; editorContext?: EditorContextValue; @@ -247,6 +252,7 @@ const renderTestPanel = ({ strictMode?: boolean; titleEditable?: boolean; requestDiagnostics?: LanguageClientContextValue["requestDiagnostics"]; + experimentHost?: PetrinautExperimentHost; }) => { const handle = createJsonDocHandle({ id: "ai-assistant-panel-test", @@ -276,20 +282,29 @@ const renderTestPanel = ({ ) => ( - - - - - - - + + Promise.reject(new Error("Experiment host unavailable")), + } + } + > + + + + + + + + ); @@ -5374,3 +5389,216 @@ describe("AiAssistantPanel host interactive tools", () => { } }); }); + +describe("AI experiment requests", () => { + const request: PetrinautExperimentRequest = { + name: "Chat experiment", + scenarioId: "scenario-1", + scenarioParameterValues: {}, + runCount: 8, + seed: 42, + dt: 0.1, + maxTime: 10, + metricIds: ["metric-1"], + execution: { mode: "simulate" }, + }; + const result: PetrinautExperimentResult = { + status: "complete", + experimentId: "experiment-1", + name: request.name, + runsCompleted: 8, + metrics: [{ id: "metric-1", label: "Count", value: 12 }], + }; + const createTransport = () => { + const sendMessages = vi.fn(); + sendMessages.mockImplementationOnce(async () => + streamChunks([ + { type: "start-step" }, + { + type: "tool-input-available", + toolCallId: "experiment-call", + toolName: "createExperiment", + input: request, + }, + { type: "finish-step" }, + { type: "finish", finishReason: "tool-calls" }, + ]), + ); + sendMessages.mockImplementation(async () => + streamChunks(textChunks("done", "Result received")), + ); + return { + transport: { reconnectToStream: async () => null, sendMessages }, + sendMessages, + }; + }; + + test("shows progress and sends exactly one captured result after completion", async () => { + const completion = Promise.withResolvers(); + const createExperiment = vi.fn( + (input, options) => { + options?.onProgress?.({ + experimentId: "experiment-1", + name: input.name, + phase: "running", + runsCompleted: 3, + runsTarget: 8, + }); + return completion.promise; + }, + ); + const { transport, sendMessages } = createTransport(); + renderTestPanel({ + aiAssistant: { transport }, + initialMessage: "Run an experiment", + experimentHost: { createExperiment }, + }); + + const card = await screen.findByRole("region", { + name: "Experiment: Chat experiment", + }); + await waitFor(() => + expect(within(card).getByText("3 of 8 runs")).not.toBeNull(), + ); + expect(sendMessages).toHaveBeenCalledTimes(1); + await act(async () => completion.resolve(result)); + await screen.findByText("Result received"); + expect(within(card).getByText("Finished")).not.toBeNull(); + expect(within(card).getByText("Count")).not.toBeNull(); + expect(within(card).getByText("12")).not.toBeNull(); + expect(sendMessages).toHaveBeenCalledTimes(2); + expect( + sendMessages.mock.calls[1]?.[0].messages.flatMap( + (message) => message.parts, + ), + ).toContainEqual( + expect.objectContaining({ + type: "tool-createExperiment", + output: result, + }), + ); + }); + + test("cancels browser computation through the experiment card", async () => { + const createExperiment = vi.fn( + (input, options) => + new Promise((resolve) => { + options?.onProgress?.({ + experimentId: "experiment-1", + name: input.name, + phase: "running", + runsCompleted: 0, + runsTarget: 8, + }); + options?.signal?.addEventListener("abort", () => + resolve({ + ...result, + status: "cancelled", + runsCompleted: 0, + metrics: [], + }), + ); + }), + ); + const { transport } = createTransport(); + renderTestPanel({ + aiAssistant: { transport }, + initialMessage: "Run an experiment", + experimentHost: { createExperiment }, + }); + const card = await screen.findByRole("region", { + name: "Experiment: Chat experiment", + }); + await waitFor(() => expect(createExperiment).toHaveBeenCalledOnce()); + fireEvent.click(within(card).getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(within(card).getByText("Cancelled")).not.toBeNull(), + ); + expect(createExperiment.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + }); + + test("ignores a late experiment from a replaced conversation with the same tool ID", async () => { + const previous = Promise.withResolvers(); + const current = Promise.withResolvers(); + const createExperiment = + vi.fn(); + createExperiment.mockImplementationOnce(() => previous.promise); + createExperiment.mockImplementationOnce((input, options) => { + options?.onProgress?.({ + experimentId: "experiment-2", + name: input.name, + phase: "running", + runsCompleted: 3, + runsTarget: 8, + }); + options?.signal?.addEventListener("abort", () => + current.resolve({ + ...result, + experimentId: "experiment-2", + name: input.name, + status: "cancelled", + runsCompleted: 3, + metrics: [], + }), + ); + return current.promise; + }); + const sendMessages = vi.fn(async () => + streamChunks(textChunks("result", "Result received")), + ); + const config = (conversationId: string): PetrinautAiAssistant => ({ + conversationId, + transport: { reconnectToStream: async () => null, sendMessages }, + messages: [ + { + id: `${conversationId}-request`, + role: "assistant", + parts: [ + { + type: "tool-createExperiment", + toolCallId: "reused-experiment-call", + state: "input-available", + input: { ...request, name: conversationId }, + }, + ], + }, + ], + }); + const { rerenderPanel } = renderTestPanel({ + aiAssistant: config("Previous experiment"), + experimentHost: { createExperiment }, + }); + await waitFor(() => expect(createExperiment).toHaveBeenCalledOnce()); + rerenderPanel(config("Current experiment")); + await waitFor(() => expect(createExperiment).toHaveBeenCalledTimes(2)); + expect(createExperiment.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + const card = await screen.findByRole("region", { + name: "Experiment: Current experiment", + }); + + await act(async () => { + createExperiment.mock.calls[0]?.[1]?.onProgress?.({ + experimentId: "experiment-1", + name: "Previous experiment", + phase: "running", + runsCompleted: 7, + runsTarget: 8, + }); + previous.resolve({ ...result, name: "Previous experiment" }); + await previous.promise; + }); + expect(within(card).getByText("3 of 8 runs")).not.toBeNull(); + expect(within(card).queryByText("Finished")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Experiment: Previous experiment" }), + ).toBeNull(); + expect(sendMessages).not.toHaveBeenCalled(); + + fireEvent.click(within(card).getByRole("button", { name: "Cancel" })); + expect(createExperiment.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + await waitFor(() => + expect(within(card).getByText("Cancelled")).not.toBeNull(), + ); + await waitFor(() => expect(sendMessages).toHaveBeenCalledOnce()); + }); +}); 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 3f86c40d2f1..25795bf2103 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 @@ -18,6 +18,10 @@ import { import { aiCommandActionInputSchemas, + createExperimentToolName, + petrinautExperimentRequestSchema, + type PetrinautExperimentProgress, + type PetrinautExperimentResult, type AiCommandActionName, getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, @@ -30,6 +34,7 @@ import { setNetTitleToolName, } from "@hashintel/petrinaut-core"; +import { AiExperimentsContext } from "../../../../react/ai-experiments/context"; import { ErrorTrackerContext } from "../../../../react/error-tracker-context"; import { useLatest } from "../../../../react/hooks/use-latest"; import { PetrinautInstanceContext } from "../../../../react/instance-context"; @@ -507,9 +512,28 @@ const ConversationAiAssistantPanel = ({ const { requestDiagnostics } = use(LanguageClientContext); const requestDiagnosticsRef = useLatest(requestDiagnostics); + const experimentHost = use(AiExperimentsContext); + const experimentControllersRef = useRef(new Map()); + const [experimentStates, setExperimentStates] = useState< + Record< + string, + { + progress?: PetrinautExperimentProgress; + result?: PetrinautExperimentResult; + } + > + >({}); + useEffect(() => { + const controllers = experimentControllersRef.current; + return () => { + for (const controller of controllers.values()) controller.abort(); + controllers.clear(); + }; + }, []); const { hasSelection, + globalMode, isAiAssistantOpen, navigateTo, propertiesPanelWidth, @@ -881,6 +905,48 @@ const ConversationAiAssistantPanel = ({ return; } + if (toolCall.toolName === createExperimentToolName) { + const request = petrinautExperimentRequestSchema.parse(toolCall.input); + const controller = new AbortController(); + experimentControllersRef.current.set(toolCall.toolCallId, controller); + const isCurrentRequest = () => + generation === submissionGenerationRef.current && + executionConversationId === toolHostIdentityRef.current && + experimentControllersRef.current.get(toolCall.toolCallId) === + controller; + try { + const result = await experimentHost.createExperiment(request, { + signal: controller.signal, + onProgress: (progress) => { + if (!isCurrentRequest()) return; + setExperimentStates((states) => ({ + ...states, + [toolCall.toolCallId]: { progress }, + })); + }, + }); + if (isCurrentRequest()) { + setExperimentStates((states) => ({ + ...states, + [toolCall.toolCallId]: { result }, + })); + } + await addAutomaticToolOutput({ + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + output: result, + }); + } finally { + if ( + experimentControllersRef.current.get(toolCall.toolCallId) === + controller + ) { + experimentControllersRef.current.delete(toolCall.toolCallId); + } + } + return; + } + if (toolCall.toolName === getLatestNetDefinitionToolName) { await addAutomaticToolOutput({ tool: toolCall.toolName, @@ -1253,6 +1319,10 @@ const ConversationAiAssistantPanel = ({ const submissionConversationIdRef = useRef(conversationId); useLayoutEffect(() => { if (submissionConversationIdRef.current === conversationId) return; + for (const controller of experimentControllersRef.current.values()) + controller.abort(); + experimentControllersRef.current.clear(); + setExperimentStates({}); submissionConversationIdRef.current = conversationId; followedMessagesRef.current = undefined; locallyStreamedToolCallsRef.current.clear(); @@ -1751,6 +1821,8 @@ const ConversationAiAssistantPanel = ({ const generation = submissionGenerationRef.current; automaticToolTerminationRef.current = { generation, kind: "stopped" }; abortAutomaticTools(); + for (const controller of experimentControllersRef.current.values()) + controller.abort(); stopRequestedRef.current = true; if (requestStop !== undefined) { try { @@ -1962,9 +2034,6 @@ const ConversationAiAssistantPanel = ({ stop: stopComposer, submitText, }; - /* eslint-disable react-hooks-js/refs -- The public render prop receives - stable event callbacks that read their refs only when the host invokes - them from an event handler or effect. */ const composerControl = aiAssistant.renderComposerControl?.( composerControlContext, ); @@ -1979,7 +2048,6 @@ const ConversationAiAssistantPanel = ({ setVoiceActive, submitVoiceInput, }); - /* eslint-enable react-hooks-js/refs */ return ( + experimentControllersRef.current.get(toolCallId)?.abort() + } input={input} inputMode={interactionMode} interactiveTools={aiAssistant.interactiveTools} @@ -1997,6 +2069,10 @@ const ConversationAiAssistantPanel = ({ messages={messages} onClearMessages={() => { abortAutomaticTools(); + for (const controller of experimentControllersRef.current.values()) + controller.abort(); + experimentControllersRef.current.clear(); + setExperimentStates({}); submissionGenerationRef.current += 1; // Clearing aborts any in-flight response too, which fires `onFinish` // with `isAbort`. Drop the stop flag first so that handler treats this @@ -2108,7 +2184,11 @@ const ConversationAiAssistantPanel = ({ onSubmit={submitComposerInput} onVoiceDockCollapsedChange={setVoiceDockCollapsed} promptChips={promptChips} - rightOffset={hasSelection ? propertiesPanelWidth + PANEL_MARGIN : 0} + rightOffset={ + globalMode === "edit" && hasSelection + ? propertiesPanelWidth + PANEL_MARGIN + : 0 + } status={status} stopped={stopped} voiceHandoffPending={voiceHandoffPending} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md new file mode 100644 index 00000000000..7e30dc0d923 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md @@ -0,0 +1,14 @@ +--- +layer: ui.views.editor.ai +role: Renders AI conversations and executes client tools against the editor host +--- + +The assistant renders AI SDK messages and dispatches client tools from the +parent `ai-assistant-panel.tsx`. Document tools call the core instance. +Experiment tools call the browser host and show progress until its final result. +The embedding application supplies transport and conversation persistence. + +See the browser host +for execution and cancellation, and +the chat integration +for tool dispatch and presentation. 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 167e835b509..948acef7b9e 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 @@ -28,6 +28,10 @@ import { HorizontalTabsHeader } from "../../../../components/sub-view/horizontal import { ResizeHandle } from "../../../../resize/resize-handle"; import { AiVoiceModeIcon } from "../../components/ai-voice-mode-button"; import { voiceSetupLabels } from "../../components/voice-session-labels"; +import { + ExperimentCard, + type AiExperimentState, +} from "./ai-assistant-contents/experiment-card"; import { aiFooterMinHeight } from "./ai-assistant-contents/footer-height"; import { getMessageRenderItems } from "./ai-assistant-contents/get-message-render-items"; import { @@ -64,6 +68,8 @@ export type AiAssistantContentsProps = { composerControl?: ReactNode; composerFocusRequest?: number; error?: Error; + experimentStates?: Record; + onCancelExperiment?: (toolCallId: string) => void; input: string; inputMode?: PetrinautAiInputMode; interactiveTools?: readonly PetrinautAiInteractiveTool[]; @@ -431,10 +437,14 @@ const AiAssistantMessage = memo( handlersRef, interactiveTools, message, + experimentStates, + onCancelExperiment, }: { handlersRef: MessageHandlersRef; interactiveTools: readonly PetrinautAiInteractiveTool[]; message: PetrinautAiMessage; + experimentStates?: Record; + onCancelExperiment?: (toolCallId: string) => void; }) => { const role = message.role === "user" ? "user" : "assistant"; const renderItems = getMessageRenderItems(message, interactiveTools); @@ -474,6 +484,15 @@ const AiAssistantMessage = memo( part={item.part} /> ); + case "experiment": + return ( + + ); case "tools": return ( ))} {stopped && !error && !messages.at(-1)?.metadata?.stopped && ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx new file mode 100644 index 00000000000..86158bcf419 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx @@ -0,0 +1,116 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; + +import { ExperimentCard } from "./experiment-card"; + +import type { ExperimentToolPart } from "./experiment-card"; +import type { PetrinautExperimentResult } from "@hashintel/petrinaut-core"; + +afterEach(cleanup); + +const part: ExperimentToolPart = { + type: "tool-createExperiment", + toolCallId: "tool", + state: "input-available", + input: { + name: "Population", + scenarioId: "scenario", + scenarioParameterValues: {}, + runCount: 8, + seed: 1, + dt: 1, + maxTime: 10, + metricIds: ["count"], + execution: { mode: "simulate" }, + }, +}; +const result: PetrinautExperimentResult = { + status: "complete", + experimentId: "experiment", + name: "Population", + runsCompleted: 8, + metrics: [{ id: "count", label: "Count", value: 12 }], +}; + +it("reports actual run progress and removes active indicators when finished", () => { + const progress = { + experimentId: "experiment", + name: "Population", + phase: "running" as const, + runsCompleted: 3, + runsTarget: 8, + }; + const view = render(); + const card = screen.getByRole("region", { name: "Experiment: Population" }); + expect(card.getAttribute("data-tone")).toBe("simulation"); + expect(card.getAttribute("aria-busy")).toBe("true"); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( + "3", + ); + expect(screen.getByRole("progressbar").getAttribute("aria-valuemax")).toBe( + "8", + ); + view.rerender( + , + ); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( + "5", + ); + view.rerender( + , + ); + expect(card.getAttribute("aria-busy")).toBe("false"); + expect(card.getAttribute("data-pending")).toBe("false"); + expect(screen.queryByRole("progressbar")).toBeNull(); + expect(screen.getByRole("status").textContent).toBe("Finished"); +}); + +it("keeps optimization identity while validating and after cancellation", () => { + const optimizationPart: ExperimentToolPart = { + ...part, + input: { + ...part.input, + execution: { + mode: "optimize", + objectiveMetricId: "count", + direction: "maximize", + steps: 3, + runsPerStep: 4, + }, + }, + }; + const view = render(); + const card = screen.getByRole("region", { name: "Experiment: Population" }); + expect(card.getAttribute("data-tone")).toBe("optimization"); + expect(screen.getByRole("status").textContent).toBe("Validating"); + view.rerender( + , + ); + expect(card.getAttribute("data-tone")).toBe("optimization"); + expect(card.getAttribute("data-pending")).toBe("false"); + expect(screen.getByRole("status").textContent).toBe("Cancelled"); +}); + +it("shows a failed tool without a pending validation label or progress", () => { + render( + , + ); + expect(screen.getByRole("status").textContent).toBe("Failed"); + expect(screen.queryByText("Checking the model")).toBeNull(); + expect(screen.queryByRole("progressbar")).toBeNull(); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx new file mode 100644 index 00000000000..0a832c315c3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx @@ -0,0 +1,432 @@ +import { use } from "react"; + +import { Button, Icon } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { ExperimentsContext } from "../../../../../../react/experiments/context"; +import { EditorContext } from "../../../../../../react/state/editor-context"; + +import type { PetrinautAiMessage } from "../types"; +import type { + PetrinautExperimentProgress, + PetrinautExperimentResult, +} from "@hashintel/petrinaut-core"; + +export type ExperimentToolPart = Extract< + PetrinautAiMessage["parts"][number], + { type: "tool-createExperiment" } +>; + +export type AiExperimentState = { + progress?: PetrinautExperimentProgress; + result?: PetrinautExperimentResult; +}; + +const cardStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2.5", + padding: "2.5", + borderRadius: "xl", + border: "[1px solid]", + borderColor: "blue.a30", + backgroundColor: "blue.s10", + backgroundImage: + "[linear-gradient(135deg, var(--colors-blue-a10), transparent 70%)]", + fontSize: "sm", + color: "neutral.s90", + transition: "[border-color 200ms ease, box-shadow 200ms ease]", + "&[data-pending=true]": { + boxShadow: + "[0 0 0 1px var(--colors-blue-a15), 0 0 14px var(--colors-blue-a20)]", + }, + "&[data-tone=optimization]": { + borderColor: "purple.a30", + backgroundColor: "purple.s10", + backgroundImage: + "[linear-gradient(135deg, var(--colors-purple-a10), transparent 70%)]", + boxShadow: "[0 2px 12px var(--colors-purple-a10)]", + }, + "&[data-tone=optimization][data-pending=true]": { + animationName: "[petrinautOptimizingGlow]", + animationDuration: "[2.8s]", + animationTimingFunction: "ease-in-out", + animationIterationCount: "[infinite]", + }, + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + "&[data-tone=optimization][data-pending=true]": { + animationName: "[none]", + boxShadow: + "[0 0 0 1px var(--colors-purple-a15), 0 0 14px var(--colors-purple-a20)]", + }, + }, +}); + +const headerStyle = css({ + display: "flex", + alignItems: "flex-start", + gap: "2", +}); +const iconStyle = css({ + display: "grid", + placeItems: "center", + width: "[28px]", + height: "[28px]", + marginTop: "0.5", + flexShrink: "0", + borderRadius: "lg", + color: "blue.s100", + backgroundColor: "blue.a15", + "[data-tone=optimization] &": { + color: "purple.s100", + backgroundColor: "purple.a15", + }, +}); +const kindStyle = css({ + fontSize: "xs", + fontWeight: "medium", + color: "blue.s100", + lineHeight: "[16px]", + "[data-tone=optimization] &": { color: "purple.s100" }, +}); +const titleStyle = css({ + display: "block", + fontSize: "sm", + fontWeight: "semibold", + lineHeight: "[20px]", + color: "neutral.s120", + overflowWrap: "anywhere", +}); +const metadataStyle = css({ + display: "flex", + alignItems: "center", + columnGap: "1.5", + flexWrap: "wrap", + "& > :not(:first-child)::before": { + content: '"·"', + marginRight: "1.5", + color: "neutral.s60", + }, +}); +const statusStyle = css({ + display: "inline-flex", + alignItems: "center", + gap: "1.5", + width: "[fit-content]", + padding: "[2px 6px]", + flexShrink: "0", + marginTop: "0.5", + borderRadius: "full", + fontSize: "xs", + fontWeight: "medium", + lineHeight: "[14px]", + color: "blue.s110", + backgroundColor: "blue.a10", + "&[data-status=pending]": { + "[data-tone=optimization] &": { + color: "purple.s110", + backgroundColor: "purple.a10", + }, + }, + "&[data-status=complete]": { + color: "green.s110", + backgroundColor: "green.a10", + }, + "&[data-status=error]": { color: "red.s110", backgroundColor: "red.a10" }, + "&[data-status=cancelled]": { + color: "neutral.s90", + backgroundColor: "neutral.a10", + }, +}); +const statusDotStyle = css({ + width: "[5px]", + height: "[5px]", + borderRadius: "full", + backgroundColor: "[currentColor]", + animationName: "pulse", + animationDuration: "[1.6s]", + animationTimingFunction: "ease-in-out", + animationIterationCount: "[infinite]", + "@media (prefers-reduced-motion: reduce)": { animationName: "[none]" }, +}); +const detailStyle = css({ + fontSize: "xs", + color: "neutral.s90", + fontVariantNumeric: "tabular-nums", + overflowWrap: "anywhere", +}); +const progressTrackStyle = css({ + height: "[5px]", + borderRadius: "full", + overflow: "hidden", + backgroundColor: "blue.a15", + "[data-tone=optimization] &": { backgroundColor: "purple.a15" }, +}); +const progressFillStyle = css({ + height: "[100%]", + borderRadius: "full", + backgroundColor: "blue.s90", + transition: "[width 280ms ease-out]", + "[data-tone=optimization] &": { backgroundColor: "purple.s90" }, + "@media (prefers-reduced-motion: reduce)": { transition: "[none]" }, +}); +const metricsStyle = css({ + display: "flex", + flexWrap: "wrap", + columnGap: "4", + rowGap: "2", + flex: "[1 1 100px]", + minWidth: "[0]", + margin: "0", +}); +const metricStyle = css({ + display: "flex", + flexDirection: "column", + minWidth: "[0]", + overflowWrap: "anywhere", + "& > dt": { fontSize: "xs", color: "neutral.s90", lineHeight: "[16px]" }, + "& > dd": { + margin: "0", + fontSize: "lg", + lineHeight: "[24px]", + fontWeight: "semibold", + fontVariantNumeric: "tabular-nums", + letterSpacing: "[-0.02em]", + color: "neutral.s120", + }, +}); +const actionsStyle = css({ + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: "2", + marginLeft: "[auto]", +}); + +export const ExperimentCard = ({ + part, + state, + onCancel, +}: { + part: ExperimentToolPart; + state?: AiExperimentState; + onCancel?: (toolCallId: string) => void; +}) => { + const { experiments } = use(ExperimentsContext); + const { navigateTo } = use(EditorContext); + const result = + part.state === "output-available" ? part.output : state?.result; + const progress = state?.progress; + const experimentId = result?.experimentId ?? progress?.experimentId; + const name = + result?.name ?? progress?.name ?? part.input?.name ?? "Experiment"; + const errorText = part.state === "output-error" ? part.errorText : undefined; + const pending = !result && !errorText; + const optimization = + part.input?.execution?.mode === "optimize" || + result?.optimization !== undefined || + progress?.phase === "optimizing" || + progress?.phase === "refining"; + const statusKind = errorText ? "error" : (result?.status ?? "pending"); + const status = errorText + ? "Failed" + : result + ? { complete: "Finished", cancelled: "Cancelled", error: "Failed" }[ + result.status + ] + : progress?.phase === "optimizing" + ? "Optimizing" + : progress?.phase === "refining" + ? "Refining" + : progress?.phase === "running" + ? "Running" + : "Validating"; + const progressValue = progress + ? Math.min(progress.runsTarget, Math.max(0, progress.runsCompleted)) + : 0; + const progressPercent = + progress && progress.runsTarget > 0 + ? (progressValue / progress.runsTarget) * 100 + : 0; + const available = + experimentId && + experiments.some((experiment) => experiment.id === experimentId); + + return ( +
+
+ +
+ {name} +
+ + {optimization ? "Optimization" : "Simulation"} + + {(result || pending) && ( + + {result + ? `${result.runsCompleted} runs` + : progress + ? `${progress.runsCompleted} of ${progress.runsTarget} runs` + : "Checking the model"} + + )} +
+
+
+ {pending ? ( +
+
+ {progress && pending && ( +
+
+
+
+ {progress.phase === "optimizing" && + progress.step !== undefined && + progress.steps !== undefined && ( + + Step {progress.step} of {progress.steps} + + )} + {progress.phase === "refining" && ( + Refining the best result + )} +
+ )} + {(result?.message ?? errorText) && ( +

+ {result?.message ?? errorText} +

+ )} + {result?.metrics.length || + available || + (pending && onCancel) || + result?.experimentId ? ( +
+ {result && result.metrics.length > 0 && ( +
+ {result.metrics.map((metric) => ( +
+
{metric.label}
+
+ {metric.value === null + ? "No value" + : metric.value.toLocaleString(undefined, { + maximumSignificantDigits: 6, + })} +
+
+ ))} +
+ )} + {(available || (pending && onCancel) || result?.experimentId) && ( +
+ {available && ( + + )} + {pending && onCancel && ( + + )} + {result?.experimentId && !available && ( + + Results saved in chat; experiment is no longer open. + + )} +
+ )} +
+ ) : null} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts index d7170d4e910..efd0690c338 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/get-message-render-items.ts @@ -8,6 +8,7 @@ import { isToolPart, toToolRenderItem, type ToolRenderItem } from "./tool-list"; import type { PetrinautAiInteractiveTool } from "../../../../../types/ai-interactive-tool"; import type { PetrinautAiMessage } from "../types"; +import type { ExperimentToolPart } from "./experiment-card"; export type MessagePart = PetrinautAiMessage["parts"][number]; export type TextPart = Extract; @@ -16,6 +17,7 @@ export type ReasoningMessagePart = Extract; export type MessageRenderItem = | { type: "reasoning"; key: string; part: ReasoningMessagePart } | { type: "text"; key: string; part: TextPart } + | { type: "experiment"; key: string; part: ExperimentToolPart } | { type: "tools"; key: string; tools: ToolRenderItem[] }; export const isPartActive = ( @@ -67,6 +69,12 @@ export const getMessageRenderItems = ( return; } + if (part.type === "tool-createExperiment") { + flushTools(); + items.push({ type: "experiment", key: part.toolCallId, part }); + return; + } + if (isToolPart(part)) { const tool = toToolRenderItem(message, part, interactiveTools); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts index c8e2a24b72d..50a4c015f03 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts @@ -56,7 +56,9 @@ describe("formatDiagnosticsForAi", () => { definition, diagnosticsByUri: new Map(), }), - ).toBe("No errors or warnings found in net function code."); + ).toBe( + "No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.", + ); }); test("formats transition and differential-equation diagnostics", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts index 0ddaa881f4f..0ed8bdaf469 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts @@ -62,7 +62,7 @@ export const formatDiagnosticsForAi = ({ ); if (diagnostics.length === 0) { - return "No errors or warnings found in net function code."; + return "No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment."; } const shownDiagnostics = diagnostics.slice(0, maxDiagnostics); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts index f7905126ced..03d7d77d3d4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts @@ -1,5 +1,8 @@ import type { AiToolCall, AiToolOutput } from "./tool-summaries"; import type { + createExperimentToolName, + PetrinautExperimentRequest, + PetrinautExperimentResult, getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, PetrinautAiCommandToolInput, @@ -34,6 +37,10 @@ type PetrinautAiUiTools = { output: AiToolOutput; }; } & { + [createExperimentToolName]: { + input: PetrinautExperimentRequest; + output: PetrinautExperimentResult; + }; [getLatestNetDefinitionToolName]: { input: PetrinautAiToolInput; output: { diff --git a/libs/@local/petrinaut-arch-docs/content/ai/experiment-chat.mdx b/libs/@local/petrinaut-arch-docs/content/ai/experiment-chat.mdx new file mode 100644 index 00000000000..04a8a8d01fc --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/ai/experiment-chat.mdx @@ -0,0 +1,27 @@ +--- +title: Experiments in AI chat +description: Dispatch experiment tools and show live progress in the conversation. +attachTo: ui.views.editor.ai +sidebar_order: 20 +--- + +Petrinaut AI dispatches `createExperiment` to the [browser experiment host](doc:experiments/ai-created-experiments). +Its chat card shows live progress while the client tool waits for its final result. + +![Experiment chat flow](@diagrams/ai-experiment-chat.svg) + +The tool definition in `petrinautAiTools` supplies the canonical request schema. +The embedding application admits the tool and preserves its call ID through +transport and continuation. Browser progress updates the existing card; +completion supplies one captured tool result to the same conversation. + +Simulation cards use blue. Optimization cards use purple and glow while +running. Progress transitions respect reduced-motion preferences. **Cancel** +aborts the request; **View experiment** opens the drawer for chart inspection. +The chat stays mounted across Edit, Simulate, and Notebook modes so opening +an experiment preserves the active conversation. + +The [host guide](doc:experiments/ai-created-experiments#execution-and-ownership) +describes input locks, final refinement, result semantics, and browser lifetime. +The [user guide](https://github.com/hashintel/hash/blob/main/libs/%40hashintel/petrinaut/docs/ai-assistant.md#experiments-from-chat) +describes the controls. diff --git a/libs/@local/petrinaut-arch-docs/content/diagrams/ai-experiment-chat.d2 b/libs/@local/petrinaut-arch-docs/content/diagrams/ai-experiment-chat.d2 new file mode 100644 index 00000000000..e21e6bd7871 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/diagrams/ai-experiment-chat.d2 @@ -0,0 +1,12 @@ +vars: {d2-config: {theme-id: 0}} +grid-rows: 2 +grid-columns: 2 +horizontal-gap: 160 +vertical-gap: 120 +transport: "AI transport" +chat: "AI chat\nDispatch + card" +drawer: "Experiment drawer" +host: "Browser host" +transport <-> chat: "call / result + ID" +chat <-> host: "run / cancel\nprogress / result" +chat -> drawer: "open" diff --git a/libs/@local/petrinaut-arch-docs/content/experiments/ai-created-experiments.mdx b/libs/@local/petrinaut-arch-docs/content/experiments/ai-created-experiments.mdx index 3dbc47f4bb2..0bdb83a249c 100644 --- a/libs/@local/petrinaut-arch-docs/content/experiments/ai-created-experiments.mdx +++ b/libs/@local/petrinaut-arch-docs/content/experiments/ai-created-experiments.mdx @@ -1,6 +1,6 @@ --- -title: Browser experiment host -description: Validate and run bounded experiment requests in the browser. +title: AI-created experiments +description: Run experiments from AI chat through the browser host. sidebar_order: 40 attachTo: react.experiment-host --- @@ -11,6 +11,9 @@ captured result. Optimization uses the same request and experiment record. ![Browser experiment architecture](@diagrams/ai-experiments-architecture.svg) +The [AI chat integration](doc:ai/experiment-chat) exposes this host through +`petrinautAiTools.createExperiment` and displays its progress in the conversation. + ## Request and result The [core contract](layer:core.experiments) exports Zod schemas and the host interface From 22aac62ef77775cf7a4bf46fb057707f33692777 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 14 Sep 2026 17:54:03 +0200 Subject: [PATCH 2/6] Provide the title capability in the experiment editor test --- .../petrinaut/src/ui/views/Editor/editor-view.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx index c6b8324a96f..8dfe8fd3ce5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx @@ -88,7 +88,7 @@ const EditorAtMode = ({ mode }: { mode: EditorGlobalMode }) => { - + ); From b9b859688c1cd8487291920de1a4eb1275eee496 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 14 Sep 2026 17:56:48 +0200 Subject: [PATCH 3/6] Remove obsolete diagnostics lint suppression --- .../src/ui/views/Editor/panels/ai-assistant-panel.tsx | 4 ---- 1 file changed, 4 deletions(-) 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 25795bf2103..8332861d338 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 @@ -614,9 +614,6 @@ const ConversationAiAssistantPanel = ({ return readCurrentDiagnostics(instance, requestDiagnosticsRef.current); }, [instance, requestDiagnosticsRef]); - /* eslint-disable react-hooks-js/refs -- See the `"use no memo"` directive - above: the refs are only read when the wrapped transport runs, never during - render. The lint rule can't see that. */ // The wrapper is render-derived from the host transport. Delaying this to an // effect leaves useChat on the previous host for one committed render. // Timing stays outside diagnostics so it tags receipt of the response chunks. @@ -630,7 +627,6 @@ const ConversationAiAssistantPanel = ({ ), [aiAssistant.transport, readDiagnosticsContext], ); - /* eslint-enable react-hooks-js/refs */ // Stream errors (server returned an error chunk, function timed out, etc.) // are otherwise opaque to the user — `useChat` resets `status` to `"ready"` From 3c4a5f46e17f65e741c88a868ed28e88b32bcda4 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 14 Sep 2026 18:12:37 +0200 Subject: [PATCH 4/6] Match browser assertions to experiment diagnostic guidance --- apps/brunch-agent/test/compiler-feedback.integration.ts | 3 ++- apps/brunch-agent/test/integration/compiler-feedback.test.ts | 2 +- apps/brunch-agent/test/root-creation.integration.ts | 5 +++-- apps/brunch-agent/test/typed-state.integration.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/brunch-agent/test/compiler-feedback.integration.ts b/apps/brunch-agent/test/compiler-feedback.integration.ts index 2db5b938b37..5187f225184 100644 --- a/apps/brunch-agent/test/compiler-feedback.integration.ts +++ b/apps/brunch-agent/test/compiler-feedback.integration.ts @@ -34,7 +34,8 @@ import { openBrowserFixture } from "./browser-fixture.ts"; import { browserResultFrom } from "./browser-result.ts"; import { nativeSchemaProvider } from "./native-schema-provider.ts"; -const cleanCompilation = "No errors or warnings found in net function code."; +const cleanCompilation = + "No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment."; const output = mkdtempSync(join(tmpdir(), "m7c-compiler-feedback-")); const save = (name: string, value: unknown) => writeFileSync(join(output, `${name}.json`), JSON.stringify(value, null, 2)); diff --git a/apps/brunch-agent/test/integration/compiler-feedback.test.ts b/apps/brunch-agent/test/integration/compiler-feedback.test.ts index 2ea0e6a5e1b..5c126074ccc 100644 --- a/apps/brunch-agent/test/integration/compiler-feedback.test.ts +++ b/apps/brunch-agent/test/integration/compiler-feedback.test.ts @@ -37,7 +37,7 @@ test.skipIf(!enabled)( expect(summary.mode).toBe("batched-construction"); expect(summary.dirtyCompilation).toContain("definitelyNotDefined"); expect(summary.cleanCompilation).toBe( - "No errors or warnings found in net function code.", + "No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.", ); expect(summary.repairHash).toMatch(/^[a-f0-9]{64}$/u); expect(summary.layoutHash).toMatch(/^[a-f0-9]{64}$/u); diff --git a/apps/brunch-agent/test/root-creation.integration.ts b/apps/brunch-agent/test/root-creation.integration.ts index f12e4d74afa..d83c5a0b8bc 100644 --- a/apps/brunch-agent/test/root-creation.integration.ts +++ b/apps/brunch-agent/test/root-creation.integration.ts @@ -301,7 +301,7 @@ try { save("compilation", result); assert.equal( result.output, - "No errors or warnings found in net function code.", + "No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.", ); return text("Native creation and canonical check completed."); }), @@ -504,7 +504,8 @@ try { requests: contexts.length, applied: records.length, schemaClasses: observedNodeMutationNames, - compilation: "No errors or warnings found in net function code.", + compilation: + "No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.", scope: "Same-session synthetic creation/correction only; reopen assertion follows.", }); diff --git a/apps/brunch-agent/test/typed-state.integration.ts b/apps/brunch-agent/test/typed-state.integration.ts index 74a659b1701..1f54ac36cdd 100644 --- a/apps/brunch-agent/test/typed-state.integration.ts +++ b/apps/brunch-agent/test/typed-state.integration.ts @@ -631,7 +631,7 @@ try { compilations.push(result); assert.equal( result.output, - "No errors or warnings found in net function code.", + "No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.", "Final corrected net must report clean canonical diagnostics; no scenario execution follows", ); return text( From 6692f1a3fc0b5fe6e7bba391fce152cba10e237e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 15 Sep 2026 14:00:06 +0200 Subject: [PATCH 5/6] Reconnect experiment chat to the host and track active requests --- libs/@hashintel/petrinaut-core/src/ai.ts | 2 +- .../@hashintel/petrinaut/docs/ai-assistant.md | 4 + .../Editor/panels/ai-assistant-panel.test.tsx | 113 ++++++++++++++++-- .../Editor/panels/ai-assistant-panel.tsx | 31 +++-- .../panels/ai-assistant-panel/README.md | 2 +- .../experiment-card.test.tsx | 48 +++++++- .../ai-assistant-contents/experiment-card.tsx | 29 +++-- 7 files changed, 193 insertions(+), 36 deletions(-) diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 5e546df1092..b2bc968bccc 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -4,12 +4,12 @@ import { mutationActionInputSchemas, type MutationActionName, } from "./action-schemas"; -import { petrinautExperimentRequestSchema } from "./ai/experiments"; import { aiCommandActionInputSchemas, type AiCommandActionName, } from "./command-schemas"; import { probabilisticSatellitesSDCPN } from "./examples"; +import { petrinautExperimentRequestSchema } from "./experiments"; import { typedKeys } from "./lib/typed-entries"; import type { Petrinaut } from "./instance"; diff --git a/libs/@hashintel/petrinaut/docs/ai-assistant.md b/libs/@hashintel/petrinaut/docs/ai-assistant.md index 4ba938580e0..5ce4f524111 100644 --- a/libs/@hashintel/petrinaut/docs/ai-assistant.md +++ b/libs/@hashintel/petrinaut/docs/ai-assistant.md @@ -198,6 +198,10 @@ parameters again; the result already recorded in chat stays unchanged. Experiments run in your current browser session, so keep the page open until they finish. See [Experiments](experiments.md#experiments-created-by-the-assistant). +A request with no saved result and no active run shows **Not running**. +Ask the assistant to run a new experiment. **Cancel** is available only for +experiments running in this panel. + ## Read-only behaviour Whether the assistant can change the net depends on the editor state: diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx index 47e3b9ce281..6b59ddcea95 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx @@ -25,11 +25,11 @@ import { type SDCPN, } from "@hashintel/petrinaut-core"; -import { AiExperimentsContext } from "../../../../react/ai-experiments/context"; import { type ErrorTracker, ErrorTrackerContext, } from "../../../../react/error-tracker-context"; +import { ExperimentHostContext } from "../../../../react/experiment-host/context"; import { PetrinautInstanceContext } from "../../../../react/instance-context"; import { DEFAULT_LANGUAGE_CLIENT_CONTEXT, @@ -282,10 +282,10 @@ const renderTestPanel = ({ ) => ( - + runExperiment: () => Promise.reject(new Error("Experiment host unavailable")), } } @@ -304,7 +304,7 @@ const renderTestPanel = ({ - + ); @@ -5435,7 +5435,7 @@ describe("AI experiment requests", () => { test("shows progress and sends exactly one captured result after completion", async () => { const completion = Promise.withResolvers(); - const createExperiment = vi.fn( + const createExperiment = vi.fn( (input, options) => { options?.onProgress?.({ experimentId: "experiment-1", @@ -5451,7 +5451,7 @@ describe("AI experiment requests", () => { renderTestPanel({ aiAssistant: { transport }, initialMessage: "Run an experiment", - experimentHost: { createExperiment }, + experimentHost: { runExperiment: createExperiment }, }); const card = await screen.findByRole("region", { @@ -5480,7 +5480,7 @@ describe("AI experiment requests", () => { }); test("cancels browser computation through the experiment card", async () => { - const createExperiment = vi.fn( + const createExperiment = vi.fn( (input, options) => new Promise((resolve) => { options?.onProgress?.({ @@ -5504,7 +5504,7 @@ describe("AI experiment requests", () => { renderTestPanel({ aiAssistant: { transport }, initialMessage: "Run an experiment", - experimentHost: { createExperiment }, + experimentHost: { runExperiment: createExperiment }, }); const card = await screen.findByRole("region", { name: "Experiment: Chat experiment", @@ -5517,11 +5517,102 @@ describe("AI experiment requests", () => { expect(createExperiment.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); }); + test("shows observed experiment requests without claiming local execution", async () => { + const runExperiment = vi.fn(); + const { transport, sendMessages } = createTransport(); + renderTestPanel({ + aiAssistant: { + transport, + followMessages: { canReplace: () => true }, + messages: [ + { + id: "observed-experiment", + role: "assistant", + parts: [ + { + type: "tool-createExperiment", + toolCallId: "observed-call", + state: "input-available", + input: request, + }, + ], + }, + ], + }, + experimentHost: { runExperiment }, + }); + const card = await screen.findByRole("region", { + name: "Experiment: Chat experiment", + }); + await act(async () => {}); + expect(within(card).getByText("Not running")).not.toBeNull(); + expect(within(card).queryByRole("button", { name: "Cancel" })).toBeNull(); + expect(card.getAttribute("aria-busy")).toBe("false"); + expect(runExperiment).not.toHaveBeenCalled(); + expect(sendMessages).not.toHaveBeenCalled(); + }); + + test("allows cancellation during validation before the host reports progress", async () => { + const completion = Promise.withResolvers(); + const runExperiment = vi.fn( + (_input, options) => { + options?.signal?.addEventListener("abort", () => + completion.resolve({ + ...result, + status: "cancelled", + experimentId: null, + runsCompleted: 0, + metrics: [], + }), + ); + return completion.promise; + }, + ); + const { transport } = createTransport(); + renderTestPanel({ + aiAssistant: { transport }, + initialMessage: "Run", + experimentHost: { runExperiment }, + }); + const card = await screen.findByRole("region", { + name: "Experiment: Chat experiment", + }); + await waitFor(() => + expect(within(card).getByText("Validating")).not.toBeNull(), + ); + fireEvent.click(within(card).getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(within(card).getByText("Cancelled")).not.toBeNull(), + ); + expect(runExperiment.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + }); + + test("clears active experiment indicators if the host rejects", async () => { + const completion = Promise.withResolvers(); + const runExperiment = vi.fn( + () => completion.promise, + ); + const { transport } = createTransport(); + renderTestPanel({ + aiAssistant: { transport }, + initialMessage: "Run", + experimentHost: { runExperiment }, + }); + const card = await screen.findByRole("region", { + name: "Experiment: Chat experiment", + }); + await waitFor(() => + expect(within(card).getByText("Validating")).not.toBeNull(), + ); + await act(async () => completion.reject(new Error("Host failed"))); + await waitFor(() => expect(card.getAttribute("aria-busy")).toBe("false")); + expect(within(card).queryByRole("button", { name: "Cancel" })).toBeNull(); + }); + test("ignores a late experiment from a replaced conversation with the same tool ID", async () => { const previous = Promise.withResolvers(); const current = Promise.withResolvers(); - const createExperiment = - vi.fn(); + const createExperiment = vi.fn(); createExperiment.mockImplementationOnce(() => previous.promise); createExperiment.mockImplementationOnce((input, options) => { options?.onProgress?.({ @@ -5566,7 +5657,7 @@ describe("AI experiment requests", () => { }); const { rerenderPanel } = renderTestPanel({ aiAssistant: config("Previous experiment"), - experimentHost: { createExperiment }, + experimentHost: { runExperiment: createExperiment }, }); await waitFor(() => expect(createExperiment).toHaveBeenCalledOnce()); rerenderPanel(config("Current experiment")); 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 8332861d338..d8114fac1be 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 @@ -34,8 +34,8 @@ import { setNetTitleToolName, } from "@hashintel/petrinaut-core"; -import { AiExperimentsContext } from "../../../../react/ai-experiments/context"; import { ErrorTrackerContext } from "../../../../react/error-tracker-context"; +import { ExperimentHostContext } from "../../../../react/experiment-host/context"; import { useLatest } from "../../../../react/hooks/use-latest"; import { PetrinautInstanceContext } from "../../../../react/instance-context"; import { LanguageClientContext } from "../../../../react/lsp/context"; @@ -512,17 +512,21 @@ const ConversationAiAssistantPanel = ({ const { requestDiagnostics } = use(LanguageClientContext); const requestDiagnosticsRef = useLatest(requestDiagnostics); - const experimentHost = use(AiExperimentsContext); + const experimentHost = use(ExperimentHostContext); const experimentControllersRef = useRef(new Map()); const [experimentStates, setExperimentStates] = useState< Record< string, { + active: boolean; progress?: PetrinautExperimentProgress; result?: PetrinautExperimentResult; } > >({}); + const cancelExperiment = useCallback((toolCallId: string) => { + experimentControllersRef.current.get(toolCallId)?.abort(); + }, []); useEffect(() => { const controllers = experimentControllersRef.current; return () => { @@ -905,26 +909,30 @@ const ConversationAiAssistantPanel = ({ const request = petrinautExperimentRequestSchema.parse(toolCall.input); const controller = new AbortController(); experimentControllersRef.current.set(toolCall.toolCallId, controller); + setExperimentStates((states) => ({ + ...states, + [toolCall.toolCallId]: { active: true }, + })); const isCurrentRequest = () => generation === submissionGenerationRef.current && executionConversationId === toolHostIdentityRef.current && experimentControllersRef.current.get(toolCall.toolCallId) === controller; try { - const result = await experimentHost.createExperiment(request, { + const result = await experimentHost.runExperiment(request, { signal: controller.signal, onProgress: (progress) => { if (!isCurrentRequest()) return; setExperimentStates((states) => ({ ...states, - [toolCall.toolCallId]: { progress }, + [toolCall.toolCallId]: { active: true, progress }, })); }, }); if (isCurrentRequest()) { setExperimentStates((states) => ({ ...states, - [toolCall.toolCallId]: { result }, + [toolCall.toolCallId]: { active: false, result }, })); } await addAutomaticToolOutput({ @@ -933,6 +941,15 @@ const ConversationAiAssistantPanel = ({ output: result, }); } finally { + if (isCurrentRequest()) { + setExperimentStates((states) => ({ + ...states, + [toolCall.toolCallId]: { + ...states[toolCall.toolCallId], + active: false, + }, + })); + } if ( experimentControllersRef.current.get(toolCall.toolCallId) === controller @@ -2055,9 +2072,7 @@ const ConversationAiAssistantPanel = ({ composerControl={composerControl} error={streamError ?? error} experimentStates={experimentStates} - onCancelExperiment={(toolCallId) => - experimentControllersRef.current.get(toolCallId)?.abort() - } + onCancelExperiment={cancelExperiment} input={input} inputMode={interactionMode} interactiveTools={aiAssistant.interactiveTools} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md index 7e30dc0d923..8f3807b8414 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/README.md @@ -8,7 +8,7 @@ parent `ai-assistant-panel.tsx`. Document tools call the core instance. Experiment tools call the browser host and show progress until its final result. The embedding application supplies transport and conversation persistence. -See the browser host +See the browser host for execution and cancellation, and the chat integration for tool dispatch and presentation. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx index 86158bcf419..61acb19004d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; import { ExperimentCard } from "./experiment-card"; @@ -41,7 +41,9 @@ it("reports actual run progress and removes active indicators when finished", () runsCompleted: 3, runsTarget: 8, }; - const view = render(); + const view = render( + , + ); const card = screen.getByRole("region", { name: "Experiment: Population" }); expect(card.getAttribute("data-tone")).toBe("simulation"); expect(card.getAttribute("aria-busy")).toBe("true"); @@ -54,7 +56,7 @@ it("reports actual run progress and removes active indicators when finished", () view.rerender( , ); expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( @@ -85,14 +87,19 @@ it("keeps optimization identity while validating and after cancellation", () => }, }, }; - const view = render(); + const view = render( + , + ); const card = screen.getByRole("region", { name: "Experiment: Population" }); expect(card.getAttribute("data-tone")).toBe("optimization"); expect(screen.getByRole("status").textContent).toBe("Validating"); view.rerender( , ); expect(card.getAttribute("data-tone")).toBe("optimization"); @@ -114,3 +121,32 @@ it("shows a failed tool without a pending validation label or progress", () => { expect(screen.queryByText("Checking the model")).toBeNull(); expect(screen.queryByRole("progressbar")).toBeNull(); }); + +it("only offers cancellation while this panel owns a running request", () => { + const onCancel = vi.fn(); + const view = render(); + const card = screen.getByRole("region", { name: "Experiment: Population" }); + expect(card.getAttribute("aria-busy")).toBe("false"); + expect(screen.getByRole("status").textContent).toBe("Not running"); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + expect( + screen.getByText(/Ask the assistant to run a new experiment/), + ).not.toBeNull(); + + view.rerender( + , + ); + expect(screen.getByRole("status").textContent).toBe("Validating"); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledWith(part.toolCallId); + + view.rerender( + , + ); + expect(card.getAttribute("aria-busy")).toBe("false"); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx index 0a832c315c3..697c9662e2d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents/experiment-card.tsx @@ -18,6 +18,7 @@ export type ExperimentToolPart = Extract< >; export type AiExperimentState = { + active: boolean; progress?: PetrinautExperimentProgress; result?: PetrinautExperimentResult; }; @@ -222,26 +223,30 @@ export const ExperimentCard = ({ const name = result?.name ?? progress?.name ?? part.input?.name ?? "Experiment"; const errorText = part.state === "output-error" ? part.errorText : undefined; - const pending = !result && !errorText; + const pending = state?.active === true && !result && !errorText; const optimization = part.input?.execution?.mode === "optimize" || result?.optimization !== undefined || progress?.phase === "optimizing" || progress?.phase === "refining"; - const statusKind = errorText ? "error" : (result?.status ?? "pending"); + const statusKind = errorText + ? "error" + : (result?.status ?? (pending ? "pending" : "unavailable")); const status = errorText ? "Failed" : result ? { complete: "Finished", cancelled: "Cancelled", error: "Failed" }[ result.status ] - : progress?.phase === "optimizing" - ? "Optimizing" - : progress?.phase === "refining" - ? "Refining" - : progress?.phase === "running" - ? "Running" - : "Validating"; + : !pending + ? "Not running" + : progress?.phase === "optimizing" + ? "Optimizing" + : progress?.phase === "refining" + ? "Refining" + : progress?.phase === "running" + ? "Running" + : "Validating"; const progressValue = progress ? Math.min(progress.runsTarget, Math.max(0, progress.runsCompleted)) : 0; @@ -340,6 +345,12 @@ export const ExperimentCard = ({ )} )} + {!pending && !result && !errorText && ( +

+ No result is available for this request. Ask the assistant to run a + new experiment. +

+ )} {(result?.message ?? errorText) && (

Date: Tue, 15 Sep 2026 14:19:12 +0200 Subject: [PATCH 6/6] Import the AI experiment schema without its worker backend --- libs/@hashintel/petrinaut-core/src/ai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index b2bc968bccc..894cb7126d7 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -9,7 +9,7 @@ import { type AiCommandActionName, } from "./command-schemas"; import { probabilisticSatellitesSDCPN } from "./examples"; -import { petrinautExperimentRequestSchema } from "./experiments"; +import { petrinautExperimentRequestSchema } from "./experiments/host"; import { typedKeys } from "./lib/typed-entries"; import type { Petrinaut } from "./instance";