From d6b9b9f3f7ecdbb2534fe7dbff3f512f636aa94f Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Mon, 24 Aug 2026 17:44:25 +0200 Subject: [PATCH 1/2] Add comprehensive Oxlint policy for Brunch Enable type-aware and compiler-backed linting across the Brunch packages, share scoped rules with the app, and resolve the initial diagnostics without widening repository policy. Co-authored-by: Cursor --- .config/oxlint/brunch/base.json | 102 ++++++++++++++++++ .config/oxlint/brunch/react.json | 49 +++++++++ .github/actions/prune-repository/prune.py | 1 + apps/brunch-agent/.oxlintrc.json | 61 +++++++++++ apps/brunch-agent/package.json | 4 +- apps/brunch-agent/src/app.ts | 1 + apps/brunch-agent/src/petrinaut-chat.ts | 2 +- apps/brunch-agent/src/ui/chat.tsx | 1 + .../test/petrinaut-ask.integration.ts | 6 +- .../test/petrinaut-chat.integration.ts | 4 +- .../test/transport-aisdk-server.test.ts | 4 +- .../test/walking-skeleton.integration.ts | 4 +- libs/@hashintel/brunch-agent/AGENTS.md | 12 +++ .../packages/binding-flue/.oxlintrc.json | 52 +++++++++ .../packages/binding-flue/package.json | 4 +- .../binding-flue/src/capture-accounting.ts | 19 ++-- .../test/local-capture-store.test.ts | 4 +- .../brunch-agent/packages/core/.oxlintrc.json | 56 ++++++++++ .../brunch-agent/packages/core/package.json | 4 +- .../packages/core/src/capture-store.ts | 2 +- .../brunch-agent/packages/core/src/plugin.ts | 1 + .../packages/core/src/session-log.ts | 2 +- .../test/architecture/baseline-runner.test.ts | 44 +++++--- .../core/test/architecture/boundaries.test.ts | 40 ++++--- .../architecture/control-surfaces.test.ts | 22 +++- .../core/test/architecture/docs-index.test.ts | 15 ++- .../core/test/architecture/open-gaps.test.ts | 2 +- .../core/test/architecture/workspace.ts | 4 +- .../packages/core/test/capture-store.test.ts | 2 + .../packages/core/test/sweep-protocol.test.ts | 4 +- .../packages/plugin-gherkin/.oxlintrc.json | 60 +++++++++++ .../packages/plugin-gherkin/package.json | 4 +- .../test/statement-noted.test.ts | 4 +- .../packages/transport-aisdk/.oxlintrc.json | 56 ++++++++++ .../packages/transport-aisdk/package.json | 4 +- .../packages/transport-aisdk/src/index.ts | 1 - .../transport-aisdk/test/ask-reply.test.ts | 2 +- 37 files changed, 578 insertions(+), 81 deletions(-) create mode 100644 .config/oxlint/brunch/base.json create mode 100644 .config/oxlint/brunch/react.json create mode 100644 apps/brunch-agent/.oxlintrc.json create mode 100644 libs/@hashintel/brunch-agent/packages/binding-flue/.oxlintrc.json create mode 100644 libs/@hashintel/brunch-agent/packages/core/.oxlintrc.json create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-gherkin/.oxlintrc.json create mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/.oxlintrc.json diff --git a/.config/oxlint/brunch/base.json b/.config/oxlint/brunch/base.json new file mode 100644 index 00000000000..fdda879f279 --- /dev/null +++ b/.config/oxlint/brunch/base.json @@ -0,0 +1,102 @@ +{ + "$schema": "../../../node_modules/oxlint/configuration_schema.json", + "plugins": [ + "eslint", + "typescript", + "unicorn", + "oxc", + "import", + "jsdoc", + "node", + "promise", + "vitest" + ], + "rules": { + "array-callback-return": ["error", { "allowImplicit": true }], + "default-case-last": "error", + "default-param-last": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "guard-for-in": "error", + "no-alert": "error", + "no-bitwise": "error", + "no-cond-assign": ["error", "always"], + "no-console": "error", + "no-extend-native": "error", + "no-loop-func": "error", + "no-multi-assign": "error", + "no-new": "error", + "no-new-func": "error", + "no-param-reassign": [ + "error", + { + "props": true, + "ignorePropertyModificationsForRegex": ["^existing", "draft"] + } + ], + "no-return-assign": ["error", "always"], + "no-self-compare": "error", + "no-sequences": "error", + "no-template-curly-in-string": "error", + "no-unsafe-optional-chaining": [ + "error", + { "disallowArithmeticOperators": true } + ], + "no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_+", + "varsIgnorePattern": "^_+" + } + ], + "no-void": ["error", { "allowAsStatement": true }], + "func-names": "error", + "new-cap": "error", + "import/no-cycle": "error", + "import/no-duplicates": "error", + "import/no-mutable-exports": "error", + "import/no-named-as-default": "error", + "import/no-named-as-default-member": "error", + "import/no-named-default": "error", + "import/no-self-import": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/ban-ts-comment": [ + "error", + { + "ts-expect-error": "allow-with-description", + "minimumDescriptionLength": 10 + } + ], + "@typescript-eslint/no-empty-object-type": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-implied-eval": "error", + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/no-require-imports": "error", + "@typescript-eslint/no-unnecessary-condition": "error", + "@typescript-eslint/no-unnecessary-type-constraint": "error", + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-return": "error", + "unicorn/filename-case": "error", + "unicorn/no-new-array": "off", + "vitest/valid-expect": "off", + "constructor-super": "off", + "no-class-assign": "off", + "no-const-assign": "off", + "no-constant-condition": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-throw-literal": "off", + "no-unsafe-negation": "off", + "prefer-promise-reject-errors": "off" + } +} diff --git a/.config/oxlint/brunch/react.json b/.config/oxlint/brunch/react.json new file mode 100644 index 00000000000..99c985729d8 --- /dev/null +++ b/.config/oxlint/brunch/react.json @@ -0,0 +1,49 @@ +{ + "$schema": "../../../node_modules/oxlint/configuration_schema.json", + "extends": ["./base.json"], + "plugins": [ + "eslint", + "typescript", + "unicorn", + "oxc", + "import", + "jsdoc", + "node", + "promise", + "vitest", + "react", + "react-perf", + "jsx-a11y" + ], + "rules": { + "react/button-has-type": [ + "error", + { "button": true, "submit": true, "reset": false } + ], + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-target-blank": ["error", { "enforceDynamicLinks": "always" }], + "react/jsx-pascal-case": ["error", { "allowAllCaps": true }], + "react/no-array-index-key": "error", + "react/no-danger": "error", + "jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }], + "jsx-a11y/label-has-associated-control": "error", + "jsx-a11y/no-noninteractive-tabindex": [ + "error", + { "tags": [], "roles": ["tabpanel"] } + ], + "jsx-a11y/no-static-element-interactions": [ + "error", + { + "handlers": [ + "onClick", + "onMouseDown", + "onMouseUp", + "onKeyPress", + "onKeyDown", + "onKeyUp" + ] + } + ], + "jsx-a11y/prefer-tag-over-role": "off" + } +} diff --git a/.github/actions/prune-repository/prune.py b/.github/actions/prune-repository/prune.py index bfb5a60564d..891da3fdfe2 100644 --- a/.github/actions/prune-repository/prune.py +++ b/.github/actions/prune-repository/prune.py @@ -54,6 +54,7 @@ # architecture tests in packages/core read its docs, scripts, and agent # contract files "@hashintel/brunch-agent": [ + ".config/oxlint/brunch", "libs/@hashintel/brunch-agent/AGENTS.md", "libs/@hashintel/brunch-agent/CONTEXT.md", "libs/@hashintel/brunch-agent/docs", diff --git a/apps/brunch-agent/.oxlintrc.json b/apps/brunch-agent/.oxlintrc.json new file mode 100644 index 00000000000..b83c386eb3b --- /dev/null +++ b/apps/brunch-agent/.oxlintrc.json @@ -0,0 +1,61 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.config/oxlint/brunch/react.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "settings": { + "react": { + "version": "19.2" + } + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "The Brunch server must remain independent of Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "The Brunch application must not depend on unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "The Brunch server must remain independent of Petrinaut implementations." + } + ] + } + ] + }, + "overrides": [ + { + "files": ["src/ui/**/*.{ts,tsx}"], + "env": { + "browser": true + } + } + ], + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index fc199cec26f..b27b47ca55a 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -8,8 +8,8 @@ "scripts": { "build": "vite build && vite build --config vite.client.config.ts", "dev": "vite dev", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "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", "petrinaut:dev": "vite dev --config petrinaut-local.vite.config.ts", "test:unit": "vitest run --config vitest.config.ts" diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 6ce50876433..65915d05f8d 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -42,6 +42,7 @@ app.on(["POST", "OPTIONS"], PETRINAUT_CHAT_ROUTE, (c) => // client build is a second, plain vite build — without it the ui tree would // have no build coverage at all. const uiRoot = new URL( + // oxlint-disable-next-line typescript/no-unnecessary-condition -- import.meta.env is absent when Node executes this module directly. import.meta.env?.DEV === false ? "./client/" : "../", import.meta.url, ); diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts index 9480653f56d..280fed99ad8 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -25,7 +25,7 @@ const inspect = ? (event: TransportInspectionEvent): void => { // This is an opt-in shell diagnostic stream. It is never dispatched // into Flue and therefore cannot become elicitation evidence. - console.log(`TRANSPORT_AISDK ${JSON.stringify(event)}`); + process.stdout.write(`TRANSPORT_AISDK ${JSON.stringify(event)}\n`); } : undefined; diff --git a/apps/brunch-agent/src/ui/chat.tsx b/apps/brunch-agent/src/ui/chat.tsx index ea9d6dd2f59..3756e7b11e5 100644 --- a/apps/brunch-agent/src/ui/chat.tsx +++ b/apps/brunch-agent/src/ui/chat.tsx @@ -25,6 +25,7 @@ function VisibleMessage({ message }: { message: FlueConversationMessage }) { {message.parts.map((part, index) => { if (part.type === "text") { return ( + // oxlint-disable-next-line react/no-array-index-key -- Flue text parts expose no stable identifier.

{part.text}

