diff --git a/apps/brunch-agent/.oxlintrc.json b/apps/brunch-agent/.oxlintrc.json index e8045938605..77ba4302de5 100644 --- a/apps/brunch-agent/.oxlintrc.json +++ b/apps/brunch-agent/.oxlintrc.json @@ -30,6 +30,10 @@ } ], "patterns": [ + { + "regex": "^(\\.\\./)+((apps|libs|tests|@[a-z0-9-]+)/|[^./][^/]*/(src|dist|test)/)", + "message": "Import another workspace package by its package name, not a relative path into it." + }, { "group": [ "@hashintel/petrinaut/*", diff --git a/apps/brunch-agent/README.md b/apps/brunch-agent/README.md index 2286d22217a..f60b3aaca70 100644 --- a/apps/brunch-agent/README.md +++ b/apps/brunch-agent/README.md @@ -43,8 +43,19 @@ To try a candidate, copy `src/agents/petrinaut/` to `src/agents/petrinaut-alt- response.request().method() === "POST" && admissionPath.test(new URL(response.url()).pathname); @@ -136,13 +134,7 @@ export const submitPersonaBrowserTurn = async ( }; signal?.addEventListener("abort", cancel, { once: true }); let turnSettled = false; - const allowing = allowDestructiveEdits( - page, - () => turnSettled, - options.onAllowedEdit, - ); - // Raced while the turn runs, awaited once it ends. - void allowing.catch(() => {}); + let allowing: Promise | undefined; try { await composer.fill(message); signal?.throwIfAborted(); @@ -197,7 +189,16 @@ export const submitPersonaBrowserTurn = async ( headers: agentOwnershipHeaders(session), }); await options.onAdmission?.(session, admission); - if (signal?.aborted) cancel(); + signal?.throwIfAborted(); + // Submission revokes earlier approvals before its POST. Wait for the + // receipt and utterance log before watching for this turn's approvals. + allowing = allowDestructiveEdits( + page, + () => turnSettled, + options.onAllowedEdit, + ); + // Raced while the turn runs, awaited once it ends. + void allowing.catch(() => {}); await Promise.race([ stop.waitFor({ state: "hidden", timeout: 0 }), aborted.promise, @@ -263,6 +264,6 @@ export const submitPersonaBrowserTurn = async ( } finally { signal?.removeEventListener("abort", cancel); turnSettled = true; - await allowing.catch(() => {}); + await allowing?.catch(() => {}); } }; diff --git a/apps/brunch-agent/test/proof-artifacts.test.ts b/apps/brunch-agent/evals/petrinaut/persona/proof-artifacts.test.ts similarity index 98% rename from apps/brunch-agent/test/proof-artifacts.test.ts rename to apps/brunch-agent/evals/petrinaut/persona/proof-artifacts.test.ts index 84e61626d35..5443ed57fee 100644 --- a/apps/brunch-agent/test/proof-artifacts.test.ts +++ b/apps/brunch-agent/evals/petrinaut/persona/proof-artifacts.test.ts @@ -5,10 +5,7 @@ import { join } from "node:path"; import { afterEach, expect, test } from "vitest"; -import { - deriveProofTrace, - writeProofArtifacts, -} from "../evals/petrinaut/persona/proof-artifacts"; +import { deriveProofTrace, writeProofArtifacts } from "./proof-artifacts"; import type { FlueConversationSnapshot } from "@flue/sdk"; diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index ccb610e1d97..8e3ca931aed 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -5,6 +5,10 @@ "description": "Remote Brunch server and local Petrinaut development loop.", "license": "AGPL-3.0", "type": "module", + "exports": { + "./testing/admission-controls": "./test/integration/run-admission-controls.ts", + "./testing/load-built-application": "./test/load-built-application.ts" + }, "scripts": { "build": "vite build && vite build --config vite.client.config.ts", "build:docker": "docker buildx build --tag brunch-agent --file docker/Dockerfile ../../ --load", @@ -23,6 +27,7 @@ "test:integration": "vitest run --config vitest.integration.config.ts", "test:manual:browser": "VITE_BRUNCH_CHAT_ENDPOINT=/agents/petrinaut VITE_PETRINAUT_DEFAULT_ASSISTANT=brunch turbo run build --filter '@apps/brunch-agent...' --filter '@apps/petrinaut-website...' --env-mode=loose && node --experimental-strip-types test/browser-witness/petrinaut-panel.ts", "test:manual:browser:ledger-tab": "VITE_BRUNCH_CHAT_ENDPOINT=/agents/petrinaut VITE_PETRINAUT_DEFAULT_ASSISTANT=brunch turbo run build --filter '@apps/brunch-agent...' --filter '@apps/petrinaut-website...' --env-mode=loose && node --experimental-strip-types test/browser-witness/ledger-tab.ts", + "test:manual:browser:persona": "VITE_BRUNCH_CHAT_ENDPOINT=${VITE_BRUNCH_CHAT_ENDPOINT:-/agents/petrinaut} VITE_PETRINAUT_DEFAULT_ASSISTANT=brunch turbo run build --filter '@apps/brunch-agent...' --filter '@apps/petrinaut-website...' --env-mode=loose && node --experimental-strip-types test/browser-witness/persona-turn.ts", "test:unit": "vitest run --config vitest.config.ts", "transcript": "node --experimental-strip-types src/server/transcript-cli.ts" }, @@ -50,6 +55,7 @@ "devDependencies": { "@earendil-works/pi-tui": "0.84.3", "@flue/vite": "2.0.3", + "@local/pi-faux-providers": "workspace:*", "@playwright/test": "1.58.2", "@types/node": "22.18.13", "@types/pg": "8.23.1", diff --git a/apps/brunch-agent/test/experiment-configuration.test.ts b/apps/brunch-agent/src/agents/petrinaut/experiment-configuration.test.ts similarity index 97% rename from apps/brunch-agent/test/experiment-configuration.test.ts rename to apps/brunch-agent/src/agents/petrinaut/experiment-configuration.test.ts index 66ad951b993..b1101d84ebd 100644 --- a/apps/brunch-agent/test/experiment-configuration.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/experiment-configuration.test.ts @@ -10,16 +10,16 @@ import { type PetrinautExperimentRequest, } from "@hashintel/petrinaut-core"; -import { openaiProviderWithAddedModels } from "../src/server/openai-provider.ts"; -import { openrouterProviderWithAddedModels } from "../src/server/openrouter-provider.ts"; +import { openaiProviderWithAddedModels } from "../../server/openai-provider.ts"; +import { openrouterProviderWithAddedModels } from "../../server/openrouter-provider.ts"; import { selectChatModelSpecifier, selectChatThinking, -} from "../src/shared/chat-model.ts"; +} from "../../shared/chat-model.ts"; const reference = readFileSync( new URL( - "../src/agents/petrinaut/skills/modelling/references/experiment-configuration.md", + "./skills/modelling/references/experiment-configuration.md", import.meta.url, ), "utf8", diff --git a/apps/brunch-agent/test/petrinaut-agent-mounting.test.ts b/apps/brunch-agent/src/agents/petrinaut/petrinaut-agent-mounting.test.ts similarity index 86% rename from apps/brunch-agent/test/petrinaut-agent-mounting.test.ts rename to apps/brunch-agent/src/agents/petrinaut/petrinaut-agent-mounting.test.ts index 1d376b42ca0..4dbbd4ad83d 100644 --- a/apps/brunch-agent/test/petrinaut-agent-mounting.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/petrinaut-agent-mounting.test.ts @@ -6,16 +6,16 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { brunchTools, sdcpnInitialDataSchema } from "@hashintel/brunch-agent"; import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; -import { - commitToolDescription, - compileToolDescription, -} from "../src/agents/petrinaut/tools/ledger.ts"; -import { queryBasisToolDescription } from "../src/agents/petrinaut/tools/query-basis.ts"; import { assertPetrinautToolCatalogueConformance, canonicalPetrinautToolCatalogue, brunchToolCatalogue, -} from "../src/shared/tool-catalogue.ts"; +} from "../../shared/tool-catalogue.ts"; +import { + commitToolDescription, + compileToolDescription, +} from "./tools/ledger.ts"; +import { queryBasisToolDescription } from "./tools/query-basis.ts"; const mounted = vi.hoisted(() => ({ initialData: undefined as unknown, @@ -46,13 +46,13 @@ vi.mock("@flue/runtime", async (importOriginal) => ({ }, })); // Only the Flue build packages skills; these tests exercise the composition around them. -vi.mock("../src/agents/petrinaut/skills/eliciting/SKILL.md", () => ({ +vi.mock("./skills/eliciting/SKILL.md", () => ({ default: { name: "eliciting" }, })); -vi.mock("../src/agents/petrinaut/skills/modelling/SKILL.md", () => ({ +vi.mock("./skills/modelling/SKILL.md", () => ({ default: { name: "modelling" }, })); -vi.mock("../src/agents/petrinaut/skills/petrinaut/SKILL.md", () => ({ +vi.mock("./skills/petrinaut/SKILL.md", () => ({ default: { name: "petrinaut" }, })); @@ -64,10 +64,7 @@ const bound = { }; const systemPrompt = async () => ( - await readFile( - new URL("../src/agents/petrinaut/prompts/system.md", import.meta.url), - "utf8", - ) + await readFile(new URL("./prompts/system.md", import.meta.url), "utf8") ).trim(); beforeEach(() => { vi.resetModules(); @@ -86,8 +83,7 @@ afterEach(() => vi.unstubAllEnvs()); test("a document binding mounts the guidance, the complete canonical catalogue, the Ledger and the draft", async () => { mounted.initialData = bound; - const { PetrinautAgent: renderAgent } = - await import("../src/agents/petrinaut/agent.ts"); + const { PetrinautAgent: renderAgent } = await import("./agent.ts"); expect(renderAgent({ id: "bound" })).toBe(await systemPrompt()); expect(mounted.contextProjections).toBe(1); expect(mounted.skills).toEqual(["eliciting", "modelling", "petrinaut"]); @@ -118,8 +114,7 @@ test("a document binding mounts the guidance, the complete canonical catalogue, }); test("a conversation without initial data mounts the guidance and Ledger without browser tools", async () => { - const { PetrinautAgent: renderAgent } = - await import("../src/agents/petrinaut/agent.ts"); + const { PetrinautAgent: renderAgent } = await import("./agent.ts"); renderAgent({ id: "unbound" }); expect(mounted.skills).toEqual(["eliciting", "modelling", "petrinaut"]); expect(mounted.tools.sort()).toEqual( diff --git a/apps/brunch-agent/test/ledger-kind-enums.test.ts b/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts similarity index 99% rename from apps/brunch-agent/test/ledger-kind-enums.test.ts rename to apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts index 0a3c16bef1b..5ff614fa4ea 100644 --- a/apps/brunch-agent/test/ledger-kind-enums.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/ledger-kind-enums.test.ts @@ -26,16 +26,16 @@ import { vStatus, } from "@hashintel/brunch-agent"; +import { openaiProviderWithAddedModels } from "../../../server/openai-provider.ts"; +import { + selectChatModelSpecifier, + selectChatThinking, +} from "../../../shared/chat-model.ts"; import { commitToolDescription, createLedgerCommitTool, createLedgerCompileTool, -} from "../src/agents/petrinaut/tools/ledger.ts"; -import { openaiProviderWithAddedModels } from "../src/server/openai-provider.ts"; -import { - selectChatModelSpecifier, - selectChatThinking, -} from "../src/shared/chat-model.ts"; +} from "./ledger.ts"; type Batch = LedgerAppend["entries"]; diff --git a/apps/brunch-agent/test/query-basis.test.ts b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts similarity index 99% rename from apps/brunch-agent/test/query-basis.test.ts rename to apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts index 16f3ef58add..02661996c6a 100644 --- a/apps/brunch-agent/test/query-basis.test.ts +++ b/apps/brunch-agent/src/agents/petrinaut/tools/query-basis.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { queryBasis } from "../src/agents/petrinaut/tools/query-basis.ts"; +import { queryBasis } from "./query-basis.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; diff --git a/apps/brunch-agent/test/db-path.test.ts b/apps/brunch-agent/src/db-path.test.ts similarity index 97% rename from apps/brunch-agent/test/db-path.test.ts rename to apps/brunch-agent/src/db-path.test.ts index 40987a9647f..f677e0d057a 100644 --- a/apps/brunch-agent/test/db-path.test.ts +++ b/apps/brunch-agent/src/db-path.test.ts @@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; -import { conversationDbPath } from "../src/db-path"; +import { conversationDbPath } from "./db-path"; const appDir = fileURLToPath(new URL("..", import.meta.url)); diff --git a/apps/brunch-agent/test/provider-registration.test.ts b/apps/brunch-agent/src/provider-registration.test.ts similarity index 97% rename from apps/brunch-agent/test/provider-registration.test.ts rename to apps/brunch-agent/src/provider-registration.test.ts index 3fea88f31ed..3d7ae278ed7 100644 --- a/apps/brunch-agent/test/provider-registration.test.ts +++ b/apps/brunch-agent/src/provider-registration.test.ts @@ -9,8 +9,8 @@ import { instrument, setProvider } from "@flue/runtime"; import { Hono } from "hono"; import { beforeAll, expect, test, vi } from "vitest"; -vi.mock("../src/server/telemetry-bootstrap.ts", () => ({})); -vi.mock("../src/agents/petrinaut/agent.ts", () => ({ +vi.mock("./server/telemetry-bootstrap.ts", () => ({})); +vi.mock("./agents/petrinaut/agent.ts", () => ({ PetrinautAgent: { agentName: "brunch-chat-agent" }, })); vi.mock("@flue/runtime/routing", () => ({ @@ -52,7 +52,7 @@ vi.mock( }, ); beforeAll(async () => { - await import("../src/app"); + await import("./app"); }); test("app registration admits the Anthropic, OpenAI and OpenRouter providers", () => { diff --git a/apps/brunch-agent/test/database-config.test.ts b/apps/brunch-agent/src/server/database-config.test.ts similarity index 99% rename from apps/brunch-agent/test/database-config.test.ts rename to apps/brunch-agent/src/server/database-config.test.ts index dd6d8ae6968..31a21f73653 100644 --- a/apps/brunch-agent/test/database-config.test.ts +++ b/apps/brunch-agent/src/server/database-config.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { brunchEnv } from "@hashintel/brunch-agent"; -import { loadDatabaseConfig } from "../src/server/database-config.ts"; +import { loadDatabaseConfig } from "./database-config.ts"; const productionEnvironment = { NODE_ENV: "production", diff --git a/apps/brunch-agent/test/deployment-smoke-validation.test.ts b/apps/brunch-agent/src/server/deployment-smoke-validation.test.ts similarity index 98% rename from apps/brunch-agent/test/deployment-smoke-validation.test.ts rename to apps/brunch-agent/src/server/deployment-smoke-validation.test.ts index fa9bd50257e..d42e8b90194 100644 --- a/apps/brunch-agent/test/deployment-smoke-validation.test.ts +++ b/apps/brunch-agent/src/server/deployment-smoke-validation.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import { validatePersistedHistory, validateUiMessageStream, -} from "../src/server/deployment-smoke-validation.ts"; +} from "./deployment-smoke-validation.ts"; const streamOf = (...values: string[]): ReadableStream => { const encoder = new TextEncoder(); diff --git a/apps/brunch-agent/test/dev-configuration-preflight.test.ts b/apps/brunch-agent/src/server/dev-configuration-preflight.test.ts similarity index 98% rename from apps/brunch-agent/test/dev-configuration-preflight.test.ts rename to apps/brunch-agent/src/server/dev-configuration-preflight.test.ts index a439782c122..3069f0a1840 100644 --- a/apps/brunch-agent/test/dev-configuration-preflight.test.ts +++ b/apps/brunch-agent/src/server/dev-configuration-preflight.test.ts @@ -4,11 +4,11 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { checkDevConfiguration } from "../src/server/dev-configuration-preflight.ts"; import { selectChatModelSpecifier, selectChatThinking, -} from "../src/shared/chat-model.ts"; +} from "../shared/chat-model.ts"; +import { checkDevConfiguration } from "./dev-configuration-preflight.ts"; const syntheticKey = "synthetic-config-fixture-not-a-real-credential"; let root: string; diff --git a/apps/brunch-agent/test/health.test.ts b/apps/brunch-agent/src/server/health.test.ts similarity index 92% rename from apps/brunch-agent/test/health.test.ts rename to apps/brunch-agent/src/server/health.test.ts index fbc6ae84657..c4db3498252 100644 --- a/apps/brunch-agent/test/health.test.ts +++ b/apps/brunch-agent/src/server/health.test.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { expect, test, vi } from "vitest"; -import { healthHandler } from "../src/server/health.ts"; +import { healthHandler } from "./health.ts"; test("health reports liveness without consulting dependencies", async () => { const dependency = vi.fn<() => void>(); diff --git a/apps/brunch-agent/test/agent-ownership.test.ts b/apps/brunch-agent/src/server/http/agent-ownership.test.ts similarity index 93% rename from apps/brunch-agent/test/agent-ownership.test.ts rename to apps/brunch-agent/src/server/http/agent-ownership.test.ts index 3953dce1c5c..266cfe5dc55 100644 --- a/apps/brunch-agent/test/agent-ownership.test.ts +++ b/apps/brunch-agent/src/server/http/agent-ownership.test.ts @@ -3,11 +3,11 @@ import { expect, test } from "vitest"; import { brunchRoutes } from "@hashintel/brunch-agent"; -import { agentOwnershipGuard } from "../src/server/http/ownership.ts"; import { agentOwnershipHeaders, flueConversationIdFrom, -} from "../src/shared/conversation/identity.ts"; +} from "../../shared/conversation/identity.ts"; +import { agentOwnershipGuard } from "./ownership.ts"; const mount = `${brunchRoutes.agents}/${brunchRoutes.petrinautAgent}`; const app = new Hono(); diff --git a/apps/brunch-agent/test/assets.test.ts b/apps/brunch-agent/src/server/http/assets.test.ts similarity index 99% rename from apps/brunch-agent/test/assets.test.ts rename to apps/brunch-agent/src/server/http/assets.test.ts index fa907e9090f..8a8d97dcff7 100644 --- a/apps/brunch-agent/test/assets.test.ts +++ b/apps/brunch-agent/src/server/http/assets.test.ts @@ -27,7 +27,7 @@ import { pathToFileURL } from "node:url"; import { Hono } from "hono"; import { afterAll, describe, expect, test } from "vitest"; -import { assetHandler } from "../src/server/http/assets"; +import { assetHandler } from "./assets"; const uiRoot = mkdtempSync(join(tmpdir(), "brunch-assets-")); const BINARY_BYTES = Uint8Array.from({ length: 256 }, (_, i) => i); diff --git a/apps/brunch-agent/test/cors.test.ts b/apps/brunch-agent/src/server/http/cors.test.ts similarity index 97% rename from apps/brunch-agent/test/cors.test.ts rename to apps/brunch-agent/src/server/http/cors.test.ts index 34c07aa3980..e9ea956ac74 100644 --- a/apps/brunch-agent/test/cors.test.ts +++ b/apps/brunch-agent/src/server/http/cors.test.ts @@ -7,15 +7,12 @@ import { brunchRoutes, } from "@hashintel/brunch-agent"; -import { - createAgentCors, - parseCorsAllowedOrigins, -} from "../src/server/http/cors.ts"; -import { agentOwnershipGuard } from "../src/server/http/ownership.ts"; import { agentOwnershipHeaders, flueConversationIdFrom, -} from "../src/shared/conversation/identity.ts"; +} from "../../shared/conversation/identity.ts"; +import { createAgentCors, parseCorsAllowedOrigins } from "./cors.ts"; +import { agentOwnershipGuard } from "./ownership.ts"; describe("parseCorsAllowedOrigins", () => { test("grants no cross-origin access when configuration is absent or blank", () => { diff --git a/apps/brunch-agent/test/live-tool-route.test.ts b/apps/brunch-agent/src/server/http/live-tool-route.test.ts similarity index 92% rename from apps/brunch-agent/test/live-tool-route.test.ts rename to apps/brunch-agent/src/server/http/live-tool-route.test.ts index 7dd07a2257c..b85d4f6223f 100644 --- a/apps/brunch-agent/test/live-tool-route.test.ts +++ b/apps/brunch-agent/src/server/http/live-tool-route.test.ts @@ -7,11 +7,11 @@ import { liveToolRouteSegment, } from "@local/flue-aisdk-transport/server"; -import { agentOwnershipGuard } from "../src/server/http/ownership.ts"; import { agentOwnershipHeaders, flueConversationIdFrom, -} from "../src/shared/conversation/identity.ts"; +} from "../../shared/conversation/identity.ts"; +import { agentOwnershipGuard } from "./ownership.ts"; const mount = "/agents/chat"; const identity = { diff --git a/apps/brunch-agent/test/local-dev-origins.test.ts b/apps/brunch-agent/src/server/http/local-dev-origins.test.ts similarity index 93% rename from apps/brunch-agent/test/local-dev-origins.test.ts rename to apps/brunch-agent/src/server/http/local-dev-origins.test.ts index b0bd233bd6a..2b5228c5c20 100644 --- a/apps/brunch-agent/test/local-dev-origins.test.ts +++ b/apps/brunch-agent/src/server/http/local-dev-origins.test.ts @@ -2,23 +2,23 @@ import { readFileSync } from "node:fs"; import { afterEach, expect, test, vi } from "vitest"; -import { mergePetrinautPanelConfig } from "../petrinaut-local.vite.config.ts"; +import { mergePetrinautPanelConfig } from "../../../petrinaut-local.vite.config.ts"; import { defaultChatOrigin, localChatListen, localPanelListen, petrinautLocalServer, -} from "../src/server/http/local-origins.ts"; +} from "./local-origins.ts"; const readAppFile = (relativePath: string): string => - readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8"); + readFileSync(new URL(`../../../${relativePath}`, import.meta.url), "utf8"); /** The module reads the port variables once, as it is imported. */ const importWithPorts = async (chatPort: string, panelPort: string) => { vi.resetModules(); vi.stubEnv("BRUNCH_CHAT_PORT", chatPort); vi.stubEnv("BRUNCH_PANEL_PORT", panelPort); - return import("../src/server/http/local-origins.ts"); + return import("./local-origins.ts"); }; afterEach(() => { diff --git a/apps/brunch-agent/test/postgres.test.ts b/apps/brunch-agent/src/server/postgres.test.ts similarity index 98% rename from apps/brunch-agent/test/postgres.test.ts rename to apps/brunch-agent/src/server/postgres.test.ts index fc17504e381..0fb37a7336b 100644 --- a/apps/brunch-agent/test/postgres.test.ts +++ b/apps/brunch-agent/src/server/postgres.test.ts @@ -2,15 +2,15 @@ import { describe, expect, test, vi } from "vitest"; import { brunchEnv } from "@hashintel/brunch-agent"; -import { type PostgresDatabaseConfig } from "../src/server/database-config.ts"; +import { type PostgresDatabaseConfig } from "./database-config.ts"; import { createPostgresPool, createPostgresPoolConfig, createPostgresRunnerFromPool, POSTGRES_CONNECTION_TIMEOUT_MS, POSTGRES_QUERY_TIMEOUT_MS, -} from "../src/server/postgres.ts"; -import { diagnostics } from "../src/server/runtime-diagnostics.ts"; +} from "./postgres.ts"; +import { diagnostics } from "./runtime-diagnostics.ts"; interface TestQueryResult { readonly rows: Record[]; diff --git a/apps/brunch-agent/test/provider-accounting.test.ts b/apps/brunch-agent/src/server/provider-accounting.test.ts similarity index 98% rename from apps/brunch-agent/test/provider-accounting.test.ts rename to apps/brunch-agent/src/server/provider-accounting.test.ts index 2e43ebe8c88..a4cb62a2bb2 100644 --- a/apps/brunch-agent/test/provider-accounting.test.ts +++ b/apps/brunch-agent/src/server/provider-accounting.test.ts @@ -17,9 +17,9 @@ import { import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; import { afterEach, expect, test } from "vitest"; -import { createStepARequestAccounting } from "../src/server/provider-accounting.ts"; -import { RequestLedger } from "../src/server/provider-accounting/request-ledger.ts"; -import { withBufferedToolAdmission } from "../src/server/provider-admission.ts"; +import { createStepARequestAccounting } from "./provider-accounting.ts"; +import { RequestLedger } from "./provider-accounting/request-ledger.ts"; +import { withBufferedToolAdmission } from "./provider-admission.ts"; const native: Provider = anthropicProvider(); const model = native @@ -519,7 +519,7 @@ test("historical five-call authority is compatible only in a disposable copy; pr // Freeze the historical premise; the live authority now contains an unknown r2 call. // Exact source: f2b9bfd040:.../fe-1573-step-a/usage-ledger.json. const path = new URL( - "./fixtures/provider-accounting/historical-five-call-ledger.json", + "./provider-accounting/fixtures/historical-five-call-ledger.json", import.meta.url, ); const original = readFileSync(path, "utf8"); @@ -705,7 +705,7 @@ test("accepted unknown uses the larger observed hold and never waives journalPen test("two real processes cannot interleave ledger transactions; a stale guard is never stolen", async () => { const fixture = setup(); const source = new URL( - "../src/server/provider-accounting/request-ledger.ts", + "./provider-accounting/request-ledger.ts", import.meta.url, ).href; // Child pauses inside the real transaction's read. This is test-only scheduling, diff --git a/apps/brunch-agent/test/fixtures/provider-accounting/README.md b/apps/brunch-agent/src/server/provider-accounting/fixtures/README.md similarity index 100% rename from apps/brunch-agent/test/fixtures/provider-accounting/README.md rename to apps/brunch-agent/src/server/provider-accounting/fixtures/README.md diff --git a/apps/brunch-agent/test/fixtures/provider-accounting/historical-five-call-ledger.json b/apps/brunch-agent/src/server/provider-accounting/fixtures/historical-five-call-ledger.json similarity index 100% rename from apps/brunch-agent/test/fixtures/provider-accounting/historical-five-call-ledger.json rename to apps/brunch-agent/src/server/provider-accounting/fixtures/historical-five-call-ledger.json diff --git a/apps/brunch-agent/test/provider-admission.test.ts b/apps/brunch-agent/src/server/provider-admission.test.ts similarity index 99% rename from apps/brunch-agent/test/provider-admission.test.ts rename to apps/brunch-agent/src/server/provider-admission.test.ts index 9f07c5ce561..a52e3d52444 100644 --- a/apps/brunch-agent/test/provider-admission.test.ts +++ b/apps/brunch-agent/src/server/provider-admission.test.ts @@ -15,7 +15,7 @@ import { modelAdmissionScope, modelStreamIdleTimeoutDefaults, withBufferedToolAdmission, -} from "../src/server/provider-admission"; +} from "./provider-admission"; const collect = async (stream: ReturnType) => { const events = []; diff --git a/apps/brunch-agent/test/runtime-diagnostics.test.ts b/apps/brunch-agent/src/server/runtime-diagnostics.test.ts similarity index 99% rename from apps/brunch-agent/test/runtime-diagnostics.test.ts rename to apps/brunch-agent/src/server/runtime-diagnostics.test.ts index f12f0f8d46a..d26c7dae3aa 100644 --- a/apps/brunch-agent/test/runtime-diagnostics.test.ts +++ b/apps/brunch-agent/src/server/runtime-diagnostics.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import { classifyError, createRuntimeDiagnostics, -} from "../src/server/runtime-diagnostics.ts"; +} from "./runtime-diagnostics.ts"; import type { FlueObservation } from "@flue/runtime"; diff --git a/apps/brunch-agent/test/telemetry.test.ts b/apps/brunch-agent/src/server/telemetry.test.ts similarity index 99% rename from apps/brunch-agent/test/telemetry.test.ts rename to apps/brunch-agent/src/server/telemetry.test.ts index f0e57a21e7e..da5e9c7bd0e 100644 --- a/apps/brunch-agent/test/telemetry.test.ts +++ b/apps/brunch-agent/src/server/telemetry.test.ts @@ -15,7 +15,7 @@ import { probeCollector, recordOperationalFailure, withReachableCollector, -} from "../src/server/telemetry.ts"; +} from "./telemetry.ts"; import type { OpenTelemetrySetup, diff --git a/apps/brunch-agent/test/turn-chronology.test.ts b/apps/brunch-agent/src/server/turn-chronology.test.ts similarity index 99% rename from apps/brunch-agent/test/turn-chronology.test.ts rename to apps/brunch-agent/src/server/turn-chronology.test.ts index 4ed8dadb46c..536f61787f6 100644 --- a/apps/brunch-agent/test/turn-chronology.test.ts +++ b/apps/brunch-agent/src/server/turn-chronology.test.ts @@ -3,7 +3,7 @@ import { expect, test } from "vitest"; import { createTurnChronologyObserver, type SubmissionChronology, -} from "../src/server/observe-turn-chronology.ts"; +} from "./observe-turn-chronology.ts"; import type { FlueEventContext, FlueObservation } from "@flue/runtime"; diff --git a/apps/brunch-agent/test/context-projection.test.ts b/apps/brunch-agent/src/shared/context-projection.test.ts similarity index 97% rename from apps/brunch-agent/test/context-projection.test.ts rename to apps/brunch-agent/src/shared/context-projection.test.ts index ef90d7c824a..a33cffc0fb6 100644 --- a/apps/brunch-agent/test/context-projection.test.ts +++ b/apps/brunch-agent/src/shared/context-projection.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { expect, test } from "vitest"; -import { projectBrunchContext } from "../src/shared/context-projection"; +import { projectBrunchContext } from "./context-projection"; import type { ContextProjection, ContextProjectionEntry } from "@flue/runtime"; diff --git a/apps/brunch-agent/test/browser-call-rendezvous.test.ts b/apps/brunch-agent/src/shared/conversation/browser-call-rendezvous.test.ts similarity index 99% rename from apps/brunch-agent/test/browser-call-rendezvous.test.ts rename to apps/brunch-agent/src/shared/conversation/browser-call-rendezvous.test.ts index 79beefe8d67..41453dd8af1 100644 --- a/apps/brunch-agent/test/browser-call-rendezvous.test.ts +++ b/apps/brunch-agent/src/shared/conversation/browser-call-rendezvous.test.ts @@ -8,7 +8,7 @@ import { issueBrowserCall, renewBrowserCall, settleBrowserCall, -} from "../src/shared/conversation/browser-call-rendezvous.ts"; +} from "./browser-call-rendezvous.ts"; afterEach(() => vi.useRealTimers()); const verify = async (result: Result): Promise => result; diff --git a/apps/brunch-agent/test/conversation-identity.test.ts b/apps/brunch-agent/src/shared/conversation/conversation-identity.test.ts similarity index 97% rename from apps/brunch-agent/test/conversation-identity.test.ts rename to apps/brunch-agent/src/shared/conversation/conversation-identity.test.ts index aeec7c783dd..dc0c9802fed 100644 --- a/apps/brunch-agent/test/conversation-identity.test.ts +++ b/apps/brunch-agent/src/shared/conversation/conversation-identity.test.ts @@ -6,7 +6,7 @@ import { flueConversationId, flueConversationIdFrom, ownsFlueInstance, -} from "../src/shared/conversation/identity.ts"; +} from "./identity.ts"; test("the same principal and conversation id always hash to the same Flue instance", () => { const identity = { diff --git a/apps/brunch-agent/test/flue-transcript.test.ts b/apps/brunch-agent/src/shared/conversation/flue-transcript.test.ts similarity index 90% rename from apps/brunch-agent/test/flue-transcript.test.ts rename to apps/brunch-agent/src/shared/conversation/flue-transcript.test.ts index 04d9ec327c6..4965f471c50 100644 --- a/apps/brunch-agent/test/flue-transcript.test.ts +++ b/apps/brunch-agent/src/shared/conversation/flue-transcript.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { formatFlueTranscript } from "../src/shared/conversation/transcript"; +import { formatFlueTranscript } from "./transcript"; import type { FlueConversationSnapshot } from "@flue/sdk"; diff --git a/apps/brunch-agent/test/net-change-attribution.test.ts b/apps/brunch-agent/src/shared/conversation/net-change-attribution.test.ts similarity index 96% rename from apps/brunch-agent/test/net-change-attribution.test.ts rename to apps/brunch-agent/src/shared/conversation/net-change-attribution.test.ts index e7b08f76d03..56340a9d89e 100644 --- a/apps/brunch-agent/test/net-change-attribution.test.ts +++ b/apps/brunch-agent/src/shared/conversation/net-change-attribution.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "vitest"; import { toPetrinautId } from "@hashintel/petrinaut-core"; -import { callsForElement } from "../src/shared/conversation/net-changes.ts"; +import { callsForElement } from "./net-changes.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; diff --git a/apps/brunch-agent/test/net-changes.test.ts b/apps/brunch-agent/src/shared/conversation/net-changes.test.ts similarity index 98% rename from apps/brunch-agent/test/net-changes.test.ts rename to apps/brunch-agent/src/shared/conversation/net-changes.test.ts index f64d35f0d02..02e21fb766e 100644 --- a/apps/brunch-agent/test/net-changes.test.ts +++ b/apps/brunch-agent/src/shared/conversation/net-changes.test.ts @@ -2,13 +2,13 @@ import { expect, test } from "vitest"; import { toPetrinautId } from "@hashintel/petrinaut-core"; -import { queryBasis } from "../src/agents/petrinaut/tools/query-basis.ts"; +import { queryBasis } from "../../agents/petrinaut/tools/query-basis.ts"; import { isAppliedChange, latestNetDefinition, latestCanonicalNetReadBefore, netCalls, -} from "../src/shared/conversation/net-changes.ts"; +} from "./net-changes.ts"; import type { FlueConversationSnapshot } from "@flue/sdk"; diff --git a/apps/brunch-agent/test/net-freshness.test.ts b/apps/brunch-agent/src/shared/conversation/net-freshness.test.ts similarity index 98% rename from apps/brunch-agent/test/net-freshness.test.ts rename to apps/brunch-agent/src/shared/conversation/net-freshness.test.ts index b02c61626c6..37a5c3b0b9c 100644 --- a/apps/brunch-agent/test/net-freshness.test.ts +++ b/apps/brunch-agent/src/shared/conversation/net-freshness.test.ts @@ -1,11 +1,11 @@ import { expect, test } from "vitest"; -import { createBrunchContextProjection } from "../src/shared/context-projection"; +import { createBrunchContextProjection } from "../context-projection"; import { createNetViewTracker, expectedNetRevision, noNetViewRefusal, -} from "../src/shared/conversation/net-freshness"; +} from "./net-freshness"; import type { ContextProjectionEntry } from "@flue/runtime"; import type { FlueConversationSnapshot } from "@flue/sdk"; diff --git a/apps/brunch-agent/test/petrinaut-tools/construction-tools.test.ts b/apps/brunch-agent/src/shared/petrinaut-tools/construction-tools.test.ts similarity index 92% rename from apps/brunch-agent/test/petrinaut-tools/construction-tools.test.ts rename to apps/brunch-agent/src/shared/petrinaut-tools/construction-tools.test.ts index 9075b2051b7..ea049545ee2 100644 --- a/apps/brunch-agent/test/petrinaut-tools/construction-tools.test.ts +++ b/apps/brunch-agent/src/shared/petrinaut-tools/construction-tools.test.ts @@ -7,7 +7,7 @@ import { } from "@hashintel/brunch-agent"; import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; -import { asyncCanonicalPetrinautTools } from "../../src/shared/petrinaut-tools/petrinaut-construction.ts"; +import { asyncCanonicalPetrinautTools } from "./petrinaut-construction.ts"; const bound = { binding: { diff --git a/apps/brunch-agent/test/petrinaut-tools/draft-experiment.test.ts b/apps/brunch-agent/src/shared/petrinaut-tools/draft-experiment.test.ts similarity index 97% rename from apps/brunch-agent/test/petrinaut-tools/draft-experiment.test.ts rename to apps/brunch-agent/src/shared/petrinaut-tools/draft-experiment.test.ts index 3cff140eba5..6d2686ffe2e 100644 --- a/apps/brunch-agent/test/petrinaut-tools/draft-experiment.test.ts +++ b/apps/brunch-agent/src/shared/petrinaut-tools/draft-experiment.test.ts @@ -7,9 +7,9 @@ import { } from "@hashintel/brunch-agent"; import { toPetrinautId } from "@hashintel/petrinaut-core"; -import { createDraftExperimentTool } from "../../src/shared/petrinaut-tools/draft-experiment.ts"; +import { createDraftExperimentTool } from "./draft-experiment.ts"; -import type { BrowserToolExecutor } from "../../src/shared/petrinaut-tools/petrinaut-construction.ts"; +import type { BrowserToolExecutor } from "./petrinaut-construction.ts"; const experiment = { name: "Vans on the parcel route", diff --git a/apps/brunch-agent/test/test-compaction-config.test.ts b/apps/brunch-agent/src/shared/test-compaction-config.test.ts similarity index 94% rename from apps/brunch-agent/test/test-compaction-config.test.ts rename to apps/brunch-agent/src/shared/test-compaction-config.test.ts index 499df77c7b4..05066962204 100644 --- a/apps/brunch-agent/test/test-compaction-config.test.ts +++ b/apps/brunch-agent/src/shared/test-compaction-config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { loadTestCompactionConfig } from "../src/shared/test-compaction-config.ts"; +import { loadTestCompactionConfig } from "./test-compaction-config.ts"; describe("local compaction configuration", () => { test.each([undefined, "development", "test", "production"])( diff --git a/apps/brunch-agent/test/admission-voice-evidence.ts b/apps/brunch-agent/test/admission-voice-evidence.ts deleted file mode 100644 index 4d0ef381ea1..00000000000 --- a/apps/brunch-agent/test/admission-voice-evidence.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FlueAiSdkAdapter } from "@local/flue-aisdk-transport"; -import type { UIMessage } from "ai"; - -type ReopenedMessages = ReturnType["reopen"]>; - -/** Serialized Voice-facing evidence, shared without importing the Node probe. */ -export interface AdmissionVoiceEvidence { - readonly question: string; - readonly buffering: readonly { - readonly caseId: string; - readonly projectedDuring: ReopenedMessages; - readonly projectedAfter: ReopenedMessages; - readonly text: string; - readonly privateMarkdown: string; - }[]; -} diff --git a/apps/brunch-agent/test/browser-witness/browser-fixture.ts b/apps/brunch-agent/test/browser-witness/browser-fixture.ts index d3412a6dbd5..5c6d7d49a7f 100644 --- a/apps/brunch-agent/test/browser-witness/browser-fixture.ts +++ b/apps/brunch-agent/test/browser-witness/browser-fixture.ts @@ -20,12 +20,13 @@ import { import { createFlueClient } from "@flue/sdk"; import { chromium, type Page } from "@playwright/test"; +import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; +import { nativeOpenaiProvider } from "@local/pi-faux-providers/native-openai-provider"; + import { agentOwnershipHeaders, flueConversationIdFrom, } from "../../src/shared/conversation/identity.ts"; -import { installFauxProvider } from "../install-faux-provider.ts"; -import { nativeOpenaiProvider } from "../native-openai-provider.ts"; import type { BuiltBrunchApplication } from "../load-built-application.ts"; diff --git a/apps/brunch-agent/test/browser-witness/persona-turn.ts b/apps/brunch-agent/test/browser-witness/persona-turn.ts new file mode 100644 index 00000000000..6b69c7edb9d --- /dev/null +++ b/apps/brunch-agent/test/browser-witness/persona-turn.ts @@ -0,0 +1,224 @@ +/** + * Real-browser regression witness for the persona helper. Build the website + * with either /agents/petrinaut or /agents/chat as VITE_BRUNCH_CHAT_ENDPOINT. + * Uses the built app, local Chrome and synthetic OpenAI only; no paid calls. + */ +import assert from "node:assert/strict"; + +import { fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai"; + +import { toPetrinautId, type SDCPN } from "@hashintel/petrinaut-core"; + +import { + submitPersonaBrowserTurn, + type PersonaAllowedEdit, +} from "../../evals/petrinaut/persona/browser-turn.ts"; +import { + isAppliedChange, + netCalls, +} from "../../src/shared/conversation/net-changes.ts"; +import { loadBuiltBrunchApplication } from "../load-built-application.ts"; +import { + installFauxOpenai, + openaiCallId, + openBrowserFixture, + prepareWitnessProcess, + toolCall, +} from "./browser-fixture.ts"; + +prepareWitnessProcess("persona-turn-witness"); +const faux = installFauxOpenai(); +const app = await loadBuiltBrunchApplication(); +const fixture = await openBrowserFixture(app); + +const documentId = toPetrinautId("persona-approval-net"); +const net: SDCPN = { + places: ["Victim", "Keeper"].map((name, index) => ({ + id: name.toLowerCase(), + name, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: index * 200, + y: 0, + })), + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; +const openNet = () => + fixture.openAssistant("/", { + [documentId]: { + id: documentId, + revisionId: "initial-revision", + title: "Persona approval witness", + lastUpdated: "2020-01-01T00:00:00.000Z", + sdcpn: net, + }, + }); + +/** The actual configured route must admit both the opening and a later utterance. */ +const personaAdmission = async () => { + const page = await fixture.openAssistant(); + page.setDefaultTimeout(5_000); + const answer = "Persona admission completed."; + faux.setResponses([ + fauxAssistantMessage([fauxText(answer)]), + fauxAssistantMessage([fauxText(answer)]), + ]); + const first = await submitPersonaBrowserTurn(page, "Say hello."); + const next = await submitPersonaBrowserTurn(page, "Say hello again.", { + session: first.session, + }); + assert.equal(first.reply.text, answer); + assert.equal(next.reply.text, answer); + assert.equal(next.session.url, first.session.url); + process.stdout.write( + `Persona endpoint: ${new URL(first.session.url).pathname.split("/")[2]}\n`, + ); + await page.context().close(); +}; + +/** Moving the watcher must still allow and record the current turn's requested edit. */ +const currentApproval = async () => { + const page = await openNet(); + const allowed: PersonaAllowedEdit[] = []; + let admitted = false; + faux.setResponses([ + toolCall("getLatestNetDefinition", {}, "current-read"), + toolCall( + "removePlace", + { placeId: "victim", targetSubnetId: null }, + "current-remove", + ), + fauxAssistantMessage([fauxText("Current edit completed.")]), + ]); + const result = await submitPersonaBrowserTurn( + page, + "Read the net and remove Victim.", + { + signal: AbortSignal.timeout(30_000), + onAdmission: async () => { + admitted = true; + }, + onAllowedEdit: async (edit) => { + assert( + admitted, + "An approval must be recorded after its utterance is admitted", + ); + allowed.push(edit); + }, + }, + ); + assert.deepEqual(allowed, [{ items: ["Remove place — Victim"] }]); + const removal = netCalls(result.snapshot).find( + ({ toolCallId }) => toolCallId === openaiCallId("current-remove"), + ); + assert(removal && isAppliedChange(removal)); + const stored = await fixture.storedDocument<{ sdcpn: SDCPN }>( + page, + documentId, + ); + assert.deepEqual( + stored?.sdcpn.places.map(({ name }) => name), + ["Keeper"], + ); + await page.context().close(); +}; + +/** An unanswered panel can survive a failed stream, but must not poison the next persona turn. */ +const staleApprovalAfterDisconnect = async () => { + const page = await openNet(); + const approval = page.getByRole("region", { + name: "Approve destructive edits", + exact: true, + }); + const stop = page.getByRole("button", { + name: "Stop AI response", + exact: true, + }); + faux.setResponses([ + toolCall("getLatestNetDefinition", {}, "stale-read"), + toolCall( + "removePlace", + { placeId: "victim", targetSubnetId: null }, + "stale-remove", + ), + ]); + // A normal composer turn leaves the approval unanswered. No fabricated panel. + await fixture.send(page, "Read the net and remove Victim."); + await approval.waitFor({ state: "visible", timeout: 15_000 }); + assert(await stop.isVisible()); + fixture.cutStreams(10); + try { + await stop.waitFor({ state: "hidden", timeout: 20_000 }); + assert( + await approval.isVisible(), + "The failed turn must leave the real pending approval visible", + ); + } finally { + fixture.restoreStreams(); + } + + const answer = "No old edit approved."; + // The new utterance may join the still-running server submission or start its own. + faux.setResponses([ + fauxAssistantMessage([fauxText(answer)]), + fauxAssistantMessage([fauxText(answer)]), + ]); + const allowed: PersonaAllowedEdit[] = []; + const result = await submitPersonaBrowserTurn( + page, + "Do not remove Victim; just reply.", + { + signal: AbortSignal.timeout(75_000), + onAllowedEdit: async (edit) => { + allowed.push(edit); + }, + }, + ); + assert.equal(result.reply.text, answer); + assert.deepEqual( + allowed, + [], + "Do not attribute an old approval to the new utterance", + ); + const stored = await fixture.storedDocument<{ sdcpn: SDCPN }>( + page, + documentId, + ); + assert.deepEqual( + stored?.sdcpn.places.map(({ name }) => name), + ["Victim", "Keeper"], + ); + assert( + !netCalls(result.snapshot).some( + (call) => + call.toolCallId === openaiCallId("stale-remove") && + isAppliedChange(call), + ), + ); + await page.context().close(); +}; + +try { + for (const witness of [ + personaAdmission, + currentApproval, + staleApprovalAfterDisconnect, + ]) { + // oxlint-disable-next-line eslint/no-await-in-loop -- Cases share the scripted provider queue. + await witness(); + process.stdout.write(`PASS ${witness.name}\n`); + } + assert.deepEqual(fixture.errors, []); + assert.deepEqual(fixture.blocked, []); + process.stdout.write("PERSONA_BROWSER_WITNESS_PASS\n"); +} finally { + try { + await fixture.close(); + } finally { + await app.stop(); + } +} diff --git a/apps/brunch-agent/test/browser-witness/petrinaut-panel.ts b/apps/brunch-agent/test/browser-witness/petrinaut-panel.ts index 8e456fcfa6c..0760cac9eca 100644 --- a/apps/brunch-agent/test/browser-witness/petrinaut-panel.ts +++ b/apps/brunch-agent/test/browser-witness/petrinaut-panel.ts @@ -415,8 +415,8 @@ const callsAfterStreamLoss = async () => { binding.documentId, ); assert.deepEqual( - stored?.sdcpn.places.map(({ id }) => id), - ["queue"], + stored?.sdcpn.places.map(({ name }) => name), + ["Queue"], ); } finally { fixture.restoreStreams(); diff --git a/apps/brunch-agent/test/integration/admission-controls.integration.ts b/apps/brunch-agent/test/integration/admission-controls.integration.ts index 456cbb75c1f..3d526ab4f15 100644 --- a/apps/brunch-agent/test/integration/admission-controls.integration.ts +++ b/apps/brunch-agent/test/integration/admission-controls.integration.ts @@ -20,21 +20,17 @@ import { } from "@flue/sdk"; import { + agentOwnershipHeaders, browserToolOutput, brunchTools, projectBrunchMessageMetadata, } from "@hashintel/brunch-agent"; import { createFlueAiSdkAdapter } from "@local/flue-aisdk-transport"; +import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; -import { - agentOwnershipHeaders, - flueConversationIdFrom, -} from "../../src/shared/conversation/identity.ts"; -import { installFauxProvider } from "../install-faux-provider.ts"; +import { flueConversationId } from "../../src/shared/conversation/identity.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; -import type { AdmissionVoiceEvidence } from "../admission-voice-evidence.ts"; - const directory = process.env.A2_OUTPUT_DIRECTORY ?? mkdtempSync(join(tmpdir(), "admission-")); if (process.env.A2_OUTPUT_DIRECTORY !== undefined) { @@ -122,9 +118,7 @@ const run = async () => { }, }; const client = createFlueClient({ - url: `http://brunch.local/agents/petrinaut/${flueConversationIdFrom( - identity, - )}`, + url: `http://brunch.local/agents/petrinaut/${flueConversationId(identity)}`, headers: agentOwnershipHeaders(identity), fetch: async (input, init) => application.fetch( @@ -330,8 +324,13 @@ const run = async () => { privateMarkdown, }); } - const voice: AdmissionVoiceEvidence = { question, buffering }; - return { observations, refusals, buffering, question, wire, voice }; + return { + observations, + refusals, + buffering, + question, + wire, + }; } finally { await application.stop(); unobserve(); diff --git a/apps/brunch-agent/test/integration/admission-controls.test.ts b/apps/brunch-agent/test/integration/admission-controls.test.ts index a49dfe801f0..70c002effcb 100644 --- a/apps/brunch-agent/test/integration/admission-controls.test.ts +++ b/apps/brunch-agent/test/integration/admission-controls.test.ts @@ -1,34 +1,23 @@ -import { join } from "node:path"; - import { isToolUIPart, type UIMessageChunk } from "ai"; import { beforeAll, expect, test } from "vitest"; import { brunchTools } from "@hashintel/brunch-agent"; import { createFlueUiStream } from "@local/flue-aisdk-transport"; -import { runNodeScript } from "./run-node-script"; - -import type { AdmissionControlsResult } from "./admission-controls.integration"; +import { + runAdmissionControls, + type AdmissionControlsResult, +} from "./run-admission-controls.ts"; let result: AdmissionControlsResult; let serverOutput: string; let localDiagnosticOutput: string; beforeAll(async () => { - const { exitCode, stdout, stderr } = await runNodeScript( - join(import.meta.dirname, "admission-controls.integration.ts"), - join(import.meta.dirname, "../../../.."), - { NODE_ENV: "development" }, - ); - if (exitCode !== 0) throw new Error(stderr || stdout); - serverOutput = stdout; - localDiagnosticOutput = stderr; - const line = stdout - .split("\n") - .find((entry) => entry.startsWith("ADMISSION_CONTROLS ")); - if (line === undefined) throw new Error(stdout); - result = JSON.parse( - line.slice("ADMISSION_CONTROLS ".length), - ) as AdmissionControlsResult; + ({ + result, + stdout: serverOutput, + stderr: localDiagnosticOutput, + } = await runAdmissionControls({ NODE_ENV: "development" })); }); test("production rejects every mixed proposal outside the allowlist before publishing or partially executing it", () => { diff --git a/apps/brunch-agent/test/integration/build-artifact.test.ts b/apps/brunch-agent/test/integration/build-artifact.test.ts index 61eea07b8f7..cb60adad1c9 100644 --- a/apps/brunch-agent/test/integration/build-artifact.test.ts +++ b/apps/brunch-agent/test/integration/build-artifact.test.ts @@ -23,7 +23,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { brunchHeaders } from "@hashintel/brunch-agent"; -import { loadBuiltBrunchApplication } from "../load-built-application"; +import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const DEV_APP = fileURLToPath(new URL("../..", import.meta.url)).replace( /[/\\]$/u, @@ -271,6 +271,6 @@ describe("the emitted client bundle", () => { }); }); -// The production asset route is tested in `apps/brunch-agent/test/assets.test.ts`, +// The production asset route is tested in `apps/brunch-agent/src/server/http/assets.test.ts`, // against the handler module directly: the emitted server bundle targets // Node (`node:sqlite`), so the handler test isolates the asset policy. diff --git a/apps/brunch-agent/test/integration/compaction.integration.ts b/apps/brunch-agent/test/integration/compaction.integration.ts index bda13fee825..d10d4708cee 100644 --- a/apps/brunch-agent/test/integration/compaction.integration.ts +++ b/apps/brunch-agent/test/integration/compaction.integration.ts @@ -20,11 +20,12 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; +import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; + import { agentOwnershipHeaders, flueConversationIdFrom, } from "../../src/shared/conversation/identity.ts"; -import { installFauxProvider } from "../install-faux-provider.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const directory = diff --git a/apps/brunch-agent/test/integration/compaction.test.ts b/apps/brunch-agent/test/integration/compaction.test.ts index 1d8be6a0075..86ba11d6ae5 100644 --- a/apps/brunch-agent/test/integration/compaction.test.ts +++ b/apps/brunch-agent/test/integration/compaction.test.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { expect, test } from "vitest"; -import { runNodeScript } from "./run-node-script"; +import { runNodeScript } from "./run-node-script.ts"; test("compaction removes earlier results from the model's context while the Ledger still compiles", async () => { const { exitCode, stdout, stderr } = await runNodeScript( diff --git a/apps/brunch-agent/test/integration/guidance.integration.ts b/apps/brunch-agent/test/integration/guidance.integration.ts index 9011e069fb3..26c6d6f450c 100644 --- a/apps/brunch-agent/test/integration/guidance.integration.ts +++ b/apps/brunch-agent/test/integration/guidance.integration.ts @@ -12,11 +12,12 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; +import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; + import { agentOwnershipHeaders, flueConversationIdFrom, } from "../../src/shared/conversation/identity.ts"; -import { installFauxProvider } from "../install-faux-provider.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const directory = mkdtempSync(join(tmpdir(), "brunch-guidance-")); diff --git a/apps/brunch-agent/test/integration/guidance.test.ts b/apps/brunch-agent/test/integration/guidance.test.ts index cb1ec84af02..1646a55171e 100644 --- a/apps/brunch-agent/test/integration/guidance.test.ts +++ b/apps/brunch-agent/test/integration/guidance.test.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { expect, test } from "vitest"; -import { runNodeScript } from "./run-node-script"; +import { runNodeScript } from "./run-node-script.ts"; test("the built app activates native skills, reads packaged resources and keeps the Ledger", async () => { const { exitCode, stdout, stderr } = await runNodeScript( diff --git a/apps/brunch-agent/test/history-retention-crash.integration.ts b/apps/brunch-agent/test/integration/history-retention-crash.integration.ts similarity index 97% rename from apps/brunch-agent/test/history-retention-crash.integration.ts rename to apps/brunch-agent/test/integration/history-retention-crash.integration.ts index c715730d3d8..5a591f6a377 100644 --- a/apps/brunch-agent/test/history-retention-crash.integration.ts +++ b/apps/brunch-agent/test/integration/history-retention-crash.integration.ts @@ -13,12 +13,13 @@ import { } from "@earendil-works/pi-ai"; import { createFlueClient } from "@flue/sdk"; +import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; + import { agentOwnershipHeaders, flueConversationIdFrom, -} from "../src/shared/conversation/identity.ts"; -import { installFauxProvider } from "./install-faux-provider.ts"; -import { loadBuiltBrunchApplication } from "./load-built-application.ts"; +} from "../../src/shared/conversation/identity.ts"; +import { loadBuiltBrunchApplication } from "../load-built-application.ts"; import type { AgentSendResult, FlueConversationSnapshot } from "@flue/sdk"; diff --git a/apps/brunch-agent/test/integration/history-retention-crash.test.ts b/apps/brunch-agent/test/integration/history-retention-crash.test.ts index b33f0fcdc3f..69f6941b999 100644 --- a/apps/brunch-agent/test/integration/history-retention-crash.test.ts +++ b/apps/brunch-agent/test/integration/history-retention-crash.test.ts @@ -9,15 +9,15 @@ import { assertCrashRecovery, type CrashRecoveryKind, } from "./history-retention-crash-audit"; -import { runNodeScript } from "./run-node-script"; +import { runNodeScript } from "./run-node-script.ts"; const repositoryRoot = join(import.meta.dirname, "../../../.."); const crashScript = join( import.meta.dirname, - "../history-retention-crash.integration.ts", + "./history-retention-crash.integration.ts", ); const runtimeHook = pathToFileURL( - join(import.meta.dirname, "../history-retention-runtime-hook.ts"), + join(import.meta.dirname, "./history-retention-runtime-hook.ts"), ).href; const wasKilled = (result: { diff --git a/apps/brunch-agent/test/history-retention-runtime-hook.ts b/apps/brunch-agent/test/integration/history-retention-runtime-hook.ts similarity index 100% rename from apps/brunch-agent/test/history-retention-runtime-hook.ts rename to apps/brunch-agent/test/integration/history-retention-runtime-hook.ts diff --git a/apps/brunch-agent/test/integration/ledger-history.integration.ts b/apps/brunch-agent/test/integration/ledger-history.integration.ts index b2a03dd6c41..ec5cd4ebd08 100644 --- a/apps/brunch-agent/test/integration/ledger-history.integration.ts +++ b/apps/brunch-agent/test/integration/ledger-history.integration.ts @@ -22,12 +22,12 @@ import { import { createFlueClient } from "@flue/sdk"; import { foldCommits } from "@hashintel/brunch-agent"; +import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; import { agentOwnershipHeaders, flueConversationIdFrom, } from "../../src/shared/conversation/identity.ts"; -import { installFauxProvider } from "../install-faux-provider.ts"; import { loadBuiltBrunchApplication } from "../load-built-application.ts"; const directory = diff --git a/apps/brunch-agent/test/integration/ledger-history.test.ts b/apps/brunch-agent/test/integration/ledger-history.test.ts index 5f336f24f0d..998ab767d77 100644 --- a/apps/brunch-agent/test/integration/ledger-history.test.ts +++ b/apps/brunch-agent/test/integration/ledger-history.test.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { expect, test } from "vitest"; -import { runNodeScript } from "./run-node-script"; +import { runNodeScript } from "./run-node-script.ts"; test("the Ledger is reconstructed from canonical history within a turn and after a restart", async () => { const { exitCode, stdout, stderr } = await runNodeScript( diff --git a/apps/brunch-agent/test/integration/run-admission-controls.ts b/apps/brunch-agent/test/integration/run-admission-controls.ts new file mode 100644 index 00000000000..3037f3d3dcb --- /dev/null +++ b/apps/brunch-agent/test/integration/run-admission-controls.ts @@ -0,0 +1,36 @@ +import { join } from "node:path"; + +import { runNodeScript, type NodeScriptResult } from "./run-node-script.ts"; + +import type { AdmissionControlsResult } from "./admission-controls.integration.ts"; + +export type { AdmissionControlsResult }; + +const outputPrefix = "ADMISSION_CONTROLS "; + +/** Runs the probe in its own process, since it installs module hooks before loading the build. */ +export const runAdmissionControls = async ( + env: Parameters[2] = {}, +): Promise< + Pick & { + readonly result: AdmissionControlsResult; + } +> => { + const { exitCode, stdout, stderr } = await runNodeScript( + join(import.meta.dirname, "admission-controls.integration.ts"), + join(import.meta.dirname, "../../../.."), + env, + ); + if (exitCode !== 0) throw new Error(stderr || stdout); + const line = stdout + .split("\n") + .find((entry) => entry.startsWith(outputPrefix)); + if (line === undefined) throw new Error(stdout); + return { + result: JSON.parse( + line.slice(outputPrefix.length), + ) as AdmissionControlsResult, + stderr, + stdout, + }; +}; diff --git a/apps/brunch-agent/vitest.config.ts b/apps/brunch-agent/vitest.config.ts index 0effd623845..bf1c6660dd6 100644 --- a/apps/brunch-agent/vitest.config.ts +++ b/apps/brunch-agent/vitest.config.ts @@ -2,7 +2,7 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["test/**/*.test.ts", "evals/**/*.test.ts"], + include: ["src/**/*.test.ts", "test/**/*.test.ts", "evals/**/*.test.ts"], // Built-runtime subprocess probes retain their five-second oracle budgets without competing files. fileParallelism: false, exclude: [...configDefaults.exclude, "test/integration/**"], diff --git a/apps/petrinaut-website/.oxlintrc.json b/apps/petrinaut-website/.oxlintrc.json index 7e38f4716e4..e8ce8ff2601 100644 --- a/apps/petrinaut-website/.oxlintrc.json +++ b/apps/petrinaut-website/.oxlintrc.json @@ -73,6 +73,18 @@ "import/no-self-import": "error", "import/no-cycle": "error", + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": "^(\\.\\./)+((apps|libs|tests|@[a-z0-9-]+)/|[^./][^/]*/(src|dist|test)/)", + "message": "Import another workspace package by its package name, not a relative path into it." + } + ] + } + ], + "react/jsx-pascal-case": ["error", { "allowAllCaps": true }], "react/no-danger": "error", "react/jsx-no-target-blank": ["error", { "enforceDynamicLinks": "always" }], diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 0de1c033b75..96e9f14d85e 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -68,6 +68,8 @@ When Voice is enabled for Brunch, Labs also shows **Realtime mode**, off by defa A Brunch-focused deployment or test launch may set `VITE_PETRINAUT_DEFAULT_ASSISTANT=brunch`; explicit browser-local assistant choices remain authoritative, so changing the launch fallback does not migrate existing users. With `VITE_BRUNCH_CHAT_ENDPOINT` configured, the command palette (⌘K) continues to offer **Use Brunch** and, once switched, **Use the stock Petrinaut assistant**. With the stock assistant selected, the panel talks to `/api/chat` with the stock tool surface, keeps its messages in the local store, and creates no Flue client, mounts no Brunch tools and shows no Ledger pane or Voice; Brunch's conversation lives in Flue history and is untouched. Switching back restores it. Without a configured endpoint, the Labs control remains visible but disabled, the stock assistant is the only one, and no command is offered. +While Brunch is the assistant, **User settings → Plugins** lists **Brunch**, the `website.brunch` plugin in `src/main/app/plugins/brunch/editor-api-plugin.tsx`. It has no switch: Brunch's cards read the open net, its experiments and the editor's navigation through its `api`, and without it they show a notice instead and report the failure. It leaves the list with the Brunch assistant. + Voice is available only when Brunch is selected, the browser-local Voice preference is enabled, and the existing server capability check reports Voice available. Enabling the preference does not start microphone capture or a provider session. ## Snapshot links diff --git a/apps/petrinaut-website/docs/task-dependencies.json b/apps/petrinaut-website/docs/task-dependencies.json index a5e54d4c975..16101b08fe0 100644 --- a/apps/petrinaut-website/docs/task-dependencies.json +++ b/apps/petrinaut-website/docs/task-dependencies.json @@ -93,51 +93,6 @@ "examples:generate" ] }, - "test:integration": { - "dependsOn": [ - "@apps/brunch-agent#build", - "@hashintel/brunch-agent#build", - "@hashintel/ds-components#build", - "@hashintel/ds-components#codegen", - "@hashintel/petrinaut#build", - "@hashintel/petrinaut-core#build", - "@local/flue-aisdk-transport#build", - "codegen", - "examples:generate" - ], - "env": [ - "TEST_COVERAGE" - ], - "affectedBy": [ - "@apps/brunch-agent", - "@blockprotocol/graph", - "@blockprotocol/type-system", - "@local/advanced-types", - "@local/harpc-client", - "@local/hash-backend-utils", - "@local/hash-codec", - "@local/hash-graph-authorization", - "@local/hash-graph-client", - "@local/hash-graph-sdk", - "@local/hash-graph-store", - "@local/hash-isomorphic-utils", - "@local/internal-api-client", - "@local/status", - "error-stack", - "error-stack-macros", - "harpc-types", - "harpc-wire-protocol", - "hash-codec", - "hash-codegen", - "hash-graph-api", - "hash-graph-authorization", - "hash-graph-store", - "hash-graph-temporal-versioning", - "hash-graph-types", - "hash-temporal-client", - "type-system" - ] - }, "test:unit": { "dependsOn": [ "@hashintel/brunch-agent#build", diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index 23d1f3fb28c..1a40c6d9189 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -14,7 +14,6 @@ "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit && tsgo --noEmit --project api/tsconfig.json", "preview": "vite preview", - "test:integration": "vitest run --config vitest.integration.config.ts", "test:unit": "vitest run --passWithNoTests" }, "dependencies": { @@ -40,9 +39,7 @@ "zod": "4.4.3" }, "devDependencies": { - "@earendil-works/pi-ai": "0.83.0", "@fast-check/vitest": "0.4.1", - "@flue/runtime": "2.0.3", "@tanstack/router-generator": "1.167.32", "@tanstack/router-plugin": "1.168.34", "@testing-library/dom": "10.4.1", diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx index ee05acc719c..20e48fbb42e 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/assistant-labs-settings.tsx @@ -3,7 +3,7 @@ import { useId } from "react"; import { Toggle } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import type { OpenAIVoiceConfig } from "../plugins/voice/session/voice-interview-control"; +import type { OpenAIVoiceConfig } from "../plugins/brunch/voice/session/voice-interview-control"; import type { AssistantSelection } from "./assistant-selection"; const sectionStyle = css({ 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 d51d5ce1b33..aaa4779b9d0 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 @@ -36,8 +36,8 @@ import { canonicalPetrinautClientToolNames, brunchPetrinautClientToolNames, } from "../plugins/brunch/tools/brunch-client-tools"; +import { OpenAIRealtimeSession } from "../plugins/brunch/voice/realtime/openai-realtime-session"; import { demoPlugins } from "../plugins/demo-plugins"; -import { OpenAIRealtimeSession } from "../plugins/voice/realtime/openai-realtime-session"; import { assistantSelectionStorageKey, defaultAssistantSelection, @@ -2124,6 +2124,24 @@ describe("assistant selection", () => { ).toEqual(["demo.assistant.switch"]); }); + test("adds the Brunch plugin only while Brunch is the assistant", async () => { + seedStoredNet(); + localStorage.setItem(assistantSelectionStorageKey, "brunch"); + flueClientMock.current = flueHistoryClient(); + render( {}} search={{}} />); + await waitFor(() => + expect( + editorProps.current?.plugins?.map(({ manifest }) => manifest.id), + ).toEqual([ + ...demoPlugins.map(({ manifest }) => manifest.id), + "website.brunch", + ]), + ); + + switchAssistant(/Use the stock Petrinaut assistant/); + await waitFor(() => expect(editorProps.current?.plugins).toBe(demoPlugins)); + }); + test("without a configured Brunch endpoint there is no choice to make", () => { brunchPreviewConfig.isBrunchConfigured = false; seedStoredNet(); 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 377ec9c1c82..e8c38067bcb 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 @@ -3,90 +3,28 @@ * @role Editable demo shell: nets in local storage, one live document handle */ -import { createFlueClient } from "@flue/sdk"; -import { - use, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useState, - useSyncExternalStore, -} from "react"; +import { useMemo, useState } from "react"; -import { - agentOwnershipHeaders, - flueConversationIdWeb, -} from "@hashintel/brunch-agent"; import { CommandRegistryProvider, - ErrorTrackerContext, useCommand, } from "@hashintel/petrinaut/react"; import { DefaultChatTransport, Petrinaut, - type PetrinautAiComposerControlContext, + type PetrinautAiAssistant, type PetrinautAiMessage, } from "@hashintel/petrinaut/ui"; import { useSharedSearchNavigation } from "../../../examples/use-shared-search-navigation"; import { StatusPage } from "../../../shared/status-page"; import { VOICE_REQUEST_ID_HEADER } from "../../../voice-diagnostics"; -import { createBrunchFlueAdapter } from "../plugins/brunch/brunch-flue-adapter"; -import { - BrunchPanelConversationTracker, - createBrunchPanelTransport, -} from "../plugins/brunch/brunch-panel-transport"; -import { resolveBrunchPreviewConfig } from "../plugins/brunch/brunch-preview-config"; -import { - brunchEvaluationConversationIdFrom, - getOrCreateBrunchConversationId, - ordinaryConstructionConversationIdFrom, - replaceBrunchConversationId, -} from "../plugins/brunch/conversation/brunch-conversation-id"; -import { getOrCreateBrunchPrincipal } from "../plugins/brunch/conversation/brunch-principal"; -import { useFlueChatHistory } from "../plugins/brunch/conversation/use-flue-chat-history"; -import { - useProcessAgentBinding, - type FixtureProcessAgentConfiguration, - type ProcessAgentBinding, -} from "../plugins/brunch/conversation/use-process-agent-binding"; -import { foldBrunchLedgerHistory } from "../plugins/brunch/ledger/brunch-ledger-history"; -import { BrunchLedgerPane } from "../plugins/brunch/ledger/brunch-ledger-pane"; -import { requestFlueStop } from "../plugins/brunch/plugin/request-flue-stop"; -import { useImmutableReplayBaseline } from "../plugins/brunch/plugin/use-immutable-replay-baseline"; import { - brunchPetrinautClientToolNames, - canonicalPetrinautClientToolNames, -} from "../plugins/brunch/tools/brunch-client-tools"; -import { - createBrunchDraftExperimentInteractiveTool, - resolveDraftAuthorityFromHistory, -} from "../plugins/brunch/tools/brunch-draft-experiment-interactive-tool"; -import { BrunchExperimentFollowUp } from "../plugins/brunch/tools/brunch-experiment-follow-up"; -import { - createBrunchMutationAdmission, - createBrunchMutationApprovalCoordinator, - createBrunchMutationApprovalInteractiveTools, -} from "../plugins/brunch/tools/brunch-mutation-approval"; -import { - createCanonicalPetrinautHostTools, - issuedCanonicalCallsFromHistory, - EMPTY_CANONICAL_PETRINAUT_REPLAY, - type CanonicalPetrinautReplay, - type CanonicalPetrinautReplayReadiness, -} from "../plugins/brunch/tools/brunch-petrinaut-tools"; -import { resolveBrunchToolPresentation } from "../plugins/brunch/tools/brunch-tool-presentation"; -import { createInBandBrowserCalls } from "../plugins/brunch/tools/in-band-browser-call"; + brunchPreviewConfig, + useBrunchAssistant, +} from "../plugins/brunch/use-brunch-assistant"; import { demoPlugins } from "../plugins/demo-plugins"; import { useLocalStorageAiMessages } from "../plugins/petrinaut-ai/plugin/use-local-storage-ai-messages"; -import { getBrunchVoiceMode } from "../plugins/voice/brunch-voice-mode"; -import { useVoiceMediationHistory } from "../plugins/voice/history/use-voice-mediation-history"; -import { - loadOpenAIVoiceConfig, - type OpenAIVoiceConfig, -} from "../plugins/voice/session/voice-interview-control"; import { AssistantLabsSettings } from "./assistant-labs-settings"; import { isBrunchSelected, @@ -102,15 +40,6 @@ import { useRealtimePreference, useVoicePreference } from "./voice-preference"; import type { SharedExampleSearch } from "../../../examples/example-search"; import type { MinimalNetMetadata, SDCPN } from "@hashintel/petrinaut-core"; -const brunchPreviewConfig = resolveBrunchPreviewConfig( - import.meta.env.VITE_BRUNCH_CHAT_ENDPOINT, -); - -const brunchPrincipal = getOrCreateBrunchPrincipal(); - -const subscribeToNothing = () => () => {}; -const readNothing = () => undefined; - // The stock assistant's transport is the same whether or not Brunch is // configured: selecting the stock assistant must not route it through Brunch. const stockChatTransport = new DefaultChatTransport({ @@ -120,40 +49,6 @@ const stockChatTransport = new DefaultChatTransport({ }), }); -const createBrunchFlueClient = async (conversationId: string) => { - const identity = { conversationId, principalKey: brunchPrincipal }; - const instanceId = await flueConversationIdWeb(identity); - const mountUrl = new URL( - brunchPreviewConfig.chatEndpoint, - window.location.origin, - ); - mountUrl.pathname = `${mountUrl.pathname.replace(/\/+$/u, "")}/${instanceId}`; - return createFlueClient({ - url: mountUrl.href, - headers: () => agentOwnershipHeaders(identity), - }); -}; - -const createConversationTrackerFor = ( - _conversationId: string | null, -): BrunchPanelConversationTracker => new BrunchPanelConversationTracker(); - -const useProcessAgentSession = (input: { - readonly binding: ProcessAgentBinding | null; - readonly brunchSelected: boolean; -}) => { - return useMemo(() => { - const conversationTracker = createConversationTrackerFor( - input.binding?.conversationId ?? null, - ); - const flueClientPromise = - input.brunchSelected && input.binding !== null - ? createBrunchFlueClient(input.binding.conversationId) - : null; - return { conversationTracker, flueClientPromise }; - }, [input.binding, input.brunchSelected]); -}; - /** * The demo's own palette commands, registered beside Petrinaut's. * Brunch and Stock are selected through the product UI. @@ -225,11 +120,7 @@ export const LocalStorageDemoApp = ({ brunchPreviewConfig.isBrunchConfigured, assistantSelection, ); - const [openAIVoiceConfig, setOpenAIVoiceConfig] = useState< - OpenAIVoiceConfig | null | undefined - >(undefined); const selectAssistant = (selection: AssistantSelection) => { - setOpenAIVoiceConfig(selection === "brunch" ? undefined : null); setAssistantSelection(selection); if (selection === "brunch") setVoiceEnabled(true); }; @@ -256,26 +147,6 @@ export const LocalStorageDemoApp = ({ const currentDocument = repository.current; const currentNetId = currentDocument?.documentId ?? null; - useEffect(() => { - if (!brunchSelected) { - return; - } - - const abortController = new AbortController(); - // eslint-disable-next-line react/set-state-in-effect -- reset stale capability before a newly selected Brunch session checks Voice - setOpenAIVoiceConfig(undefined); - void loadOpenAIVoiceConfig( - globalThis.fetch.bind(globalThis), - abortController.signal, - ).then((config) => { - if (!abortController.signal.aborted) { - setOpenAIVoiceConfig(config); - } - }); - - return () => abortController.abort(); - }, [brunchSelected]); - const { activeHandle, unsavedChangeMessage } = useActiveHandle(repository); const existingNets: MinimalNetMetadata[] = repository.records @@ -327,381 +198,41 @@ export const LocalStorageDemoApp = ({ documentId: currentDocument.documentId, title, }); - const [freshConversationIds, setFreshConversationIds] = useState< - Record - >({}); - const baseConstructionConversationId = useMemo(() => { - if (!brunchSelected || currentNetId === null) return undefined; - const initialId = ordinaryConstructionConversationIdFrom(currentNetId); - return ( - freshConversationIds[currentNetId] ?? - getOrCreateBrunchConversationId( - initialId, - window.localStorage, - () => initialId, - ) - ); - }, [brunchSelected, currentNetId, freshConversationIds]); - const fixtureProcessAgentConfiguration = useMemo< - FixtureProcessAgentConfiguration | undefined - >( - () => - baseConstructionConversationId === undefined - ? undefined - : { conversationId: baseConstructionConversationId }, - [baseConstructionConversationId], - ); - const baseProcessAgentBinding = useProcessAgentBinding({ - document: currentDocument, - fixture: fixtureProcessAgentConfiguration, - }); - const processAgentBinding = useMemo( + const brunchEditor = useMemo( () => - baseProcessAgentBinding === null + activeHandle === null ? null : { - ...baseProcessAgentBinding, - conversationId: brunchEvaluationConversationIdFrom( - baseProcessAgentBinding.conversationId, - ), + handle: activeHandle.handle, + readTitle: () => activeHandle.document.title, }, - [baseProcessAgentBinding], + [activeHandle], ); - 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, - }); - const { conversationTracker, flueClientPromise } = processAgentSession; - // Failures the host contains — a stopped batch operation, an unrecordable - // transition, a lost history observation, a failed server tool, a dropped - // update stream — resolve normally for the panel and the model; this is - // where they become visible. - const { captureException } = use(ErrorTrackerContext); - const reportBrunchFailure = useCallback( - ( - failureSource: string, - error: unknown, - tags?: Readonly>, - ) => - captureException(error, { - source: `brunch.${failureSource}`, - ...(tags === undefined ? {} : { tags }), - }), - [captureException], - ); - const constructionBrowser = useMemo( - () => - brunchSelected && processAgentBinding !== null - ? { binding: processAgentBinding } - : undefined, - [brunchSelected, processAgentBinding], - ); - const dynamicClientToolNames = - constructionBrowser === undefined - ? undefined - : brunchPetrinautClientToolNames; - const constructionClientTools = brunchSelected - ? brunchPetrinautClientToolNames - : undefined; - const flueAdapter = useMemo( + const brunchOpenDocument = useMemo( () => - createBrunchFlueAdapter( - { - clientToolNames: - constructionClientTools ?? canonicalPetrinautClientToolNames, - dynamicClientToolNames, - }, - { - onInvalidReopenedMetadata: ({ messageId, error }) => - reportBrunchFailure("reopened-metadata", error, { messageId }), - }, - ), - [constructionClientTools, dynamicClientToolNames, reportBrunchFailure], - ); - const flueHistory = useFlueChatHistory( - flueClientPromise, - conversationId ?? "", - flueAdapter, + currentNetId === null + ? null + : { documentId: currentNetId, editor: brunchEditor }, + [currentNetId, brunchEditor], ); - const replayBindingKey = constructionBrowser - ? `${constructionBrowser.binding.documentId}:${constructionBrowser.binding.conversationId}` - : undefined; - const deriveCanonicalReplay = useCallback( - async (snapshot: NonNullable) => { - if (constructionBrowser === undefined) - return EMPTY_CANONICAL_PETRINAUT_REPLAY; - return issuedCanonicalCallsFromHistory({ snapshot }); + const brunch = useBrunchAssistant({ + selected: brunchSelected, + openDocument: brunchOpenDocument, + voice: { + enabled: voicePreferenceReady && voiceEnabled && realtimePreferenceReady, + realtime: realtimeEnabled, }, - [constructionBrowser], - ); - const canonicalReplayReadiness = useImmutableReplayBaseline< - NonNullable, - CanonicalPetrinautReplay - >({ - bindingKey: replayBindingKey, - emptyReplay: EMPTY_CANONICAL_PETRINAUT_REPLAY, - historyPhase: flueHistory.phase, - snapshot: flueHistory.snapshot, - derive: deriveCanonicalReplay, - }) satisfies CanonicalPetrinautReplayReadiness; - const canonicalHostTools = useMemo(() => { - if (!constructionBrowser || !activeHandle) return undefined; - return createCanonicalPetrinautHostTools({ - handle: activeHandle.handle, - readTitle: () => activeHandle.document.title, - replayReadiness: canonicalReplayReadiness, - }); - }, [activeHandle, canonicalReplayReadiness, constructionBrowser]); - const mediationHistory = useVoiceMediationHistory(conversationId); - const mapVoiceMessages = useSyncExternalStore( - mediationHistory?.subscribe ?? subscribeToNothing, - mediationHistory?.getSnapshot ?? readNothing, - mediationHistory?.getSnapshot ?? readNothing, - ); - useLayoutEffect(() => { - mediationHistory?.sync(flueHistory.snapshot); - }, [mediationHistory, flueHistory.snapshot]); - useEffect(() => { - if (flueHistory.error === undefined) return; - reportBrunchFailure("history", flueHistory.error, { - phase: flueHistory.phase ?? "unknown", - }); - }, [flueHistory.error, flueHistory.phase, reportBrunchFailure]); - const brunchVoiceMode = useMemo( - () => - getBrunchVoiceMode( - brunchSelected && - voicePreferenceReady && - voiceEnabled && - realtimePreferenceReady && - openAIVoiceConfig - ? { - ...openAIVoiceConfig, - provider: realtimeEnabled ? "realtime" : "live", - } - : null, - conversationTracker, - flueHistory.settlements, - flueHistory.snapshot, - mediationHistory, - (toolCallId) => mutationApproval.coordinator.approvalState(toolCallId), - ), - [ - brunchSelected, - conversationTracker, - flueHistory.settlements, - flueHistory.snapshot, - mediationHistory, - mutationApproval, - openAIVoiceConfig, - realtimeEnabled, - realtimePreferenceReady, - voiceEnabled, - voicePreferenceReady, - ], - ); - const transportClientPromise = flueClientPromise; - const petrinautAiChatTransport = useMemo(() => { - if (transportClientPromise !== null) { - return createBrunchPanelTransport( - transportClientPromise, - conversationTracker, - { - ...(constructionBrowser - ? { initialData: { binding: constructionBrowser.binding } } - : {}), - ...(transportClientPromise === flueClientPromise && - conversationId !== null - ? { - liveToolStream: { - headers: agentOwnershipHeaders({ - conversationId, - principalKey: brunchPrincipal, - }), - }, - } - : {}), - adapter: flueAdapter, - onAdmission: flueHistory.refresh, - onToolOutputError: (event) => - reportBrunchFailure("server-tool", new Error(event.errorText), { - submissionId: event.submissionId, - toolCallId: event.toolCallId, - toolName: event.toolName ?? "unknown", - }), - onReattach: (event) => - reportBrunchFailure("stream-reattach", event.error, { - submissionId: event.submissionId, - attempt: event.attempt, - }), - onDisconnect: flueHistory.refresh, - }, - ); - } - return stockChatTransport; - }, [ - conversationTracker, - conversationId, - constructionBrowser, - flueAdapter, - flueClientPromise, - flueHistory.refresh, - reportBrunchFailure, - transportClientPromise, - ]); - - const inBandBrowserTools = useMemo( - () => - constructionBrowser && flueClientPromise - ? createInBandBrowserCalls({ - client: flueClientPromise, - principalKey: brunchPrincipal, - binding: constructionBrowser.binding, - metadataFor: (toolCallId, output) => - canonicalHostTools?.clientToolResultMetadataFor( - toolCallId, - output, - ), - prepareInput: (call) => { - canonicalHostTools?.mapClientToolInput(call); - }, - admit: createBrunchMutationAdmission(mutationApproval.coordinator), - acceptsRevision: (expected) => - canonicalHostTools?.acceptsRevision(expected) ?? false, - }) - : undefined, - [ - constructionBrowser, - flueClientPromise, - canonicalHostTools, - mutationApproval, - ], - ); - - const draftInteractiveTool = useMemo( - () => - constructionBrowser && - activeHandle && - flueClientPromise && - inBandBrowserTools - ? createBrunchDraftExperimentInteractiveTool({ - browserCalls: inBandBrowserTools, - readTitle: () => activeHandle.document.title, - readDraftAuthority: async (toolCallId) => { - const client = await flueClientPromise; - return resolveDraftAuthorityFromHistory( - await client.history(), - toolCallId, - ); - }, - }) - : undefined, - [activeHandle, flueClientPromise, inBandBrowserTools, constructionBrowser], - ); - const aiAssistant = useMemo(() => { - const activityIdentities = - constructionBrowser && flueHistory.ready - ? flueHistory.phase === "absent" - ? [] - : flueHistory.snapshot === undefined - ? undefined - : foldBrunchLedgerHistory(flueHistory.snapshot.messages) - .activityIdentities - : undefined; - return { - additionalTab: constructionBrowser - ? { - label: "Ledger", - activityIdentities, - content: ( - - ), - } - : undefined, - ...(brunchSelected - ? { - primaryLabel: "Chat", - presentation: "brunch" as const, - mapMessagesForDisplay: mapVoiceMessages, - resolveToolPresentation: resolveBrunchToolPresentation, - workingLabel: "Working…", - renderComposerControl: ( - context: PetrinautAiComposerControlContext, - ) => , - } - : {}), - ...(conversationId === null ? {} : { conversationId }), + plugins: demoPlugins, + }); + const stockAssistant = useMemo( + (): PetrinautAiAssistant => ({ 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: [ - ...(inBandBrowserTools ? mutationApprovalTools : []), - ...(draftInteractiveTool ? [draftInteractiveTool] : []), - ], - transport: petrinautAiChatTransport, - ...(flueClientPromise === null - ? {} - : { - requestStop: () => - requestFlueStop(flueClientPromise, conversationTracker), - followMessages: { - // This closure and `messages` below describe the same observed - // snapshot, never a later mutable settlement cache. - canReplace: () => - conversationTracker.canReplaceMessages(flueHistory.snapshot), - }, - }), - messages: - flueClientPromise === null - ? currentNetId - ? aiMessagesByNetId[currentNetId] - : undefined - : flueHistory.messages, + automaticTools: [], + interactiveTools: [], + transport: stockChatTransport, + messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined, onMessages: (messages: PetrinautAiMessage[]) => { - if (!currentNetId || flueClientPromise !== null) { + if (!currentNetId) { return; } @@ -711,18 +242,7 @@ export const LocalStorageDemoApp = ({ })); }, onClearMessages: () => { - if (flueClientPromise !== null && currentNetId !== null) { - const initialId = - ordinaryConstructionConversationIdFrom(currentNetId); - const nextId = `${initialId}:${crypto.randomUUID()}`; - replaceBrunchConversationId(initialId, nextId); - setFreshConversationIds((current) => ({ - ...current, - [currentNetId]: nextId, - })); - return; - } - if (!currentNetId || flueClientPromise !== null) { + if (!currentNetId) { return; } @@ -732,33 +252,10 @@ export const LocalStorageDemoApp = ({ return next; }); }, - ...(brunchVoiceMode - ? { - renderVoiceMode: brunchVoiceMode, - } - : {}), - }; - }, [ - aiMessagesByNetId, - mapVoiceMessages, - brunchSelected, - brunchVoiceMode, - canonicalHostTools, - inBandBrowserTools, - draftInteractiveTool, - mutationApprovalTools, - constructionBrowser, - conversationTracker, - conversationId, - currentNetId, - flueClientPromise, - flueHistory.messages, - flueHistory.phase, - flueHistory.ready, - flueHistory.snapshot, - petrinautAiChatTransport, - setAiMessagesByNetId, - ]); + }), + [aiMessagesByNetId, currentNetId, setAiMessagesByNetId], + ); + const aiAssistant = brunch.assistant ?? stockAssistant; if (repository.status.state === "ready" && currentDocument === null) { return ( @@ -795,7 +292,7 @@ export const LocalStorageDemoApp = ({ createNewNet={createNewNet} loadPetriNet={loadPetriNet} navigation={navigation} - plugins={demoPlugins} + plugins={brunch.plugins} readonly={false} setTitle={setTitle} slots={{ @@ -804,7 +301,7 @@ export const LocalStorageDemoApp = ({ assistantReady={assistantSelectionReady} brunchConfigured={brunchPreviewConfig.isBrunchConfigured} brunchSelected={brunchSelected} - openAIVoiceConfig={openAIVoiceConfig} + openAIVoiceConfig={brunch.openAIVoiceConfig} realtimeEnabled={realtimeEnabled} realtimePreferenceReady={realtimePreferenceReady} selectAssistant={selectAssistant} diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts index cc9243c8e66..c54ba92f91a 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/conversation/use-process-agent-binding.ts @@ -2,8 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import { getOrCreateBrunchConversationId } from "./brunch-conversation-id"; -import type { DocumentRecord } from "../../../local-storage-demo/documents/document-repository"; - export interface ProcessAgentBinding { readonly conversationId: string; readonly documentId: string; @@ -19,7 +17,7 @@ export interface FixtureProcessAgentConfiguration { } export const useProcessAgentBinding = (input: { - readonly document: DocumentRecord | null; + readonly document: { readonly documentId: string } | null; readonly fixture: FixtureProcessAgentConfiguration | undefined; }): ProcessAgentBinding | null => { const documentId = input.document?.documentId; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/editor-api-plugin.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/editor-api-plugin.tsx new file mode 100644 index 00000000000..5418314f59c --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/editor-api-plugin.tsx @@ -0,0 +1,80 @@ +import { use, useEffect, type ReactNode } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; +import { ErrorTrackerContext } from "@hashintel/petrinaut/react"; +import { + definePetrinautPlugin, + pluginService, + usePluginService, + type AccessApi, + type PluginHook, +} from "@hashintel/petrinaut/ui/plugin"; + +const brunchAccess = { + document: "read", + experiments: "write", + editor: "write", +} as const; + +/** What Brunch's cards and controls reach inside the editor. */ +export type BrunchEditorApi = AccessApi; + +const BrunchPlugin = definePetrinautPlugin({ + id: "website.brunch", + name: "Brunch", + description: + "Gives the Brunch assistant's cards the open net, its experiments and the editor's navigation.", + author: "HASH", + // Brunch's cards can't work without it, so the user cannot switch it off. + required: true, + access: brunchAccess, + provides: pluginService(), +}); + +/** Contributes nothing visible: it hands its `api` to Brunch's cards. */ +const useBrunchPlugin: PluginHook = (api) => ({ + provides: api, +}); + +/** Runs while Brunch is the assistant, so its cards read the editor through `api`. */ +export const brunchPlugin = BrunchPlugin(useBrunchPlugin); + +const missingPluginStyle = css({ + padding: "2", + borderRadius: "md", + backgroundColor: "red.a10", + color: "neutral.s100", + fontSize: "xs", + lineHeight: "relaxed", +}); + +/** + * Renders `children` with the Brunch plugin's `api`. The editor shows its view + * only after every plugin has run, so a missing `api` means the host left the + * plugin out or it failed: the card says so and the failure is reported, + * rather than an approval waiting with nothing to answer it. + */ +export const WithBrunchEditorApi = ({ + children, +}: { + children: (api: BrunchEditorApi) => ReactNode; +}) => { + const api = usePluginService(BrunchPlugin); + const { captureException } = use(ErrorTrackerContext); + const missing = api === undefined; + useEffect(() => { + if (missing) { + captureException(new Error("The Brunch editor plugin is not running"), { + source: "brunch.editor-plugin", + }); + } + }, [missing, captureException]); + + return api === undefined ? ( +

+ Brunch can’t show this card because its editor plugin isn’t running. +

+ ) : ( + children(api) + ); +}; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.test.tsx index e8060d7e925..31855ad172e 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.test.tsx @@ -18,16 +18,12 @@ import { createReadableStore, toPetrinautId, } from "@hashintel/petrinaut-core"; -import { - ExperimentHostContext, - OptimizationsContext, - PetrinautInstanceContext, - prepareExperiment, -} from "@hashintel/petrinaut/react"; +import { prepareExperiment } from "@hashintel/petrinaut/react"; import { BrunchDraftExperimentWidget, resetBrunchEditorDrafts, + type BrunchDraftExperimentApi, } from "./brunch-draft-experiment-interactive-tool"; import { describeBudget, @@ -69,12 +65,11 @@ import type { PetrinautExperimentResult, SDCPN, } from "@hashintel/petrinaut-core"; -import type { OptimizationsContextValue } from "@hashintel/petrinaut/react"; +import type { ExperimentRecord } from "@hashintel/petrinaut/react"; import type { PetrinautAiInteractiveToolWidgetProps, PetrinautAiComposerControlContext, } from "@hashintel/petrinaut/ui"; -import type { ReactNode } from "react"; // Distributive so the awaiting/submitted discriminant survives the Pick. type DistributivePick = T extends unknown @@ -185,7 +180,6 @@ const renderWidget = ({ definition, runExperiment, optimizationUnavailableReason = null, - omitOptimizationUnavailableReason = false, submitOutput = async () => {}, claim = async () => {}, readDraftAuthority, @@ -197,7 +191,6 @@ const renderWidget = ({ definition: Petrinaut["definition"]; runExperiment: PetrinautExperimentHost["runExperiment"]; optimizationUnavailableReason?: string | null; - omitOptimizationUnavailableReason?: boolean; submitOutput?: (output: DraftPetrinautExperimentOutput) => Promise; claim?: () => Promise; readDraftAuthority?: (toolCallId: string) => Promise; @@ -216,43 +209,40 @@ const renderWidget = ({ definition, handle: { doc: () => definition.get() }, } as unknown as Petrinaut); - const host: PetrinautExperimentHost = { runExperiment }; - const optimizationActions = { - optimizations: [], - createOptimization: () => Promise.reject(new Error("Not used")), - cancelOptimization: () => {}, - removeOptimization: () => {}, + const api: BrunchDraftExperimentApi = { + document: { net: instance.definition }, + experiments: { + records: createReadableStore([]), + optimizationUnavailableReason: createReadableStore( + optimizationUnavailableReason, + ), + run: runExperiment, + }, + editor: { navigate: vi.fn() }, }; - const optimizations = ( - omitOptimizationUnavailableReason - ? optimizationActions - : { ...optimizationActions, optimizationUnavailableReason } - ) as OptimizationsContextValue; - const wrap = (children: ReactNode) => ( - - - - {children} - - - - ); const utils = render( - wrap( - "Support desk"} - readDraftAuthority={ - readDraftAuthority ?? (async () => revisionOfHandle(instance)) - } - submit={() => {}} - claimAndSubmit={claimAndSubmit} - toolCallId={toolCallId} - />, - ), + "Support desk"} + handle={instance.handle} + readDraftAuthority={ + readDraftAuthority ?? (async () => revisionOfHandle(instance)) + } + submit={() => {}} + claimAndSubmit={claimAndSubmit} + toolCallId={toolCallId} + />, ); - return { ...utils, submit, claimAndSubmit, wrap }; + return { + ...utils, + submit, + claimAndSubmit, + api, + handle: instance.handle, + net: instance.definition, + }; }; const heading = () => @@ -314,7 +304,7 @@ describe("BrunchDraftExperimentWidget", () => { it("prepares, reports drafted once, and starts nothing", async () => { const runExperiment = vi.fn(); - const { submit, claimAndSubmit, rerender, wrap } = renderWidget({ + const { submit, claimAndSubmit, rerender, api, handle } = renderWidget({ input: makeInput(makeRequest(), [ { condition: "No more than 5% of callers abandon.", @@ -360,17 +350,17 @@ describe("BrunchDraftExperimentWidget", () => { // Once the panel marks the call submitted the card keeps its draft and // does not report again. rerender( - wrap( - "Support desk"} - submit={() => {}} - claimAndSubmit={claimAndSubmit} - toolCallId="call_draft_1" - readDraftAuthority={async () => "test-revision"} - />, - ), + "Support desk"} + handle={handle} + submit={() => {}} + claimAndSubmit={claimAndSubmit} + toolCallId="call_draft_1" + readDraftAuthority={async () => "test-revision"} + />, ); expect(submit).toHaveBeenCalledTimes(1); expect(screen.getByRole("button", { name: "Run" })).toBeTruthy(); @@ -493,6 +483,48 @@ describe("BrunchDraftExperimentWidget", () => { instance.dispose(); }); + it("checks the revision against the full document when a disabled extension hides data from `api.document.net`", async () => { + // The revision basis is a pending decision (see the card's `handle` prop): + // Brunch hashes `handle.doc()`, disabled-extension data included. A JSON + // handle drops that data itself, so this handle keeps the colour type its + // capabilities disable, as a stored document can. + const stored = createJsonDocHandle({ + id: "disabled-colours", + initial: { + ...makeDefinition(), + types: [ + { + id: "type__caller", + name: "Caller", + iconSlug: "circle", + displayColor: "#336699", + elements: [], + }, + ], + }, + }); + const instance = createPetrinaut({ + document: { ...stored, capabilities: { disabledExtensions: ["colors"] } }, + }); + expect(revisionOfHandle(instance)).not.toBe( + documentRevisionOf(instance.definition.get()), + ); + + const { submit } = renderWidget({ + input: makeInput(), + readDraftAuthority: async () => revisionOfHandle(instance), + toolCallId: "disabled-colours", + state: awaiting, + definition: instance.definition, + instance, + runExperiment: vi.fn(), + }); + + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + expect(submit.mock.calls[0]?.[0]).toMatchObject({ status: "drafted" }); + instance.dispose(); + }); + it("reports invalid without a Run button when the model lacks the metric", async () => { const runExperiment = vi.fn(); const definition = createReadableStore(makeDefinition()); @@ -710,22 +742,6 @@ describe("BrunchDraftExperimentWidget", () => { expect(runExperiment).not.toHaveBeenCalled(); }); - it("keeps legacy optimization contexts without an availability reason usable", async () => { - const runExperiment = vi.fn(); - const { submit } = renderWidget({ - input: makeInput(), - toolCallId: "legacy-optimization-context", - state: awaiting, - definition: createReadableStore(makeDefinition()), - runExperiment, - omitOptimizationUnavailableReason: true, - }); - - await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); - expect(submit.mock.calls[0]?.[0].summary).not.toContain("undefined"); - expect(screen.getByRole("button", { name: "Run" })).toBeTruthy(); - }); - it("lets a later draft supersede the earlier card", async () => { const runExperiment = vi.fn(); const definition = createReadableStore(makeDefinition()); @@ -908,7 +924,7 @@ describe("BrunchDraftExperimentWidget", () => { it("reports active runs only to their conversation and clears the hint on unmount", async () => { const result = Promise.withResolvers(); - const { submit, wrap } = renderWidget({ + const { submit, net } = renderWidget({ input: makeInput(), toolCallId: "running-hint", state: awaiting, @@ -942,7 +958,7 @@ describe("BrunchDraftExperimentWidget", () => { reportExperimentRunning, }; const followUp = render( - wrap(), + , ); expect(reportExperimentRunning).toHaveBeenLastCalledWith(false); fireEvent.click(screen.getByRole("button", { name: "Run" })); @@ -950,10 +966,13 @@ describe("BrunchDraftExperimentWidget", () => { expect(reportExperimentRunning).toHaveBeenLastCalledWith(true), ); followUp.rerender( - wrap(), + , ); expect(reportExperimentRunning).toHaveBeenLastCalledWith(false); - followUp.rerender(wrap()); + followUp.rerender(); expect(reportExperimentRunning).toHaveBeenLastCalledWith(true); clearRunning.mockClear(); followUp.unmount(); @@ -963,7 +982,7 @@ describe("BrunchDraftExperimentWidget", () => { it("sends completed results once, only to the originating idle conversation", async () => { const definition = createReadableStore(makeDefinition()); - const { submit, wrap } = renderWidget({ + const { submit, net } = renderWidget({ input: makeInput(), toolCallId: "follow-up", state: awaiting, @@ -1000,29 +1019,28 @@ describe("BrunchDraftExperimentWidget", () => { stop: async () => {}, }; const followUp = render( - wrap(), + , ); expect(submitText).not.toHaveBeenCalled(); followUp.rerender( - wrap( - , - ), + , ); expect(submitText).not.toHaveBeenCalled(); followUp.rerender( - wrap( - , - ), + , ); expect(submitText).not.toHaveBeenCalled(); followUp.rerender( - wrap( - , - ), + , ); await waitFor(() => expect(submitText).toHaveBeenCalledOnce()); const submission = submitText.mock.calls[0]?.[0]; @@ -1033,11 +1051,10 @@ describe("BrunchDraftExperimentWidget", () => { expect(submission?.text).toContain(JSON.stringify(finishedResult)); await screen.findByRole("button", { name: "Retry result summary" }); followUp.rerender( - wrap( - , - ), + , ); expect(submitText).toHaveBeenCalledOnce(); fireEvent.click( @@ -1046,9 +1063,10 @@ describe("BrunchDraftExperimentWidget", () => { await waitFor(() => expect(submitText).toHaveBeenCalledTimes(2)); followUp.unmount(); render( - wrap( - , - ), + , ); expect(submitText).toHaveBeenCalledTimes(2); expect(submit).toHaveBeenCalledOnce(); @@ -1170,25 +1188,22 @@ describe("BrunchDraftExperimentWidget", () => { output: {}, }); render( - - {}, - }} - /> - , + {}, + }} + />, ); await waitFor(() => diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx index df24fe44444..a780b701e70 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-draft-experiment-interactive-tool.tsx @@ -1,4 +1,4 @@ -import { use, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { browserToolMutatesDocument, @@ -13,20 +13,20 @@ import { import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { - ExperimentHostContext, - ExperimentsContext, openPetrinautSimulationResource, - OptimizationsContext, prepareExperiment, - usePetrinautInstance, - usePetrinautNavigation, } from "@hashintel/petrinaut/react"; import { definePetrinautAiInteractiveTool, ExperimentExecutionCard, type PetrinautAiInteractiveToolWidgetProps, } from "@hashintel/petrinaut/ui"; +import { useStore } from "@hashintel/petrinaut/ui/plugin"; +import { + WithBrunchEditorApi, + type BrunchEditorApi, +} from "../editor-api-plugin"; import { canonicalPetrinautClientToolNames } from "./brunch-client-tools"; import { describeBudget, @@ -46,10 +46,18 @@ import { documentRevisionOf } from "./shared/document-revision"; import type { createInBandBrowserCalls } from "./in-band-browser-call"; import type { FlueConversationState } from "@flue/sdk"; import type { + PetrinautDocHandle, PetrinautExperimentRequest, SDCPN, } from "@hashintel/petrinaut-core"; +/** The part of the Brunch plugin's `api` the draft card uses. */ +export type BrunchDraftExperimentApi = { + readonly document: Pick; + readonly experiments: BrunchEditorApi["experiments"]; + readonly editor: Pick; +}; + const containerStyle = css({ display: "flex", flexDirection: "column", @@ -255,29 +263,36 @@ const prepareOrExplain = ( export const BrunchDraftExperimentWidget = ({ input, readTitle, + handle, readDraftAuthority, state, claimAndSubmit, toolCallId, + api, }: WidgetProps & { readTitle: () => string; + /** + * The open document's handle. Brunch hashes `handle.doc()`, disabled-extension + * data included; `api.document.net` leaves that data out, so it cannot check + * a revision. Taking the handle keeps that choice inside the card. + */ + handle: Pick; readDraftAuthority: (toolCallId: string) => Promise; /** Claim the issued call, then submit what `prepareOutput` resolves to, so the lease covers preparation. */ claimAndSubmit: ( prepareOutput: () => Promise, ) => Promise; + api: BrunchDraftExperimentApi; }) => { - const instance = usePetrinautInstance(); - const experimentHost = use(ExperimentHostContext); - const { experiments } = use(ExperimentsContext); - const { navigate } = usePetrinautNavigation(); - const optimizationUnavailableReason = - use(OptimizationsContext).optimizationUnavailableReason ?? null; + const experiments = useStore(api.experiments.records); + const optimizationUnavailableReason = useStore( + api.experiments.optimizationUnavailableReason, + ); const executionUnavailable = input.experiment.execution.mode === "optimize" ? optimizationUnavailableReason : null; - const editorDrafts = editorDraftsFor(instance.definition); + const editorDrafts = editorDraftsFor(api.document.net); const { draft, isCurrent } = useEditorDraft(editorDrafts, toolCallId); const preparedOnceRef = useRef(false); const [reviewed, setReviewed] = useState<{ @@ -300,7 +315,7 @@ export const BrunchDraftExperimentWidget = ({ useEffect(() => { if (state !== "awaiting" || preparedOnceRef.current) return; preparedOnceRef.current = true; - const initialDefinition = instance.handle.doc(); + const initialDefinition = handle.doc(); if (!initialDefinition) { // eslint-disable-next-line react/set-state-in-effect -- this effect's one live-model preparation found no document to prepare against setPreparationFailure({ @@ -316,7 +331,7 @@ export const BrunchDraftExperimentWidget = ({ let outcome: ReturnType; try { const readRevision = await readDraftAuthority(toolCallId); - const latestDefinition = instance.handle.doc(); + const latestDefinition = handle.doc(); if (latestDefinition) definition = latestDefinition; outcome = latestDefinition && @@ -391,7 +406,7 @@ export const BrunchDraftExperimentWidget = ({ }, [ executionUnavailable, input, - instance, + handle, readTitle, readDraftAuthority, editorDrafts, @@ -401,7 +416,7 @@ export const BrunchDraftExperimentWidget = ({ ]); const definition = - reviewed?.definition ?? draft?.definition ?? instance.definition.get(); + reviewed?.definition ?? draft?.definition ?? api.document.net.get(); const displayedPrepared = reviewed?.prepared ?? draft?.prepared ?? null; const request = displayedPrepared?.request ?? null; const blocksRun = input.unsupported.some(conditionBlocksRun); @@ -424,7 +439,7 @@ export const BrunchDraftExperimentWidget = ({ return; // Prepare again against the model as it is now: Run must start what the // person sees, and a changed metric or parameter is shown before any call. - const currentDefinition = structuredClone(instance.definition.get()); + const currentDefinition = structuredClone(api.document.net.get()); const current = prepareOrExplain( pending.prepared.request, currentDefinition, @@ -463,16 +478,13 @@ export const BrunchDraftExperimentWidget = ({ run: { phase: "running", controller, progress: null }, }); try { - const result = await experimentHost.runExperiment( - current.prepared.request, - { - signal: controller.signal, - onProgress: (progress) => - editorDrafts.update(toolCallId, { - run: { phase: "running", controller, progress }, - }), - }, - ); + const result = await api.experiments.run(current.prepared.request, { + signal: controller.signal, + onProgress: (progress) => + editorDrafts.update(toolCallId, { + run: { phase: "running", controller, progress }, + }), + }); editorDrafts.update(toolCallId, { run: result.status === "error" @@ -560,12 +572,11 @@ export const BrunchDraftExperimentWidget = ({ onViewExperiment={ canViewExperiment ? () => { - navigate( + api.editor.navigate( openPetrinautSimulationResource({ type: "experiment", id: experimentId, }), - { cause: "user", action: "simulation-resource" }, ); } : undefined @@ -777,10 +788,12 @@ const settleIssuedDraft = async ( export const createBrunchDraftExperimentInteractiveTool = ({ browserCalls, readTitle, + handle, readDraftAuthority, }: { browserCalls: ReturnType; readTitle: () => string; + handle: Pick; readDraftAuthority: (toolCallId: string) => Promise; }) => definePetrinautAiInteractiveTool< @@ -792,17 +805,23 @@ export const createBrunchDraftExperimentInteractiveTool = ({ inputSchema: draftPetrinautExperimentInputSchema, outputSchema: draftPetrinautExperimentOutputSchema, component: (props) => ( - - settleIssuedDraft( - browserCalls, - { toolCallId: props.toolCallId, input: props.input }, - prepareOutput, - ) - } - readTitle={readTitle} - readDraftAuthority={readDraftAuthority} - /> + + {(api) => ( + + settleIssuedDraft( + browserCalls, + { toolCallId: props.toolCallId, input: props.input }, + prepareOutput, + ) + } + readTitle={readTitle} + handle={handle} + readDraftAuthority={readDraftAuthority} + /> + )} + ), }); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-experiment-follow-up.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-experiment-follow-up.tsx index b2dde64a1eb..f17c17b1046 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-experiment-follow-up.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-experiment-follow-up.tsx @@ -1,13 +1,13 @@ import { useEffect, useSyncExternalStore } from "react"; import { Button } from "@hashintel/ds-components"; -import { usePetrinautInstance } from "@hashintel/petrinaut/react"; import { editorDraftsFor, type EditorDraft, } from "./shared/brunch-draft-experiment-drafts"; +import type { BrunchEditorApi } from "../editor-api-plugin"; import type { PetrinautAiComposerControlContext } from "@hashintel/petrinaut/ui"; const sendResults = ( @@ -48,11 +48,13 @@ const hasDraftToolCall = ( /** Sends local run results as a new turn, never as a second draft-tool output. */ export const BrunchExperimentFollowUp = ({ context, + net, }: { context: PetrinautAiComposerControlContext; + /** The open net, whose editor holds the drafts. */ + net: BrunchEditorApi["document"]["net"]; }) => { - const instance = usePetrinautInstance(); - const drafts = editorDraftsFor(instance.definition); + const drafts = editorDraftsFor(net); const snapshot = useSyncExternalStore(drafts.subscribe, drafts.get); const running = [...snapshot.drafts.values()].some( (draft) => diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.in-editor.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.in-editor.test.tsx new file mode 100644 index 00000000000..1511b52e79a --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.in-editor.test.tsx @@ -0,0 +1,134 @@ +/** @vitest-environment jsdom */ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeAll, expect, test, vi } from "vitest"; + +import { createJsonDocHandle } from "@hashintel/petrinaut-core"; +import { ErrorTrackerContext } from "@hashintel/petrinaut/react"; +import { Petrinaut } from "@hashintel/petrinaut/ui"; + +import { + InProcessLspWorker, + NoopResizeObserver, +} from "../../../shared/petrinaut-jsdom"; +import { brunchPlugin } from "../editor-api-plugin"; +import { + createBrunchMutationApprovalCoordinator, + createBrunchMutationApprovalInteractiveTools, +} from "./brunch-mutation-approval"; + +import type { + PetrinautAiAssistant, + PetrinautAiMessage, +} from "@hashintel/petrinaut/ui"; +import type { PetrinautPlugin } from "@hashintel/petrinaut/ui/plugin"; +import type { UIMessageChunk } from "ai"; + +await vi.hoisted(async () => { + const { installPetrinautDomShims } = + await import("../../../shared/petrinaut-jsdom"); + installPetrinautDomShims(); +}); + +beforeAll(async () => { + // Settle the editor's import and theme setup before mounting. + const monaco = await import("monaco-editor"); + monaco.editor.setTheme("vs"); +}, 30_000); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +// Brunch's calls arrive as dynamic tools, which the editor leaves to the host: +// a static `tool-deleteItemsByIds` part is one the editor runs itself. +const pendingRemoval: PetrinautAiMessage = { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolName: "deleteItemsByIds", + toolCallId: "delete-1", + state: "input-available", + input: { items: [{ type: "place", id: "queue" }] }, + }, + ], +}; + +// A turn that stays open, as a real one does while the user decides. If the +// call ever settled without them, a stream that closed at once would let the +// editor's chat send again in a loop that starves the test's timers. +const openStream = () => Promise.resolve(new ReadableStream()); + +/** + * Opens the real editor's assistant on one destructive call waiting for + * approval, with `plugins` installed. + */ +const openPendingApproval = async (plugins: readonly PetrinautPlugin[]) => { + // Kept until the environment is disposed: the language server starts lazily. + vi.stubGlobal("ResizeObserver", NoopResizeObserver); + vi.stubGlobal("Worker", InProcessLspWorker); + const coordinator = createBrunchMutationApprovalCoordinator(); + coordinator.open(); + void coordinator.request({ + toolCallId: "delete-1", + toolName: "deleteItemsByIds", + signal: new AbortController().signal, + }); + const aiAssistant: PetrinautAiAssistant = { + interactiveTools: createBrunchMutationApprovalInteractiveTools(coordinator), + messages: [pendingRemoval], + transport: { + reconnectToStream: () => Promise.resolve(null), + sendMessages: openStream, + }, + }; + const captureException = vi.fn(); + render( + + + , + ); + const showAssistant = await screen.findByRole("button", { + name: "Show AI assistant", + }); + await act(async () => fireEvent.click(showAssistant)); + return { captureException }; +}; + +test("with the Brunch plugin, a waiting removal shows its approval", async () => { + const { captureException } = await openPendingApproval([brunchPlugin]); + + expect(await screen.findByRole("button", { name: "Allow" })).not.toBeNull(); + expect(captureException).not.toHaveBeenCalled(); +}); + +test("without the Brunch plugin, the approval says why it can't show and reports it", async () => { + const { captureException } = await openPendingApproval([]); + + // Plain queries: a failing `findBy` prints the whole editor's DOM. + await vi.waitFor(() => + expect(captureException).toHaveBeenCalledWith(expect.any(Error), { + source: "brunch.editor-plugin", + }), + ); + expect( + screen + .queryByText(/its editor plugin isn’t running/u) + ?.getAttribute("role"), + ).toBe("alert"); + expect(screen.queryByRole("button", { name: "Allow" })).toBeNull(); +}); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.test.tsx index 5a3f6b4618d..0825035b600 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.test.tsx @@ -12,7 +12,6 @@ import { toPetrinautId, type SDCPNInput, } from "@hashintel/petrinaut-core"; -import { PetrinautInstanceContext } from "@hashintel/petrinaut/react"; import { createBrunchMutationAdmission, @@ -53,8 +52,8 @@ const destructiveInput = { ], }; -const editorWith = (initial: SDCPNInput) => - createPetrinaut({ document: createJsonDocHandle({ initial }) }); +const netWith = (initial: SDCPNInput) => + createPetrinaut({ document: createJsonDocHandle({ initial }) }).definition; describe("Brunch destructive edit approval", () => { test("covers every destructive canonical tool and no constructive one", () => { @@ -75,7 +74,7 @@ describe("Brunch destructive edit approval", () => { signal: new AbortController().signal, }); // The equation is missing from the document, so its row shows the id. - const editor = editorWith({ + const net = netWith({ places: [{ id: "queue", name: "Queue", x: 0, y: 0 }], transitions: [ { @@ -94,15 +93,14 @@ describe("Brunch destructive edit approval", () => { ); const submit = vi.fn(); render( - - - , + , ); expect(screen.getByText("Remove place — Queue")).not.toBeNull(); @@ -255,17 +253,14 @@ describe("Brunch destructive edit approval", () => { "deleteItemsByIds", ); render( - - - , + , ); fireEvent.click(screen.getByRole("button", { name: "Allow" })); expect(coordinator.hasPending("historical-call")).toBe(false); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx index b08814ffc32..56d9da9f28e 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-mutation-approval.tsx @@ -8,12 +8,17 @@ import { type PetrinautAiMutationToolName, type SDCPN, } from "@hashintel/petrinaut-core"; -import { usePetrinautInstance, useStore } from "@hashintel/petrinaut/react"; import { definePetrinautAiInteractiveTool, type PetrinautAiInteractiveTool, type PetrinautAiInteractiveToolWidgetProps, } from "@hashintel/petrinaut/ui"; +import { useStore } from "@hashintel/petrinaut/ui/plugin"; + +import { + WithBrunchEditorApi, + type BrunchEditorApi, +} from "../editor-api-plugin"; import type { InBandBrowserCallAdmission } from "./in-band-browser-call"; @@ -330,8 +335,15 @@ export const createBrunchMutationApprovalWidget = ( coordinator: BrunchMutationApprovalCoordinator, toolName: DestructiveToolName, ) => { - const Widget = ({ input, toolCallId }: WidgetProps) => { - const nameOf = nameLookupOf(useStore(usePetrinautInstance().definition)); + const Widget = ({ + input, + toolCallId, + net, + }: WidgetProps & { + /** The open net, which names the items being removed. */ + readonly net: BrunchEditorApi["document"]["net"]; + }) => { + const nameOf = nameLookupOf(useStore(net)); return (
value }; export const createBrunchMutationApprovalInteractiveTools = ( coordinator: BrunchMutationApprovalCoordinator, ): readonly PetrinautAiInteractiveTool[] => - destructiveToolNames.map((toolName) => - definePetrinautAiInteractiveTool({ + destructiveToolNames.map((toolName) => { + const Widget = createBrunchMutationApprovalWidget(coordinator, toolName); + return definePetrinautAiInteractiveTool({ toolName, inputSchema: { parse: (value) => mutationActionInputSchemas[toolName].parse(value), @@ -401,6 +414,10 @@ export const createBrunchMutationApprovalInteractiveTools = ( outputSchema: passthrough, // Earlier rows of the same tool keep their normal presentation. shouldHandle: ({ toolCallId }) => coordinator.hasPending(toolCallId), - component: createBrunchMutationApprovalWidget(coordinator, toolName), - }), - ); + component: (props) => ( + + {(api) => } + + ), + }); + }); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/shared/brunch-draft-experiment-drafts.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/shared/brunch-draft-experiment-drafts.ts index d684e8b6933..31d5661ffed 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/shared/brunch-draft-experiment-drafts.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/tools/shared/brunch-draft-experiment-drafts.ts @@ -84,7 +84,12 @@ const createEditorDrafts = () => { // The definition store survives panel remounts, but belongs to one editor. // Weak keys release drafts when that editor is disposed rather than retaining -// every model and run in a tab-wide singleton. +// every model and run in a tab-wide singleton. Callers pass the Brunch +// plugin's `api.document.net`: the card and the follow-up control read the +// same `api`, whose families Petrinaut builds once per editor. Drafts outlive +// a `readonly` toggle, which rebuilds those families, only because `net` is +// the core instance's own definition store; if Petrinaut ever wraps it, they +// reset on that toggle. let editors = new WeakMap>(); export const editorDraftsFor = (definitionStore: object) => { let drafts = editors.get(definitionStore); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx new file mode 100644 index 00000000000..5afad6f027b --- /dev/null +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant.tsx @@ -0,0 +1,614 @@ +import { createFlueClient } from "@flue/sdk"; +import { + use, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; + +import { + agentOwnershipHeaders, + flueConversationIdWeb, +} from "@hashintel/brunch-agent"; +import { ErrorTrackerContext } from "@hashintel/petrinaut/react"; + +import { createBrunchFlueAdapter } from "./brunch-flue-adapter"; +import { + BrunchPanelConversationTracker, + createBrunchPanelTransport, +} from "./brunch-panel-transport"; +import { resolveBrunchPreviewConfig } from "./brunch-preview-config"; +import { + brunchEvaluationConversationIdFrom, + getOrCreateBrunchConversationId, + ordinaryConstructionConversationIdFrom, + replaceBrunchConversationId, +} from "./conversation/brunch-conversation-id"; +import { getOrCreateBrunchPrincipal } from "./conversation/brunch-principal"; +import { useFlueChatHistory } from "./conversation/use-flue-chat-history"; +import { + useProcessAgentBinding, + type FixtureProcessAgentConfiguration, + type ProcessAgentBinding, +} from "./conversation/use-process-agent-binding"; +import { brunchPlugin, WithBrunchEditorApi } from "./editor-api-plugin"; +import { foldBrunchLedgerHistory } from "./ledger/brunch-ledger-history"; +import { BrunchLedgerPane } from "./ledger/brunch-ledger-pane"; +import { + brunchPetrinautClientToolNames, + canonicalPetrinautClientToolNames, +} from "./tools/brunch-client-tools"; +import { + createBrunchDraftExperimentInteractiveTool, + resolveDraftAuthorityFromHistory, +} from "./tools/brunch-draft-experiment-interactive-tool"; +import { BrunchExperimentFollowUp } from "./tools/brunch-experiment-follow-up"; +import { + createBrunchMutationAdmission, + createBrunchMutationApprovalCoordinator, + createBrunchMutationApprovalInteractiveTools, +} from "./tools/brunch-mutation-approval"; +import { + createCanonicalPetrinautHostTools, + issuedCanonicalCallsFromHistory, + EMPTY_CANONICAL_PETRINAUT_REPLAY, + type CanonicalPetrinautReplay, + type CanonicalPetrinautReplayReadiness, +} from "./tools/brunch-petrinaut-tools"; +import { resolveBrunchToolPresentation } from "./tools/brunch-tool-presentation"; +import { createInBandBrowserCalls } from "./tools/in-band-browser-call"; +import { requestFlueStop } from "./use-brunch-assistant/request-flue-stop"; +import { useImmutableReplayBaseline } from "./use-brunch-assistant/use-immutable-replay-baseline"; +import { getBrunchVoiceMode } from "./voice/brunch-voice-mode"; +import { useVoiceMediationHistory } from "./voice/history/use-voice-mediation-history"; +import { + loadOpenAIVoiceConfig, + type OpenAIVoiceConfig, +} from "./voice/session/voice-interview-control"; + +import type { PetrinautDocHandle } from "@hashintel/petrinaut-core"; +import type { + PetrinautAiAssistant, + PetrinautAiComposerControlContext, +} from "@hashintel/petrinaut/ui"; +import type { PetrinautPlugin } from "@hashintel/petrinaut/ui/plugin"; + +export const brunchPreviewConfig = resolveBrunchPreviewConfig( + import.meta.env.VITE_BRUNCH_CHAT_ENDPOINT, +); + +const brunchPrincipal = getOrCreateBrunchPrincipal(); + +const subscribeToNothing = () => () => {}; +const readNothing = () => undefined; + +const createBrunchFlueClient = async (conversationId: string) => { + const identity = { conversationId, principalKey: brunchPrincipal }; + const instanceId = await flueConversationIdWeb(identity); + const mountUrl = new URL( + brunchPreviewConfig.chatEndpoint, + window.location.origin, + ); + mountUrl.pathname = `${mountUrl.pathname.replace(/\/+$/u, "")}/${instanceId}`; + return createFlueClient({ + url: mountUrl.href, + headers: () => agentOwnershipHeaders(identity), + }); +}; + +const createConversationTrackerFor = ( + _conversationId: string | null, +): BrunchPanelConversationTracker => new BrunchPanelConversationTracker(); + +const useProcessAgentSession = (input: { + readonly binding: ProcessAgentBinding | null; + readonly brunchSelected: boolean; +}) => { + return useMemo(() => { + const conversationTracker = createConversationTrackerFor( + input.binding?.conversationId ?? null, + ); + const flueClientPromise = + input.brunchSelected && input.binding !== null + ? createBrunchFlueClient(input.binding.conversationId) + : null; + return { conversationTracker, flueClientPromise }; + }, [input.binding, input.brunchSelected]); +}; + +/** The open document as Brunch's browser tools reach it. */ +export interface BrunchEditorDocument { + readonly handle: PetrinautDocHandle; + readonly readTitle: () => string; +} + +/** The document open in the editor. */ +export interface BrunchOpenDocument { + /** Its stored id, which Brunch binds the conversation to. */ + readonly documentId: string; + /** Its live handle and title, or `null` while it loads. */ + readonly editor: BrunchEditorDocument | null; +} + +/** Brunch's Voice choices, as the host stores them. */ +export interface BrunchVoicePreferences { + /** The host's Voice preference is loaded and on. */ + readonly enabled: boolean; + /** Use Realtime rather than Live for the next Voice session. */ + readonly realtime: boolean; +} + +export interface BrunchAssistant { + /** + * Brunch's assistant configuration for ``, or `null` + * while Brunch is not selected or no document is bound. + */ + readonly assistant: PetrinautAiAssistant | null; + /** + * Whether the server offers Voice: `undefined` while checking, `null` when it + * does not or Brunch is not selected. + */ + readonly openAIVoiceConfig: OpenAIVoiceConfig | null | undefined; + /** + * The host's plugins, with Brunch's added while it is the assistant. Pass + * this list to ``: Brunch's cards read the editor through + * its plugin and, without it, show a notice in place of each card. + */ + readonly plugins: readonly PetrinautPlugin[]; +} + +/** + * Brunch as the editor's assistant: binds the open document to its Flue + * conversation and assembles the chat, the Ledger tab, Brunch's browser tools + * and Voice. + */ +export const useBrunchAssistant = ({ + selected, + openDocument, + voice, + plugins, +}: { + /** Brunch is configured and chosen as the assistant. */ + readonly selected: boolean; + /** The open document, or `null` while none is open. */ + readonly openDocument: BrunchOpenDocument | null; + readonly voice: BrunchVoicePreferences; + /** The plugins the host gives the editor. */ + readonly plugins: readonly PetrinautPlugin[]; +}): BrunchAssistant => { + const currentNetId = openDocument?.documentId ?? null; + const editor = openDocument?.editor ?? null; + + const [openAIVoiceConfig, setOpenAIVoiceConfig] = useState< + OpenAIVoiceConfig | null | undefined + >(undefined); + // A newly selected Brunch checks Voice afresh; until then it reads as checking. + const [voiceCheckSelected, setVoiceCheckSelected] = useState(selected); + if (selected !== voiceCheckSelected) { + setVoiceCheckSelected(selected); + setOpenAIVoiceConfig(undefined); + } + useEffect(() => { + if (!selected) { + return; + } + + const abortController = new AbortController(); + void loadOpenAIVoiceConfig( + globalThis.fetch.bind(globalThis), + abortController.signal, + ).then((config) => { + if (!abortController.signal.aborted) { + setOpenAIVoiceConfig(config); + } + }); + + return () => abortController.abort(); + }, [selected]); + + const [freshConversationIds, setFreshConversationIds] = useState< + Record + >({}); + const baseConstructionConversationId = useMemo(() => { + if (!selected || currentNetId === null) return undefined; + const initialId = ordinaryConstructionConversationIdFrom(currentNetId); + return ( + freshConversationIds[currentNetId] ?? + getOrCreateBrunchConversationId( + initialId, + window.localStorage, + () => initialId, + ) + ); + }, [selected, currentNetId, freshConversationIds]); + const fixtureProcessAgentConfiguration = useMemo< + FixtureProcessAgentConfiguration | undefined + >( + () => + baseConstructionConversationId === undefined + ? undefined + : { conversationId: baseConstructionConversationId }, + [baseConstructionConversationId], + ); + const baseProcessAgentBinding = useProcessAgentBinding({ + document: openDocument, + fixture: fixtureProcessAgentConfiguration, + }); + const processAgentBinding = useMemo( + () => + baseProcessAgentBinding === null + ? null + : { + ...baseProcessAgentBinding, + conversationId: brunchEvaluationConversationIdFrom( + baseProcessAgentBinding.conversationId, + ), + }, + [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: selected, + }); + const { conversationTracker, flueClientPromise } = processAgentSession; + // Failures the host contains — a stopped batch operation, an unrecordable + // transition, a lost history observation, a failed server tool, a dropped + // update stream — resolve normally for the panel and the model; this is + // where they become visible. + const { captureException } = use(ErrorTrackerContext); + const reportBrunchFailure = useCallback( + ( + failureSource: string, + error: unknown, + tags?: Readonly>, + ) => + captureException(error, { + source: `brunch.${failureSource}`, + ...(tags === undefined ? {} : { tags }), + }), + [captureException], + ); + const constructionBrowser = useMemo( + () => + selected && processAgentBinding !== null + ? { binding: processAgentBinding } + : undefined, + [selected, processAgentBinding], + ); + const dynamicClientToolNames = + constructionBrowser === undefined + ? undefined + : brunchPetrinautClientToolNames; + const constructionClientTools = selected + ? brunchPetrinautClientToolNames + : undefined; + const flueAdapter = useMemo( + () => + createBrunchFlueAdapter( + { + clientToolNames: + constructionClientTools ?? canonicalPetrinautClientToolNames, + dynamicClientToolNames, + }, + { + onInvalidReopenedMetadata: ({ messageId, error }) => + reportBrunchFailure("reopened-metadata", error, { messageId }), + }, + ), + [constructionClientTools, dynamicClientToolNames, reportBrunchFailure], + ); + const flueHistory = useFlueChatHistory( + flueClientPromise, + conversationId ?? "", + flueAdapter, + ); + const replayBindingKey = constructionBrowser + ? `${constructionBrowser.binding.documentId}:${constructionBrowser.binding.conversationId}` + : undefined; + const deriveCanonicalReplay = useCallback( + async (snapshot: NonNullable) => { + if (constructionBrowser === undefined) + return EMPTY_CANONICAL_PETRINAUT_REPLAY; + return issuedCanonicalCallsFromHistory({ snapshot }); + }, + [constructionBrowser], + ); + const canonicalReplayReadiness = useImmutableReplayBaseline< + NonNullable, + CanonicalPetrinautReplay + >({ + bindingKey: replayBindingKey, + emptyReplay: EMPTY_CANONICAL_PETRINAUT_REPLAY, + historyPhase: flueHistory.phase, + snapshot: flueHistory.snapshot, + derive: deriveCanonicalReplay, + }) satisfies CanonicalPetrinautReplayReadiness; + const canonicalHostTools = useMemo(() => { + if (!constructionBrowser || !editor) return undefined; + return createCanonicalPetrinautHostTools({ + handle: editor.handle, + readTitle: editor.readTitle, + replayReadiness: canonicalReplayReadiness, + }); + }, [editor, canonicalReplayReadiness, constructionBrowser]); + const mediationHistory = useVoiceMediationHistory(conversationId); + const mapVoiceMessages = useSyncExternalStore( + mediationHistory?.subscribe ?? subscribeToNothing, + mediationHistory?.getSnapshot ?? readNothing, + mediationHistory?.getSnapshot ?? readNothing, + ); + useLayoutEffect(() => { + mediationHistory?.sync(flueHistory.snapshot); + }, [mediationHistory, flueHistory.snapshot]); + useEffect(() => { + if (flueHistory.error === undefined) return; + reportBrunchFailure("history", flueHistory.error, { + phase: flueHistory.phase ?? "unknown", + }); + }, [flueHistory.error, flueHistory.phase, reportBrunchFailure]); + const brunchVoiceMode = useMemo( + () => + getBrunchVoiceMode( + selected && voice.enabled && openAIVoiceConfig + ? { + ...openAIVoiceConfig, + provider: voice.realtime ? "realtime" : "live", + } + : null, + conversationTracker, + flueHistory.settlements, + flueHistory.snapshot, + mediationHistory, + (toolCallId) => mutationApproval.coordinator.approvalState(toolCallId), + ), + [ + selected, + conversationTracker, + flueHistory.settlements, + flueHistory.snapshot, + mediationHistory, + mutationApproval, + openAIVoiceConfig, + voice.enabled, + voice.realtime, + ], + ); + const brunchChatTransport = useMemo( + () => + flueClientPromise === null + ? null + : createBrunchPanelTransport(flueClientPromise, conversationTracker, { + ...(constructionBrowser + ? { initialData: { binding: constructionBrowser.binding } } + : {}), + ...(conversationId === null + ? {} + : { + liveToolStream: { + headers: agentOwnershipHeaders({ + conversationId, + principalKey: brunchPrincipal, + }), + }, + }), + adapter: flueAdapter, + onAdmission: flueHistory.refresh, + onToolOutputError: (event) => + reportBrunchFailure("server-tool", new Error(event.errorText), { + submissionId: event.submissionId, + toolCallId: event.toolCallId, + toolName: event.toolName ?? "unknown", + }), + onReattach: (event) => + reportBrunchFailure("stream-reattach", event.error, { + submissionId: event.submissionId, + attempt: event.attempt, + }), + onDisconnect: flueHistory.refresh, + }), + [ + conversationTracker, + conversationId, + constructionBrowser, + flueAdapter, + flueClientPromise, + flueHistory.refresh, + reportBrunchFailure, + ], + ); + + const inBandBrowserTools = useMemo( + () => + constructionBrowser && flueClientPromise + ? createInBandBrowserCalls({ + client: flueClientPromise, + principalKey: brunchPrincipal, + binding: constructionBrowser.binding, + metadataFor: (toolCallId, output) => + canonicalHostTools?.clientToolResultMetadataFor( + toolCallId, + output, + ), + prepareInput: (call) => { + canonicalHostTools?.mapClientToolInput(call); + }, + admit: createBrunchMutationAdmission(mutationApproval.coordinator), + acceptsRevision: (expected) => + canonicalHostTools?.acceptsRevision(expected) ?? false, + }) + : undefined, + [ + constructionBrowser, + flueClientPromise, + canonicalHostTools, + mutationApproval, + ], + ); + + const draftInteractiveTool = useMemo( + () => + constructionBrowser && editor && flueClientPromise && inBandBrowserTools + ? createBrunchDraftExperimentInteractiveTool({ + browserCalls: inBandBrowserTools, + readTitle: editor.readTitle, + handle: editor.handle, + readDraftAuthority: async (toolCallId) => { + const client = await flueClientPromise; + return resolveDraftAuthorityFromHistory( + await client.history(), + toolCallId, + ); + }, + }) + : undefined, + [editor, flueClientPromise, inBandBrowserTools, constructionBrowser], + ); + const assistant = useMemo((): PetrinautAiAssistant | null => { + if (flueClientPromise === null || brunchChatTransport === null) { + return null; + } + const activityIdentities = + constructionBrowser && flueHistory.ready + ? flueHistory.phase === "absent" + ? [] + : flueHistory.snapshot === undefined + ? undefined + : foldBrunchLedgerHistory(flueHistory.snapshot.messages) + .activityIdentities + : undefined; + return { + additionalTab: constructionBrowser + ? { + label: "Ledger", + activityIdentities, + content: ( + + ), + } + : undefined, + primaryLabel: "Chat", + presentation: "brunch", + mapMessagesForDisplay: mapVoiceMessages, + resolveToolPresentation: resolveBrunchToolPresentation, + workingLabel: "Working…", + renderComposerControl: (context: PetrinautAiComposerControlContext) => ( + + {(api) => ( + + )} + + ), + ...(conversationId === null ? {} : { conversationId }), + 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: [ + ...(inBandBrowserTools ? mutationApprovalTools : []), + ...(draftInteractiveTool ? [draftInteractiveTool] : []), + ], + transport: brunchChatTransport, + requestStop: () => + requestFlueStop(flueClientPromise, conversationTracker), + followMessages: { + // This closure and `messages` below describe the same observed + // snapshot, never a later mutable settlement cache. + canReplace: () => + conversationTracker.canReplaceMessages(flueHistory.snapshot), + }, + messages: flueHistory.messages, + onClearMessages: () => { + if (currentNetId === null) return; + const initialId = ordinaryConstructionConversationIdFrom(currentNetId); + const nextId = `${initialId}:${crypto.randomUUID()}`; + replaceBrunchConversationId(initialId, nextId); + setFreshConversationIds((current) => ({ + ...current, + [currentNetId]: nextId, + })); + }, + ...(brunchVoiceMode + ? { + renderVoiceMode: brunchVoiceMode, + } + : {}), + }; + }, [ + brunchChatTransport, + brunchVoiceMode, + canonicalHostTools, + constructionBrowser, + conversationId, + conversationTracker, + currentNetId, + draftInteractiveTool, + flueClientPromise, + flueHistory.messages, + flueHistory.phase, + flueHistory.ready, + flueHistory.snapshot, + inBandBrowserTools, + mapVoiceMessages, + mutationApprovalTools, + ]); + + const brunchActive = assistant !== null; + const editorPlugins = useMemo( + () => (brunchActive ? [...plugins, brunchPlugin] : plugins), + [brunchActive, plugins], + ); + + return { + assistant, + openAIVoiceConfig: selected ? openAIVoiceConfig : null, + plugins: editorPlugins, + }; +}; diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/plugin/request-flue-stop.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/request-flue-stop.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/brunch/plugin/request-flue-stop.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/request-flue-stop.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/plugin/request-flue-stop.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/request-flue-stop.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/brunch/plugin/request-flue-stop.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/request-flue-stop.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/plugin/use-immutable-replay-baseline.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/use-immutable-replay-baseline.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/brunch/plugin/use-immutable-replay-baseline.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/use-brunch-assistant/use-immutable-replay-baseline.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.test.tsx similarity index 98% rename from apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.test.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.test.tsx index ccf768f4bfa..5ab70ede3be 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import { FlueChatAdmissionError } from "@local/flue-aisdk-transport"; -import { BrunchPanelConversationTracker } from "../brunch/brunch-panel-transport"; +import { BrunchPanelConversationTracker } from "../brunch-panel-transport"; import { getBrunchVoiceMode } from "./brunch-voice-mode"; import { VoiceInterviewControl } from "./session/voice-interview-control"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.tsx similarity index 98% rename from apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.tsx index 65b14993aeb..cb2a2d96a4c 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/brunch-voice-mode.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/brunch-voice-mode.tsx @@ -6,7 +6,7 @@ import { import type { BrunchPanelAdmissionTarget, BrunchPanelConversationTracker, -} from "../brunch/brunch-panel-transport"; +} from "../brunch-panel-transport"; import type { VoiceMediationHistory } from "./history/voice-mediation-history"; import type { ToolApprovalState } from "./live/live-brunch-bridge"; import type { diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/history/use-voice-mediation-history.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/use-voice-mediation-history.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/history/use-voice-mediation-history.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/use-voice-mediation-history.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/history/use-voice-mediation-history.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/use-voice-mediation-history.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/history/use-voice-mediation-history.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/use-voice-mediation-history.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/history/voice-mediation-history.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/voice-mediation-history.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/history/voice-mediation-history.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/voice-mediation-history.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/history/voice-mediation-history.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/voice-mediation-history.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/history/voice-mediation-history.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/voice-mediation-history.ts index 8b0f10518ec..5ee7763dd90 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/history/voice-mediation-history.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/history/voice-mediation-history.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { VoiceBriefFields, VoiceLine, -} from "../../../../../shared/voice-mediation"; +} from "../../../../../../shared/voice-mediation"; import type { FlueConversationState } from "@flue/sdk"; import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/canonical-speech.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/canonical-speech.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/canonical-speech.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/canonical-speech.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/canonical-speech.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/canonical-speech.ts similarity index 97% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/canonical-speech.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/canonical-speech.ts index 3051d536581..fb4c670b5e5 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/canonical-speech.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/canonical-speech.ts @@ -1,4 +1,4 @@ -import { hashCanonicalSpeechText } from "../../../../../canonical-speech-fingerprint"; +import { hashCanonicalSpeechText } from "../../../../../../canonical-speech-fingerprint"; import type { AgentSendResult } from "@flue/sdk"; import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.test.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.test.ts index 5d478ed55cd..2dffa0938aa 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { prepareVoiceBrief } from "../../../../../shared/voice-mediation"; +import { prepareVoiceBrief } from "../../../../../../shared/voice-mediation"; import { VoiceMediationHistory } from "../history/voice-mediation-history"; import { LiveBrunchBridge } from "./live-brunch-bridge"; -import type { UtteranceJudgment } from "../../../../../shared/live-utterance-judgment"; +import type { UtteranceJudgment } from "../../../../../../shared/live-utterance-judgment"; import type { ToolApprovalState } from "./live-brunch-bridge"; import type { FlueConversationState } from "@flue/sdk"; import type { MockInstance } from "vitest"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.ts index 798e227c820..3f26d127a5c 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge.ts @@ -3,8 +3,8 @@ import { getToolName, isToolUIPart } from "ai"; import { maxUtteranceTextLength, shouldWithholdUtterance, -} from "../../../../../shared/live-utterance-judgment"; -import { serializeVoiceBrief } from "../../../../../shared/voice-mediation"; +} from "../../../../../../shared/live-utterance-judgment"; +import { serializeVoiceBrief } from "../../../../../../shared/voice-mediation"; import { logLiveDiagnostic } from "../shared/live-diagnostic"; import { selectCanonicalSpeech } from "./canonical-speech"; import { ProgressPolicy } from "./live-brunch-bridge/progress-policy"; @@ -16,8 +16,8 @@ import { import type { UtteranceJudgment, UtteranceJudgmentState, -} from "../../../../../shared/live-utterance-judgment"; -import type { VoiceBriefFields } from "../../../../../shared/voice-mediation"; +} from "../../../../../../shared/live-utterance-judgment"; +import type { VoiceBriefFields } from "../../../../../../shared/voice-mediation"; import type { VoiceMediationHistory } from "../history/voice-mediation-history"; import type { RealtimeBrunchBridge, diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/progress-policy.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/progress-policy.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/progress-policy.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/progress-policy.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/progress-policy.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/repeats-live-output.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-brunch-bridge/utterance-pipeline/spoken-phrases.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.test.tsx similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.test.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.test.tsx index f94ff410e5d..2afc1df42db 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.test.tsx @@ -12,7 +12,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { BrunchPanelConversationTracker, createBrunchPanelTransport, -} from "../../brunch/brunch-panel-transport"; +} from "../../brunch-panel-transport"; import { VoiceMediationHistory } from "../history/voice-mediation-history"; import { LIVE_VOICE_INTERVIEW_DISCLOSURE_STORAGE_KEY, diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.tsx similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.tsx index 1979a8db319..d6ca0b693a4 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation-control.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation-control.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { z } from "zod"; -import { voiceWrapUpResponseSchema } from "../../../../../shared/voice-mediation"; +import { voiceWrapUpResponseSchema } from "../../../../../../shared/voice-mediation"; import { VoiceMediationHistory } from "../history/voice-mediation-history"; import { VoiceAudioSettings } from "../session/voice-audio-settings"; import { diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation.ts index 69966c3a214..bbeafecf088 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation.ts @@ -1,4 +1,4 @@ -import { voicePreferenceHeader } from "../../../../../shared/voice-settings"; +import { voicePreferenceHeader } from "../../../../../../shared/voice-settings"; import { logLiveDiagnostic } from "../shared/live-diagnostic"; import { createOutputEchoTrace, diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/echo-diagnostics.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/echo-diagnostics.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/echo-diagnostics.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/echo-diagnostics.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/output-overlap.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/output-overlap.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/output-overlap.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/output-overlap.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/output-overlap.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/output-overlap.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/output-overlap.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/output-overlap.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/shared/echo-tail.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/shared/echo-tail.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/shared/echo-tail.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/shared/echo-tail.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/transcription-confidence.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/transcription-confidence.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/transcription-confidence.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/transcription-confidence.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/utterance-levels.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/utterance-levels.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-conversation/utterance-levels.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-conversation/utterance-levels.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-speech-captions.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-speech-captions.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-speech-captions.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-speech-captions.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-speech-captions.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-speech-captions.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/live-speech-captions.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-speech-captions.ts index c867e1b7ffe..d25c9cb1f6a 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/live-speech-captions.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/live-speech-captions.ts @@ -1,4 +1,4 @@ -import type { VoiceLine } from "../../../../../shared/voice-mediation"; +import type { VoiceLine } from "../../../../../../shared/voice-mediation"; export interface LiveTranscriptFragment { readonly id: string; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/request-utterance-judgment.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/request-utterance-judgment.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/request-utterance-judgment.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/request-utterance-judgment.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/request-utterance-judgment.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/request-utterance-judgment.ts similarity index 89% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/request-utterance-judgment.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/request-utterance-judgment.ts index 70329b0bfde..3cc63631078 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/request-utterance-judgment.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/request-utterance-judgment.ts @@ -1,12 +1,12 @@ import { isUtteranceJudgment, utteranceJudgmentTimeoutMs, -} from "../../../../../shared/live-utterance-judgment"; +} from "../../../../../../shared/live-utterance-judgment"; import type { UtteranceJudgment, UtteranceJudgmentState, -} from "../../../../../shared/live-utterance-judgment"; +} from "../../../../../../shared/live-utterance-judgment"; /** One request, no retry. Failure affects the diagnostic only, never Brunch. */ export const createUtteranceJudgmentRequester = diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/realtime/openai-realtime-session.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/openai-realtime-session.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/realtime/openai-realtime-session.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/openai-realtime-session.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/realtime/openai-realtime-session.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/openai-realtime-session.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/realtime/openai-realtime-session.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/openai-realtime-session.ts index 4249a7ceb54..8e147793113 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/realtime/openai-realtime-session.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/openai-realtime-session.ts @@ -1,4 +1,4 @@ -import { voicePreferenceHeader } from "../../../../../shared/voice-settings"; +import { voicePreferenceHeader } from "../../../../../../shared/voice-settings"; import { createVoiceRequestId, VoiceError, @@ -10,7 +10,7 @@ import { type VoiceDiagnosticReporter, type VoiceErrorCode, type VoiceOperation, -} from "../../../../../voice-diagnostics"; +} from "../../../../../../voice-diagnostics"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; import type { VoiceAudioSettings } from "../session/voice-audio-settings"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/realtime/realtime-brunch-bridge.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/realtime/realtime-brunch-bridge.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/realtime/realtime-brunch-bridge.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/realtime/realtime-brunch-bridge.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.ts index 14d4e2d0a86..cdee0bbcd2d 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/realtime/realtime-brunch-bridge.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge.ts @@ -5,7 +5,7 @@ import { reportVoiceDiagnostic, voiceDurationMs, type VoiceDiagnosticReporter, -} from "../../../../../voice-diagnostics"; +} from "../../../../../../voice-diagnostics"; import { classifyInterruption } from "../shared/classify-interruption"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.ts index 5662873d41c..6d821dbee4b 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings.ts @@ -2,7 +2,7 @@ import { isSupportedVoice, voiceNames, type VoiceProvider, -} from "../../../../../shared/voice-settings"; +} from "../../../../../../shared/voice-settings"; import { previewVoice } from "./voice-audio-settings/preview"; import type { diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings/preview.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.test.ts similarity index 96% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings/preview.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.test.ts index 9a7058d3f6e..cb958036e86 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings/preview.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, expect, test, vi } from "vitest"; -import { voiceNames } from "../../../../../../shared/voice-settings"; +import { voiceNames } from "../../../../../../../shared/voice-settings"; import { previewVoice } from "./preview"; afterEach(() => { @@ -136,7 +136,10 @@ test("releases a sample when playback is denied", async () => { test("has a static preview asset for every supported provider voice", () => { const assetRoot = fileURLToPath( - new URL("../../../../../../../public/voice-previews/v1", import.meta.url), + new URL( + "../../../../../../../../public/voice-previews/v1", + import.meta.url, + ), ); for (const [provider, voices] of Object.entries(voiceNames)) { for (const voice of voices) { diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings/preview.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.ts similarity index 97% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings/preview.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.ts index 4e29d79c3e8..9815eb5ef84 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-audio-settings/preview.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-audio-settings/preview.ts @@ -1,4 +1,4 @@ -import type { VoiceProvider } from "../../../../../../shared/voice-settings"; +import type { VoiceProvider } from "../../../../../../../shared/voice-settings"; type PreviewState = "loading" | "playing" | null; type Output = { muted: boolean; volume: number; sinkId?: string }; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-browser-tools.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-browser-tools.test.tsx similarity index 98% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-browser-tools.test.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-browser-tools.test.tsx index 4a913892ce5..f496a8b58b7 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-browser-tools.test.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-browser-tools.test.tsx @@ -9,11 +9,11 @@ import { Petrinaut } from "@hashintel/petrinaut/ui"; import { NoopResizeObserver, preloadMonaco, -} from "../../../shared/petrinaut-jsdom"; +} from "../../../../shared/petrinaut-jsdom"; import { BrunchPanelConversationTracker, createBrunchPanelTransport, -} from "../../brunch/brunch-panel-transport"; +} from "../../brunch-panel-transport"; import { selectCanonicalSpeech } from "../live/canonical-speech"; import { RealtimeBrunchBridge } from "../realtime/realtime-brunch-bridge"; import { submitVoiceInputWithAdmission } from "./voice-interview-control"; @@ -27,7 +27,7 @@ import type { PetrinautAiVoiceModeContext } from "@hashintel/petrinaut/ui"; await vi.hoisted(async () => { const { installPetrinautDomShims } = - await import("../../../shared/petrinaut-jsdom"); + await import("../../../../shared/petrinaut-jsdom"); installPetrinautDomShims(); }); diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.test.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.test.tsx similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.test.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.test.tsx diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.tsx similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.tsx index b84d97dc1f8..e9f3d80e197 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-control.tsx +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-control.tsx @@ -12,7 +12,7 @@ import { type FlueChatResponseMessageStartedEvent, } from "@local/flue-aisdk-transport"; -import { reportVoiceDiagnostic } from "../../../../../voice-diagnostics"; +import { reportVoiceDiagnostic } from "../../../../../../voice-diagnostics"; import { selectCanonicalSpeech } from "../live/canonical-speech"; import { LiveConversationControl } from "../live/live-conversation-control"; import { OpenAIRealtimeSession } from "../realtime/openai-realtime-session"; @@ -30,7 +30,7 @@ import { type VoiceTurnSnapshot, } from "./voice-turn-controller"; -import type { UtteranceJudgmentMode } from "../../../../../shared/live-utterance-judgment"; +import type { UtteranceJudgmentMode } from "../../../../../../shared/live-utterance-judgment"; import type { VoiceMediationHistory } from "../history/voice-mediation-history"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; import type { ToolApprovalState } from "../live/live-brunch-bridge"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-disclosure.tsx b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-disclosure.tsx similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-interview-disclosure.tsx rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-interview-disclosure.tsx diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-preview.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-preview.test.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-preview.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-preview.test.ts index d0e16f8a70e..f1aba1a8d02 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-preview.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-preview.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test, vi } from "vitest"; -import { createOpenAIRealtimeCallHandler } from "../../../../../server/voice/openai-realtime-call"; +import { createOpenAIRealtimeCallHandler } from "../../../../../../server/voice/openai-realtime-call"; import { VOICE_REQUEST_ID_HEADER, type VoiceDiagnosticEvent, -} from "../../../../../voice-diagnostics"; +} from "../../../../../../voice-diagnostics"; import { BrunchPanelConversationTracker, createBrunchPanelTransport, -} from "../../brunch/brunch-panel-transport"; +} from "../../brunch-panel-transport"; import { selectCanonicalSpeech } from "../live/canonical-speech"; import { OpenAIRealtimeSession } from "../realtime/openai-realtime-session"; import { RealtimeBrunchBridge } from "../realtime/realtime-brunch-bridge"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-session-state.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-session-state.test.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-session-state.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-session-state.test.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-session-state.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-session-state.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-session-state.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-session-state.ts diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-turn-controller.test.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.test.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-turn-controller.test.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.test.ts index 0a33ebb9e3d..438248aae48 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-turn-controller.test.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -import { VoiceError } from "../../../../../voice-diagnostics"; +import { VoiceError } from "../../../../../../voice-diagnostics"; import { VoiceTurnController } from "./voice-turn-controller"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-turn-controller.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.ts similarity index 99% rename from apps/petrinaut-website/src/main/app/plugins/voice/session/voice-turn-controller.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.ts index afff8a9ceb6..beb0dc94b38 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/session/voice-turn-controller.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/session/voice-turn-controller.ts @@ -1,7 +1,7 @@ import { VoiceError, type VoiceErrorCode, -} from "../../../../../voice-diagnostics"; +} from "../../../../../../voice-diagnostics"; import type { CanonicalSpeechSegment } from "../live/canonical-speech"; import type { OpenAIRealtimeSessionEvent } from "../realtime/openai-realtime-session"; diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/shared/classify-interruption.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/shared/classify-interruption.ts similarity index 95% rename from apps/petrinaut-website/src/main/app/plugins/voice/shared/classify-interruption.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/shared/classify-interruption.ts index 513398611d1..dba7383609b 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/shared/classify-interruption.ts +++ b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/shared/classify-interruption.ts @@ -1,4 +1,4 @@ -import { voiceTranscriptionPrompt } from "../../../../../shared/voice-transcription"; +import { voiceTranscriptionPrompt } from "../../../../../../shared/voice-transcription"; export const tokensOf = (text: string): string[] => text diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/shared/live-diagnostic.ts b/apps/petrinaut-website/src/main/app/plugins/brunch/voice/shared/live-diagnostic.ts similarity index 100% rename from apps/petrinaut-website/src/main/app/plugins/voice/shared/live-diagnostic.ts rename to apps/petrinaut-website/src/main/app/plugins/brunch/voice/shared/live-diagnostic.ts diff --git a/apps/petrinaut-website/turbo.json b/apps/petrinaut-website/turbo.json index 952006272a3..b4785161a83 100644 --- a/apps/petrinaut-website/turbo.json +++ b/apps/petrinaut-website/turbo.json @@ -41,16 +41,6 @@ // the root declares this so a coverage run cannot reuse a plain cache // entry. "env": ["TEST_COVERAGE"] - }, - "test:integration": { - // Cross-app Voice admission consumes the built Brunch server. - "dependsOn": [ - "codegen", - "examples:generate", - "^build", - "@apps/brunch-agent#build" - ], - "env": ["TEST_COVERAGE"] } } } diff --git a/apps/petrinaut-website/vitest.integration.config.ts b/apps/petrinaut-website/vitest.integration.config.ts deleted file mode 100644 index f943c281411..00000000000 --- a/apps/petrinaut-website/vitest.integration.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.integration.test.{ts,tsx}"], - }, -}); diff --git a/libs/@hashintel/brunch-agent/.oxlintrc.json b/libs/@hashintel/brunch-agent/.oxlintrc.json index 0088b57d444..4fdf270bf1d 100644 --- a/libs/@hashintel/brunch-agent/.oxlintrc.json +++ b/libs/@hashintel/brunch-agent/.oxlintrc.json @@ -25,6 +25,10 @@ } ], "patterns": [ + { + "regex": "^(\\.\\./)+((apps|libs|tests|@[a-z0-9-]+)/|[^./][^/]*/(src|dist|test)/)", + "message": "Import another workspace package by its package name, not a relative path into it." + }, { "group": ["@local/*"], "message": "Brunch libraries must remain independent of unpublished HASH packages." diff --git a/libs/@hashintel/brunch-agent/library-vite-config.ts b/libs/@hashintel/brunch-agent/library-vite-config.ts index a8653cb38d7..ae9a035b9a5 100644 --- a/libs/@hashintel/brunch-agent/library-vite-config.ts +++ b/libs/@hashintel/brunch-agent/library-vite-config.ts @@ -49,7 +49,7 @@ export const defineBrunchLibraryConfig = ( }, root: fileURLToPath(new URL(".", packageUrl)), test: { - include: ["test/**/*.test.ts"], + include: ["src/**/*.test.ts"], }, }); }; diff --git a/libs/@hashintel/brunch-agent/test/client-tool-history.test.ts b/libs/@hashintel/brunch-agent/src/client-tools/client-tool-history.test.ts similarity index 98% rename from libs/@hashintel/brunch-agent/test/client-tool-history.test.ts rename to libs/@hashintel/brunch-agent/src/client-tools/client-tool-history.test.ts index 6ee03dcac6e..22c7b492eb8 100644 --- a/libs/@hashintel/brunch-agent/test/client-tool-history.test.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools/client-tool-history.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { clientToolHistoryFrom, type ClientToolHistoryMessage, -} from "../src/client-tools"; +} from "./client-tool-history"; describe("clientToolHistoryFrom", () => { test("projects an in-band Flue browser outcome with its host metadata", () => { diff --git a/libs/@hashintel/brunch-agent/test/message-metadata.test.ts b/libs/@hashintel/brunch-agent/src/client-tools/message-metadata.test.ts similarity index 91% rename from libs/@hashintel/brunch-agent/test/message-metadata.test.ts rename to libs/@hashintel/brunch-agent/src/client-tools/message-metadata.test.ts index dc849a87c55..8ce24ba50ad 100644 --- a/libs/@hashintel/brunch-agent/test/message-metadata.test.ts +++ b/libs/@hashintel/brunch-agent/src/client-tools/message-metadata.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { projectBrunchMessageMetadata } from "../src/client-tools"; +import { projectBrunchMessageMetadata } from "./message-metadata"; describe("projectBrunchMessageMetadata", () => { test("marks only a durably aborted response stopped", () => { diff --git a/libs/@hashintel/brunch-agent/test/naming.test.ts b/libs/@hashintel/brunch-agent/src/conversation/naming.test.ts similarity index 97% rename from libs/@hashintel/brunch-agent/test/naming.test.ts rename to libs/@hashintel/brunch-agent/src/conversation/naming.test.ts index 8cd8fd8f94d..cdfb4a0eb81 100644 --- a/libs/@hashintel/brunch-agent/test/naming.test.ts +++ b/libs/@hashintel/brunch-agent/src/conversation/naming.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "vitest"; -import { brunchProductName } from "../src/constants"; +import { brunchProductName } from "../constants"; import { OPERATIONS, toolName, toolPrefix, type Operation, type ToolName, -} from "../src/conversation/naming"; +} from "./naming"; // Spec §12.3: architectural strings name identity, not function. The tool // prefix derives from the product name so the unresolved name-fog costs one diff --git a/libs/@hashintel/brunch-agent/test/__snapshots__/ledger-projection.test.ts.snap b/libs/@hashintel/brunch-agent/src/ledger/__snapshots__/projection.test.ts.snap similarity index 100% rename from libs/@hashintel/brunch-agent/test/__snapshots__/ledger-projection.test.ts.snap rename to libs/@hashintel/brunch-agent/src/ledger/__snapshots__/projection.test.ts.snap diff --git a/libs/@hashintel/brunch-agent/test/ledger-projection.test.ts b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts similarity index 95% rename from libs/@hashintel/brunch-agent/test/ledger-projection.test.ts rename to libs/@hashintel/brunch-agent/src/ledger/projection.test.ts index cea5a60e48d..44abf85fa20 100644 --- a/libs/@hashintel/brunch-agent/test/ledger-projection.test.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; -import { projectLedger, renderLedgerMarkdown } from "../src/ledger"; -import { supportDeskLedger } from "./fixtures/support-desk-ledger"; +import { projectLedger, renderLedgerMarkdown } from "./projection"; +import { supportDeskLedger } from "./projection/fixtures/support-desk-ledger"; const projection = projectLedger(supportDeskLedger); const user = renderLedgerMarkdown(projection, "user"); diff --git a/libs/@hashintel/brunch-agent/test/fixtures/support-desk-ledger.ts b/libs/@hashintel/brunch-agent/src/ledger/projection/fixtures/support-desk-ledger.ts similarity index 99% rename from libs/@hashintel/brunch-agent/test/fixtures/support-desk-ledger.ts rename to libs/@hashintel/brunch-agent/src/ledger/projection/fixtures/support-desk-ledger.ts index efdb61c56e1..015d72dc659 100644 --- a/libs/@hashintel/brunch-agent/test/fixtures/support-desk-ledger.ts +++ b/libs/@hashintel/brunch-agent/src/ledger/projection/fixtures/support-desk-ledger.ts @@ -5,7 +5,7 @@ * assigned IDs and turn anchors (user-message IDs). */ -import type { LedgerState } from "../../src/ledger/projection/records"; +import type { LedgerState } from "../records"; export const supportDeskLedger: LedgerState = { title: "Brightwater support desk — peak staffing", diff --git a/libs/@hashintel/brunch-agent/test/contextual-user-message.test.ts b/libs/@hashintel/brunch-agent/src/petrinaut/contextual-user-message.test.ts similarity index 97% rename from libs/@hashintel/brunch-agent/test/contextual-user-message.test.ts rename to libs/@hashintel/brunch-agent/src/petrinaut/contextual-user-message.test.ts index f43314a9a65..a52b9179d6d 100644 --- a/libs/@hashintel/brunch-agent/test/contextual-user-message.test.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/contextual-user-message.test.ts @@ -5,7 +5,7 @@ import { PETRINAUT_CONTEXTUAL_USER_TEXT_MAX_LENGTH, parsePetrinautUserMessageBody, petrinautContextualUserMessageBody, -} from "../src/petrinaut/contextual-user-message"; +} from "./contextual-user-message"; test("round trips contextual user evidence and diagnostics through explicit framing", () => { const markerLikeText = [ diff --git a/libs/@hashintel/brunch-agent/test/net-readers.test.ts b/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.test.ts similarity index 98% rename from libs/@hashintel/brunch-agent/test/net-readers.test.ts rename to libs/@hashintel/brunch-agent/src/petrinaut/net-readers.test.ts index 099e15c41f3..cd2ce524c69 100644 --- a/libs/@hashintel/brunch-agent/test/net-readers.test.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/net-readers.test.ts @@ -4,8 +4,8 @@ import { isNetObservationTool, netReaderToolNames, projectNetDefinition, -} from "../src/petrinaut/net-readers"; -import { browserToolMutatesDocument } from "../src/petrinaut/petrinaut-tool-effects"; +} from "./net-readers"; +import { browserToolMutatesDocument } from "./petrinaut-tool-effects"; const definition = { metadata: { host: "opaque" }, diff --git a/libs/@hashintel/brunch-agent/test/petrinaut-tool-effects.test.ts b/libs/@hashintel/brunch-agent/src/petrinaut/petrinaut-tool-effects.test.ts similarity index 90% rename from libs/@hashintel/brunch-agent/test/petrinaut-tool-effects.test.ts rename to libs/@hashintel/brunch-agent/src/petrinaut/petrinaut-tool-effects.test.ts index d6e9adb92bc..6031c942148 100644 --- a/libs/@hashintel/brunch-agent/test/petrinaut-tool-effects.test.ts +++ b/libs/@hashintel/brunch-agent/src/petrinaut/petrinaut-tool-effects.test.ts @@ -2,11 +2,11 @@ import { expect, test } from "vitest"; import { petrinautAiTools } from "@hashintel/petrinaut-core/ai"; -import { brunchTools } from "../src/constants"; +import { brunchTools } from "../constants"; import { browserToolMutatesDocument, petrinautToolEffects, -} from "../src/petrinaut/petrinaut-tool-effects"; +} from "./petrinaut-tool-effects"; test("classifies every canonical Petrinaut tool", () => { expect(Object.keys(petrinautToolEffects).toSorted()).toEqual( diff --git a/libs/@hashintel/brunch-agent/tsconfig.json b/libs/@hashintel/brunch-agent/tsconfig.json index 844edbd8e66..a929c3f7f44 100644 --- a/libs/@hashintel/brunch-agent/tsconfig.json +++ b/libs/@hashintel/brunch-agent/tsconfig.json @@ -15,5 +15,5 @@ "skipLibCheck": true, "isolatedModules": true }, - "include": ["src", "test"] + "include": ["src"] } diff --git a/libs/@local/pi-faux-providers/.oxlintrc.json b/libs/@local/pi-faux-providers/.oxlintrc.json new file mode 100644 index 00000000000..1634c69a6c4 --- /dev/null +++ b/libs/@local/pi-faux-providers/.oxlintrc.json @@ -0,0 +1,34 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../.config/oxlint/brunch/base.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": "^(\\.\\./)+((apps|libs|tests|@[a-z0-9-]+)/|[^./][^/]*/(src|dist|test)/)", + "message": "Import another workspace package by its package name, not a relative path into it." + }, + { + "group": ["@apps/*", "@hashintel/*", "@local/*"], + "message": "Faux providers stay independent of the products that use them." + } + ] + } + ] + } +} diff --git a/libs/@local/pi-faux-providers/LICENSE.md b/libs/@local/pi-faux-providers/LICENSE.md new file mode 100644 index 00000000000..c7d627721e2 --- /dev/null +++ b/libs/@local/pi-faux-providers/LICENSE.md @@ -0,0 +1,607 @@ +GNU Affero General Public License +================================= + +_Version 3, 19 November 2007_ +_Copyright © 2007 Free Software Foundation, Inc. <>_ + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: **(1)** assert copyright on the software, and **(2)** offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions + +“This License” refers to version 3 of the GNU Affero General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this +License. Each licensee is addressed as “you”. “Licensees” and +“recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a “modified version” of the +earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based +on the Program. + +To “propagate” a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” +to the extent that it includes a convenient and prominently visible +feature that **(1)** displays an appropriate copyright notice, and **(2)** +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code + +The “source code” for a work means the preferred form of the work +for making modifications to it. “Object code” means any non-source +form of a work. + +A “Standard Interface” means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other +than the work as a whole, that **(a)** is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and **(b)** serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +“Major Component”, in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +### 2. Basic Permissions + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +### 4. Conveying Verbatim Copies + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + +* **a)** The work must carry prominent notices stating that you modified +it, and giving a relevant date. +* **b)** The work must carry prominent notices stating that it is +released under this License and any conditions added under section 7. +This requirement modifies the requirement in section 4 to +“keep intact all notices”. +* **c)** You must license the entire work, as a whole, under this +License to anyone who comes into possession of a copy. This +License will therefore apply, along with any applicable section 7 +additional terms, to the whole of the work, and all its parts, +regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not +invalidate such permission if you have separately received it. +* **d)** If the work has interactive user interfaces, each must display +Appropriate Legal Notices; however, if the Program has interactive +interfaces that do not display Appropriate Legal Notices, your +work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +“aggregate” if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + +* **a)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by the +Corresponding Source fixed on a durable physical medium +customarily used for software interchange. +* **b)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by a +written offer, valid for at least three years and valid for as +long as you offer spare parts or customer support for that product +model, to give anyone who possesses the object code either **(1)** a +copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical +medium customarily used for software interchange, for a price no +more than your reasonable cost of physically performing this +conveying of source, or **(2)** access to copy the +Corresponding Source from a network server at no charge. +* **c)** Convey individual copies of the object code with a copy of the +written offer to provide the Corresponding Source. This +alternative is allowed only occasionally and noncommercially, and +only if you received the object code with such an offer, in accord +with subsection 6b. +* **d)** Convey the object code by offering access from a designated +place (gratis or for a charge), and offer equivalent access to the +Corresponding Source in the same way through the same place at no +further charge. You need not require recipients to copy the +Corresponding Source along with the object code. If the place to +copy the object code is a network server, the Corresponding Source +may be on a different server (operated by you or a third party) +that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the +Corresponding Source, you remain obligated to ensure that it is +available for as long as needed to satisfy these requirements. +* **e)** Convey the object code using peer-to-peer transmission, provided +you inform other peers where the object code and Corresponding +Source of the work are being offered to the general public at no +charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A “User Product” is either **(1)** a “consumer product”, which means any +tangible personal property which is normally used for personal, family, +or household purposes, or **(2)** anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, “normally used” refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms + +“Additional permissions” are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + +* **a)** Disclaiming warranty or limiting liability differently from the +terms of sections 15 and 16 of this License; or +* **b)** Requiring preservation of specified reasonable legal notices or +author attributions in that material or in the Appropriate Legal +Notices displayed by works containing it; or +* **c)** Prohibiting misrepresentation of the origin of that material, or +requiring that modified versions of such material be marked in +reasonable ways as different from the original version; or +* **d)** Limiting the use for publicity purposes of names of licensors or +authors of the material; or +* **e)** Declining to grant rights under trademark law for use of some +trade names, trademarks, or service marks; or +* **f)** Requiring indemnification of licensors and authors of that +material by anyone who conveys the material (or modified versions of +it) with contractual assumptions of liability to the recipient, for +any liability that these contractual assumptions directly impose on +those licensors and authors. + +All other non-permissive additional terms are considered “further +restrictions” within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +### 8. Termination + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated **(a)** +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and **(b)** permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents + +A “contributor” is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To “grant” such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either **(1)** cause the Corresponding Source to be so +available, or **(2)** arrange to deprive yourself of the benefit of the +patent license for this particular work, or **(3)** arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is “discriminatory” if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license **(a)** in connection with copies of the covered work +conveyed by you (or copies made from those copies), or **(b)** primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License “or any later version” applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +### 16. Limitation of Liability + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16 + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. diff --git a/libs/@local/pi-faux-providers/README.md b/libs/@local/pi-faux-providers/README.md new file mode 100644 index 00000000000..d2dc8e03c57 --- /dev/null +++ b/libs/@local/pi-faux-providers/README.md @@ -0,0 +1,6 @@ +# pi-ai faux providers + +Test doubles for `@earendil-works/pi-ai` model providers, for any host built on Pi (Flue included). Unpublished and test-only. + +- `@local/pi-faux-providers/install-faux-provider`: `installFauxProvider` replaces the `@earendil-works/pi-ai/providers/` factory with a Node module hook, so the host's own provider registration runs unchanged around the substitute. +- `@local/pi-faux-providers/native-openai-provider`: `nativeOpenaiProvider` keeps the native OpenAI request serialization and SSE parsing and substitutes synthetic HTTP responses; `createNativeOpenaiToolStall` scripts a tool call that stalls until cancelled. diff --git a/libs/@local/pi-faux-providers/docs/task-dependencies.json b/libs/@local/pi-faux-providers/docs/task-dependencies.json new file mode 100644 index 00000000000..ed0f18eb549 --- /dev/null +++ b/libs/@local/pi-faux-providers/docs/task-dependencies.json @@ -0,0 +1,22 @@ +{ + "package": "@local/pi-faux-providers", + "dependencies": [], + "tasks": { + "fix:eslint": { + "dependsOn": [ + "@local/eslint#build" + ] + }, + "lint:eslint": { + "dependsOn": [ + "@local/eslint#build" + ], + "env": [ + "CHECK_TEMPORARILY_DISABLED_RULES" + ] + }, + "lint:tsc": { + "dependsOn": [] + } + } +} diff --git a/libs/@local/pi-faux-providers/package.json b/libs/@local/pi-faux-providers/package.json new file mode 100644 index 00000000000..b7eb599198d --- /dev/null +++ b/libs/@local/pi-faux-providers/package.json @@ -0,0 +1,32 @@ +{ + "name": "@local/pi-faux-providers", + "version": "0.0.0-private", + "private": true, + "description": "Test doubles for pi-ai model providers: swap a provider factory, or drive the native OpenAI provider with synthetic responses.", + "license": "AGPL-3.0", + "type": "module", + "exports": { + "./install-faux-provider": { + "types": "./src/install-faux-provider.ts", + "default": "./src/install-faux-provider.ts" + }, + "./native-openai-provider": { + "types": "./src/native-openai-provider.ts", + "default": "./src/native-openai-provider.ts" + } + }, + "scripts": { + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit" + }, + "dependencies": { + "@earendil-works/pi-ai": "0.83.0" + }, + "devDependencies": { + "@types/node": "22.18.13", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003" + } +} diff --git a/apps/brunch-agent/test/install-faux-provider.ts b/libs/@local/pi-faux-providers/src/install-faux-provider.ts similarity index 82% rename from apps/brunch-agent/test/install-faux-provider.ts rename to libs/@local/pi-faux-providers/src/install-faux-provider.ts index 577d335b633..cdd590abc03 100644 --- a/apps/brunch-agent/test/install-faux-provider.ts +++ b/libs/@local/pi-faux-providers/src/install-faux-provider.ts @@ -1,4 +1,4 @@ -/** Test/evaluation-only factory substitution; never imported by the application. */ +/** Test-only factory substitution; never imported by an application. */ import { registerHooks } from "node:module"; import type { Provider } from "@earendil-works/pi-ai"; @@ -9,7 +9,7 @@ const installed = new Set(); export const installFauxProvider = (provider: Provider): void => { if (provider.id !== "anthropic" && provider.id !== "openai") throw new Error("Expected a faux Anthropic or OpenAI provider."); - const key = `brunch.evaluation.faux-provider.${provider.id}`; + const key = `pi-faux-providers.${provider.id}`; const providerKey = Symbol.for(key); const factoryUrl = `brunch-faux-provider:${provider.id}`; Reflect.set(globalThis, providerKey, provider); @@ -27,8 +27,8 @@ export const installFauxProvider = (provider: Provider): void => { format: "module", source: provider.id === "anthropic" - ? 'export const anthropicProvider = () => globalThis[Symbol.for("brunch.evaluation.faux-provider.anthropic")];' - : 'export const openaiProvider = () => globalThis[Symbol.for("brunch.evaluation.faux-provider.openai")];', + ? 'export const anthropicProvider = () => globalThis[Symbol.for("pi-faux-providers.anthropic")];' + : 'export const openaiProvider = () => globalThis[Symbol.for("pi-faux-providers.openai")];', shortCircuit: true, } : nextLoad(url, context); diff --git a/apps/brunch-agent/test/native-openai-provider.ts b/libs/@local/pi-faux-providers/src/native-openai-provider.ts similarity index 100% rename from apps/brunch-agent/test/native-openai-provider.ts rename to libs/@local/pi-faux-providers/src/native-openai-provider.ts diff --git a/libs/@local/pi-faux-providers/tsconfig.json b/libs/@local/pi-faux-providers/tsconfig.json new file mode 100644 index 00000000000..a929c3f7f44 --- /dev/null +++ b/libs/@local/pi-faux-providers/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["ESNext"], + "types": ["node"], + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/tests/petrinaut-brunch-integration/.oxlintrc.json b/tests/petrinaut-brunch-integration/.oxlintrc.json new file mode 100644 index 00000000000..c9372199b82 --- /dev/null +++ b/tests/petrinaut-brunch-integration/.oxlintrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.config/oxlint/brunch/react.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "env": { + "builtin": true, + "es2026": true, + "browser": true, + "node": true + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": "^(\\.\\./)+((apps|libs|tests|@[a-z0-9-]+)/|[^./][^/]*/(src|dist|test)/)", + "message": "Import another workspace package by its package name, not a relative path into it." + } + ] + } + ] + } +} diff --git a/tests/petrinaut-brunch-integration/LICENSE.md b/tests/petrinaut-brunch-integration/LICENSE.md new file mode 100644 index 00000000000..c7d627721e2 --- /dev/null +++ b/tests/petrinaut-brunch-integration/LICENSE.md @@ -0,0 +1,607 @@ +GNU Affero General Public License +================================= + +_Version 3, 19 November 2007_ +_Copyright © 2007 Free Software Foundation, Inc. <>_ + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: **(1)** assert copyright on the software, and **(2)** offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions + +“This License” refers to version 3 of the GNU Affero General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this +License. Each licensee is addressed as “you”. “Licensees” and +“recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a “modified version” of the +earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based +on the Program. + +To “propagate” a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” +to the extent that it includes a convenient and prominently visible +feature that **(1)** displays an appropriate copyright notice, and **(2)** +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code + +The “source code” for a work means the preferred form of the work +for making modifications to it. “Object code” means any non-source +form of a work. + +A “Standard Interface” means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other +than the work as a whole, that **(a)** is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and **(b)** serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +“Major Component”, in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +### 2. Basic Permissions + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +### 4. Conveying Verbatim Copies + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + +* **a)** The work must carry prominent notices stating that you modified +it, and giving a relevant date. +* **b)** The work must carry prominent notices stating that it is +released under this License and any conditions added under section 7. +This requirement modifies the requirement in section 4 to +“keep intact all notices”. +* **c)** You must license the entire work, as a whole, under this +License to anyone who comes into possession of a copy. This +License will therefore apply, along with any applicable section 7 +additional terms, to the whole of the work, and all its parts, +regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not +invalidate such permission if you have separately received it. +* **d)** If the work has interactive user interfaces, each must display +Appropriate Legal Notices; however, if the Program has interactive +interfaces that do not display Appropriate Legal Notices, your +work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +“aggregate” if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + +* **a)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by the +Corresponding Source fixed on a durable physical medium +customarily used for software interchange. +* **b)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by a +written offer, valid for at least three years and valid for as +long as you offer spare parts or customer support for that product +model, to give anyone who possesses the object code either **(1)** a +copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical +medium customarily used for software interchange, for a price no +more than your reasonable cost of physically performing this +conveying of source, or **(2)** access to copy the +Corresponding Source from a network server at no charge. +* **c)** Convey individual copies of the object code with a copy of the +written offer to provide the Corresponding Source. This +alternative is allowed only occasionally and noncommercially, and +only if you received the object code with such an offer, in accord +with subsection 6b. +* **d)** Convey the object code by offering access from a designated +place (gratis or for a charge), and offer equivalent access to the +Corresponding Source in the same way through the same place at no +further charge. You need not require recipients to copy the +Corresponding Source along with the object code. If the place to +copy the object code is a network server, the Corresponding Source +may be on a different server (operated by you or a third party) +that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the +Corresponding Source, you remain obligated to ensure that it is +available for as long as needed to satisfy these requirements. +* **e)** Convey the object code using peer-to-peer transmission, provided +you inform other peers where the object code and Corresponding +Source of the work are being offered to the general public at no +charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A “User Product” is either **(1)** a “consumer product”, which means any +tangible personal property which is normally used for personal, family, +or household purposes, or **(2)** anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, “normally used” refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms + +“Additional permissions” are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + +* **a)** Disclaiming warranty or limiting liability differently from the +terms of sections 15 and 16 of this License; or +* **b)** Requiring preservation of specified reasonable legal notices or +author attributions in that material or in the Appropriate Legal +Notices displayed by works containing it; or +* **c)** Prohibiting misrepresentation of the origin of that material, or +requiring that modified versions of such material be marked in +reasonable ways as different from the original version; or +* **d)** Limiting the use for publicity purposes of names of licensors or +authors of the material; or +* **e)** Declining to grant rights under trademark law for use of some +trade names, trademarks, or service marks; or +* **f)** Requiring indemnification of licensors and authors of that +material by anyone who conveys the material (or modified versions of +it) with contractual assumptions of liability to the recipient, for +any liability that these contractual assumptions directly impose on +those licensors and authors. + +All other non-permissive additional terms are considered “further +restrictions” within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +### 8. Termination + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated **(a)** +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and **(b)** permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents + +A “contributor” is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To “grant” such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either **(1)** cause the Corresponding Source to be so +available, or **(2)** arrange to deprive yourself of the benefit of the +patent license for this particular work, or **(3)** arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is “discriminatory” if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license **(a)** in connection with copies of the covered work +conveyed by you (or copies made from those copies), or **(b)** primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License “or any later version” applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +### 16. Limitation of Liability + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16 + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. diff --git a/tests/petrinaut-brunch-integration/README.md b/tests/petrinaut-brunch-integration/README.md new file mode 100644 index 00000000000..c2c7e762683 --- /dev/null +++ b/tests/petrinaut-brunch-integration/README.md @@ -0,0 +1,14 @@ +# Petrinaut and Brunch integration tests + +These tests run the Petrinaut website's Brunch code against the built Brunch server (`apps/brunch-agent`), in-process and with faux model providers, so they cost no model turns. They live in their own package so that neither app depends on the other: the website does not build the Brunch server, and the Brunch server does not depend on Petrinaut's UI. + +- `local-storage-demo-app.canonical-state-change`: the website's panel runs the browser tools the server issues, saves revisions, and the server continues the turn. +- `ledger-refusal-presentation`: the website renders the server's applied, refused and thrown Ledger commits, live and reopened from history. +- `live-pending-tool`: the website's transport bounds a stalled native OpenAI tool row without replaying completed tool work. +- `buffered-admission`: the website's Voice code speaks only what the server's admission-controls probe approved. + +The tests import website modules as `@apps/petrinaut-website/src/...` and reach the server through `@apps/brunch-agent/testing/*`, which loads `apps/brunch-agent/dist/app.mjs`. + +```sh +turbo run test:integration --filter @tests/petrinaut-brunch-integration +``` diff --git a/tests/petrinaut-brunch-integration/docs/task-dependencies.json b/tests/petrinaut-brunch-integration/docs/task-dependencies.json new file mode 100644 index 00000000000..389c00c46f2 --- /dev/null +++ b/tests/petrinaut-brunch-integration/docs/task-dependencies.json @@ -0,0 +1,117 @@ +{ + "package": "@tests/petrinaut-brunch-integration", + "dependencies": [ + "@apps/brunch-agent", + "@apps/petrinaut-website", + "@hashintel/brunch-agent", + "@hashintel/petrinaut", + "@hashintel/petrinaut-core", + "@local/pi-faux-providers" + ], + "tasks": { + "fix:eslint": { + "dependsOn": [ + "@apps/brunch-agent#build", + "@apps/petrinaut-website#build", + "@hashintel/brunch-agent#build", + "@hashintel/petrinaut#build", + "@hashintel/petrinaut-core#build", + "@local/eslint#build" + ], + "affectedBy": [ + "error-stack", + "error-stack-macros", + "harpc-types", + "harpc-wire-protocol", + "hash-codec", + "hash-codegen", + "hash-graph-api", + "hash-graph-authorization", + "hash-graph-store", + "hash-graph-temporal-versioning", + "hash-graph-types", + "hash-temporal-client", + "type-system" + ] + }, + "lint:eslint": { + "dependsOn": [ + "@apps/brunch-agent#build", + "@apps/petrinaut-website#build", + "@hashintel/brunch-agent#build", + "@hashintel/petrinaut#build", + "@hashintel/petrinaut-core#build", + "@local/eslint#build" + ], + "env": [ + "CHECK_TEMPORARILY_DISABLED_RULES" + ], + "affectedBy": [ + "error-stack", + "error-stack-macros", + "harpc-types", + "harpc-wire-protocol", + "hash-codec", + "hash-codegen", + "hash-graph-api", + "hash-graph-authorization", + "hash-graph-store", + "hash-graph-temporal-versioning", + "hash-graph-types", + "hash-temporal-client", + "type-system" + ] + }, + "lint:tsc": { + "dependsOn": [ + "@apps/brunch-agent#build", + "@apps/petrinaut-website#build", + "@hashintel/brunch-agent#build", + "@hashintel/petrinaut#build", + "@hashintel/petrinaut-core#build" + ], + "affectedBy": [ + "error-stack", + "error-stack-macros", + "harpc-types", + "harpc-wire-protocol", + "hash-codec", + "hash-codegen", + "hash-graph-api", + "hash-graph-authorization", + "hash-graph-store", + "hash-graph-temporal-versioning", + "hash-graph-types", + "hash-temporal-client", + "type-system" + ] + }, + "test:integration": { + "dependsOn": [ + "@apps/brunch-agent#build", + "@apps/petrinaut-website#build", + "@hashintel/brunch-agent#build", + "@hashintel/petrinaut#build", + "@hashintel/petrinaut-core#build" + ], + "env": [ + "TEST_COVERAGE" + ], + "affectedBy": [ + "error-stack", + "error-stack-macros", + "harpc-types", + "harpc-wire-protocol", + "hash-codec", + "hash-codegen", + "hash-graph-api", + "hash-graph-authorization", + "hash-graph-store", + "hash-graph-temporal-versioning", + "hash-graph-types", + "hash-temporal-client", + "type-system" + ] + } + } +} diff --git a/tests/petrinaut-brunch-integration/package.json b/tests/petrinaut-brunch-integration/package.json new file mode 100644 index 00000000000..526bd9f21b8 --- /dev/null +++ b/tests/petrinaut-brunch-integration/package.json @@ -0,0 +1,41 @@ +{ + "name": "@tests/petrinaut-brunch-integration", + "version": "0.0.0-private", + "private": true, + "description": "Integration tests that run the Petrinaut website's Brunch code against the built Brunch server.", + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit", + "test:integration": "vitest run" + }, + "dependencies": { + "@apps/brunch-agent": "workspace:*", + "@apps/petrinaut-website": "workspace:*", + "@earendil-works/pi-ai": "0.83.0", + "@flue/runtime": "2.0.3", + "@flue/sdk": "2.0.3", + "@hashintel/brunch-agent": "workspace:*", + "@hashintel/petrinaut": "workspace:*", + "@hashintel/petrinaut-core": "workspace:*", + "@local/pi-faux-providers": "workspace:*", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "ai": "6.0.286", + "react": "19.2.6", + "react-dom": "19.2.6" + }, + "devDependencies": { + "@types/node": "22.18.13", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "jsdom": "24.1.3", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", + "vite": "8.2.2", + "vitest": "4.1.11" + } +} diff --git a/apps/petrinaut-website/src/main/app/plugins/voice/live/buffered-admission.integration.test.ts b/tests/petrinaut-brunch-integration/src/buffered-admission.integration.test.ts similarity index 68% rename from apps/petrinaut-website/src/main/app/plugins/voice/live/buffered-admission.integration.test.ts rename to tests/petrinaut-brunch-integration/src/buffered-admission.integration.test.ts index 79b676bafbe..d77528f53e9 100644 --- a/apps/petrinaut-website/src/main/app/plugins/voice/live/buffered-admission.integration.test.ts +++ b/tests/petrinaut-brunch-integration/src/buffered-admission.integration.test.ts @@ -1,38 +1,19 @@ /// -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - import { beforeAll, expect, test, vi } from "vitest"; -import { runNodeScript } from "../../../../../../../brunch-agent/test/integration/run-node-script"; -import { RealtimeBrunchBridge } from "../realtime/realtime-brunch-bridge"; -import { selectCanonicalSpeech } from "./canonical-speech"; - -import type { AdmissionVoiceEvidence } from "../../../../../../../brunch-agent/test/admission-voice-evidence"; +import { + runAdmissionControls, + type AdmissionControlsResult, +} from "@apps/brunch-agent/testing/admission-controls"; +import { selectCanonicalSpeech } from "@apps/petrinaut-website/src/main/app/plugins/brunch/voice/live/canonical-speech"; +import { RealtimeBrunchBridge } from "@apps/petrinaut-website/src/main/app/plugins/brunch/voice/realtime/realtime-brunch-bridge"; -const testDirectory = dirname(fileURLToPath(import.meta.url)); -let result: AdmissionVoiceEvidence; +let result: AdmissionControlsResult; beforeAll(async () => { - const { exitCode, stdout, stderr } = await runNodeScript( - join( - testDirectory, - "../../../../../../../brunch-agent/test/integration/admission-controls.integration.ts", - ), - join(testDirectory, "../../../../../../../.."), - {}, - ); - if (exitCode !== 0) throw new Error(stderr || stdout); - const line = stdout - .split("\n") - .find((entry) => entry.startsWith("ADMISSION_CONTROLS ")); - if (line === undefined) throw new Error(stdout); - const parsed = JSON.parse(line.slice("ADMISSION_CONTROLS ".length)) as { - voice: AdmissionVoiceEvidence; - }; - result = parsed.voice; + ({ result } = await runAdmissionControls()); }); const speechFrom = ( - messages: AdmissionVoiceEvidence["buffering"][number]["projectedAfter"], + messages: AdmissionControlsResult["buffering"][number]["projectedAfter"], ) => // The mounted runtime also emits core server tools absent from the editor's // static tool type. Retain every actual part in this controlled fixture: the @@ -41,7 +22,12 @@ const speechFrom = ( messages as unknown as Parameters[0], ); const voice = () => { - const speakCanonical = vi.fn(); + const speakCanonical = + vi.fn< + ConstructorParameters< + typeof RealtimeBrunchBridge + >[0]["session"]["speakCanonical"] + >(); const bridge = new RealtimeBrunchBridge({ session: { speakCanonical, subscribe: () => () => {} }, submitInterviewAnswer: async () => { diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/ledger-refusal-presentation.integration.test.tsx b/tests/petrinaut-brunch-integration/src/ledger-refusal-presentation.integration.test.tsx similarity index 76% rename from apps/petrinaut-website/src/main/app/plugins/brunch/tools/ledger-refusal-presentation.integration.test.tsx rename to tests/petrinaut-brunch-integration/src/ledger-refusal-presentation.integration.test.tsx index 2cd79b89371..5f22d64e070 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/ledger-refusal-presentation.integration.test.tsx +++ b/tests/petrinaut-brunch-integration/src/ledger-refusal-presentation.integration.test.tsx @@ -1,8 +1,4 @@ -/** - * @vitest-environment jsdom - */ -// oxlint-disable-next-line typescript/triple-slash-reference -- The rendered source fixture needs the package's CSS-only module declarations. -/// +// @vitest-environment jsdom import { fauxAssistantMessage, fauxProvider, @@ -14,33 +10,36 @@ import { createFlueClient } from "@flue/sdk"; import { cleanup, fireEvent, - render, screen, waitFor, within, } from "@testing-library/react"; import { getToolName, isToolUIPart, readUIMessageStream } from "ai"; -import { afterAll, afterEach, beforeAll, expect, test } from "vitest"; +import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest"; +import { loadBuiltBrunchApplication } from "@apps/brunch-agent/testing/load-built-application"; +import { renderEditorWith } from "@apps/petrinaut-website/src/main/app/plugins/_shared/testing/render-editor-with"; +import { createBrunchFlueAdapter } from "@apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter"; +import { + BrunchPanelConversationTracker, + createBrunchPanelTransport, +} from "@apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport"; +import { resolveBrunchToolPresentation } from "@apps/petrinaut-website/src/main/app/plugins/brunch/tools/brunch-tool-presentation"; import { agentOwnershipHeaders, flueConversationIdWeb, } from "@hashintel/brunch-agent"; -import { AiAssistantContents } from "../../../../../../../../libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents"; -import { loadBuiltBrunchApplication } from "../../../../../../../brunch-agent/test/load-built-application"; -import { createBrunchFlueAdapter } from "../brunch-flue-adapter"; -import { - BrunchPanelConversationTracker, - createBrunchPanelTransport, -} from "../brunch-panel-transport"; -import { resolveBrunchToolPresentation } from "./brunch-tool-presentation"; - import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; +await vi.hoisted(async () => { + const { installPetrinautDomShims } = + await import("@apps/petrinaut-website/src/main/app/shared/petrinaut-jsdom"); + installPetrinautDomShims(); +}); + const adapter = createBrunchFlueAdapter({ clientToolNames: new Set() }); -const noop = () => {}; const originalFetch = globalThis.fetch; const entity = (name: string) => [ @@ -54,11 +53,6 @@ const claimAbout = (entityId: string, text: string) => [ const commitName = "ledger_commit"; beforeAll(() => { - globalThis.ResizeObserver = class { - public disconnect() {} - public observe() {} - public unobserve() {} - }; globalThis.fetch = () => Promise.reject(new Error("External fetch is forbidden in this fixture.")); }); @@ -68,26 +62,24 @@ afterAll(() => { }); afterEach(cleanup); -const renderAssistant = ( - messages: readonly PetrinautAiMessage[], - status: "ready" | "streaming" = "ready", -) => - render( - , - ); +/** Opens the real editor's Brunch assistant on `messages`, through Petrinaut's public props. */ +const renderAssistant = (messages: readonly PetrinautAiMessage[]) => { + renderEditorWith([], { + aiAssistant: { + presentation: "brunch", + messages: [...messages], + resolveToolPresentation: resolveBrunchToolPresentation, + transport: { + sendMessages: () => new Promise(() => {}), + reconnectToStream: async () => null, + }, + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Show AI assistant" })); +}; const expandSettledTools = async () => { - const activity = screen.getByRole("button", { name: /^Activity/u }); + const activity = await screen.findByRole("button", { name: /^Activity/u }); fireEvent.click(activity); await waitFor(() => expect(activity.getAttribute("aria-expanded")).toBe("true"), @@ -97,7 +89,7 @@ const expandSettledTools = async () => { await waitFor(() => expect(tools.getAttribute("aria-expanded")).toBe("true")); }; -test("renders pending gold, applied green, typed refusal compact, and thrown red across reopen", async () => { +test("presents pending calls as pending, and renders applied green, typed refusal compact, and thrown red across reopen", async () => { process.env.BRUNCH_CHAT_MODEL = "openai/faux-model"; process.env.BRUNCH_CHAT_THINKING = "medium"; process.env.BRUNCH_DEV_DB_PATH = ":memory:"; @@ -193,7 +185,7 @@ test("renders pending gold, applied green, typed refusal compact, and thrown red ], trigger: "submit-message", }); - const pendingById = new Map(); + const pendingInputById = new Map(); let terminalMessage: PetrinautAiMessage | undefined; for await (const message of readUIMessageStream({ stream, @@ -207,8 +199,8 @@ test("renders pending gold, applied green, typed refusal compact, and thrown red ) { continue; } - if (!pendingById.has(part.toolCallId)) { - pendingById.set(part.toolCallId, structuredClone(message)); + if (!pendingInputById.has(part.toolCallId)) { + pendingInputById.set(part.toolCallId, structuredClone(part.input)); } } } @@ -221,17 +213,21 @@ test("renders pending gold, applied green, typed refusal compact, and thrown red "refused-reference", "applied-second", ]) { - const pending = pendingById.get(toolCallId); - expect(pending, `pending row missing for ${toolCallId}`).toBeDefined(); - cleanup(); - renderAssistant([pending!], "streaming"); - const pendingRow = screen - .getAllByRole("button") - .find((row) => row.getAttribute("aria-busy") === "true"); - expect(pendingRow?.getAttribute("data-tone")).toBe("pending"); expect( - pendingRow?.querySelector('[data-tool-status="pending"]'), - ).not.toBeNull(); + pendingInputById.has(toolCallId), + `pending call missing for ${toolCallId}`, + ).toBe(true); + // Petrinaut draws a pending row only while its own chat streams, which + // its public props cannot set; the tone it draws is Brunch's, from here. + expect( + resolveBrunchToolPresentation({ + toolName: commitName, + state: "pending", + input: pendingInputById.get(toolCallId), + output: undefined, + error: undefined, + })?.tone, + ).toBe("pending"); } cleanup(); diff --git a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/live-pending-tool.integration.test.ts b/tests/petrinaut-brunch-integration/src/live-pending-tool.integration.test.ts similarity index 88% rename from apps/petrinaut-website/src/main/app/plugins/brunch/tools/live-pending-tool.integration.test.ts rename to tests/petrinaut-brunch-integration/src/live-pending-tool.integration.test.ts index 659ed4d97b3..4a1e0bd874b 100644 --- a/apps/petrinaut-website/src/main/app/plugins/brunch/tools/live-pending-tool.integration.test.ts +++ b/tests/petrinaut-brunch-integration/src/live-pending-tool.integration.test.ts @@ -3,30 +3,26 @@ import { fauxProvider, fauxToolCall, } from "@earendil-works/pi-ai"; -import { setProvider } from "@flue/runtime"; import { createFlueClient } from "@flue/sdk"; import { getToolName, isToolUIPart, readUIMessageStream } from "ai"; import { afterAll, beforeAll, expect, test } from "vitest"; +import { loadBuiltBrunchApplication } from "@apps/brunch-agent/testing/load-built-application"; +import { createBrunchFlueAdapter } from "@apps/petrinaut-website/src/main/app/plugins/brunch/brunch-flue-adapter"; +import { + BrunchPanelConversationTracker, + createBrunchPanelTransport, +} from "@apps/petrinaut-website/src/main/app/plugins/brunch/brunch-panel-transport"; import { agentOwnershipHeaders, + brunchEnv, flueConversationIdWeb, } from "@hashintel/brunch-agent"; - -import { - claimModelStreamIdleRetry, - withBufferedToolAdmission, -} from "../../../../../../../brunch-agent/src/server/provider-admission"; -import { loadBuiltBrunchApplication } from "../../../../../../../brunch-agent/test/load-built-application"; +import { installFauxProvider } from "@local/pi-faux-providers/install-faux-provider"; import { createNativeOpenaiToolStall, nativeOpenaiProvider, -} from "../../../../../../../brunch-agent/test/native-openai-provider"; -import { createBrunchFlueAdapter } from "../brunch-flue-adapter"; -import { - BrunchPanelConversationTracker, - createBrunchPanelTransport, -} from "../brunch-panel-transport"; +} from "@local/pi-faux-providers/native-openai-provider"; import type { PetrinautAiMessage } from "@hashintel/petrinaut/ui"; @@ -47,6 +43,11 @@ test("bounds a native OpenAI tool row without replaying completed tool work", as process.env.BRUNCH_CHAT_MODEL = "openai/gpt-5.6-sol"; process.env.BRUNCH_CHAT_THINKING = "low"; process.env.BRUNCH_DEV_DB_PATH = ":memory:"; + process.env.NODE_ENV = "test"; + process.env[brunchEnv.modelStreamCancellationTimeoutMs] = "100"; + process.env[brunchEnv.modelStreamFirstEventTimeoutMs] = "3000"; + process.env[brunchEnv.modelStreamIdleTimeoutMs] = "500"; + process.env[brunchEnv.modelStreamReasoningStartTimeoutMs] = "3000"; const faux = fauxProvider({ models: [{ id: "gpt-5.6-sol", reasoning: true }], provider: "openai", @@ -60,28 +61,15 @@ test("bounds a native OpenAI tool row without replaying completed tool work", as ), ]); - const application = await loadBuiltBrunchApplication(); - const retryScope = { idleRetryAvailable: true }; - setProvider( - withBufferedToolAdmission( - nativeOpenaiProvider( - faux.provider, - requests, - async () => {}, - (input) => - input.requestIndex === 0 ? undefined : stall.response(input), - ), - () => true, - new Set(), - { - cancellationTimeoutMs: 100, - claimRetry: () => claimModelStreamIdleRetry(retryScope), - firstEventTimeoutMs: 3_000, - idleTimeoutMs: 500, - reasoningStartTimeoutMs: 3_000, - }, + installFauxProvider( + nativeOpenaiProvider( + faux.provider, + requests, + async () => {}, + (input) => (input.requestIndex === 0 ? undefined : stall.response(input)), ), ); + const application = await loadBuiltBrunchApplication(); const identity = { conversationId: `live-pending-${crypto.randomUUID()}`, principalKey: "live-pending-principal", diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.canonical-state-change.integration.test.tsx b/tests/petrinaut-brunch-integration/src/local-storage-demo-app.canonical-state-change.integration.test.tsx similarity index 91% rename from apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.canonical-state-change.integration.test.tsx rename to tests/petrinaut-brunch-integration/src/local-storage-demo-app.canonical-state-change.integration.test.tsx index c6e18f4989b..5e2947c2295 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.canonical-state-change.integration.test.tsx +++ b/tests/petrinaut-brunch-integration/src/local-storage-demo-app.canonical-state-change.integration.test.tsx @@ -1,4 +1,4 @@ -/** @vitest-environment jsdom */ +// @vitest-environment jsdom import { fauxAssistantMessage, fauxProvider, @@ -15,18 +15,17 @@ import { } from "@testing-library/react"; import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest"; -import { clientToolHistoryFrom } from "@hashintel/brunch-agent"; -import { toPetrinautId } from "@hashintel/petrinaut-core"; - -import { loadBuiltBrunchApplication } from "../../../../../brunch-agent/test/load-built-application"; -import { documentRevisionOf } from "../plugins/brunch/tools/shared/document-revision"; +import { loadBuiltBrunchApplication } from "@apps/brunch-agent/testing/load-built-application"; +import { assistantSelectionStorageKey } from "@apps/petrinaut-website/src/main/app/local-storage-demo/assistant-selection"; +import { LocalStorageDemoApp } from "@apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app"; +import { documentRevisionOf } from "@apps/petrinaut-website/src/main/app/plugins/brunch/tools/shared/document-revision"; import { InProcessLspWorker, NoopResizeObserver, preloadMonaco, -} from "../shared/petrinaut-jsdom"; -import { assistantSelectionStorageKey } from "./assistant-selection"; -import { LocalStorageDemoApp } from "./local-storage-demo-app"; +} from "@apps/petrinaut-website/src/main/app/shared/petrinaut-jsdom"; +import { clientToolHistoryFrom } from "@hashintel/brunch-agent"; +import { toPetrinautId } from "@hashintel/petrinaut-core"; import type { FlueClient } from "@flue/sdk"; import type { PetrinautDocHandle, SDCPN } from "@hashintel/petrinaut-core"; @@ -34,7 +33,7 @@ import type { ComponentProps } from "react"; await vi.hoisted(async () => { const { installPetrinautDomShims } = - await import("../shared/petrinaut-jsdom"); + await import("@apps/petrinaut-website/src/main/app/shared/petrinaut-jsdom"); installPetrinautDomShims(); }); @@ -68,15 +67,21 @@ vi.mock("@hashintel/petrinaut/ui", async (importOriginal) => { }, }; }); -vi.mock("../plugins/brunch/brunch-preview-config", () => ({ - resolveBrunchPreviewConfig: () => ({ - chatEndpoint: "/agents/petrinaut", - isBrunchConfigured: true, +vi.mock( + "@apps/petrinaut-website/src/main/app/plugins/brunch/brunch-preview-config", + () => ({ + resolveBrunchPreviewConfig: () => ({ + chatEndpoint: "/agents/petrinaut", + isBrunchConfigured: true, + }), }), -})); -vi.mock("../plugins/brunch/conversation/brunch-principal", () => ({ - getOrCreateBrunchPrincipal: () => "test-principal", -})); +); +vi.mock( + "@apps/petrinaut-website/src/main/app/plugins/brunch/conversation/brunch-principal", + () => ({ + getOrCreateBrunchPrincipal: () => "test-principal", + }), +); const originalFetch = globalThis.fetch; beforeAll(async () => { diff --git a/tests/petrinaut-brunch-integration/src/website-env.d.ts b/tests/petrinaut-brunch-integration/src/website-env.d.ts new file mode 100644 index 00000000000..73b0976e618 --- /dev/null +++ b/tests/petrinaut-brunch-integration/src/website-env.d.ts @@ -0,0 +1,4 @@ +/// + +// The website sources these tests import read `import.meta.env` and import +// assets through Vite, as the website declares. diff --git a/tests/petrinaut-brunch-integration/tsconfig.json b/tests/petrinaut-brunch-integration/tsconfig.json new file mode 100644 index 00000000000..7dd40fbf358 --- /dev/null +++ b/tests/petrinaut-brunch-integration/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "target": "es2024", + "lib": ["dom", "dom.iterable", "ESNext"], + "types": ["node"], + "module": "preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src", "vitest.config.ts"] +} diff --git a/tests/petrinaut-brunch-integration/turbo.json b/tests/petrinaut-brunch-integration/turbo.json new file mode 100644 index 00000000000..2386ef001b6 --- /dev/null +++ b/tests/petrinaut-brunch-integration/turbo.json @@ -0,0 +1,11 @@ +{ + "extends": ["//"], + "tasks": { + "test:integration": { + // `^build` builds the Brunch server the tests load, and the website's + // build first generates the route tree and examples its sources import. + "dependsOn": ["^build"], + "env": ["TEST_COVERAGE"] + } + } +} diff --git a/tests/petrinaut-brunch-integration/vitest.config.ts b/tests/petrinaut-brunch-integration/vitest.config.ts new file mode 100644 index 00000000000..43a76f70bcd --- /dev/null +++ b/tests/petrinaut-brunch-integration/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.{ts,tsx}"], + // Node must load the built Brunch server so faux-provider module hooks apply. + server: { deps: { external: [/\/apps\/brunch-agent\/dist\//] } }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 854d75306eb..4c02850bb1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -431,7 +431,7 @@ __metadata: languageName: node linkType: hard -"@apps/brunch-agent@workspace:apps/brunch-agent": +"@apps/brunch-agent@workspace:*, @apps/brunch-agent@workspace:apps/brunch-agent": version: 0.0.0-use.local resolution: "@apps/brunch-agent@workspace:apps/brunch-agent" dependencies: @@ -448,6 +448,7 @@ __metadata: "@hashintel/petrinaut-core": "workspace:*" "@local/flue-aisdk-transport": "workspace:*" "@local/hash-backend-utils": "workspace:*" + "@local/pi-faux-providers": "workspace:*" "@opentelemetry/api": "npm:1.9.1" "@playwright/test": "npm:1.58.2" "@types/node": "npm:22.18.13" @@ -915,9 +916,7 @@ __metadata: resolution: "@apps/petrinaut-website@workspace:apps/petrinaut-website" dependencies: "@ai-sdk/openai": "npm:3.0.114" - "@earendil-works/pi-ai": "npm:0.83.0" "@fast-check/vitest": "npm:0.4.1" - "@flue/runtime": "npm:2.0.3" "@flue/sdk": "npm:2.0.3" "@hashintel/brunch-agent": "workspace:*" "@hashintel/ds-components": "workspace:*" @@ -10012,6 +10011,18 @@ __metadata: languageName: unknown linkType: soft +"@local/pi-faux-providers@workspace:*, @local/pi-faux-providers@workspace:libs/@local/pi-faux-providers": + version: 0.0.0-use.local + resolution: "@local/pi-faux-providers@workspace:libs/@local/pi-faux-providers" + dependencies: + "@earendil-works/pi-ai": "npm:0.83.0" + "@types/node": "npm:22.18.13" + "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + oxlint: "npm:1.86.0" + oxlint-tsgolint: "npm:7.0.2003" + languageName: unknown + linkType: soft + "@local/repo-chores@workspace:libs/@local/repo-chores/node": version: 0.0.0-use.local resolution: "@local/repo-chores@workspace:libs/@local/repo-chores/node" @@ -17550,6 +17561,36 @@ __metadata: languageName: unknown linkType: soft +"@tests/petrinaut-brunch-integration@workspace:tests/petrinaut-brunch-integration": + version: 0.0.0-use.local + resolution: "@tests/petrinaut-brunch-integration@workspace:tests/petrinaut-brunch-integration" + dependencies: + "@apps/brunch-agent": "workspace:*" + "@apps/petrinaut-website": "workspace:*" + "@earendil-works/pi-ai": "npm:0.83.0" + "@flue/runtime": "npm:2.0.3" + "@flue/sdk": "npm:2.0.3" + "@hashintel/brunch-agent": "workspace:*" + "@hashintel/petrinaut": "workspace:*" + "@hashintel/petrinaut-core": "workspace:*" + "@local/pi-faux-providers": "workspace:*" + "@testing-library/dom": "npm:10.4.1" + "@testing-library/react": "npm:16.3.2" + "@types/node": "npm:22.18.13" + "@types/react": "npm:19.2.14" + "@types/react-dom": "npm:19.2.3" + "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + ai: "npm:6.0.286" + jsdom: "npm:24.1.3" + oxlint: "npm:1.86.0" + oxlint-tsgolint: "npm:7.0.2003" + react: "npm:19.2.6" + react-dom: "npm:19.2.6" + vite: "npm:8.2.2" + vitest: "npm:4.1.11" + languageName: unknown + linkType: soft + "@tldraw/editor@npm:2.0.0-alpha.12": version: 2.0.0-alpha.12 resolution: "@tldraw/editor@npm:2.0.0-alpha.12"