diff --git a/apps/brunch-agent/test/petrinaut-ask.integration.ts b/apps/brunch-agent/test/petrinaut-ask.integration.ts index 558e280b624..708cae0db5b 100644 --- a/apps/brunch-agent/test/petrinaut-ask.integration.ts +++ b/apps/brunch-agent/test/petrinaut-ask.integration.ts @@ -128,7 +128,7 @@ try { const duplicate = await postChat("request-fe1449-duplicate", returnBody); - console.log( + process.stdout.write( `PETRINAUT_ASK_RESULT ${JSON.stringify({ initialStatus: initial.status, askCall, @@ -143,8 +143,8 @@ try { .join(""), resumedFinish: resumedChunks.at(-1), duplicateStatus: duplicate.status, - duplicateBody: await duplicate.json(), - })}`, + duplicateBody: (await duplicate.json()) as unknown, + })}\n`, ); } finally { await flue.stop(); diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index cf04e8a757b..41720a7f282 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -79,7 +79,7 @@ try { ) .map((chunk) => chunk.id); - console.log( + process.stdout.write( `PETRINAUT_CHAT_RESULT ${JSON.stringify({ status: response.status, messageId: startChunk?.messageId, @@ -94,7 +94,7 @@ try { .join(""), finish: chunks.at(-1), chunks, - })}`, + })}\n`, ); } finally { await flue.stop(); diff --git a/apps/brunch-agent/test/transport-aisdk-server.test.ts b/apps/brunch-agent/test/transport-aisdk-server.test.ts index 1c1523b7d3e..02dd86701a1 100644 --- a/apps/brunch-agent/test/transport-aisdk-server.test.ts +++ b/apps/brunch-agent/test/transport-aisdk-server.test.ts @@ -136,7 +136,7 @@ describe("FE-1436 Petrinaut wire server", () => { expect({ body, status: response.status, - refusal: await response.json(), + refusal: (await response.json()) as unknown, }).toEqual({ body, status: 400, @@ -207,6 +207,7 @@ describe("FE-1436 Petrinaut wire server", () => { ).toEqual([ { type: "request-finish", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. requestId: expect.any(String), terminalState, finishReason: "error", @@ -215,6 +216,7 @@ describe("FE-1436 Petrinaut wire server", () => { expect(inspections.find((event) => event.type === "turn-finish")).toEqual( { type: "turn-finish", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. requestId: expect.any(String), turnId: `turn-${terminalState}`, }, diff --git a/apps/brunch-agent/test/walking-skeleton.integration.ts b/apps/brunch-agent/test/walking-skeleton.integration.ts index 66f85667a46..2fcb8647235 100644 --- a/apps/brunch-agent/test/walking-skeleton.integration.ts +++ b/apps/brunch-agent/test/walking-skeleton.integration.ts @@ -273,7 +273,7 @@ try { const serializedReplyContext = replyContext === undefined ? undefined : JSON.stringify(replyContext); - console.log( + process.stdout.write( `WALKING_SKELETON_RESULT ${JSON.stringify({ affordanceReplyClassified, archivePointerResolved, @@ -325,7 +325,7 @@ try { unaccountedAskAdvisory: appliedSweepOutputs.some((output) => JSON.stringify(output.advisories).includes("unaccounted-ask"), ), - })}`, + })}\n`, ); } finally { delete process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR; diff --git a/libs/@hashintel/brunch-agent/AGENTS.md b/libs/@hashintel/brunch-agent/AGENTS.md index 9e6a4226a8c..129149986e5 100644 --- a/libs/@hashintel/brunch-agent/AGENTS.md +++ b/libs/@hashintel/brunch-agent/AGENTS.md @@ -12,6 +12,18 @@ guidance always wins where it conflicts with this file. - `../../../apps/brunch-agent`: remote server, application composition, and local diagnostics. - `evaluations`: cases, protocols, and oracles; see `evaluations/AGENTS.md` before changing them. +## Stack + +- Format TypeScript and JSON with HASH-root `oxfmt` (double quotes, 80 columns), not Biome or + Prettier. Brunch Markdown remains excluded. +- `lint:eslint` runs Oxlint with multi-file import analysis, type-aware rules, and compiler + diagnostics. Package `.oxlintrc.json` files extend Brunch presets under + `.config/oxlint/brunch/`. +- `lint:tsc` remains the independent `tsgo --noEmit` type-check gate. +- `test:unit` runs Vitest through `vitest run`; architecture tests remain the topology, Flue + placement, and hermetic-runtime gates. +- Vite 8 builds the libraries and application. + The context root is not a package-manager root. Do not add a `package.json`, lockfile, nested workspace configuration, or standalone CI here. Run package tasks through HASH's root Yarn/Turbo workspace. diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/binding-flue/.oxlintrc.json new file mode 100644 index 00000000000..d7cfb823d33 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/.oxlintrc.json @@ -0,0 +1,52 @@ +{ + "$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", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "A binding may depend inward on the harness, not on Brunch extensions." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/package.json b/libs/@hashintel/brunch-agent/packages/binding-flue/package.json index 44cdc5a27c5..d7a4463edbb 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/package.json +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/package.json @@ -13,8 +13,8 @@ }, "scripts": { "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "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:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts index a12e6400489..dbdaf658b6d 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/capture-accounting.ts @@ -14,14 +14,17 @@ export const capturedUserEntryIdsForSession = async ( sessionId: string, ): Promise> => { const entryIds = new Set(); - for (const capture of snapshot.captures) { - if (!("evidence" in capture)) continue; - for (const evidence of capture.evidence) { - if (evidence.pointer.sessionId !== sessionId) continue; - for (const entry of await store.readArchivedEntries(evidence.pointer)) { - if (entry.versions.at(-1)?.kind === "user-affordance-payload") { - entryIds.add(entry.substrateEntryId); - } + const archiveReads = snapshot.captures.flatMap((capture) => + "evidence" in capture + ? capture.evidence + .filter((evidence) => evidence.pointer.sessionId === sessionId) + .map((evidence) => store.readArchivedEntries(evidence.pointer)) + : [], + ); + for (const archivedEntries of await Promise.all(archiveReads)) { + for (const entry of archivedEntries) { + if (entry.versions.at(-1)?.kind === "user-affordance-payload") { + entryIds.add(entry.substrateEntryId); } } } diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts index 534eb6b1205..8e7ac59bafe 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts @@ -281,6 +281,8 @@ describe("local capture store", () => { }), ); - await expect(createLocalCaptureStoreAdapter(path).read()).rejects.toThrow(); + await expect(createLocalCaptureStoreAdapter(path).read()).rejects.toThrow( + Error, + ); }); }); diff --git a/libs/@hashintel/brunch-agent/packages/core/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/core/.oxlintrc.json new file mode 100644 index 00000000000..871350799af --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/.oxlintrc.json @@ -0,0 +1,56 @@ +{ + "$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", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@flue/*", "@earendil-works/*"], + "message": "The Brunch harness must remain substrate-independent." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "The Brunch harness must not depend on bindings, plugins, or transports." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/core/package.json b/libs/@hashintel/brunch-agent/packages/core/package.json index 76258ade034..10899eaaf31 100644 --- a/libs/@hashintel/brunch-agent/packages/core/package.json +++ b/libs/@hashintel/brunch-agent/packages/core/package.json @@ -26,9 +26,9 @@ "scripts": { "baseline:run": "node --experimental-strip-types ../../evaluations/protocols/process-model-elicitation/baseline/run.ts", "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", "linear:graph": "node --experimental-strip-types ../../scripts/linear-project-graph.ts", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "test:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts b/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts index 54356282519..c398008580e 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts @@ -615,7 +615,7 @@ const isJsonValue = (value: unknown): value is JsonValue => { return Number.isFinite(value) && !Object.is(value, -0); if (Array.isArray(value)) return value.every(isJsonValue); if (typeof value !== "object") return false; - const prototype = Object.getPrototypeOf(value); + const prototype: unknown = Object.getPrototypeOf(value); return ( (prototype === Object.prototype || prototype === null) && Object.values(value as Record).every(isJsonValue) diff --git a/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts b/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts index 34c8403d420..4861710b030 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts @@ -43,6 +43,7 @@ export type Plugin = v.InferOutput & { export function definePlugin(descriptor: Plugin): Plugin { const identity = v.parse(PluginDescriptor, descriptor); const [proposal, ...extraProposals] = descriptor.proposalCatalog; + // oxlint-disable-next-line typescript/no-unnecessary-condition -- Public JavaScript callers still require the runtime cardinality guard. if (!proposal || extraProposals.length > 0) { throw new TypeError( "This slice requires exactly one declared proposal type.", diff --git a/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts b/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts index 328c1fa6db6..95ae176dbdd 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts @@ -149,7 +149,7 @@ const isJsonValue = (value: unknown): value is JsonValue => { return Number.isFinite(value) && !Object.is(value, -0); if (Array.isArray(value)) return value.every(isJsonValue); if (typeof value !== "object") return false; - const prototype = Object.getPrototypeOf(value); + const prototype: unknown = Object.getPrototypeOf(value); return ( (prototype === Object.prototype || prototype === null) && Object.values(value as Record).every(isJsonValue) diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts index 01fb800398f..75498305cc9 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import * as v from "valibot"; import { afterEach, describe, expect, test } from "vitest"; import { CONTEXT_ROOT, contextRootPresent } from "./workspace"; @@ -34,6 +35,22 @@ interface BaselineCopy { testDirectory: string; } +const BaselineCheckpoint = v.object({ + stopReason: v.string(), + calls: v.array(v.unknown()), + interviewerMessages: v.array( + v.object({ + role: v.picklist(["user", "assistant"]), + content: v.string(), + truncated: v.optional(v.boolean()), + }), + ), +}); + +const BaselineRequest = v.object({ + messages: v.array(v.record(v.string(), v.unknown())), +}); + async function createBaselineCopy(): Promise { const testDirectory = await mkdtemp(join(tmpdir(), "baseline-runner-test-")); temporaryDirectories.push(testDirectory); @@ -61,17 +78,9 @@ async function runBaseline( replies: StubReply[], mode?: "--resume" | "--continue-final", ): Promise<{ - checkpoint: { - stopReason: string; - calls: unknown[]; - interviewerMessages: Array<{ - role: "user" | "assistant"; - content: string; - truncated?: boolean; - }>; - }; + checkpoint: v.InferOutput; stderr: string; - requests: Array<{ messages: Array> }>; + requests: Array>; }> { const requestsPath = join(baselineCopy.testDirectory, "requests.jsonl"); const subprocess = spawn( @@ -105,16 +114,19 @@ async function runBaseline( }); expect(exitCode).toBe(0); - const checkpoint = JSON.parse( - await readFile( - join(baselineCopy.outputDirectory, "condition-1.raw.json"), - "utf8", - ), + const checkpoint = v.parse( + BaselineCheckpoint, + JSON.parse( + await readFile( + join(baselineCopy.outputDirectory, "condition-1.raw.json"), + "utf8", + ), + ) as unknown, ); const requests = (await readFile(requestsPath, "utf8")) .trim() .split("\n") - .map((line) => JSON.parse(line)); + .map((line) => v.parse(BaselineRequest, JSON.parse(line) as unknown)); return { checkpoint, stderr, requests }; } diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts index 5d15cdfcd9b..f35b536ff89 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/boundaries.test.ts @@ -155,7 +155,9 @@ describe("dependency direction (spec §4, §12.2)", () => { for (const specifier of importedPackages(file)) { const pkg = packageOf(specifier); expect(isSubstrate(pkg)).toBe(false); - if (pkg.startsWith("@hashintel/brunch-agent")) expect(pkg).toBe(CORE); + expect(pkg.startsWith("@hashintel/brunch-agent") ? pkg : CORE).toBe( + CORE, + ); expect(specifier).not.toBe(`${CORE}/storage`); } } @@ -207,17 +209,23 @@ describe("the direction is enforced under HASH's linker", () => { for (const pkg of PACKAGES) { const declared = runtimeDependencies(pkg); for (const file of sourceFiles(pkg)) { - for (const imported of importedPackages(file).map(packageOf)) { - if ( - imported === CORE || - imported.startsWith("@hashintel/brunch-agent-") - ) { - expect({ file: file.relPath, imported, declared }).toEqual({ - file: file.relPath, - imported, - declared: expect.arrayContaining([imported]), - }); - } + const importedWorkspaces = importedPackages(file) + .map(packageOf) + .filter( + (imported) => + imported === CORE || + imported.startsWith("@hashintel/brunch-agent-"), + ); + for (const imported of importedWorkspaces) { + expect({ + file: file.relPath, + imported, + declared: declared.includes(imported), + }).toEqual({ + file: file.relPath, + imported, + declared: true, + }); } } } @@ -426,11 +434,9 @@ describe("core auxiliary subpaths stay in their assigned lanes (spec §12.2)", ( describe("the HASH smoke is runnable without a model key or a network (spec §12.5)", () => { test("every Brunch workspace exposes HASH lint, typecheck, and unit-test tasks", () => { for (const pkg of PACKAGES) { - expect(pkg.manifest.scripts).toMatchObject({ - "lint:eslint": expect.any(String), - "lint:tsc": expect.any(String), - "test:unit": expect.stringContaining("vitest run"), - }); + expect(typeof pkg.manifest.scripts?.["lint:eslint"]).toBe("string"); + expect(typeof pkg.manifest.scripts?.["lint:tsc"]).toBe("string"); + expect(pkg.manifest.scripts?.["test:unit"]).toContain("vitest run"); } }); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts index 9e27a187488..6224ab98cf0 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/control-surfaces.test.ts @@ -70,14 +70,32 @@ describe.skipIf(!contextRootPresent)("strategic control surfaces", () => { test("supersedes targets resolve backward without cycles", () => { const seen = new Set(); + const invalidTargets: Array<{ + entry: string; + target: string; + reason: "malformed" | "not-earlier"; + }> = []; for (const entry of entries) { const supersedes = entry.fields.get("Supersedes")!; if (supersedes !== "none") { - expect(supersedes).toMatch(/^S-\d{3}$/); - expect(seen.has(supersedes)).toBe(true); + if (!/^S-\d{3}$/.test(supersedes)) { + invalidTargets.push({ + entry: entry.id, + target: supersedes, + reason: "malformed", + }); + } + if (!seen.has(supersedes)) { + invalidTargets.push({ + entry: entry.id, + target: supersedes, + reason: "not-earlier", + }); + } } seen.add(entry.id); } + expect(invalidTargets).toEqual([]); }); test("every strategy ID in steering resolves and is unsuperseded", () => { diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts index db2d443be41..070dcfcf8d2 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/docs-index.test.ts @@ -25,22 +25,22 @@ const DOCS_ROOT = join(REPO_ROOT, "docs"); const INDEX_RELPATH = "INDEX.md"; /** Gitignored ephemera — in the tree but not of it, so never indexed. */ -const SKIP_DIRECTORIES = ["drafts"]; +const SKIP_DIRECTORIES = new Set(["drafts"]); /** Filesystem and placeholder artefacts: not documents. */ -const SKIP_FILES = [".DS_Store", ".gitkeep"]; +const SKIP_FILES = new Set([".DS_Store", ".gitkeep"]); /** * `docs/agents/` is deliberately outside the INDEX's remit: those files are * pointed at from `AGENTS.md`, which is the pointer an agent actually reads, and * the third rule below governs them there. Listing them twice would let the two * registries disagree about what the protocol set is. */ -const INDEX_EXEMPT = ["agents", INDEX_RELPATH]; +const INDEX_EXEMPT = new Set(["agents", INDEX_RELPATH]); /** * Preserved external analysis containing links into its source checkout and * embedded Markdown examples. Those links are evidence, not context-local * navigation. */ -const LINK_CHECK_EXEMPT = ["reference/amp-analysis-flue-vs-tilde.md"]; +const LINK_CHECK_EXEMPT = new Set(["reference/amp-analysis-flue-vs-tilde.md"]); /** Immutable migration snapshots whose old paths are part of the evidence. */ const LINK_CHECK_EXEMPT_PREFIXES = [ "archive/migrations/hash-monorepo-import-plan.md", @@ -63,11 +63,10 @@ function documentationFiles(): string[] { const found: string[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir).sort()) { - if (SKIP_DIRECTORIES.includes(entry) || SKIP_FILES.includes(entry)) - continue; + if (SKIP_DIRECTORIES.has(entry) || SKIP_FILES.has(entry)) continue; const path = join(dir, entry); const rel = relPath(path); - if (INDEX_EXEMPT.includes(rel)) continue; + if (INDEX_EXEMPT.has(rel)) continue; if (statSync(path).isDirectory()) walk(path); else found.push(rel); } @@ -180,7 +179,7 @@ test("relative links in context documentation point at existing files", () => { for (const file of FILES) { if ( !file.endsWith(".md") || - LINK_CHECK_EXEMPT.includes(file) || + LINK_CHECK_EXEMPT.has(file) || LINK_CHECK_EXEMPT_PREFIXES.some((prefix) => file.startsWith(prefix)) ) continue; diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts index 3daa437caea..b4717bf4040 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/open-gaps.test.ts @@ -14,7 +14,7 @@ import { OPEN_GAPS } from "./open-gaps"; // than filed somewhere they would have to think to look. Silent when the ledger // is empty, because that is the goal state and not a warning. if (OPEN_GAPS.length > 0) { - console.warn( + process.stderr.write( [ "", `⚠ ${OPEN_GAPS.length} verification gaps are open (spec §14.5 and friends):`, diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts index 26d6acf3180..0a2952dd77c 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/workspace.ts @@ -118,7 +118,7 @@ export interface SourceFile { const SOURCE_EXTENSIONS = /\.(ts|tsx|mts|mjs|js|jsx)$/; /** Never scanned: not authored here, or build output. */ const SKIP_DIRECTORIES = ["node_modules", "dist", ".flue", ".git", ".turbo"]; -const TEST_DIRECTORIES = ["test", "tests", "__tests__"]; +const TEST_DIRECTORIES = new Set(["test", "tests", "__tests__"]); /** Every source file under a directory, recursively. A missing directory yields none. */ export function filesIn( @@ -163,7 +163,7 @@ function partitionedFiles(pkg: WorkspacePackage): { const test: SourceFile[] = []; for (const file of filesIn(pkg.path)) { const segments = relative(pkg.path, file.path).split(/[/\\]/); - (segments.some((segment) => TEST_DIRECTORIES.includes(segment)) + (segments.some((segment) => TEST_DIRECTORIES.has(segment)) ? test : source ).push(file); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts index fb59595d102..53000f8a5f0 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts @@ -682,6 +682,7 @@ describe("capture-store contract", () => { reason, refused: true, code: "invalid-envelope", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. message: expect.stringMatching(expectedMessage), }); } @@ -770,6 +771,7 @@ describe("capture-store contract", () => { reason, refused: true, code: "invalid-envelope", + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. message: expect.stringMatching(/open conflict.*share/i), }); } diff --git a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts index 049383c556a..78830ace982 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts @@ -45,7 +45,9 @@ describe("settlement and sweep protocol", () => { lastCheckedUserEntryId: null, }); expect(parseSweepState(initial)).toEqual(initial); - expect(() => parseSweepState({ ...initial, invented: true })).toThrow(); + expect(() => parseSweepState({ ...initial, invented: true })).toThrow( + Error, + ); }); test("computes the unswept range through the latest true-user entry only", () => { diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/.oxlintrc.json new file mode 100644 index 00000000000..52c387bca8c --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/.oxlintrc.json @@ -0,0 +1,60 @@ +{ + "$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", + { + "paths": [ + { + "name": "@hashintel/brunch-agent/storage", + "message": "Plugins receive harness capabilities and must remain storage-blind." + }, + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@flue/*", "@earendil-works/*"], + "message": "Brunch plugins must remain substrate-independent." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "A plugin may depend inward on the harness, not on Brunch extensions." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json index 1808988f8d0..5fcb5411090 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/package.json @@ -13,8 +13,8 @@ }, "scripts": { "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "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:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts index 04a01a4748c..548b646d061 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/test/statement-noted.test.ts @@ -61,7 +61,7 @@ describe("the Gherkin verbatim-grade proposal floor", () => { }, ], }), - ).toThrow(); + ).toThrow(v.ValiError); expect(() => v.parse(schema, { proposals: [ @@ -76,6 +76,6 @@ describe("the Gherkin verbatim-grade proposal floor", () => { }, ], }), - ).toThrow(); + ).toThrow(v.ValiError); }); }); diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/transport-aisdk/.oxlintrc.json new file mode 100644 index 00000000000..8cf02a4f042 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/.oxlintrc.json @@ -0,0 +1,56 @@ +{ + "$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", + { + "paths": [ + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@flue/*", "@earendil-works/*"], + "message": "Brunch transports must remain substrate-independent." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "A transport may depend inward on the harness, not on Brunch extensions." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json b/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json index 6095c8f6a15..1c0f37880ff 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json @@ -17,8 +17,8 @@ }, "scripts": { "build": "vite build", - "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", - "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "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:unit": "vitest run" }, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts index 503793f2bea..e0820b6ae33 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts @@ -234,7 +234,6 @@ const userTextFrom = (message: PanelMessage): string | undefined => { .filter( (part): part is { readonly type: "text"; readonly text: string } => typeof part === "object" && - part !== null && "type" in part && part.type === "text" && "text" in part && diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts index a12578e635e..3b5ab8a676b 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts @@ -265,7 +265,7 @@ describe("FE-1449 ask return POST", () => { expect({ reason, status: response.status, - body: await response.json(), + body: (await response.json()) as unknown, }).toEqual({ reason, status: 409, From ddecada5e882b75ddb2b8bdd059ca490b71f8b5b Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Mon, 24 Aug 2026 17:55:53 +0200 Subject: [PATCH 2/2] Unify Brunch TypeScript contract ownership Derive persisted, transport, and test types from their schemas and library contracts so runtime boundaries and TypeScript consumers cannot drift independently. Co-authored-by: Cursor --- apps/brunch-agent/package.json | 1 + .../src/agents/gherkin-elicitor.ts | 11 +- apps/brunch-agent/src/elicitation-session.ts | 8 +- .../brunch-agent/test/petrinaut-ask-result.ts | 19 ++ .../test/petrinaut-ask.integration.ts | 44 ++-- apps/brunch-agent/test/petrinaut-ask.test.ts | 20 +- .../test/petrinaut-chat-result.ts | 11 + .../test/petrinaut-chat.integration.ts | 41 ++-- apps/brunch-agent/test/petrinaut-chat.test.ts | 65 ++--- .../test/transport-aisdk-server.test.ts | 6 +- .../test/walking-skeleton.integration.ts | 15 +- .../process-model-elicitation/baseline/run.ts | 32 +-- .../packages/binding-flue/src/index.ts | 3 +- .../binding-flue/src/reply-projector.ts | 10 +- .../packages/core/src/capture-store.ts | 230 +++++------------- .../brunch-agent/packages/core/src/index.ts | 9 +- .../packages/core/src/json-value.ts | 30 +++ .../packages/core/src/readonly-deep.ts | 21 ++ .../packages/core/src/reply-protocol.ts | 15 +- .../packages/core/src/session-log.ts | 117 +++------ .../packages/core/src/sweep-protocol.ts | 32 +-- .../packages/core/test/anchoring.test.ts | 3 +- .../test/architecture/baseline-runner.test.ts | 7 +- .../fixtures/baseline-anthropic-stub.ts | 18 +- .../packages/core/test/capture-store.test.ts | 18 +- .../packages/core/test/sweep-protocol.test.ts | 3 +- .../packages/plugin-gherkin/src/index.ts | 6 +- .../packages/transport-aisdk/src/index.ts | 54 ++-- .../transport-aisdk/test/ask-reply.test.ts | 6 +- .../transport-aisdk/test/golden.test.ts | 76 +++--- yarn.lock | 1 + 31 files changed, 443 insertions(+), 489 deletions(-) create mode 100644 apps/brunch-agent/test/petrinaut-ask-result.ts create mode 100644 apps/brunch-agent/test/petrinaut-chat-result.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/src/json-value.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/src/readonly-deep.ts diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index b27b47ca55a..2768015dbfa 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -34,6 +34,7 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260511.1", + "ai": "6.0.182", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", "vite": "8.1.0", diff --git a/apps/brunch-agent/src/agents/gherkin-elicitor.ts b/apps/brunch-agent/src/agents/gherkin-elicitor.ts index a6bbc6d387d..9aaf8b1e493 100644 --- a/apps/brunch-agent/src/agents/gherkin-elicitor.ts +++ b/apps/brunch-agent/src/agents/gherkin-elicitor.ts @@ -34,9 +34,14 @@ import { createGherkinElicitationSession } from "../elicitation-session.ts"; */ export const GHERKIN_MODEL_ID = "claude-haiku-4-5"; +const gherkinElicitorInitialData = v.object({ + targetDocumentId: v.pipe(v.string(), v.nonEmpty()), +}); + export function GherkinElicitor(props: AgentProps) { useModel(`anthropic/${GHERKIN_MODEL_ID}`); - const initialData = useInitialData<{ targetDocumentId: string }>(); + const initialData = + useInitialData>(); return useElicitation( gherkin, createGherkinElicitationSession(props.id, initialData.targetDocumentId), @@ -68,6 +73,4 @@ GherkinElicitor.agentName = "brunch-gherkin-elicitor"; * to an existing conversation id resumes that session against the current state * of its target-document. */ -GherkinElicitor.initialData = v.object({ - targetDocumentId: v.pipe(v.string(), v.nonEmpty()), -}); +GherkinElicitor.initialData = gherkinElicitorInitialData; diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts index ae7a9753668..827a91833fd 100644 --- a/apps/brunch-agent/src/elicitation-session.ts +++ b/apps/brunch-agent/src/elicitation-session.ts @@ -4,15 +4,19 @@ import { createFlueHistoryReader, createLocalCaptureStore, type ElicitationSession, + type FlueHistoryReaderOptions, } from "@hashintel/brunch-agent-binding-flue"; import { GHERKIN_AGENT_ROUTE } from "./routes.ts"; import { targetDocumentPath } from "./target-document-path.ts"; -const appTransport = (async (input: RequestInfo | URL, init?: RequestInit) => { +const appTransport: FlueHistoryReaderOptions["transport"] = async ( + input, + init, +) => { const { default: app } = await import("./app.ts"); return app.fetch(input instanceof Request ? input : new Request(input, init)); -}) as typeof fetch; +}; export const createGherkinElicitationSession = ( sessionId: string, diff --git a/apps/brunch-agent/test/petrinaut-ask-result.ts b/apps/brunch-agent/test/petrinaut-ask-result.ts new file mode 100644 index 00000000000..f0175bfea88 --- /dev/null +++ b/apps/brunch-agent/test/petrinaut-ask-result.ts @@ -0,0 +1,19 @@ +import type { UIMessageChunk } from "ai"; + +type ToolInputChunk = Extract; +type ToolOutputChunk = Extract< + UIMessageChunk, + { type: "tool-output-available" } +>; + +export interface PetrinautAskResult { + readonly initialStatus: number; + readonly askCall: ToolInputChunk | undefined; + readonly initialToolOutputs: readonly ToolOutputChunk[]; + readonly initialFinish: UIMessageChunk | undefined; + readonly resumedStatus: number; + readonly resumedText: string; + readonly resumedFinish: UIMessageChunk | undefined; + readonly duplicateStatus: number; + readonly duplicateBody: unknown; +} diff --git a/apps/brunch-agent/test/petrinaut-ask.integration.ts b/apps/brunch-agent/test/petrinaut-ask.integration.ts index 708cae0db5b..ae6033c43ec 100644 --- a/apps/brunch-agent/test/petrinaut-ask.integration.ts +++ b/apps/brunch-agent/test/petrinaut-ask.integration.ts @@ -24,6 +24,9 @@ import { GherkinElicitor, } from "../src/agents/gherkin-elicitor.ts"; +import type { PetrinautAskResult } from "./petrinaut-ask-result"; +import type { UIMessageChunk } from "ai"; + const targetDirectory = await mkdtemp(join(tmpdir(), "brunch-petrinaut-ask-")); process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory; process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1"; @@ -59,14 +62,12 @@ const flue = await start({ providers: [faux.provider], }); -type StreamChunk = Record & { readonly type: string }; - -const chunksFrom = (body: string): StreamChunk[] => +const chunksFrom = (body: string): UIMessageChunk[] => body .trim() .split("\n\n") .slice(0, -1) - .map((frame) => JSON.parse(frame.slice("data: ".length)) as StreamChunk); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); try { const { default: app } = await import("../src/app.ts"); @@ -128,24 +129,23 @@ try { const duplicate = await postChat("request-fe1449-duplicate", returnBody); - process.stdout.write( - `PETRINAUT_ASK_RESULT ${JSON.stringify({ - initialStatus: initial.status, - askCall, - initialToolOutputs: initialChunks.filter( - (chunk) => chunk.type === "tool-output-available", - ), - initialFinish: initialChunks.at(-1), - resumedStatus: resumed.status, - resumedText: resumedChunks - .filter((chunk) => chunk.type === "text-delta") - .map((chunk) => chunk.delta) - .join(""), - resumedFinish: resumedChunks.at(-1), - duplicateStatus: duplicate.status, - duplicateBody: (await duplicate.json()) as unknown, - })}\n`, - ); + const result: PetrinautAskResult = { + initialStatus: initial.status, + askCall, + initialToolOutputs: initialChunks.filter( + (chunk) => chunk.type === "tool-output-available", + ), + initialFinish: initialChunks.at(-1), + resumedStatus: resumed.status, + resumedText: resumedChunks + .filter((chunk) => chunk.type === "text-delta") + .map((chunk) => chunk.delta) + .join(""), + resumedFinish: resumedChunks.at(-1), + duplicateStatus: duplicate.status, + duplicateBody: (await duplicate.json()) as unknown, + }; + process.stdout.write(`PETRINAUT_ASK_RESULT ${JSON.stringify(result)}\n`); } finally { await flue.stop(); await rm(targetDirectory, { recursive: true, force: true }); diff --git a/apps/brunch-agent/test/petrinaut-ask.test.ts b/apps/brunch-agent/test/petrinaut-ask.test.ts index 3d987b46e79..f5ad1e90664 100644 --- a/apps/brunch-agent/test/petrinaut-ask.test.ts +++ b/apps/brunch-agent/test/petrinaut-ask.test.ts @@ -4,7 +4,9 @@ import { expect, test } from "vitest"; import { runNodeScript } from "./run-node-script"; -type StreamChunk = Record & { readonly type: string }; +import type { PetrinautAskResult } from "./petrinaut-ask-result"; +import type { TransportInspectionEvent } from "@hashintel/brunch-agent-transport-aisdk"; + const testDirectory = import.meta.dirname; test("a structured ask suspends over the wire and its correlated submission resumes the conversation", async () => { @@ -20,17 +22,7 @@ test("a structured ask suspends over the wire and its correlated submission resu expect(resultLine, stdout).toBeDefined(); const result = JSON.parse( resultLine!.slice("PETRINAUT_ASK_RESULT ".length), - ) as { - initialStatus: number; - askCall: StreamChunk | undefined; - initialToolOutputs: StreamChunk[]; - initialFinish: StreamChunk; - resumedStatus: number; - resumedText: string; - resumedFinish: StreamChunk; - duplicateStatus: number; - duplicateBody: unknown; - }; + ) as PetrinautAskResult; // Suspension: the ask leaves the server as an awaiting client tool with a // stable call id; the harness's minted affordance never reaches the wire. @@ -69,7 +61,9 @@ test("a structured ask suspends over the wire and its correlated submission resu .filter((line) => line.startsWith("TRANSPORT_AISDK ")) .map( (line) => - JSON.parse(line.slice("TRANSPORT_AISDK ".length)) as StreamChunk, + JSON.parse( + line.slice("TRANSPORT_AISDK ".length), + ) as TransportInspectionEvent, ); expect(inspections.some((event) => event.type === "ask-await")).toBe(true); expect(inspections.some((event) => event.type === "ask-reply-admitted")).toBe( diff --git a/apps/brunch-agent/test/petrinaut-chat-result.ts b/apps/brunch-agent/test/petrinaut-chat-result.ts new file mode 100644 index 00000000000..0a8fe2cbe92 --- /dev/null +++ b/apps/brunch-agent/test/petrinaut-chat-result.ts @@ -0,0 +1,11 @@ +import type { UIMessageChunk } from "ai"; + +export interface PetrinautChatResult { + readonly status: number; + readonly messageId: string | undefined; + readonly partIds: readonly string[]; + readonly reasoning: string; + readonly text: string; + readonly finish: UIMessageChunk | undefined; + readonly chunks: readonly UIMessageChunk[]; +} diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index 41720a7f282..3325a33e151 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -16,6 +16,9 @@ import { GherkinElicitor, } from "../src/agents/gherkin-elicitor.ts"; +import type { PetrinautChatResult } from "./petrinaut-chat-result"; +import type { UIMessageChunk } from "ai"; + const targetDirectory = await mkdtemp(join(tmpdir(), "brunch-petrinaut-chat-")); process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory; process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1"; @@ -67,10 +70,7 @@ try { .trim() .split("\n\n") .slice(0, -1) - .map( - (frame) => - JSON.parse(frame.slice("data: ".length)) as Record, - ); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); const startChunk = chunks.find((chunk) => chunk.type === "start"); const partIds = chunks .filter( @@ -79,23 +79,22 @@ try { ) .map((chunk) => chunk.id); - process.stdout.write( - `PETRINAUT_CHAT_RESULT ${JSON.stringify({ - status: response.status, - messageId: startChunk?.messageId, - partIds, - reasoning: chunks - .filter((chunk) => chunk.type === "reasoning-delta") - .map((chunk) => chunk.delta) - .join(""), - text: chunks - .filter((chunk) => chunk.type === "text-delta") - .map((chunk) => chunk.delta) - .join(""), - finish: chunks.at(-1), - chunks, - })}\n`, - ); + const result: PetrinautChatResult = { + status: response.status, + messageId: startChunk?.messageId, + partIds, + reasoning: chunks + .filter((chunk) => chunk.type === "reasoning-delta") + .map((chunk) => chunk.delta) + .join(""), + text: chunks + .filter((chunk) => chunk.type === "text-delta") + .map((chunk) => chunk.delta) + .join(""), + finish: chunks.at(-1), + chunks, + }; + process.stdout.write(`PETRINAUT_CHAT_RESULT ${JSON.stringify(result)}\n`); } finally { await flue.stop(); await rm(targetDirectory, { recursive: true, force: true }); diff --git a/apps/brunch-agent/test/petrinaut-chat.test.ts b/apps/brunch-agent/test/petrinaut-chat.test.ts index 5d6be446944..72e8b8b643d 100644 --- a/apps/brunch-agent/test/petrinaut-chat.test.ts +++ b/apps/brunch-agent/test/petrinaut-chat.test.ts @@ -5,33 +5,49 @@ import { expect, test } from "vitest"; import { runNodeScript } from "./run-node-script"; -type StreamChunk = Record & { readonly type: string }; +import type { PetrinautChatResult } from "./petrinaut-chat-result"; +import type { TransportInspectionEvent } from "@hashintel/brunch-agent-transport-aisdk"; +import type { UIMessageChunk } from "ai"; + const testDirectory = import.meta.dirname; const normalizedChunk = ( - chunk: StreamChunk, + chunk: UIMessageChunk, messageId: string, -): StreamChunk => { - const normalized = { ...chunk }; - if (normalized.messageId === messageId) normalized.messageId = "$message"; - if (typeof normalized.id === "string") +): UIMessageChunk => { + const normalized = structuredClone(chunk); + if ("messageId" in normalized && normalized.messageId === messageId) + normalized.messageId = "$message"; + if ("id" in normalized && typeof normalized.id === "string") normalized.id = normalized.id.replace(messageId, "$message"); return normalized; }; +type DeltaChunk = Extract< + UIMessageChunk, + { type: `${string}-delta`; id: string; delta: string } +>; + +const isDeltaChunk = (chunk: UIMessageChunk): chunk is DeltaChunk => + chunk.type.endsWith("-delta") && + "id" in chunk && + typeof chunk.id === "string" && + "delta" in chunk && + typeof chunk.delta === "string"; + const normalizedChunks = ( - chunks: readonly StreamChunk[], + chunks: readonly UIMessageChunk[], messageId: string, -): StreamChunk[] => - chunks.reduce((normalized, chunk) => { +): UIMessageChunk[] => + chunks.reduce((normalized, chunk) => { const current = normalizedChunk(chunk, messageId); const previous = normalized.at(-1); if ( - current.type.endsWith("-delta") && - previous?.type === current.type && - previous.id === current.id && - typeof previous.delta === "string" && - typeof current.delta === "string" + isDeltaChunk(current) && + previous !== undefined && + isDeltaChunk(previous) && + previous.type === current.type && + previous.id === current.id ) { previous.delta += current.delta; return normalized; @@ -52,10 +68,9 @@ test("the committed application route drives the actual elicitor for reasoning a .filter((line) => line.startsWith("TRANSPORT_AISDK ")) .map( (line) => - JSON.parse(line.slice("TRANSPORT_AISDK ".length)) as Record< - string, - unknown - >, + JSON.parse( + line.slice("TRANSPORT_AISDK ".length), + ) as TransportInspectionEvent, ); const resultLine = stdout .split("\n") @@ -63,17 +78,11 @@ test("the committed application route drives the actual elicitor for reasoning a expect(resultLine, stdout).toBeDefined(); const result = JSON.parse( resultLine!.slice("PETRINAUT_CHAT_RESULT ".length), - ) as { - status: number; - messageId: string; - partIds: string[]; - reasoning: string; - text: string; - finish: unknown; - chunks: StreamChunk[]; - }; + ) as PetrinautChatResult; expect(result.status).toBe(200); + expect(result.messageId).toBeDefined(); + if (result.messageId === undefined) throw new Error("missing message id"); expect(result.messageId.length).toBeGreaterThan(0); expect( result.partIds.every((partId) => partId.startsWith(`${result.messageId}:`)), @@ -91,7 +100,7 @@ test("the committed application route drives the actual elicitor for reasoning a ), "utf8", ), - ) as StreamChunk[]; + ) as UIMessageChunk[]; expect(normalizedChunks(result.chunks, result.messageId)).toEqual(golden); expect(inspectionLines[0]).toMatchObject({ type: "request-start", diff --git a/apps/brunch-agent/test/transport-aisdk-server.test.ts b/apps/brunch-agent/test/transport-aisdk-server.test.ts index 02dd86701a1..cecfb9042e9 100644 --- a/apps/brunch-agent/test/transport-aisdk-server.test.ts +++ b/apps/brunch-agent/test/transport-aisdk-server.test.ts @@ -10,7 +10,7 @@ import { type TransportInspectionEvent, } from "@hashintel/brunch-agent-transport-aisdk"; -type GoldenChunk = Record & { readonly type: string }; +import type { UIMessageChunk } from "ai"; const FIXTURES = join( import.meta.dirname, @@ -22,12 +22,12 @@ const fixture = (name: string): string => const responseChunks = async ( response: Response, -): Promise => +): Promise => (await response.text()) .trim() .split("\n\n") .slice(0, -1) - .map((frame) => JSON.parse(frame.slice("data: ".length)) as GoldenChunk); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); const panelInitialHarnessEvents: readonly HarnessReplyEvent[] = [ { type: "response-start", messageId: "assistant-fe1435-1" }, diff --git a/apps/brunch-agent/test/walking-skeleton.integration.ts b/apps/brunch-agent/test/walking-skeleton.integration.ts index 2fcb8647235..607ca8872dc 100644 --- a/apps/brunch-agent/test/walking-skeleton.integration.ts +++ b/apps/brunch-agent/test/walking-skeleton.integration.ts @@ -18,6 +18,7 @@ import { toolName } from "@hashintel/brunch-agent"; import { createFlueHistoryReader, createLocalCaptureStore, + type FlueHistoryReaderOptions, } from "@hashintel/brunch-agent-binding-flue"; import { @@ -28,19 +29,21 @@ import app from "../src/app.ts"; import { GHERKIN_AGENT_ROUTE } from "../src/routes.ts"; import { targetDocumentPath } from "../src/target-document-path.ts"; +import type { StatementNotedProposalInput } from "@hashintel/brunch-agent-plugin-gherkin"; + const ask = toolName("ask"); const sweep = toolName("sweep"); const omittedQuote = "A shopper completes checkout."; const newlyCapturedQuote = "Payment is authorized before fulfillment."; const repairedQuote = "Refunds require approval."; const missingQuote = "This quote is not in the conversation."; -const statementNoted = (quote: string) => ({ +const statementNoted = (quote: string): StatementNotedProposalInput => ({ evidence: [{ excerpt: quote }], - epistemicStatus: "explicit" as const, - confidence: "firm" as const, + epistemicStatus: "explicit", + confidence: "firm", content: { value: { - type: "statement-noted" as const, + type: "statement-noted", interior: { verbatim: quote }, }, }, @@ -136,10 +139,10 @@ const targetDirectory = await mkdtemp( try { process.env.BRUNCH_DEV_TARGET_DOCUMENT_DIR = targetDirectory; - const fetchApp = ((input: RequestInfo | URL, init?: RequestInit) => + const fetchApp: FlueHistoryReaderOptions["transport"] = (input, init) => Promise.resolve( app.fetch(input instanceof Request ? input : new Request(input, init)), - )) as typeof fetch; + ); const conversationId = `walking-skeleton-${crypto.randomUUID()}`; const targetDocumentId = "walking-skeleton-test"; const captureStore = createLocalCaptureStore( diff --git a/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts b/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts index 4ec4b0bae3e..b75d45614e2 100644 --- a/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts +++ b/libs/@hashintel/brunch-agent/evaluations/protocols/process-model-elicitation/baseline/run.ts @@ -39,25 +39,24 @@ const FORCED_WRAP_MESSAGE = const CONTINUE_MESSAGE = "You were cut off mid-document. Continue exactly from where you stopped — no preamble, no repetition."; -interface ChatMessage { - role: "user" | "assistant"; - content: string; +type ChatMessage = Omit & { + content: Extract; // Present only when the API ended this model-generated message at its token limit. // Older checkpoints and human-authored messages legitimately omit it. truncated?: true; -} +}; -interface Usage { - input_tokens: number; - output_tokens: number; - // Absent in checkpoints written before the SDK migration; treated as 0. - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; -} +type Usage = Pick & + Partial< + Pick< + Anthropic.Usage, + "cache_creation_input_tokens" | "cache_read_input_tokens" + > + >; interface CallRecord { agent: "interviewer" | "expert" | "classifier"; - model: string; + model: Anthropic.Model; usage: Usage; } @@ -68,7 +67,7 @@ interface CallResult { interface RawCheckpoint { startedAt: string; - condition: string; + condition: "1" | "2"; stopReason: string; calls: CallRecord[]; interviewerMessages: ChatMessage[]; @@ -112,8 +111,9 @@ function usage(): never { const conditionArg = process.argv[2]; const mode = process.argv[3] ?? "fresh"; if (conditionArg !== "1" && conditionArg !== "2") usage(); -if (!["fresh", "--resume", "--continue-final"].includes(mode)) usage(); -const condition: "1" | "2" = conditionArg; +if (mode !== "fresh" && mode !== "--resume" && mode !== "--continue-final") + usage(); +const condition = conditionArg; const baseDir = fileURLToPath(new URL(".", import.meta.url)); const caseDir = fileURLToPath( @@ -135,7 +135,7 @@ const calls: CallRecord[] = []; async function callClaude( agent: CallRecord["agent"], - model: string, + model: CallRecord["model"], system: string | undefined, messages: ChatMessage[], maxTokens: number, diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts index 715684db65b..f16f4ecdb1d 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts @@ -26,6 +26,7 @@ import { ASK_TOOL_DESCRIPTION, AskInput, FreeTextAffordance, + SWEEP_RESULT_STATUSES, advanceSweepHighWater, askProtocolInstructionFragments, buildSettlementCheckSignal, @@ -57,7 +58,7 @@ import { } from "./history-reader"; const SweepToolOutput = v.looseObject({ - status: v.picklist(["no-settled-range", "refused", "applied"]), + status: v.picklist(SWEEP_RESULT_STATUSES), }); export { CAPABILITIES, type Capability, type Provision } from "./capabilities"; diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts index ed0322ae76e..0caba201aaf 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/reply-projector.ts @@ -13,10 +13,10 @@ export interface FlueReplyProjector { accept(chunk: ConversationStreamChunk): void; } -type StreamingPart = { - readonly kind: "text" | "reasoning"; - readonly partId: string; -}; +type StreamingPart = Omit< + Extract, + "type" +>; export const createFlueReplyProjector = ( options: FlueReplyProjectorOptions, @@ -40,7 +40,7 @@ export const createFlueReplyProjector = ( turnId = undefined; }; - const startPart = (kind: "text" | "reasoning"): StreamingPart => { + const startPart = (kind: StreamingPart["kind"]): StreamingPart => { finishPart(); partOrdinal += 1; const part = { diff --git a/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts b/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts index c398008580e..ec9206b8d5f 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/capture-store.ts @@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto"; import * as v from "valibot"; +import { JsonValueSchema } from "./json-value"; import { + EvidenceQuoteSchema, resolveEvidenceQuotes, type EvidenceQuote, type EvidenceResolutionRefusal, @@ -11,6 +13,11 @@ import { type SessionLogArchive, } from "./session-log"; +import type { JsonValue } from "./json-value"; +import type { ReadonlyDeep } from "./readonly-deep"; + +export type { JsonValue } from "./json-value"; + export const ABSENCE_STATES = [ "unknown-to-user", "not-yet-decided", @@ -43,95 +50,29 @@ export type EpistemicStatus = (typeof EPISTEMIC_STATUSES)[number]; export type IssueType = (typeof ISSUE_TYPES)[number]; export type CaptureStatus = "active" | "superseded" | "retracted"; export type IssueStatus = "open" | "closed"; -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | { readonly [key: string]: JsonValue }; - -export interface EvidenceSpan { - readonly excerpt: string; - readonly pointer: { - readonly sessionId: string; - readonly entryStart: number; - readonly entryEnd: number; - }; - readonly source: "user" | "user-affordance-payload"; -} - -export type CaptureContent = - | { readonly value: JsonValue } - | { readonly absence: AbsenceState }; - -interface CaptureProposalCommon { - readonly confidence: string; - readonly content: CaptureContent; - readonly alternativeGroup?: string; - readonly supersedes?: string; -} - -export type CaptureInputProposal = CaptureProposalCommon & - ( - | { - readonly evidence: readonly EvidenceQuote[]; - readonly epistemicStatus: "explicit" | "inferred" | "tentative"; - } - | { - readonly basis: { - readonly type: "declared-default"; - readonly description: string; - }; - readonly epistemicStatus: "defaulted"; - } - | { - readonly basis: { - readonly type: "documented-transformation"; - readonly description: string; - }; - readonly epistemicStatus: "external-lookup"; - } - ); - -export type CaptureProposal = CaptureProposalCommon & - ( - | { - readonly evidence: readonly EvidenceSpan[]; - readonly epistemicStatus: "explicit" | "inferred" | "tentative"; - } - | { - readonly basis: { - readonly type: "declared-default"; - readonly description: string; - }; - readonly epistemicStatus: "defaulted"; - } - | { - readonly basis: { - readonly type: "documented-transformation"; - readonly description: string; - }; - readonly epistemicStatus: "external-lookup"; - } - ); - -export type CaptureEnvelope = CaptureProposal & { - readonly id: string; - readonly dedupKey: string; -}; - -export type IssueOrigin = - | { readonly type: "harness" } - | { readonly type: "plugin"; readonly namespace: string }; - -export interface CaptureIssue { - readonly id: string; - readonly type: IssueType; - readonly origin: IssueOrigin; - readonly references: readonly string[]; - readonly canDefault: boolean; -} +export type EvidenceSpan = ReadonlyDeep< + v.InferOutput +>; +export type CaptureContent = ReadonlyDeep>; +type ParsedCaptureInputProposal = ReadonlyDeep< + v.InferOutput +>; +export type CaptureInputProposal = + ParsedCaptureInputProposal extends infer Proposal + ? Proposal extends { readonly evidence: readonly unknown[] } + ? Omit & { + readonly evidence: readonly EvidenceQuote[]; + } + : Proposal + : never; +export type CaptureProposal = ReadonlyDeep< + v.InferOutput +>; +export type CaptureEnvelope = ReadonlyDeep< + v.InferOutput +>; +export type CaptureIssue = ReadonlyDeep>; +export type IssueOrigin = CaptureIssue["origin"]; export type CaptureAdvisory = | { @@ -141,39 +82,21 @@ export type CaptureAdvisory = } | MultipleEvidenceMatchesAdvisory; -export interface ResolutionRecord { - readonly type: "resolution"; - readonly id: string; - readonly issueId: string; - readonly decision: string; - readonly evidence: readonly EvidenceSpan[]; - readonly winnerCaptureId: string; - readonly loserCaptureIds: readonly string[]; -} - -export interface RetractionEvent { - readonly type: "retraction"; - readonly id: string; - readonly captureId: string; - readonly evidence: readonly EvidenceSpan[]; -} - -export interface IssueClosedEvent { - readonly type: "issue-closed"; - readonly id: string; - readonly issueId: string; -} - -export type CaptureStoreEvent = - | ResolutionRecord - | RetractionEvent - | IssueClosedEvent; - -export interface CaptureStoreSnapshot { - readonly captures: readonly CaptureEnvelope[]; - readonly issues: readonly CaptureIssue[]; - readonly events: readonly CaptureStoreEvent[]; -} +export type ResolutionRecord = ReadonlyDeep< + v.InferOutput +>; +export type RetractionEvent = ReadonlyDeep< + v.InferOutput +>; +export type IssueClosedEvent = ReadonlyDeep< + v.InferOutput +>; +export type CaptureStoreEvent = ReadonlyDeep< + v.InferOutput +>; +export type CaptureStoreSnapshot = ReadonlyDeep< + v.InferOutput +>; export interface CaptureStore { read(): Promise; @@ -310,9 +233,8 @@ const evidenceSpanSchema = v.strictObject({ ), source: v.picklist(["user", "user-affordance-payload"]), }); -const evidenceQuoteSchema = v.strictObject({ excerpt: nonEmptyString }); const contentSchema = v.union([ - v.strictObject({ value: v.unknown() }), + v.strictObject({ value: JsonValueSchema }), v.strictObject({ absence: v.picklist(ABSENCE_STATES) }), ]); const captureCommonFields = { @@ -350,7 +272,7 @@ const captureProposalSchema = v.union([ export const CaptureInputProposalSchema = v.union([ v.strictObject({ ...captureCommonFields, - evidence: v.pipe(v.array(evidenceQuoteSchema), v.minLength(1)), + evidence: v.pipe(v.array(EvidenceQuoteSchema), v.minLength(1)), epistemicStatus: v.picklist(["explicit", "inferred", "tentative"]), }), v.strictObject(defaultedCaptureFields), @@ -414,12 +336,15 @@ const issueClosedSchema = v.strictObject({ id: nonEmptyString, issueId: nonEmptyString, }); +const captureStoreEventSchema = v.variant("type", [ + resolutionSchema, + retractionSchema, + issueClosedSchema, +]); const snapshotSchema = v.strictObject({ captures: v.array(captureEnvelopeSchema), issues: v.array(issueSchema), - events: v.array( - v.variant("type", [resolutionSchema, retractionSchema, issueClosedSchema]), - ), + events: v.array(captureStoreEventSchema), }); /** @@ -450,7 +375,7 @@ export const createEmptyCaptureStoreSnapshot = (): CaptureStoreSnapshot => ({ export const parseCaptureStoreSnapshot = ( input: unknown, ): CaptureStoreSnapshot => { - const snapshot = v.parse(snapshotSchema, input) as CaptureStoreSnapshot; + const snapshot = v.parse(snapshotSchema, input); for (const records of [snapshot.captures, snapshot.issues, snapshot.events]) { if (new Set(records.map((record) => record.id)).size !== records.length) { throw new TypeError( @@ -459,11 +384,6 @@ export const parseCaptureStoreSnapshot = ( } } for (const capture of snapshot.captures) { - if ("value" in capture.content && !isJsonValue(capture.content.value)) { - throw new TypeError( - `Capture ${capture.id} contains a value that cannot be stored as JSON`, - ); - } if (capture.dedupKey !== captureDedupKey(capture)) { throw new TypeError( `Capture ${capture.id} has a stale content dedup key.`, @@ -606,22 +526,6 @@ export const parseCaptureStoreSnapshot = ( return snapshot; }; -const isJsonValue = (value: unknown): value is JsonValue => { - if (value === null || typeof value === "string" || typeof value === "boolean") - return true; - // Negative zero is refused alongside the non-finite numbers: JSON.stringify - // writes it as "0", so the read path could never reproduce what was accepted. - if (typeof value === "number") - return Number.isFinite(value) && !Object.is(value, -0); - if (Array.isArray(value)) return value.every(isJsonValue); - if (typeof value !== "object") return false; - const prototype: unknown = Object.getPrototypeOf(value); - return ( - (prototype === Object.prototype || prototype === null) && - Object.values(value as Record).every(isJsonValue) - ); -}; - const canonicalize = (value: JsonValue): JsonValue => { if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { @@ -747,16 +651,7 @@ const validateProposal = ( // well formed and declare a source, which is not the same as provenance // having been resolved against an entry projection. message: - "A capture must carry the provenance shape its epistemic status names, exactly one of value or absence, and evidence ranges that do not end before they start.", - }; - } - if ( - "value" in parsed.output.content && - !isJsonValue(parsed.output.content.value) - ) { - return { - code: "invalid-envelope", - message: "A capture value must be JSON-compatible.", + "A capture must carry the provenance shape its epistemic status names, exactly one JSON-compatible value or absence, and evidence ranges that do not end before they start.", }; } return undefined; @@ -770,16 +665,7 @@ const validateInputProposal = ( return { code: "invalid-envelope", message: - "A capture must carry the provenance shape its epistemic status names, exactly one of value or absence, and non-empty verbatim evidence quotes.", - }; - } - if ( - "value" in parsed.output.content && - !isJsonValue(parsed.output.content.value) - ) { - return { - code: "invalid-envelope", - message: "A capture value must be JSON-compatible.", + "A capture must carry the provenance shape its epistemic status names, exactly one JSON-compatible value or absence, and non-empty verbatim evidence quotes.", }; } return undefined; @@ -1218,7 +1104,7 @@ export const applyCaptureStoreCommand = ( } if ( !v.safeParse( - v.pipe(v.array(evidenceQuoteSchema), v.minLength(1)), + v.pipe(v.array(EvidenceQuoteSchema), v.minLength(1)), command.evidence, ).success ) { @@ -1301,7 +1187,7 @@ export const applyCaptureStoreCommand = ( } if ( !v.safeParse( - v.pipe(v.array(evidenceQuoteSchema), v.minLength(1)), + v.pipe(v.array(EvidenceQuoteSchema), v.minLength(1)), command.evidence, ).success ) { diff --git a/libs/@hashintel/brunch-agent/packages/core/src/index.ts b/libs/@hashintel/brunch-agent/packages/core/src/index.ts index 40a29668ac5..76ee8da9f85 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/index.ts @@ -36,7 +36,11 @@ export { toolPrefix, type Operation, } from "./naming"; -export { type HarnessReplyEvent } from "./reply-protocol"; +export { + type HarnessReplyEvent, + type ReplyPartKind, + type ToolExecution, +} from "./reply-protocol"; export { definePlugin, PluginDescriptor, @@ -78,6 +82,8 @@ export { type JsonValue, } from "./capture-store"; export { + EvidenceQuoteSchema, + SESSION_ENTRY_KINDS, type ArchivedSessionEntry, type ArchivedSessionEntryVersion, type EvidenceQuote, @@ -87,6 +93,7 @@ export { type SessionEntryKind, } from "./session-log"; export { + SWEEP_RESULT_STATUSES, advanceSweepHighWater, buildSettlementCheckSignal, buildSweepExtractionPrompt, diff --git a/libs/@hashintel/brunch-agent/packages/core/src/json-value.ts b/libs/@hashintel/brunch-agent/packages/core/src/json-value.ts new file mode 100644 index 00000000000..b360d869070 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/json-value.ts @@ -0,0 +1,30 @@ +import * as v from "valibot"; + +export type JsonValue = + | null + | boolean + | number + | string + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +export const isJsonValue = (value: unknown): value is JsonValue => { + if (value === null || typeof value === "string" || typeof value === "boolean") + return true; + // JSON.stringify would silently rewrite non-finite numbers and negative zero, + // so the persisted value could not be reproduced on read. + if (typeof value === "number") + return Number.isFinite(value) && !Object.is(value, -0); + if (Array.isArray(value)) return value.every(isJsonValue); + if (typeof value !== "object") return false; + const prototype: unknown = Object.getPrototypeOf(value); + return ( + (prototype === Object.prototype || prototype === null) && + Object.values(value as Record).every(isJsonValue) + ); +}; + +export const JsonValueSchema = v.custom( + isJsonValue, + "Expected a JSON-compatible value.", +); diff --git a/libs/@hashintel/brunch-agent/packages/core/src/readonly-deep.ts b/libs/@hashintel/brunch-agent/packages/core/src/readonly-deep.ts new file mode 100644 index 00000000000..b3ea862d88d --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/readonly-deep.ts @@ -0,0 +1,21 @@ +/** Recursively expose a parsed data contract as immutable. */ +export type ReadonlyDeep< + Value, + Depth extends readonly unknown[] = [], +> = Depth["length"] extends 8 + ? Value + : Value extends readonly unknown[] + ? { + readonly [Index in keyof Value]: ReadonlyDeep< + Value[Index], + readonly [unknown, ...Depth] + >; + } + : Value extends object + ? { + readonly [Key in keyof Value]: ReadonlyDeep< + Value[Key], + readonly [unknown, ...Depth] + >; + } + : Value; diff --git a/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts b/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts index 8e5334564c0..6de31a5e247 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/reply-protocol.ts @@ -5,23 +5,26 @@ * crosses the boundary. Message, part, turn, and tool-call identities are * supplied by the binding and preserved by transports. */ +export type ReplyPartKind = "text" | "reasoning"; +export type ToolExecution = "client" | "server"; + export type HarnessReplyEvent = | { readonly type: "response-start"; readonly messageId: string } | { readonly type: "turn-start"; readonly turnId: string } | { readonly type: "part-start"; - readonly kind: "text" | "reasoning"; + readonly kind: ReplyPartKind; readonly partId: string; } | { readonly type: "part-delta"; - readonly kind: "text" | "reasoning"; + readonly kind: ReplyPartKind; readonly partId: string; readonly delta: string; } | { readonly type: "part-end"; - readonly kind: "text" | "reasoning"; + readonly kind: ReplyPartKind; readonly partId: string; } | { @@ -29,19 +32,19 @@ export type HarnessReplyEvent = readonly toolCallId: string; readonly toolName: string; readonly input: unknown; - readonly execution: "client" | "server"; + readonly execution: ToolExecution; } | { readonly type: "tool-output"; readonly toolCallId: string; readonly output: unknown; - readonly execution: "client" | "server"; + readonly execution: ToolExecution; } | { readonly type: "tool-output-error"; readonly toolCallId: string; readonly errorText: string; - readonly execution: "client" | "server"; + readonly execution: ToolExecution; } | { readonly type: "turn-finish"; readonly turnId: string } | { diff --git a/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts b/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts index 95ae176dbdd..434841d9e2a 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/session-log.ts @@ -1,12 +1,19 @@ import * as v from "valibot"; -import type { EvidenceSpan, JsonValue } from "./capture-store"; +import { JsonValueSchema, isJsonValue } from "./json-value"; -export type SessionEntryKind = - | "user" - | "user-affordance-payload" - | "assistant" - | "non-user"; +import type { EvidenceSpan } from "./capture-store"; +import type { JsonValue } from "./json-value"; +import type { ReadonlyDeep } from "./readonly-deep"; + +export const SESSION_ENTRY_KINDS = [ + "user", + "user-affordance-payload", + "assistant", + "non-user", +] as const; + +export type SessionEntryKind = (typeof SESSION_ENTRY_KINDS)[number]; export interface SessionLogEntrySnapshot { /** Stable identity supplied by the substrate's public projection. */ @@ -30,50 +37,30 @@ export interface SessionLogRead { readonly settlements: readonly JsonValue[]; } -export interface ArchivedSessionEntryVersion { - readonly version: number; - readonly observedAtOffset: string; - readonly kind: SessionEntryKind; - readonly text: string; - readonly materialized: JsonValue; -} +export type ArchivedSessionEntryVersion = ReadonlyDeep< + v.InferOutput +>; +export type ArchivedSessionEntry = ReadonlyDeep< + v.InferOutput +>; +export type ArchivedSessionRead = ReadonlyDeep< + v.InferOutput +>; +export type ArchivedSessionLog = ReadonlyDeep< + v.InferOutput +>; +export type SessionLogArchive = ReadonlyDeep< + v.InferOutput +>; -export interface ArchivedSessionEntry { - /** Harness-owned, one-based entry identity used by evidence pointers. */ - readonly ordinal: number; - readonly substrateEntryId: string; - readonly substrateIncarnation?: string; - readonly versions: readonly ArchivedSessionEntryVersion[]; -} - -export interface ArchivedSessionRead { - readonly offset: string; - readonly substrateConversationId?: string; - readonly incarnation?: string; - readonly entries: readonly { - readonly ordinal: number; - readonly version: number; - }[]; - readonly settlements: readonly JsonValue[]; -} - -export interface ArchivedSessionLog { - readonly sessionId: string; - readonly entries: readonly ArchivedSessionEntry[]; - readonly reads: readonly ArchivedSessionRead[]; -} - -export interface SessionLogArchive { - readonly sessions: readonly ArchivedSessionLog[]; -} - -export interface EvidenceQuote { - readonly excerpt: string; +export type EvidenceQuote = ReadonlyDeep< + v.InferOutput +> & { /** Persisted pointer fields are deliberately unassignable to caller input. */ readonly pointer?: never; /** Provenance is derived from the archive, never asserted by the caller. */ readonly source?: never; -} +}; export interface MultipleEvidenceMatchesAdvisory { readonly type: "multiple-evidence-matches"; @@ -104,18 +91,14 @@ export type EvidenceResolutionResult = const nonEmptyString = v.pipe(v.string(), v.nonEmpty()); const positiveInteger = v.pipe(v.number(), v.integer(), v.minValue(1)); -const kindSchema = v.picklist([ - "user", - "user-affordance-payload", - "assistant", - "non-user", -]); +const kindSchema = v.picklist(SESSION_ENTRY_KINDS); +export const EvidenceQuoteSchema = v.strictObject({ excerpt: nonEmptyString }); const versionSchema = v.strictObject({ version: positiveInteger, observedAtOffset: nonEmptyString, kind: kindSchema, text: v.string(), - materialized: v.unknown(), + materialized: JsonValueSchema, }); const entrySchema = v.strictObject({ ordinal: positiveInteger, @@ -133,7 +116,7 @@ const readSchema = v.strictObject({ version: positiveInteger, }), ), - settlements: v.array(v.unknown()), + settlements: v.array(JsonValueSchema), }); const sessionSchema = v.strictObject({ sessionId: nonEmptyString, @@ -142,20 +125,6 @@ const sessionSchema = v.strictObject({ }); const archiveSchema = v.strictObject({ sessions: v.array(sessionSchema) }); -const isJsonValue = (value: unknown): value is JsonValue => { - if (value === null || typeof value === "string" || typeof value === "boolean") - return true; - if (typeof value === "number") - return Number.isFinite(value) && !Object.is(value, -0); - if (Array.isArray(value)) return value.every(isJsonValue); - if (typeof value !== "object") return false; - const prototype: unknown = Object.getPrototypeOf(value); - return ( - (prototype === Object.prototype || prototype === null) && - Object.values(value as Record).every(isJsonValue) - ); -}; - const canonicalize = (value: JsonValue): JsonValue => { if (Array.isArray(value)) return value.map(canonicalize); if (value !== null && typeof value === "object") { @@ -176,7 +145,7 @@ export const createEmptySessionLogArchive = (): SessionLogArchive => ({ }); export const parseSessionLogArchive = (input: unknown): SessionLogArchive => { - const archive = v.parse(archiveSchema, input) as SessionLogArchive; + const archive = v.parse(archiveSchema, input); const sessionIds = new Set(); for (const session of archive.sessions) { if (sessionIds.has(session.sessionId)) { @@ -206,20 +175,8 @@ export const parseSessionLogArchive = (input: unknown): SessionLogArchive => { `Archived entry ${entry.ordinal} has non-contiguous versions.`, ); } - for (const version of entry.versions) { - if (!isJsonValue(version.materialized)) { - throw new TypeError( - `Archived entry ${entry.ordinal} is not JSON-compatible.`, - ); - } - } } for (const read of session.reads) { - if (!read.settlements.every(isJsonValue)) { - throw new TypeError( - `Session log ${session.sessionId} has a non-JSON settlement.`, - ); - } for (const reference of read.entries) { const archived = session.entries[reference.ordinal - 1]; if (!archived || !archived.versions[reference.version - 1]) { diff --git a/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts b/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts index 4672fa7361f..2abb1d9a866 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/sweep-protocol.ts @@ -2,11 +2,10 @@ import * as v from "valibot"; import { toolName } from "./naming"; -import type { - CaptureInputProposal, - CaptureStoreRefusal, -} from "./capture-store"; +import type { FreeTextAffordance } from "./affordance"; +import type { CaptureInputProposal } from "./capture-store"; import type { Plugin } from "./plugin"; +import type { ReadonlyDeep } from "./readonly-deep"; import type { SessionEntryKind } from "./session-log"; const nonEmptyString = v.pipe(v.string(), v.nonEmpty()); @@ -22,18 +21,22 @@ export const createSweepExtractionResultSchema = ( proposals: v.array(plugin.proposalCatalog[0].schema), }); -export interface SweepAffordance { - readonly id: string; - readonly markdown: string; -} +export type SweepAffordance = Pick; export interface SweepRefusalFact { + /** Durable history may contain refusal codes from a different harness version. */ readonly code: string; readonly message: string; } +export const SWEEP_RESULT_STATUSES = [ + "no-settled-range", + "refused", + "applied", +] as const; + export interface SweepResultFact { - readonly status: "no-settled-range" | "refused" | "applied"; + readonly status: (typeof SWEEP_RESULT_STATUSES)[number]; readonly refusal?: SweepRefusalFact; } @@ -48,15 +51,12 @@ export interface SweepSessionEntry { readonly sweepRepairSignal?: true; } -export interface SweepState { - /** Latest true-user entry included in a successfully applied sweep. */ - readonly sweptThroughUserEntryId: string | null; - /** Loop guard: latest true-user entry offered for settlement judgment. */ - readonly lastCheckedUserEntryId: string | null; -} +export type SweepState = ReadonlyDeep>; const sweepStateSchema = v.strictObject({ + /** Latest true-user entry included in a successfully applied sweep. */ sweptThroughUserEntryId: v.nullable(nonEmptyString), + /** Loop guard: latest true-user entry offered for settlement judgment. */ lastCheckedUserEntryId: v.nullable(nonEmptyString), }); @@ -237,7 +237,7 @@ export interface SweepRepairSignal { } export const buildSweepRepairSignal = ( - refusal: Pick | SweepRefusalFact, + refusal: SweepRefusalFact, ): SweepRepairSignal => ({ type: "sweep-repair", tagName: "sweep-repair", diff --git a/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts index 224c7aca753..cb27a9da9f9 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/anchoring.test.ts @@ -9,11 +9,12 @@ import { import { archiveSessionLogRead, createEmptySessionLogArchive, + type EvidenceQuote, } from "../src/session-log"; type UserCaptureInput = Extract< CaptureInputProposal, - { readonly evidence: readonly { readonly excerpt: string }[] } + { readonly evidence: readonly EvidenceQuote[] } >; const archive = archiveSessionLogRead(createEmptySessionLogArchive(), { diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts index 75498305cc9..4a25c4ea06e 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/baseline-runner.test.ts @@ -11,6 +11,8 @@ import { afterEach, describe, expect, test } from "vitest"; import { CONTEXT_ROOT, contextRootPresent } from "./workspace"; +import type { StubReply } from "./fixtures/baseline-anthropic-stub"; + const BASELINE_PROTOCOL_DIR = join( CONTEXT_ROOT, "evaluations/protocols/process-model-elicitation/baseline", @@ -24,11 +26,6 @@ const STUB_MODULE = pathToFileURL( ).href; const temporaryDirectories: string[] = []; -interface StubReply { - text: string; - truncated?: boolean; -} - interface BaselineCopy { outputDirectory: string; protocolDirectory: string; diff --git a/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts b/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts index 160d8f8858d..cac95247f9e 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/architecture/fixtures/baseline-anthropic-stub.ts @@ -1,6 +1,8 @@ import { appendFile } from "node:fs/promises"; -interface StubReply { +import type Anthropic from "@anthropic-ai/sdk"; + +export interface StubReply { text: string; truncated?: boolean; } @@ -13,23 +15,31 @@ let requestCount = 0; export default { messages: { - create: async (request: unknown) => { + create: async (request: Anthropic.MessageCreateParamsNonStreaming) => { if (requestsPath) { await appendFile(requestsPath, `${JSON.stringify(request)}\n`); } const reply = replies[requestCount++]; if (!reply) throw new Error(`unexpected model call ${requestCount}`); return { + id: `test-message-${requestCount}`, + type: "message", + role: "assistant", model: "test-model", - content: [{ type: "text" as const, text: reply.text }], + content: [{ type: "text", text: reply.text, citations: null }], stop_reason: reply.truncated ? "max_tokens" : "end_turn", + stop_sequence: null, usage: { + cache_creation: null, input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, + inference_geo: null, + server_tool_use: null, + service_tier: null, }, - }; + } satisfies Anthropic.Message; }, }, }; diff --git a/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts index 53000f8a5f0..5d7dafea9be 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/capture-store.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import { + ABSENCE_STATES, applyCaptureStoreCommand as applyCaptureStoreCommandWithArchive, createEmptyCaptureStoreSnapshot, deriveCaptureStatus, @@ -213,15 +214,7 @@ describe("capture-store contract", () => { }); test("harness-invariant: 9 — all six absence values remain first-class capture content", () => { - const absences = [ - "unknown-to-user", - "not-yet-decided", - "not-applicable", - "explicitly-absent", - "declined", - "deferred", - ] as const; - const proposals: CaptureInputProposal[] = absences.map( + const proposals: CaptureInputProposal[] = ABSENCE_STATES.map( (absence, index) => ({ evidence: [userEvidence(absence, index + 1)], epistemicStatus: "inferred", @@ -236,7 +229,7 @@ describe("capture-store contract", () => { }); expect(result.snapshot.captures.map((capture) => capture.content)).toEqual( - absences.map((absence) => ({ absence })), + ABSENCE_STATES.map((absence) => ({ absence })), ); expect( result.snapshot.captures.every( @@ -1159,7 +1152,10 @@ describe("capture-store contract", () => { // Bent from a snapshot the store itself produced, so the reversed range is // the only thing wrong with what the parser is handed. - type EvidenceBearing = { evidence: { pointer: Record }[] }; + type Mutable = { -readonly [Key in keyof Value]: Value[Key] }; + type EvidenceBearing = { + evidence: Array<{ pointer: Mutable }>; + }; const withReversedRange = (family: "captures" | "events"): unknown => { const clone = structuredClone(retracted) as unknown as Record< string, diff --git a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts index 78830ace982..2e0f008bbba 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts @@ -14,6 +14,7 @@ import { settlementProtocolInstructionFragments, sweepableRange, unsweptTail, + type SweepRefusalFact, type SweepSessionEntry, } from "../src/sweep-protocol"; @@ -180,7 +181,7 @@ describe("settlement and sweep protocol", () => { const refusal = { code: "evidence-quote-not-found", message: "Use an exact quote.", - }; + } satisfies SweepRefusalFact; expect(buildSweepRepairSignal(refusal)).toEqual({ type: "sweep-repair", tagName: "sweep-repair", diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts index 2d517da3ca2..64d9b52375a 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts @@ -18,7 +18,7 @@ import { definePlugin } from "@hashintel/brunch-agent"; const nonEmptyString = v.pipe(v.string(), v.nonEmpty()); const evidenceQuote = v.strictObject({ excerpt: nonEmptyString }); -const StatementNotedProposal = v.pipe( +export const StatementNotedProposal = v.pipe( v.strictObject({ evidence: v.pipe(v.array(evidenceQuote), v.minLength(1)), epistemicStatus: v.literal("explicit"), @@ -40,6 +40,10 @@ const StatementNotedProposal = v.pipe( ), ); +export type StatementNotedProposalInput = v.InferInput< + typeof StatementNotedProposal +>; + export const gherkin = definePlugin({ name: "plugin-gherkin", targetDomain: "gherkin", diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts index e0820b6ae33..2fda5f55683 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts @@ -18,7 +18,7 @@ import { type HarnessReplyEvent, } from "@hashintel/brunch-agent"; -import { ASK_TOOL_NAME } from "./client-tools"; +import { ASK_TOOL_NAME, type BrunchAskOutput } from "./client-tools"; export { type AskReplyAdmission, @@ -46,14 +46,27 @@ export type HarnessTurnRunner = ( emit: (event: HarnessReplyEvent) => void, ) => Promise; +type HarnessPartEvent = Extract< + HarnessReplyEvent, + { type: "part-start" | "part-delta" | "part-end" } +>; +type HarnessToolEvent = Extract< + HarnessReplyEvent, + { type: "tool-input" | "tool-output" | "tool-output-error" } +>; +type HarnessResponseFinishEvent = Extract< + HarnessReplyEvent, + { type: "response-finish" } +>; +type AskReplyRefusal = Extract; + export interface HarnessAskReplyInput { readonly conversationId: string; /** Existing assistant UI message whose pending tool call this continues. */ readonly assistantMessageId: string; readonly idempotencyKey: string; - readonly ask: { + readonly ask: BrunchAskOutput & { readonly toolCallId: string; - readonly answer: string; }; } @@ -91,12 +104,7 @@ export type TransportInspectionEvent = | { readonly type: "part-emitted"; readonly requestId: string; - readonly kind: - | "text" - | "reasoning" - | "tool-input" - | "tool-output" - | "tool-output-error"; + readonly kind: HarnessPartEvent["kind"] | HarnessToolEvent["type"]; readonly partId?: string; readonly toolCallId?: string; } @@ -108,8 +116,8 @@ export type TransportInspectionEvent = | { readonly type: "request-finish"; readonly requestId: string; - readonly terminalState: "completed" | "failed" | "aborted"; - readonly finishReason: "stop" | "tool-calls" | "error"; + readonly terminalState: HarnessResponseFinishEvent["terminalState"]; + readonly finishReason: HarnessResponseFinishEvent["finishReason"]; } | { readonly type: "ask-await"; @@ -127,7 +135,7 @@ export type TransportInspectionEvent = readonly requestId: string; readonly conversationId: string; readonly toolCallId: string; - readonly reason: "no-pending-ask" | "different-ask-pending"; + readonly reason: AskReplyRefusal["reason"]; }; export interface AiSdkChatHandlerOptions { @@ -169,23 +177,6 @@ const panelPostBodySchema = v.looseObject({ type PanelMessage = v.InferOutput; type PanelPostBody = v.InferOutput; -type TransportRequestRefusal = - | { - readonly reason: "invalid-chat-request"; - readonly status: 400; - readonly error: "invalid_chat_request"; - } - | { - readonly reason: "tool-result-follow-up-not-supported"; - readonly status: 422; - readonly error: "tool_result_follow_up_not_supported"; - } - | { - readonly reason: "invalid-ask-submission"; - readonly status: 400; - readonly error: "invalid_ask_submission"; - }; - const transportRequestRefusals = { invalidChatRequest: { reason: "invalid-chat-request", @@ -202,7 +193,10 @@ const transportRequestRefusals = { status: 400, error: "invalid_ask_submission", }, -} as const satisfies Record; +} as const; + +type TransportRequestRefusal = + (typeof transportRequestRefusals)[keyof typeof transportRequestRefusals]; const askReplyRefusalErrors = { "no-pending-ask": "ask_not_pending", diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts index 3b5ab8a676b..e66c0674b51 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts @@ -20,7 +20,7 @@ import { type TransportInspectionEvent, } from "../src/index"; -type StreamChunk = Record & { readonly type: string }; +import type { UIMessageChunk } from "ai"; const FIXTURES = join(import.meta.dirname, "fixtures"); @@ -30,12 +30,12 @@ test("keeps the client ask tool name aligned with the Brunch product name", () = const responseChunks = async ( response: Response, -): Promise => +): Promise => (await response.text()) .trim() .split("\n\n") .slice(0, -1) - .map((frame) => JSON.parse(frame.slice("data: ".length)) as StreamChunk); + .map((frame) => JSON.parse(frame.slice("data: ".length)) as UIMessageChunk); const post = (body: unknown): Request => new Request("http://brunch.test/api/petrinaut/chat", { diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts index 1e1cc5fa867..6ea3afada8e 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/golden.test.ts @@ -3,49 +3,54 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; -type MessagePart = { - readonly input?: unknown; - readonly output?: { readonly applied?: boolean }; - readonly providerExecuted?: boolean; - readonly state?: string; - readonly text?: string; - readonly toolCallId?: string; - readonly toolName?: string; - readonly type: string; -}; +import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"; -type PanelMessage = { - readonly id: string; - readonly parts: MessagePart[]; - readonly role: string; -}; +type SendMessagesOptions = Parameters< + ChatTransport["sendMessages"] +>[0]; +type ToolMessagePart = Extract< + UIMessage["parts"][number], + { type: `tool-${string}` } +>; +type ToolInputChunk = Extract; type PanelPostBody = { - readonly id: string; - readonly messageId?: string; - readonly messages: PanelMessage[]; - readonly trigger: string; -}; - -type StreamChunk = MessagePart & { - readonly delta?: string; - readonly finishReason?: string; - readonly messageId?: string; + readonly id: SendMessagesOptions["chatId"]; + readonly messageId?: SendMessagesOptions["messageId"]; + readonly messages: SendMessagesOptions["messages"]; + readonly trigger: SendMessagesOptions["trigger"]; }; const FIXTURES = join(import.meta.dirname, "fixtures"); +const isToolMessagePart = ( + part: UIMessage["parts"][number], +): part is ToolMessagePart => part.type.startsWith("tool-"); + +const isClientToolOutput = ( + part: UIMessage["parts"][number], +): part is ToolMessagePart => + isToolMessagePart(part) && + (part.type === "tool-addPlace" || part.type === "tool-addTransition"); + +const appliedFrom = (part: ToolMessagePart): unknown => + typeof part.output === "object" && + part.output !== null && + "applied" in part.output + ? part.output.applied + : undefined; + const readPostBody = (name: string): PanelPostBody => JSON.parse(readFileSync(join(FIXTURES, name), "utf8")) as PanelPostBody; -const readSseChunks = (name: string): StreamChunk[] => { +const readSseChunks = (name: string): UIMessageChunk[] => { const frames = readFileSync(join(FIXTURES, name), "utf8") .trim() .split("\n\n"); expect(frames.at(-1)).toBe("data: [DONE]"); return frames.slice(0, -1).map((frame) => { expect(frame.startsWith("data: ")).toBe(true); - return JSON.parse(frame.slice("data: ".length)) as StreamChunk; + return JSON.parse(frame.slice("data: ".length)) as UIMessageChunk; }); }; @@ -66,19 +71,13 @@ describe("FE-1435 real-panel wire transcript", () => { expect(body.messages).toHaveLength(3); const assistant = body.messages[1]!; - const clientToolOutputs = assistant.parts.filter( - (part) => - part.type === "tool-addPlace" || part.type === "tool-addTransition", - ); + const clientToolOutputs = assistant.parts.filter(isClientToolOutput); expect(clientToolOutputs).toHaveLength(2); expect(clientToolOutputs.map((part) => part.state)).toEqual([ "output-available", "output-available", ]); - expect(clientToolOutputs.map((part) => part.output?.applied)).toEqual([ - true, - true, - ]); + expect(clientToolOutputs.map(appliedFrom)).toEqual([true, true]); const serverTool = assistant.parts.find( (part) => part.type === "tool-serverProbe", @@ -91,8 +90,11 @@ describe("FE-1435 real-panel wire transcript", () => { const diagnostics = body.messages[2]!; expect(diagnostics.id).toBe("petrinaut-diagnostics-context"); + const diagnosticsText = diagnostics.parts.find( + (part) => part.type === "text", + ); expect( - diagnostics.parts[0]?.text?.startsWith( + diagnosticsText?.text.startsWith( "Petrinaut diagnostics context only; this is not a user request.", ), ).toBe(true); @@ -113,7 +115,7 @@ describe("FE-1435 real-panel wire transcript", () => { expect( chunks .filter( - (chunk) => + (chunk): chunk is ToolInputChunk => chunk.type === "tool-input-available" && chunk.providerExecuted !== true, ) diff --git a/yarn.lock b/yarn.lock index ae29103804d..106f4dba50c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -447,6 +447,7 @@ __metadata: "@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.182" hono: "npm:4.13.2" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1"