diff --git a/.changeset/config.json b/.changeset/config.json index 2226a67..6bf2aa2 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -6,7 +6,9 @@ [ "@full-self-browsing/concierge", "@full-self-browsing/concierge-react", - "@full-self-browsing/concierge-svelte" + "@full-self-browsing/concierge-svelte", + "@full-self-browsing/concierge-dom", + "@full-self-browsing/concierge-realtime" ] ], "linked": [], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90d8fd3..c9a0d1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Validate v0.3 source and release policy + - name: Validate v0.4 source and release policy run: | node scripts/release/check.mjs source node scripts/release/version.mjs self-test @@ -50,7 +50,7 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: v0.3-archives-${{ github.sha }} + name: v0.4-archives-${{ github.sha }} path: ${{ runner.temp }}/release-archives if-no-files-found: error @@ -65,7 +65,7 @@ jobs: steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: v0.3-archives-${{ github.sha }} + name: v0.4-archives-${{ github.sha }} path: archives - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -73,7 +73,7 @@ jobs: node-version: ${{ matrix.node }} package-manager-cache: false - - name: Install and import the sealed trio + - name: Install and import the sealed five-package set run: | mkdir consumer cd consumer @@ -87,9 +87,24 @@ jobs: await import("@full-self-browsing/concierge/ai-sdk/browser"); await import("@full-self-browsing/concierge-react"); await import("@full-self-browsing/concierge-svelte"); + // dom and realtime ship in the fixed set, so they are imported here + // too. Both are expected to load with no DOM present: the dom + // contract guard runs on first registration rather than at module + // scope, and every realtime subpath reaches its transport lazily. + const dom = await import("@full-self-browsing/concierge-dom"); + await import("@full-self-browsing/concierge-realtime"); + await import("@full-self-browsing/concierge-realtime/openai"); + await import("@full-self-browsing/concierge-realtime/webrtc"); + await import("@full-self-browsing/concierge-realtime/websocket"); assertSingleInstance(); - if (CONTRACT_VERSION !== 3 || adapter.EXPECTED_CORE_CONTRACT_VERSION !== 3) { - throw new Error("contract v3 did not survive the packed install"); + for (const [label, observed] of [ + ["core", CONTRACT_VERSION], + ["ai-sdk", adapter.EXPECTED_CORE_CONTRACT_VERSION], + ["dom", dom.EXPECTED_CORE_CONTRACT_VERSION], + ]) { + if (observed !== 4) { + throw new Error(`contract v4 did not survive the packed install: ${label} reported ${observed}`); + } } NODE @@ -111,7 +126,7 @@ jobs: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: v0.3-archives-${{ github.sha }} + name: v0.4-archives-${{ github.sha }} path: archives - run: node scripts/release/compatibility.mjs "$(realpath archives)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7f6f47..1413b37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: node scripts/release/seal.mjs self-test node scripts/release/publisher.mjs self-test - - name: Open or update the fixed-trio Version Packages PR + - name: Open or update the fixed-set Version Packages PR id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: @@ -203,7 +203,7 @@ jobs: name: ${{ needs.seal.outputs.sealedArtifact }} path: ${{ runner.temp }}/release-sealed - - name: Install the exact trio into an isolated example + - name: Install the exact package set into an isolated example run: | npm install --global npm@11.19.0 test "$(npm --version)" = "11.19.0" @@ -284,13 +284,13 @@ jobs: seal.runAttempt > Number(process.env.GITHUB_RUN_ATTEMPT) || seal.sourceRef !== process.env.GITHUB_REF || seal.outputArtifact !== process.env.RELEASE_OUTPUT_ARTIFACT || - seal.distTag !== "latest" || seal.contractVersion !== 3 + seal.distTag !== "latest" || seal.contractVersion !== 4 ) throw new Error("release seal identity or digest drifted"); const pinned = { - "config.mjs": "704e722e96934ac6f4534a28953e185aafd369713dd38f2964d3fa62523e10a4", - "release-publisher.mjs": "a6757a8a8c5f4ef67ab6318e8f492844c6b100bf0f12051a6fd1d5b9bd9f0636", - "release-line.json": "2fd76bdd314bfa509ede3a0e8b4a044c50fe556a1fcec015378986a2b6df899c", + "config.mjs": "17f2f25fd40aea7d99024c0da5c7fc8b137dc272646fa1b8d47947bfb3735866", + "release-publisher.mjs": "3ddab82fbf3ef5479f823618b8d1a79a73a1862b297459120d3596a2534a3f3b", + "release-line.json": "d650e2ac9707867db77b5f7b8fd2dd97f8fd0399f578a991091c73ee791dca73", }; const expected = ["release-seal.json"]; for (const record of seal.tools) { @@ -334,7 +334,7 @@ jobs: appendFileSync(process.env.GITHUB_ENV, `RELEASE_NPM_CLI=${cli}\n`); NODE - - name: Publish or safely resume the exact trio through OIDC + - name: Publish or safely resume the exact package set through OIDC env: RELEASE_OUTPUT_ARTIFACT: ${{ needs.seal.outputs.sealedArtifact }} run: >- diff --git a/.release/lines/0.4.json b/.release/lines/0.4.json new file mode 100644 index 0000000..cde3577 --- /dev/null +++ b/.release/lines/0.4.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": 1, + "releaseLine": "0.4", + "contractVersion": 4, + "initialVersion": "0.4.0", + "distTag": "latest", + "registry": "https://registry.npmjs.org/", + "repository": "fullselfbrowsing/Concierge", + "repositoryUrl": "git+https://github.com/fullselfbrowsing/Concierge.git", + "repositoryWebUrl": "https://github.com/fullselfbrowsing/Concierge", + "sourceRef": "refs/heads/main", + "workflowPath": ".github/workflows/release.yml", + "environment": "npm-production", + "node": { + "consumerEngine": ">=22.12.0", + "ci": "24", + "publisherMinimum": "22.14.0" + }, + "npm": { + "version": "11.19.0", + "integrity": "sha512-SDd/hHg3KqHE5Ht2NHWxNYNtqCQ2pXAPLl6OtQhPyED5PHsRfrOtO199MZTIG2cQoQ1ZRI9t28shrD+2cr3AAw==" + }, + "compatibility": { + "ai": "^6.0.0 || ^7.0.0", + "react": "^18.2.0 || ^19.0.0", + "reactDom": "^18.2.0 || ^19.0.0", + "svelte": "^5.0.0" + }, + "packages": [ + { + "name": "@full-self-browsing/concierge", + "path": "packages/concierge", + "role": "core", + "requiresCore": false + }, + { + "name": "@full-self-browsing/concierge-react", + "path": "packages/concierge-react", + "role": "react", + "requiresCore": true + }, + { + "name": "@full-self-browsing/concierge-svelte", + "path": "packages/concierge-svelte", + "role": "svelte", + "requiresCore": true + }, + { + "name": "@full-self-browsing/concierge-dom", + "path": "packages/concierge-dom", + "role": "dom", + "requiresCore": true + }, + { + "name": "@full-self-browsing/concierge-realtime", + "path": "packages/concierge-realtime", + "role": "realtime", + "requiresCore": true + } + ] +} diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 40a6e47..0893b9d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,14 +1,16 @@ # Compatibility -Concierge 0.3 is a supported public preview. The three public packages form one -fixed release set and share runtime contract v3. +Concierge 0.4 is a supported public preview. The five public packages form one +fixed release set and share runtime contract v4. ## Supported ranges | Component | Supported range | Release certification | | --- | --- | --- | | Node.js | `>=22.12.0` | 22.12 floor consumer and Node 24 CI/publisher | -| `@full-self-browsing/concierge` | `^0.3.0` | Same patch as every adapter | +| `@full-self-browsing/concierge` | `^0.4.0` | Same patch as every adapter | +| `@full-self-browsing/concierge-dom` | `^0.4.0` | Installed consumer cell; imports with no DOM present | +| `@full-self-browsing/concierge-realtime` | `^0.4.0` | Installed consumer cell; root and `openai`/`webrtc`/`websocket` subpaths | | React | `^18.2.0 || ^19.0.0` | 18.2 and 19.2 lines | | React DOM | `^18.2.0 || ^19.0.0` | Matches React | | Svelte | `^5.0.0` | 5.0 floor and current 5.56.9 | @@ -21,7 +23,7 @@ the fixed package family has one runtime contract. Node 22.12 is the consumer floor; contributing with the pinned pnpm requires Node 22.13 or newer. Trusted npm publishing requires Node 22.14 or newer and uses Node 24. -## AI SDK stacks certified for 0.3.0 +## AI SDK stacks certified for 0.4.0 | Cell | `ai` | `@ai-sdk/react` | OpenRouter provider | Purpose | | --- | ---: | ---: | ---: | --- | @@ -44,32 +46,43 @@ contract. Other AI SDK providers can consume the same `ToolSet`. replay store additionally needs a browser IndexedDB implementation. - `@full-self-browsing/concierge/openai-realtime` is runtime-neutral and owns no WebRTC, audio, credential, transcript, or network capability. +- `@full-self-browsing/concierge-dom` is the framework-neutral visible-element + registry. It never searches the document; it only returns elements the + application registered. +- `@full-self-browsing/concierge-realtime` owns the voice session, delivery + ledger, and optional WebRTC/WebSocket channels. Core's + `/openai-realtime` entry remains a codec only. - The full Next example declares the Node runtime. Edge deployment is not part - of the 0.3 support matrix. + of the 0.4 support matrix. - CommonJS output and `require()` are not supported. Use ESM imports. -The release gate installs only the packed trio into foreign temporary -consumers. Both framework cells verify that React and Svelte public entries can -be imported during ESM server rendering, typecheck with `skipLibCheck: false`, -and resolve the same physical core from the consumer and each adapter. The +The release gate installs the packed public set into foreign temporary +consumers. Both framework cells carry all five archives and verify that every +public entry can be imported during ESM server rendering, typechecks with +`skipLibCheck: false`, and resolves the same physical core from the consumer +and each adapter. `concierge-dom` and `concierge-realtime` are imported there +with no DOM present, which is what proves neither reaches `document` or a +transport at module scope. The sealed AI 7 example then exercises the signed bridge in Chromium, Firefox, and WebKit before the OIDC publish job can start. ## Version mixing -Do not mix contract-v2 and contract-v3 packages. All adapters keep core as a -peer dependency, and every runtime entry checks contract v3 before registration +Do not mix contract-v3 and contract-v4 packages. All adapters keep core as a +peer dependency, and every runtime entry checks contract v4 before registration or dispatch. -Upgrade the trio and regenerate the lockfile together: +Upgrade the set and regenerate the lockfile together: ```sh -pnpm up @full-self-browsing/concierge@^0.3 \ - @full-self-browsing/concierge-react@^0.3 \ - @full-self-browsing/concierge-svelte@^0.3 +pnpm up @full-self-browsing/concierge@^0.4 \ + @full-self-browsing/concierge-react@^0.4 \ + @full-self-browsing/concierge-svelte@^0.4 \ + @full-self-browsing/concierge-dom@^0.4 \ + @full-self-browsing/concierge-realtime@^0.4 pnpm why @full-self-browsing/concierge ``` The final command should converge on one physical core version. See the -[0.2-to-0.3 migration guide](./docs/migrations/0.2-to-0.3.md) for API changes +[0.3-to-0.4 migration guide](./docs/migrations/0.3-to-0.4.md) for API changes and backward-compatible adoption guidance. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4c1bfb..e180555 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,11 @@ release archives. - The catalog is least authority. Do not add generic click, selector, coordinate, URL-navigation, DOM-query, or arbitrary-JavaScript actions. +- `@full-self-browsing/concierge-dom` never finds an element; it only + returns one the application registered. `scripts/pkg-dom-catalog-boundary.mjs` + enforces that on the built artifact. Adding an identifier to that script's + allow-set, or deleting a banned class, requires a threat model in the same + pull request. - Core remains framework-, DOM-, model-provider-, and transport-neutral. - One physical core owns catalog revisions, bridge identity, consent, scheduling, deduplication, dispatch, workflow lineage, and terminal control. @@ -69,24 +74,23 @@ Use a compound action and core's `workflow` controls for an application-owned sequence. Child calls must use stable step IDs. Do not put loops, delays, child dispatch, or cleanup orchestration in a framework or AI adapter. -## Contract v3 changes +## Contract v4 changes -Contract v3 includes atomic `ResolvedCatalog` revisions, structured validated -results, action-scoped bridge precedence, object-form dispatch, explicit -terminal batch outcomes, lifecycle events, compound-action lineage, and the -signed AI and OpenAI Realtime adapters' core dependencies. +Contract v4 includes handler-proposed consent payloads, `attestReadback`, +catalog acknowledgement with deferred `setContext` promotion, vacuous +snapshot fail-closed, and the widened catalog diagnostic vocabulary. An additive implementation detail does not require a contract bump. A change that lets two versions disagree about bridge shape, revision capability, invocation identity, consent records, batch/terminal semantics, event lineage, or signed dispatch interpretation does. Contract changes require: -1. a synchronized minor release of all three packages; +1. a synchronized minor release of all five packages; 2. every adapter's expected-contract guard to change together; 3. mismatch mutations proving failure occurs before registration or dispatch; 4. a migration guide and compatibility update. -Contract v3 is fixed throughout `0.3.x`. +Contract v4 is fixed throughout `0.4.x`. Contract v3 remains the 0.3 line. ## Tests and checks @@ -132,9 +136,11 @@ The public release set is exactly: 1. `@full-self-browsing/concierge` 2. `@full-self-browsing/concierge-react` 3. `@full-self-browsing/concierge-svelte` +4. `@full-self-browsing/concierge-dom` +5. `@full-self-browsing/concierge-realtime` They belong to one fixed Changesets group and must leave a Version Packages PR -at the same version. A user-visible change adds a changeset naming all three at +at the same version. A user-visible change adds a changeset naming all five at the same bump level. Private examples and fixtures are never versioned. Adapters keep core as `peerDependencies["@full-self-browsing/concierge"] = @@ -157,7 +163,7 @@ created the code. Historical `.planning` evidence and `scripts/phase-09-*` reproduce the v0.1 milestone and must not be rewritten as current release tooling. The live release -contract is `.release/lines/0.3.json`, `scripts/release/`, and +contract is `.release/lines/0.4.json`, `scripts/release/`, and `.github/workflows/release.yml`. ## Pull requests diff --git a/HANDOFF.md b/HANDOFF.md index e1bce8c..42bfc4c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,8 +2,8 @@ ## Current state -Concierge 0.3 is a supported-public-preview implementation built around runtime -contract v3. The repository contains: +Concierge 0.4 is a supported-public-preview implementation built around runtime +contract v4. The repository contains: - a framework-neutral action catalog, atomic catalog revisions, direct and batch dispatch, consent, deduplication, cancellation, terminal control, @@ -17,26 +17,28 @@ contract v3. The repository contains: completed calls, and correlated function-call output events; - a full Next App Router/OpenRouter example and the existing dual-framework SSR harness; -- a version-neutral three-package release path with exact archives, independent +- a version-neutral five-package release path with exact archives, independent sealing, OIDC trusted publishing, provenance verification, safe resumption, and the `latest` dist-tag. -The public package set is one fixed trio at a shared `0.3.x` version: +The public package set is one fixed group at a shared `0.4.x` version: 1. `@full-self-browsing/concierge` 2. `@full-self-browsing/concierge-react` 3. `@full-self-browsing/concierge-svelte` +4. `@full-self-browsing/concierge-dom` +5. `@full-self-browsing/concierge-realtime` Do not infer registry publication from the repository version. Check npm and the release workflow. First publication remains externally blocked until the -npm scope/package bootstrap and three trusted-publisher records are complete; +npm scope/package bootstrap and five trusted-publisher records are complete; the exact ceremony is in [RELEASING.md](./RELEASING.md). ## Read in this order 1. [README.md](./README.md) — public product and security promise. 2. [COMPATIBILITY.md](./COMPATIBILITY.md) and [SUPPORT.md](./SUPPORT.md) — the - 0.3 support contract. + 0.4 support contract. 3. [`packages/concierge/src/types.ts`](./packages/concierge/src/types.ts) — the runtime contract as code. 4. [`packages/concierge/src/concierge.ts`](./packages/concierge/src/concierge.ts) @@ -50,7 +52,7 @@ the exact ceremony is in [RELEASING.md](./RELEASING.md). Use `.planning/` when investigating how earlier decisions and evidence were derived. Its phase scripts and receipts are historical reproduction inputs, -not the live 0.3 release authority. +not the live 0.4 release authority. ## Locked boundaries @@ -71,7 +73,7 @@ not the live 0.3 release authority. envelope, replay consumption, and a live-catalog match. - Client consent, signed results, and client context are not server authorization. -- All packages remain ESM-only and contract v3 throughout `0.3.x`. +- All packages remain ESM-only and contract v4 throughout `0.4.x`. ## Signed bridge invariants @@ -89,10 +91,10 @@ application-supplied stronger store. ## Live release authority -- `.release/lines/0.3.json` — strict package set, contract, destination, +- `.release/lines/0.4.json` — strict package set, contract, destination, compatibility, Node, and content-addressed npm identity. - `scripts/release/config.mjs` — strict parser and shared invariants. -- `scripts/release/check.mjs` — source/workflow/fixed-trio gate. +- `scripts/release/check.mjs` — source/workflow/five-package gate. - `scripts/release/version.mjs` — Changesets wrapper and peer normalization. - `scripts/release/package.mjs` — build-once exact archive export. - `scripts/release/compatibility.mjs` — AI 6/7, React 18/19, Svelte 5 @@ -125,15 +127,15 @@ node scripts/release/publisher.mjs self-test node scripts/release/check.mjs all ``` -Then confirm the Changesets fixed group is the exact trio, the worktree is -clean, repository URLs preserve `fullselfbrowsing/Concierge` case, and npm's -three trusted-publisher records name `release.yml` plus `npm-production`. +Then confirm the Changesets fixed group is the exact five-package set, the +worktree is clean, repository URLs preserve `fullselfbrowsing/Concierge` case, +and npm's trusted-publisher records name `release.yml` plus `npm-production`. ## Known limitations -- 0.3 is public preview, not a commercial-SLA release. +- 0.4 is public preview, not a commercial-SLA release. - The signed bridge authenticates server admission of a browser batch; it does not authorize protected server effects or repair XSS. -- Edge runtime is not in the 0.3 Next matrix. +- Edge runtime is not in the 0.4 Next matrix. - Live model-provider calls are intentionally outside release authorization. -- Only the latest 0.3 patch is maintained under [SUPPORT.md](./SUPPORT.md). +- Only the latest 0.4 patch is maintained under [SUPPORT.md](./SUPPORT.md). diff --git a/README.md b/README.md index 5d08784..9abf04c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ application retains control of validation, consent, execution, and results. [![npm](https://img.shields.io/npm/v/@full-self-browsing/concierge?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@full-self-browsing/concierge) ![Node](https://img.shields.io/badge/Node-%3E%3D22.12-339933?style=for-the-badge&logo=nodedotjs&logoColor=white) ![ESM](https://img.shields.io/badge/ESM-only-000000?style=for-the-badge) -![Contract](https://img.shields.io/badge/runtime_contract-v3-1B998B?style=for-the-badge) +![Contract](https://img.shields.io/badge/runtime_contract-v4-1B998B?style=for-the-badge) ![License](https://img.shields.io/badge/license-MIT-3DA639?style=for-the-badge) [![CI](https://img.shields.io/github/actions/workflow/status/fullselfbrowsing/Concierge/ci.yml?branch=main&style=flat-square&logo=github&label=CI)](https://github.com/fullselfbrowsing/Concierge/actions/workflows/ci.yml) @@ -42,9 +42,10 @@ deduplication, workflow execution, and structured results. It does not own the model, chat interface, planning loop, authentication system, or server authorization policy. -Version `0.3.0` is a supported public preview. The current release uses runtime -contract v3 and ships as one synchronized set of three packages. Existing -data-less actions and stage-scoped bridges remain supported. +Version `0.4.0` is a supported public preview. The current release uses runtime +contract v4 and ships as one synchronized set of five packages. Existing +data-less actions and stage-scoped bridges remain supported. Consent binds the +payload a review handler proposes, not the review action's arguments. ### Why Concierge @@ -94,6 +95,8 @@ together so every adapter resolves the same physical core and contract version. | [`@full-self-browsing/concierge`](./packages/concierge/README.md) | Framework-neutral catalog, dispatch, consent, workflow, telemetry, and transport runtime | | [`@full-self-browsing/concierge-react`](./packages/concierge-react/README.md) | React context, bridge lifecycle, and optional activity visuals | | [`@full-self-browsing/concierge-svelte`](./packages/concierge-svelte/README.md) | Svelte context, bridge lifecycle, and reactive snapshot normalization | +| [`@full-self-browsing/concierge-dom`](./packages/concierge-dom/README.md) | Visible-element registry; never searches the document | +| [`@full-self-browsing/concierge-realtime`](./packages/concierge-realtime/README.md) | Realtime session, delivery ledger, and WebRTC/WebSocket channels | | Component | Supported range | | --- | --- | @@ -102,11 +105,11 @@ together so every adapter resolves the same physical core and contract version. | React and React DOM | `^18.2.0 || ^19.0.0` | | Svelte | `^5.0.0` | | AI SDK core | `^6.0.0 || ^7.0.0` | -| Runtime contract | v3 throughout `0.3.x` | +| Runtime contract | v4 throughout `0.4.x` | React and Svelte package roots are server-safe. Their runtime bindings live in `/client` and `/client.svelte`. Edge deployment -is not part of the `0.3` support matrix. See [COMPATIBILITY.md](./COMPATIBILITY.md) +is not part of the `0.4` support matrix. See [COMPATIBILITY.md](./COMPATIBILITY.md) for the full certified matrix and runtime boundaries. ## Install @@ -120,17 +123,36 @@ pnpm add @full-self-browsing/concierge zod Add the matching framework adapter when needed: ```sh -pnpm add @full-self-browsing/concierge@^0.3 \ - @full-self-browsing/concierge-react@^0.3 \ +pnpm add @full-self-browsing/concierge@^0.4 \ + @full-self-browsing/concierge-react@^0.4 \ zod ``` ```sh -pnpm add @full-self-browsing/concierge@^0.3 \ - @full-self-browsing/concierge-svelte@^0.3 \ +pnpm add @full-self-browsing/concierge@^0.4 \ + @full-self-browsing/concierge-svelte@^0.4 \ zod ``` +Add the visible-element registry when an action needs to reveal or read an +element the application has registered. It never searches the document: + +```sh +pnpm add @full-self-browsing/concierge@^0.4 \ + @full-self-browsing/concierge-dom@^0.4 +``` + +Add the realtime package for a voice session, its delivery ledger, and the +optional WebRTC or WebSocket channels: + +```sh +pnpm add @full-self-browsing/concierge@^0.4 \ + @full-self-browsing/concierge-realtime@^0.4 +``` + +Every Concierge package in an installation must be on the same `0.4.x` +version — they are one fixed release set sharing runtime contract v4. + AI SDK integrations also install a supported AI SDK version and the relevant provider packages. The maintained example uses AI SDK 7 and OpenRouter, but the Concierge adapter is provider-neutral. @@ -209,7 +231,8 @@ For a complete model integration, continue with the | Svelte | `@full-self-browsing/concierge-svelte/client.svelte` | Provide the core instance with the Svelte snapshot normalizer and register bridges during initialization | | AI SDK | `@full-self-browsing/concierge/ai-sdk` | Convert a resolved catalog into model tools and correlate completed calls | | Signed server bridge | `/ai-sdk/server` and `/ai-sdk/browser` | Issue, verify, and dispatch short-lived browser batches | -| OpenAI Realtime | `@full-self-browsing/concierge/openai-realtime` | Translate acknowledged catalogs, completed calls, and correlated output events without owning WebRTC | +| OpenAI Realtime codec | `@full-self-browsing/concierge/openai-realtime` | Translate acknowledged catalogs, completed calls, and correlated output events without owning WebRTC | +| Realtime session | `@full-self-browsing/concierge-realtime` | Open a voice channel, acknowledge catalogs, and report delivery under the causing response | The React adapter includes `ConciergeActivityOverlay` for a configurable edge glow and optional “Powered by FSB” badge. Applications with their own activity @@ -254,7 +277,7 @@ can consume the same `ToolSet`. * An action-scoped bridge takes precedence over its stage bridge; existing stage fallback remains unchanged. * `onDispatch` receives redacted lifecycle events without controlling them. -* Mixed contract-v2 and contract-v3 installations fail before bridge registration or +* Mixed contract-v3 and contract-v4 installations fail before bridge registration or dispatch. ## Telemetry and privacy @@ -294,9 +317,9 @@ public issue. ## Public preview and support -The documented `0.3` surface is supported as a public preview. Patches do not -intentionally break documented exports or contract v3 wire shapes. Only the -latest `0.3.x` patch receives fixes. +The documented `0.4` surface is supported as a public preview. Patches do not +intentionally break documented exports or contract v4 wire shapes. Only the +latest `0.4.x` patch receives fixes. A contract change, Node.js floor increase, removal of a documented export, or removal of AI SDK 6 or 7 support requires a synchronized minor release and a @@ -313,6 +336,9 @@ and exclusions. | [AI SDK integration](./docs/integrations/ai-sdk.md) | Tool conversion, signed batches, result delivery, and deployment boundaries | | [Structured results](./docs/integrations/structured-results.md) | Output schemas, normalization, limits, and observer redaction | | [OpenAI Realtime](./docs/integrations/openai-realtime.md) | App-owned connection flow, catalog acknowledgements, batches, and output events | +| [Realtime session](./docs/integrations/realtime.md) | Voice session package, WebRTC/WebSocket channels, and test stubs | +| [Bridge registration](./docs/bridge-registration.md) | `subscribe`, `drain`, and `awaitRegistration` | +| [Migration from 0.3](./docs/migrations/0.3-to-0.4.md) | Contract v4 upgrade and consent-kernel changes | | [Next.js example](./examples/next-ai-sdk) | Complete AI SDK 7 application with the signed browser bridge | | [Compatibility](./COMPATIBILITY.md) | Certified versions, runtimes, framework boundaries, and version mixing | | [Telemetry privacy](./docs/privacy.md) | Data fields, local coordination, retention, opt-out, and erasure | diff --git a/RELEASING.md b/RELEASING.md index 859d3c8..08e8a99 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,13 +1,15 @@ -# Releasing Concierge 0.3 +# Releasing Concierge 0.4 -Concierge publishes one fixed trio at one stable `0.3.x` version under the -npm `latest` dist-tag. Release artifacts are built without publish credentials, -independently sealed from a clean checkout, and published byte-for-byte from a -protected GitHub environment using npm trusted publishing. +Concierge publishes one fixed set of five packages at one stable `0.4.x` +version under the npm `latest` dist-tag. Release artifacts are built without +publish credentials, independently sealed from a clean checkout, and published +byte-for-byte from a protected GitHub environment using npm trusted publishing. -The live release identity is `.release/lines/0.3.json`. Historical Phase 09 +The live release identity is `.release/lines/0.4.json`. Historical Phase 09 scripts and `.planning` evidence reproduce the unpublished v0.1 milestone; they -do not authorize a 0.3 release and must not be edited into the current flow. +do not authorize a 0.4 release and must not be edited into the current flow. +`.release/lines/0.3.json` is the retired 0.3 line and authorizes nothing on +this one. ## Fixed release set @@ -16,10 +18,16 @@ Publish order is load-bearing: 1. `@full-self-browsing/concierge` 2. `@full-self-browsing/concierge-react` 3. `@full-self-browsing/concierge-svelte` +4. `@full-self-browsing/concierge-dom` +5. `@full-self-browsing/concierge-realtime` -Core is first because each adapter has a core peer. All three manifests, packed -archives, Changesets output, release seal, registry versions, and `latest` tags -must agree. Contract v3 is fixed throughout the 0.3 line. +Core is first because every other package has a core peer. All five manifests, +packed archives, Changesets output, release seal, registry versions, and +`latest` tags must agree. Contract v4 is fixed throughout the 0.4 line. + +The set is enforced in three places and they must not drift apart: +`.release/lines/0.4.json`'s `packages[]`, the `fixed` group in +`.changeset/config.json`, and `node scripts/release/check.mjs all`. ## One-time registry bootstrap @@ -30,12 +38,14 @@ release run. ### 1. Confirm ownership and names Confirm that the npm `@full-self-browsing` organization or user scope exists and -the maintainer has package/settings write permission. Recheck all three names: +the maintainer has package/settings write permission. Recheck all five names: ```sh npm view @full-self-browsing/concierge version npm view @full-self-browsing/concierge-react version npm view @full-self-browsing/concierge-svelte version +npm view @full-self-browsing/concierge-dom version +npm view @full-self-browsing/concierge-realtime version ``` An `E404` means no public package record exists; it does not prove scope write @@ -71,7 +81,7 @@ npm pkg set publishConfig.access='public' npm pkg set publishConfig.tag='bootstrap' # Replace the generated entry with a module that fails loudly if installed. -printf '%s\n' 'throw new Error("This is an inert Concierge registry bootstrap; install 0.3 or newer.");' > index.js +printf '%s\n' 'throw new Error("This is an inert Concierge registry bootstrap; install 0.4 or newer.");' > index.js npm pkg set main='./index.js' npm pkg set exports='./index.js' @@ -89,7 +99,7 @@ Do not publish any repository-built `0.1.0`, assign `latest`, or use an automation token for bootstrap. Do not unpublish the inert version after launch; registry history is immutable evidence. -### 3. Configure three trusted publishers +### 3. Configure five trusted publishers Use npm 11.19.0 or newer in the npm 11 line and authenticate interactively: @@ -100,7 +110,9 @@ npm login for package in \ @full-self-browsing/concierge \ @full-self-browsing/concierge-react \ - @full-self-browsing/concierge-svelte + @full-self-browsing/concierge-svelte \ + @full-self-browsing/concierge-dom \ + @full-self-browsing/concierge-realtime do npm trust github "$package" \ --repo fullselfbrowsing/Concierge \ @@ -142,14 +154,14 @@ job must run on a GitHub-hosted runner and receive only `id-token: write`. ### 1. Add a Changeset -A release Changeset names all three public packages at the same bump level. +A release Changeset names all five public packages at the same bump level. They are one exact fixed group in `.changeset/config.json`. -For a 0.3 patch, keep every adapter's source core peer at `workspace:^`. For a -future pre-1.0 minor, first use a bounded old/new transition such as -`workspace:^0.3.3 || ^0.4.0`; the version wrapper verifies the target and -normalizes the Version Packages PR back to `workspace:^`. Never publish a broad -`>=0.0.0` core peer. +For a 0.4 patch, keep every dependent package's source core peer at +`workspace:^`. For a future pre-1.0 minor, first use a bounded old/new +transition such as `workspace:^0.4.3 || ^0.5.0`; the version wrapper verifies +the target and normalizes the Version Packages PR back to `workspace:^`. Never +publish a broad `>=0.0.0` core peer. ### 2. Run candidate checks @@ -187,6 +199,14 @@ against current AI 6 and 7 stacks. It also installs the exact React and Svelte archives into minimum/current framework cells, checks ESM SSR imports, strict declarations, and one physical core. It never uses a live model credential. +`concierge-dom` and `concierge-realtime` ride the framework cells rather than +having cells of their own. Both are installed from their exact archives, and +the cell imports the dom root plus the realtime root and its `openai`, +`webrtc`, and `websocket` subpaths with no DOM present — the server-render case +that would expose a module-scope `document` or `RTCPeerConnection`. Both also +pass the `skipLibCheck: false` declaration gate and the physical-core topology +probe. The Next cell probes only the packages that example installs. + To prepare the exact AI 7 example in a new path for a local browser run: ```sh @@ -198,7 +218,7 @@ CONCIERGE_RELEASE_BROWSERS=1 npm run test:e2e ``` Install Chromium, Firefox, and WebKit with Playwright first if they are not -already present. The prepared manifest points at all three exact archives; it +already present. The prepared manifest points at all five exact archives; it contains no workspace dependency. ### 3. Review the Version Packages PR @@ -207,10 +227,10 @@ Pushing a Changeset to `main` causes `changesets/action` to open or update a Version Packages PR through `scripts/release/version.mjs`. Review that the PR: - consumes at least one intended Changeset; -- gives all three packages one stable `0.3.x` version; -- updates all three changelogs; -- retains contract v3 for a patch; -- leaves adapter core peers as canonical `workspace:^`; +- gives all five packages one stable `0.4.x` version; +- updates all five changelogs; +- retains contract v4 for a patch; +- leaves every dependent package's core peer as canonical `workspace:^`; - contains only expected manifest, changelog, and lockfile changes. Merge only after the source, example, compatibility, security, and migration @@ -227,7 +247,7 @@ Merging the Version Packages PR leaves no pending Changeset. The next | `version` | Repository and PR write | Validate policy; open a Version Packages PR when Changesets remain | | `verify` | Contents read | Install, build, typecheck, test, pack each package once, run publint/ATTW, test AI 6/7 plus React/Svelte minimum/current cells, fetch pinned npm | | `seal` | Contents read | Clean checkout; independently validate policy, archive manifests/digests, and npm integrity; copy exact tools/archives and create `release-seal.json` | -| `browser_e2e` | Contents read | Revalidate the seal, install its exact trio into an isolated example, and test the signed bridge in Chromium, Firefox, and WebKit | +| `browser_e2e` | Contents read | Revalidate the seal, install its exact five-package set into an isolated example, and test the signed bridge in Chromium, Firefox, and WebKit | | `publish` | OIDC only, protected environment | No checkout/install/build/repack; verify sealed launcher, publish exact archives, verify registry integrity/provenance/tag | Every artifact name binds workflow run, attempt, and source SHA. The seal binds @@ -265,16 +285,18 @@ After publication, independently check: for package in \ @full-self-browsing/concierge \ @full-self-browsing/concierge-react \ - @full-self-browsing/concierge-svelte + @full-self-browsing/concierge-svelte \ + @full-self-browsing/concierge-dom \ + @full-self-browsing/concierge-realtime do npm view "$package" version dist-tags dist.integrity dist.attestations --json done ``` -Install the trio in a new Node 22.12 consumer, confirm one physical core with +Install the set in a new Node 22.12 consumer, confirm one physical core with `pnpm why`, import every public subpath, and run the documented quick start. -Only after all three registry records pass should a maintainer create GitHub tag +Only after all five registry records pass should a maintainer create GitHub tag and release `v` at the exact sealed commit. Attach checksums or link the workflow; do not attach repacked npm archives. @@ -306,11 +328,11 @@ Published npm versions cannot be overwritten. For a code or security defect: 1. stop or reject pending environment approvals; 2. privately assess impact under [SECURITY.md](./SECURITY.md); -3. prepare and certify a synchronized patch trio; +3. prepare and certify a synchronized patch across all five packages; 4. publish it through the same workflow; 5. deprecate the affected version with a safe generic message if needed. Changing `latest` outside the publisher is an exceptional registry mutation. It requires an explicitly reviewed maintainer operation with 2FA, a recorded exact -target, and follow-up verification across all three packages. Never silently -retag only part of the trio. +target, and follow-up verification across all five packages. Never silently +retag only part of the set. diff --git a/SECURITY.md b/SECURITY.md index 620d7f9..b87c914 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -58,7 +58,7 @@ rejected envelope must never fall back to unsigned dispatch. ## Package and release integrity -Official releases are one fixed quartet, built and checked without publish +Official releases are one fixed set of five packages, built and checked without publish credentials, independently sealed, then published from the protected `npm-production` GitHub environment through npm trusted publishing. The OIDC job receives only `id-token: write`; it does not checkout source, install @@ -66,8 +66,8 @@ dependencies, build, or repack. It publishes the sealed bytes with provenance and verifies their integrity, source workflow, commit, run, and `latest` tag. Before installing, verify that package provenance points to -`fullselfbrowsing/Concierge/.github/workflows/release.yml` and that all four -packages resolve to the same `0.2.x` version. +`fullselfbrowsing/Concierge/.github/workflows/release.yml` and that all five +packages resolve to the same `0.4.x` version. ## Supported versions diff --git a/SUPPORT.md b/SUPPORT.md index ef0ca43..3db20b4 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,20 +1,20 @@ # Support policy -Concierge 0.3 is a supported public preview. “Supported” means its documented +Concierge 0.4 is a supported public preview. “Supported” means its documented surface has compatibility gates, security fixes, migration notes, and a defined maintenance window. It does not imply a commercial SLA. ## Supported surface -The 0.3 support contract includes: +The 0.4 support contract includes: - documented exports from the public package export maps; -- runtime contract v3 and the documented signed-envelope v1 wire fields; +- runtime contract v4 and the documented signed-envelope v1 wire fields; - public result, rejection, diagnostic, event, and reason discriminants; - the peer and Node ranges in [COMPATIBILITY.md](./COMPATIBILITY.md); - the behavior demonstrated by the maintained examples and integration guides. -For `0.3.x` patches, maintainers will not intentionally break those surfaces. +For `0.4.x` patches, maintainers will not intentionally break those surfaces. Bug or security fixes may reject input that was previously accepted when that input violated a documented invariant or crossed a security boundary. @@ -28,15 +28,15 @@ The following are not stable public surface: ## Maintenance window -Only the latest `0.3.x` patch receives general fixes. The 0.3 line is supported until +Only the latest `0.4.x` patch receives general fixes. The 0.4 line is supported until the later of: -- six months after `0.3.0` is published; or -- 90 days after `0.4.0` is published. +- six months after `0.4.0` is published; or +- 90 days after `0.5.0` is published. -The 0.2 line remains eligible for critical security fixes until 90 days after -0.3.0 is published. Applications may remain on 0.2 during that window, but must -not combine its contract-v2 core with 0.3 adapters. +The 0.3 line remains eligible for critical security fixes until 90 days after +0.4.0 is published. Applications may remain on 0.3 during that window, but must +not combine its contract-v3 core with 0.4 adapters. A contract bump, removal of AI SDK 6 or 7, removal of a documented export, or increase in the Node floor requires a synchronized minor release, release diff --git a/docs/bridge-registration.md b/docs/bridge-registration.md new file mode 100644 index 0000000..a0519e2 --- /dev/null +++ b/docs/bridge-registration.md @@ -0,0 +1,63 @@ +# Bridge registration + +`createBridge` still returns a single-slot registry. The object is now an +`ObservableBridgeRegistry`: `id`, `read`, and `register` keep their existing +meanings, and two new members expose the arrival edge. + +`BridgeRegistry` itself is unchanged. Hand-built wrappers that only implement +`id`/`read`/`register` keep compiling. They lose `subscribe` and `drain`, and +`awaitRegistration` then refuses them at compile time. + +## Ordering + +1. `register(bridge)` commits the slot, then emits `"registered"`. Inside a + listener, `read()` already returns that bridge. +2. An accepted unregister clears the slot, then emits `"unregistered"`. +3. A refused unregister (React StrictMode, HMR, remount) emits nothing. +4. An overwrite emits one `"registered"` for the new bridge and no + `"unregistered"` for the displaced one. +5. `drain()` emits `"drained"` and changes nothing else. Call it from the + surface's own unmount so pending waiters can fail closed instead of hanging. + +Fan-out is synchronous. That is deliberate: a microtask would let a second +`register()` land before the first event, which would break waiters. + +## Wait for a lazy surface + +```ts +const wait = await awaitRegistration(registry, { + timeoutMs: 2_000, + signal: ctx.signal, +}); + +switch (wait.status) { + case "ready": + return operate(wait.bridge); + case "aborted": + return { ok: false, reason: "cancelled", message: "Cancelled." }; + case "timed-out": + return { + ok: false, + reason: "handler_error", + message: "The panel did not finish opening. Try again in a moment.", + }; + case "drained": + return offPageResult("That panel", "workspace"); + case "unavailable": + return { ok: false, reason: "handler_error", message: "Something went wrong." }; +} +``` + +Supply at least one of `timeoutMs` or `signal`. A requested timeout with no +reachable scheduler resolves `"unavailable"` immediately so the wait cannot +hang. + +Adapters do not wrap `drain()`. Call it from your own lifecycle: + +```ts +useEffect(() => () => registry.drain(), [registry]); +``` + +```ts +onDestroy(() => registry.drain()); +``` diff --git a/docs/integrations/ai-sdk.md b/docs/integrations/ai-sdk.md index 58bb086..aa8ab82 100644 --- a/docs/integrations/ai-sdk.md +++ b/docs/integrations/ai-sdk.md @@ -13,8 +13,8 @@ It does not depend on experimental AI SDK callbacks. AI SDK 7 and React: ```sh -pnpm add @full-self-browsing/concierge@^0.3 \ - @full-self-browsing/concierge-react@^0.3 \ +pnpm add @full-self-browsing/concierge@^0.4 \ + @full-self-browsing/concierge-react@^0.4 \ ai@^7 @ai-sdk/react@^4 ``` @@ -31,7 +31,7 @@ adapter itself is provider-neutral. | `@full-self-browsing/concierge/ai-sdk/browser` | Signature verification, replay protection, live-catalog check, and dispatch | The server subpath has an explicit fail-closed browser condition. All entries -check core contract v3 before doing work. +check core contract v4 before doing work. ## 1. Convert an atomic catalog @@ -158,7 +158,7 @@ interface SignedToolBatchEnvelopeV1 { } ``` -The canonical claims bind contract v3, audience, session, catalog stage and +The canonical claims bind contract v4, audience, session, catalog stage and digest, issued/expiry times, nonce, response, required user turn, and ordered calls. The protected header fixes ES256, key ID, media type, and envelope version. diff --git a/docs/integrations/realtime.md b/docs/integrations/realtime.md new file mode 100644 index 0000000..ee83257 --- /dev/null +++ b/docs/integrations/realtime.md @@ -0,0 +1,38 @@ +# Realtime session package + +`@full-self-browsing/concierge-realtime` opens a bidirectional event channel, +hands core a `Transport` with `acknowledgesCatalog: true`, and owns delivery +evidence, turn identity, and interruption. It declares no actions. + +The core `@full-self-browsing/concierge/openai-realtime` entry remains a +protocol codec only. The realtime package consumes that codec; it does not fork +it. + +```ts +import { createRealtimeSession } from "@full-self-browsing/concierge-realtime"; +import { createOpenAIRealtimeProvider } from "@full-self-browsing/concierge-realtime/openai"; +import { createWebRTCRealtimeChannel } from "@full-self-browsing/concierge-realtime/webrtc"; + +const channel = createWebRTCRealtimeChannel({ negotiate }); +const provider = createOpenAIRealtimeProvider({ sessionType: "realtime" }); +const handle = await createRealtimeSession({ + concierge, + channel, + provider, + presentOutcome, + initialContext, + sessionId: "session-1", + turnSource: "explicit", +}); +``` + +Tests should drive the session through a stub channel rather than a live peer +connection. A WebRTC path still needs an application-owned `negotiate` that +posts SDP and returns the remote answer. The package never fetches credentials +or inspects transcripts. + +CI does not yet run Vitest browser mode or Playwright against a live +`RTCPeerConnection`. `./webrtc` and `./websocket` ship a fake-peer unit suite +instead; that is the current release gate for those subpaths. + +See [openai-realtime.md](./openai-realtime.md) for the codec-only contract. diff --git a/docs/integrations/structured-results.md b/docs/integrations/structured-results.md index d7569cf..8f38ce6 100644 --- a/docs/integrations/structured-results.md +++ b/docs/integrations/structured-results.md @@ -1,6 +1,6 @@ # Structured action results -Concierge 0.3 lets an action return schema-controlled JSON data to the calling +Concierge 0.4 lets an action return schema-controlled JSON data to the calling agent while keeping observer exposure independent and explicit. ## Declare the output diff --git a/docs/migrations/0.3-to-0.4.md b/docs/migrations/0.3-to-0.4.md new file mode 100644 index 0000000..a608677 --- /dev/null +++ b/docs/migrations/0.3-to-0.4.md @@ -0,0 +1,140 @@ +# Migrate from 0.3 to 0.4 + +Concierge 0.4 moves the synchronized package family to runtime contract v4. +The contract bump prevents a mixed installation from silently disagreeing about +consent records, catalog acknowledgement, or adapter guards. + +## Upgrade the fixed package set + +Upgrade every installed Concierge package together and regenerate the +lockfile. 0.4 adds `@full-self-browsing/concierge-dom` (visible-element +registry) and `@full-self-browsing/concierge-realtime` (session runtime, +OpenAI provider, WebRTC, and WebSocket channels): + +```sh +pnpm up @full-self-browsing/concierge@^0.4 \ + @full-self-browsing/concierge-react@^0.4 \ + @full-self-browsing/concierge-svelte@^0.4 \ + @full-self-browsing/concierge-dom@^0.4 \ + @full-self-browsing/concierge-realtime@^0.4 + +pnpm why @full-self-browsing/concierge +``` + +The final command should show one physical 0.4 core. A 0.4 adapter +deliberately rejects contract-v3 core before bridge registration or dispatch. + +## Existing actions remain valid until they declare consent + +Actions that return only `ok`, `reason`, and `message`, and that declare no +`consent` policy, keep their runtime shape. Stage-level bridges, direct +dispatch, batch dispatch, sessions without catalog acknowledgement, React/Svelte +bridge hooks, and the signed AI SDK envelope remain supported. + +## Adopt handler-proposed consent + +A consent policy now binds the payload the review handler proposes, not the +review action's arguments. + +1. Declare `consentProfile` on `createConcierge`. +2. Call `ctx.review.propose(payload)` in the review handler. Without it, a + `minGrade: "attested"` policy fails closed. +3. Read `ack.payload` as that proposed value. +4. Call `concierge.attestReadback({ act, actId, readbackHash })` from app code + that observed the human act. Do not put attestation on `DeliveryReport`, and + do not expose `attestReadback` through the signed AI-SDK browser envelope. +5. Reconsider `minGrade`. `delivered` now arms when the review handler returns + ok, even if the transport has no delivery hook. Raise `minGrade` for + consequential actions. `destructive_without_grade` reports destructive + actions that stay on the delivered default. +6. `bindTo: "response"` now also refuses the readback response id when one was + recorded. `bindTo: "unverifiedUserTurn"` is the honest weak rung for + agent-forgeable turn identity. + +`makeReadbackReceipt(readback, digest)` is the public producer for +`presentReadback`. Canonicalization hashes `{payload, presented?}` together. + +The delivery `responseId === pending.responseId` kernel gate is unchanged. +Producers must still report the causing response id. + +## Vacuous snapshots fail closed + +An empty bridge snapshot (`{}`) makes drift detection a no-op. If any action +bound to that bridge is named in a consent `requires` list and the profile is +stronger than `"none"`, `buildCatalog` errors with `vacuous_consent_snapshot`. +A confirming action still refuses `consent_stale` at runtime if the captured +snapshot has zero own keys. Snapshot slots must be zero-argument getters; +arity greater than 0 is `snapshot_slot_not_a_getter`. + +Both build-time checks read `registry.read()`, so they only see registries +that already hold a bridge. A catalog built at module scope — before any +component mounts — inspects nothing and reports nothing, which is silence +rather than a pass. Rebuild the catalog after the bridges register if you +want the build-time report. The runtime `consent_stale` refusal is the gate +that holds either way, and it needs no rebuild. + +## Catalog acknowledgement + +`TransportCapabilities.acknowledgesCatalog` is required own-data. Existing +transports must set it to `false`. When it is `true`, `onCatalogAcknowledged` +is required and `createSession` defers promoting a new context until the +transport confirms that revision. `session.catalog()` and dispatch keep using +the last acknowledged context. A rejected publication keeps that context and +emits `catalog_acknowledgement_failed`; the session does not stop. + +## Observer messages + +`event.result.message` is now an `ObservedMessage`: + +```ts +const text = + event.result.message.kind === "included" + ? event.result.message.value + : null; +``` + +The default `redactMessage` policy is `"passthrough"`, so existing ungated +actions keep including the sanitized sentence. + +## Small additives + +- `isReasonCode` is a root export. +- `sanitizeText` is a root export; `sanitizeMessage` remains the dispatcher + wrapper with the same byte-identical behaviour. +- Handler `ctx.context` is the existing occurrence stage context. +- `createTurnLedger`, `createRenditionBinder`, `awaitRegistration`, + `resolveValue`, `renderCatalogPrompt`, and `catalogDerivedPolicy` are root + exports. +- Import test helpers from `@full-self-browsing/concierge/testing`. +- `useConciergeActivity()` now returns `{ active, lastEvent }`. +- `useConciergeBridge(registry, null)` unregisters. + +## Threat model + +- `attestReadback` is page-reachable authority, strictly weaker than `dispatch` + on the same handle. It cannot create a generation, choose a payload, or raise + the profile ceiling. It must never enter the signed browser envelope. +- Keeping delivery `responseId` equality means attribution still requires the + transport to invoke the generation-scoped closure with the causing id. +- `presented` in the hash is a strengthening with no downgrade path. +- Arming `delivered` without a hook is a genuine loosening, mitigated by + `destructive_without_grade` and by raising `minGrade`. +- `ConsentAck` remains a client assertion, not server authorization. +- A hostile app can attest a hash it obtained from `propose`. `attested` is + exactly as trustworthy as the app's claim that it rendered the payload. +- An unacknowledged revision must not become authorized. A v3 transport + against v4 core throws at `createSession`. An acknowledgement for a revision + that was never published is ignored. Acknowledgements after `stop()` are + ignored. + +## Defaults taken from the handoff + +- `delivered` may arm without a delivery hook. +- `bindTo: "unverifiedUserTurn"` ships in 0.4.0. +- Vacuous snapshots warn at propose and refuse at confirm. +- `Readback.presented` enters the hash. +- The handler seam is named `ctx.review`. +- Retained reviews have no timer. +- `attestReadback` lives on the `Concierge` handle. + +See [COMPATIBILITY.md](../../COMPATIBILITY.md) for the 0.4 support matrix. diff --git a/examples/next-ai-sdk/README.md b/examples/next-ai-sdk/README.md index 11df370..d24034e 100644 --- a/examples/next-ai-sdk/README.md +++ b/examples/next-ai-sdk/README.md @@ -1,6 +1,6 @@ # Next.js + AI SDK signed-browser example -This Next 16 App Router application is the complete contract-v3 integration +This Next 16 App Router application is the complete contract-v4 integration pattern. Concierge remains the action and control layer; AI SDK owns the model loop, React owns rendering, the application owns navigation/speech/viewer state, and OpenRouter is only an injected server-side model boundary. diff --git a/examples/next-ai-sdk/src/portfolio-concierge.ts b/examples/next-ai-sdk/src/portfolio-concierge.ts index 7a9523c..cf62b29 100644 --- a/examples/next-ai-sdk/src/portfolio-concierge.ts +++ b/examples/next-ai-sdk/src/portfolio-concierge.ts @@ -147,13 +147,24 @@ export function createPortfolioConcierge(): PortfolioConcierge { schema: projectInputSchema, jsonSchema: PROJECT_SCHEMA, redact: ({ projectId }) => ({ projectId }), + redactMessage: "drop", effects: { readOnly: true, destructive: false, idempotent: true }, availableWhen: (ctx) => ctx.pathname === "/portfolio" && ctx.browserOpen === false, - handler: ({ args }) => ({ - ok: true, - message: `Reviewed project ${args.projectId}; opening it still requires consent.`, - }), + handler: async ({ args, review }) => { + const proposed = await review.propose(args); + if (!proposed.ok) { + return { + ok: false, + reason: "precondition_failed", + message: "The review payload could not be proposed.", + }; + } + return { + ok: true, + message: `Reviewed project ${args.projectId}; opening it still requires consent.`, + }; + }, }); const launchReviewedProject = defineAction< @@ -169,13 +180,14 @@ export function createPortfolioConcierge(): PortfolioConcierge { schema: projectInputSchema, jsonSchema: PROJECT_SCHEMA, redact: ({ projectId }) => ({ projectId }), + redactMessage: "drop", effects: { readOnly: false, destructive: false, idempotent: true }, availableWhen: (ctx) => ctx.pathname === "/portfolio" && ctx.browserOpen === false, consent: { requires: "reviewProjectLaunch", bindTo: "response", - minGrade: "delivered", + minGrade: "relayed", }, handler: ({ ack, bridge: mounted }) => { if (ack === undefined) { @@ -324,7 +336,7 @@ export function createPortfolioConcierge(): PortfolioConcierge { crossStage: [navigate, startTour, switchToText, endCall], scheduler: schedule, consentProfile: { - consentGrade: "delivered", + consentGrade: "relayed", userTurnIdentity: "agent-forgeable", }, maxWorkflowDepth: 16, diff --git a/examples/next-ai-sdk/test/consent.test.ts b/examples/next-ai-sdk/test/consent.test.ts index 6d4145a..ae7f8ab 100644 --- a/examples/next-ai-sdk/test/consent.test.ts +++ b/examples/next-ai-sdk/test/consent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { DeliveryReport } from "@full-self-browsing/concierge"; +import { createCompletedDelivery } from "@full-self-browsing/concierge/testing"; import { createPortfolioConcierge } from "../src/portfolio-concierge"; import type { PortfolioBridge, PortfolioContext } from "../src/portfolio-concierge"; @@ -60,10 +61,7 @@ describe("the reviewed-project consent flow", () => { expect(opened).toEqual([]); expect(completeDelivery).toBeTypeOf("function"); - completeDelivery?.({ - responseId: "consent-response", - outcome: "completed", - }); + completeDelivery?.(createCompletedDelivery("consent-response")); await Promise.resolve(); const launched = await runtime.concierge.dispatch(CONTEXT, { diff --git a/packages/concierge-dom/CHANGELOG.md b/packages/concierge-dom/CHANGELOG.md new file mode 100644 index 0000000..748a7fb --- /dev/null +++ b/packages/concierge-dom/CHANGELOG.md @@ -0,0 +1,16 @@ +# @full-self-browsing/concierge-dom + +## 0.4.0 + +### Minor Changes + +- 2ffb743: Ship `@full-self-browsing/concierge-dom`: registered-element resolve, reveal, and untrusted readback. +- 2ffb743: Ship Concierge 0.4: contract v4 consent kernel, catalog acknowledgement, dispatch observability, DOM and realtime packages, adapter last-event/null-bridge hooks, and the five-package release set. + + Two details worth knowing before you write against `concierge-dom`. An `AnchorRef` now returns the cleanup for the element it attached, so React 19 releases exactly the node each JSX site registered; React 18 ignores the return value and keeps the `ref(null)` protocol. A key holds a set of registrations rather than one, so several simultaneously mounted nodes for one record all stay reachable. + +## 0.3.0 + +### Minor Changes + +- Introduce the package identity for the five-package Concierge 0.4 set. diff --git a/packages/concierge-dom/LICENSE b/packages/concierge-dom/LICENSE new file mode 100644 index 0000000..4cc47cb --- /dev/null +++ b/packages/concierge-dom/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Full Self Browsing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/concierge-dom/README.md b/packages/concierge-dom/README.md new file mode 100644 index 0000000..75579f9 --- /dev/null +++ b/packages/concierge-dom/README.md @@ -0,0 +1,142 @@ +
+ +Concierge + +# `@full-self-browsing/concierge-dom` + +
+ +Framework-neutral visible-element helpers for +[`@full-self-browsing/concierge`](https://github.com/fullselfbrowsing/Concierge). + +The package never searches the document. It only returns an element the +application registered under a key it already owns. There is no selector, +predicate, XPath, or coordinate in any public signature, and the built +artifact is gated against host-DOM query and actuation primitives. + +Requires Node 22.12 or newer. Peers on `@full-self-browsing/concierge` at +contract v4. + +## Install + +```sh +pnpm add @full-self-browsing/concierge-dom @full-self-browsing/concierge +``` + +## Register, then resolve + +Construct the registry at module scope — construction touches no global, so +it is safe for a server to evaluate. Registration itself must not run during +server rendering; ref callbacks and Svelte `use:` actions already satisfy +that, the same way `createBridge` does. + +```ts +import { createAnchorRegistry } from "@full-self-browsing/concierge-dom"; + +export const anchors = createAnchorRegistry({ id: "pipeline" }); +``` + +```tsx +
{deal.label}
+
+ {deal.notes} +
+``` + +```svelte +
  • {line.label}
  • +``` + +`ref(key)` returns the same callback identity on every call, so it is safe +in JSX without memoisation. Options supplied on a later call replace the +options used for subsequent registrations. A key holds a *set* of +registrations: two simultaneously mounted nodes for one record is the +responsive case this package exists to solve. + +The callback returns the cleanup for the element it just attached. React 19 +calls that cleanup on unmount and never calls back with `null`, so each JSX +site releases exactly the node it registered — which is what makes several +live nodes under one key exact. React 18 ignores the return value and calls +`ref(null)` instead, naming no element; there the callback drops any +registration whose node has left the document, and failing that the most +recent one. If you need multi-node precision under React 18, call +`register()` (or `action()` in Svelte) directly — both hand back a release +bound to their own node. + +Do not place an `AnchorRegistry` on a bridge snapshot. It is a capability +object, not a value; `captureSnapshot` would report it as exotic. + +## Resolve, reveal, read + +`resolve` is a pure query. It measures each live registration, keeps the +ones that satisfy `isRendered`, and returns the first survivor. Pass +`within` as an element you already registered (an open dialog, a sheet) to +prefer a survivor that element contains. There is no fallback to a hidden +candidate. + +```ts +const found = anchors.resolve(dealId, { + within: anchors.resolve("detail-panel").element, +}); + +if (found.status !== "rendered") { + return { ok: false, reason: "not_found", message: appCopy(found.status) }; +} + +await anchors.reveal(dealId, { + within: found.element, + defer: "frame", + block: "center", + markMs: 1200, +}); +``` + +`reveal` is the only routine that mutates the page: scroll position, and +optionally the reserved `data-concierge-reveal` attribute so the application +can style `[data-concierge-reveal]`. A second reveal for the same key +cancels that key's pending frame and pending mark. An aborted signal +cancels without scrolling. `.focus()` is banned; the application may focus +the returned element itself. + +`readUntrusted` extracts bounded visible text from a subtree registered +with `{ readable: true }`. Readability is a declaration, not a markup +accident — an anchored billing panel is not thereby agent-readable. The +walk uses the live tree (a detached clone has no computed style) and skips +`script`, `style`, `noscript`, `template`, `svg`, `iframe`, and `object` by +`tagName`. The result is untrusted. An action that returns it must declare +`readsUntrusted: true`. `ReadOutcome` is not a consent artifact. + +Every operation returns a status, never a sentence. The application owns +narration. + +## Visibility + +`measureVisibility` walks `parentElement` and reports attributes, computed +style, `aria-busy`, layout, and depth. Two named policies sit on that +report and are not merged: + +- `isRendered` — connected, no hiding attribute, no hiding style. Ignores + `busy` and `hasLayout`. +- `isReadable` — `isRendered` and not `aria-busy`. + +`hasLayout` is `getClientRects().length > 0`. It is always `false` under +jsdom and is consulted by neither policy. + +## Viewport + +`scrollViewport`, `readViewportPosition`, and `preferredScrollBehavior` are +the exports with no registry gate: they reach no element and read nothing +back. They throw if invoked where `window` / `document` do not exist. On a +page with scroll-triggered loading, scrolling causes the application to +fetch. + +## Contract guard + +The first registration of a registry's life calls `assertSingleInstance()` +and checks `CONTRACT_VERSION` against `EXPECTED_CORE_CONTRACT_VERSION` +(4). A mismatched core throws before any registration is stored. The guard +is not at module scope. + +## License + +MIT © Full Self Browsing diff --git a/packages/concierge-dom/package.json b/packages/concierge-dom/package.json new file mode 100644 index 0000000..c2743e3 --- /dev/null +++ b/packages/concierge-dom/package.json @@ -0,0 +1,58 @@ +{ + "name": "@full-self-browsing/concierge-dom", + "version": "0.4.0", + "description": "Framework-neutral DOM helpers for @full-self-browsing/concierge", + "keywords": [ + "ai", + "agent", + "dom", + "tool-calling", + "browser-control", + "typescript" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/fullselfbrowsing/Concierge.git", + "directory": "packages/concierge-dom" + }, + "homepage": "https://github.com/fullselfbrowsing/Concierge#readme", + "bugs": { + "url": "https://github.com/fullselfbrowsing/Concierge/issues" + }, + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public", + "tag": "latest" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@full-self-browsing/concierge": "workspace:^" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --root ../.. --project dom --project dom-artifact" + }, + "devDependencies": { + "@full-self-browsing/concierge": "workspace:*" + } +} diff --git a/packages/concierge-dom/src/constants.ts b/packages/concierge-dom/src/constants.ts new file mode 100644 index 0000000..6bdb5da --- /dev/null +++ b/packages/concierge-dom/src/constants.ts @@ -0,0 +1,19 @@ +/** + * Fixed literals for `@full-self-browsing/concierge-dom`. + * + * The core contract pin is a literal, not a runtime read of core, so a + * mismatched install fails on the first registration rather than agreeing + * with whichever core happened to load. This package peers on contract v4. + * + * The reveal attribute name is a module constant so no caller-supplied + * string ever reaches an attribute-name position. + */ + +export const EXPECTED_CORE_CONTRACT_VERSION: 4 = 4; + +export const ANCESTOR_WALK_LIMIT: 512 = 512; + +export const REVEAL_ATTRIBUTE: "data-concierge-reveal" = "data-concierge-reveal"; + +/** dataset key for {@link REVEAL_ATTRIBUTE}. Not a caller-supplied name. */ +export const REVEAL_DATASET_KEY: "conciergeReveal" = "conciergeReveal"; diff --git a/packages/concierge-dom/src/index.ts b/packages/concierge-dom/src/index.ts new file mode 100644 index 0000000..e80eb6d --- /dev/null +++ b/packages/concierge-dom/src/index.ts @@ -0,0 +1,56 @@ +/** + * `@full-self-browsing/concierge-dom` — framework-neutral visible-element + * resolve, reveal, and untrusted readback. + * + * The catalog-boundary rule, stated once: this package never finds an + * element. It only hands back an element the application registered. There + * is no selector, predicate, XPath, coordinate, tag filter, or attribute + * filter in any public signature. The reachable set is exactly the set the + * application's own render tree passed in. + * + * Nothing here is an ActionDefinition and nothing calls `defineAction`. + * The model's reachable verb set is unchanged by adopting this package. + * + * Construction of a registry touches no global, so it is safe at a module + * scope a server evaluates. Every other runtime export touches a document + * or window capability when invoked, and none does when imported. + */ + +export { + ANCESTOR_WALK_LIMIT, + EXPECTED_CORE_CONTRACT_VERSION, + REVEAL_ATTRIBUTE, +} from "./constants.js"; + +export { isReadable, isRendered, measureVisibility } from "./visibility.js"; + +export { + preferredScrollBehavior, + readViewportPosition, + scrollViewport, +} from "./viewport.js"; + +export { createAnchorRegistry } from "./registry.js"; + +export type { + AnchorAction, + AnchorOptions, + AnchorRef, + AnchorRegistry, + AnchorRegistryOptions, + AnchorResolution, + HiddenByAttribute, + HiddenByStyle, + ReadOptions, + ReadOutcome, + ReadStatus, + ResolveOptions, + ResolveStatus, + RevealOptions, + RevealOutcome, + RevealStatus, + ViewportDirection, + ViewportPosition, + ViewportScrollOptions, + VisibilityReport, +} from "./types.js"; diff --git a/packages/concierge-dom/src/registry.ts b/packages/concierge-dom/src/registry.ts new file mode 100644 index 0000000..bc76d85 --- /dev/null +++ b/packages/concierge-dom/src/registry.ts @@ -0,0 +1,702 @@ +/** + * Anchor registry — the application hands elements over under keys it owns. + * + * Mutable state lives in `let` bindings inside the factory, never at module + * scope: a long-lived server reuses this module across requests, and a + * module-scope token counter would hand one request's tokens to another + * registry. `assertSingleInstance` and the contract-version check run on + * the first registration of a registry's life, never at construction and + * never at module evaluation (`sideEffects: false` would delete that). + * + * The returned object is frozen. The registry is a capability; leaving + * `resolve` writable lets same-realm script swap it for a function that + * returns attacker-chosen elements while every check upstream still + * reports success. Freezing the object does not freeze the closure. + */ + +import { + assertSingleInstance, + CONTRACT_VERSION, + sanitizeText, +} from "@full-self-browsing/concierge"; +import type { AbortSignalLike, Scheduler } from "@full-self-browsing/concierge"; + +import { + EXPECTED_CORE_CONTRACT_VERSION, + REVEAL_DATASET_KEY, +} from "./constants.js"; +import type { + AnchorAction, + AnchorOptions, + AnchorRef, + AnchorRegistry, + AnchorRegistryOptions, + AnchorResolution, + ReadOptions, + ReadOutcome, + ResolveOptions, + RevealOptions, + RevealOutcome, + VisibilityReport, +} from "./types.js"; +import { isRendered, measureVisibility } from "./visibility.js"; +import { preferredScrollBehavior } from "./viewport.js"; + +interface Registration { + readonly token: number; + readonly element: HTMLElement; + readonly readable: boolean; +} + +interface PendingMark { + readonly cancel: () => void; + readonly element: HTMLElement; +} + +type FrameWait = "proceed" | "aborted"; + +const SKIPPED_TAGS: ReadonlySet = new Set([ + "SCRIPT", + "STYLE", + "NOSCRIPT", + "TEMPLATE", + "SVG", + "IFRAME", + "OBJECT", +]); + +const DEFAULT_MAX_NODES: number = 2000; +const DEFAULT_FRAME_FALLBACK_MS: number = 100; +const RAW_LENGTH_FACTOR: number = 4; + +function defaultScheduler(fn: () => void, delayMs: number): () => void { + const handle: ReturnType = setTimeout(fn, delayMs); + return (): void => { + clearTimeout(handle); + }; +} + +function defaultFrame(fn: () => void): () => void { + const handle: number = requestAnimationFrame((): void => { + fn(); + }); + return (): void => { + cancelAnimationFrame(handle); + }; +} + +function abortedOutcome(): RevealOutcome { + return { status: "aborted", element: null, report: null }; +} + +function emptyRead(status: ReadOutcome["status"], report: VisibilityReport | null): ReadOutcome { + return { + status, + text: "", + truncated: false, + visited: 0, + report, + }; +} + +function applyRevealMark(element: HTMLElement): void { + element.dataset[REVEAL_DATASET_KEY] = "true"; +} + +function clearRevealMark(element: HTMLElement): void { + delete element.dataset[REVEAL_DATASET_KEY]; +} + +function scrollWinner( + element: HTMLElement, + block: ScrollLogicalPosition, + behavior: ScrollBehavior, + offsetTop: number, +): void { + if (block === "start" && offsetTop > 0) { + const top: number = + element.getBoundingClientRect().top + window.scrollY - offsetTop; + window.scrollTo({ top, behavior }); + return; + } + if (typeof element.scrollIntoView === "function") { + element.scrollIntoView({ behavior, block }); + } +} + +function extractVisibleText( + root: HTMLElement, + maxChars: number, + maxNodes: number, +): { readonly raw: string; readonly visited: number; readonly stopped: boolean } { + let raw: string = ""; + let visited: number = 0; + let stopped: boolean = false; + const rawLimit: number = maxChars * RAW_LENGTH_FACTOR; + + const visit = (node: Node): boolean => { + if (visited >= maxNodes) { + stopped = true; + return false; + } + visited += 1; + + if (node.nodeType === Node.TEXT_NODE) { + raw += node.nodeValue ?? ""; + if (raw.length > rawLimit) { + stopped = true; + return false; + } + return true; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return true; + } + + const element: Element = node as Element; + if (SKIPPED_TAGS.has(element.tagName.toUpperCase())) { + return true; + } + + if (element !== root && !isRendered(measureVisibility(element as HTMLElement))) { + return true; + } + + const children: NodeListOf = element.childNodes; + for (let index = 0; index < children.length; index += 1) { + const child: ChildNode | undefined = children[index]; + if (child === undefined) { + continue; + } + if (!visit(child)) { + return false; + } + } + return true; + }; + + visit(root); + return { raw, visited, stopped }; +} + +function resolveFrom( + entries: readonly Registration[], + options: ResolveOptions = {}, +): AnchorResolution { + if (entries.length === 0) { + return { + status: "not-registered", + element: null, + report: null, + registered: 0, + }; + } + + const reports: VisibilityReport[] = entries.map( + (entry): VisibilityReport => measureVisibility(entry.element), + ); + const survivors: HTMLElement[] = []; + const survivorReports: VisibilityReport[] = []; + for (let index = 0; index < entries.length; index += 1) { + const entry: Registration | undefined = entries[index]; + const report: VisibilityReport | undefined = reports[index]; + if (entry === undefined || report === undefined) { + continue; + } + if (isRendered(report)) { + survivors.push(entry.element); + survivorReports.push(report); + } + } + + if (survivors.length === 0) { + return { + status: "not-rendered", + element: null, + report: reports[0] ?? null, + registered: entries.length, + }; + } + + const within: HTMLElement | null | undefined = options.within; + let winner: HTMLElement = survivors[0] as HTMLElement; + let winnerReport: VisibilityReport = survivorReports[0] as VisibilityReport; + + if (within != null && within.isConnected) { + for (let index = 0; index < survivors.length; index += 1) { + const candidate: HTMLElement | undefined = survivors[index]; + const report: VisibilityReport | undefined = survivorReports[index]; + if (candidate !== undefined && report !== undefined && within.contains(candidate)) { + winner = candidate; + winnerReport = report; + break; + } + } + } + + return { + status: "rendered", + element: winner, + report: winnerReport, + registered: entries.length, + }; +} + +export function createAnchorRegistry( + options: AnchorRegistryOptions = {}, +): AnchorRegistry { + const id: string = options.id ?? ""; + const scheduler: Scheduler = options.scheduler ?? defaultScheduler; + const frame: (fn: () => void) => () => void = options.frame ?? defaultFrame; + const frameFallbackMs: number = options.frameFallbackMs ?? DEFAULT_FRAME_FALLBACK_MS; + + const byKey: Map = new Map(); + const refCallbacks: Map = new Map(); + const refOptions: Map = new Map(); + // One release per ELEMENT, not one per key. A key holds a set of + // registrations, and `ref(key)` returns one shared callback for every JSX + // site using that key — so a single release slot made the second node + // silently unregister the first. Kept at factory scope rather than in the + // callback closure so `clear()` can drop every outstanding release. + const refReleases: Map void>> = new Map(); + const pendingFrames: Map void> = new Map(); + const pendingMarks: Map = new Map(); + + let next: number = 0; + let armed: boolean = false; + let warnedOffset: boolean = false; + + const arm = (): void => { + if (armed) { + return; + } + assertSingleInstance(); + if (CONTRACT_VERSION !== EXPECTED_CORE_CONTRACT_VERSION) { + throw new Error( + `@full-self-browsing/concierge-dom expected core contract v${EXPECTED_CORE_CONTRACT_VERSION} ` + + `but found v${CONTRACT_VERSION}; upgrade or reinstall ` + + `@full-self-browsing/concierge-dom and @full-self-browsing/concierge together.`, + ); + } + armed = true; + }; + + const live = (key: string): Registration[] => byKey.get(key) ?? []; + + const cancelFrame = (key: string): void => { + const cancel: (() => void) | undefined = pendingFrames.get(key); + if (cancel !== undefined) { + pendingFrames.delete(key); + cancel(); + } + }; + + const cancelMark = (key: string): void => { + const mark: PendingMark | undefined = pendingMarks.get(key); + if (mark !== undefined) { + pendingMarks.delete(key); + mark.cancel(); + clearRevealMark(mark.element); + } + }; + + const supersede = (key: string): void => { + cancelFrame(key); + cancelMark(key); + }; + + const register = ( + key: string, + element: HTMLElement, + registerOptions?: AnchorOptions, + ): (() => void) => { + arm(); + const token: number = ++next; + const entry: Registration = { + token, + element, + readable: registerOptions?.readable === true, + }; + const existing: Registration[] | undefined = byKey.get(key); + if (existing === undefined) { + byKey.set(key, [entry]); + } else { + existing.push(entry); + } + + return (): void => { + const bucket: Registration[] | undefined = byKey.get(key); + if (bucket === undefined) { + return; + } + const index: number = bucket.findIndex( + (candidate: Registration): boolean => candidate.token === token, + ); + if (index < 0) { + return; + } + bucket.splice(index, 1); + if (bucket.length === 0) { + byKey.delete(key); + } + }; + }; + + const resolve = ( + key: string, + resolveOptions: ResolveOptions = {}, + ): AnchorResolution => resolveFrom(live(key), resolveOptions); + + const waitForFrame = ( + key: string, + signal: AbortSignalLike | undefined, + ): Promise => + new Promise((settle: (result: FrameWait) => void): void => { + let settled: boolean = false; + // **`let`, not `const`, and called optionally.** `frame` and `scheduler` + // are injected, and an injected double may invoke its callback + // synchronously — `createTestScheduler` from + // `@full-self-browsing/concierge/testing` does exactly that for + // `delayMs <= 0`. With `const` declarations below `finish`, that + // synchronous call reached them in the temporal dead zone and the whole + // `reveal` rejected with a `ReferenceError` instead of returning an + // outcome. Each handle is instead cancelled at its own call site when + // the wait has already settled. + let cancelScheduledFrame: (() => void) | undefined; + let cancelFallback: (() => void) | undefined; + let abortListenerAttached: boolean = false; + + const finish = (result: FrameWait): void => { + if (settled) { + return; + } + settled = true; + pendingFrames.delete(key); + cancelScheduledFrame?.(); + cancelScheduledFrame = undefined; + cancelFallback?.(); + cancelFallback = undefined; + if (abortListenerAttached && signal !== undefined) { + abortListenerAttached = false; + signal.removeEventListener("abort", onAbort); + } + settle(result); + }; + + const onAbort = (): void => { + finish("aborted"); + }; + + // Registered and wired BEFORE anything is scheduled, so an already + // aborted signal never arms a timer it would only have to cancel. + pendingFrames.set(key, (): void => { + finish("aborted"); + }); + + if (signal !== undefined) { + if (signal.aborted) { + finish("aborted"); + return; + } + signal.addEventListener("abort", onAbort); + abortListenerAttached = true; + } + + const scheduledFrame: () => void = frame((): void => { + finish("proceed"); + }); + if (settled) { + scheduledFrame(); + return; + } + cancelScheduledFrame = scheduledFrame; + + const scheduledFallback: () => void = scheduler((): void => { + finish("proceed"); + }, frameFallbackMs); + if (settled) { + scheduledFallback(); + return; + } + cancelFallback = scheduledFallback; + }); + + const finishReveal = ( + key: string, + resolution: AnchorResolution, + revealOptions: RevealOptions, + ): RevealOutcome => { + const element: HTMLElement | null = resolution.element; + const report: VisibilityReport | null = resolution.report; + if (element === null || report === null) { + return { + status: resolution.status, + element: null, + report, + }; + } + + const block: ScrollLogicalPosition = revealOptions.block ?? "center"; + const offsetTop: number = revealOptions.offsetTop ?? 0; + const behavior: ScrollBehavior = + revealOptions.behavior ?? preferredScrollBehavior(); + + if (offsetTop > 0 && block !== "start" && !warnedOffset) { + warnedOffset = true; + console.warn( + `@full-self-browsing/concierge-dom: offsetTop applies only with block "start"; it was ignored.`, + ); + } + + scrollWinner(element, block, behavior, offsetTop); + + const markMs: number = revealOptions.markMs ?? 0; + if (markMs > 0) { + applyRevealMark(element); + const cancel: () => void = scheduler((): void => { + pendingMarks.delete(key); + clearRevealMark(element); + }, markMs); + pendingMarks.set(key, { cancel, element }); + } + + return { + status: "revealed", + element, + report, + }; + }; + + const reveal = async ( + key: string, + revealOptions: RevealOptions = {}, + ): Promise => { + supersede(key); + + const signal: AbortSignalLike | undefined = revealOptions.signal; + if (signal?.aborted === true) { + return abortedOutcome(); + } + + const initial: AnchorResolution = resolve(key, revealOptions); + if (initial.status !== "rendered") { + return { + status: initial.status, + element: null, + report: initial.report, + }; + } + + if (revealOptions.defer === "frame") { + const wait: FrameWait = await waitForFrame(key, signal); + if (wait === "aborted") { + return abortedOutcome(); + } + const again: AnchorResolution = resolve(key, revealOptions); + if (again.status !== "rendered") { + return { + status: again.status, + element: null, + report: again.report, + }; + } + return finishReveal(key, again, revealOptions); + } + + return finishReveal(key, initial, revealOptions); + }; + + const readUntrusted = ( + key: string, + readOptions: ReadOptions, + ): ReadOutcome => { + const entries: Registration[] = live(key); + if (entries.length === 0) { + return emptyRead("not-registered", null); + } + + const readableEntries: Registration[] = entries.filter( + (entry: Registration): boolean => entry.readable, + ); + const first: Registration | undefined = entries[0]; + if (readableEntries.length === 0) { + return emptyRead( + "not-readable", + first === undefined ? null : measureVisibility(first.element), + ); + } + + const resolution: AnchorResolution = resolveFrom(readableEntries, readOptions); + if (resolution.status !== "rendered" || resolution.element === null) { + return emptyRead("not-rendered", resolution.report); + } + + const refuseWhileBusy: boolean = readOptions.refuseWhileBusy !== false; + if (refuseWhileBusy && resolution.report !== null && resolution.report.busy) { + return emptyRead("busy", resolution.report); + } + + const maxNodes: number = readOptions.maxNodes ?? DEFAULT_MAX_NODES; + const extracted = extractVisibleText( + resolution.element, + readOptions.maxChars, + maxNodes, + ); + const uncapped: string = sanitizeText(extracted.raw, { + maxChars: Number.MAX_SAFE_INTEGER, + }); + const text: string = sanitizeText(extracted.raw, { + maxChars: readOptions.maxChars, + ellipsis: true, + }); + const truncated: boolean = + extracted.stopped || uncapped.length > readOptions.maxChars; + + if (text.length === 0) { + return { + status: "empty", + text: "", + truncated: false, + visited: extracted.visited, + report: resolution.report, + }; + } + + return { + status: "read", + text, + truncated, + visited: extracted.visited, + report: resolution.report, + }; + }; + + /** + * Release one element's registration and forget it. + * + * Split out so the cleanup a caller holds and the bare-`null` detach path + * below cannot drift onto different bookkeeping. + */ + const releaseRefElement = ( + key: string, + element: HTMLElement, + held: Map void>, + ): void => { + const release: (() => void) | undefined = held.get(element); + if (release === undefined) { + return; + } + held.delete(element); + if (held.size === 0) { + refReleases.delete(key); + } + release(); + }; + + const ref = (key: string, refOpts?: AnchorOptions): AnchorRef => { + if (refOpts !== undefined) { + refOptions.set(key, refOpts); + } + const existing: AnchorRef | undefined = refCallbacks.get(key); + if (existing !== undefined) { + return existing; + } + + // **One callback identity per key, many live elements under it.** The + // identity is what makes `ref(key)` safe in JSX without memoisation, and + // it is also why the callback cannot hold a single release: every JSX + // site using this key calls the same function, so a lone slot made the + // second mounted node evict the first. + const callback: AnchorRef = ( + element: HTMLElement | null, + ): (() => void) | void => { + let held: Map void> | undefined = refReleases.get(key); + + // **Detach with no element is React 18's shape, and it is lossy.** The + // caller does not say WHICH node left, so prefer the ones the document + // can no longer reach; a disconnected registration never wins `resolve` + // anyway, so dropping them is free. Only when none is disconnected does + // this fall back to newest-first. React 19 callers never reach here — + // they get the cleanup returned below, which names its own element. + if (element === null) { + if (held === undefined || held.size === 0) { + return undefined; + } + const disconnected: HTMLElement[] = [...held.keys()].filter( + (candidate: HTMLElement): boolean => !candidate.isConnected, + ); + if (disconnected.length > 0) { + for (const stale of disconnected) { + releaseRefElement(key, stale, held); + } + return undefined; + } + const newest: HTMLElement | undefined = [...held.keys()].at(-1); + if (newest !== undefined) { + releaseRefElement(key, newest, held); + } + return undefined; + } + + if (held?.has(element) === true) { + const settled: Map void> = held; + return (): void => { + releaseRefElement(key, element, settled); + }; + } + + if (held === undefined) { + held = new Map void>(); + refReleases.set(key, held); + } + const bound: Map void> = held; + bound.set(element, register(key, element, refOptions.get(key))); + return (): void => { + releaseRefElement(key, element, bound); + }; + }; + refCallbacks.set(key, callback); + return callback; + }; + + const action = (key: string, actionOptions?: AnchorOptions): AnchorAction => { + return (node: HTMLElement): { destroy: () => void } => { + const destroy: () => void = register(key, node, actionOptions); + return { destroy }; + }; + }; + + const keys = (): readonly string[] => + Object.freeze([...byKey.keys()].sort()); + + const clear = (): void => { + for (const key of [...pendingFrames.keys()]) { + cancelFrame(key); + } + for (const key of [...pendingMarks.keys()]) { + cancelMark(key); + } + byKey.clear(); + // The ref bookkeeping is dropped with the registrations it describes. + // Left behind it would grow for the registry's lifetime and hand out + // cleanups closing over releases that can no longer find their bucket. + refReleases.clear(); + refCallbacks.clear(); + refOptions.clear(); + }; + + const registry: AnchorRegistry = { + id, + ref, + action, + register, + resolve, + reveal, + readUntrusted, + keys, + clear, + }; + + return Object.freeze(registry); +} diff --git a/packages/concierge-dom/src/types.ts b/packages/concierge-dom/src/types.ts new file mode 100644 index 0000000..0af2a49 --- /dev/null +++ b/packages/concierge-dom/src/types.ts @@ -0,0 +1,161 @@ +/** + * Public types for `@full-self-browsing/concierge-dom`. + * + * This package never finds an element. Every element that can come back out + * was supplied by the application at registration. Types therefore carry + * keys, reports, and statuses — not selectors, predicates, or coordinates. + */ + +import type { AbortSignalLike, Scheduler } from "@full-self-browsing/concierge"; + +/** The nearest self-or-ancestor attribute that removes an element from the UI. */ +export type HiddenByAttribute = "hidden" | "inert" | "aria-hidden"; + +/** The nearest self-or-ancestor computed style that removes it. */ +export type HiddenByStyle = "display" | "visibility" | "content-visibility"; + +/** + * A structured, policy-free report. Callers apply {@link isRendered} / + * {@link isReadable}, or their own rule. `hasLayout` is reported and is + * consulted by neither shipped policy. + */ +export interface VisibilityReport { + readonly connected: boolean; + readonly hiddenBy: HiddenByAttribute | null; + readonly styledOutBy: HiddenByStyle | null; + /** `aria-busy="true"` on the element or any ancestor. */ + readonly busy: boolean; + /** Environment-dependent. Always `false` under jsdom. */ + readonly hasLayout: boolean; + /** How many ancestors were walked. Bounded by {@link ANCESTOR_WALK_LIMIT}. */ + readonly depth: number; +} + +export interface AnchorOptions { + /** + * Permit text extraction of this element's subtree. Default false. + * Readability is declared at render time; it is never inferred from markup. + */ + readonly readable?: boolean | undefined; +} + +/** + * A ref callback that registers an element and hands back the cleanup for + * *that* element. + * + * Returning the cleanup is what makes a key holding several simultaneously + * mounted nodes exact: React 19 calls the returned function on unmount and + * never calls back with `null`, so each site releases the node it attached. + * React 18 ignores the return value and calls `ref(null)` instead, naming no + * element — see `createAnchorRegistry` for what that path can and cannot + * recover. The union keeps this assignable to React 18's `void`-returning + * `Ref` and to React 19's `RefCallback` alike. + */ +export type AnchorRef = ( + element: HTMLElement | null, +) => (() => void) | void; + +/** A Svelte `use:` action. Same registration, different calling convention. */ +export type AnchorAction = (node: HTMLElement) => { destroy: () => void }; + +export interface ResolveOptions { + /** + * Prefer a registration contained by this element. The application passes + * an element it already registered (an open dialog, a sheet). No selector. + */ + readonly within?: HTMLElement | null | undefined; +} + +export type ResolveStatus = + | "rendered" + | "not-rendered" + | "not-registered"; + +export interface AnchorResolution { + readonly status: ResolveStatus; + readonly element: HTMLElement | null; + readonly report: VisibilityReport | null; + readonly registered: number; +} + +export interface RevealOptions extends ResolveOptions { + readonly defer?: "none" | "frame" | undefined; + readonly block?: "start" | "center" | "end" | "nearest" | undefined; + readonly offsetTop?: number | undefined; + readonly behavior?: "auto" | "smooth" | undefined; + readonly markMs?: number | undefined; + readonly signal?: AbortSignalLike | undefined; +} + +export type RevealStatus = ResolveStatus | "revealed" | "aborted"; + +export interface RevealOutcome { + readonly status: RevealStatus; + readonly element: HTMLElement | null; + readonly report: VisibilityReport | null; +} + +export interface ReadOptions extends ResolveOptions { + readonly maxChars: number; + readonly refuseWhileBusy?: boolean | undefined; + readonly maxNodes?: number | undefined; +} + +export type ReadStatus = + | "read" + | "not-registered" + | "not-readable" + | "not-rendered" + | "busy" + | "empty"; + +/** + * Extracted application-rendered prose. `text` is untrusted: a CMS editor, + * supplier feed, or review author can put anything in the tree, including + * prompt-injection. An action that returns it must declare `readsUntrusted`. + * This value is not a consent artifact and does not prove a human saw it. + */ +export interface ReadOutcome { + readonly status: ReadStatus; + readonly text: string; + readonly truncated: boolean; + readonly visited: number; + readonly report: VisibilityReport | null; +} + +export interface AnchorRegistryOptions { + readonly id?: string | undefined; + readonly scheduler?: Scheduler | undefined; + readonly frame?: ((fn: () => void) => () => void) | undefined; + readonly frameFallbackMs?: number | undefined; +} + +export interface AnchorRegistry { + readonly id: string; + ref: (key: string, options?: AnchorOptions) => AnchorRef; + action: (key: string, options?: AnchorOptions) => AnchorAction; + register: ( + key: string, + element: HTMLElement, + options?: AnchorOptions, + ) => () => void; + resolve: (key: string, options?: ResolveOptions) => AnchorResolution; + reveal: (key: string, options?: RevealOptions) => Promise; + readUntrusted: (key: string, options: ReadOptions) => ReadOutcome; + keys: () => readonly string[]; + clear: () => void; +} + +export type ViewportDirection = "up" | "down" | "top" | "bottom"; + +export interface ViewportScrollOptions { + readonly step?: number | undefined; + readonly behavior?: "auto" | "smooth" | undefined; +} + +export interface ViewportPosition { + readonly scrollTop: number; + readonly maxScrollTop: number; + readonly atTop: boolean; + readonly atBottom: boolean; +} diff --git a/packages/concierge-dom/src/viewport.ts b/packages/concierge-dom/src/viewport.ts new file mode 100644 index 0000000..17c5e07 --- /dev/null +++ b/packages/concierge-dom/src/viewport.ts @@ -0,0 +1,95 @@ +/** + * Page-level viewport helpers. + * + * These are the only exports with no registry gate: they reach no element + * and read no subtree back. They do throw if invoked where `window` and + * `document` do not exist — that is the documented failure, not a silent + * no-op. On a page with scroll-triggered loading, scrolling causes the + * application to fetch; that is the application's own gesture. + */ + +import type { + ViewportDirection, + ViewportPosition, + ViewportScrollOptions, +} from "./types.js"; + +const DEFAULT_STEP: number = 0.85; +const REDUCE_MOTION_QUERY: string = "(prefers-reduced-motion: reduce)"; + +/** + * Re-read per call: the preference can change mid-session. + * + * **Unknown resolves to `"auto"`, not `"smooth"`.** This is the default for + * every `reveal()` and `scrollViewport()` that omits `behavior`, so a host + * that cannot report the reduced-motion preference — no `matchMedia`, or one + * that throws — must not be animated on the assumption that it is fine. The + * preference is only honoured in the affirmative. + */ +export function preferredScrollBehavior(): "auto" | "smooth" { + const matchMedia: typeof globalThis.matchMedia | undefined = + globalThis.matchMedia; + if (typeof matchMedia !== "function") { + return "auto"; + } + try { + return matchMedia(REDUCE_MOTION_QUERY).matches ? "auto" : "smooth"; + } catch { + return "auto"; + } +} + +export function readViewportPosition(): ViewportPosition { + const root: HTMLElement = document.documentElement; + const scrollTop: number = window.scrollY; + const maxScrollTop: number = Math.max(0, root.scrollHeight - root.clientHeight); + return { + scrollTop, + maxScrollTop, + atTop: scrollTop <= 0, + atBottom: scrollTop >= maxScrollTop, + }; +} + +/** + * Page-level scroll. Returns the clamped target position, not the settled + * position — a smooth scroll has not finished when this returns. + */ +export function scrollViewport( + direction: ViewportDirection, + options: ViewportScrollOptions = {}, +): ViewportPosition { + const current: ViewportPosition = readViewportPosition(); + const step: number = options.step ?? DEFAULT_STEP; + const behavior: ScrollBehavior = options.behavior ?? preferredScrollBehavior(); + const page: number = + (window.innerHeight || document.documentElement.clientHeight) * step; + + let target: number = current.scrollTop; + switch (direction) { + case "up": + target = current.scrollTop - page; + break; + case "down": + target = current.scrollTop + page; + break; + case "top": + target = 0; + break; + case "bottom": + target = current.maxScrollTop; + break; + } + + const scrollTop: number = Math.min( + current.maxScrollTop, + Math.max(0, target), + ); + window.scrollTo({ top: scrollTop, behavior }); + return { + scrollTop, + maxScrollTop: current.maxScrollTop, + atTop: scrollTop <= 0, + atBottom: scrollTop >= current.maxScrollTop, + }; +} diff --git a/packages/concierge-dom/src/visibility.ts b/packages/concierge-dom/src/visibility.ts new file mode 100644 index 0000000..e6b1893 --- /dev/null +++ b/packages/concierge-dom/src/visibility.ts @@ -0,0 +1,104 @@ +/** + * One measurement and two named policies. + * + * Walking uses `parentElement` and attribute/style facts only. Selector + * helpers are out of bounds for this package: the catalog-boundary script + * rejects them in the built artifact. `hasLayout` is reported and is + * ignored by both shipped policies so jsdom tests exercise the real path. + */ + +import { ANCESTOR_WALK_LIMIT } from "./constants.js"; +import type { + HiddenByAttribute, + HiddenByStyle, + VisibilityReport, +} from "./types.js"; + +function hidingAttribute(element: Element): HiddenByAttribute | null { + if (element.hasAttribute("hidden")) { + return "hidden"; + } + if (element.hasAttribute("inert")) { + return "inert"; + } + if (element.getAttribute("aria-hidden") === "true") { + return "aria-hidden"; + } + return null; +} + +function hidingStyle(element: Element): HiddenByStyle | null { + const style: CSSStyleDeclaration = getComputedStyle(element); + if (style.display === "none") { + return "display"; + } + if (style.visibility === "hidden" || style.visibility === "collapse") { + return "visibility"; + } + if (style.getPropertyValue("content-visibility") === "hidden") { + return "content-visibility"; + } + return null; +} + +function isBusy(element: Element): boolean { + return element.getAttribute("aria-busy") === "true"; +} + +/** + * Walk the element and its ancestors. `getComputedStyle` runs once per + * visited node. The walk stops at the first hiding attribute or style, and + * never exceeds {@link ANCESTOR_WALK_LIMIT} hops. + */ +export function measureVisibility(element: HTMLElement): VisibilityReport { + let hiddenBy: HiddenByAttribute | null = null; + let styledOutBy: HiddenByStyle | null = null; + let busy: boolean = false; + let depth: number = 0; + let current: Element | null = element; + + while (current !== null) { + if (hiddenBy === null) { + hiddenBy = hidingAttribute(current); + } + if (styledOutBy === null) { + styledOutBy = hidingStyle(current); + } + if (!busy) { + busy = isBusy(current); + } + if (hiddenBy !== null || styledOutBy !== null) { + break; + } + if (depth >= ANCESTOR_WALK_LIMIT) { + break; + } + const parent: HTMLElement | null = current.parentElement; + if (parent === null) { + break; + } + current = parent; + depth += 1; + } + + return { + connected: element.isConnected, + hiddenBy, + styledOutBy, + busy, + hasLayout: element.getClientRects().length > 0, + depth, + }; +} + +/** Connected, no hiding attribute, no hiding style. Ignores busy and layout. */ +export function isRendered(report: VisibilityReport): boolean { + return ( + report.connected && report.hiddenBy === null && report.styledOutBy === null + ); +} + +/** {@link isRendered} and not `aria-busy`. The skeleton-readback gate. */ +export function isReadable(report: VisibilityReport): boolean { + return isRendered(report) && !report.busy; +} diff --git a/packages/concierge-dom/test/catalog-boundary.test.ts b/packages/concierge-dom/test/catalog-boundary.test.ts new file mode 100644 index 0000000..2987880 --- /dev/null +++ b/packages/concierge-dom/test/catalog-boundary.test.ts @@ -0,0 +1,47 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { beforeAll, describe, expect, it } from "vitest"; + +import { + BANNED_CLASSES, + assertCatalogBoundary, + findBannedIdentifiers, +} from "../../../scripts/pkg-dom-catalog-boundary.mjs"; + +const JS_PATH = fileURLToPath(new URL("../dist/index.js", import.meta.url)); + +let artifact: string = ""; + +beforeAll(() => { + if (!existsSync(JS_PATH)) { + throw new Error( + "packages/concierge-dom/dist/index.js is missing. Build the package first.", + ); + } + artifact = readFileSync(JS_PATH, "utf8"); +}); + +describe("the built concierge-dom catalog boundary", () => { + it("contains no banned host-DOM identifiers", () => { + expect(findBannedIdentifiers(artifact)).toEqual([]); + expect(() => assertCatalogBoundary(artifact)).not.toThrow(); + }); + + it("goes red for each banned identifier class when that class is injected", () => { + expect(BANNED_CLASSES.length).toBeGreaterThan(0); + for (const banned of BANNED_CLASSES) { + const findings = findBannedIdentifiers( + `${artifact}\n${banned.representative}\n`, + ); + expect( + findings.some((finding) => finding.classId === banned.id), + `class ${banned.id} stayed green after injecting ${banned.representative}`, + ).toBe(true); + } + }); + + it("keeps core as an external import rather than bundling it", () => { + expect(artifact).toContain("@full-self-browsing/concierge"); + }); +}); diff --git a/packages/concierge-dom/test/contract.test.ts b/packages/concierge-dom/test/contract.test.ts new file mode 100644 index 0000000..43d5f40 --- /dev/null +++ b/packages/concierge-dom/test/contract.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@full-self-browsing/concierge", async (importOriginal) => { + const actual: typeof import("@full-self-browsing/concierge") = + await importOriginal(); + return { + ...actual, + CONTRACT_VERSION: 3, + }; +}); + +import { createAnchorRegistry } from "../src/index.js"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("core contract mismatch", () => { + it("throws on the first registration and stores nothing", () => { + const anchors = createAnchorRegistry({ id: "mismatch" }); + const element: HTMLElement = document.createElement("div"); + document.body.appendChild(element); + + expect(() => anchors.register("deal", element)).toThrow( + /@full-self-browsing\/concierge-dom expected core contract v4[\s\S]*found v3[\s\S]*upgrade or reinstall/, + ); + expect(anchors.resolve("deal").status).toBe("not-registered"); + expect(anchors.keys()).toEqual([]); + }); +}); diff --git a/packages/concierge-dom/test/export-surface.test.ts b/packages/concierge-dom/test/export-surface.test.ts new file mode 100644 index 0000000..011d133 --- /dev/null +++ b/packages/concierge-dom/test/export-surface.test.ts @@ -0,0 +1,99 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { beforeAll, describe, expect, it } from "vitest"; + +const DTS_URL = new URL("../dist/index.d.ts", import.meta.url); +const DTS_PATH = fileURLToPath(DTS_URL); +const JS_URL = new URL("../dist/index.js", import.meta.url); +const JS_PATH = fileURLToPath(JS_URL); + +const EXPORT_BLOCK = /^export\s*\{([^}]*)\}\s*;?\s*$/gm; + +interface Surface { + readonly names: readonly string[]; + readonly values: readonly string[]; + readonly types: readonly string[]; +} + +function readSurface(): Surface { + const source = readFileSync(DTS_PATH, "utf8"); + const blocks = [...source.matchAll(EXPORT_BLOCK)]; + if (blocks.length === 0) { + throw new Error( + "no trailing `export { … };` statement found in dist/index.d.ts", + ); + } + + const entries = blocks + .flatMap((block) => (block[1] ?? "").split(",")) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + return { + names: entries.map((entry) => entry.replace(/^type\s+/, "")), + values: entries.filter((entry) => !/^type\s/.test(entry)), + types: entries + .filter((entry) => /^type\s/.test(entry)) + .map((entry) => entry.replace(/^type\s+/, "")), + }; +} + +const VALUE_EXPORTS = [ + "ANCESTOR_WALK_LIMIT", + "EXPECTED_CORE_CONTRACT_VERSION", + "REVEAL_ATTRIBUTE", + "createAnchorRegistry", + "isReadable", + "isRendered", + "measureVisibility", + "preferredScrollBehavior", + "readViewportPosition", + "scrollViewport", +] as const; + +beforeAll(() => { + if (!existsSync(DTS_PATH) || !existsSync(JS_PATH)) { + throw new Error( + "packages/concierge-dom/dist is missing. This guard reads the BUILT " + + "declaration file, not the source. Run `pnpm build` first.", + ); + } +}); + +describe("the published export surface of dist/index.d.ts", () => { + it("is exactly 30 names — an export added or dropped by a build-config change lands here", () => { + const { names } = readSurface(); + expect(names).toHaveLength(30); + }); + + it("splits 20 types to 10 values", () => { + const { types, values } = readSurface(); + expect(types).toHaveLength(20); + expect(values).toHaveLength(10); + }); + + it("carries all ten runtime value exports by name", () => { + const { values } = readSurface(); + for (const name of VALUE_EXPORTS) { + expect(values).toContain(name); + } + }); + + it("does not publish PACKAGE, REVEAL_DATASET_KEY, or sanitizeText", () => { + const { names } = readSurface(); + expect(names).not.toContain("PACKAGE"); + expect(names).not.toContain("REVEAL_DATASET_KEY"); + expect(names).not.toContain("sanitizeText"); + }); + + it("pins the contract literal at 4 in the built artifact", () => { + const source = readFileSync(JS_PATH, "utf8"); + expect(source).toMatch(/EXPECTED_CORE_CONTRACT_VERSION\s*=\s*4\b/u); + expect(source).toContain("assertSingleInstance()"); + expect(source).toMatch(/CONTRACT_VERSION\s*!==\s*4\b/u); + expect(source).toContain( + "@full-self-browsing/concierge-dom expected core contract v4", + ); + }); +}); diff --git a/packages/concierge-dom/test/harness.ts b/packages/concierge-dom/test/harness.ts new file mode 100644 index 0000000..e9c7f13 --- /dev/null +++ b/packages/concierge-dom/test/harness.ts @@ -0,0 +1,25 @@ +import { createAnchorRegistry } from "../src/index.js"; +import type { AnchorRegistry, AnchorRegistryOptions } from "../src/index.js"; + +export function mount( + tag: string = "div", + text: string = "", +): HTMLElement { + const element: HTMLElement = document.createElement(tag); + if (text.length > 0) { + element.textContent = text; + } + document.body.appendChild(element); + return element; +} + +export function registry(options: AnchorRegistryOptions = {}): AnchorRegistry { + return createAnchorRegistry({ id: "test", ...options }); +} + +export function styleSheet(css: string): HTMLStyleElement { + const style: HTMLStyleElement = document.createElement("style"); + style.textContent = css; + document.head.appendChild(style); + return style; +} diff --git a/packages/concierge-dom/test/read-untrusted.test.ts b/packages/concierge-dom/test/read-untrusted.test.ts new file mode 100644 index 0000000..61d3748 --- /dev/null +++ b/packages/concierge-dom/test/read-untrusted.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { mount, registry, styleSheet } from "./harness.js"; + +afterEach(() => { + document.body.replaceChildren(); + document.head.replaceChildren(); +}); + +describe("AnchorRegistry.readUntrusted", () => { + it("returns not-registered before any other gate", () => { + const anchors = registry(); + expect(anchors.readUntrusted("notes", { maxChars: 80 })).toEqual({ + status: "not-registered", + text: "", + truncated: false, + visited: 0, + report: null, + }); + }); + + it("returns not-readable before visibility when no registration is declared readable", () => { + const anchors = registry(); + const hidden: HTMLElement = mount("section", "secret"); + hidden.style.display = "none"; + anchors.register("billing", hidden); + + const outcome = anchors.readUntrusted("billing", { maxChars: 80 }); + expect(outcome.status).toBe("not-readable"); + expect(outcome.text).toBe(""); + expect(outcome.visited).toBe(0); + expect(outcome.report?.styledOutBy).toBe("display"); + }); + + it("returns not-rendered for a readable key whose survivors are hidden", () => { + const anchors = registry(); + const hidden: HTMLElement = mount("section", "notes"); + hidden.setAttribute("hidden", ""); + anchors.register("notes", hidden, { readable: true }); + + const outcome = anchors.readUntrusted("notes", { maxChars: 80 }); + expect(outcome.status).toBe("not-rendered"); + expect(outcome.text).toBe(""); + }); + + it("returns busy when the winner or an ancestor is aria-busy", () => { + const anchors = registry(); + const surface: HTMLElement = mount(); + surface.setAttribute("aria-busy", "true"); + const summary: HTMLElement = document.createElement("section"); + summary.textContent = "$12.00"; + surface.appendChild(summary); + anchors.register("cart-summary", summary, { readable: true }); + + expect(anchors.readUntrusted("cart-summary", { maxChars: 80 }).status).toBe( + "busy", + ); + expect( + anchors.readUntrusted("cart-summary", { + maxChars: 80, + refuseWhileBusy: false, + }).status, + ).toBe("read"); + }); + + it("reads live visible text, skips banned tagNames, and sanitizes with ellipsis", () => { + styleSheet(".dup { display: none; }"); + const anchors = registry(); + const root: HTMLElement = mount("section"); + const visible: HTMLElement = document.createElement("p"); + visible.textContent = "visible copy"; + const hiddenDup: HTMLElement = document.createElement("p"); + hiddenDup.className = "dup"; + hiddenDup.textContent = "hidden duplicate that a clone would include"; + const script: HTMLScriptElement = document.createElement("script"); + script.type = "application/json"; + script.textContent = "{\"injected\":true}"; + const svg: SVGSVGElement = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + const label: SVGTextElement = document.createElementNS( + "http://www.w3.org/2000/svg", + "text", + ); + label.textContent = "icon label"; + svg.appendChild(label); + root.append(visible, hiddenDup, script, svg); + anchors.register("notes", root, { readable: true }); + + const outcome = anchors.readUntrusted("notes", { maxChars: 80 }); + expect(outcome.status).toBe("read"); + expect(outcome.text).toBe("visible copy"); + expect(outcome.text).not.toContain("hidden duplicate"); + expect(outcome.text).not.toContain("injected"); + expect(outcome.text).not.toContain("icon label"); + expect(outcome.truncated).toBe(false); + expect(outcome.visited).toBeGreaterThan(0); + }); + + it("returns empty when every visible text node is whitespace", () => { + const anchors = registry(); + const root: HTMLElement = mount("section", " "); + anchors.register("notes", root, { readable: true }); + expect(anchors.readUntrusted("notes", { maxChars: 40 }).status).toBe("empty"); + }); + + it("bounds with sanitizeText ellipsis and reports truncated", () => { + const anchors = registry(); + const root: HTMLElement = mount("section", "abcdefghijklmnopqrstuvwxyz"); + anchors.register("notes", root, { readable: true }); + + const outcome = anchors.readUntrusted("notes", { maxChars: 8 }); + expect(outcome.status).toBe("read"); + expect(outcome.text.endsWith("…")).toBe(true); + expect(outcome.text.length).toBeLessThanOrEqual(8); + expect(outcome.truncated).toBe(true); + }); + + it("truncates astral text without stranding a surrogate", () => { + const anchors = registry(); + const root: HTMLElement = mount("section", "\u{1F600}".repeat(6)); + anchors.register("notes", root, { readable: true }); + + const outcome = anchors.readUntrusted("notes", { maxChars: 11 }); + expect(outcome.status).toBe("read"); + expect(outcome.text.endsWith("…")).toBe(true); + expect(outcome.text.length).toBeLessThanOrEqual(11); + expect(outcome.text.isWellFormed()).toBe(true); + }); + + it("stops the live walk at maxNodes", () => { + const anchors = registry(); + const root: HTMLElement = mount("section"); + for (let index = 0; index < 8; index += 1) { + const child: HTMLElement = document.createElement("span"); + child.textContent = `n${index}`; + root.appendChild(child); + } + anchors.register("notes", root, { readable: true }); + + const outcome = anchors.readUntrusted("notes", { maxChars: 200, maxNodes: 3 }); + expect(outcome.visited).toBe(3); + expect(outcome.truncated).toBe(true); + }); + + it("skips style, noscript, template, iframe, and object by tagName", () => { + const anchors = registry(); + const root: HTMLElement = mount("section"); + const visible: HTMLElement = document.createElement("p"); + visible.textContent = "keep"; + const style: HTMLStyleElement = document.createElement("style"); + style.textContent = ".x{color:red}"; + const noscript: HTMLElement = document.createElement("noscript"); + noscript.textContent = "no script copy"; + const template: HTMLTemplateElement = document.createElement("template"); + const stamped: HTMLElement = document.createElement("p"); + stamped.textContent = "template copy"; + template.content.appendChild(stamped); + const iframe: HTMLIFrameElement = document.createElement("iframe"); + const object: HTMLObjectElement = document.createElement("object"); + object.textContent = "object copy"; + root.append(visible, style, noscript, template, iframe, object); + anchors.register("notes", root, { readable: true }); + + const outcome = anchors.readUntrusted("notes", { maxChars: 80 }); + expect(outcome.status).toBe("read"); + expect(outcome.text).toBe("keep"); + expect(outcome.text).not.toContain("no script"); + expect(outcome.text).not.toContain("template copy"); + expect(outcome.text).not.toContain("object copy"); + }); + + it("prefers a readable survivor contained by within", () => { + const anchors = registry(); + const list: HTMLElement = mount("section", "board copy"); + const panel: HTMLElement = mount("div"); + const notes: HTMLElement = document.createElement("section"); + notes.textContent = "panel copy"; + panel.appendChild(notes); + anchors.register("notes", list, { readable: true }); + anchors.register("notes", notes, { readable: true }); + + const unconstrained = anchors.readUntrusted("notes", { maxChars: 80 }); + expect(unconstrained.text).toBe("board copy"); + + const constrained = anchors.readUntrusted("notes", { + maxChars: 80, + within: panel, + }); + expect(constrained.status).toBe("read"); + expect(constrained.text).toBe("panel copy"); + }); + + it("does not read a rendered sibling that was not declared readable", () => { + const anchors = registry(); + const secret: HTMLElement = mount("section", "credit limit"); + const notes: HTMLElement = mount("section", "call the buyer"); + notes.style.display = "none"; + anchors.register("deal", secret); + anchors.register("deal", notes, { readable: true }); + + const outcome = anchors.readUntrusted("deal", { maxChars: 80 }); + expect(outcome.status).toBe("not-rendered"); + expect(outcome.text).toBe(""); + }); +}); diff --git a/packages/concierge-dom/test/registry.test.ts b/packages/concierge-dom/test/registry.test.ts new file mode 100644 index 0000000..1bc776a --- /dev/null +++ b/packages/concierge-dom/test/registry.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as core from "@full-self-browsing/concierge"; + +import { createAnchorRegistry } from "../src/index.js"; +import { mount, registry } from "./harness.js"; + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe("createAnchorRegistry", () => { + it("constructs a frozen capability without calling assertSingleInstance", () => { + const spy = vi.spyOn(core, "assertSingleInstance"); + const anchors = createAnchorRegistry({ id: "pipeline" }); + + expect(anchors.id).toBe("pipeline"); + expect(Object.isFrozen(anchors)).toBe(true); + expect(spy).not.toHaveBeenCalled(); + expect(() => { + (anchors as { resolve: unknown }).resolve = (): void => undefined; + }).toThrow(TypeError); + }); + + it("calls assertSingleInstance on the first registration only", () => { + const spy = vi.spyOn(core, "assertSingleInstance"); + const anchors = registry(); + const first: HTMLElement = mount(); + const second: HTMLElement = mount(); + + const releaseFirst = anchors.register("deal-1", first); + expect(spy).toHaveBeenCalledTimes(1); + anchors.register("deal-2", second); + expect(spy).toHaveBeenCalledTimes(1); + releaseFirst(); + anchors.register("deal-1", first); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("holds a set of registrations per key and unregisters only the matching token", () => { + const anchors = registry(); + const first: HTMLElement = mount("div", "one"); + const second: HTMLElement = mount("div", "two"); + + const releaseFirst = anchors.register("deal", first); + const releaseSecond = anchors.register("deal", second); + expect(anchors.resolve("deal").registered).toBe(2); + expect(anchors.resolve("deal").element).toBe(first); + + releaseFirst(); + expect(anchors.resolve("deal").registered).toBe(1); + expect(anchors.resolve("deal").element).toBe(second); + + releaseFirst(); + expect(anchors.resolve("deal").registered).toBe(1); + + const reregistered = anchors.register("deal", first); + releaseSecond(); + expect(anchors.resolve("deal").element).toBe(first); + reregistered(); + expect(anchors.resolve("deal").status).toBe("not-registered"); + }); + + it("refuses a stale cleanup when the same element is registered again", () => { + const anchors = registry(); + const element: HTMLElement = mount(); + const stale = anchors.register("deal", element); + const live = anchors.register("deal", element); + + stale(); + expect(anchors.resolve("deal").registered).toBe(1); + expect(anchors.resolve("deal").element).toBe(element); + live(); + expect(anchors.resolve("deal").status).toBe("not-registered"); + }); + + it("returns the same ref callback for a key and overwrites options for later registrations", () => { + const anchors = registry(); + const first = anchors.ref("notes"); + const second = anchors.ref("notes", { readable: true }); + expect(first).toBe(second); + + const element: HTMLElement = mount("section", "notes"); + first(element); + expect(anchors.readUntrusted("notes", { maxChars: 80 }).status).toBe("read"); + + const other = anchors.ref("panel"); + expect(other).not.toBe(first); + other(element); + expect(anchors.readUntrusted("panel", { maxChars: 80 }).status).toBe( + "not-readable", + ); + + first(null); + expect(anchors.resolve("notes").status).toBe("not-registered"); + }); + + it("holds both nodes when one key is mounted twice through ref", () => { + const anchors = registry(); + const mobile: HTMLElement = mount("article", "mobile"); + const desktop: HTMLElement = mount("article", "desktop"); + const attach = anchors.ref("deal-1"); + + attach(mobile); + attach(desktop); + + expect(anchors.resolve("deal-1").registered).toBe(2); + expect(anchors.resolve("deal-1").element).toBe(mobile); + }); + + it("returns a cleanup that releases only the element it attached", () => { + const anchors = registry(); + const first: HTMLElement = mount("article", "one"); + const second: HTMLElement = mount("article", "two"); + const attach = anchors.ref("deal"); + + const releaseFirst = attach(first); + attach(second); + expect(typeof releaseFirst).toBe("function"); + + (releaseFirst as () => void)(); + expect(anchors.resolve("deal").registered).toBe(1); + expect(anchors.resolve("deal").element).toBe(second); + }); + + it("re-attaching the same element does not double-register it", () => { + const anchors = registry(); + const element: HTMLElement = mount(); + const attach = anchors.ref("deal"); + + attach(element); + attach(element); + + expect(anchors.resolve("deal").registered).toBe(1); + }); + + it("a bare null detach drops disconnected nodes before live ones", () => { + const anchors = registry(); + const removed: HTMLElement = mount("article", "gone"); + const live: HTMLElement = mount("article", "here"); + const attach = anchors.ref("deal"); + + attach(removed); + attach(live); + removed.remove(); + + attach(null); + expect(anchors.resolve("deal").registered).toBe(1); + expect(anchors.resolve("deal").element).toBe(live); + + // Nothing is disconnected now, so the fallback releases newest-first. + attach(null); + expect(anchors.resolve("deal").status).toBe("not-registered"); + }); + + it("clear drops the ref bookkeeping, not just the registrations", () => { + const anchors = registry(); + const element: HTMLElement = mount(); + const before = anchors.ref("deal", { readable: true }); + before(element); + + anchors.clear(); + + const after = anchors.ref("deal"); + expect(after).not.toBe(before); + after(mount()); + expect(anchors.resolve("deal").registered).toBe(1); + // Options were cleared with the callback, so readability is back to false. + expect(anchors.readUntrusted("deal", { maxChars: 40 }).status).toBe( + "not-readable", + ); + }); + + it("action registers on the node and destroy unregisters that token", () => { + const anchors = registry(); + const node: HTMLElement = mount("li", "line"); + const applied = anchors.action("line:1")(node); + expect(anchors.resolve("line:1").element).toBe(node); + applied.destroy(); + expect(anchors.resolve("line:1").status).toBe("not-registered"); + }); + + it("keys returns a sorted frozen snapshot of live keys", () => { + const anchors = registry(); + anchors.register("zeta", mount()); + anchors.register("alpha", mount()); + const keys = anchors.keys(); + expect(keys).toEqual(["alpha", "zeta"]); + expect(Object.isFrozen(keys)).toBe(true); + }); + + it("clear drops every registration and cancels pending marks", async () => { + const cancels: string[] = []; + const anchors = registry({ + scheduler: (fn, _delayMs) => { + return (): void => { + cancels.push("mark"); + void fn; + }; + }, + }); + const element: HTMLElement = mount(); + anchors.register("deal", element); + await anchors.reveal("deal", { markMs: 50, behavior: "auto" }); + expect(element.dataset.conciergeReveal).toBe("true"); + + anchors.clear(); + expect(anchors.keys()).toEqual([]); + expect(anchors.resolve("deal").status).toBe("not-registered"); + expect(element.dataset.conciergeReveal).toBeUndefined(); + expect(cancels).toEqual(["mark"]); + }); +}); diff --git a/packages/concierge-dom/test/resolve.test.ts b/packages/concierge-dom/test/resolve.test.ts new file mode 100644 index 0000000..5895bba --- /dev/null +++ b/packages/concierge-dom/test/resolve.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { mount, registry } from "./harness.js"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("AnchorRegistry.resolve", () => { + it("returns not-registered when the key was never live", () => { + const anchors = registry(); + expect(anchors.resolve("missing")).toEqual({ + status: "not-registered", + element: null, + report: null, + registered: 0, + }); + }); + + it("returns the first isRendered survivor in registration order", () => { + const anchors = registry(); + const hidden: HTMLElement = mount(); + hidden.style.display = "none"; + const firstVisible: HTMLElement = mount("div", "board"); + const secondVisible: HTMLElement = mount("div", "sheet"); + + anchors.register("deal-4417", hidden); + anchors.register("deal-4417", firstVisible); + anchors.register("deal-4417", secondVisible); + + const found = anchors.resolve("deal-4417"); + expect(found.status).toBe("rendered"); + expect(found.element).toBe(firstVisible); + expect(found.registered).toBe(3); + expect(found.report?.connected).toBe(true); + }); + + it("does not fall back to a hidden candidate", () => { + const anchors = registry(); + const hidden: HTMLElement = mount(); + hidden.setAttribute("hidden", ""); + anchors.register("deal", hidden); + + const found = anchors.resolve("deal"); + expect(found.status).toBe("not-rendered"); + expect(found.element).toBeNull(); + expect(found.registered).toBe(1); + expect(found.report?.hiddenBy).toBe("hidden"); + }); + + it("prefers the first survivor contained by a connected within element", () => { + const anchors = registry(); + const list: HTMLElement = mount("div", "list"); + const panel: HTMLElement = mount("div"); + const inPanel: HTMLElement = document.createElement("div"); + inPanel.textContent = "detail"; + panel.appendChild(inPanel); + + anchors.register("deal", list); + anchors.register("deal", inPanel); + + const unconstrained = anchors.resolve("deal"); + expect(unconstrained.element).toBe(list); + + const constrained = anchors.resolve("deal", { within: panel }); + expect(constrained.element).toBe(inPanel); + expect(constrained.status).toBe("rendered"); + }); + + it("ignores a disconnected within and still returns the first survivor", () => { + const anchors = registry(); + const visible: HTMLElement = mount(); + const detached: HTMLElement = document.createElement("div"); + anchors.register("deal", visible); + + const found = anchors.resolve("deal", { within: detached }); + expect(found.element).toBe(visible); + }); + + it("falls back to the first survivor when within contains none of them", () => { + const anchors = registry(); + const visible: HTMLElement = mount(); + const other: HTMLElement = mount(); + anchors.register("deal", visible); + + const found = anchors.resolve("deal", { within: other }); + expect(found.element).toBe(visible); + }); +}); diff --git a/packages/concierge-dom/test/reveal.test.ts b/packages/concierge-dom/test/reveal.test.ts new file mode 100644 index 0000000..eea7e23 --- /dev/null +++ b/packages/concierge-dom/test/reveal.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { REVEAL_ATTRIBUTE } from "../src/index.js"; +import { mount, registry } from "./harness.js"; + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +function installScrollSpies(): { + readonly intoView: ReturnType; + readonly scrollTo: ReturnType; +} { + const intoView = vi.fn(); + const scrollTo = vi.fn(); + Element.prototype.scrollIntoView = intoView; + window.scrollTo = scrollTo as typeof window.scrollTo; + return { intoView, scrollTo }; +} + +describe("AnchorRegistry.reveal", () => { + it("exposes the reserved reveal attribute name", () => { + expect(REVEAL_ATTRIBUTE).toBe("data-concierge-reveal"); + }); + + it("scrolls the winner with scrollIntoView and default block center", async () => { + const { intoView, scrollTo } = installScrollSpies(); + const anchors = registry(); + const element: HTMLElement = mount(); + anchors.register("deal", element); + + const outcome = await anchors.reveal("deal", { behavior: "auto" }); + expect(outcome.status).toBe("revealed"); + expect(outcome.element).toBe(element); + expect(intoView).toHaveBeenCalledTimes(1); + expect(intoView).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("uses window.scrollTo when block is start and offsetTop is positive", async () => { + const { intoView, scrollTo } = installScrollSpies(); + const anchors = registry(); + const element: HTMLElement = mount(); + element.getBoundingClientRect = (): DOMRect => + ({ + top: 120, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 120, + toJSON: (): object => ({}), + }) as DOMRect; + Object.defineProperty(window, "scrollY", { value: 40, configurable: true }); + anchors.register("deal", element); + + const outcome = await anchors.reveal("deal", { + block: "start", + offsetTop: 64, + behavior: "auto", + }); + expect(outcome.status).toBe("revealed"); + expect(scrollTo).toHaveBeenCalledWith({ top: 96, behavior: "auto" }); + expect(intoView).not.toHaveBeenCalled(); + }); + + it("warns once per registry when offsetTop is used with a non-start block", async () => { + installScrollSpies(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const anchors = registry(); + const element: HTMLElement = mount(); + anchors.register("deal", element); + + await anchors.reveal("deal", { + block: "center", + offsetTop: 64, + behavior: "auto", + }); + await anchors.reveal("deal", { + block: "end", + offsetTop: 32, + behavior: "auto", + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("offsetTop"); + }); + + it("re-resolves with within before scrolling", async () => { + const { intoView } = installScrollSpies(); + const anchors = registry(); + const list: HTMLElement = mount("div", "list"); + const panel: HTMLElement = mount("div"); + const inPanel: HTMLElement = document.createElement("div"); + panel.appendChild(inPanel); + anchors.register("item", list); + anchors.register("item", inPanel); + + const outcome = await anchors.reveal("item", { + within: panel, + behavior: "auto", + }); + expect(outcome.status).toBe("revealed"); + expect(outcome.element).toBe(inPanel); + expect(intoView).toHaveBeenCalledTimes(1); + expect(intoView).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); + }); + + it("returns the resolve status without scrolling when nothing is rendered", async () => { + const { intoView, scrollTo } = installScrollSpies(); + const anchors = registry(); + const hidden: HTMLElement = mount(); + hidden.style.display = "none"; + anchors.register("deal", hidden); + + const missing = await anchors.reveal("missing"); + expect(missing.status).toBe("not-registered"); + const hiddenOutcome = await anchors.reveal("deal"); + expect(hiddenOutcome.status).toBe("not-rendered"); + expect(intoView).not.toHaveBeenCalled(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("defers one frame, then re-resolves before scrolling", async () => { + const { intoView } = installScrollSpies(); + const frames: Array<() => void> = []; + const anchors = registry({ + frame: (fn) => { + frames.push(fn); + return (): void => { + const index = frames.indexOf(fn); + if (index >= 0) { + frames.splice(index, 1); + } + }; + }, + scheduler: () => (): void => undefined, + }); + const element: HTMLElement = mount(); + const release = anchors.register("deal", element); + + const pending = anchors.reveal("deal", { defer: "frame", behavior: "auto" }); + expect(intoView).not.toHaveBeenCalled(); + void release; + element.remove(); + const frame = frames[0]; + expect(frame).toBeTypeOf("function"); + frame?.(); + const outcome = await pending; + expect(outcome.status).toBe("not-rendered"); + expect(intoView).not.toHaveBeenCalled(); + }); + + it("settles a deferred reveal from the scheduler fallback when no frame arrives", async () => { + const { intoView } = installScrollSpies(); + const timers: Array<() => void> = []; + const anchors = registry({ + frame: () => (): void => undefined, + scheduler: (fn) => { + timers.push(fn); + return (): void => undefined; + }, + frameFallbackMs: 100, + }); + const element: HTMLElement = mount(); + anchors.register("deal", element); + + const pending = anchors.reveal("deal", { defer: "frame", behavior: "auto" }); + expect(intoView).not.toHaveBeenCalled(); + timers[0]?.(); + const outcome = await pending; + expect(outcome.status).toBe("revealed"); + expect(intoView).toHaveBeenCalledTimes(1); + }); + + it("supersedes a pending frame and mark for the same key", async () => { + const { intoView } = installScrollSpies(); + const frames: Array<() => void> = []; + const anchors = registry({ + frame: (fn) => { + frames.push(fn); + return (): void => { + const index = frames.indexOf(fn); + if (index >= 0) { + frames.splice(index, 1); + } + }; + }, + scheduler: () => (): void => undefined, + }); + const first: HTMLElement = mount(); + const second: HTMLElement = mount(); + anchors.register("deal", first); + anchors.register("deal", second); + first.style.display = "none"; + + const firstReveal = anchors.reveal("deal", { defer: "frame", behavior: "auto" }); + expect(frames).toHaveLength(1); + const secondReveal = anchors.reveal("deal", { behavior: "auto", markMs: 25 }); + const firstOutcome = await firstReveal; + const secondOutcome = await secondReveal; + expect(firstOutcome.status).toBe("aborted"); + expect(secondOutcome.status).toBe("revealed"); + expect(secondOutcome.element).toBe(second); + expect(intoView).toHaveBeenCalledTimes(1); + expect(second.dataset.conciergeReveal).toBe("true"); + + const third = await anchors.reveal("deal", { markMs: 25 }); + expect(third.status).toBe("revealed"); + expect(second.dataset.conciergeReveal).toBe("true"); + }); + + it("returns aborted without scrolling when the signal is already aborted", async () => { + const { intoView } = installScrollSpies(); + const anchors = registry(); + const element: HTMLElement = mount(); + anchors.register("deal", element); + const controller = new AbortController(); + controller.abort(); + + const outcome = await anchors.reveal("deal", { signal: controller.signal }); + expect(outcome).toEqual({ + status: "aborted", + element: null, + report: null, + }); + expect(intoView).not.toHaveBeenCalled(); + }); + + it("aborts a pending deferred frame and never scrolls", async () => { + const { intoView } = installScrollSpies(); + const frames: Array<() => void> = []; + const anchors = registry({ + frame: (fn) => { + frames.push(fn); + return (): void => { + const index = frames.indexOf(fn); + if (index >= 0) { + frames.splice(index, 1); + } + }; + }, + scheduler: () => (): void => undefined, + }); + const element: HTMLElement = mount(); + anchors.register("deal", element); + const controller = new AbortController(); + + const pending = anchors.reveal("deal", { + defer: "frame", + signal: controller.signal, + }); + controller.abort(); + const outcome = await pending; + expect(outcome.status).toBe("aborted"); + expect(intoView).not.toHaveBeenCalled(); + frames[0]?.(); + expect(intoView).not.toHaveBeenCalled(); + }); + + it("returns an outcome when the injected frame fires synchronously", async () => { + installScrollSpies(); + const anchors = registry({ + frame: (fn) => { + fn(); + return (): void => undefined; + }, + scheduler: (fn, delayMs) => { + if (delayMs <= 0) { + fn(); + } + return (): void => undefined; + }, + }); + const element: HTMLElement = mount(); + anchors.register("deal", element); + + const outcome = await anchors.reveal("deal", { + defer: "frame", + behavior: "auto", + }); + expect(outcome.status).toBe("revealed"); + expect(outcome.element).toBe(element); + }); + + it("returns an outcome when the fallback scheduler fires synchronously", async () => { + installScrollSpies(); + const anchors = registry({ + frameFallbackMs: 0, + frame: () => (): void => undefined, + scheduler: (fn, delayMs) => { + if (delayMs <= 0) { + fn(); + } + return (): void => undefined; + }, + }); + const element: HTMLElement = mount(); + anchors.register("deal", element); + + const outcome = await anchors.reveal("deal", { + defer: "frame", + behavior: "auto", + }); + expect(outcome.status).toBe("revealed"); + }); + + it("sets the reserved reveal mark and removes it when the scheduler fires", async () => { + installScrollSpies(); + let expire: (() => void) | undefined; + const anchors = registry({ + scheduler: (fn) => { + expire = fn; + return (): void => undefined; + }, + }); + const element: HTMLElement = mount(); + anchors.register("deal", element); + + await anchors.reveal("deal", { markMs: 40, behavior: "auto" }); + expect(element.dataset.conciergeReveal).toBe("true"); + expect(element.getAttribute(REVEAL_ATTRIBUTE)).toBe("true"); + expire?.(); + expect(element.dataset.conciergeReveal).toBeUndefined(); + }); +}); diff --git a/packages/concierge-dom/test/ssr.test.ts b/packages/concierge-dom/test/ssr.test.ts new file mode 100644 index 0000000..292745b --- /dev/null +++ b/packages/concierge-dom/test/ssr.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + EXPECTED_CORE_CONTRACT_VERSION, + createAnchorRegistry, + isReadable, + isRendered, + scrollViewport, +} from "../src/index.js"; + +describe("server-safe construction", () => { + it("constructs a frozen registry without touching a document", () => { + expect("document" in globalThis).toBe(false); + expect("window" in globalThis).toBe(false); + + const anchors = createAnchorRegistry({ id: "ssr" }); + expect(anchors.id).toBe("ssr"); + expect(Object.isFrozen(anchors)).toBe(true); + expect(anchors.keys()).toEqual([]); + expect(anchors.resolve("missing").status).toBe("not-registered"); + expect(EXPECTED_CORE_CONTRACT_VERSION).toBe(4); + }); + + it("applies visibility policies without a document", () => { + expect( + isRendered({ + connected: true, + hiddenBy: null, + styledOutBy: null, + busy: true, + hasLayout: false, + depth: 0, + }), + ).toBe(true); + expect( + isReadable({ + connected: true, + hiddenBy: null, + styledOutBy: null, + busy: true, + hasLayout: false, + depth: 0, + }), + ).toBe(false); + }); + + it("throws when a viewport helper is invoked without window", () => { + expect(() => scrollViewport("down")).toThrow(ReferenceError); + }); +}); diff --git a/packages/concierge-dom/test/viewport.test.ts b/packages/concierge-dom/test/viewport.test.ts new file mode 100644 index 0000000..506a286 --- /dev/null +++ b/packages/concierge-dom/test/viewport.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + preferredScrollBehavior, + readViewportPosition, + scrollViewport, +} from "../src/index.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function stubViewport(input: { + readonly scrollY: number; + readonly scrollHeight: number; + readonly clientHeight: number; + readonly innerHeight: number; +}): ReturnType { + Object.defineProperty(window, "scrollY", { + value: input.scrollY, + configurable: true, + }); + Object.defineProperty(window, "innerHeight", { + value: input.innerHeight, + configurable: true, + }); + Object.defineProperty(document.documentElement, "scrollHeight", { + value: input.scrollHeight, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: input.clientHeight, + configurable: true, + }); + const scrollTo = vi.fn(); + window.scrollTo = scrollTo as typeof window.scrollTo; + return scrollTo; +} + +describe("viewport helpers", () => { + it("prefers auto when reduced motion is requested", () => { + const media = (matches: boolean): MediaQueryList => + ({ + matches, + media: "(prefers-reduced-motion: reduce)", + onchange: null, + addEventListener: (): void => undefined, + removeEventListener: (): void => undefined, + addListener: (): void => undefined, + removeListener: (): void => undefined, + dispatchEvent: (): boolean => false, + }) as MediaQueryList; + + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: (query: string): MediaQueryList => { + void query; + return media(true); + }, + }); + expect(preferredScrollBehavior()).toBe("auto"); + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: (query: string): MediaQueryList => { + void query; + return media(false); + }, + }); + expect(preferredScrollBehavior()).toBe("smooth"); + }); + + it("prefers auto when the preference cannot be read at all", () => { + // `vi.restoreAllMocks` does not undo a `defineProperty`, so this test + // removes `matchMedia` itself rather than trusting the shared afterEach. + const original = Reflect.getOwnPropertyDescriptor(window, "matchMedia"); + try { + Reflect.deleteProperty(window, "matchMedia"); + expect(preferredScrollBehavior()).toBe("auto"); + + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: (): never => { + throw new Error("no media support"); + }, + }); + expect(preferredScrollBehavior()).toBe("auto"); + } finally { + Reflect.deleteProperty(window, "matchMedia"); + if (original !== undefined) { + Object.defineProperty(window, "matchMedia", original); + } + } + }); + + it("reads the current position and reports the clamped target, not the settled one", () => { + const scrollTo = stubViewport({ + scrollY: 100, + scrollHeight: 2000, + clientHeight: 800, + innerHeight: 800, + }); + + const current = readViewportPosition(); + expect(current).toEqual({ + scrollTop: 100, + maxScrollTop: 1200, + atTop: false, + atBottom: false, + }); + + const down = scrollViewport("down", { step: 0.85, behavior: "auto" }); + expect(down.scrollTop).toBe(780); + expect(scrollTo).toHaveBeenCalledWith({ top: 780, behavior: "auto" }); + + const up = scrollViewport("up", { step: 0.85, behavior: "auto" }); + expect(up.scrollTop).toBe(0); + expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "auto" }); + + const top = scrollViewport("top", { behavior: "auto" }); + expect(top.atTop).toBe(true); + expect(top.scrollTop).toBe(0); + + const bottom = scrollViewport("bottom", { behavior: "auto" }); + expect(bottom.atBottom).toBe(true); + expect(bottom.scrollTop).toBe(1200); + }); +}); diff --git a/packages/concierge-dom/test/visibility.test.ts b/packages/concierge-dom/test/visibility.test.ts new file mode 100644 index 0000000..53e36fd --- /dev/null +++ b/packages/concierge-dom/test/visibility.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + ANCESTOR_WALK_LIMIT, + isReadable, + isRendered, + measureVisibility, +} from "../src/index.js"; +import { mount } from "./harness.js"; + +afterEach(() => { + document.body.replaceChildren(); + document.head.replaceChildren(); +}); + +describe("measureVisibility", () => { + it("reports a connected unstyled element as rendered and readable", () => { + const element: HTMLElement = mount("div", "item"); + const report = measureVisibility(element); + + expect(report.connected).toBe(true); + expect(report.hiddenBy).toBeNull(); + expect(report.styledOutBy).toBeNull(); + expect(report.busy).toBe(false); + expect(report.hasLayout).toBe(false); + expect(report.depth).toBe(2); + expect(isRendered(report)).toBe(true); + expect(isReadable(report)).toBe(true); + }); + + it("treats a detached element as not rendered", () => { + const element: HTMLElement = document.createElement("div"); + const report = measureVisibility(element); + + expect(report.connected).toBe(false); + expect(isRendered(report)).toBe(false); + expect(isReadable(report)).toBe(false); + }); + + it("names the nearest hiding attribute in hidden / inert / aria-hidden order", () => { + const hidden: HTMLElement = mount(); + hidden.setAttribute("hidden", ""); + expect(measureVisibility(hidden).hiddenBy).toBe("hidden"); + + const inert: HTMLElement = mount(); + inert.setAttribute("inert", ""); + expect(measureVisibility(inert).hiddenBy).toBe("inert"); + + const aria: HTMLElement = mount(); + aria.setAttribute("aria-hidden", "true"); + expect(measureVisibility(aria).hiddenBy).toBe("aria-hidden"); + + const notAria: HTMLElement = mount(); + notAria.setAttribute("aria-hidden", "false"); + expect(measureVisibility(notAria).hiddenBy).toBeNull(); + expect(isRendered(measureVisibility(notAria))).toBe(true); + }); + + it("walks ancestors for attributes, style, and aria-busy", () => { + const parent: HTMLElement = mount(); + parent.setAttribute("hidden", ""); + parent.setAttribute("aria-busy", "true"); + const child: HTMLElement = document.createElement("div"); + parent.appendChild(child); + + const report = measureVisibility(child); + expect(report.hiddenBy).toBe("hidden"); + expect(report.busy).toBe(true); + expect(report.depth).toBe(1); + expect(isRendered(report)).toBe(false); + }); + + it("reports display, visibility, and content-visibility as styledOutBy", () => { + const display: HTMLElement = mount(); + display.style.display = "none"; + expect(measureVisibility(display).styledOutBy).toBe("display"); + + const visibility: HTMLElement = mount(); + visibility.style.visibility = "hidden"; + expect(measureVisibility(visibility).styledOutBy).toBe("visibility"); + + const collapse: HTMLElement = mount(); + collapse.style.visibility = "collapse"; + expect(measureVisibility(collapse).styledOutBy).toBe("visibility"); + + const content: HTMLElement = mount(); + content.style.setProperty("content-visibility", "hidden"); + expect(measureVisibility(content).styledOutBy).toBe("content-visibility"); + }); + + it("stops at the nearest hiding cause and does not name a farther ancestor", () => { + const outer: HTMLElement = mount(); + outer.style.display = "none"; + const inner: HTMLElement = document.createElement("div"); + inner.setAttribute("hidden", ""); + outer.appendChild(inner); + + const report = measureVisibility(inner); + expect(report.hiddenBy).toBe("hidden"); + expect(report.depth).toBe(0); + }); + + it("ignores busy and hasLayout in isRendered, and requires not-busy for isReadable", () => { + const element: HTMLElement = mount(); + element.setAttribute("aria-busy", "true"); + const report = measureVisibility(element); + + expect(report.busy).toBe(true); + expect(report.hasLayout).toBe(false); + expect(isRendered(report)).toBe(true); + expect(isReadable(report)).toBe(false); + + expect( + isRendered({ + connected: true, + hiddenBy: null, + styledOutBy: null, + busy: true, + hasLayout: true, + depth: 0, + }), + ).toBe(true); + expect( + isReadable({ + connected: true, + hiddenBy: null, + styledOutBy: null, + busy: false, + hasLayout: true, + depth: 0, + }), + ).toBe(true); + expect( + isReadable({ + connected: true, + hiddenBy: null, + styledOutBy: null, + busy: true, + hasLayout: true, + depth: 0, + }), + ).toBe(false); + }); + + it("caps ancestor hops at ANCESTOR_WALK_LIMIT", () => { + expect(ANCESTOR_WALK_LIMIT).toBe(512); + + let current: HTMLElement = document.createElement("div"); + const leaf: HTMLElement = current; + for (let hop = 0; hop < ANCESTOR_WALK_LIMIT + 8; hop += 1) { + const parent: HTMLElement = document.createElement("div"); + parent.appendChild(current); + current = parent; + } + current.setAttribute("hidden", ""); + document.body.appendChild(current); + + const report = measureVisibility(leaf); + expect(report.depth).toBe(ANCESTOR_WALK_LIMIT); + expect(report.hiddenBy).toBeNull(); + expect(isRendered(report)).toBe(true); + }); +}); diff --git a/packages/concierge-dom/tsconfig.json b/packages/concierge-dom/tsconfig.json new file mode 100644 index 0000000..389e441 --- /dev/null +++ b/packages/concierge-dom/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/concierge-dom/tsdown.config.ts b/packages/concierge-dom/tsdown.config.ts new file mode 100644 index 0000000..13689b2 --- /dev/null +++ b/packages/concierge-dom/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + platform: "neutral", + dts: true, + clean: true, + outDir: "dist", + deps: { + neverBundle: ["@full-self-browsing/concierge"], + }, + publint: { level: "error" }, + attw: { level: "error", profile: "esm-only" }, +}); diff --git a/packages/concierge-react/CHANGELOG.md b/packages/concierge-react/CHANGELOG.md index 9f78e98..fb95220 100644 --- a/packages/concierge-react/CHANGELOG.md +++ b/packages/concierge-react/CHANGELOG.md @@ -1,5 +1,14 @@ # @full-self-browsing/concierge-react +## 0.4.0 + +### Minor Changes + +- 2ffb743: Ship `@full-self-browsing/concierge-dom`: registered-element resolve, reveal, and untrusted readback. +- 2ffb743: Ship Concierge 0.4: contract v4 consent kernel, catalog acknowledgement, dispatch observability, DOM and realtime packages, adapter last-event/null-bridge hooks, and the five-package release set. + + Two details worth knowing before you write against `concierge-dom`. An `AnchorRef` now returns the cleanup for the element it attached, so React 19 releases exactly the node each JSX site registered; React 18 ignores the return value and keeps the `ref(null)` protocol. A key holds a set of registrations rather than one, so several simultaneously mounted nodes for one record all stay reachable. + ## 0.3.0 ### Minor Changes diff --git a/packages/concierge-react/README.md b/packages/concierge-react/README.md index 52c5e87..d029418 100644 --- a/packages/concierge-react/README.md +++ b/packages/concierge-react/README.md @@ -10,9 +10,10 @@ React lifecycle bindings and optional action-state chrome for an existing [`@full-self-browsing/concierge`](https://github.com/fullselfbrowsing/Concierge) instance and bridge registry. -Version 0.3 is a public preview of contract 3. It supports React 18 and 19, +Version 0.4 is a public preview of contract 4. It supports React 18 and 19, requires Node 22.12 or newer for server rendering, and does not support Edge -runtimes in the 0.3 line. The existing provider and bridge hooks are unchanged. +runtimes in the 0.4 line. `useConciergeActivity` now returns the last observer +event, and `useConciergeBridge` accepts `null` to unregister. ## Entry points @@ -194,7 +195,8 @@ override its presentation. Both layers use fixed positioning, ignore pointer input, and accept a shared `zIndex`. For application-owned visuals, `useConciergeActivity()` exposes the same -concurrency-safe active boolean without rendering anything. +concurrency-safe `{ active, lastEvent }` store without rendering anything. +`lastEvent` is the redacted observer event; it is never unredacted args. ## Lifecycle guarantees @@ -205,6 +207,7 @@ concurrency-safe active boolean without rendering anything. cannot hide an active parent. - `useConciergeBridge` calls `registry.register(bridge)` only from `useEffect` and returns that exact registration unsubscriber as cleanup. + Passing `null` unregisters and does not install a dummy bridge. - React StrictMode's development sequence—setup, cleanup, setup—therefore leaves the current registration live. The core registry's monotonic token makes a retained stale cleanup an idempotent no-op, while final unmount diff --git a/packages/concierge-react/overlay/activity.tsx b/packages/concierge-react/overlay/activity.tsx index 60cd24c..079559a 100644 --- a/packages/concierge-react/overlay/activity.tsx +++ b/packages/concierge-react/overlay/activity.tsx @@ -22,8 +22,13 @@ const ConciergeActivityContext: Context = const useBrowserLayoutEffect: typeof useLayoutEffect = "window" in globalThis ? useLayoutEffect : useEffect; +export interface ConciergeActivity { + readonly active: boolean; + readonly lastEvent: DispatchEvent | null; +} + interface ConciergeActivityStore { - readonly getSnapshot: () => boolean; + readonly getSnapshot: () => ConciergeActivity; readonly observe: (event: DispatchEvent) => void; readonly subscribe: (listener: () => void) => () => void; } @@ -79,27 +84,25 @@ function isTerminalDispatchPhase(phase: DispatchEvent["phase"]): boolean { function createConciergeActivityStore(): ConciergeActivityStore { const activeDispatches: Set = new Set(); const listeners: Set<() => void> = new Set<() => void>(); + let snapshot: ConciergeActivity = Object.freeze({ + active: false, + lastEvent: null, + }); return { - getSnapshot: (): boolean => activeDispatches.size > 0, + getSnapshot: (): ConciergeActivity => snapshot, observe: (event): void => { const terminal: boolean = isTerminalDispatchPhase(event.phase); - if ( - terminal - ? !activeDispatches.has(event.dispatchId) - : activeDispatches.has(event.dispatchId) - ) { - return; - } - - const wasActive: boolean = activeDispatches.size > 0; if (terminal) { activeDispatches.delete(event.dispatchId); - } else { + } else if (!activeDispatches.has(event.dispatchId)) { activeDispatches.add(event.dispatchId); } - if (wasActive === (activeDispatches.size > 0)) return; + snapshot = Object.freeze({ + active: activeDispatches.size > 0, + lastEvent: event, + }); listeners.forEach((listener) => { listener(); }); @@ -181,7 +184,7 @@ export function useConcierge(): Concierge { return concierge; } -export function useConciergeActivity(): boolean { +export function useConciergeActivity(): ConciergeActivity { const activityStore: ConciergeActivityStore | null = useContext( ConciergeActivityContext, ); @@ -205,7 +208,7 @@ export function ConciergeActivityOverlay({ poweredByFSB = false, zIndex = 2_147_483_000, }: ConciergeActivityOverlayProps): ReactElement | null { - const active: boolean = useConciergeActivity(); + const { active }: ConciergeActivity = useConciergeActivity(); if (!active) return null; diff --git a/packages/concierge-react/package.json b/packages/concierge-react/package.json index a1e5762..389d68e 100644 --- a/packages/concierge-react/package.json +++ b/packages/concierge-react/package.json @@ -1,6 +1,6 @@ { "name": "@full-self-browsing/concierge-react", - "version": "0.3.0", + "version": "0.4.0", "private": false, "description": "React bindings and optional action visuals for @full-self-browsing/concierge", "keywords": [ diff --git a/packages/concierge-react/src/client.tsx b/packages/concierge-react/src/client.tsx index a07be06..99ed6a0 100644 --- a/packages/concierge-react/src/client.tsx +++ b/packages/concierge-react/src/client.tsx @@ -14,6 +14,7 @@ export { useConciergeActivity, } from "../overlay/activity.js"; export type { + ConciergeActivity, ConciergeActivityOverlayProps, ConciergeBadgePosition, ConciergeGlowOptions, @@ -21,7 +22,7 @@ export type { ConciergeProviderProps, } from "../overlay/activity.js"; -const EXPECTED_CONTRACT_VERSION: number = 3; +const EXPECTED_CONTRACT_VERSION: number = 4; export function useConciergeValue(value: T): () => T { const valueRef = useRef(value); @@ -35,9 +36,9 @@ export function useConciergeValue(value: T): () => T { export function useConciergeBridge( registry: BridgeRegistry, - bridge: B, + bridge: B | null, ): void { - useEffect((): (() => void) => { + useEffect((): (() => void) | undefined => { assertSingleInstance(); if (CONTRACT_VERSION !== EXPECTED_CONTRACT_VERSION) { @@ -48,7 +49,10 @@ export function useConciergeBridge( ); } - const unregister: () => void = registry.register(bridge); - return unregister; + if (bridge === null) { + return undefined; + } + + return registry.register(bridge); }, [registry, bridge]); } diff --git a/packages/concierge-react/test-d/public.test-d.ts b/packages/concierge-react/test-d/public.test-d.ts index 88240af..8c10656 100644 --- a/packages/concierge-react/test-d/public.test-d.ts +++ b/packages/concierge-react/test-d/public.test-d.ts @@ -15,11 +15,13 @@ import { useConciergeValue, } from "@full-self-browsing/concierge-react/client"; import type { + ConciergeActivity, ConciergeActivityOverlayProps, ConciergeBadgePosition, ConciergeGlowOptions, ConciergePoweredByFSBOptions, } from "@full-self-browsing/concierge-react/client"; +import type { DispatchEvent } from "@full-self-browsing/concierge"; type Equal = (() => T extends Left ? 1 : 2) extends @@ -37,12 +39,15 @@ type _activityPropsRemainPublic = Expect< >; const _consumerSignature: () => Concierge = useConcierge; -const _activitySignature: () => boolean = useConciergeActivity; +const _activitySignature: () => ConciergeActivity = useConciergeActivity; const _valueSignature: (value: T) => () => T = useConciergeValue; const _bridgeSignature: ( registry: BridgeRegistry, - bridge: B, + bridge: B | null, ) => void = useConciergeBridge; +type _activityLastEvent = Expect< + Equal +>; declare const concierge: Concierge; const _providerElement: ReactElement = createElement(ConciergeProvider, { @@ -90,6 +95,7 @@ const _bridgeReturn: void = useConciergeBridge( bookingRegistry, bookingBridge, ); +const _nullBridgeReturn: void = useConciergeBridge(bookingRegistry, null); // @ts-expect-error -- the provider requires a constructed Concierge. createElement(ConciergeProvider, {}); diff --git a/packages/concierge-react/test/artifact.test.ts b/packages/concierge-react/test/artifact.test.ts index e794fdf..6f90fff 100644 --- a/packages/concierge-react/test/artifact.test.ts +++ b/packages/concierge-react/test/artifact.test.ts @@ -89,7 +89,7 @@ describe("the built @full-self-browsing/concierge-react entries", () => { ); previousIndex = index; } - expect(clientSource).toMatch(/EXPECTED_CONTRACT_VERSION\s*=\s*3\b/u); + expect(clientSource).toMatch(/EXPECTED_CONTRACT_VERSION\s*=\s*4\b/u); await withoutBrowserGlobals(async () => { const [root, client, core] = await Promise.all([ @@ -120,6 +120,7 @@ describe("the built @full-self-browsing/concierge-react entries", () => { snapshot: { server: () => true }, }; const concierge = { + instanceId: "react-artifact", dispatch: async () => ({ ok: true, message: "Done." }), dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ @@ -129,6 +130,7 @@ describe("the built @full-self-browsing/concierge-react entries", () => { }), onDispatch: () => () => undefined, explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), + attestReadback: () => "unknown_readback", }; function ServerConsumer() { diff --git a/packages/concierge-react/test/lifecycle.test.tsx b/packages/concierge-react/test/lifecycle.test.tsx index be7a0e0..85d22a8 100644 --- a/packages/concierge-react/test/lifecycle.test.tsx +++ b/packages/concierge-react/test/lifecycle.test.tsx @@ -11,6 +11,7 @@ import type { Concierge, DispatchEvent, DispatchListener, + DispatchTiming, } from "@full-self-browsing/concierge"; const telemetryMount = vi.hoisted(() => vi.fn()); @@ -47,14 +48,25 @@ type TestBridge = Bridge< { current: () => Sentinel } >; +function emptyTiming(): DispatchTiming { + return { + clockMs: 0, + wallClockMs: 0, + elapsedMs: 0, + monotonic: false, + }; +} + function conciergeStub(): Concierge { const revision = Symbol("react-test-catalog") as ReturnType["revision"]; return { + instanceId: "react-test", dispatch: async () => ({ ok: true, message: "Done." }), dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ stage: null, tools: [], revision }), onDispatch: () => () => undefined, explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), + attestReadback: () => "unknown_readback", }; } @@ -66,6 +78,7 @@ function activityConciergeStub(): { const listeners: Set = new Set(); const revision = Symbol("react-activity-catalog") as CatalogRevision; const concierge: Concierge = { + instanceId: "react-activity", dispatch: async () => ({ ok: true, message: "Done." }), dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ stage: null, tools: [], revision }), @@ -76,6 +89,7 @@ function activityConciergeStub(): { }; }, explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), + attestReadback: () => "unknown_readback", }; return { @@ -104,6 +118,7 @@ function dispatchEvent( input: { kind: "dropped" as const }, terminalAction: false, terminalEntered: false, + timing: emptyTiming(), }; return phase === "accepted" @@ -111,7 +126,11 @@ function dispatchEvent( : { ...base, phase, - result: { ok: true, message: "Opened project." }, + result: { + ok: true, + message: { kind: "included", value: "Opened project." }, + }, + resultData: { kind: "absent" }, }; } @@ -177,8 +196,21 @@ function LayoutDispatch({ emit }: { readonly emit: () => void }) { } function ActivityState() { - const active: boolean = useConciergeActivity(); - return {String(active)}; + const activity = useConciergeActivity(); + return ( + + {`${String(activity.active)}:${activity.lastEvent?.phase ?? "none"}`} + + ); +} + +function NullBridgeHarness({ + registry, +}: { + readonly registry: BridgeRegistry; +}) { + useConciergeBridge(registry, null); + return null; } describe("@full-self-browsing/concierge-react lifecycle", () => { @@ -411,7 +443,7 @@ describe("@full-self-browsing/concierge-react lifecycle", () => { expect( mounted.container.querySelector("[data-concierge-activity-state]") ?.textContent, - ).toBe("true"); + ).toBe("true:accepted"); act(() => activity.emit(dispatchEvent("initial", "succeeded"))); @@ -419,7 +451,7 @@ describe("@full-self-browsing/concierge-react lifecycle", () => { expect( mounted.container.querySelector("[data-concierge-activity-state]") ?.textContent, - ).toBe("false"); + ).toBe("false:succeeded"); mounted.unmount(); expect(activity.listenerCount()).toBe(0); @@ -443,4 +475,14 @@ describe("@full-self-browsing/concierge-react lifecycle", () => { expect(badge.style.left).toBe("1rem"); expect(badge.style.right).toBe(""); }); + + it("leaves the registry empty when the bridge is null", () => { + const coreRegistry = createBridge("react-null-bridge"); + const mounted = render( + , + ); + expect(coreRegistry.read()).toBeNull(); + mounted.unmount(); + expect(coreRegistry.read()).toBeNull(); + }); }); diff --git a/packages/concierge-realtime/CHANGELOG.md b/packages/concierge-realtime/CHANGELOG.md new file mode 100644 index 0000000..8896da4 --- /dev/null +++ b/packages/concierge-realtime/CHANGELOG.md @@ -0,0 +1,17 @@ +# @full-self-browsing/concierge-realtime + +## 0.4.0 + +### Minor Changes + +- 2ffb743: Ship `@full-self-browsing/concierge-dom`: registered-element resolve, reveal, and untrusted readback. +- 2ffb743: Ship Concierge 0.4: contract v4 consent kernel, catalog acknowledgement, dispatch observability, DOM and realtime packages, adapter last-event/null-bridge hooks, and the five-package release set. + + Two details worth knowing before you write against `concierge-dom`. An `AnchorRef` now returns the cleanup for the element it attached, so React 19 releases exactly the node each JSX site registered; React 18 ignores the return value and keeps the `ref(null)` protocol. A key holds a set of registrations rather than one, so several simultaneously mounted nodes for one record all stay reachable. + +## 0.3.0 + +### Minor Changes + +- Ship the realtime session runtime with vendor-neutral ledgers, an OpenAI + provider over core's codec, and WebRTC/WebSocket channels. diff --git a/packages/concierge-realtime/LICENSE b/packages/concierge-realtime/LICENSE new file mode 100644 index 0000000..4cc47cb --- /dev/null +++ b/packages/concierge-realtime/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Full Self Browsing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/concierge-realtime/README.md b/packages/concierge-realtime/README.md new file mode 100644 index 0000000..b83a823 --- /dev/null +++ b/packages/concierge-realtime/README.md @@ -0,0 +1,39 @@ +# `@full-self-browsing/concierge-realtime` + +Realtime session runtime for +[`@full-self-browsing/concierge`](https://github.com/fullselfbrowsing/Concierge). +It opens a bidirectional event channel, hands core a `Transport` with +`acknowledgesCatalog: true`, and owns delivery evidence, turn identity, and +interruption. It declares no actions and actuates nothing. + +A realtime session is a **client-authority** path. This package signs nothing +and is not server authorization. Irreversible work still needs a server-side +check. + +```ts +import { createRealtimeSession } from "@full-self-browsing/concierge-realtime"; +import { createOpenAIRealtimeProvider } from "@full-self-browsing/concierge-realtime/openai"; +import { createWebRTCRealtimeChannel } from "@full-self-browsing/concierge-realtime/webrtc"; + +// Example: a CRM posts SDP from `negotiate` and calls `beginTurn` from a keypress. +const channel = createWebRTCRealtimeChannel({ negotiate }); +const provider = createOpenAIRealtimeProvider({ sessionType: "realtime" }); +const handle = await createRealtimeSession({ + concierge, + channel, + provider, + presentOutcome, + initialContext, + sessionId: "session-1", + turnSource: "explicit", +}); +``` + +Subpaths: + +- `.` — vendor-neutral runtime, ledgers, and stop-intent classifier (DOM-free) +- `./openai` — provider that consumes core's `openai-realtime` codec +- `./webrtc` — browser peer-connection channel (mic, peer, audio element only) +- `./websocket` — browser WebSocket channel + +Requires Node 22.12 or newer and core contract v4. diff --git a/packages/concierge-realtime/package.json b/packages/concierge-realtime/package.json new file mode 100644 index 0000000..2cf4318 --- /dev/null +++ b/packages/concierge-realtime/package.json @@ -0,0 +1,70 @@ +{ + "name": "@full-self-browsing/concierge-realtime", + "version": "0.4.0", + "description": "Realtime voice session runtime for @full-self-browsing/concierge", + "keywords": [ + "ai", + "agent", + "realtime", + "webrtc", + "tool-calling", + "typescript" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/fullselfbrowsing/Concierge.git", + "directory": "packages/concierge-realtime" + }, + "homepage": "https://github.com/fullselfbrowsing/Concierge#readme", + "bugs": { + "url": "https://github.com/fullselfbrowsing/Concierge/issues" + }, + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./openai": { + "types": "./dist/openai/index.d.ts", + "default": "./dist/openai/index.js" + }, + "./webrtc": { + "types": "./dist/webrtc/index.d.ts", + "default": "./dist/webrtc/index.js" + }, + "./websocket": { + "types": "./dist/websocket/index.d.ts", + "default": "./dist/websocket/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public", + "tag": "latest" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@full-self-browsing/concierge": "workspace:^" + }, + "scripts": { + "build": "tsdown && tsdown --config tsdown.dom.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.dom.json --noEmit", + "test": "vitest run --root ../.. --project realtime --project realtime-dom" + }, + "devDependencies": { + "@full-self-browsing/concierge": "workspace:*" + } +} diff --git a/packages/concierge-realtime/src/delivery-ledger.ts b/packages/concierge-realtime/src/delivery-ledger.ts new file mode 100644 index 0000000..3d7a61d --- /dev/null +++ b/packages/concierge-realtime/src/delivery-ledger.ts @@ -0,0 +1,295 @@ +import type { DeliveryReport, ReadbackAttestation } from "@full-self-browsing/concierge"; +import { + asRecord, + createBoundedStore, + createDiagnostic, + notifyDiagnostic, + ownData, + resolveScheduler, + validIdentifier, +} from "./host.js"; +import type { + RealtimeDeliveryLedger, + RealtimeDeliveryLedgerConfig, +} from "./types.js"; + +const DEFAULT_MAX_TRACKED_ORIGINS: number = 256; + +interface DeliveryGroup { + readonly originResponseId: string; + originTurnId: string | null; + readonly effects: Array<(report: DeliveryReport) => void>; + readbackHash: string | undefined; + voicerId: string | undefined; + playing: boolean; + settled: boolean; + holding: boolean; + cancelHold: (() => void) | undefined; +} + +/** + * Detach an attestation into frozen own data before anything is validated. + * + * **Every field is read exactly once, through `ownData`.** An earlier draft + * read `act`, `actId` and `readbackHash` once to validate and again to build + * the copy, so an accessor-backed attestation could pass `validIdentifier` + * and then hand a different `actId` to the frozen record. Reading each key's + * own data descriptor — never the property — is the same discipline core + * applies in `turn-ledger.ts` and `consent-evidence.ts`, and it also means a + * getter never runs at all. + */ +function snapshotAttestation( + attestation: ReadbackAttestation, +): ReadbackAttestation | null { + const record: object | null = asRecord(attestation); + if (record === null) { + return null; + } + const act: unknown = ownData(record, "act"); + const actId: unknown = ownData(record, "actId"); + const readbackHash: unknown = ownData(record, "readbackHash"); + const userTurnId: unknown = ownData(record, "userTurnId"); + if ( + (act !== "confirmed" && act !== "declined" && act !== "dismissed") || + !validIdentifier(actId) || + typeof readbackHash !== "string" || + (userTurnId !== undefined && typeof userTurnId !== "string") + ) { + return null; + } + return Object.freeze( + userTurnId === undefined + ? { act, actId, readbackHash } + : { act, actId, readbackHash, userTurnId }, + ); +} + +export interface RealtimeDeliveryLedgerInternal extends RealtimeDeliveryLedger { + rememberOriginTurn(originResponseId: string, userTurnId: string): void; +} + +/** + * Create a FIFO delivery ledger. `deferFor(N)` reports under N even when + * response M voices the results. + */ +export function createRealtimeDeliveryLedger( + config: RealtimeDeliveryLedgerConfig, +): RealtimeDeliveryLedger { + const scheduler = resolveScheduler(config.scheduler); + // Per-origin facts outlive the group that consumed them, because a later + // `deferFor` for the same origin opens a fresh group that must still see + // them. Bounded rather than cleared, so the lookup survives that case + // without growing for the length of the session. + const hashesByOrigin = createBoundedStore( + DEFAULT_MAX_TRACKED_ORIGINS, + DEFAULT_MAX_TRACKED_ORIGINS, + ); + const turnsByOrigin = createBoundedStore( + DEFAULT_MAX_TRACKED_ORIGINS, + DEFAULT_MAX_TRACKED_ORIGINS, + ); + // `groups` holds only OPEN groups. Every reader already skipped settled + // ones, so dropping them on settle is behaviour-neutral — and it is what + // keeps `findOpenGroup`, `observeAttestation` and `revokeAll` scanning the + // live set rather than the whole session's history. + const groups: DeliveryGroup[] = []; + const unbound: DeliveryGroup[] = []; + const byVoicer: Map = new Map(); + + const diagnose = (code: "delivery_revoked" | "delivery_unbound", responseId?: string): void => { + notifyDiagnostic(config.onDiagnostic, createDiagnostic(code, responseId)); + }; + + const findOpenGroup = (originResponseId: string): DeliveryGroup | undefined => { + for (let index: number = groups.length - 1; index >= 0; index -= 1) { + const group: DeliveryGroup | undefined = groups[index]; + if (group !== undefined && group.originResponseId === originResponseId && !group.settled) { + return group; + } + } + return undefined; + }; + + const createGroup = (originResponseId: string): DeliveryGroup => { + const group: DeliveryGroup = { + originResponseId, + originTurnId: turnsByOrigin.get(originResponseId) ?? null, + effects: [], + readbackHash: hashesByOrigin.get(originResponseId), + voicerId: undefined, + playing: false, + settled: false, + holding: false, + cancelHold: undefined, + }; + groups.push(group); + unbound.push(group); + return group; + }; + + const runEffects = (group: DeliveryGroup, report: DeliveryReport): void => { + for (const effect of group.effects) { + try { + effect(report); + } catch { + // Delivery effects are host-owned; one throw cannot strand the rest. + } + } + }; + + const settle = ( + group: DeliveryGroup, + outcome: DeliveryReport["outcome"], + attestation?: ReadbackAttestation, + ): void => { + if (group.settled) return; + group.settled = true; + group.holding = false; + group.playing = false; + group.cancelHold?.(); + group.cancelHold = undefined; + if (group.voicerId !== undefined) byVoicer.delete(group.voicerId); + const unboundIndex: number = unbound.indexOf(group); + if (unboundIndex >= 0) unbound.splice(unboundIndex, 1); + const openIndex: number = groups.indexOf(group); + if (openIndex >= 0) groups.splice(openIndex, 1); + // **The hash rides with the attestation or it does not ride at all.** + // Core reads `DeliveryReport.readbackHash` in exactly one place: to + // substantiate a claim to `attested`. A hash arriving with no confirming + // attestation is therefore read as a claim that failed to substantiate, + // and the kernel closes the consent generation outright — the + // `missing-attestation` variant of E02 pins that. + // + // An attestation hold that expires unanswered has not failed a claim. It + // has delivered a readback the person has not responded to yet, which is + // `relayed` and nothing more. Sending the bare hash turned that silence + // into a revocation: the generation closed, and a person who confirmed a + // moment after the hold elapsed got `unknown_readback` from + // `attestReadback` and `consent_required` from the gate. Since `attested` + // is unreachable without `attestationWindowMs`, that was the whole + // attested realtime path. + const report: DeliveryReport = Object.freeze({ + responseId: group.originResponseId, + outcome, + ...(attestation === undefined || group.readbackHash === undefined + ? {} + : { readbackHash: group.readbackHash }), + ...(attestation === undefined ? {} : { attestation }), + }); + runEffects(group, report); + }; + + const completeGroup = (group: DeliveryGroup): void => { + if (config.attestationWindowMs !== undefined && group.readbackHash !== undefined) { + if (scheduler === undefined) { + settle(group, "completed"); + return; + } + group.holding = true; + group.cancelHold = scheduler(() => { + group.cancelHold = undefined; + if (!group.settled) settle(group, "completed"); + }, config.attestationWindowMs); + return; + } + settle(group, "completed"); + }; + + const ledger: RealtimeDeliveryLedgerInternal = { + deferFor( + originResponseId: string, + ): (effect: (report: DeliveryReport) => void) => void { + return (effect: (report: DeliveryReport) => void): void => { + const existing: DeliveryGroup | undefined = findOpenGroup(originResponseId); + const group: DeliveryGroup = + existing !== undefined && !existing.settled + ? existing + : createGroup(originResponseId); + group.effects.push(effect); + }; + }, + attachReadbackHash(originResponseId: string, readbackHash: string): void { + hashesByOrigin.set(originResponseId, readbackHash); + const group: DeliveryGroup | undefined = findOpenGroup(originResponseId); + if (group !== undefined) group.readbackHash = readbackHash; + }, + rememberOriginTurn(originResponseId: string, userTurnId: string): void { + turnsByOrigin.set(originResponseId, userTurnId); + const group: DeliveryGroup | undefined = findOpenGroup(originResponseId); + if (group !== undefined) group.originTurnId = userTurnId; + }, + observeAttestation(attestation: ReadbackAttestation): void { + const frozen: ReadbackAttestation | null = snapshotAttestation(attestation); + if (frozen === null || frozen.userTurnId === undefined) return; + for (const group of groups) { + if (!group.holding || group.settled) continue; + if (group.readbackHash !== frozen.readbackHash) continue; + if (group.originTurnId !== null && frozen.userTurnId === group.originTurnId) { + continue; + } + settle(group, "completed", frozen); + return; + } + }, + bindResponse(responseId: string): void { + if (!validIdentifier(responseId) || byVoicer.has(responseId)) return; + const group: DeliveryGroup | undefined = unbound.shift(); + if (group === undefined) return; + group.voicerId = responseId; + byVoicer.set(responseId, group); + }, + playbackStarted(responseId: string): void { + const group: DeliveryGroup | undefined = byVoicer.get(responseId); + if (group === undefined || group.settled) return; + group.playing = true; + }, + playbackDrained(responseId: string): void { + const group: DeliveryGroup | undefined = byVoicer.get(responseId); + if (group === undefined || group.settled) return; + group.playing = false; + completeGroup(group); + }, + playbackCleared(responseId: string): void { + const group: DeliveryGroup | undefined = byVoicer.get(responseId); + if (group === undefined || group.settled) return; + diagnose("delivery_revoked", group.originResponseId); + settle(group, "interrupted"); + }, + generationCompleted(responseId: string): void { + const group: DeliveryGroup | undefined = byVoicer.get(responseId); + if (group === undefined || group.settled || group.playing) return; + if (config.deliveryEvidence === "buffer-drain") { + diagnose("delivery_revoked", group.originResponseId); + settle(group, "interrupted"); + return; + } + completeGroup(group); + }, + revokeAll( + options?: Readonly<{ retainPlaying?: boolean | undefined }> | undefined, + ): void { + const retainPlaying: boolean = options?.retainPlaying === true; + for (const group of [...groups]) { + if (group.settled) continue; + if (retainPlaying && group.playing) continue; + if (group.voicerId === undefined) diagnose("delivery_unbound", group.originResponseId); + else diagnose("delivery_revoked", group.originResponseId); + settle(group, "interrupted"); + } + }, + reset(): void { + for (const group of [...groups]) { + if (group.settled) continue; + if (group.voicerId === undefined) diagnose("delivery_unbound", group.originResponseId); + else diagnose("delivery_revoked", group.originResponseId); + settle(group, "interrupted"); + } + groups.length = 0; + unbound.length = 0; + byVoicer.clear(); + hashesByOrigin.clear(); + turnsByOrigin.clear(); + }, + }; + return Object.freeze(ledger); +} diff --git a/packages/concierge-realtime/src/host.ts b/packages/concierge-realtime/src/host.ts new file mode 100644 index 0000000..d5f3846 --- /dev/null +++ b/packages/concierge-realtime/src/host.ts @@ -0,0 +1,204 @@ +import type { AbortSignalLike, Scheduler } from "@full-self-browsing/concierge"; +import type { RealtimeDiagnostic, RealtimeDiagnosticCode } from "./types.js"; + +const DIAGNOSTIC_MESSAGES: Readonly> = + Object.freeze({ + channel_send_failed: "A realtime channel could not send an event.", + channel_fault: "The realtime channel failed and the session stopped.", + catalog_rejected: + "The agent rejected a catalog publication; the last acknowledged catalog still stands.", + catalog_unacknowledged: + "A catalog publication was not acknowledged before the timeout.", + batch_extract_failed: + "The provider could not extract a tool batch from a completed response.", + delivery_revoked: "A pending delivery was revoked before it reached the human.", + delivery_unbound: + "A delivery group ended without being bound to a voicing response.", + provider_decode_failed: "The provider could not decode one channel event.", + playback_source_missing: + "A host-signalled provider requires a playback source; consent was clamped to none.", + follow_up_failed: + "A tool-result or follow-up event was not sent; remaining events were withheld.", + }); + +interface TimerHost { + setTimeout?(fn: () => void, delayMs: number): unknown; + clearTimeout?(handle: unknown): void; +} + +export function readHostScheduler(): Scheduler | undefined { + const host: TimerHost = globalThis as TimerHost; + const schedule: TimerHost["setTimeout"] = host.setTimeout; + const clear: TimerHost["clearTimeout"] = host.clearTimeout; + if (schedule === undefined || clear === undefined) { + return undefined; + } + return (fn: () => void, delayMs: number): (() => void) => { + const handle: unknown = schedule.call(host, fn, delayMs); + let cancelled: boolean = false; + return (): void => { + if (cancelled) return; + cancelled = true; + try { + clear.call(host, handle); + } catch { + // Cancellation reaches the host timer at most once. + } + }; + }; +} + +export function resolveScheduler( + scheduler: Scheduler | undefined, +): Scheduler | undefined { + return scheduler ?? readHostScheduler(); +} + +export function createDiagnostic( + code: RealtimeDiagnosticCode, + responseId?: string, +): RealtimeDiagnostic { + return Object.freeze( + responseId === undefined + ? { code, message: DIAGNOSTIC_MESSAGES[code] } + : { code, message: DIAGNOSTIC_MESSAGES[code], responseId }, + ); +} + +export function invokeHost(fn: () => void): void { + try { + fn(); + } catch { + // Host callbacks cannot affect control flow. + } +} + +export function notifyDiagnostic( + onDiagnostic: ((diagnostic: RealtimeDiagnostic) => void) | undefined, + diagnostic: RealtimeDiagnostic, +): void { + if (onDiagnostic === undefined) return; + invokeHost(() => { + onDiagnostic(diagnostic); + }); +} + +export interface LocalAbortController { + readonly signal: AbortSignalLike; + abort(): void; +} + +export function createLocalAbortController(): LocalAbortController { + let aborted: boolean = false; + let nextToken: number = 0; + const listeners: Map void> = new Map(); + const signal: AbortSignalLike = Object.freeze({ + get aborted(): boolean { + return aborted; + }, + addEventListener(type: "abort", listener: () => void): void { + if (type === "abort" && !aborted) listeners.set(++nextToken, listener); + }, + removeEventListener(type: "abort", listener: () => void): void { + if (type !== "abort") return; + for (const [token, current] of listeners) { + if (current === listener) listeners.delete(token); + } + }, + }); + return { + signal, + abort(): void { + if (aborted) return; + aborted = true; + const snapshot: ReadonlyArray<() => void> = [...listeners.values()]; + listeners.clear(); + for (const listener of snapshot) invokeHost(listener); + }, + }; +} + +export function isAbortLike(error: unknown): boolean { + if (error === null || typeof error !== "object") return false; + try { + const name: unknown = (error as { name?: unknown }).name; + return name === "AbortError"; + } catch { + return false; + } +} + +export function validIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 1_024; +} + +export function asRecord(value: unknown): object | null { + if (typeof value !== "object" || value === null) return null; + try { + if (Array.isArray(value)) return null; + const prototype: object | null = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return null; + } catch { + return null; + } + return value; +} + +/** + * An insertion-ordered string map that forgets its oldest entry past a cap. + * + * A realtime session is long-lived by construction — that is the whole point + * of the package — so every per-response map inside one has to have a ceiling + * or it is a leak measured in session length rather than in a bug. The shape + * is the one `createRealtimeTurnLedger` already used for `byResponse`, lifted + * here so the ledgers cannot drift onto different eviction rules. + */ +export interface BoundedStore { + get(key: string): V | undefined; + set(key: string, value: V): void; + clear(): void; + readonly size: number; +} + +export function createBoundedStore( + maxEntries: number, + fallback: number, +): BoundedStore { + const cap: number = + Number.isSafeInteger(maxEntries) && maxEntries > 0 ? maxEntries : fallback; + const entries: Map = new Map(); + const order: string[] = []; + + return { + get(key: string): V | undefined { + return entries.get(key); + }, + set(key: string, value: V): void { + if (!entries.has(key)) order.push(key); + entries.set(key, value); + while (order.length > cap) { + const oldest: string | undefined = order.shift(); + if (oldest === undefined) break; + entries.delete(oldest); + } + }, + clear(): void { + entries.clear(); + order.length = 0; + }, + get size(): number { + return entries.size; + }, + }; +} + +export function ownData(record: object, key: string): unknown { + try { + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(record, key); + if (descriptor === undefined || !("value" in descriptor)) return undefined; + return descriptor.value; + } catch { + return undefined; + } +} diff --git a/packages/concierge-realtime/src/index.ts b/packages/concierge-realtime/src/index.ts new file mode 100644 index 0000000..fecdd03 --- /dev/null +++ b/packages/concierge-realtime/src/index.ts @@ -0,0 +1,41 @@ +/** + * Vendor-neutral realtime session runtime. Builds under `lib: ["ES2022"]`. + * + * Catalog publication, revisions, consent, and dispatch stay in core. This + * package owns the channel lifecycle, provider taxonomy, turn identity, and + * delivery evidence. A realtime session is a client-authority path and is not + * server authorization. + */ + +export type { + RealtimeBatchSource, + RealtimeBargeInPolicy, + RealtimeCatalogPublication, + RealtimeChannel, + RealtimeChannelFault, + RealtimeChannelState, + RealtimeDeliveryEvidence, + RealtimeDeliveryLedger, + RealtimeDeliveryLedgerConfig, + RealtimeDiagnostic, + RealtimeDiagnosticCode, + RealtimeForeignTool, + RealtimePlaybackEvent, + RealtimePlaybackSource, + RealtimeProvider, + RealtimeRuntimeStatus, + RealtimeSessionHandle, + RealtimeSessionOptions, + RealtimeSignal, + RealtimeStopIntentClassifier, + RealtimeTranscriptEvent, + RealtimeTurnLedger, + RealtimeTurnLedgerConfig, + RealtimeWireEvent, + StopIntentOptions, +} from "./types.js"; + +export { createRealtimeDeliveryLedger } from "./delivery-ledger.js"; +export { createRealtimeTurnLedger } from "./turn-ledger.js"; +export { createStopIntentClassifier } from "./stop-intent.js"; +export { createRealtimeSession } from "./session.js"; diff --git a/packages/concierge-realtime/src/openai/index.ts b/packages/concierge-realtime/src/openai/index.ts new file mode 100644 index 0000000..59fe6af --- /dev/null +++ b/packages/concierge-realtime/src/openai/index.ts @@ -0,0 +1,318 @@ +import { createOpenAIRealtimeCodec } from "@full-self-browsing/concierge/openai-realtime"; +import type { + BatchDispatchOutcome, + EmittedTool, + ToolBatch, +} from "@full-self-browsing/concierge"; +import { asRecord, ownData, validIdentifier } from "../host.js"; +import type { + RealtimeBatchSource, + RealtimeCatalogPublication, + RealtimeForeignTool, + RealtimePlaybackEvent, + RealtimeProvider, + RealtimeSignal, + RealtimeWireEvent, +} from "../types.js"; + +export interface OpenAIRealtimeProviderOptions { + /** `session.type`. Omitting it makes the provider reject every update. */ + readonly sessionType?: string | undefined; + /** Prefix for `event_id` echo attribution. Default `"concierge-su-"`. */ + readonly correlationIdPrefix?: string | undefined; + readonly toolChoice?: "auto" | "none" | "required" | undefined; + /** + * Strip `$schema`/`$id` from emitted tool parameters on the way to the wire. + * Default `true`. Done here, not in core's emitter, so `explain()` and the + * wire never disagree about what the agent was shown. + */ + readonly stripSchemaDialectKeys?: boolean | undefined; +} + +const DEFAULT_CORRELATION_PREFIX: string = "concierge-su-"; +const FOLLOW_UP: RealtimeWireEvent = Object.freeze({ type: "response.create" }); +const INTERRUPT: RealtimeWireEvent = Object.freeze({ type: "response.cancel" }); + +function stripDialectKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return Object.freeze(value.map(stripDialectKeys)); + } + if (typeof value !== "object" || value === null) return value; + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (key === "$schema" || key === "$id") continue; + result[key] = stripDialectKeys(entry); + } + return Object.freeze(result); +} + +function readString(record: object, key: string): string | null { + const value: unknown = ownData(record, key); + return validIdentifier(value) ? value : null; +} + +function readNested(record: object, key: string): object | null { + return asRecord(ownData(record, key)); +} + +function playbackEvent( + kind: RealtimePlaybackEvent["kind"], + responseId: string, +): RealtimeSignal { + return Object.freeze({ + kind: "playback", + event: Object.freeze({ kind, responseId }), + }); +} + +function isToolsParam(param: unknown): boolean { + return typeof param === "string" && param.startsWith("session.tools["); +} + +/** + * OpenAI Realtime provider. The three encode/decode primitives that touch + * catalog tools and completed batches are the shipped core codec. + */ +export function createOpenAIRealtimeProvider( + options?: OpenAIRealtimeProviderOptions, +): RealtimeProvider { + const codec = createOpenAIRealtimeCodec(); + const sessionType: string | undefined = options?.sessionType; + const correlationIdPrefix: string = + options?.correlationIdPrefix ?? DEFAULT_CORRELATION_PREFIX; + const toolChoice: "auto" | "none" | "required" | undefined = + options?.toolChoice; + const stripSchemaDialectKeys: boolean = + options?.stripSchemaDialectKeys !== false; + const strippedCache: WeakMap< + ReadonlyArray, + ReadonlyArray + > = new WeakMap(); + + const toWireTools = ( + publication: RealtimeCatalogPublication, + ): ReadonlyArray => { + const sessionTools = codec.toSessionTools(publication.catalog); + let catalogTools: ReadonlyArray; + if (stripSchemaDialectKeys) { + const cached: ReadonlyArray | undefined = + strippedCache.get(publication.catalog.tools); + if (cached !== undefined) { + catalogTools = cached; + } else { + catalogTools = Object.freeze( + sessionTools.map((tool) => + Object.freeze({ + type: tool.type, + name: tool.name, + description: tool.description, + parameters: stripDialectKeys(tool.parameters), + }), + ), + ); + strippedCache.set(publication.catalog.tools, catalogTools); + } + } else { + catalogTools = Object.freeze( + sessionTools.map((tool) => + Object.freeze({ + type: tool.type, + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }), + ), + ); + } + const foreign: ReadonlyArray = + publication.foreignTools ?? []; + return Object.freeze([...catalogTools, ...foreign]); + }; + + return Object.freeze({ + id: "openai", + playback: "provider-signalled", + echoesCorrelationId: false, + decode(event: unknown): RealtimeSignal { + const record: object | null = asRecord(event); + if (record === null) return Object.freeze({ kind: "ignored" }); + const type: unknown = ownData(record, "type"); + if (typeof type !== "string") return Object.freeze({ kind: "ignored" }); + + switch (type) { + case "session.created": + return Object.freeze({ kind: "session.ready" }); + case "session.updated": + if (sessionType === undefined) { + return Object.freeze({ + kind: "session.rejected", + correlationId: readString(record, "event_id"), + recoverable: true, + message: "Session type was not declared.", + }); + } + return Object.freeze({ + kind: "session.configured", + correlationId: readString(record, "event_id"), + }); + case "error": { + const error: object | null = readNested(record, "error"); + const param: unknown = error === null ? undefined : ownData(error, "param"); + const echoed: string | null = + error === null ? null : readString(error, "event_id"); + const message: string = + error !== null && typeof ownData(error, "message") === "string" + ? (ownData(error, "message") as string) + : "The realtime provider reported an error."; + const catalogRejection: boolean = + isToolsParam(param) || + (echoed !== null && echoed.startsWith(correlationIdPrefix)); + if (catalogRejection) { + return Object.freeze({ + kind: "session.rejected", + correlationId: echoed, + recoverable: true, + message, + }); + } + return Object.freeze({ + kind: "error", + recoverable: false, + message, + }); + } + case "input_audio_buffer.speech_started": { + const turnId: string | null = readString(record, "item_id"); + return turnId === null + ? Object.freeze({ kind: "ignored" }) + : Object.freeze({ kind: "turn.started", turnId }); + } + case "input_audio_buffer.committed": { + const turnId: string | null = readString(record, "item_id"); + return turnId === null + ? Object.freeze({ kind: "ignored" }) + : Object.freeze({ kind: "turn.committed", turnId }); + } + case "conversation.item.input_audio_transcription.completed": { + const turnId: string | null = readString(record, "item_id"); + const text: unknown = ownData(record, "transcript"); + return turnId === null || typeof text !== "string" + ? Object.freeze({ kind: "ignored" }) + : Object.freeze({ kind: "turn.transcribed", turnId, text }); + } + case "conversation.item.input_audio_transcription.failed": { + const turnId: string | null = readString(record, "item_id"); + return turnId === null + ? Object.freeze({ kind: "ignored" }) + : Object.freeze({ kind: "turn.transcription_failed", turnId }); + } + case "response.created": { + const response: object | null = readNested(record, "response"); + const responseId: string | null = + response === null ? null : readString(response, "id"); + return responseId === null + ? Object.freeze({ kind: "ignored" }) + : Object.freeze({ kind: "response.created", responseId }); + } + case "response.output_audio_transcript.done": + case "response.audio_transcript.done": { + const responseId: string | null = readString(record, "response_id"); + const text: unknown = ownData(record, "transcript"); + return responseId === null || typeof text !== "string" + ? Object.freeze({ kind: "ignored" }) + : Object.freeze({ kind: "response.transcript", responseId, text }); + } + case "response.done": { + const response: object | null = readNested(record, "response"); + const responseId: string | null = + response === null ? null : readString(response, "id"); + if (responseId === null) return Object.freeze({ kind: "ignored" }); + const status: unknown = + response === null ? undefined : ownData(response, "status"); + return status === "completed" + ? Object.freeze({ + kind: "response.completed", + responseId, + raw: event, + }) + : Object.freeze({ kind: "response.aborted", responseId }); + } + case "output_audio_buffer.started": { + const responseId: string | null = readString(record, "response_id"); + return responseId === null + ? Object.freeze({ kind: "ignored" }) + : playbackEvent("started", responseId); + } + case "output_audio_buffer.stopped": { + const responseId: string | null = readString(record, "response_id"); + return responseId === null + ? Object.freeze({ kind: "ignored" }) + : playbackEvent("drained", responseId); + } + case "output_audio_buffer.cleared": { + const responseId: string | null = readString(record, "response_id"); + return responseId === null + ? Object.freeze({ kind: "ignored" }) + : playbackEvent("cleared", responseId); + } + default: + return Object.freeze({ kind: "ignored" }); + } + }, + encodeCatalog(publication: RealtimeCatalogPublication): RealtimeWireEvent { + const session: Record = { + tools: toWireTools(publication), + }; + if (sessionType !== undefined) session.type = sessionType; + if (toolChoice !== undefined) session.tool_choice = toolChoice; + return Object.freeze({ + type: "session.update", + event_id: publication.correlationId, + session: Object.freeze(session), + }); + }, + extractBatch(source: RealtimeBatchSource): ToolBatch | null { + return codec.extractCompletedBatch({ + response: source.raw, + sessionId: source.sessionId, + userTurnId: source.userTurnId, + catalogRevision: source.catalogRevision, + signal: source.signal, + deferUntilDelivered: source.deferUntilDelivered, + }); + }, + encodeToolResults( + outcome: BatchDispatchOutcome, + ): ReadonlyArray { + return Object.freeze( + codec.toFunctionCallOutputEvents(outcome).map((event) => + Object.freeze({ + type: event.type, + item: event.item, + }), + ), + ); + }, + encodeFollowUp(): RealtimeWireEvent | null { + return FOLLOW_UP; + }, + encodeUserText(text: string): ReadonlyArray { + return Object.freeze([ + Object.freeze({ + type: "conversation.item.create", + item: Object.freeze({ + type: "message", + role: "user", + content: Object.freeze([ + Object.freeze({ type: "input_text", text }), + ]), + }), + }), + FOLLOW_UP, + ]); + }, + encodeInterrupt(): RealtimeWireEvent | null { + return INTERRUPT; + }, + }); +} diff --git a/packages/concierge-realtime/src/session.ts b/packages/concierge-realtime/src/session.ts new file mode 100644 index 0000000..3b50b66 --- /dev/null +++ b/packages/concierge-realtime/src/session.ts @@ -0,0 +1,807 @@ +import { + assertSingleInstance, + CONSENT_GRADE_ORDER, + CONTRACT_VERSION, + createSession, +} from "@full-self-browsing/concierge"; +import type { + BatchDispatchOutcome, + CatalogAcknowledgement, + CatalogRevision, + ConsentGrade, + ResolvedCatalog, + Session, + SessionDiagnostic, + ToolBatch, + Transport, + TransportCapabilities, + TransportStatus, +} from "@full-self-browsing/concierge"; +import { + createRealtimeDeliveryLedger, + type RealtimeDeliveryLedgerInternal, +} from "./delivery-ledger.js"; +import { + createDiagnostic, + createLocalAbortController, + invokeHost, + isAbortLike, + notifyDiagnostic, + resolveScheduler, + validIdentifier, +} from "./host.js"; +import { createRealtimeTurnLedger } from "./turn-ledger.js"; +import type { + RealtimeChannelState, + RealtimeDiagnostic, + RealtimeForeignTool, + RealtimePlaybackEvent, + RealtimeRuntimeStatus, + RealtimeSessionHandle, + RealtimeSessionOptions, + RealtimeSignal, + RealtimeTranscriptEvent, + RealtimeWireEvent, +} from "./types.js"; + +const EXPECTED_CORE_CONTRACT_VERSION: number = 4; + +const START_ERROR: string = "The realtime session could not start."; +const EXPLICIT_TURN_ERROR: string = + "beginTurn is only available when turnSource is \"explicit\"."; +const DEFAULT_ACK_TIMEOUT_MS: number = 10_000; + +function clampGrade( + requested: ConsentGrade, + ceiling: ConsentGrade, +): ConsentGrade { + const requestedRank: number = CONSENT_GRADE_ORDER.indexOf(requested); + const ceilingRank: number = CONSENT_GRADE_ORDER.indexOf(ceiling); + if (requestedRank <= ceilingRank) return requested; + return ceiling; +} + +function channelStatus(state: RealtimeChannelState): TransportStatus { + if (state === "open") return "connected"; + if (state === "opening") return "connecting"; + if (state === "closed") return "closed"; + return "idle"; +} + +interface InFlightPublication { + readonly revision: CatalogRevision; + readonly correlationId: string; + cancelTimeout: (() => void) | undefined; +} + +interface PendingAgentTranscript { + readonly responseId: string; + turnId: string | null; + text: string; + resolved: boolean; +} + +function createHandle( + session: Session, + statusOf: () => RealtimeRuntimeStatus, + catalogSettled: () => boolean, + beginTurn: (turnId: string) => void, + sendUserText: (text: string) => boolean, + interrupt: () => void, + resolveTranscript: ( + responseId: string, + decision: "retain" | "discard", + ) => void, + attachReadbackHash: (originResponseId: string, readbackHash: string) => void, + attest: RealtimeSessionHandle["attest"], + stop: () => Promise, +): RealtimeSessionHandle { + return Object.freeze({ + session, + status: statusOf, + catalogSettled, + beginTurn, + sendUserText, + interrupt, + resolveTranscript, + attachReadbackHash, + attest, + stop, + }); +} + +/** + * Open a live agent session against one Concierge instance. + * + * Constructs a `Transport` over `channel` + `provider`, hands it to core's + * `createSession`, and owns connection lifecycle, the provider event + * taxonomy, turn identity, delivery evidence, interruption, and result + * emission. + */ +export async function createRealtimeSession( + options: RealtimeSessionOptions, +): Promise { + assertSingleInstance(); + if (CONTRACT_VERSION !== EXPECTED_CORE_CONTRACT_VERSION) { + throw new Error( + `@full-self-browsing/concierge-realtime expected core contract v${EXPECTED_CORE_CONTRACT_VERSION} ` + + `but found v${CONTRACT_VERSION}; upgrade or reinstall ` + + `@full-self-browsing/concierge-realtime and @full-self-browsing/concierge together.`, + ); + } + + const provider = options.provider; + const channel = options.channel; + const turnSource = options.turnSource; + const revokeOn = options.bargeIn?.revokeOn ?? "turn-start"; + const abortWork = options.bargeIn?.abortWork !== false; + const stopRendition = options.bargeIn?.stopRendition !== false; + const acknowledgementTimeoutMs = + options.acknowledgementTimeoutMs ?? DEFAULT_ACK_TIMEOUT_MS; + const scheduler = resolveScheduler(options.scheduler); + const playbackSource = options.playbackSource; + const diagnose = ( + diagnostic: RealtimeDiagnostic | SessionDiagnostic, + ): void => { + if (options.onDiagnostic === undefined) return; + invokeHost(() => { + options.onDiagnostic?.(diagnostic); + }); + }; + const emitRealtime = ( + code: RealtimeDiagnostic["code"], + responseId?: string, + ): void => { + diagnose(createDiagnostic(code, responseId)); + }; + + let consentGrade: ConsentGrade = options.consentGrade ?? "relayed"; + if (provider.playback === "host-signalled" && playbackSource === undefined) { + if (consentGrade !== "none") emitRealtime("playback_source_missing"); + consentGrade = "none"; + } + if (options.attestationWindowMs === undefined) { + consentGrade = clampGrade(consentGrade, "relayed"); + } + if (revokeOn === "playback-cleared") { + consentGrade = clampGrade(consentGrade, "delivered"); + } + + const capabilities: TransportCapabilities = Object.freeze({ + consentGrade, + userTurnIdentity: + turnSource === "explicit" ? "human-attested" : "agent-forgeable", + parallelCalls: true, + dynamicCatalog: true, + acknowledgesCatalog: true, + }); + + const deliveryEvidence = + provider.playback === "host-signalled" + ? (playbackSource?.deliveryEvidence ?? "buffer-drain") + : "buffer-drain"; + const deliveryLedger = createRealtimeDeliveryLedger({ + deliveryEvidence, + attestationWindowMs: options.attestationWindowMs, + scheduler: options.scheduler, + onDiagnostic: (diagnostic) => diagnose(diagnostic), + }) as RealtimeDeliveryLedgerInternal; + const turnLedger = createRealtimeTurnLedger({ + provenance: capabilities.userTurnIdentity, + }); + + const connection = createLocalAbortController(); + let generation: number = 1; + const attempt: number = generation; + let active: boolean = true; + let failed: boolean = false; + let runtimeStatus: RealtimeRuntimeStatus = "connecting"; + let stopPromise: Promise | null = null; + let coreSession: Session | null = null; + let acceptBatch: + | ((batch: ToolBatch) => Promise) + | null = null; + let emitAck: ((ack: CatalogAcknowledgement) => void) | null = null; + let inFlight: InFlightPublication | null = null; + let pendingCatalog: ResolvedCatalog | null = null; + let nextCorrelation: number = 0; + let firstAccepted: boolean = false; + let resolveFirstAck: (() => void) | null = null; + let rejectFirstAck: ((error: Error) => void) | null = null; + const inFlightWork: Map void> = new Map(); + const pendingTranscripts: Map = new Map(); + const statusListeners: Array<(status: TransportStatus) => void> = []; + + const setRuntimeStatus = (next: RealtimeRuntimeStatus): void => { + if (runtimeStatus === next) return; + runtimeStatus = next; + if (options.onStatusChange !== undefined) { + invokeHost(() => { + options.onStatusChange?.(next); + }); + } + }; + + const emitTranscript = (event: RealtimeTranscriptEvent): void => { + if (options.onTranscript === undefined) return; + invokeHost(() => { + options.onTranscript?.(event); + }); + }; + + const emitTransportStatus = (status: TransportStatus): void => { + for (const listener of [...statusListeners]) { + invokeHost(() => { + listener(status); + }); + } + }; + + const readForeignTools = (): ReadonlyArray | null => { + if (options.foreignTools === undefined) return []; + try { + return options.foreignTools(); + } catch { + return null; + } + }; + + const sendEvent = (event: RealtimeWireEvent): boolean => { + try { + return channel.send(event); + } catch { + return false; + } + }; + + const acknowledge = (revision: CatalogRevision, accepted: boolean): void => { + if (emitAck === null) return; + invokeHost(() => { + emitAck?.(Object.freeze({ revision, accepted })); + }); + if (accepted && !firstAccepted) { + firstAccepted = true; + resolveFirstAck?.(); + resolveFirstAck = null; + rejectFirstAck = null; + } + }; + + const publishCatalog = (catalog: ResolvedCatalog): void => { + if (!active || inFlight !== null) { + pendingCatalog = catalog; + return; + } + const foreignTools: ReadonlyArray | null = + readForeignTools(); + if (foreignTools === null) { + pendingCatalog = catalog; + return; + } + nextCorrelation += 1; + const correlationId: string = `concierge-su-${nextCorrelation}`; + let encoded: RealtimeWireEvent; + try { + encoded = provider.encodeCatalog( + Object.freeze({ catalog, foreignTools, correlationId }), + ); + } catch { + pendingCatalog = catalog; + return; + } + if (!sendEvent(encoded)) { + emitRealtime("channel_send_failed"); + pendingCatalog = catalog; + return; + } + pendingCatalog = null; + let cancelTimeout: (() => void) | undefined; + if (scheduler !== undefined) { + cancelTimeout = scheduler(() => { + if (inFlight === null || inFlight.revision !== catalog.revision) return; + inFlight = null; + emitRealtime("catalog_unacknowledged"); + acknowledge(catalog.revision, false); + if (pendingCatalog !== null) publishCatalog(pendingCatalog); + }, acknowledgementTimeoutMs); + } + inFlight = { revision: catalog.revision, correlationId, cancelTimeout }; + }; + + const settleInFlight = (accepted: boolean, rejected?: boolean): void => { + const current: InFlightPublication | null = inFlight; + if (current === null) return; + current.cancelTimeout?.(); + inFlight = null; + if (rejected === true) emitRealtime("catalog_rejected"); + acknowledge(current.revision, accepted); + if (pendingCatalog !== null) publishCatalog(pendingCatalog); + }; + + const matchesPublication = (correlationId: string | null): boolean => { + if (inFlight === null) return false; + if (provider.echoesCorrelationId) { + return correlationId === inFlight.correlationId; + } + return true; + }; + + const discardTranscript = (responseId: string): void => { + const pending: PendingAgentTranscript | undefined = + pendingTranscripts.get(responseId); + if (pending === undefined || pending.resolved) return; + pending.resolved = true; + pendingTranscripts.delete(responseId); + emitTranscript( + Object.freeze({ + turnId: pending.turnId ?? pending.responseId, + responseId: pending.responseId, + role: "agent", + text: pending.text, + status: "discarded", + }), + ); + }; + + const finalizeTranscript = (responseId: string): void => { + const pending: PendingAgentTranscript | undefined = + pendingTranscripts.get(responseId); + if (pending === undefined || pending.resolved) return; + pending.resolved = true; + pendingTranscripts.delete(responseId); + emitTranscript( + Object.freeze({ + turnId: pending.turnId ?? pending.responseId, + responseId: pending.responseId, + role: "agent", + text: pending.text, + status: "final", + }), + ); + }; + + const applyPlayback = (event: RealtimePlaybackEvent): void => { + if (event.kind === "started") { + deliveryLedger.playbackStarted(event.responseId); + return; + } + if (event.kind === "drained") { + deliveryLedger.playbackDrained(event.responseId); + finalizeTranscript(event.responseId); + return; + } + deliveryLedger.playbackCleared(event.responseId); + discardTranscript(event.responseId); + }; + + const abortInFlightWork = (): void => { + const aborts: ReadonlyArray<() => void> = [...inFlightWork.values()]; + inFlightWork.clear(); + for (const abort of aborts) invokeHost(abort); + }; + + const interruptNow = (): void => { + if (stopRendition) { + let encoded: RealtimeWireEvent | null; + try { + encoded = provider.encodeInterrupt(); + } catch { + encoded = null; + } + if (encoded !== null && !sendEvent(encoded)) { + emitRealtime("channel_send_failed"); + } + if (playbackSource !== undefined) { + for (const responseId of pendingTranscripts.keys()) { + invokeHost(() => { + playbackSource.interrupt(responseId); + }); + } + } + } + if (abortWork) abortInFlightWork(); + deliveryLedger.revokeAll( + Object.freeze({ retainPlaying: revokeOn === "playback-cleared" }), + ); + }; + + const emitResults = (outcome: BatchDispatchOutcome): void => { + if (!active) return; + if (outcome.kind === "terminal") return; + let encoded: ReadonlyArray; + try { + encoded = provider.encodeToolResults(outcome); + } catch { + emitRealtime("follow_up_failed"); + return; + } + for (const event of encoded) { + if (!sendEvent(event)) { + emitRealtime("follow_up_failed"); + return; + } + } + let followUp: RealtimeWireEvent | null; + try { + followUp = provider.encodeFollowUp(); + } catch { + emitRealtime("follow_up_failed"); + return; + } + if (followUp !== null && !sendEvent(followUp)) { + emitRealtime("follow_up_failed"); + } + }; + + const handleCompletedResponse = ( + responseId: string, + raw: unknown, + ): void => { + const catalog: ResolvedCatalog | null = coreSession?.catalog() ?? null; + const userTurnId: string | null = + turnLedger.turnOf(responseId) ?? turnLedger.currentTurnId(); + if (catalog === null || userTurnId === null) { + emitRealtime("batch_extract_failed", responseId); + deliveryLedger.generationCompleted(responseId); + setRuntimeStatus("listening"); + return; + } + deliveryLedger.rememberOriginTurn(responseId, userTurnId); + const work = createLocalAbortController(); + inFlightWork.set(responseId, () => work.abort()); + let batch: ToolBatch | null; + try { + batch = provider.extractBatch( + Object.freeze({ + raw, + sessionId: options.sessionId, + userTurnId, + catalogRevision: catalog.revision, + signal: work.signal, + deferUntilDelivered: deliveryLedger.deferFor(responseId), + }), + ); + } catch { + batch = null; + emitRealtime("batch_extract_failed", responseId); + } + deliveryLedger.generationCompleted(responseId); + if (batch === null) { + inFlightWork.delete(responseId); + setRuntimeStatus("listening"); + return; + } + const accept: typeof acceptBatch = acceptBatch; + if (accept === null) { + inFlightWork.delete(responseId); + setRuntimeStatus("listening"); + return; + } + setRuntimeStatus("working"); + void accept(batch) + .then((outcome) => { + inFlightWork.delete(responseId); + emitResults(outcome); + if (active && runtimeStatus === "working") setRuntimeStatus("listening"); + }) + .catch(() => { + inFlightWork.delete(responseId); + if (active && runtimeStatus === "working") setRuntimeStatus("listening"); + }); + }; + + const handleSignal = (signal: RealtimeSignal): void => { + switch (signal.kind) { + case "ignored": + return; + case "session.ready": + if (pendingCatalog !== null && inFlight === null) { + publishCatalog(pendingCatalog); + } + return; + case "session.configured": + if (!matchesPublication(signal.correlationId)) return; + settleInFlight(true); + return; + case "session.rejected": + if (!matchesPublication(signal.correlationId)) return; + if (signal.recoverable) { + settleInFlight(false, true); + return; + } + failed = true; + void stopHandle(); + return; + case "turn.started": + turnLedger.openTurn(signal.turnId); + interruptNow(); + return; + case "turn.committed": + turnLedger.openTurn(signal.turnId); + return; + case "turn.transcribed": + turnLedger.openTurn(signal.turnId); + emitTranscript( + Object.freeze({ + turnId: signal.turnId, + responseId: null, + role: "human", + text: signal.text, + status: "final", + }), + ); + if (options.stopIntent?.(signal.text) === true) interruptNow(); + return; + case "turn.transcription_failed": + turnLedger.openTurn(signal.turnId); + return; + case "response.created": { + const boundTurn: string | null = turnLedger.bindResponse(signal.responseId); + deliveryLedger.bindResponse(signal.responseId); + if (boundTurn !== null) { + const pending: PendingAgentTranscript | undefined = + pendingTranscripts.get(signal.responseId); + if (pending !== undefined) pending.turnId = boundTurn; + } + setRuntimeStatus("responding"); + return; + } + case "response.transcript": { + const turnId: string | null = turnLedger.turnOf(signal.responseId); + const pending: PendingAgentTranscript = { + responseId: signal.responseId, + turnId, + text: signal.text, + resolved: false, + }; + pendingTranscripts.set(signal.responseId, pending); + emitTranscript( + Object.freeze({ + turnId: turnId ?? signal.responseId, + responseId: signal.responseId, + role: "agent", + text: signal.text, + status: "pending", + }), + ); + return; + } + case "response.completed": + handleCompletedResponse(signal.responseId, signal.raw); + return; + case "response.aborted": + inFlightWork.get(signal.responseId)?.(); + inFlightWork.delete(signal.responseId); + deliveryLedger.playbackCleared(signal.responseId); + discardTranscript(signal.responseId); + if (runtimeStatus === "responding" || runtimeStatus === "working") { + setRuntimeStatus("listening"); + } + return; + case "playback": + applyPlayback(signal.event); + return; + case "error": + diagnose( + Object.freeze({ + code: "channel_fault" as const, + message: createDiagnostic("channel_fault").message, + }), + ); + if (!signal.recoverable) { + failed = true; + void stopHandle(); + } + return; + default: + return; + } + }; + + const onChannelEvent = (event: unknown): void => { + if (!active) return; + let decoded: RealtimeSignal; + try { + decoded = provider.decode(event); + } catch { + emitRealtime("provider_decode_failed"); + return; + } + handleSignal(decoded); + }; + + const onChannelState = (state: RealtimeChannelState): void => { + emitTransportStatus(channelStatus(state)); + if (state === "open" && pendingCatalog !== null && inFlight === null) { + publishCatalog(pendingCatalog); + } + if (state === "closed" && active) { + failed = true; + emitRealtime("channel_fault"); + void stopHandle(); + } + }; + + let unsubscribeEvents: () => void = () => {}; + let unsubscribeState: () => void = () => {}; + let unsubscribePlayback: () => void = () => {}; + + function stopHandle(): Promise { + if (stopPromise !== null) return stopPromise; + active = false; + generation += 1; + connection.abort(); + inFlight?.cancelTimeout?.(); + inFlight = null; + pendingCatalog = null; + abortInFlightWork(); + invokeHost(unsubscribeEvents); + invokeHost(unsubscribeState); + invokeHost(unsubscribePlayback); + deliveryLedger.reset(); + turnLedger.reset(); + for (const responseId of [...pendingTranscripts.keys()]) { + discardTranscript(responseId); + } + try { + channel.close(); + } catch { + // Channel close is best effort during teardown. + } + const sessionStop: Promise = + coreSession?.stop() ?? Promise.resolve(); + stopPromise = sessionStop.then(() => { + setRuntimeStatus(failed ? "failed" : "closed"); + }); + return stopPromise; + } + + try { + unsubscribeEvents = channel.onEvent(onChannelEvent); + unsubscribeState = channel.onStateChange((state, fault) => { + if (fault !== undefined && active) { + failed = true; + emitRealtime("channel_fault"); + void stopHandle(); + return; + } + onChannelState(state); + }); + if (playbackSource !== undefined) { + unsubscribePlayback = playbackSource.onPlayback((event) => { + if (!active) return; + invokeHost(() => { + applyPlayback(event); + }); + }); + } + + await channel.open(connection.signal); + if (attempt !== generation || connection.signal.aborted) { + channel.close(); + throw new Error(START_ERROR); + } + + const transport: Transport = { + capabilities, + get status(): TransportStatus { + return channelStatus(channel.state); + }, + setCatalog(catalog: ResolvedCatalog): void { + publishCatalog(catalog); + }, + onStatusChange(cb: (status: TransportStatus) => void): () => void { + statusListeners.push(cb); + return (): void => { + const index: number = statusListeners.indexOf(cb); + if (index >= 0) statusListeners.splice(index, 1); + }; + }, + onToolBatch(cb: (batch: ToolBatch) => Promise): () => void { + acceptBatch = cb; + return (): void => { + if (acceptBatch === cb) acceptBatch = null; + }; + }, + onCatalogAcknowledged( + cb: (ack: CatalogAcknowledgement) => void, + ): () => void { + emitAck = cb; + return (): void => { + if (emitAck === cb) emitAck = null; + }; + }, + }; + + coreSession = createSession({ + concierge: options.concierge, + transport, + presentOutcome: options.presentOutcome, + initialContext: options.initialContext, + onDiagnostic: (diagnostic) => diagnose(diagnostic), + }); + + if (attempt !== generation) { + await stopHandle(); + throw new Error(START_ERROR); + } + + if (!firstAccepted) { + const firstAck: Promise = new Promise((resolve, reject) => { + resolveFirstAck = resolve; + rejectFirstAck = reject; + }); + let cancelWait: (() => void) | undefined; + if (scheduler !== undefined) { + cancelWait = scheduler(() => { + emitRealtime("catalog_unacknowledged"); + rejectFirstAck?.(new Error(START_ERROR)); + }, acknowledgementTimeoutMs); + } + try { + await firstAck; + } catch (error) { + cancelWait?.(); + await stopHandle(); + throw error instanceof Error ? error : new Error(START_ERROR); + } + cancelWait?.(); + } + + if (attempt !== generation) { + await stopHandle(); + throw new Error(START_ERROR); + } + + setRuntimeStatus("listening"); + return createHandle( + coreSession, + () => runtimeStatus, + () => inFlight === null && pendingCatalog === null, + (turnId: string): void => { + if (turnSource !== "explicit") throw new Error(EXPLICIT_TURN_ERROR); + if (!validIdentifier(turnId)) return; + turnLedger.openTurn(turnId); + }, + (text: string): boolean => { + if (!active) return false; + let events: ReadonlyArray; + try { + events = provider.encodeUserText(text); + } catch { + return false; + } + for (const event of events) { + if (!sendEvent(event)) { + emitRealtime("channel_send_failed"); + return false; + } + } + return true; + }, + (): void => { + if (active) interruptNow(); + }, + (responseId: string, decision: "retain" | "discard"): void => { + if (decision === "discard") discardTranscript(responseId); + else finalizeTranscript(responseId); + }, + (originResponseId: string, readbackHash: string): void => { + deliveryLedger.attachReadbackHash(originResponseId, readbackHash); + }, + (attestation) => { + deliveryLedger.observeAttestation(attestation); + }, + stopHandle, + ); + } catch (error) { + if (isAbortLike(error) || attempt !== generation) { + await stopHandle(); + throw new Error(START_ERROR); + } + await stopHandle(); + throw error instanceof Error && error.message === START_ERROR + ? error + : new Error(START_ERROR); + } +} diff --git a/packages/concierge-realtime/src/stop-intent.ts b/packages/concierge-realtime/src/stop-intent.ts new file mode 100644 index 0000000..a82c8d7 --- /dev/null +++ b/packages/concierge-realtime/src/stop-intent.ts @@ -0,0 +1,81 @@ +import type { + RealtimeStopIntentClassifier, + StopIntentOptions, +} from "./types.js"; + +const DEFAULT_PHRASES: ReadonlyArray = Object.freeze([ + "stop", + "stop it", + "cancel", + "cancel that", + "never mind", + "nevermind", + "hold on", + "wait", + "that's enough", +]); + +const DEFAULT_FILLERS: ReadonlyArray = Object.freeze([ + "uh", + "um", + "er", + "ah", + "well", +]); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeTranscript(transcript: string): string { + return transcript + .replace(/^[^\p{L}\p{N}]+/u, "") + .replace(/[^\p{L}\p{N}]+$/u, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +function stripLeadingFillers(text: string, fillers: ReadonlyArray): string { + if (fillers.length === 0) return text; + const pattern: RegExp = new RegExp( + `^(?:${fillers.map(escapeRegExp).join("|")})\\b[^\\p{L}\\p{N}]*`, + "iu", + ); + let current: string = text; + for (let step: number = 0; step < 8; step += 1) { + const next: string = current.replace(pattern, "").trim(); + if (next === current) break; + current = next; + } + return current; +} + +/** Matches only a whole committed transcript, never a partial prefix. */ +export function createStopIntentClassifier( + options?: StopIntentOptions, +): RealtimeStopIntentClassifier { + const phrases: ReadonlyArray = options?.phrases ?? DEFAULT_PHRASES; + const fillers: ReadonlyArray = options?.fillers ?? DEFAULT_FILLERS; + const normalizedPhrases: ReadonlyArray = Object.freeze( + phrases + .map((phrase) => phrase.trim().toLowerCase()) + .filter((phrase) => phrase.length > 0), + ); + const matcher: RegExp | null = + normalizedPhrases.length === 0 + ? null + : new RegExp( + `^(?:${normalizedPhrases.map(escapeRegExp).join("|")})$`, + "u", + ); + + return (transcript: string): boolean => { + if (matcher === null || typeof transcript !== "string") return false; + const cleaned: string = stripLeadingFillers( + normalizeTranscript(transcript), + fillers, + ); + return cleaned.length > 0 && matcher.test(cleaned); + }; +} diff --git a/packages/concierge-realtime/src/turn-ledger.ts b/packages/concierge-realtime/src/turn-ledger.ts new file mode 100644 index 0000000..613c059 --- /dev/null +++ b/packages/concierge-realtime/src/turn-ledger.ts @@ -0,0 +1,62 @@ +import type { TurnIdentityProvenance } from "@full-self-browsing/concierge"; +import { validIdentifier } from "./host.js"; +import type { RealtimeTurnLedger, RealtimeTurnLedgerConfig } from "./types.js"; + +const DEFAULT_MAX_TRACKED_RESPONSES: number = 64; + +/** + * Latest-wins turn identity. Many responses may bind to one turn; no id is + * minted when no turn is open. + */ +export function createRealtimeTurnLedger( + config: RealtimeTurnLedgerConfig, +): RealtimeTurnLedger { + const maxTracked: number = + typeof config.maxTrackedResponses === "number" && + Number.isSafeInteger(config.maxTrackedResponses) && + config.maxTrackedResponses > 0 + ? config.maxTrackedResponses + : DEFAULT_MAX_TRACKED_RESPONSES; + const provenance: TurnIdentityProvenance = config.provenance; + let current: string | null = null; + const byResponse: Map = new Map(); + const order: string[] = []; + + const evictIfNeeded = (): void => { + while (order.length > maxTracked) { + const oldest: string | undefined = order.shift(); + if (oldest !== undefined) byResponse.delete(oldest); + } + }; + + return Object.freeze({ + provenance, + openTurn(turnId: string): void { + if (!validIdentifier(turnId)) return; + current = turnId; + }, + currentTurnId(): string | null { + return current; + }, + bindResponse(responseId: string): string | null { + if (!validIdentifier(responseId) || current === null) return null; + if (!byResponse.has(responseId)) order.push(responseId); + byResponse.set(responseId, current); + evictIfNeeded(); + return current; + }, + turnOf(responseId: string): string | null { + return byResponse.get(responseId) ?? null; + }, + releaseResponse(responseId: string): void { + if (!byResponse.delete(responseId)) return; + const index: number = order.indexOf(responseId); + if (index >= 0) order.splice(index, 1); + }, + reset(): void { + current = null; + byResponse.clear(); + order.length = 0; + }, + }); +} diff --git a/packages/concierge-realtime/src/types.ts b/packages/concierge-realtime/src/types.ts new file mode 100644 index 0000000..f9aad9a --- /dev/null +++ b/packages/concierge-realtime/src/types.ts @@ -0,0 +1,371 @@ +import type { + AbortSignalLike, + BatchDispatchOutcome, + CatalogRevision, + Concierge, + ConsentGrade, + DeliveryReport, + OutcomeSink, + ReadbackAttestation, + ResolvedCatalog, + Scheduler, + Session, + SessionDiagnostic, + StageContext, + ToolBatch, + TurnIdentityProvenance, +} from "@full-self-browsing/concierge"; + +/** One JSON value as it travels on the provider's wire, in either direction. */ +export type RealtimeWireEvent = Readonly>; + +/** A provider-shaped tool descriptor the host owns (server-executed tools). */ +export type RealtimeForeignTool = Readonly>; + +export type RealtimeChannelState = "idle" | "opening" | "open" | "closed"; + +export interface RealtimeChannelFault { + readonly code: + | "unreachable" + | "rejected" + | "dropped" + | "closed-by-peer" + | "malformed-handshake"; + readonly message: string; +} + +/** + * A bidirectional JSON event channel to a live agent. The runtime never + * inspects media and never learns how the channel is carried. + */ +export interface RealtimeChannel { + readonly state: RealtimeChannelState; + /** + * Establish the channel. Resolves when `state` is `"open"`. Implementations + * must abandon every resource they created if `signal` aborts mid-handshake. + */ + open(signal: AbortSignalLike): Promise; + /** `false` means the event was not handed to the wire; the caller retries. */ + send(event: RealtimeWireEvent): boolean; + onEvent(cb: (event: unknown) => void): () => void; + onStateChange( + cb: (state: RealtimeChannelState, fault?: RealtimeChannelFault) => void, + ): () => void; + close(): void; +} + +export type RealtimePlaybackEvent = + | Readonly<{ kind: "started"; responseId: string }> + /** The rendition drained in full. */ + | Readonly<{ kind: "drained"; responseId: string }> + /** The rendition was cut off. Whatever was pending is not consent. */ + | Readonly<{ kind: "cleared"; responseId: string }>; + +/** + * What a transport may honestly treat as proof a rendition reached the human. + * + * `"buffer-drain"` — a separate playback channel reports drain/clear, and + * generation completing with no output ever started fails closed. + * `"generation-complete"` — the surface renders atomically, so there is no + * output-started edge at all and generation completion IS delivery. + */ +export type RealtimeDeliveryEvidence = "buffer-drain" | "generation-complete"; + +/** Host-owned playback, for providers that stream raw audio frames. */ +export interface RealtimePlaybackSource { + readonly deliveryEvidence: RealtimeDeliveryEvidence; + onPlayback(cb: (event: RealtimePlaybackEvent) => void): () => void; + /** Discard anything still queued for `responseId`. */ + interrupt(responseId: string): void; +} + +/** Neutral vocabulary every provider decodes into. */ +export type RealtimeSignal = + | Readonly<{ kind: "ignored" }> + | Readonly<{ kind: "session.ready" }> + | Readonly<{ kind: "session.configured"; correlationId: string | null }> + | Readonly<{ + kind: "session.rejected"; + correlationId: string | null; + /** `false` tears the session down; `true` keeps it and drops the update. */ + recoverable: boolean; + message: string; + }> + /** The human began producing input. */ + | Readonly<{ kind: "turn.started"; turnId: string }> + /** The human's input closed. */ + | Readonly<{ kind: "turn.committed"; turnId: string }> + | Readonly<{ kind: "turn.transcribed"; turnId: string; text: string }> + | Readonly<{ kind: "turn.transcription_failed"; turnId: string }> + | Readonly<{ kind: "response.created"; responseId: string }> + | Readonly<{ kind: "response.transcript"; responseId: string; text: string }> + /** + * Generation finished. `raw` is handed verbatim to `extractBatch`; the + * runtime never parses provider payloads itself. + */ + | Readonly<{ kind: "response.completed"; responseId: string; raw: unknown }> + | Readonly<{ kind: "response.aborted"; responseId: string }> + | Readonly<{ kind: "playback"; event: RealtimePlaybackEvent }> + | Readonly<{ kind: "error"; recoverable: boolean; message: string }>; + +export interface RealtimeCatalogPublication { + readonly catalog: ResolvedCatalog; + /** + * Host tools published alongside the catalog. `null` means "not ready" and + * suppresses publication entirely — distinct from an empty array, which + * means "ready, and there are none". + */ + readonly foreignTools: ReadonlyArray | null; + /** Echo target for acknowledgement attribution, when the provider echoes. */ + readonly correlationId: string; +} + +export interface RealtimeBatchSource { + readonly raw: unknown; + readonly sessionId: string; + readonly userTurnId: string; + readonly catalogRevision: CatalogRevision; + readonly signal: AbortSignalLike; + readonly deferUntilDelivered: + | ((effect: (report: DeliveryReport) => void) => void) + | undefined; +} + +/** Pure protocol translation for one vendor. Holds no connection state. */ +export interface RealtimeProvider { + readonly id: string; + /** + * `"provider-signalled"` — playback edges arrive on the wire and are decoded + * into `RealtimeSignal`. `"host-signalled"` — a `RealtimePlaybackSource` is + * required in the session options. + */ + readonly playback: "provider-signalled" | "host-signalled"; + /** `true` when acknowledgements echo `correlationId`; `false` means FIFO. */ + readonly echoesCorrelationId: boolean; + decode(event: unknown): RealtimeSignal; + encodeCatalog(publication: RealtimeCatalogPublication): RealtimeWireEvent; + /** Delegates to the shipped core codec; `null` on any malformed payload. */ + extractBatch(source: RealtimeBatchSource): ToolBatch | null; + encodeToolResults( + outcome: BatchDispatchOutcome, + ): ReadonlyArray; + /** One follow-up after every result event. `null` if the vendor auto-continues. */ + encodeFollowUp(): RealtimeWireEvent | null; + encodeUserText(text: string): ReadonlyArray; + encodeInterrupt(): RealtimeWireEvent | null; +} + +export interface RealtimeDeliveryLedgerConfig { + readonly deliveryEvidence: RealtimeDeliveryEvidence; + /** Hold a completed report open for a human act. Omit to report at drain. */ + readonly attestationWindowMs?: number | undefined; + readonly scheduler?: Scheduler | undefined; + readonly onDiagnostic?: ((diagnostic: RealtimeDiagnostic) => void) | undefined; +} + +/** + * Binds deferrals registered while dispatching the batch of response N to the + * playback of the response that voices those results (N+1), and reports them + * under **N**. + * + * The consent kernel arms only when `report.responseId` equals the + * `meta.responseId` of the dispatch that registered the deferral. Reporting + * the voicing response's id compiles, runs, and fails every gate forever. + */ +export interface RealtimeDeliveryLedger { + /** The `deferUntilDelivered` hook to put on the batch from `originResponseId`. */ + deferFor( + originResponseId: string, + ): (effect: (report: DeliveryReport) => void) => void; + /** Attach the `ReadbackSink` receipt hash that this delivery will carry. */ + attachReadbackHash(originResponseId: string, readbackHash: string): void; + /** A human act the app observed. Settles any report still held for attestation. */ + observeAttestation(attestation: ReadbackAttestation): void; + /** The next created response voices the oldest queued deferral group. */ + bindResponse(responseId: string): void; + playbackStarted(responseId: string): void; + playbackDrained(responseId: string): void; + playbackCleared(responseId: string): void; + /** Generation ended. Under `"buffer-drain"` with no output, fails closed. */ + generationCompleted(responseId: string): void; + /** + * Revoke everything. `retainPlaying` keeps groups whose rendition is still + * mid-flight — see `RealtimeBargeInPolicy.revokeOn`, which is what selects it. + */ + revokeAll( + options?: Readonly<{ retainPlaying?: boolean | undefined }> | undefined, + ): void; + reset(): void; +} + +export interface RealtimeTurnLedgerConfig { + readonly provenance: TurnIdentityProvenance; + /** Bounded response->turn retention. Default 64. */ + readonly maxTrackedResponses?: number | undefined; +} + +/** + * Latest-wins human turn identity, many responses to one turn. + * + * Deliberately not the same machine as the delivery ledger: no FIFO queue, no + * complete-once, and a binding that outlives the response that created it. + */ +export interface RealtimeTurnLedger { + readonly provenance: TurnIdentityProvenance; + openTurn(turnId: string): void; + currentTurnId(): string | null; + /** Binds and returns the turn that caused `responseId`, or `null`. */ + bindResponse(responseId: string): string | null; + turnOf(responseId: string): string | null; + releaseResponse(responseId: string): void; + reset(): void; +} + +export interface RealtimeBargeInPolicy { + /** + * Which observation revokes a pending delivery. + * + * `"turn-start"` (default) matches the published fail-closed rule: input + * detection revokes. `"playback-cleared"` waits for an explicit clear, which + * avoids revoking on the agent's own rendition re-entering an acoustic input + * path — and is strictly weaker, so it clamps the declarable grade. + */ + readonly revokeOn?: "turn-start" | "playback-cleared" | undefined; + /** Abort in-flight dispatch work on interruption. Default `true`. */ + readonly abortWork?: boolean | undefined; + /** Ask the provider to stop the agent mid-rendition. Default `true`. */ + readonly stopRendition?: boolean | undefined; +} + +export type RealtimeStopIntentClassifier = (transcript: string) => boolean; + +export interface StopIntentOptions { + /** Anchored phrases. Default is an English set; supply your own per locale. */ + readonly phrases?: ReadonlyArray | undefined; + /** Leading tokens stripped before matching. Default English fillers. */ + readonly fillers?: ReadonlyArray | undefined; +} + +export type RealtimeTranscriptEvent = Readonly<{ + turnId: string; + /** `null` for a human turn not yet bound to a response. */ + responseId: string | null; + role: "human" | "agent"; + text: string; + /** + * `"pending"` until the app calls `resolveTranscript` for the response, or + * the response settles. `"discarded"` supersedes a prior `"pending"` and the + * app must drop what it buffered. + */ + status: "pending" | "final" | "discarded"; +}>; + +export type RealtimeRuntimeStatus = + | "idle" + | "connecting" + | "listening" + | "responding" + | "working" + | "closed" + | "failed"; + +export type RealtimeDiagnosticCode = + | "channel_send_failed" + | "channel_fault" + | "catalog_rejected" + | "catalog_unacknowledged" + | "batch_extract_failed" + | "delivery_revoked" + | "delivery_unbound" + | "provider_decode_failed" + | "playback_source_missing" + | "follow_up_failed"; + +export interface RealtimeDiagnostic { + readonly code: RealtimeDiagnosticCode; + readonly message: string; + /** Never carries provider payloads, transcripts, or action arguments. */ + readonly responseId?: string | undefined; +} + +export interface RealtimeSessionOptions { + readonly concierge: Concierge; + readonly channel: RealtimeChannel; + readonly provider: RealtimeProvider; + /** Core's failure-presentation gate. Forwarded verbatim to `createSession`. */ + readonly presentOutcome: OutcomeSink; + readonly initialContext: StageContext; + /** Application session namespace for invocation identity. */ + readonly sessionId: string; + /** + * How a human turn begins, and therefore what the transport may declare. + * + * `"detected"` -> `userTurnIdentity: "agent-forgeable"`: a recognizer closes + * turns, so the agent's own rendition can re-enter and mint one. + * `"explicit"` -> `"human-attested"`: the app calls `beginTurn()` from an act + * the agent cannot perform. This is the only route by which a streaming + * transport reaches `bindTo: "userTurn"`, and it is fixed at construction. + */ + readonly turnSource: "detected" | "explicit"; + /** + * Ceiling on what this session declares. The runtime lowers it to what the + * configuration can honestly support and never raises it. Default `"relayed"`. + */ + readonly consentGrade?: ConsentGrade | undefined; + readonly bargeIn?: RealtimeBargeInPolicy | undefined; + /** Required when `provider.playback === "host-signalled"`. */ + readonly playbackSource?: RealtimePlaybackSource | undefined; + /** + * Live getter for host-owned tools. Returning `null` suppresses publication + * until they are ready; an empty array publishes the catalog alone. + */ + readonly foreignTools?: + | (() => ReadonlyArray | null) + | undefined; + /** Hold a delivered report open for a human act. Omit to disable. */ + readonly attestationWindowMs?: number | undefined; + /** How long to wait for a catalog acknowledgement. Default 10_000. */ + readonly acknowledgementTimeoutMs?: number | undefined; + /** Injected clock. Falls back to the structural host timer, then degrades. */ + readonly scheduler?: Scheduler | undefined; + readonly stopIntent?: RealtimeStopIntentClassifier | undefined; + readonly onTranscript?: + | ((event: RealtimeTranscriptEvent) => void) + | undefined; + readonly onStatusChange?: + | ((status: RealtimeRuntimeStatus) => void) + | undefined; + readonly onDiagnostic?: + | ((diagnostic: RealtimeDiagnostic | SessionDiagnostic) => void) + | undefined; +} + +export interface RealtimeSessionHandle { + /** + * The core session. Catalog publication, revisions, epochs, batch + * serialization, dispatch and terminal control live there, not here. Change + * stage with `handle.session.setContext(ctx)`. + */ + readonly session: Session; + status(): RealtimeRuntimeStatus; + /** `false` while a publication is in flight or queued. */ + catalogSettled(): boolean; + /** + * Open an explicit human turn. Required under `turnSource: "explicit"`; + * throws under `"detected"`, where the recognizer owns turn boundaries. + */ + beginTurn(turnId: string): void; + /** Inject a typed human turn. Does not change declared turn provenance. */ + sendUserText(text: string): boolean; + /** Treat this as a human interruption right now. */ + interrupt(): void; + /** Keep or drop the buffered transcript for a response. */ + resolveTranscript(responseId: string, decision: "retain" | "discard"): void; + /** Attach the `ReadbackSink` receipt hash that a later delivery will carry. */ + attachReadbackHash(originResponseId: string, readbackHash: string): void; + /** Report a human act observed against a rendered readback hash. */ + attest(attestation: ReadbackAttestation): void; + stop(): Promise; +} + +export type { CatalogRevision, ToolBatch }; diff --git a/packages/concierge-realtime/src/webrtc/index.ts b/packages/concierge-realtime/src/webrtc/index.ts new file mode 100644 index 0000000..297774b --- /dev/null +++ b/packages/concierge-realtime/src/webrtc/index.ts @@ -0,0 +1,466 @@ +import type { AbortSignalLike, Scheduler } from "@full-self-browsing/concierge"; +import { invokeHost, isAbortLike, resolveScheduler } from "../host.js"; +import type { + RealtimeChannel, + RealtimeChannelFault, + RealtimeChannelState, + RealtimeForeignTool, + RealtimeWireEvent, +} from "../types.js"; + +export interface WebRTCNegotiationRequest { + readonly sdp: string; + readonly signal: AbortSignalLike; +} + +export interface WebRTCNegotiationAnswer { + readonly sdp: string; + /** Host tools minted server-side during negotiation, if any. */ + readonly foreignTools?: ReadonlyArray | undefined; +} + +export interface WebRTCRealtimeChannelOptions { + /** + * The app's own SDP exchange. Credentials, endpoints, auth headers and error + * bodies stay in the app; the channel never learns any of them. + */ + readonly negotiate: ( + request: WebRTCNegotiationRequest, + ) => Promise; + readonly dataChannelLabel?: string | undefined; + readonly peerConnectionFactory?: (() => RTCPeerConnection) | undefined; + readonly requestMicrophone?: (() => Promise) | undefined; + readonly audioElementFactory?: (() => HTMLAudioElement) | undefined; + /** Grace before a `disconnected` peer is declared failed. Default 5_000. */ + readonly disconnectGraceMs?: number | undefined; + readonly scheduler?: Scheduler | undefined; +} + +export interface WebRTCMediaControls { + setMicrophoneEnabled(enabled: boolean): void; + microphoneEnabled(): boolean; + localStream(): MediaStream | null; + remoteStream(): MediaStream | null; + onStreamChange( + cb: (which: "local" | "remote", stream: MediaStream | null) => void, + ): () => void; +} + +export interface WebRTCRealtimeChannel extends RealtimeChannel { + readonly media: WebRTCMediaControls; +} + +const DEFAULT_DATA_CHANNEL_LABEL: string = "oai-events"; +const DEFAULT_DISCONNECT_GRACE_MS: number = 5_000; + +function waitForAbort(signal: AbortSignalLike, onAbort: () => void): () => void { + if (signal.aborted) { + onAbort(); + return (): void => {}; + } + const listener = (): void => { + onAbort(); + }; + signal.addEventListener("abort", listener); + return (): void => { + try { + signal.removeEventListener("abort", listener); + } catch { + // Best-effort unsubscribe. + } + }; +} + +/** + * Browser WebRTC data-channel transport. Touches a peer connection, a + * microphone track, and an audio element — never a document query. + */ +export function createWebRTCRealtimeChannel( + options: WebRTCRealtimeChannelOptions, +): WebRTCRealtimeChannel { + const label: string = options.dataChannelLabel ?? DEFAULT_DATA_CHANNEL_LABEL; + const disconnectGraceMs: number = + options.disconnectGraceMs ?? DEFAULT_DISCONNECT_GRACE_MS; + const scheduler: Scheduler | undefined = resolveScheduler(options.scheduler); + + let state: RealtimeChannelState = "idle"; + let peer: RTCPeerConnection | null = null; + let dataChannel: RTCDataChannel | null = null; + let local: MediaStream | null = null; + let remote: MediaStream | null = null; + let audio: HTMLAudioElement | null = null; + let micEnabled: boolean = true; + let closedByUs: boolean = false; + let cancelGrace: (() => void) | undefined; + let generation: number = 0; + + const eventListeners: Set<(event: unknown) => void> = new Set(); + const stateListeners: Set< + (state: RealtimeChannelState, fault?: RealtimeChannelFault) => void + > = new Set(); + const streamListeners: Set< + (which: "local" | "remote", stream: MediaStream | null) => void + > = new Set(); + + const emitState = ( + next: RealtimeChannelState, + fault?: RealtimeChannelFault, + ): void => { + if (state === next && fault === undefined) return; + state = next; + for (const listener of [...stateListeners]) { + invokeHost(() => { + listener(next, fault); + }); + } + }; + + const emitStream = ( + which: "local" | "remote", + stream: MediaStream | null, + ): void => { + for (const listener of [...streamListeners]) { + invokeHost(() => { + listener(which, stream); + }); + } + }; + + const stopTracks = (stream: MediaStream | null): void => { + if (stream === null) return; + for (const track of stream.getTracks()) { + try { + track.stop(); + } catch { + // Track stop is best effort. + } + } + }; + + const abandon = (): void => { + cancelGrace?.(); + cancelGrace = undefined; + try { + dataChannel?.close(); + } catch { + // Data-channel close is best effort. + } + dataChannel = null; + try { + peer?.close(); + } catch { + // Peer close is best effort. + } + peer = null; + stopTracks(local); + local = null; + remote = null; + if (audio !== null) { + try { + audio.srcObject = null; + audio.pause(); + } catch { + // Audio teardown is best effort. + } + } + audio = null; + }; + + const fail = (fault: RealtimeChannelFault): void => { + if (state === "closed") return; + abandon(); + emitState("closed", fault); + }; + + const applyMicEnabled = (): void => { + if (local === null) return; + for (const track of local.getAudioTracks()) { + track.enabled = micEnabled; + } + }; + + const channel: WebRTCRealtimeChannel = { + get state(): RealtimeChannelState { + return state; + }, + media: Object.freeze({ + setMicrophoneEnabled(enabled: boolean): void { + micEnabled = enabled; + applyMicEnabled(); + }, + microphoneEnabled(): boolean { + return micEnabled; + }, + localStream(): MediaStream | null { + return local; + }, + remoteStream(): MediaStream | null { + return remote; + }, + onStreamChange( + cb: (which: "local" | "remote", stream: MediaStream | null) => void, + ): () => void { + streamListeners.add(cb); + return (): void => { + streamListeners.delete(cb); + }; + }, + }), + async open(signal: AbortSignalLike): Promise { + if (state === "open") return; + if (state === "opening") { + throw new Error("A WebRTC channel open is already in progress."); + } + closedByUs = false; + generation += 1; + const attempt: number = generation; + emitState("opening"); + let released: boolean = false; + const releaseAbort: () => void = waitForAbort(signal, () => { + if (released || attempt !== generation) return; + abandon(); + emitState("closed"); + }); + try { + if (signal.aborted) throw new Error("The WebRTC handshake was aborted."); + const createPeer: () => RTCPeerConnection = + options.peerConnectionFactory ?? + ((): RTCPeerConnection => new RTCPeerConnection()); + const nextPeer: RTCPeerConnection = createPeer(); + if (attempt !== generation || signal.aborted) { + nextPeer.close(); + throw new Error("The WebRTC handshake was aborted."); + } + peer = nextPeer; + + nextPeer.addEventListener("connectionstatechange", () => { + if (attempt !== generation || closedByUs) return; + const connectionState: RTCPeerConnectionState = nextPeer.connectionState; + if (connectionState === "connected") { + cancelGrace?.(); + cancelGrace = undefined; + return; + } + if (connectionState === "disconnected") { + cancelGrace?.(); + if (scheduler === undefined) { + fail( + Object.freeze({ + code: "dropped", + message: "The peer connection disconnected.", + }), + ); + return; + } + cancelGrace = scheduler(() => { + cancelGrace = undefined; + if ( + attempt === generation && + nextPeer.connectionState === "disconnected" + ) { + fail( + Object.freeze({ + code: "dropped", + message: "The peer connection disconnected.", + }), + ); + } + }, disconnectGraceMs); + return; + } + if (connectionState === "failed" || connectionState === "closed") { + fail( + Object.freeze({ + code: connectionState === "failed" ? "dropped" : "closed-by-peer", + message: "The peer connection closed.", + }), + ); + } + }); + + const createAudio: () => HTMLAudioElement = + options.audioElementFactory ?? ((): HTMLAudioElement => new Audio()); + audio = createAudio(); + audio.autoplay = true; + nextPeer.addEventListener("track", (event: RTCTrackEvent) => { + if (attempt !== generation) return; + const incoming: MediaStream | null = event.streams[0] ?? null; + remote = incoming; + if (audio !== null) { + audio.srcObject = incoming; + void audio.play().catch(() => undefined); + } + emitStream("remote", incoming); + }); + + const requestMic: () => Promise = + options.requestMicrophone ?? + ((): Promise => + navigator.mediaDevices.getUserMedia({ audio: true })); + const stream: MediaStream = await requestMic(); + if (attempt !== generation || signal.aborted) { + stopTracks(stream); + throw new Error("The WebRTC handshake was aborted."); + } + local = stream; + applyMicEnabled(); + emitStream("local", stream); + for (const track of stream.getAudioTracks()) { + nextPeer.addTrack(track, stream); + } + + const nextChannel: RTCDataChannel = nextPeer.createDataChannel(label); + dataChannel = nextChannel; + nextChannel.addEventListener("message", (message: MessageEvent) => { + if (attempt !== generation) return; + let parsed: unknown = message.data; + if (typeof message.data === "string") { + try { + parsed = JSON.parse(message.data) as unknown; + } catch { + return; + } + } + for (const listener of [...eventListeners]) { + invokeHost(() => { + listener(parsed); + }); + } + }); + nextChannel.addEventListener("close", () => { + if (attempt !== generation || closedByUs) return; + fail( + Object.freeze({ + code: "closed-by-peer", + message: "The data channel closed.", + }), + ); + }); + nextChannel.addEventListener("error", () => { + if (attempt !== generation || closedByUs) return; + fail( + Object.freeze({ + code: "dropped", + message: "The data channel failed.", + }), + ); + }); + + const offer: RTCSessionDescriptionInit = await nextPeer.createOffer(); + if (attempt !== generation || signal.aborted) { + throw new Error("The WebRTC handshake was aborted."); + } + await nextPeer.setLocalDescription(offer); + if (attempt !== generation || signal.aborted) { + throw new Error("The WebRTC handshake was aborted."); + } + + const sdp: string | undefined = + nextPeer.localDescription?.sdp ?? offer.sdp; + if (typeof sdp !== "string" || sdp.length === 0) { + throw new Error("The local description did not contain SDP."); + } + const answer: WebRTCNegotiationAnswer = await options.negotiate({ + sdp, + signal, + }); + if (attempt !== generation || signal.aborted) { + throw new Error("The WebRTC handshake was aborted."); + } + if (typeof answer.sdp !== "string" || answer.sdp.length === 0) { + throw new Error("Negotiation did not return SDP."); + } + await nextPeer.setRemoteDescription({ + type: "answer", + sdp: answer.sdp, + }); + if (attempt !== generation || signal.aborted) { + throw new Error("The WebRTC handshake was aborted."); + } + + if (nextChannel.readyState !== "open") { + await new Promise((resolve, reject) => { + const onOpen = (): void => { + cleanup(); + resolve(); + }; + const onFail = (): void => { + cleanup(); + reject(new Error("The data channel closed during handshake.")); + }; + const cleanup = (): void => { + nextChannel.removeEventListener("open", onOpen); + nextChannel.removeEventListener("close", onFail); + nextChannel.removeEventListener("error", onFail); + releaseOpenAbort(); + }; + const releaseOpenAbort: () => void = waitForAbort(signal, () => { + cleanup(); + reject(new Error("The WebRTC handshake was aborted.")); + }); + nextChannel.addEventListener("open", onOpen); + nextChannel.addEventListener("close", onFail); + nextChannel.addEventListener("error", onFail); + }); + } + if (attempt !== generation || signal.aborted) { + throw new Error("The WebRTC handshake was aborted."); + } + emitState("open"); + } catch (error) { + if (attempt === generation) { + abandon(); + emitState( + "closed", + isAbortLike(error) || + (error instanceof Error && error.message.includes("aborted")) + ? undefined + : Object.freeze({ + code: "rejected", + message: "The WebRTC handshake failed.", + }), + ); + } + throw error instanceof Error + ? error + : new Error("The WebRTC handshake failed."); + } finally { + released = true; + releaseAbort(); + } + }, + send(event: RealtimeWireEvent): boolean { + if (state !== "open" || dataChannel === null || dataChannel.readyState !== "open") { + return false; + } + try { + dataChannel.send(JSON.stringify(event)); + return true; + } catch { + return false; + } + }, + onEvent(cb: (event: unknown) => void): () => void { + eventListeners.add(cb); + return (): void => { + eventListeners.delete(cb); + }; + }, + onStateChange( + cb: (state: RealtimeChannelState, fault?: RealtimeChannelFault) => void, + ): () => void { + stateListeners.add(cb); + return (): void => { + stateListeners.delete(cb); + }; + }, + close(): void { + if (state === "closed") return; + closedByUs = true; + generation += 1; + abandon(); + emitState("closed"); + }, + }; + return channel; +} diff --git a/packages/concierge-realtime/src/websocket/index.ts b/packages/concierge-realtime/src/websocket/index.ts new file mode 100644 index 0000000..39b0da7 --- /dev/null +++ b/packages/concierge-realtime/src/websocket/index.ts @@ -0,0 +1,250 @@ +import type { AbortSignalLike } from "@full-self-browsing/concierge"; +import { invokeHost, isAbortLike } from "../host.js"; +import type { + RealtimeChannel, + RealtimeChannelFault, + RealtimeChannelState, + RealtimeWireEvent, +} from "../types.js"; + +export interface WebSocketRealtimeChannelOptions { + /** Resolved per attempt so a short-lived credential can be refreshed. */ + readonly url: () => Promise | string; + readonly protocols?: ReadonlyArray | undefined; + readonly socketFactory?: + | ((url: string, protocols: ReadonlyArray) => WebSocket) + | undefined; +} + +function waitForAbort(signal: AbortSignalLike, onAbort: () => void): () => void { + if (signal.aborted) { + onAbort(); + return (): void => {}; + } + const listener = (): void => { + onAbort(); + }; + signal.addEventListener("abort", listener); + return (): void => { + try { + signal.removeEventListener("abort", listener); + } catch { + // Best-effort unsubscribe. + } + }; +} + +/** + * Browser WebSocket JSON event channel. The runtime never inspects media. + */ +export function createWebSocketRealtimeChannel( + options: WebSocketRealtimeChannelOptions, +): RealtimeChannel { + const protocols: ReadonlyArray = options.protocols ?? []; + let state: RealtimeChannelState = "idle"; + let socket: WebSocket | null = null; + let closedByUs: boolean = false; + let generation: number = 0; + + const eventListeners: Set<(event: unknown) => void> = new Set(); + const stateListeners: Set< + (state: RealtimeChannelState, fault?: RealtimeChannelFault) => void + > = new Set(); + + const emitState = ( + next: RealtimeChannelState, + fault?: RealtimeChannelFault, + ): void => { + if (state === next && fault === undefined) return; + state = next; + for (const listener of [...stateListeners]) { + invokeHost(() => { + listener(next, fault); + }); + } + }; + + const abandon = (): void => { + try { + socket?.close(); + } catch { + // Socket close is best effort. + } + socket = null; + }; + + const fail = (fault: RealtimeChannelFault): void => { + if (state === "closed") return; + abandon(); + emitState("closed", fault); + }; + + const channel: RealtimeChannel = { + get state(): RealtimeChannelState { + return state; + }, + async open(signal: AbortSignalLike): Promise { + if (state === "open") return; + if (state === "opening") { + throw new Error("A WebSocket channel open is already in progress."); + } + closedByUs = false; + generation += 1; + const attempt: number = generation; + emitState("opening"); + let released: boolean = false; + const releaseAbort: () => void = waitForAbort(signal, () => { + if (released || attempt !== generation) return; + abandon(); + emitState("closed"); + }); + try { + if (signal.aborted) throw new Error("The WebSocket handshake was aborted."); + const resolved: string = await options.url(); + if (attempt !== generation || signal.aborted) { + throw new Error("The WebSocket handshake was aborted."); + } + if (typeof resolved !== "string" || resolved.length === 0) { + throw new Error("The WebSocket URL was empty."); + } + const createSocket: + | ((url: string, protocols: ReadonlyArray) => WebSocket) + | undefined = options.socketFactory; + const nextSocket: WebSocket = + createSocket === undefined + ? new WebSocket(resolved, [...protocols]) + : createSocket(resolved, protocols); + if (attempt !== generation || signal.aborted) { + nextSocket.close(); + throw new Error("The WebSocket handshake was aborted."); + } + socket = nextSocket; + + if (nextSocket.readyState !== WebSocket.OPEN) { + await new Promise((resolve, reject) => { + const onOpen = (): void => { + cleanup(); + resolve(); + }; + const onFail = (): void => { + cleanup(); + reject(new Error("The WebSocket handshake failed.")); + }; + const cleanup = (): void => { + nextSocket.removeEventListener("open", onOpen); + nextSocket.removeEventListener("error", onFail); + nextSocket.removeEventListener("close", onFail); + releaseOpenAbort(); + }; + const releaseOpenAbort: () => void = waitForAbort(signal, () => { + cleanup(); + nextSocket.close(); + reject(new Error("The WebSocket handshake was aborted.")); + }); + if (nextSocket.readyState === WebSocket.OPEN) { + cleanup(); + resolve(); + return; + } + nextSocket.addEventListener("open", onOpen); + nextSocket.addEventListener("error", onFail); + nextSocket.addEventListener("close", onFail); + }); + } + + if (attempt !== generation || signal.aborted) { + throw new Error("The WebSocket handshake was aborted."); + } + + nextSocket.addEventListener("message", (message: MessageEvent) => { + if (attempt !== generation) return; + let parsed: unknown = message.data; + if (typeof message.data === "string") { + try { + parsed = JSON.parse(message.data) as unknown; + } catch { + return; + } + } + for (const listener of [...eventListeners]) { + invokeHost(() => { + listener(parsed); + }); + } + }); + nextSocket.addEventListener("close", () => { + if (attempt !== generation || closedByUs) return; + fail( + Object.freeze({ + code: "closed-by-peer", + message: "The WebSocket closed.", + }), + ); + }); + nextSocket.addEventListener("error", () => { + if (attempt !== generation || closedByUs) return; + fail( + Object.freeze({ + code: "dropped", + message: "The WebSocket failed.", + }), + ); + }); + emitState("open"); + } catch (error) { + if (attempt === generation) { + abandon(); + emitState( + "closed", + isAbortLike(error) || + (error instanceof Error && error.message.includes("aborted")) + ? undefined + : Object.freeze({ + code: "unreachable", + message: "The WebSocket handshake failed.", + }), + ); + } + throw error instanceof Error + ? error + : new Error("The WebSocket handshake failed."); + } finally { + released = true; + releaseAbort(); + } + }, + send(event: RealtimeWireEvent): boolean { + if (state !== "open" || socket === null || socket.readyState !== WebSocket.OPEN) { + return false; + } + try { + socket.send(JSON.stringify(event)); + return true; + } catch { + return false; + } + }, + onEvent(cb: (event: unknown) => void): () => void { + eventListeners.add(cb); + return (): void => { + eventListeners.delete(cb); + }; + }, + onStateChange( + cb: (state: RealtimeChannelState, fault?: RealtimeChannelFault) => void, + ): () => void { + stateListeners.add(cb); + return (): void => { + stateListeners.delete(cb); + }; + }, + close(): void { + if (state === "closed") return; + closedByUs = true; + generation += 1; + abandon(); + emitState("closed"); + }, + }; + return channel; +} diff --git a/packages/concierge-realtime/test/delivery-ledger.test.ts b/packages/concierge-realtime/test/delivery-ledger.test.ts new file mode 100644 index 0000000..6f83402 --- /dev/null +++ b/packages/concierge-realtime/test/delivery-ledger.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; + +import { createRealtimeDeliveryLedger } from "../src/delivery-ledger.js"; + +describe("createRealtimeDeliveryLedger", () => { + it("reports the originating response N when voicer M drains — reporting M is the required-failing mutant", () => { + const reports = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "buffer-drain", + }); + ledger.deferFor("response-N")((report) => { + reports.push(report); + }); + ledger.bindResponse("response-M"); + ledger.playbackStarted("response-M"); + ledger.playbackDrained("response-M"); + + expect(reports).toHaveLength(1); + expect(reports[0]?.responseId).toBe("response-N"); + expect(reports[0]?.responseId).not.toBe("response-M"); + expect(reports[0]?.outcome).toBe("completed"); + }); + + it("fails closed under buffer-drain when generation ends with no output", () => { + const reports = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "buffer-drain", + }); + ledger.deferFor("origin")((report) => { + reports.push(report); + }); + ledger.bindResponse("voicer"); + ledger.generationCompleted("voicer"); + expect(reports).toEqual([ + expect.objectContaining({ responseId: "origin", outcome: "interrupted" }), + ]); + }); + + it("treats generation completion as delivery under generation-complete", () => { + const reports = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "generation-complete", + }); + ledger.deferFor("origin")((report) => { + reports.push(report); + }); + ledger.bindResponse("voicer"); + ledger.generationCompleted("voicer"); + expect(reports).toEqual([ + expect.objectContaining({ responseId: "origin", outcome: "completed" }), + ]); + }); + + it("does not complete a live rendition when generation ends", () => { + const reports = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "buffer-drain", + }); + ledger.deferFor("origin")((report) => { + reports.push(report); + }); + ledger.bindResponse("voicer"); + ledger.playbackStarted("voicer"); + ledger.generationCompleted("voicer"); + expect(reports).toEqual([]); + ledger.playbackDrained("voicer"); + expect(reports[0]?.outcome).toBe("completed"); + }); + + it("revokes an unbound group under its origin id and never mints a placeholder", () => { + const reports = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "buffer-drain", + }); + ledger.deferFor("origin-unbound")((report) => { + reports.push(report); + }); + ledger.reset(); + expect(reports).toEqual([ + expect.objectContaining({ + responseId: "origin-unbound", + outcome: "interrupted", + }), + ]); + expect(reports[0]?.responseId).not.toBe("unbound-response"); + }); + + it("holds a drained report for attestation and settles on a later human turn", () => { + const reports = []; + const armed = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "buffer-drain", + attestationWindowMs: 1_000, + scheduler: (fn) => { + armed.push(fn); + return () => {}; + }, + }); + ledger.rememberOriginTurn("origin", "turn-review"); + ledger.attachReadbackHash("origin", "hash-1"); + ledger.deferFor("origin")((report) => { + reports.push(report); + }); + ledger.bindResponse("voicer"); + ledger.playbackDrained("voicer"); + expect(reports).toEqual([]); + + ledger.observeAttestation({ + act: "confirmed", + actId: "act-1", + readbackHash: "hash-1", + userTurnId: "turn-review", + }); + expect(reports).toEqual([]); + + ledger.observeAttestation({ + act: "confirmed", + actId: "act-2", + readbackHash: "hash-1", + userTurnId: "turn-confirm", + }); + expect(reports).toEqual([ + expect.objectContaining({ + responseId: "origin", + outcome: "completed", + readbackHash: "hash-1", + attestation: expect.objectContaining({ actId: "act-2" }), + }), + ]); + }); + + it("emits a completed report with neither attestation nor hash when the hold elapses", () => { + // The hash is what core reads to substantiate a claim to `attested`, so a + // report carrying one with no attestation is a claim that failed — and + // the kernel answers a failed claim by closing the consent generation. + // An elapsed hold is not a failed claim; it is a delivery nobody has + // answered yet. Emitting the bare hash made the timeout revoke consent, + // so a person confirming a moment later got `unknown_readback`. + const reports = []; + const armed = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "buffer-drain", + attestationWindowMs: 40, + scheduler: (fn) => { + armed.push(fn); + return () => {}; + }, + }); + ledger.attachReadbackHash("origin", "hash-1"); + ledger.deferFor("origin")((report) => { + reports.push(report); + }); + ledger.bindResponse("voicer"); + ledger.playbackDrained("voicer"); + expect(armed).toHaveLength(1); + armed[0]?.(); + expect(reports).toEqual([ + expect.objectContaining({ + responseId: "origin", + outcome: "completed", + }), + ]); + expect(reports[0]?.attestation).toBeUndefined(); + expect(reports[0]).not.toHaveProperty("readbackHash"); + }); + + it("settles interruption immediately and keeps playing groups when asked", () => { + const reports = []; + const ledger = createRealtimeDeliveryLedger({ + deliveryEvidence: "buffer-drain", + }); + ledger.deferFor("quiet")((report) => { + reports.push(report); + }); + ledger.deferFor("loud")((report) => { + reports.push(report); + }); + ledger.bindResponse("voice-quiet"); + ledger.bindResponse("voice-loud"); + ledger.playbackStarted("voice-loud"); + ledger.revokeAll({ retainPlaying: true }); + expect(reports).toEqual([ + expect.objectContaining({ responseId: "quiet", outcome: "interrupted" }), + ]); + ledger.playbackDrained("voice-loud"); + expect(reports).toHaveLength(2); + expect(reports[1]?.responseId).toBe("loud"); + expect(reports[1]?.outcome).toBe("completed"); + }); +}); + +describe("attestation snapshotting", () => { + it("rejects an accessor-backed attestation and never invokes its getters", () => { + const ledger = createRealtimeDeliveryLedger({ attestationWindowMs: 50 }); + let reads = 0; + const hostile = { + get act() { + reads += 1; + return "confirmed"; + }, + get actId() { + reads += 1; + return reads > 2 ? "swapped" : "act-1"; + }, + get readbackHash() { + reads += 1; + return "hash-1"; + }, + get userTurnId() { + reads += 1; + return "turn-2"; + }, + }; + + const reports = []; + ledger.deferFor("origin-1")((report) => reports.push(report)); + ledger.attachReadbackHash("origin-1", "hash-1"); + ledger.bindResponse("voice-1"); + ledger.playbackStarted("voice-1"); + ledger.playbackDrained("voice-1"); + + ledger.observeAttestation(hostile); + + // Every field is read through its own-data descriptor, so no getter runs + // and the attestation cannot settle the held group. + expect(reads).toBe(0); + expect(reports).toEqual([]); + }); + + it("carries a plain-data attestation through to the report", () => { + const ledger = createRealtimeDeliveryLedger({ attestationWindowMs: 50 }); + const reports = []; + ledger.deferFor("origin-1")((report) => reports.push(report)); + ledger.attachReadbackHash("origin-1", "hash-1"); + ledger.rememberOriginTurn("origin-1", "turn-1"); + ledger.bindResponse("voice-1"); + ledger.playbackStarted("voice-1"); + ledger.playbackDrained("voice-1"); + + ledger.observeAttestation({ + act: "confirmed", + actId: "act-1", + readbackHash: "hash-1", + userTurnId: "turn-2", + }); + + expect(reports).toHaveLength(1); + expect(reports[0].attestation).toMatchObject({ + act: "confirmed", + actId: "act-1", + readbackHash: "hash-1", + userTurnId: "turn-2", + }); + expect(Object.isFrozen(reports[0].attestation)).toBe(true); + }); +}); + +describe("bounded bookkeeping", () => { + it("does not rescan settled groups as a session runs on", () => { + const ledger = createRealtimeDeliveryLedger({}); + const reports = []; + + for (let index = 0; index < 200; index += 1) { + const origin = `origin-${index}`; + const voicer = `voice-${index}`; + ledger.deferFor(origin)((report) => reports.push(report)); + ledger.bindResponse(voicer); + ledger.playbackStarted(voicer); + ledger.playbackDrained(voicer); + } + expect(reports).toHaveLength(200); + expect(reports.every((report) => report.outcome === "completed")).toBe(true); + + // Every group above settled, so revoking has nothing left to find and + // emits no diagnostic for work that already finished. + const diagnostics = []; + const live = createRealtimeDeliveryLedger({ + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + for (let index = 0; index < 50; index += 1) { + const origin = `o-${index}`; + const voicer = `v-${index}`; + live.deferFor(origin)(() => undefined); + live.bindResponse(voicer); + live.playbackStarted(voicer); + live.playbackDrained(voicer); + } + live.revokeAll(); + expect(diagnostics).toEqual([]); + }); + + it("still reports an origin's readback hash after many later responses", () => { + // The hash only reaches a report alongside an attestation, so the + // attestation is how this asserts that `hashesByOrigin` kept the entry. + const armed = []; + const ledger = createRealtimeDeliveryLedger({ + attestationWindowMs: 40, + scheduler: (fn) => { + armed.push(fn); + return () => {}; + }, + }); + ledger.attachReadbackHash("origin-keep", "hash-keep"); + ledger.rememberOriginTurn("origin-keep", "turn-review"); + for (let index = 0; index < 100; index += 1) { + ledger.attachReadbackHash(`origin-${index}`, `hash-${index}`); + } + const reports = []; + ledger.deferFor("origin-keep")((report) => reports.push(report)); + ledger.bindResponse("voice-keep"); + ledger.playbackStarted("voice-keep"); + ledger.playbackDrained("voice-keep"); + ledger.observeAttestation({ + act: "confirmed", + actId: "act-keep", + readbackHash: "hash-keep", + userTurnId: "turn-confirm", + }); + + expect(reports).toHaveLength(1); + expect(reports[0].readbackHash).toBe("hash-keep"); + }); +}); diff --git a/packages/concierge-realtime/test/export-surface.test.ts b/packages/concierge-realtime/test/export-surface.test.ts new file mode 100644 index 0000000..36ac0cf --- /dev/null +++ b/packages/concierge-realtime/test/export-surface.test.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { beforeAll, describe, expect, it } from "vitest"; + +const EXPORT_BLOCK = /^export\s*\{([^}]*)\}\s*;?\s*$/gm; + +interface Surface { + readonly names: readonly string[]; + readonly values: readonly string[]; + readonly types: readonly string[]; +} + +function readSurface(relative: string): Surface { + const url = new URL(relative, import.meta.url); + const path = fileURLToPath(url); + if (!existsSync(path)) { + throw new Error( + `${path} is missing. This guard reads the BUILT declaration file. Run \`pnpm build\` first.`, + ); + } + const source = readFileSync(path, "utf8"); + const blocks = [...source.matchAll(EXPORT_BLOCK)]; + if (blocks.length === 0) { + throw new Error( + `no trailing \`export { … };\` statement found in ${relative}`, + ); + } + const entries = blocks + .flatMap((block) => (block[1] ?? "").split(",")) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => entry.replace(/\s+as\s+\w+$/u, "")); + return { + names: entries.map((entry) => entry.replace(/^type\s+/, "")), + values: entries.filter((entry) => !/^type\s/.test(entry)), + types: entries + .filter((entry) => /^type\s/.test(entry)) + .map((entry) => entry.replace(/^type\s+/, "")), + }; +} + +const ROOT_VALUES = [ + "createRealtimeDeliveryLedger", + "createRealtimeTurnLedger", + "createStopIntentClassifier", + "createRealtimeSession", +] as const; + +const ROOT_TYPES = [ + "RealtimeBatchSource", + "RealtimeBargeInPolicy", + "RealtimeCatalogPublication", + "RealtimeChannel", + "RealtimeChannelFault", + "RealtimeChannelState", + "RealtimeDeliveryEvidence", + "RealtimeDeliveryLedger", + "RealtimeDeliveryLedgerConfig", + "RealtimeDiagnostic", + "RealtimeDiagnosticCode", + "RealtimeForeignTool", + "RealtimePlaybackEvent", + "RealtimePlaybackSource", + "RealtimeProvider", + "RealtimeRuntimeStatus", + "RealtimeSessionHandle", + "RealtimeSessionOptions", + "RealtimeSignal", + "RealtimeStopIntentClassifier", + "RealtimeTranscriptEvent", + "RealtimeTurnLedger", + "RealtimeTurnLedgerConfig", + "RealtimeWireEvent", + "StopIntentOptions", +] as const; + +beforeAll(() => { + for (const relative of [ + "../dist/index.d.ts", + "../dist/openai/index.d.ts", + "../dist/webrtc/index.d.ts", + "../dist/websocket/index.d.ts", + ]) { + if (!existsSync(fileURLToPath(new URL(relative, import.meta.url)))) { + throw new Error( + `packages/concierge-realtime/${relative.slice(3)} is missing. Run \`pnpm build\` first.`, + ); + } + } +}); + +describe("the published export surface of concierge-realtime", () => { + it("pins the vendor-neutral root at 29 names — 25 types and 4 values", () => { + const surface = readSurface("../dist/index.d.ts"); + expect(surface.names).toHaveLength(29); + expect(surface.types).toHaveLength(25); + expect(surface.values).toHaveLength(4); + for (const name of ROOT_VALUES) expect(surface.values).toContain(name); + for (const name of ROOT_TYPES) expect(surface.types).toContain(name); + }); + + it("pins ./openai at createOpenAIRealtimeProvider and OpenAIRealtimeProviderOptions", () => { + const surface = readSurface("../dist/openai/index.d.ts"); + expect(surface.names).toHaveLength(2); + expect(surface.names).toEqual( + expect.arrayContaining([ + "createOpenAIRealtimeProvider", + "OpenAIRealtimeProviderOptions", + ]), + ); + expect(surface.values).toContain("createOpenAIRealtimeProvider"); + }); + + it("pins ./webrtc at the channel factory and five negotiation types", () => { + const surface = readSurface("../dist/webrtc/index.d.ts"); + expect(surface.names).toHaveLength(6); + expect(surface.names).toEqual( + expect.arrayContaining([ + "createWebRTCRealtimeChannel", + "WebRTCNegotiationRequest", + "WebRTCNegotiationAnswer", + "WebRTCRealtimeChannelOptions", + "WebRTCMediaControls", + "WebRTCRealtimeChannel", + ]), + ); + expect(surface.values).toContain("createWebRTCRealtimeChannel"); + }); + + it("pins ./websocket at createWebSocketRealtimeChannel and its options type", () => { + const surface = readSurface("../dist/websocket/index.d.ts"); + expect(surface.names).toHaveLength(2); + expect(surface.names).toEqual( + expect.arrayContaining([ + "createWebSocketRealtimeChannel", + "WebSocketRealtimeChannelOptions", + ]), + ); + expect(surface.values).toContain("createWebSocketRealtimeChannel"); + }); +}); diff --git a/packages/concierge-realtime/test/fixtures.ts b/packages/concierge-realtime/test/fixtures.ts new file mode 100644 index 0000000..ec8b783 --- /dev/null +++ b/packages/concierge-realtime/test/fixtures.ts @@ -0,0 +1,257 @@ +import type { + BatchDispatchOutcome, + ToolBatch, +} from "@full-self-browsing/concierge"; +import type { + RealtimeChannel, + RealtimeChannelState, + RealtimeProvider, + RealtimeSignal, + RealtimeWireEvent, +} from "../src/types.js"; + +const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); + +export function resetContract(): void { + delete (globalThis as Record)[CONTRACT_KEY]; +} + +export function schema() { + return { + "~standard": { + version: 1, + vendor: "concierge-realtime-test", + validate: (value: unknown) => ({ value }), + }, + }; +} + +export function action( + name: string, + handler: () => { ok: boolean; message: string }, +) { + return { + name, + description: `Run ${name}.`, + schema: schema(), + jsonSchema: { type: "object" }, + redact: "drop", + effects: { readOnly: true }, + handler, + }; +} + +export interface FakeChannel { + readonly channel: RealtimeChannel; + readonly sent: RealtimeWireEvent[]; + emit(event: unknown): void; + setSendResult(next: boolean): void; + setAutoAck(next: boolean): void; +} + +export function createFakeChannel(autoAck = true): FakeChannel { + let state: RealtimeChannelState = "idle"; + let sendResult = true; + let shouldAck = autoAck; + const sent: RealtimeWireEvent[] = []; + const eventListeners = new Set<(event: unknown) => void>(); + const stateListeners = new Set< + (state: RealtimeChannelState) => void + >(); + + const emit = (event: unknown): void => { + for (const listener of [...eventListeners]) listener(event); + }; + + const channel: RealtimeChannel = { + get state() { + return state; + }, + async open() { + state = "opening"; + state = "open"; + for (const listener of [...stateListeners]) listener(state); + }, + send(event) { + sent.push(event); + if ( + shouldAck && + event.type === "session.update" && + typeof event.event_id === "string" + ) { + queueMicrotask(() => { + emit({ + kind: "session.configured", + correlationId: event.event_id, + }); + }); + } + return sendResult; + }, + onEvent(cb) { + eventListeners.add(cb); + return () => { + eventListeners.delete(cb); + }; + }, + onStateChange(cb) { + stateListeners.add(cb); + return () => { + stateListeners.delete(cb); + }; + }, + close() { + state = "closed"; + for (const listener of [...stateListeners]) listener(state); + }, + }; + + return { + channel, + sent, + emit, + setSendResult(next) { + sendResult = next; + }, + setAutoAck(next) { + shouldAck = next; + }, + }; +} + +export function createTestProvider( + overrides: Partial = {}, +): RealtimeProvider { + return { + id: "test", + playback: "provider-signalled", + echoesCorrelationId: true, + decode(event: unknown): RealtimeSignal { + if (typeof event !== "object" || event === null) { + return { kind: "ignored" }; + } + const record = event as { kind?: string; [key: string]: unknown }; + if (record.kind === "session.ready") return { kind: "session.ready" }; + if (record.kind === "session.configured") { + return { + kind: "session.configured", + correlationId: + typeof record.correlationId === "string" + ? record.correlationId + : null, + }; + } + if (record.kind === "session.rejected") { + return { + kind: "session.rejected", + correlationId: + typeof record.correlationId === "string" + ? record.correlationId + : null, + recoverable: record.recoverable === true, + message: "rejected", + }; + } + if (record.kind === "turn.started" && typeof record.turnId === "string") { + return { kind: "turn.started", turnId: record.turnId }; + } + if ( + record.kind === "turn.transcribed" && + typeof record.turnId === "string" && + typeof record.text === "string" + ) { + return { + kind: "turn.transcribed", + turnId: record.turnId, + text: record.text, + }; + } + if ( + record.kind === "response.created" && + typeof record.responseId === "string" + ) { + return { kind: "response.created", responseId: record.responseId }; + } + if ( + record.kind === "response.transcript" && + typeof record.responseId === "string" && + typeof record.text === "string" + ) { + return { + kind: "response.transcript", + responseId: record.responseId, + text: record.text, + }; + } + if ( + record.kind === "response.completed" && + typeof record.responseId === "string" + ) { + return { + kind: "response.completed", + responseId: record.responseId, + raw: record.raw, + }; + } + if (record.kind === "playback" && typeof record.responseId === "string") { + const playbackKind = record.playbackKind; + if ( + playbackKind === "started" || + playbackKind === "drained" || + playbackKind === "cleared" + ) { + return { + kind: "playback", + event: { kind: playbackKind, responseId: record.responseId }, + }; + } + } + return { kind: "ignored" }; + }, + encodeCatalog(publication) { + return { + type: "session.update", + event_id: publication.correlationId, + tools: publication.foreignTools?.length ?? 0, + }; + }, + extractBatch(source): ToolBatch | null { + const raw = source.raw; + if (typeof raw !== "object" || raw === null) return null; + const calls = (raw as { calls?: unknown }).calls; + if (!Array.isArray(calls) || calls.length === 0) return null; + return { + sessionId: source.sessionId, + responseId: (raw as { responseId?: string }).responseId ?? "response-N", + catalogRevision: source.catalogRevision, + userTurnId: source.userTurnId, + calls: calls as ToolBatch["calls"], + signal: source.signal, + deferUntilDelivered: source.deferUntilDelivered, + }; + }, + encodeToolResults(outcome: BatchDispatchOutcome) { + if (outcome.kind === "terminal") return []; + return outcome.rows.map((row) => + Object.freeze({ + type: "conversation.item.create", + call_id: row.callId, + }), + ); + }, + encodeFollowUp() { + return { type: "response.create" }; + }, + encodeUserText(text) { + return [{ type: "user", text }]; + }, + encodeInterrupt() { + return { type: "response.cancel" }; + }, + ...overrides, + }; +} + +export function presentOutcome() { + return Promise.resolve({ outcome: "completed" as const }); +} diff --git a/packages/concierge-realtime/test/no-dom.test.ts b/packages/concierge-realtime/test/no-dom.test.ts new file mode 100644 index 0000000..47a3375 --- /dev/null +++ b/packages/concierge-realtime/test/no-dom.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const DOM_IDENTIFIERS = + /\b(?:document|window|navigator|RTCPeerConnection|HTMLAudioElement|MediaStream|WebSocket)\b/; + +function readSrc(relative: string): string { + return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), "utf8"); +} + +describe("DOM-free subpaths", () => { + it("keeps the vendor-neutral runtime free of DOM and WebRTC identifiers", () => { + for (const relative of [ + "../src/index.ts", + "../src/types.ts", + "../src/host.ts", + "../src/delivery-ledger.ts", + "../src/turn-ledger.ts", + "../src/stop-intent.ts", + "../src/session.ts", + "../src/openai/index.ts", + ]) { + expect(readSrc(relative), relative).not.toMatch(DOM_IDENTIFIERS); + } + }); + + it("keeps webrtc free of document queries", () => { + expect(readSrc("../src/webrtc/index.ts")).not.toMatch( + /querySelector|getElementById|document\./, + ); + }); +}); diff --git a/packages/concierge-realtime/test/openai.test.ts b/packages/concierge-realtime/test/openai.test.ts new file mode 100644 index 0000000..b181099 --- /dev/null +++ b/packages/concierge-realtime/test/openai.test.ts @@ -0,0 +1,241 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { createOpenAIRealtimeProvider } from "../src/openai/index.js"; +import { resetContract } from "./fixtures.js"; + +const CORE_URL = new URL("../../concierge/dist/index.js", import.meta.url); + +let createConcierge; + +beforeAll(async () => { + if (!existsSync(fileURLToPath(CORE_URL))) { + throw new Error("Build @full-self-browsing/concierge before testing openai."); + } + ({ createConcierge } = await import(CORE_URL.href)); +}); + +beforeEach(() => { + resetContract(); +}); + +function catalogFor(create) { + const concierge = create({ + stages: [ + { + id: "active", + match: () => true, + actions: [ + { + name: "lookup", + description: "Look up an item.", + schema: { + "~standard": { + version: 1, + vendor: "realtime-openai-test", + validate: (value) => ({ value }), + }, + }, + jsonSchema: { + type: "object", + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "lookup", + properties: {}, + }, + redact: "drop", + effects: { readOnly: true }, + handler: () => ({ ok: true, message: "Done." }), + }, + ], + }, + ], + }); + return concierge.resolveCatalog({ page: "active" }); +} + +describe("createOpenAIRealtimeProvider", () => { + it("consumes all three core codec methods on the catalog, batch, and result path", () => { + const provider = createOpenAIRealtimeProvider({ sessionType: "realtime" }); + const catalog = catalogFor(createConcierge); + const encoded = provider.encodeCatalog({ + catalog, + foreignTools: [{ type: "function", name: "serverLookup" }], + correlationId: "concierge-su-1", + }); + expect(encoded).toMatchObject({ + type: "session.update", + event_id: "concierge-su-1", + session: { type: "realtime" }, + }); + const tools = encoded.session.tools; + expect(Array.isArray(tools)).toBe(true); + expect(tools.some((tool) => tool.name === "lookup")).toBe(true); + expect(tools.some((tool) => tool.name === "serverLookup")).toBe(true); + const lookup = tools.find((tool) => tool.name === "lookup"); + expect(lookup?.parameters).not.toHaveProperty("$schema"); + expect(lookup?.parameters).not.toHaveProperty("$id"); + + const batch = provider.extractBatch({ + raw: { + type: "response.done", + response: { + id: "resp-1", + status: "completed", + output: [ + { + type: "function_call", + status: "completed", + call_id: "call-1", + name: "lookup", + arguments: "{}", + }, + ], + }, + }, + sessionId: "session-1", + userTurnId: "turn-1", + catalogRevision: catalog.revision, + signal: { aborted: false, addEventListener() {}, removeEventListener() {} }, + deferUntilDelivered: undefined, + }); + expect(batch).toMatchObject({ + responseId: "resp-1", + userTurnId: "turn-1", + calls: [{ callId: "call-1", name: "lookup" }], + }); + + const results = provider.encodeToolResults({ + kind: "completed", + rows: [ + { + dispatchId: "d1", + callId: "call-1", + name: "lookup", + outputIndex: 0, + result: { ok: true, message: "Done." }, + }, + ], + }); + expect(results).toEqual([ + expect.objectContaining({ + type: "conversation.item.create", + item: expect.objectContaining({ + type: "function_call_output", + call_id: "call-1", + }), + }), + ]); + expect(provider.encodeFollowUp()).toEqual({ type: "response.create" }); + expect(provider.encodeInterrupt()).toEqual({ type: "response.cancel" }); + }); + + it("rejects every session.updated when sessionType is omitted", () => { + const provider = createOpenAIRealtimeProvider(); + expect(provider.decode({ type: "session.updated", event_id: "evt-1" })).toEqual({ + kind: "session.rejected", + correlationId: "evt-1", + recoverable: true, + message: "Session type was not declared.", + }); + }); + + it("decodes the OpenAI event taxonomy into the neutral signal set", () => { + const provider = createOpenAIRealtimeProvider({ + sessionType: "realtime", + correlationIdPrefix: "concierge-su-", + }); + expect(provider.decode({ type: "session.created" })).toEqual({ + kind: "session.ready", + }); + expect(provider.decode({ type: "session.updated", event_id: "srv-1" })).toEqual({ + kind: "session.configured", + correlationId: "srv-1", + }); + expect( + provider.decode({ + type: "error", + error: { + message: "bad tool", + param: "session.tools[0].parameters", + event_id: "concierge-su-9", + }, + }), + ).toEqual({ + kind: "session.rejected", + correlationId: "concierge-su-9", + recoverable: true, + message: "bad tool", + }); + expect( + provider.decode({ + type: "error", + error: { message: "fatal", event_id: "other" }, + }), + ).toEqual({ kind: "error", recoverable: false, message: "fatal" }); + expect( + provider.decode({ + type: "input_audio_buffer.speech_started", + item_id: "item-1", + }), + ).toEqual({ kind: "turn.started", turnId: "item-1" }); + expect( + provider.decode({ + type: "conversation.item.input_audio_transcription.completed", + item_id: "item-1", + transcript: "hello", + }), + ).toEqual({ kind: "turn.transcribed", turnId: "item-1", text: "hello" }); + expect( + provider.decode({ + type: "response.created", + response: { id: "resp-1" }, + }), + ).toEqual({ kind: "response.created", responseId: "resp-1" }); + expect( + provider.decode({ + type: "response.output_audio_transcript.done", + response_id: "resp-1", + transcript: "Hi.", + }), + ).toEqual({ + kind: "response.transcript", + responseId: "resp-1", + text: "Hi.", + }); + expect( + provider.decode({ + type: "output_audio_buffer.stopped", + response_id: "resp-1", + }), + ).toEqual({ + kind: "playback", + event: { kind: "drained", responseId: "resp-1" }, + }); + expect( + provider.decode({ + type: "response.done", + response: { id: "resp-1", status: "cancelled" }, + }), + ).toEqual({ kind: "response.aborted", responseId: "resp-1" }); + expect(provider.decode({ type: "response.function_call_arguments.delta" })).toEqual({ + kind: "ignored", + }); + }); + + it("does not inspect accessors on hostile event records", () => { + const provider = createOpenAIRealtimeProvider({ sessionType: "realtime" }); + let reads = 0; + const hostile = { type: "input_audio_buffer.speech_started" }; + Object.defineProperty(hostile, "item_id", { + enumerable: true, + get() { + reads += 1; + return "item-1"; + }, + }); + expect(provider.decode(hostile)).toEqual({ kind: "ignored" }); + expect(reads).toBe(0); + }); +}); diff --git a/packages/concierge-realtime/test/session.test.ts b/packages/concierge-realtime/test/session.test.ts new file mode 100644 index 0000000..9cc7799 --- /dev/null +++ b/packages/concierge-realtime/test/session.test.ts @@ -0,0 +1,373 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createRealtimeSession } from "../src/session.js"; +import { + action, + createFakeChannel, + createTestProvider, + presentOutcome, + resetContract, +} from "./fixtures.js"; + +const CORE_URL = new URL("../../concierge/dist/index.js", import.meta.url); +const ACTIVE = Object.freeze({ page: "active" }); + +let createConcierge; + +beforeAll(async () => { + if (!existsSync(fileURLToPath(CORE_URL))) { + throw new Error("Build @full-self-browsing/concierge before testing realtime."); + } + ({ createConcierge } = await import(CORE_URL.href)); +}); + +beforeEach(() => { + resetContract(); +}); + +function conciergeFor(handler = () => ({ ok: true, message: "Done." })) { + return createConcierge({ + stages: [ + { + id: "active", + match: (context) => context.page === "active", + actions: [action("lookup", handler)], + }, + ], + }); +} + +async function openSession(overrides = {}) { + const fake = createFakeChannel(); + const reports = []; + const handle = await createRealtimeSession({ + concierge: conciergeFor(({ meta }) => { + meta.deferUntilDelivered?.((report) => { + reports.push(report); + }); + return { ok: true, message: "Looked up." }; + }), + channel: fake.channel, + provider: createTestProvider(), + presentOutcome, + initialContext: ACTIVE, + sessionId: "session-1", + turnSource: "detected", + ...overrides, + }); + return { fake, handle, reports }; +} + +describe("createRealtimeSession", () => { + it("opens the channel, waits for the first catalog ack, and exposes a listening handle", async () => { + const { fake, handle } = await openSession(); + expect(handle.status()).toBe("listening"); + expect(handle.catalogSettled()).toBe(true); + expect(handle.session.catalog()).not.toBeNull(); + expect(fake.sent.some((event) => event.type === "session.update")).toBe(true); + await handle.stop(); + expect(handle.status()).toBe("closed"); + await handle.stop(); + }); + + it("throws beginTurn under detected turns and opens a turn under explicit", async () => { + const detected = await openSession({ turnSource: "detected" }); + expect(() => detected.handle.beginTurn("turn-1")).toThrow( + "beginTurn is only available when turnSource is \"explicit\"", + ); + await detected.handle.stop(); + + resetContract(); + const explicit = await openSession({ turnSource: "explicit" }); + explicit.handle.beginTurn("turn-explicit"); + expect(explicit.handle.sendUserText("hello")).toBe(true); + expect(explicit.fake.sent.some((event) => event.type === "user")).toBe(true); + await explicit.handle.stop(); + }); + + it("clamps requested attested down to relayed without an attestation window", async () => { + const concierge = createConcierge({ + consentProfile: { + consentGrade: "attested", + userTurnIdentity: "agent-forgeable", + }, + stages: [ + { + id: "active", + match: () => true, + actions: [action("lookup", () => ({ ok: true, message: "Done." }))], + }, + ], + }); + const fake = createFakeChannel(); + await expect( + createRealtimeSession({ + concierge, + channel: fake.channel, + provider: createTestProvider(), + presentOutcome, + initialContext: ACTIVE, + sessionId: "session-1", + turnSource: "detected", + consentGrade: "attested", + }), + ).rejects.toThrow("The realtime session could not start."); + }); + + it("clamps relayed to delivered when revokeOn is playback-cleared", async () => { + const concierge = createConcierge({ + consentProfile: { + consentGrade: "relayed", + userTurnIdentity: "agent-forgeable", + }, + stages: [ + { + id: "active", + match: () => true, + actions: [action("lookup", () => ({ ok: true, message: "Done." }))], + }, + ], + }); + const fake = createFakeChannel(); + await expect( + createRealtimeSession({ + concierge, + channel: fake.channel, + provider: createTestProvider(), + presentOutcome, + initialContext: ACTIVE, + sessionId: "session-1", + turnSource: "detected", + consentGrade: "relayed", + bargeIn: { revokeOn: "playback-cleared" }, + }), + ).rejects.toThrow("The realtime session could not start."); + }); + + it("clamps above none to none when a host-signalled provider has no playback source", async () => { + const diagnostics = []; + const { handle } = await openSession({ + provider: createTestProvider({ playback: "host-signalled" }), + consentGrade: "relayed", + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + expect(diagnostics.map((row) => row.code)).toContain("playback_source_missing"); + await handle.stop(); + }); + + it("dispatches a batch through core and reports delivery under origin N, not voicer M", async () => { + const { fake, handle, reports } = await openSession(); + fake.emit({ kind: "turn.started", turnId: "turn-1" }); + fake.emit({ kind: "response.created", responseId: "response-N" }); + fake.emit({ + kind: "response.completed", + responseId: "response-N", + raw: { + responseId: "response-N", + calls: [ + { + callId: "call-1", + name: "lookup", + arguments: "{}", + outputIndex: 0, + }, + ], + }, + }); + await vi.waitFor(() => { + expect( + fake.sent.some((event) => event.type === "conversation.item.create"), + ).toBe(true); + expect(fake.sent.some((event) => event.type === "response.create")).toBe( + true, + ); + }); + + fake.emit({ kind: "response.created", responseId: "response-M" }); + fake.emit({ + kind: "playback", + playbackKind: "started", + responseId: "response-M", + }); + fake.emit({ + kind: "playback", + playbackKind: "drained", + responseId: "response-M", + }); + expect(reports).toEqual([ + expect.objectContaining({ + responseId: "response-N", + outcome: "completed", + }), + ]); + expect(reports[0]?.responseId).not.toBe("response-M"); + await handle.stop(); + }); + + it("emits pending agent transcripts and discards them when playback is cleared", async () => { + const transcripts = []; + const { fake, handle } = await openSession({ + onTranscript: (event) => transcripts.push(event), + }); + fake.emit({ kind: "turn.started", turnId: "turn-1" }); + fake.emit({ + kind: "turn.transcribed", + turnId: "turn-1", + text: "look that up", + }); + fake.emit({ kind: "response.created", responseId: "r1" }); + fake.emit({ + kind: "response.transcript", + responseId: "r1", + text: "Here is the item.", + }); + expect(transcripts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "human", + status: "final", + text: "look that up", + }), + expect.objectContaining({ + role: "agent", + status: "pending", + responseId: "r1", + }), + ]), + ); + fake.emit({ + kind: "playback", + playbackKind: "cleared", + responseId: "r1", + }); + expect(transcripts.at(-1)).toEqual( + expect.objectContaining({ status: "discarded", responseId: "r1" }), + ); + await handle.stop(); + }); + + it("runs stop-intent only on a committed transcript and sends an interrupt", async () => { + const { fake, handle } = await openSession({ + stopIntent: (text) => text.trim().toLowerCase() === "stop", + }); + fake.emit({ kind: "response.created", responseId: "r1" }); + fake.emit({ + kind: "turn.transcribed", + turnId: "turn-stop", + text: "stop", + }); + expect(fake.sent.some((event) => event.type === "response.cancel")).toBe(true); + await handle.stop(); + }); + + it("contains a throwing decoder and never calls console", async () => { + const diagnostics = []; + const baseline = createTestProvider(); + const { fake, handle } = await openSession({ + provider: createTestProvider({ + decode(event) { + if ( + typeof event === "object" && + event !== null && + (event as { type?: unknown }).type === "garbage" + ) { + throw new Error("malformed-frame"); + } + return baseline.decode(event); + }, + }), + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + const warn = console.warn; + const error = console.error; + const log = console.log; + const calls = []; + console.warn = (...args) => { + calls.push(args); + }; + console.error = (...args) => { + calls.push(args); + }; + console.log = (...args) => { + calls.push(args); + }; + try { + fake.emit({ type: "garbage" }); + } finally { + console.warn = warn; + console.error = error; + console.log = log; + } + expect(calls).toEqual([]); + expect(diagnostics.map((row) => row.code)).toContain("provider_decode_failed"); + expect(handle.status()).toBe("listening"); + await handle.stop(); + }); + + it("keeps the session on a recoverable catalog rejection", async () => { + const diagnostics = []; + const concierge = createConcierge({ + stages: [ + { + id: "active", + match: () => true, + actions: [ + { + ...action("lookup", () => ({ ok: true, message: "Done." })), + availableWhen: (context) => context.page === "active", + }, + ], + }, + ], + }); + const fake = createFakeChannel(); + const handle = await createRealtimeSession({ + concierge, + channel: fake.channel, + provider: createTestProvider(), + presentOutcome, + initialContext: ACTIVE, + sessionId: "session-1", + turnSource: "detected", + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + fake.setAutoAck(false); + handle.session.setContext({ page: "next" }); + const update = [...fake.sent] + .reverse() + .find((event) => event.type === "session.update"); + fake.emit({ + kind: "session.rejected", + correlationId: + typeof update?.event_id === "string" ? update.event_id : null, + recoverable: true, + }); + expect(handle.status()).toBe("listening"); + expect(handle.session.catalog()).not.toBeNull(); + expect(diagnostics.map((row) => row.code)).toContain("catalog_rejected"); + await handle.stop(); + }); + + it("rejects start when the first catalog acknowledgement never arrives", async () => { + const fake = createFakeChannel(false); + await expect( + createRealtimeSession({ + concierge: conciergeFor(), + channel: fake.channel, + provider: createTestProvider(), + presentOutcome, + initialContext: ACTIVE, + sessionId: "session-1", + turnSource: "detected", + acknowledgementTimeoutMs: 1, + scheduler: (fn) => { + fn(); + return () => {}; + }, + }), + ).rejects.toThrow("The realtime session could not start."); + }); +}); diff --git a/packages/concierge-realtime/test/stop-intent.test.ts b/packages/concierge-realtime/test/stop-intent.test.ts new file mode 100644 index 0000000..0f3992d --- /dev/null +++ b/packages/concierge-realtime/test/stop-intent.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { createStopIntentClassifier } from "../src/stop-intent.js"; + +describe("createStopIntentClassifier", () => { + it("matches a whole committed English transcript after stripping fillers", () => { + const classify = createStopIntentClassifier(); + expect(classify("stop")).toBe(true); + expect(classify("uh, stop")).toBe(true); + expect(classify('"Stop it"')).toBe(true); + expect(classify("never mind")).toBe(true); + expect(classify("hold on")).toBe(true); + }); + + it("does not match a partial or mid-sentence mention", () => { + const classify = createStopIntentClassifier(); + expect(classify("do not stop looking")).toBe(false); + expect(classify("stop looking")).toBe(false); + expect(classify("stopover")).toBe(false); + expect(classify("please continue")).toBe(false); + expect(classify("")).toBe(false); + }); + + it("uses caller phrases so a non-English app can replace the default set", () => { + const classify = createStopIntentClassifier({ + phrases: ["basta"], + fillers: ["eh"], + }); + expect(classify("eh, basta")).toBe(true); + expect(classify("stop")).toBe(false); + }); +}); diff --git a/packages/concierge-realtime/test/turn-ledger.test.ts b/packages/concierge-realtime/test/turn-ledger.test.ts new file mode 100644 index 0000000..3557162 --- /dev/null +++ b/packages/concierge-realtime/test/turn-ledger.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { createRealtimeTurnLedger } from "../src/turn-ledger.js"; + +describe("createRealtimeTurnLedger", () => { + it("is latest-wins and binds many responses to one turn without minting ids", () => { + const ledger = createRealtimeTurnLedger({ + provenance: "human-attested", + }); + expect(ledger.bindResponse("r1")).toBeNull(); + expect(ledger.currentTurnId()).toBeNull(); + + ledger.openTurn("turn-a"); + ledger.openTurn("turn-b"); + expect(ledger.currentTurnId()).toBe("turn-b"); + expect(ledger.bindResponse("r1")).toBe("turn-b"); + expect(ledger.bindResponse("r2")).toBe("turn-b"); + expect(ledger.turnOf("r1")).toBe("turn-b"); + expect(ledger.turnOf("r2")).toBe("turn-b"); + + ledger.openTurn("turn-c"); + expect(ledger.bindResponse("r3")).toBe("turn-c"); + expect(ledger.turnOf("r1")).toBe("turn-b"); + }); + + it("evicts the oldest response binding when the retention bound is exceeded", () => { + const ledger = createRealtimeTurnLedger({ + provenance: "agent-forgeable", + maxTrackedResponses: 2, + }); + ledger.openTurn("turn-1"); + ledger.bindResponse("r1"); + ledger.bindResponse("r2"); + ledger.bindResponse("r3"); + expect(ledger.turnOf("r1")).toBeNull(); + expect(ledger.turnOf("r2")).toBe("turn-1"); + expect(ledger.turnOf("r3")).toBe("turn-1"); + }); + + it("releases a response and clears all state on reset", () => { + const ledger = createRealtimeTurnLedger({ provenance: "none" }); + ledger.openTurn("turn-1"); + ledger.bindResponse("r1"); + ledger.releaseResponse("r1"); + expect(ledger.turnOf("r1")).toBeNull(); + ledger.bindResponse("r2"); + ledger.reset(); + expect(ledger.currentTurnId()).toBeNull(); + expect(ledger.turnOf("r2")).toBeNull(); + expect(ledger.provenance).toBe("none"); + }); +}); diff --git a/packages/concierge-realtime/test/webrtc.test.ts b/packages/concierge-realtime/test/webrtc.test.ts new file mode 100644 index 0000000..39ecb03 --- /dev/null +++ b/packages/concierge-realtime/test/webrtc.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import { createLocalAbortController } from "../src/host.js"; +import { createWebRTCRealtimeChannel } from "../src/webrtc/index.js"; + +function createFakeTrack(enabled = true) { + return { + enabled, + kind: "audio", + stop() {}, + }; +} + +function createFakeStream(tracks = [createFakeTrack()]) { + return { + getTracks: () => tracks, + getAudioTracks: () => tracks, + }; +} + +function createFakeDataChannel(readyState = "open") { + const listeners = new Map(); + return { + readyState, + listeners, + addEventListener(type, listener) { + const bucket = listeners.get(type) ?? []; + bucket.push(listener); + listeners.set(type, bucket); + }, + removeEventListener(type, listener) { + const bucket = listeners.get(type) ?? []; + listeners.set( + type, + bucket.filter((current) => current !== listener), + ); + }, + send() {}, + close() { + this.readyState = "closed"; + }, + open() { + this.readyState = "open"; + for (const listener of listeners.get("open") ?? []) listener(); + }, + emitMessage(data) { + for (const listener of listeners.get("message") ?? []) { + listener({ data }); + } + }, + }; +} + +function createFakePeer(dataChannel) { + const listeners = new Map(); + let connectionState = "new"; + return { + connectionState, + localDescription: { sdp: "offer-sdp" }, + addEventListener(type, listener) { + const bucket = listeners.get(type) ?? []; + bucket.push(listener); + listeners.set(type, bucket); + }, + addTrack() {}, + createDataChannel() { + return dataChannel; + }, + async createOffer() { + return { sdp: "offer-sdp", type: "offer" }; + }, + async setLocalDescription() {}, + async setRemoteDescription() {}, + close() { + connectionState = "closed"; + this.connectionState = "closed"; + }, + emit(type, event) { + for (const listener of listeners.get(type) ?? []) listener(event); + }, + setConnectionState(next) { + connectionState = next; + this.connectionState = next; + this.emit("connectionstatechange"); + }, + }; +} + +function createFakeAudio() { + return { + autoplay: false, + srcObject: null, + play: async () => {}, + pause() {}, + }; +} + +describe("createWebRTCRealtimeChannel", () => { + it("opens through injected peer, mic, and audio factories without querying the document", async () => { + const dataChannel = createFakeDataChannel(); + const peer = createFakePeer(dataChannel); + const audio = createFakeAudio(); + const local = createFakeStream(); + const remote = createFakeStream(); + const negotiated = []; + const events = []; + const channel = createWebRTCRealtimeChannel({ + negotiate: async (request) => { + negotiated.push(request.sdp); + return { sdp: "answer-sdp" }; + }, + peerConnectionFactory: () => peer, + requestMicrophone: async () => local, + audioElementFactory: () => audio, + }); + channel.onEvent((event) => events.push(event)); + + const abort = createLocalAbortController(); + await channel.open(abort.signal); + + expect(channel.state).toBe("open"); + expect(negotiated).toEqual(["offer-sdp"]); + expect(channel.media.localStream()).toBe(local); + peer.emit("track", { streams: [remote] }); + expect(channel.media.remoteStream()).toBe(remote); + expect(audio.srcObject).toBe(remote); + expect(audio.autoplay).toBe(true); + + expect(channel.send({ type: "ping" })).toBe(true); + dataChannel.emitMessage(JSON.stringify({ type: "pong" })); + expect(events).toEqual([{ type: "pong" }]); + + channel.media.setMicrophoneEnabled(false); + expect(channel.media.microphoneEnabled()).toBe(false); + expect(local.getAudioTracks()[0]?.enabled).toBe(false); + + channel.close(); + expect(channel.state).toBe("closed"); + }); + + it("abandons the handshake when the signal aborts", async () => { + const dataChannel = createFakeDataChannel(); + const peer = createFakePeer(dataChannel); + let releaseMic; + const mic = new Promise((resolve) => { + releaseMic = resolve; + }); + const channel = createWebRTCRealtimeChannel({ + negotiate: async () => ({ sdp: "answer-sdp" }), + peerConnectionFactory: () => peer, + requestMicrophone: () => mic, + audioElementFactory: () => createFakeAudio(), + }); + const abort = createLocalAbortController(); + const opening = channel.open(abort.signal); + abort.abort(); + releaseMic?.(createFakeStream()); + await expect(opening).rejects.toThrow("aborted"); + expect(channel.state).toBe("closed"); + }); + + it("does not contain a document query in the webrtc source contract", () => { + expect(createWebRTCRealtimeChannel.toString()).not.toMatch( + /querySelector|getElementById|document\./, + ); + }); +}); diff --git a/packages/concierge-realtime/test/websocket.test.ts b/packages/concierge-realtime/test/websocket.test.ts new file mode 100644 index 0000000..ba67ff2 --- /dev/null +++ b/packages/concierge-realtime/test/websocket.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { createLocalAbortController } from "../src/host.js"; +import { createWebSocketRealtimeChannel } from "../src/websocket/index.js"; + +function createFakeSocket() { + const listeners = new Map(); + return { + readyState: 0, + listeners, + addEventListener(type, listener) { + const bucket = listeners.get(type) ?? []; + bucket.push(listener); + listeners.set(type, bucket); + }, + removeEventListener(type, listener) { + const bucket = listeners.get(type) ?? []; + listeners.set( + type, + bucket.filter((current) => current !== listener), + ); + }, + send() {}, + close() { + this.readyState = 3; + }, + open() { + this.readyState = 1; + for (const listener of listeners.get("open") ?? []) listener(); + }, + emitMessage(data) { + for (const listener of listeners.get("message") ?? []) { + listener({ data }); + } + }, + emitClose() { + this.readyState = 3; + for (const listener of listeners.get("close") ?? []) listener(); + }, + }; +} + +describe("createWebSocketRealtimeChannel", () => { + it("opens an injected socket, sends JSON, and forwards parsed events", async () => { + const socket = createFakeSocket(); + const urls = []; + const events = []; + const states = []; + const channel = createWebSocketRealtimeChannel({ + url: async () => { + urls.push("resolved"); + return "wss://example.test/realtime"; + }, + protocols: ["realtime"], + socketFactory: (url, protocols) => { + expect(url).toBe("wss://example.test/realtime"); + expect(protocols).toEqual(["realtime"]); + queueMicrotask(() => { + socket.open(); + }); + return socket; + }, + }); + channel.onEvent((event) => events.push(event)); + channel.onStateChange((state) => states.push(state)); + + const abort = createLocalAbortController(); + await channel.open(abort.signal); + expect(channel.state).toBe("open"); + expect(urls).toEqual(["resolved"]); + expect(channel.send({ type: "ping" })).toBe(true); + socket.emitMessage(JSON.stringify({ type: "pong" })); + expect(events).toEqual([{ type: "pong" }]); + + socket.emitClose(); + expect(channel.state).toBe("closed"); + expect(states).toContain("closed"); + }); + + it("abandons a mid-handshake socket when the signal aborts", async () => { + const socket = createFakeSocket(); + const channel = createWebSocketRealtimeChannel({ + url: () => "wss://example.test/realtime", + socketFactory: () => socket, + }); + const abort = createLocalAbortController(); + const opening = channel.open(abort.signal); + abort.abort(); + await expect(opening).rejects.toThrow("aborted"); + expect(channel.state).toBe("closed"); + }); +}); diff --git a/packages/concierge-realtime/tsconfig.dom.json b/packages/concierge-realtime/tsconfig.dom.json new file mode 100644 index 0000000..8da056a --- /dev/null +++ b/packages/concierge-realtime/tsconfig.dom.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/webrtc/**/*.ts", + "src/websocket/**/*.ts", + "src/types.ts", + "src/host.ts" + ] +} diff --git a/packages/concierge-realtime/tsconfig.json b/packages/concierge-realtime/tsconfig.json new file mode 100644 index 0000000..bbd2832 --- /dev/null +++ b/packages/concierge-realtime/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/webrtc/**", "src/websocket/**"] +} diff --git a/packages/concierge-realtime/tsdown.config.ts b/packages/concierge-realtime/tsdown.config.ts new file mode 100644 index 0000000..42926f1 --- /dev/null +++ b/packages/concierge-realtime/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/openai/index.ts"], + format: ["esm"], + platform: "neutral", + dts: true, + clean: true, + outDir: "dist", + deps: { + neverBundle: [ + "@full-self-browsing/concierge", + "@full-self-browsing/concierge/openai-realtime", + ], + }, + publint: false, + attw: false, +}); diff --git a/packages/concierge-realtime/tsdown.dom.config.ts b/packages/concierge-realtime/tsdown.dom.config.ts new file mode 100644 index 0000000..459c0e6 --- /dev/null +++ b/packages/concierge-realtime/tsdown.dom.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/webrtc/index.ts", "src/websocket/index.ts"], + format: ["esm"], + platform: "browser", + dts: true, + clean: false, + outDir: "dist", + tsconfig: "tsconfig.dom.json", + deps: { + neverBundle: ["@full-self-browsing/concierge"], + }, + publint: { level: "error" }, + attw: { level: "error", profile: "esm-only" }, +}); diff --git a/packages/concierge-svelte/CHANGELOG.md b/packages/concierge-svelte/CHANGELOG.md index 454e931..db48bcd 100644 --- a/packages/concierge-svelte/CHANGELOG.md +++ b/packages/concierge-svelte/CHANGELOG.md @@ -1,5 +1,14 @@ # @full-self-browsing/concierge-svelte +## 0.4.0 + +### Minor Changes + +- 2ffb743: Ship `@full-self-browsing/concierge-dom`: registered-element resolve, reveal, and untrusted readback. +- 2ffb743: Ship Concierge 0.4: contract v4 consent kernel, catalog acknowledgement, dispatch observability, DOM and realtime packages, adapter last-event/null-bridge hooks, and the five-package release set. + + Two details worth knowing before you write against `concierge-dom`. An `AnchorRef` now returns the cleanup for the element it attached, so React 19 releases exactly the node each JSX site registered; React 18 ignores the return value and keeps the `ref(null)` protocol. A key holds a set of registrations rather than one, so several simultaneously mounted nodes for one record all stay reachable. + ## 0.3.0 ### Minor Changes diff --git a/packages/concierge-svelte/README.md b/packages/concierge-svelte/README.md index b3791c1..4f43390 100644 --- a/packages/concierge-svelte/README.md +++ b/packages/concierge-svelte/README.md @@ -10,9 +10,10 @@ Svelte 5 context, lifecycle, and snapshot bindings for an existing [`@full-self-browsing/concierge`](https://github.com/fullselfbrowsing/Concierge) instance and bridge registry. -Version 0.3 is a public preview of contract 3. It supports Svelte 5, requires +Version 0.4 is a public preview of contract 4. It supports Svelte 5, requires Node 22.12 or newer for server rendering, and does not support Edge runtimes in -the 0.3 line. The existing context and bridge APIs are unchanged. +the 0.4 line. `useConciergeActivity` exposes `{ active, lastEvent }`, and +`useConciergeBridge` accepts a `null` bridge to unregister. ## Entry points @@ -35,6 +36,7 @@ import { provideConcierge, svelteSnapshotNormalizer, useConcierge, + useConciergeActivity, useConciergeBridge, } from "@full-self-browsing/concierge-svelte/client.svelte"; ``` diff --git a/packages/concierge-svelte/package.json b/packages/concierge-svelte/package.json index 06bae0f..89c78af 100644 --- a/packages/concierge-svelte/package.json +++ b/packages/concierge-svelte/package.json @@ -1,6 +1,6 @@ { "name": "@full-self-browsing/concierge-svelte", - "version": "0.3.0", + "version": "0.4.0", "private": false, "description": "Svelte bindings for @full-self-browsing/concierge", "keywords": [ diff --git a/packages/concierge-svelte/src/client.svelte.ts b/packages/concierge-svelte/src/client.svelte.ts index f8bd27a..6820bbb 100644 --- a/packages/concierge-svelte/src/client.svelte.ts +++ b/packages/concierge-svelte/src/client.svelte.ts @@ -8,11 +8,12 @@ import type { Bridge, BridgeRegistry, Concierge, + DispatchEvent, SnapshotNormalizer, } from "@full-self-browsing/concierge"; import { mountConciergeTelemetry } from "@full-self-browsing/concierge/telemetry"; -const EXPECTED_CONTRACT_VERSION: number = 3; +const EXPECTED_CONTRACT_VERSION: number = 4; const CONCIERGE_CONTEXT: symbol = Symbol( "@full-self-browsing/concierge-svelte.context", ); @@ -47,13 +48,51 @@ export function useConcierge(): Concierge { return concierge; } +export function useConciergeActivity(): { + readonly active: boolean; + readonly lastEvent: DispatchEvent | null; +} { + const concierge: Concierge = useConcierge(); + let active: boolean = $state(false); + let lastEvent: DispatchEvent | null = $state(null); + const inflight: Set = new Set(); + + $effect((): (() => void) => { + return concierge.onDispatch((event: DispatchEvent): void => { + lastEvent = event; + switch (event.phase) { + case "accepted": + case "waiting": + case "executing": + inflight.add(event.dispatchId); + break; + case "succeeded": + case "failed": + case "cancelled": + inflight.delete(event.dispatchId); + break; + } + active = inflight.size > 0; + }); + }); + + return { + get active(): boolean { + return active; + }, + get lastEvent(): DispatchEvent | null { + return lastEvent; + }, + }; +} + export function useConciergeBridge( getRegistry: () => BridgeRegistry, - getBridge: () => B, + getBridge: () => B | null, ): void { - $effect((): (() => void) => { + $effect((): (() => void) | undefined => { const registry: BridgeRegistry = getRegistry(); - const bridge: B = getBridge(); + const bridge: B | null = getBridge(); assertSingleInstance(); @@ -65,6 +104,10 @@ export function useConciergeBridge( ); } + if (bridge === null) { + return undefined; + } + return registry.register(bridge); }); } diff --git a/packages/concierge-svelte/test/Harness.svelte b/packages/concierge-svelte/test/Harness.svelte index 96aad00..7634363 100644 --- a/packages/concierge-svelte/test/Harness.svelte +++ b/packages/concierge-svelte/test/Harness.svelte @@ -28,7 +28,7 @@ type Props = { readonly concierge: Concierge; readonly registry: BridgeRegistry; - readonly bridge: Bridge; + readonly bridge: Bridge | null; readonly provide?: boolean; readonly telemetry?: boolean; readonly onContext?: (concierge: Concierge) => void; @@ -40,7 +40,7 @@ let props: Props = $props(); const getConcierge = (): Concierge => props.concierge; const getRegistry = (): BridgeRegistry => props.registry; - const getBridge = (): Bridge => props.bridge; + const getBridge = (): Bridge | null => props.bridge; const getProvide = (): boolean => props.provide ?? true; const getTelemetry = (): boolean | undefined => props.telemetry; const getOnContext = (): Props["onContext"] => props.onContext; diff --git a/packages/concierge-svelte/test/artifact.test.ts b/packages/concierge-svelte/test/artifact.test.ts index 70d9afe..a82f4a1 100644 --- a/packages/concierge-svelte/test/artifact.test.ts +++ b/packages/concierge-svelte/test/artifact.test.ts @@ -164,6 +164,7 @@ describe("the built @full-self-browsing/concierge-svelte entries", () => { expect(clientTypes).toContain("ProvideConciergeOptions"); expect(clientTypes).toContain("telemetry?: boolean"); expect(clientTypes).toContain("useConcierge"); + expect(clientTypes).toContain("useConciergeActivity"); expect(clientTypes).toContain("useConciergeBridge"); expect(clientTypes).toContain("getRegistry"); expect(clientTypes).toContain("getBridge"); @@ -186,7 +187,7 @@ describe("the built @full-self-browsing/concierge-svelte entries", () => { "upgrade or reinstall", "registry.register(bridge)", ]); - expect(clientSource).toMatch(/EXPECTED_CONTRACT_VERSION\s*=\s*3\b/u); + expect(clientSource).toMatch(/EXPECTED_CONTRACT_VERSION\s*=\s*4\b/u); expect(adapterSource).toContain("return $state.snapshot(value);"); expect(adapterSource).not.toMatch(/\b(?:as|any)\b/u); @@ -226,6 +227,7 @@ describe("the built @full-self-browsing/concierge-svelte entries", () => { "provideConcierge", "svelteSnapshotNormalizer", "useConcierge", + "useConciergeActivity", "useConciergeBridge", ]); expect(spyRegistry.read()).toBeNull(); diff --git a/packages/concierge-svelte/test/lifecycle.test.ts b/packages/concierge-svelte/test/lifecycle.test.ts index 2a6a01d..742eaf9 100644 --- a/packages/concierge-svelte/test/lifecycle.test.ts +++ b/packages/concierge-svelte/test/lifecycle.test.ts @@ -9,6 +9,7 @@ import type { Concierge, DispatchEvent, DispatchListener, + DispatchTiming, } from "@full-self-browsing/concierge"; const telemetryMount = vi.hoisted(() => vi.fn()); @@ -38,14 +39,25 @@ type TestBridge = Bridge< { readonly current: () => Readonly<{ label: string }> } >; +function emptyTiming(): DispatchTiming { + return { + clockMs: 0, + wallClockMs: 0, + elapsedMs: 0, + monotonic: false, + }; +} + function conciergeStub(): Concierge { const revision = Symbol("svelte-test-catalog") as ReturnType["revision"]; return { + instanceId: "svelte-test", dispatch: async () => ({ ok: true, message: "Done." }), dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ stage: null, tools: [], revision }), onDispatch: () => () => undefined, explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), + attestReadback: () => "unknown_readback", }; } @@ -57,6 +69,7 @@ function dispatchConciergeStub(): { const listeners: Set = new Set(); const revision = Symbol("svelte-telemetry-order") as CatalogRevision; const concierge: Concierge = { + instanceId: "svelte-activity", dispatch: async () => ({ ok: true, message: "Done." }), dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ stage: null, tools: [], revision }), @@ -67,6 +80,7 @@ function dispatchConciergeStub(): { }; }, explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), + attestReadback: () => "unknown_readback", }; return { @@ -83,6 +97,7 @@ function dispatchConciergeStub(): { input: { kind: "dropped" }, terminalAction: false, terminalEntered: false, + timing: emptyTiming(), }; for (const listener of listeners) void listener(event); }, @@ -335,4 +350,17 @@ describe("@full-self-browsing/concierge-svelte svelte-lifecycle", () => { expect(secondTracked.cleanupCalls).toEqual([secondTracked.cleanups[0]]); expect(secondCoreRegistry.read()).toBeNull(); }); + + it("leaves the registry empty when the bridge getter returns null", () => { + const concierge: Concierge = conciergeStub(); + const registry: BridgeRegistry = createBridge("svelte-null-bridge"); + const mounted = render(Harness, { + concierge, + registry, + bridge: null, + }); + expect(registry.read()).toBeNull(); + mounted.unmount(); + expect(registry.read()).toBeNull(); + }); }); diff --git a/packages/concierge/CHANGELOG.md b/packages/concierge/CHANGELOG.md index daff762..0871762 100644 --- a/packages/concierge/CHANGELOG.md +++ b/packages/concierge/CHANGELOG.md @@ -1,5 +1,14 @@ # @full-self-browsing/concierge +## 0.4.0 + +### Minor Changes + +- 2ffb743: Ship `@full-self-browsing/concierge-dom`: registered-element resolve, reveal, and untrusted readback. +- 2ffb743: Ship Concierge 0.4: contract v4 consent kernel, catalog acknowledgement, dispatch observability, DOM and realtime packages, adapter last-event/null-bridge hooks, and the five-package release set. + + Two details worth knowing before you write against `concierge-dom`. An `AnchorRef` now returns the cleanup for the element it attached, so React 19 releases exactly the node each JSX site registered; React 18 ignores the return value and keeps the `ref(null)` protocol. A key holds a set of registrations rather than one, so several simultaneously mounted nodes for one record all stay reachable. + ## 0.3.0 ### Minor Changes diff --git a/packages/concierge/README.md b/packages/concierge/README.md index 422be31..6f7ad59 100644 --- a/packages/concierge/README.md +++ b/packages/concierge/README.md @@ -11,9 +11,10 @@ web application. Concierge owns action admission, validation, consent, deduplication, lifecycle, workflows, and terminal execution. It does not own the model, chat UI, speech, overlay, or planning loop. -Version 0.3 is a public preview of contract 3. It requires Node 22.12 or newer; -Edge runtimes are not supported in the 0.3 line. Existing actions without -structured data and existing stage-level bridges remain supported. +Version 0.4 is a public preview of contract 4. It requires Node 22.12 or newer; +Edge runtimes are not supported in the 0.4 line. Existing actions without +structured data and existing stage-level bridges remain supported. Consent +binds the payload a review handler proposes. ## Install @@ -25,7 +26,8 @@ React and Svelte lifecycle bindings are published separately. Optional AI SDK 6/7 tool definitions and the signed browser bridge are available from `@full-self-browsing/concierge/ai-sdk`, `/ai-sdk/server`, and `/ai-sdk/browser`. The app-owned OpenAI Realtime codec is available from -`@full-self-browsing/concierge/openai-realtime`. +`@full-self-browsing/concierge/openai-realtime`. Test helpers live at +`@full-self-browsing/concierge/testing`. Anonymous browser usage reporting is isolated in the optional `@full-self-browsing/concierge/telemetry` subpath; importing this package root continues to perform no browser storage, timer, DOM, or network work. @@ -172,18 +174,18 @@ settles. Defaults are 16 nested levels and 256 steps per root workflow. `createSession` republishes a `ResolvedCatalog` whenever its effective catalog changes, including availability changes within one stage. Publishing a new -catalog aborts the prior epoch. A contract-3 transport implements +catalog aborts the prior epoch. A contract-4 transport implements `setCatalog(resolved)` and one awaited `onToolBatch` callback returning the batch outcome; there is no ambiguous per-call response channel. ## Compatibility and stability -Documented 0.3 exports, failure reasons, wire fields, peer ranges, and contract -3 remain compatible throughout `0.3.x`. Breaking changes require a synchronized +Documented 0.4 exports, failure reasons, wire fields, peer ranges, and contract +4 remain compatible throughout `0.4.x`. Breaking changes require a synchronized minor release and migration notes. See the [repository documentation](https://github.com/fullselfbrowsing/Concierge#readme), [security policy](https://github.com/fullselfbrowsing/Concierge/blob/main/SECURITY.md), -and [0.2 to 0.3 migration guide](https://github.com/fullselfbrowsing/Concierge/blob/main/docs/migrations/0.2-to-0.3.md). +and [0.3 to 0.4 migration guide](https://github.com/fullselfbrowsing/Concierge/blob/main/docs/migrations/0.3-to-0.4.md). ## License diff --git a/packages/concierge/package.json b/packages/concierge/package.json index 49fcd25..b0a7f86 100644 --- a/packages/concierge/package.json +++ b/packages/concierge/package.json @@ -1,6 +1,6 @@ { "name": "@full-self-browsing/concierge", - "version": "0.3.0", + "version": "0.4.0", "description": "Typed, consent-gated actions that let an AI agent operate your web app", "keywords": [ "ai", @@ -52,6 +52,10 @@ "types": "./dist/telemetry/index.d.ts", "default": "./dist/telemetry/index.js" }, + "./testing": { + "types": "./dist/testing/index.d.ts", + "default": "./dist/testing/index.js" + }, "./package.json": "./package.json" }, "files": [ @@ -79,8 +83,8 @@ } }, "scripts": { - "build": "tsdown && tsdown --config tsdown.ai-sdk.config.ts && tsdown --config tsdown.telemetry.config.ts", - "typecheck": "tsc -p tsconfig.test-d.json && tsc -p tsconfig.ai-sdk.test-d.json && tsc -p tsconfig.telemetry.json --noEmit" + "build": "tsdown && tsdown --config tsdown.ai-sdk.config.ts && tsdown --config tsdown.testing.config.ts && tsdown --config tsdown.telemetry.config.ts", + "typecheck": "tsc -p tsconfig.test-d.json && tsc -p tsconfig.ai-sdk.test-d.json && tsc -p tsconfig.testing.json --noEmit && tsc -p tsconfig.telemetry.json --noEmit" }, "devDependencies": { "arktype": "2.2.3", diff --git a/packages/concierge/src/ai-sdk/index.ts b/packages/concierge/src/ai-sdk/index.ts index 0add167..a29bf97 100644 --- a/packages/concierge/src/ai-sdk/index.ts +++ b/packages/concierge/src/ai-sdk/index.ts @@ -252,7 +252,7 @@ export function createAISDKAdapter(input: Readonly<{ input.concierge as ConciergeWithResolution; if (typeof concierge.resolveCatalog !== "function") { throw new ConciergeAISDKConfigurationError( - "Core contract v3 must provide resolveCatalog().", + "Core contract v4 must provide resolveCatalog().", ); } const crypto: Crypto = cryptoFor(input.crypto); diff --git a/packages/concierge/src/ai-sdk/wire.ts b/packages/concierge/src/ai-sdk/wire.ts index 5dedffe..a755983 100644 --- a/packages/concierge/src/ai-sdk/wire.ts +++ b/packages/concierge/src/ai-sdk/wire.ts @@ -6,7 +6,7 @@ import type { } from "@full-self-browsing/concierge"; export const SIGNED_ENVELOPE_VERSION = 1 as const; -export const EXPECTED_CORE_CONTRACT_VERSION = 3 as const; +export const EXPECTED_CORE_CONTRACT_VERSION = 4 as const; export const DEFAULT_MAX_CALLS = 128 as const; export const DEFAULT_MAX_PAYLOAD_BYTES = 524_288 as const; export const DEFAULT_MAX_LIFETIME_MS = 300_000 as const; @@ -27,7 +27,7 @@ export interface ProtectedHeaderV1 { } export interface ToolBatchClaimsV1 { - readonly contractVersion: 3; + readonly contractVersion: 4; readonly audience: string; readonly sessionId: string; readonly catalogStage: string | null; diff --git a/packages/concierge/src/bridge.ts b/packages/concierge/src/bridge.ts index 51e84d1..1f4b130 100644 --- a/packages/concierge/src/bridge.ts +++ b/packages/concierge/src/bridge.ts @@ -95,9 +95,21 @@ */ import { assertSingleInstance } from "./contract.js"; -import { encodeDiagnosticSubject, warnHost } from "./host.js"; +import { isAborted } from "./dispatch.js"; +import { encodeDiagnosticSubject, readHostScheduler, warnHost } from "./host.js"; import { boundedMessage } from "./message.js"; -import type { ActionResult, Bridge, BridgeRegistry, SnapshotNormalizer } from "./types.js"; +import type { + AbortSignalLike, + ActionResult, + Bridge, + BridgeRegistrationEvent, + BridgeRegistrationListener, + ObservableBridgeRegistry, + RegistrationWait, + RegistrationWaitOptions, + Scheduler, + SnapshotNormalizer, +} from "./types.js"; // --------------------------------------------------------------------------- // Module scope — immutable declarations only @@ -197,7 +209,9 @@ function bridgeOverwriteMessage(id: string): string { * registration is occurring on the server. There is no runtime * guard in this function and no change to `./host.ts`; do not add either here. */ -export function createBridge(id: string): BridgeRegistry { +export function createBridge( + id: string, +): ObservableBridgeRegistry { assertSingleInstance(); // This instance's only mutable state, and header constraint 1 is why all @@ -220,8 +234,42 @@ export function createBridge(id: string): BridgeRegis let slot: { token: number; bridge: B } | null = null; let next: number = 0; let warnedOverwrite: boolean = false; + let warnedListenerLeak: boolean = false; + let nextListenerId: number = 0; + const listeners: Map> = new Map(); - const registry: BridgeRegistry = { + function emitRegistration( + event: BridgeRegistrationEvent, + ): void { + // Shallow freeze only. Deep-freezing would freeze the consumer's live + // bridge and everything reachable from it. + const frozen: BridgeRegistrationEvent = Object.freeze(event); + const snapshot: ReadonlyArray> = [ + ...listeners.values(), + ]; + for (const listener of snapshot) { + try { + const returned: unknown = listener(frozen); + if ( + returned !== null && + typeof returned === "object" && + "then" in returned && + typeof (returned as { then?: unknown }).then === "function" + ) { + void Promise.resolve(returned as Promise).then( + () => undefined, + () => undefined, + ); + } + } catch { + warnHost( + `concierge: [bridge_listener_failed] bridge ${encodeDiagnosticSubject(id)}: a registration listener threw; remaining listeners still ran.`, + ); + } + } + } + + const registry: ObservableBridgeRegistry = { id, read: (): B | null => slot?.bridge ?? null, @@ -245,6 +293,7 @@ export function createBridge(id: string): BridgeRegis // stay live. Detachment belongs at capture time and nowhere else. const token: number = ++next; slot = { token, bridge }; + emitRegistration({ type: "registered", registryId: id, bridge }); return (): void => { // **The guard is on the TOKEN, not on the bridge object.** Guarding on @@ -265,9 +314,39 @@ export function createBridge(id: string): BridgeRegis // swallowed error. if (slot?.token === token) { slot = null; + emitRegistration({ type: "unregistered", registryId: id }); + } + }; + }, + + subscribe: (listener: BridgeRegistrationListener): (() => void) => { + if (typeof listener !== "function") { + throw new TypeError("A bridge registration listener must be callable."); + } + nextListenerId += 1; + const listenerId: number = nextListenerId; + listeners.set(listenerId, listener); + if (listeners.size > 64 && !warnedListenerLeak) { + warnedListenerLeak = true; + warnHost( + `concierge: [bridge_listener_leak] bridge ${encodeDiagnosticSubject(id)}: more than 64 registration listeners are attached to one registry.`, + ); + } + let active: boolean = true; + return (): void => { + if (!active) { + return; + } + active = false; + if (listeners.get(listenerId) === listener) { + listeners.delete(listenerId); } }; }, + + drain: (): void => { + emitRegistration({ type: "drained", registryId: id }); + }, }; // **The returned object IS sealed, and this DIVERGES from `createConcierge`**, @@ -292,6 +371,153 @@ export function createBridge(id: string): BridgeRegis return Object.freeze(registry); } +/** + * Resolve when a bridge registers into `registry`, or when the wait ends + * another way. Never rejects. Always settles. + * + * Skipping a timeout is an unbounded hang, so a requested timeout with no + * reachable scheduler resolves `"unavailable"` immediately. + */ +export function awaitRegistration( + registry: ObservableBridgeRegistry, + options: RegistrationWaitOptions, +): Promise> { + const timeoutMs: number | undefined = options.timeoutMs; + const signal: AbortSignalLike | undefined = options.signal; + const timeoutRequested: boolean = + timeoutMs !== undefined && Number.isFinite(timeoutMs); + if (!timeoutRequested && signal === undefined) { + return Promise.resolve({ status: "unavailable" }); + } + + let scheduler: Scheduler | undefined = options.scheduler; + if (scheduler === undefined && timeoutRequested) { + try { + scheduler = readHostScheduler(); + } catch { + scheduler = undefined; + } + } + if (timeoutRequested && scheduler === undefined) { + return Promise.resolve({ status: "unavailable" }); + } + if (isAborted(signal)) { + return Promise.resolve({ status: "aborted" }); + } + const already: B | null = registry.read(); + if (already !== null) { + return Promise.resolve({ status: "ready", bridge: already }); + } + + return new Promise>((resolve) => { + let settled: boolean = false; + let listenerAttached: boolean = false; + // **No "cancel once a handle exists" flag, deliberately.** Every path that + // can settle before the timer is armed — an already-aborted signal, a + // listener that throws, a bridge already in the slot — returns before the + // scheduler is reached, so there is never a handle owing cancellation at + // that point. An earlier draft carried a flag for the case; it was never + // set, and an unreachable guard is not a safety net, it is a claim no test + // can check. Anything added below that settles and then falls through to + // the scheduler has to cancel its own handle. + let cancel: (() => void) | null = null; + let firedDuringRegistration: boolean = false; + let registrationComplete: boolean = false; + let unsubscribe: (() => void) | null = null; + + function finish(wait: RegistrationWait, cancelTimer: boolean): void { + if (settled) { + return; + } + settled = true; + if (unsubscribe !== null) { + try { + unsubscribe(); + } catch { + // Settlement stays final. + } + } + if (listenerAttached && signal !== undefined) { + listenerAttached = false; + try { + signal.removeEventListener("abort", onAbort); + } catch { + // Settlement stays final. + } + } + if (cancelTimer && cancel !== null) { + const current: () => void = cancel; + cancel = null; + try { + current(); + } catch { + // Cancellation remains final even when the host canceller throws. + } + } + resolve(wait); + } + + function onAbort(): void { + finish({ status: "aborted" }, true); + } + + unsubscribe = registry.subscribe((event) => { + if (event.type === "registered") { + finish({ status: "ready", bridge: event.bridge }, true); + return; + } + if (event.type === "drained") { + finish({ status: "drained" }, true); + } + }); + + if (signal !== undefined) { + try { + signal.addEventListener("abort", onAbort); + listenerAttached = true; + } catch { + finish({ status: "aborted" }, true); + return; + } + if (isAborted(signal)) { + finish({ status: "aborted" }, true); + return; + } + } + + const mounted: B | null = registry.read(); + if (mounted !== null) { + finish({ status: "ready", bridge: mounted }, true); + return; + } + + if (!timeoutRequested || scheduler === undefined) { + return; + } + + try { + const scheduledCancel: unknown = scheduler((): void => { + if (!registrationComplete) { + firedDuringRegistration = true; + return; + } + finish({ status: "timed-out" }, false); + }, timeoutMs as number); + if (typeof scheduledCancel !== "function") { + finish({ status: "unavailable" }, false); + return; + } + cancel = scheduledCancel as () => void; + registrationComplete = true; + if (firedDuringRegistration && !settled) { + finish({ status: "timed-out" }, false); + } + } catch { + finish({ status: "unavailable" }, false); + } + }); +} + // --------------------------------------------------------------------------- // The no-bridge path // --------------------------------------------------------------------------- @@ -997,6 +1223,12 @@ export function captureSnapshot(bridge: B, id: string, normali if (getter === undefined) { continue; } + if (typeof getter === "function" && getter.length > 0) { + warnHost( + `concierge: [snapshot_slot_not_a_getter] bridge ${encodeDiagnosticSubject(id)}: snapshot key ${encodeDiagnosticSubject(key)} takes arguments, so it was not captured. Snapshots are zero-argument getters. Fix: move parameterized queries off the snapshot into actions.`, + ); + continue; + } // **`.call(holder)`, never a bare `getter()`.** `Bridge`'s // `Snapshot extends Record unknown>` accepts method // shorthand, so `snapshot: { count() { return this.total; } }` typechecks diff --git a/packages/concierge/src/catalog-prompt.ts b/packages/concierge/src/catalog-prompt.ts new file mode 100644 index 0000000..4c30803 --- /dev/null +++ b/packages/concierge/src/catalog-prompt.ts @@ -0,0 +1,210 @@ +/** + * Catalog-derived prompt fragments and policy tables. + * + * Descriptions are the action's model-facing string verbatim. No product + * sentences are added. Same catalog + same options ⇒ identical string. + */ + +import { MESSAGE_MAX_CHARS } from "./types.js"; +import type { + AnyActionDefinition, + CatalogRevision, + OutputRedactionPolicy, + ResolvedCatalog, + SideEffects, +} from "./types.js"; +import { sanitizeText } from "./message.js"; + +export type CatalogPromptFormat = "markdown-list" | "plain"; + +export interface RenderCatalogPromptOptions { + readonly format?: CatalogPromptFormat | undefined; + readonly includeUnavailable?: boolean | undefined; + readonly names?: readonly string[] | undefined; + readonly includeParameters?: boolean | undefined; +} + +export interface CatalogDerivedPolicy { + readonly continuationSensitiveNames: readonly string[]; + readonly observerRedaction: { readonly [name: string]: OutputRedactionPolicy }; + readonly sideEffects: { readonly [name: string]: SideEffects }; +} + +interface RememberedProjection { + readonly available: ReadonlySet; + readonly allNames: readonly string[]; + readonly descriptions: Readonly>; + readonly policy: CatalogDerivedPolicy; +} + +const remembered: WeakMap = new WeakMap(); + +function isContinuationSensitive(action: AnyActionDefinition): boolean { + if (action.consent?.requires !== undefined) { + return true; + } + if (action.effects?.destructive === true) { + return true; + } + if (action.effects?.idempotent === false) { + return true; + } + return false; +} + +export function rememberCatalogProjection( + revision: CatalogRevision, + actions: readonly AnyActionDefinition[], + availableNames: readonly string[], +): void { + const available: Set = new Set(availableNames); + const continuationSensitiveNames: string[] = []; + const observerRedaction: Record> = + Object.create(null); + const sideEffects: Record = Object.create(null); + const descriptions: Record = Object.create(null); + const allNames: string[] = []; + + for (const action of actions) { + allNames.push(action.name); + descriptions[action.name] = action.description; + if (isContinuationSensitive(action)) { + continuationSensitiveNames.push(action.name); + } + observerRedaction[action.name] = action.output?.redact ?? "drop"; + sideEffects[action.name] = action.effects === undefined + ? Object.freeze({}) + : Object.freeze({ ...action.effects }); + } + + remembered.set( + revision, + Object.freeze({ + available, + allNames: Object.freeze([...allNames]), + descriptions: Object.freeze(descriptions), + policy: Object.freeze({ + continuationSensitiveNames: Object.freeze(continuationSensitiveNames), + observerRedaction: Object.freeze(observerRedaction), + sideEffects: Object.freeze(sideEffects), + }), + }), + ); +} + +function wantedNames( + catalog: ResolvedCatalog, + options: RenderCatalogPromptOptions | undefined, +): readonly string[] { + const filter: ReadonlySet | null = options?.names === undefined + ? null + : new Set(options.names); + const available: string[] = catalog.tools + .map((tool) => tool.name) + .filter((name) => filter === null || filter.has(name)); + return available; +} + +function unavailableNames( + catalog: ResolvedCatalog, + options: RenderCatalogPromptOptions | undefined, +): readonly string[] { + if (options?.includeUnavailable !== true) { + return []; + } + const projection: RememberedProjection | undefined = remembered.get( + catalog.revision, + ); + const filter: ReadonlySet | null = options.names === undefined + ? null + : new Set(options.names); + if (projection === undefined) { + return options.names === undefined + ? [] + : options.names.filter( + (name) => !catalog.tools.some((tool) => tool.name === name), + ); + } + return projection.allNames.filter((name) => { + if (projection.available.has(name)) { + return false; + } + return filter === null || filter.has(name); + }); +} + +function toolLine( + name: string, + description: string, + format: CatalogPromptFormat, +): string { + const bounded: string = sanitizeText(description, { + maxChars: MESSAGE_MAX_CHARS, + }); + return format === "plain" ? `${name}: ${bounded}` : `- \`${name}\`: ${bounded}`; +} + +export function renderCatalogPrompt( + catalog: ResolvedCatalog, + options?: RenderCatalogPromptOptions, +): string { + const format: CatalogPromptFormat = options?.format === "plain" + ? "plain" + : "markdown-list"; + const projection: RememberedProjection | undefined = remembered.get( + catalog.revision, + ); + const lines: string[] = []; + for (const name of wantedNames(catalog, options)) { + const tool = catalog.tools.find((entry) => entry.name === name); + const description: string = tool?.description ?? + projection?.descriptions[name] ?? + ""; + lines.push(toolLine(name, description, format)); + } + const unavailable: readonly string[] = unavailableNames(catalog, options); + if (unavailable.length > 0) { + lines.push(format === "plain" ? "unavailable:" : "## unavailable"); + for (const name of unavailable) { + const description: string = projection?.descriptions[name] ?? ""; + lines.push(toolLine(name, description, format)); + } + } + return lines.join("\n"); +} + +export function catalogDerivedPolicy( + catalog: ResolvedCatalog, +): CatalogDerivedPolicy { + const projection: RememberedProjection | undefined = remembered.get( + catalog.revision, + ); + if (projection !== undefined) { + return projection.policy; + } + + // **Every arm of this fallback fails CLOSED, including the first.** A + // catalog reaches here when nothing remembered its revision — one built by + // a different core instance, or one whose revision symbol never passed + // through `rememberCatalogProjection`. An earlier draft reported + // `continuationSensitiveNames: []` here, which reads as restraint and is + // the opposite: a consumer using this field to decide whether to keep + // going after an action would have treated every destructive and + // consent-gated action as safe to continue past. With no projection the + // honest answer is that sensitivity is unknown, and unknown is treated as + // sensitive — matching `"drop"` for redaction directly below. + const continuationSensitiveNames: string[] = []; + const observerRedaction: Record> = + Object.create(null); + const sideEffects: Record = Object.create(null); + for (const tool of catalog.tools) { + continuationSensitiveNames.push(tool.name); + observerRedaction[tool.name] = "drop"; + sideEffects[tool.name] = Object.freeze({}); + } + return Object.freeze({ + continuationSensitiveNames: Object.freeze(continuationSensitiveNames), + observerRedaction: Object.freeze(observerRedaction), + sideEffects: Object.freeze(sideEffects), + }); +} diff --git a/packages/concierge/src/catalog.ts b/packages/concierge/src/catalog.ts index d6f27f2..bc96c2f 100644 --- a/packages/concierge/src/catalog.ts +++ b/packages/concierge/src/catalog.ts @@ -69,6 +69,7 @@ import type { JsonSchemaTarget, SchemaEmission } from "./json-schema.js"; import { CONSENT_GRADE_ORDER } from "./types.js"; import type { AnyActionDefinition, + BridgeRegistry, ConsentGrade, ConsentProfile, DigestLike, @@ -124,7 +125,10 @@ export type CatalogIssueCode = | "consent_grade_unavailable" | "user_turn_identity_unavailable" | "readback_presenter_missing" - | "digest_missing"; + | "digest_missing" + | "snapshot_slot_not_a_getter" + | "vacuous_consent_snapshot" + | "message_redaction_invalid"; /** * One build-failing problem, as structured fields. @@ -177,7 +181,11 @@ export interface CatalogIssue { */ export type CatalogDiagnosticCode = | "destructive_without_consent" - | "reads_untrusted_without_consent"; + | "reads_untrusted_without_consent" + | "destructive_without_grade" + | "vacuous_consent_snapshot" + | "snapshot_slot_not_a_getter" + | "message_redaction_unset"; /** * One non-blocking report, in {@link CatalogIssue}'s shape minus `vendor`. @@ -411,6 +419,29 @@ export interface BuildCatalogOptions { readonly consentProfile?: ConsentProfile | undefined; readonly presentReadback?: ReadbackSink | undefined; readonly digest?: DigestLike | undefined; + /** + * Live bridge registries to inspect for snapshot-slot arity and vacuous + * consent snapshots. Callers must not invoke snapshot getters; this walk + * reads descriptors only. + * + * **This reports only on registries that already hold a bridge.** The walk + * calls `registry.read()`, which is `null` until a component registers — so + * a catalog built at module scope, before anything has mounted, inspects + * nothing and reports nothing. Passing `snapshotSources` there is not a + * check that passed; it is a check that did not run. To get the build-time + * report, rebuild the catalog once the bridges are registered. + * + * Nothing depends on that rebuild for safety. The load-bearing vacuous + * snapshot gate is the runtime one: a consent generation whose captured + * snapshot has zero own keys refuses at confirm with `consent_stale`, + * whether or not this walk ever ran. + */ + readonly snapshotSources?: + | ReadonlyArray<{ + readonly actionNames: ReadonlyArray; + readonly registry: BridgeRegistry; + }> + | undefined; } // --------------------------------------------------------------------------- @@ -653,6 +684,94 @@ function consentCapabilityRequirementsOf( } } +function inspectSnapshotHolder(snapshot: object): { + readonly ownKeyCount: number; + readonly parameterizedSlots: readonly string[]; +} { + const keys: string[] = Object.keys(snapshot); + const parameterizedSlots: string[] = []; + for (const key of keys) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(snapshot, key); + } catch { + continue; + } + if ( + descriptor !== undefined && + "value" in descriptor && + typeof descriptor.value === "function" && + descriptor.value.length > 0 + ) { + parameterizedSlots.push(key); + } + } + return { ownKeyCount: keys.length, parameterizedSlots }; +} + +function inspectCatalogSnapshots( + sources: BuildCatalogOptions["snapshotSources"], + consentInvolved: ReadonlySet, + consentGrade: ConsentGrade, + issues: CatalogIssue[], + diagnostics: CatalogDiagnostic[], +): void { + if (sources === undefined) { + return; + } + const reportedVacuous: Set = new Set(); + const reportedParameterized: Set = new Set(); + for (const source of sources) { + let snapshot: unknown; + try { + const bridge = source.registry.read(); + snapshot = bridge === null || bridge === undefined + ? undefined + : bridge.snapshot; + } catch { + continue; + } + if (typeof snapshot !== "object" || snapshot === null) { + continue; + } + const inspection = inspectSnapshotHolder(snapshot); + for (const name of source.actionNames) { + if ( + inspection.parameterizedSlots.length > 0 && + !reportedParameterized.has(name) + ) { + reportedParameterized.add(name); + issues.push({ + code: "snapshot_slot_not_a_getter", + action: name, + problem: + "its snapshot includes a function that takes arguments. Snapshots are zero-argument getters.", + fix: "move parameterized queries off the snapshot into actions, and keep snapshot slots as `() => T`.", + }); + } + if ( + consentInvolved.has(name) && + inspection.ownKeyCount === 0 && + !reportedVacuous.has(name) + ) { + reportedVacuous.add(name); + const record = { + code: "vacuous_consent_snapshot" as const, + action: name, + problem: + "its consent-gated snapshot has zero own keys, so drift detection would compare `{}` to `{}` and pass.", + fix: "put the freshness-determining values on the bridge snapshot as zero-argument getters.", + }; + if (consentGrade === "none") { + diagnostics.push(record); + } else { + issues.push(record); + } + } + } + } +} + function hasCallableDigest(value: unknown): boolean { if ((typeof value !== "object" && typeof value !== "function") || value === null) { return false; @@ -1207,6 +1326,33 @@ export function buildCatalog( const parameters: JsonSchemaObject = emission.parameters; // SEC-01 — see the two-branch reading above. + const messageRedaction: unknown = Object.hasOwn(action, "redactMessage") + ? (action as { readonly redactMessage?: unknown }).redactMessage + : undefined; + if ( + messageRedaction !== undefined && + messageRedaction !== "drop" && + messageRedaction !== "passthrough" && + typeof messageRedaction !== "function" + ) { + issues.push({ + code: "message_redaction_invalid", + action: action.name, + problem: + "its `redactMessage` is not `\"drop\"`, `\"passthrough\"`, or a function.", + fix: 'set `redactMessage` to `"drop"`, `"passthrough"`, or a projection that returns a string.', + }); + } + if (action.consent !== undefined && messageRedaction === undefined) { + diagnostics.push({ + code: "message_redaction_unset", + action: action.name, + problem: + "it is consent-gated but declares no `redactMessage`, so the sentence a person is asked to approve reaches every observer verbatim.", + fix: 'add `redactMessage: "drop"` or a projection that withholds payload-quoting copy from observers.', + }); + } + const redaction: unknown = declaredRedaction(action); const redactionMissing: boolean = redaction === undefined; if (redactionMissing && hasDeclaredParameters(parameters)) { @@ -1256,6 +1402,22 @@ export function buildCatalog( }); } + if ( + bindTo === "unverifiedUserTurn" && + consentEvidence.userTurnIdentity === "none" + ) { + issues.push({ + code: "user_turn_identity_unavailable", + action: action.name, + required: "agent-forgeable", + declared: consentEvidence.userTurnIdentity, + problem: + "it binds consent to an unverified user turn, but the declared application profile provides no turn identity at all.", + fix: + "set `consentProfile.userTurnIdentity` to \"agent-forgeable\" or \"human-attested\", or use response binding.", + }); + } + if (effectiveMinGrade === "attested") { if (!consentEvidence.hasPresenter) { issues.push({ @@ -1295,6 +1457,20 @@ export function buildCatalog( }); } + if (action.effects?.destructive === true && action.consent !== undefined) { + const declaredMinGrade: unknown = action.consent.minGrade; + if (declaredMinGrade === undefined || declaredMinGrade === "delivered") { + diagnostics.push({ + code: "destructive_without_grade", + action: action.name, + problem: + "it declares `effects.destructive` with a `consent.minGrade` of `\"delivered\"` or absent, so a consequential action can arm without a delivery producer.", + fix: + "raise `consent.minGrade` to `\"relayed\"` or `\"attested\"` for irreversible actions.", + }); + } + } + // SEC-05 — same shape, distinct code, so a consumer can filter one alone. if (action.readsUntrusted === true && action.consent === undefined) { diagnostics.push({ @@ -1449,6 +1625,22 @@ export function buildCatalog( } } + const consentInvolved: Set = new Set(); + for (const action of declared) { + const requires: unknown = consentRequiresOf(action); + if (typeof requires === "string") { + consentInvolved.add(action.name); + consentInvolved.add(requires); + } + } + inspectCatalogSnapshots( + options?.snapshotSources, + consentInvolved, + consentEvidence.consentGrade, + issues, + diagnostics, + ); + if (issues.length > 0) { throw new CatalogValidationError(issues); } diff --git a/packages/concierge/src/concierge.ts b/packages/concierge/src/concierge.ts index f7110f1..e931bde 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -34,11 +34,15 @@ import { validateArguments, waitForCommit, } from "./dispatch.js"; +import { rememberCatalogProjection } from "./catalog-prompt.js"; import { encodeDiagnosticSubject, + readHostClock, + readHostRandomId, readHostScheduler, warnHost, } from "./host.js"; +import { sanitizeMessage } from "./message.js"; import { DEFAULT_ACTION_DATA_MAX_BYTES, USER_CANCELLED, @@ -82,7 +86,11 @@ import type { InvocationIdentity, InvocationMeta, ObservedInput, + Clock, + DispatchTiming, + MessageRedactionContext, ObservedActionResult, + ObservedMessage, ObservedResultData, ResolvedCatalog, Scheduler, @@ -90,6 +98,13 @@ import type { StageExplanation, ToolBatch, WorkflowControls, + AttestationOutcome, + ReadbackAttestation, + RetainedReview, + ReviewControls, + ReviewOutcome, + ReviewPresentation, + ReviewRefusalCode, } from "./types.js"; // --------------------------------------------------------------------------- @@ -119,6 +134,8 @@ const MAX_V2_BATCH_CALLS = 10_000; */ const NO_SKIP: ReadonlySet = /* @__PURE__ */ new Set(); +const INSTANCE_ID_PATTERN: RegExp = /^[A-Za-z0-9._-]{1,64}$/u; + const NEVER_ABORTED_SIGNAL: AbortSignalLike = /* @__PURE__ */ Object.freeze({ aborted: false, addEventListener(): void {}, @@ -352,6 +369,8 @@ interface V2Occurrence { readonly meta: InvocationMeta; readonly resolution: AtomicCatalogResolution; readonly root: WorkflowRootState; + readonly startedAt: number; + enteredHandlerAt: number | null; } interface QueuedDispatchEvent { @@ -409,11 +428,13 @@ interface CapturedConsentConfiguration { } interface ConsentGenerationBase { + readonly attestationActId: string | null; readonly confirmationUserTurnId: string | null; readonly generation: bigint; readonly payload: unknown; readonly preparedReadback: PreparedReadback | null; readonly readbackHash: string | null; + readonly readbackResponseId: string | null; readonly responseId: string; readonly sessionId: string | null; readonly snapshot: Readonly>; @@ -430,19 +451,44 @@ interface ConsentReviewClaim { readonly status: "reviewing"; } +interface RetainedReviewRecord { + readonly publicView: RetainedReview; + readonly preparedReadback: PreparedReadback; + readonly snapshot: Readonly>; + readonly snapshotBridgeId: string; + readonly snapshotBridgeRegistry: BridgeRegistry | undefined; + readonly verifiedReadback: VerifiedReadbackEvidence | null; +} + type ConsentGeneration = | ConsentReviewClaim + | (ConsentGenerationBase & { readonly status: "reviewing" }) + | (ConsentGenerationBase & { readonly status: "proposed" }) | (ConsentGenerationBase & { readonly status: "pendingDelivery" }) | (ConsentGenerationBase & { readonly status: "verifyingDelivery" }) | (ConsentGenerationBase & { readonly achievedGrade: Exclude; readonly status: "armed"; }) + | (ConsentGenerationBase & { readonly status: "interrupted" }) | (ConsentGenerationBase & { readonly status: "declined" | "dismissed" | "gradeUnavailable"; }); -/** A consent grade represents measured evidence only when it is not `none`. */ +function bytesEqual( + left: Readonly, + right: Readonly, +): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} function isMeasuredConsentGrade( achievedGrade: ConsentGrade, ): achievedGrade is Exclude { @@ -474,8 +520,8 @@ function hasFreshConsentBoundary( const confirmTurnId: string = confirm.userTurnId ?? ""; if ( review.confirmationUserTurnId !== null && - (profile.userTurnIdentity !== "human-attested" || - confirmTurnId !== review.confirmationUserTurnId) + profile.userTurnIdentity === "human-attested" && + confirmTurnId !== review.confirmationUserTurnId ) { return false; } @@ -487,14 +533,33 @@ function hasFreshConsentBoundary( review.userTurnId !== confirmTurnId; } + if (policy.bindTo === "unverifiedUserTurn") { + return profile.userTurnIdentity !== "none" && + review.userTurnId.length > 0 && + confirmTurnId.length > 0 && + review.userTurnId !== confirmTurnId; + } + if (policy.bindTo !== "response") { return false; } const confirmResponseId: string = confirm.responseId ?? ""; - return review.responseId.length > 0 && - confirmResponseId.length > 0 && - review.responseId !== confirmResponseId; + if ( + review.responseId.length === 0 || + confirmResponseId.length === 0 || + review.responseId === confirmResponseId + ) { + return false; + } + if ( + review.readbackResponseId !== null && + review.readbackResponseId.length > 0 && + confirmResponseId === review.readbackResponseId + ) { + return false; + } + return true; } /** @@ -894,6 +959,17 @@ function snapshotConsentPolicy( reason: declaredMissing.reason, }, ); + const declaredInterrupted = policy.onInterrupted; + const onInterrupted = declaredInterrupted === undefined + ? undefined + : Object.freeze( + declaredInterrupted.reason === undefined + ? { message: declaredInterrupted.message } + : { + message: declaredInterrupted.message, + reason: declaredInterrupted.reason, + }, + ); return Object.freeze({ requires: policy.requires, @@ -901,6 +977,7 @@ function snapshotConsentPolicy( ...(snapshotEquality === undefined ? {} : { snapshotEquality }), ...(minGrade === undefined ? {} : { minGrade }), ...(onMissing === undefined ? {} : { onMissing }), + ...(onInterrupted === undefined ? {} : { onInterrupted }), }); } @@ -1210,6 +1287,60 @@ export function createConcierge(config: ConciergeConfig): Concierge { config.maxWorkflowSteps ?? 256, "maxWorkflowSteps", ); + const configuredInstanceId: unknown = config.instanceId; + if ( + configuredInstanceId !== undefined && + (typeof configuredInstanceId !== "string" || + !INSTANCE_ID_PATTERN.test(configuredInstanceId)) + ) { + throw new TypeError( + "ConciergeConfig.instanceId must match /^[A-Za-z0-9._-]{1,64}$/.", + ); + } + const instanceId: string = typeof configuredInstanceId === "string" + ? configuredInstanceId + : readHostRandomId() ?? + (warnHost( + "concierge: [instance_id_unavailable] no host entropy was available, so dispatch ids use the \"local\" namespace. Fix: provide `ConciergeConfig.instanceId`.", + ), + "local"); + let clockMonotonic: boolean = true; + let clockDegraded: boolean = false; + let lastClockReading: number = 0; + const rawClock: Clock = (() => { + if (typeof config.clock === "function") { + return config.clock; + } + const hostClock: Clock | undefined = readHostClock(); + if (hostClock !== undefined) { + return hostClock; + } + clockMonotonic = false; + warnHost( + "concierge: [clock_unavailable] no monotonic host clock was available, so dispatch timing fell back to Date.now(). Fix: provide `ConciergeConfig.clock`.", + ); + return (): number => Date.now(); + })(); + function readClock(): number { + if (clockDegraded) { + return lastClockReading; + } + try { + const reading: number = rawClock(); + if (!Number.isFinite(reading)) { + throw new TypeError("clock returned a non-finite value"); + } + lastClockReading = reading; + return reading; + } catch { + clockDegraded = true; + clockMonotonic = false; + warnHost( + "concierge: [clock_unusable] the dispatch clock threw or returned a non-finite value; later timings reuse the last good reading. Fix: provide a total `ConciergeConfig.clock`.", + ); + return lastClockReading; + } + } // ONE flat build over every stage's actions followed by the cross-stage // actions — not one build per stage, and the choice is a requirement rather @@ -1239,6 +1370,27 @@ export function createConcierge(config: ConciergeConfig): Concierge { consentProfile: capturedConsent.profile, presentReadback: capturedConsent.presentReadback, digest: capturedConsent.digest, + snapshotSources: stages.flatMap((stage) => { + const sources: Array<{ + readonly actionNames: readonly string[]; + readonly registry: (typeof stage)["bridge"] & object; + }> = []; + if (stage.bridge !== undefined) { + sources.push({ + actionNames: stage.actions.map((action) => action.name), + registry: stage.bridge, + }); + } + for (const action of stage.actions) { + if (action.bridge !== undefined) { + sources.push({ + actionNames: [action.name], + registry: action.bridge, + }); + } + } + return sources; + }), }, ); const reviewNames: ReadonlySet = new Set( @@ -1352,6 +1504,36 @@ export function createConcierge(config: ConciergeConfig): Concierge { | null = null; let warnedDispatch: Set | null = null; let consentGenerations: Map | null = null; + let retainedReviews: Map | null = null; + // **Bounded, and the reason is worth stating because bounding a replay set + // normally is not safe.** This one is defence in depth rather than the + // replay control itself: a replayed attestation is already refused by the + // generation-identity check, by the already-attested status check, and by + // `closeConsentGeneration` deleting the generation once the gated action + // runs. The set only turns a replay that survives all three into an + // `already_attested` rather than a redundant re-arm. Left unbounded it was + // the one structure in the kernel that grew with session length, and the + // ceiling is far past any session a person actually has. + let usedAttestationActIds: Set | null = null; + const usedAttestationOrder: string[] = []; + const USED_ACT_ID_MEMORY: number = 4096; + + function rememberActId(actId: string): void { + const seen: Set = usedAttestationActIds ?? new Set(); + usedAttestationActIds = seen; + if (seen.has(actId)) { + return; + } + seen.add(actId); + usedAttestationOrder.push(actId); + while (usedAttestationOrder.length > USED_ACT_ID_MEMORY) { + const oldest: string | undefined = usedAttestationOrder.shift(); + if (oldest === undefined) { + break; + } + seen.delete(oldest); + } + } let nextConsentGeneration: bigint = 0n; /** Address review authority by both its session namespace and action name. */ @@ -1383,6 +1565,55 @@ export function createConcierge(config: ConciergeConfig): Concierge { : authoredResult(false, declared.message, declared.reason); } + function interruptedConsentResult( + policy: ConsentPolicy, + ): ActionResult { + const declared: ConsentPolicy["onInterrupted"] = + policy.onInterrupted; + return declared === undefined + ? authoredResult( + false, + "The review was interrupted before it finished. Ask to hear it again.", + "consent_interrupted", + ) + : authoredResult(false, declared.message, declared.reason); + } + + function retainGeneration( + slotKey: string, + generation: ConsentGeneration, + reason: "interrupted" | "unconfirmed", + ): void { + if (!("payload" in generation) || generation.preparedReadback === null) { + return; + } + const hash: string | null = generation.readbackHash ?? + generation.verifiedReadback?.hash ?? + null; + if (hash === null || hash.length === 0) { + return; + } + const publicView: RetainedReview = Object.freeze({ + payload: generation.payload, + hash, + reason, + responseId: generation.responseId, + userTurnId: generation.userTurnId, + }); + retainedReviews ??= new Map(); + retainedReviews.set( + slotKey, + Object.freeze({ + publicView, + preparedReadback: generation.preparedReadback, + snapshot: generation.snapshot, + snapshotBridgeId: generation.snapshotBridgeId, + snapshotBridgeRegistry: generation.snapshotBridgeRegistry, + verifiedReadback: generation.verifiedReadback, + }), + ); + } + /** Detach one already-resolved bridge without reading its registry again. */ function captureResolvedSnapshot( bridgeId: string, @@ -1397,27 +1628,33 @@ export function createConcierge(config: ConciergeConfig): Concierge { ); } - /** Arm one owned pending generation from snapshotted delivery evidence. */ + /** Upgrade or retain one owned generation from snapshotted delivery evidence. */ async function observeReviewDelivery( slotKey: string, - pending: ConsentGenerationBase & { readonly status: "pendingDelivery" }, + pending: ConsentGenerationBase, report: DeliveryReport, ): Promise { const current: ConsentGeneration | undefined = consentGenerations?.get(slotKey); if ( - current?.generation !== pending.generation || - current.status !== "pendingDelivery" || - current.responseId !== pending.responseId + current === undefined || + current.generation !== pending.generation || + current.responseId !== pending.responseId || + !("payload" in current) ) { return; } - - const claimed = Object.freeze({ - ...pending, - status: "verifyingDelivery" as const, - }); - consentGenerations?.set(slotKey, claimed); + if ( + current.status === "declined" || + current.status === "dismissed" || + current.status === "gradeUnavailable" || + current.status === "interrupted" + ) { + return; + } + if (current.status === "armed" && current.achievedGrade === "attested") { + return; + } const deliverySnapshot = snapshotDeliveryEvidence(report); if (!deliverySnapshot.ok) { @@ -1426,11 +1663,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { } const delivery: DeliveryEvidenceSnapshot = deliverySnapshot.value; - if ( - delivery.responseId !== pending.responseId || - delivery.outcome !== "completed" - ) { - closeConsentGeneration(slotKey, pending.generation); + // Sequence document: keep the kernel responseId equality gate. + if (delivery.responseId !== pending.responseId) { return; } @@ -1438,84 +1672,75 @@ export function createConcierge(config: ConciergeConfig): Concierge { if (observedAct === "declined" || observedAct === "dismissed") { consentGenerations?.set( slotKey, - Object.freeze({ ...claimed, status: observedAct }), + Object.freeze({ ...current, status: observedAct }), ); return; } - let achievedGrade: ConsentGrade = relayedGradeWithin( - capturedConsent.profile.consentGrade, - ); - let confirmationUserTurnId: string | null = null; - let readbackHash: string | null = null; - const attestation = delivery.attestation; - const verified: VerifiedReadbackEvidence | null = claimed.verifiedReadback; - const hasAttestedClaim: boolean = - delivery.readbackHash !== undefined || attestation !== undefined; - const completeAttestedClaim: boolean = - verified !== null && - consentGradeRank(capturedConsent.profile.consentGrade) >= - consentGradeRank("attested") && - capturedConsent.profile.userTurnIdentity === "human-attested" && - observedAct === "confirmed" && - typeof delivery.readbackHash === "string" && - delivery.readbackHash === verified.hash && - attestation !== undefined && - attestation.readbackHash === verified.hash && - typeof attestation.userTurnId === "string" && - attestation.userTurnId.length > 0 && - attestation.userTurnId !== claimed.userTurnId; - if (hasAttestedClaim && !completeAttestedClaim) { - closeConsentGeneration(slotKey, claimed.generation); + if (delivery.outcome === "interrupted") { + consentGenerations?.set( + slotKey, + Object.freeze({ ...current, status: "interrupted" as const }), + ); + retainGeneration(slotKey, current, "interrupted"); return; } - if (completeAttestedClaim) { - if ( - verified === null || - attestation === undefined || - typeof attestation.userTurnId !== "string" - ) { - closeConsentGeneration(slotKey, claimed.generation); - return; - } - const freshHash: string | null = await digestReadback( - capturedConsent.digest, - verified.canonical, - ); - const stillOwned: ConsentGeneration | undefined = - consentGenerations?.get(slotKey); - if ( - stillOwned?.generation !== claimed.generation || - stillOwned.status !== "verifyingDelivery" || - stillOwned.responseId !== claimed.responseId - ) { - return; - } - if (freshHash !== verified.hash) { - closeConsentGeneration(slotKey, claimed.generation); + + const claimedAttested: boolean = delivery.attestation !== undefined || + (typeof delivery.readbackHash === "string" && + delivery.readbackHash.length > 0); + if (claimedAttested) { + const reportHash: unknown = delivery.readbackHash; + const attestationHash: unknown = delivery.attestation?.readbackHash; + const confirmTurn: unknown = delivery.attestation?.userTurnId; + const validConfirm: boolean = observedAct === "confirmed" && + typeof reportHash === "string" && + reportHash === current.readbackHash && + typeof attestationHash === "string" && + attestationHash === current.readbackHash && + typeof confirmTurn === "string" && + confirmTurn.length > 0 && + confirmTurn !== current.userTurnId; + if (!validConfirm) { + closeConsentGeneration(slotKey, current.generation); return; } - achievedGrade = "attested"; - confirmationUserTurnId = attestation.userTurnId; - readbackHash = verified.hash; } + if (delivery.outcome !== "completed") { + return; + } + + const achievedGrade: ConsentGrade = relayedGradeWithin( + capturedConsent.profile.consentGrade, + ); if (!isMeasuredConsentGrade(achievedGrade)) { consentGenerations?.set( slotKey, - Object.freeze({ ...claimed, status: "gradeUnavailable" }), + Object.freeze({ ...current, status: "gradeUnavailable" as const }), ); return; } + const previousGrade: ConsentGrade = current.status === "armed" + ? current.achievedGrade + : "delivered"; + const nextGrade: Exclude = + consentGradeRank(achievedGrade) >= consentGradeRank(previousGrade) && + isMeasuredConsentGrade(achievedGrade) + ? achievedGrade + : isMeasuredConsentGrade(previousGrade) + ? previousGrade + : achievedGrade; + consentGenerations?.set( slotKey, Object.freeze({ - ...claimed, - achievedGrade, - confirmationUserTurnId, - readbackHash, - status: "armed", + ...current, + achievedGrade: nextGrade, + readbackResponseId: + typeof delivery.responseId === "string" ? delivery.responseId : current.readbackResponseId, + status: "armed" as const, }), ); } @@ -1715,6 +1940,11 @@ export function createConcierge(config: ConciergeConfig): Concierge { revision: Symbol("concierge.catalog") as CatalogRevision, tools, }); + rememberCatalogProjection( + resolved.revision, + catalog.entries.map((entry) => entry.action), + names, + ); resolvedMemo.set(key, resolved); } @@ -1743,7 +1973,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { function allocateDispatchId(): string { nextDispatchId += 1n; - return `dispatch-${nextDispatchId}`; + return `${instanceId}-${nextDispatchId}`; } function drainDispatchEvents(): void { @@ -1772,9 +2002,28 @@ export function createConcierge(config: ConciergeConfig): Concierge { } } - function emitDispatch(event: DispatchEvent): void { + function emitDispatch( + occurrence: V2Occurrence, + event: DispatchEvent extends infer Event + ? Event extends DispatchEvent + ? Omit + : never + : never, + ): void { + const at: number = readClock(); + const elapsedMs: number = Math.max(0, at - occurrence.startedAt); + const handlerMs: number | undefined = occurrence.enteredHandlerAt === null + ? undefined + : Math.max(0, at - occurrence.enteredHandlerAt); + const timing: DispatchTiming = Object.freeze({ + clockMs: at, + wallClockMs: Date.now(), + elapsedMs, + ...(handlerMs === undefined ? {} : { handlerMs }), + monotonic: clockMonotonic, + }); const frozen: DispatchEvent = deepFreeze( - event, + { ...event, timing } as DispatchEvent, NO_SKIP, new WeakSet(), ); @@ -1837,11 +2086,70 @@ export function createConcierge(config: ConciergeConfig): Concierge { return Object.freeze({ kind: "included", value: snapshot.value }); } - function observedResultStatus(result: ActionResult): ObservedActionResult { + function observedMessageFor( + entry: CatalogEntry | null, + result: ActionResult, + resultData: ObservedResultData, + ): ObservedMessage { + if (entry === null) { + return Object.freeze({ kind: "included", value: result.message }); + } + const policy: unknown = entry.action.redactMessage; + if (policy === "drop") { + return Object.freeze({ kind: "dropped" }); + } + if (policy === undefined || policy === "passthrough") { + return Object.freeze({ kind: "included", value: result.message }); + } + if (typeof policy !== "function") { + return Object.freeze({ kind: "included", value: result.message }); + } + const context: MessageRedactionContext = Object.freeze( + result.reason === undefined + ? { ok: result.ok, data: resultData } + : { ok: result.ok, reason: result.reason, data: resultData }, + ); + try { + const projected: unknown = ( + policy as ( + message: string, + context: MessageRedactionContext, + ) => unknown + )(result.message, context); + if (typeof projected !== "string") { + warnDispatchOnce( + `message-redaction-threw:${entry.action.name}`, + `concierge: [message_redaction_failed] action ${encodeDiagnosticSubject(entry.action.name)}: its message projection returned a non-string, so observer message was dropped.`, + ); + return Object.freeze({ kind: "dropped" }); + } + return Object.freeze({ + kind: "included", + value: sanitizeMessage(projected), + }); + } catch { + warnDispatchOnce( + `message-redaction-threw:${entry.action.name}`, + `concierge: [message_redaction_failed] action ${encodeDiagnosticSubject(entry.action.name)}: its message projection threw, so observer message was dropped.`, + ); + return Object.freeze({ kind: "dropped" }); + } + } + + function observedResultStatus( + entry: CatalogEntry | null, + result: ActionResult, + resultData: ObservedResultData, + ): ObservedActionResult { + const message: ObservedMessage = observedMessageFor( + entry, + result, + resultData, + ); return Object.freeze( result.reason === undefined - ? { ok: result.ok, message: result.message } - : { ok: result.ok, reason: result.reason, message: result.message }, + ? { ok: result.ok, message } + : { ok: result.ok, reason: result.reason, message }, ); } @@ -2626,30 +2934,37 @@ export function createConcierge(config: ConciergeConfig): Concierge { occurrence?.identity?.sessionId ?? null; const actionConsentSlotKey: string = consentSlotKey(consentSessionId, name); const replacesReviewAuthority: boolean = reviewNames.has(name); + const retainedForDispatch: RetainedReviewRecord | null = + retainedReviews?.get(actionConsentSlotKey) ?? null; if (replacesReviewAuthority) { - // Validation is the freshness boundary. Every later failure stays closed. + const incumbent: ConsentGeneration | undefined = + consentGenerations?.get(actionConsentSlotKey); + if ( + incumbent !== undefined && + (incumbent.status === "armed" || incumbent.status === "interrupted") && + "payload" in incumbent + ) { + retainGeneration( + actionConsentSlotKey, + incumbent, + incumbent.status === "interrupted" ? "interrupted" : "unconfirmed", + ); + } consentGenerations?.delete(actionConsentSlotKey); } - let preparedReadback: PreparedReadback | null = null; - let validatedSnapshot: InvocationValueSnapshot; - if (attestedReviewNames.has(name)) { - const prepared: PreparedReadbackResult = prepareReadback(validation.value); - if (!prepared.ok) { - return authoredResult( - false, - "The action arguments are invalid.", - "invalid_args", - ); - } - preparedReadback = prepared.value; - validatedSnapshot = { - ok: true, - value: preparedReadback.readback.payload, - }; - } else { - validatedSnapshot = snapshotInvocationValue(validation.value, true); + if (replacesReviewAuthority && !prepareReadback(validation.value).ok) { + return authoredResult( + false, + "The action arguments are invalid.", + "invalid_args", + ); } + + const validatedSnapshot: InvocationValueSnapshot = snapshotInvocationValue( + validation.value, + true, + ); if ( !validatedSnapshot.ok || (occurrence !== null && @@ -2668,9 +2983,6 @@ export function createConcierge(config: ConciergeConfig): Concierge { effectiveBridgeRegistry(entry.action, stage); let reviewingClaim: ConsentReviewClaim | null = null; - let reviewingGeneration: - | (ConsentGenerationBase & { readonly status: "reviewing" }) - | null = null; if (replacesReviewAuthority) { nextConsentGeneration += 1n; reviewingClaim = Object.freeze({ @@ -2706,7 +3018,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { if (occurrence !== null) { observation.input = observedInputFor(entry, validatedSnapshot.value); observation.accepted = true; - emitDispatch({ + emitDispatch(occurrence, { dispatchId: occurrence.dispatchId, name, stage: occurrence.resolution.resolved.stage, @@ -2731,7 +3043,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { } if (occurrence !== null && commitWindowMs > 0) { - emitDispatch({ + emitDispatch(occurrence, { dispatchId: occurrence.dispatchId, name, stage: occurrence.resolution.resolved.stage, @@ -2779,34 +3091,220 @@ export function createConcierge(config: ConciergeConfig): Concierge { } const bridge: Bridge | null = resolveBridgeRegistry(bridgeRegistry); - if (reviewingClaim !== null) { + const snapshotBridgeId: string = + bridgeRegistry?.id ?? stage?.id ?? "cross-stage"; + let proposedThisDispatch: boolean = false; + + const stillOwnsReview = (): boolean => { + if (reviewingClaim === null) { + return false; + } const currentReview: ConsentGeneration | undefined = consentGenerations?.get(actionConsentSlotKey); + return currentReview?.generation === reviewingClaim.generation && + currentReview.responseId === reviewingClaim.responseId; + }; + + const refuseReview = ( + reason: ReviewRefusalCode, + ): ReviewOutcome => Object.freeze({ ok: false, reason }); + + const bindProposal = async ( + payload: unknown, + presented: string | undefined, + retainedCanonical: PreparedReadback | null, + ): Promise> => { + if (reviewingClaim === null || !stillOwnsReview()) { + return refuseReview("superseded"); + } + if (isAborted(signal)) { + closeOwnedReview(); + return refuseReview("aborted"); + } + if ( + occurrence !== null && occurrence.lineage.depth > 0 || + !replacesReviewAuthority + ) { + return refuseReview("not_reviewable"); + } + if (proposedThisDispatch) { + return refuseReview("already_proposed"); + } + const prepared: PreparedReadbackResult = prepareReadback( + payload, + presented, + ); + if (!prepared.ok) { + return refuseReview("payload_unsupported"); + } + if ( + retainedCanonical !== null && + !bytesEqual( + retainedCanonical.canonical, + prepared.value.canonical, + ) + ) { + retainedReviews?.delete(actionConsentSlotKey); + return refuseReview("retained_stale"); + } + const snapshot: Readonly> = + captureResolvedSnapshot(snapshotBridgeId, bridge); + const needsAttested: boolean = attestedReviewNames.has(name); + if (needsAttested && typeof capturedConsent.presentReadback !== "function") { + return refuseReview("presenter_unavailable"); + } + if (needsAttested && capturedConsent.digest === undefined) { + return refuseReview("digest_unavailable"); + } if ( - currentReview?.generation === reviewingClaim.generation && - currentReview.status === "reviewing" && - currentReview.responseId === reviewingClaim.responseId + Object.keys(snapshot).length === 0 ) { - const snapshotBridgeId: string = - bridgeRegistry?.id ?? stage?.id ?? "cross-stage"; - reviewingGeneration = Object.freeze({ + warnDispatchOnce( + `vacuous-snapshot:${name}`, + `concierge: [vacuous_consent_snapshot] action ${encodeDiagnosticSubject(name)}: the propose-time snapshot has zero own keys, so drift checking is inert. Fix: put freshness-determining values on the review bridge snapshot.`, + ); + } + let verifiedReadback: VerifiedReadbackEvidence | null = null; + if ( + needsAttested && + typeof capturedConsent.presentReadback === "function" + ) { + let receipt: unknown; + try { + receipt = await capturedConsent.presentReadback( + prepared.value.readback, + ); + } catch { + return refuseReview("presentation_failed"); + } + if (!stillOwnsReview()) { + return refuseReview("superseded"); + } + if (isAborted(signal)) { + closeOwnedReview(); + return refuseReview("aborted"); + } + const receiptSnapshot: ReadbackReceiptSnapshotResult = + snapshotReadbackReceipt(receipt); + if (!receiptSnapshot.ok) { + return refuseReview("presentation_failed"); + } + const freshHash: string | null = await digestReadback( + capturedConsent.digest, + prepared.value.canonical, + ); + if (!stillOwnsReview()) { + return refuseReview("superseded"); + } + if (isAborted(signal)) { + closeOwnedReview(); + return refuseReview("aborted"); + } + verifiedReadback = verifyReadbackReceipt( + prepared.value, + receiptSnapshot.value, + freshHash, + ); + if (verifiedReadback === null) { + return refuseReview("presentation_failed"); + } + } + proposedThisDispatch = true; + let hash: string | null = verifiedReadback?.hash ?? null; + if (hash === null && needsAttested) { + hash = await digestReadback( + capturedConsent.digest, + prepared.value.canonical, + ); + } + if (hash === null) { + if (needsAttested) { + return refuseReview("digest_unavailable"); + } + hash = ""; + } + if (!stillOwnsReview()) { + return refuseReview("superseded"); + } + const ceiling: ConsentGrade = capturedConsent.profile.consentGrade; + consentGenerations?.set( + actionConsentSlotKey, + Object.freeze({ + attestationActId: null, confirmationUserTurnId: null, generation: reviewingClaim.generation, - payload: validatedSnapshot.value, - preparedReadback, - readbackHash: null, + payload: prepared.value.readback.payload, + preparedReadback: prepared.value, + readbackHash: hash, + readbackResponseId: null, responseId: reviewingClaim.responseId, sessionId: consentSessionId, - snapshot: captureResolvedSnapshot(snapshotBridgeId, bridge), + snapshot, snapshotBridgeId, snapshotBridgeRegistry: bridgeRegistry, - status: "reviewing", + status: "proposed" as const, userTurnId: meta.userTurnId ?? "", - verifiedReadback: null, - }); - consentGenerations?.set(actionConsentSlotKey, reviewingGeneration); - } - } + verifiedReadback, + }), + ); + retainedReviews?.delete(actionConsentSlotKey); + return Object.freeze({ + ok: true as const, + hash, + payload: prepared.value.readback.payload, + ceiling, + }); + }; + + const reviewControls: ReviewControls = { + retained: retainedForDispatch?.publicView ?? null, + propose( + payload: unknown, + presentation?: ReviewPresentation, + ): Promise> { + return bindProposal(payload, presentation?.presented, null); + }, + represent( + retained: RetainedReview, + ): Promise> { + if ( + retainedForDispatch === null || + retained !== retainedForDispatch.publicView + ) { + return Promise.resolve(refuseReview("retained_unknown")); + } + try { + const snapshotBridge: Bridge | null = + retainedForDispatch.snapshotBridgeRegistry === bridgeRegistry + ? bridge + : resolveBridgeRegistry( + retainedForDispatch.snapshotBridgeRegistry, + ); + const currentSnapshot: Readonly> = + captureResolvedSnapshot( + retainedForDispatch.snapshotBridgeId, + snapshotBridge, + ); + if ( + !strictSnapshotEquality( + retainedForDispatch.snapshot, + currentSnapshot, + ) + ) { + retainedReviews?.delete(actionConsentSlotKey); + return Promise.resolve(refuseReview("retained_stale")); + } + } catch { + retainedReviews?.delete(actionConsentSlotKey); + return Promise.resolve(refuseReview("retained_stale")); + } + return bindProposal( + retained.payload, + retainedForDispatch.preparedReadback.readback.presented, + retainedForDispatch.preparedReadback, + ); + }, + }; if (isAborted(signal)) { closeOwnedReview(); @@ -2845,6 +3343,10 @@ export function createConcierge(config: ConciergeConfig): Concierge { closeOwnedReview(); return owned.status === "declined" ? USER_DECLINED : USER_CANCELLED; } + if (owned?.status === "interrupted") { + closeOwnedReview(); + return interruptedConsentResult(policy); + } if ( owned?.status !== "armed" || owned.sessionId !== consentSessionId @@ -2876,8 +3378,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { } if ( owned.achievedGrade === "attested" && - (owned.readbackHash === null || - owned.confirmationUserTurnId === null) + (owned.readbackHash === null || owned.attestationActId === null) ) { closeConsentGeneration(reviewConsentSlotKey, owned.generation); closeOwnedReview(); @@ -2902,6 +3403,19 @@ export function createConcierge(config: ConciergeConfig): Concierge { ); } + if ( + Object.keys(owned.snapshot).length === 0 && + capturedConsent.profile.consentGrade !== "none" + ) { + closeConsentGeneration(reviewConsentSlotKey, owned.generation); + closeOwnedReview(); + return authoredResult( + false, + "The reviewed state changed before this action could run (vacuous snapshot).", + "consent_stale", + ); + } + let snapshotsMatch: boolean = false; try { const snapshotBridge: Bridge | null = @@ -2957,7 +3471,11 @@ export function createConcierge(config: ConciergeConfig): Concierge { grade: owned.achievedGrade, payload: owned.payload, readbackHash: owned.readbackHash as string, + attestationActId: owned.attestationActId as string, responseId: owned.responseId, + ...(owned.readbackResponseId === null + ? {} + : { readbackResponseId: owned.readbackResponseId }), snapshot: owned.snapshot, userTurnId: owned.userTurnId, } @@ -2965,6 +3483,9 @@ export function createConcierge(config: ConciergeConfig): Concierge { grade: owned.achievedGrade, payload: owned.payload, responseId: owned.responseId, + ...(owned.readbackResponseId === null + ? {} + : { readbackResponseId: owned.readbackResponseId }), snapshot: owned.snapshot, userTurnId: owned.userTurnId, }, @@ -2988,7 +3509,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { } } if (occurrence !== null) { - emitDispatch({ + occurrence.enteredHandlerAt = readClock(); + emitDispatch(occurrence, { dispatchId: occurrence.dispatchId, name, stage: occurrence.resolution.resolved.stage, @@ -3007,6 +3529,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { meta, ack: consentAck, workflow, + context: occurrence?.context ?? {}, + review: reviewControls, }); } catch { closeOwnedReview(); @@ -3055,9 +3579,6 @@ export function createConcierge(config: ConciergeConfig): Concierge { handlerResult, ); - if (reviewingGeneration === null) { - return normalizedResult; - } if (!normalizedResult.ok) { closeOwnedReview(); return normalizedResult; @@ -3066,91 +3587,55 @@ export function createConcierge(config: ConciergeConfig): Concierge { const currentReview: ConsentGeneration | undefined = consentGenerations?.get(actionConsentSlotKey); if ( - currentReview?.generation !== reviewingGeneration.generation || - currentReview.status !== "reviewing" || - currentReview.responseId !== reviewingGeneration.responseId + reviewingClaim === null || + currentReview?.generation !== reviewingClaim.generation || + currentReview.status !== "proposed" || + currentReview.responseId !== reviewingClaim.responseId || + !("payload" in currentReview) ) { + if (reviewingClaim !== null && currentReview?.status === "reviewing") { + warnDispatchOnce( + `review-without-propose:${name}`, + `concierge: [review_unproposed] action ${encodeDiagnosticSubject(name)}: the review handler returned ok without proposing a payload, so consent did not arm. Fix: call ctx.review.propose(payload) after the payload is authoritative.`, + ); + closeOwnedReview(); + } return normalizedResult; } - let verifiedReadback: VerifiedReadbackEvidence | null = null; - if (reviewingGeneration.preparedReadback !== null) { - const presenter = capturedConsent.presentReadback; - if (presenter === undefined) { - closeOwnedReview(); - return normalizedResult; - } - let receipt: unknown; - try { - receipt = await presenter(reviewingGeneration.preparedReadback.readback); - } catch { - closeOwnedReview(); - return normalizedResult; - } - const afterPresentation: ConsentGeneration | undefined = - consentGenerations?.get(actionConsentSlotKey); - if ( - afterPresentation?.generation !== reviewingGeneration.generation || - afterPresentation.status !== "reviewing" || - afterPresentation.responseId !== reviewingGeneration.responseId - ) { - return normalizedResult; - } - const receiptSnapshot: ReadbackReceiptSnapshotResult = - snapshotReadbackReceipt(receipt); - if (!receiptSnapshot.ok) { - closeOwnedReview(); - return normalizedResult; - } - const freshHash: string | null = await digestReadback( - capturedConsent.digest, - reviewingGeneration.preparedReadback.canonical, - ); - const afterDigest: ConsentGeneration | undefined = - consentGenerations?.get(actionConsentSlotKey); - if ( - afterDigest?.generation !== reviewingGeneration.generation || - afterDigest.status !== "reviewing" || - afterDigest.responseId !== reviewingGeneration.responseId - ) { - return normalizedResult; - } - verifiedReadback = verifyReadbackReceipt( - reviewingGeneration.preparedReadback, - receiptSnapshot.value, - freshHash, + const deliveredCeiling: ConsentGrade = consentGradeRank( + capturedConsent.profile.consentGrade, + ) >= consentGradeRank("delivered") + ? "delivered" + : capturedConsent.profile.consentGrade; + if (!isMeasuredConsentGrade(deliveredCeiling)) { + consentGenerations?.set( + actionConsentSlotKey, + Object.freeze({ ...currentReview, status: "gradeUnavailable" as const }), ); - if (verifiedReadback === null) { - closeOwnedReview(); - return normalizedResult; - } + return normalizedResult; } + const armed = Object.freeze({ + ...currentReview, + achievedGrade: deliveredCeiling, + status: "armed" as const, + }); + consentGenerations?.set(actionConsentSlotKey, armed); + const deliveryHook: InvocationMeta["deferUntilDelivered"] = meta.deferUntilDelivered; if ( - typeof deliveryHook !== "function" || - reviewingGeneration.responseId.length === 0 + typeof deliveryHook === "function" && + currentReview.responseId.length > 0 ) { - closeOwnedReview(); - return normalizedResult; - } - - const pendingDelivery = Object.freeze({ - ...reviewingGeneration, - status: "pendingDelivery" as const, - verifiedReadback, - }); - consentGenerations?.set(actionConsentSlotKey, pendingDelivery); - try { - deliveryHook((report: DeliveryReport): void => { - void observeReviewDelivery(actionConsentSlotKey, pendingDelivery, report); - }); - } catch { - closeConsentGeneration( - actionConsentSlotKey, - reviewingGeneration.generation, - ); + try { + deliveryHook((report: DeliveryReport): void => { + void observeReviewDelivery(actionConsentSlotKey, armed, report); + }); + } catch { + // A throwing hook must not disarm an already-delivered generation. + } } return normalizedResult; @@ -3225,7 +3710,10 @@ export function createConcierge(config: ConciergeConfig): Concierge { } result = outcome.result; - emitDispatch({ + const resultData: ObservedResultData = exposeObservedResultData( + outcome.observedData, + ); + emitDispatch(occurrence, { dispatchId: occurrence.dispatchId, name, stage: occurrence.resolution.resolved.stage, @@ -3235,8 +3723,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { input: observation.input, terminalAction: entry.action.terminal === true, phase: eventTerminalPhase(result), - result: observedResultStatus(result), - resultData: exposeObservedResultData(outcome.observedData), + result: observedResultStatus(entry, result, resultData), + resultData, terminalEntered: occurrence.root.terminalRef !== null, }); return result; @@ -3272,6 +3760,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { meta, resolution, root, + startedAt: readClock(), + enteredHandlerAt: null, }; } @@ -3485,8 +3975,11 @@ export function createConcierge(config: ConciergeConfig): Concierge { meta, resolution, root: inherited.root, + startedAt: readClock(), + enteredHandlerAt: null, }; - emitDispatch({ + const rejectedData: ObservedResultData = observedResultDataFor(null, result); + emitDispatch(occurrence, { dispatchId: occurrence.dispatchId, name, stage: resolution.resolved.stage, @@ -3498,8 +3991,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { terminalEntered: inherited?.root.terminalRef !== null && inherited?.root.terminalRef !== undefined, phase: eventTerminalPhase(result), - result: observedResultStatus(result), - resultData: observedResultDataFor(null, result), + result: observedResultStatus(null, result, rejectedData), + resultData: rejectedData, }); return trackDispatchPromise(Promise.resolve(result), executionState); } @@ -3717,6 +4210,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { meta: request.meta, resolution, root: inherited.root, + startedAt: readClock(), + enteredHandlerAt: null, }; const promise: Promise = Promise.resolve().then(() => runDispatchPipeline( @@ -4269,12 +4764,143 @@ export function createConcierge(config: ConciergeConfig): Concierge { // number of seals in this file; that argument was arithmetically wrong, and // a wrong reason attached to a right decision is how a right decision gets // reversed by the first reader who checks it. + function isSha256Hex(value: unknown): value is string { + if (typeof value !== "string" || value.length !== 64) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + const code: number = value.charCodeAt(index); + if (!((code >= 0x30 && code <= 0x39) || (code >= 0x61 && code <= 0x66))) { + return false; + } + } + return true; + } + + function attestReadback(attestation: ReadbackAttestation): AttestationOutcome { + if (typeof attestation !== "object" || attestation === null) { + return "malformed"; + } + let act: unknown; + let actId: unknown; + let readbackHash: unknown; + let userTurnId: unknown; + try { + const prototype: object | null = Object.getPrototypeOf(attestation); + if (prototype !== Object.prototype && prototype !== null) { + return "malformed"; + } + act = attestation.act; + actId = attestation.actId; + readbackHash = attestation.readbackHash; + userTurnId = attestation.userTurnId; + } catch { + return "malformed"; + } + if ( + (act !== "confirmed" && act !== "declined" && act !== "dismissed") || + !isSafeIdentifier(actId) || + !isSha256Hex(readbackHash) || + (userTurnId !== undefined && typeof userTurnId !== "string") + ) { + return "malformed"; + } + if (usedAttestationActIds?.has(actId) === true) { + return "already_attested"; + } + if (consentGenerations === null) { + return "unknown_readback"; + } + // **Every match is collected, and two matches refuse.** The hash covers + // `{payload, presented}` and nothing else — not the review action's name + // — so two review actions whose readbacks are byte-identical are + // indistinguishable here. An earlier draft took the first in Map + // insertion order, which arms a generation the person may not be the one + // who heard. When the evidence cannot say which review was confirmed, + // the honest answer is that none was. + let matched: { slotKey: string; generation: ConsentGenerationBase } | null = + null; + let ambiguous: boolean = false; + for (const [slotKey, generation] of consentGenerations) { + if ( + "payload" in generation && + generation.readbackHash === readbackHash + ) { + if (matched !== null) { + ambiguous = true; + break; + } + matched = { slotKey, generation }; + } + } + if (ambiguous) { + warnDispatchOnce( + `ambiguous-attestation:${readbackHash}`, + `concierge: [ambiguous_attestation] two pending reviews share one readback hash, so the attestation named no single review and was refused. Fix: make each review's payload distinguish the action it gates.`, + ); + return "unknown_readback"; + } + if (matched === null) { + return "unknown_readback"; + } + const current = consentGenerations.get(matched.slotKey); + if ( + current === undefined || + current.generation !== matched.generation.generation + ) { + return "unknown_readback"; + } + if ( + current.status === "declined" || + current.status === "dismissed" || + (current.status === "armed" && + "achievedGrade" in current && + current.achievedGrade === "attested") + ) { + return "already_attested"; + } + if (act === "declined" || act === "dismissed") { + rememberActId(actId); + consentGenerations.set( + matched.slotKey, + Object.freeze({ ...matched.generation, status: act }), + ); + return "accepted"; + } + if ( + consentGradeRank(capturedConsent.profile.consentGrade) < + consentGradeRank("attested") + ) { + return "grade_unavailable"; + } + if (!("payload" in current)) { + return "unknown_readback"; + } + rememberActId(actId); + consentGenerations.set( + matched.slotKey, + Object.freeze({ + ...current, + achievedGrade: "attested" as const, + attestationActId: actId, + confirmationUserTurnId: + typeof userTurnId === "string" && userTurnId.length > 0 + ? userTurnId + : current.confirmationUserTurnId, + status: "armed" as const, + }), + ); + return "accepted"; + } + const concierge: Concierge = { + instanceId, dispatch: dispatchV2, dispatchBatch: dispatchBatchV2, resolveCatalog, onDispatch, explain, + attestReadback, }; const configuredConcierge: Concierge = attachConsentProfile(concierge, capturedConsent.profile); diff --git a/packages/concierge/src/consent-evidence.ts b/packages/concierge/src/consent-evidence.ts index 96d173b..3ae75d1 100644 --- a/packages/concierge/src/consent-evidence.ts +++ b/packages/concierge/src/consent-evidence.ts @@ -1,6 +1,7 @@ import type { DigestLike, Readback, + ReadbackReceipt, } from "./types.js"; interface OwnShape { @@ -397,25 +398,37 @@ function encodeUtf8(value: string): Uint8Array | null { return new Uint8Array(bytes); } -/** Detach, freeze, and canonicalize the exact payload handed to a presenter. */ -export function prepareReadback(payload: unknown): PreparedReadbackResult { +/** Detach, freeze, and canonicalize `{payload, presented?}` as one envelope. */ +export function prepareReadback( + payload: unknown, + presented?: string | undefined, +): PreparedReadbackResult { try { + if (presented !== undefined && typeof presented !== "string") { + return { ok: false }; + } + const envelope: { readonly payload: unknown; readonly presented?: string } = + presented === undefined ? { payload } : { payload, presented }; const strict: StrictValue | null = snapshotStrictValue( - payload, + envelope, new WeakSet(), ); if (strict === null) { return { ok: false }; } - const canonical: Uint8Array | null = encodeUtf8( - `{"payload":${strict.canonical}}`, - ); + const canonical: Uint8Array | null = encodeUtf8(strict.canonical); if (canonical === null) { return { ok: false }; } - const readback: Readback = Object.freeze({ - payload: strict.value, - }); + const detached = strict.value as { + readonly payload: unknown; + readonly presented?: string; + }; + const readback: Readback = Object.freeze( + presented === undefined + ? { payload: detached.payload } + : { payload: detached.payload, presented: detached.presented }, + ); return { ok: true, value: Object.freeze({ canonical, readback }), @@ -425,6 +438,55 @@ export function prepareReadback(payload: unknown): PreparedReadbackResult { } } +/** + * Produce the receipt a {@link ReadbackSink} must return. + * + * Canonicalizes `{payload, presented?}` exactly as core does, digests it + * through the injected capability, and freezes the result. Rejects with a + * `TypeError` when the payload is not canonicalizable or the digest does not + * return 32 bytes. + */ +export async function makeReadbackReceipt( + readback: Readback, + digest: DigestLike, +): Promise { + if (typeof readback !== "object" || readback === null) { + throw new TypeError("The readback could not be canonicalized."); + } + let payload: unknown; + let presented: unknown; + try { + payload = readback.payload; + presented = readback.presented; + } catch { + throw new TypeError("The readback could not be canonicalized."); + } + if (presented !== undefined && typeof presented !== "string") { + throw new TypeError("The readback could not be canonicalized."); + } + const prepared: PreparedReadbackResult = prepareReadback( + payload, + presented === undefined ? undefined : presented, + ); + if (!prepared.ok) { + throw new TypeError("The readback could not be canonicalized."); + } + const captured: DigestLike | undefined = captureDigestCapability(digest); + const hash: string | null = await digestReadback( + captured, + prepared.value.canonical, + ); + if (hash === null) { + throw new TypeError("The readback digest did not return 32 bytes."); + } + return Object.freeze({ + hash, + alg: "SHA-256" as const, + canonicalization: "JCS" as const, + canonical: new Uint8Array(prepared.value.canonical), + }); +} + function copyTypedArrayShape( shape: OwnShape, length: number, @@ -709,6 +771,18 @@ function snapshotAttestation( return null; } const act: PropertyDescriptor | null = dataDescriptor(shape, "act"); + // **`userTurnId` is optional on the type, so it is optional here too.** + // Requiring it made a `ReadbackAttestation` that typechecks — the field is + // declared `userTurnId?: string | undefined` because `attestReadback` reads + // it only when the policy binds to `"userTurn"` — fail the whole snapshot, + // which `observeReviewDelivery` cannot tell apart from a hostile report and + // answers by closing the generation. That refusal belongs one layer up, + // where `validConfirm` already demands a non-empty confirming turn distinct + // from the review's, and where a `declined` act is recorded as a human + // decision instead of being erased. The `hasX && x === null` idiom is the + // one `snapshotDeliveryEvidence` below already uses for its own optional + // field: absent is fine, present-but-not-own-data is not. + const hasUserTurnId: boolean = shape.keys.includes("userTurnId"); const userTurnId: PropertyDescriptor | null = dataDescriptor( shape, "userTurnId", @@ -719,7 +793,7 @@ function snapshotAttestation( ); if ( act === null || - userTurnId === null || + (hasUserTurnId && userTurnId === null) || readbackHash === null || !shapeStillMatches(value, shape) ) { @@ -728,7 +802,7 @@ function snapshotAttestation( return Object.freeze({ act: act.value, readbackHash: readbackHash.value, - userTurnId: userTurnId.value, + userTurnId: userTurnId?.value, }); } diff --git a/packages/concierge/src/contract.ts b/packages/concierge/src/contract.ts index 7da66f7..320e3f3 100644 --- a/packages/concierge/src/contract.ts +++ b/packages/concierge/src/contract.ts @@ -54,13 +54,13 @@ * **Bump policy.** An integer, bumped only when the *shared runtime contract* * changes incompatibly — the bridge registry shape, the dedup key, or the * consent record. Not on every release, and not on an additive type change. - * Contract v3 ships `3`. + * Contract v4 ships `4`. * * An integer rather than a string or a semver-ish value: a richer shape buys * nothing until there is a compatibility *range* to express, and it is a one-way * door once published. */ -export const CONTRACT_VERSION = 3; +export const CONTRACT_VERSION = 4; /** * The cross-realm slot where two independently-resolved copies of core meet. diff --git a/packages/concierge/src/dispatch.ts b/packages/concierge/src/dispatch.ts index 71e9710..f8e047a 100644 --- a/packages/concierge/src/dispatch.ts +++ b/packages/concierge/src/dispatch.ts @@ -163,10 +163,15 @@ function cloneInvocationValue( ) as Record; seen.set(value, clone); for (const key of Object.keys(value)) { + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !("value" in descriptor)) { + throw new TypeError("Invocation values must be data."); + } Object.defineProperty(clone, key, { configurable: true, enumerable: true, - value: cloneInvocationValue((value as Record)[key], seen), + value: cloneInvocationValue(descriptor.value, seen), writable: true, }); } @@ -772,7 +777,7 @@ export interface ResultNormalizationOptions extends ResultWarnings { readonly maximumDataBytes: number; } -/** Runtime membership check for the closed sixteen-code vocabulary. */ +/** Runtime membership check for the closed seventeen-code vocabulary. */ export function isReasonCode(value: unknown): value is ReasonCode { switch (value) { case "declined": @@ -791,6 +796,7 @@ export function isReasonCode(value: unknown): value is ReasonCode { case "invalid_invocation": case "identity_conflict": case "precondition_failed": + case "consent_interrupted": return true; default: return false; diff --git a/packages/concierge/src/host.ts b/packages/concierge/src/host.ts index cbfa59c..f41578b 100644 --- a/packages/concierge/src/host.ts +++ b/packages/concierge/src/host.ts @@ -216,6 +216,111 @@ export function readHostScheduler(): * redirectable one is its `onDiagnostic` hook. A consumer who needs to observe * these reliably uses the hook and depends on no host global whatsoever. */ +interface PerformanceHost { + performance?: { + now?: () => number; + } | null; +} + +interface CryptoHost { + crypto?: { + randomUUID?: () => string; + getRandomValues?: (bytes: Uint8Array) => Uint8Array; + } | null; +} + +/** + * Read a monotonic clock from the host, or report that none is available. + * + * Reaches `globalThis.performance.now` structurally and invokes it with + * `performance` as receiver. Returns `undefined` when absent or not callable. + */ +export function readHostClock(): (() => number) | undefined { + const host: PerformanceHost = globalThis as PerformanceHost; + let performanceLike: PerformanceHost["performance"]; + try { + performanceLike = host.performance; + } catch { + return undefined; + } + if (performanceLike === undefined || performanceLike === null) { + return undefined; + } + let now: unknown; + try { + now = performanceLike.now; + } catch { + return undefined; + } + if (typeof now !== "function") { + return undefined; + } + return (): number => (now as () => number).call(performanceLike); +} + +/** + * Mint 32 lowercase hex characters of host entropy, or report failure. + * + * Prefers `crypto.randomUUID()` with dashes stripped, then + * `crypto.getRandomValues`. Returns `undefined` rather than a sentinel. + */ +export function readHostRandomId(): string | undefined { + const host: CryptoHost = globalThis as CryptoHost; + let cryptoLike: CryptoHost["crypto"]; + try { + cryptoLike = host.crypto; + } catch { + return undefined; + } + if (cryptoLike === undefined || cryptoLike === null) { + return undefined; + } + + let randomUUID: unknown; + try { + randomUUID = cryptoLike.randomUUID; + } catch { + randomUUID = undefined; + } + if (typeof randomUUID === "function") { + try { + const uuid: unknown = (randomUUID as () => string).call(cryptoLike); + if (typeof uuid === "string") { + const hex: string = uuid.replace(/-/g, "").toLowerCase(); + if (/^[0-9a-f]{32}$/u.test(hex)) { + return hex; + } + } + } catch { + // Fall through to getRandomValues. + } + } + + let getRandomValues: unknown; + try { + getRandomValues = cryptoLike.getRandomValues; + } catch { + return undefined; + } + if (typeof getRandomValues !== "function") { + return undefined; + } + try { + const bytes: Uint8Array = new Uint8Array(16); + (getRandomValues as (bytes: Uint8Array) => Uint8Array).call( + cryptoLike, + bytes, + ); + let hex: string = ""; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, "0"); + } + return hex; + } catch { + return undefined; + } +} + export function warnHost(message: string): void { const host: { console?: ConsoleLike | null } = globalThis as { console?: ConsoleLike | null; diff --git a/packages/concierge/src/index.ts b/packages/concierge/src/index.ts index f5de14f..a5d1433 100644 --- a/packages/concierge/src/index.ts +++ b/packages/concierge/src/index.ts @@ -1,5 +1,5 @@ /** - * @full-self-browsing/concierge contract v3. + * @full-self-browsing/concierge contract v4. * * The framework-neutral core declares typed, consent-gated actions; resolves * stage, dynamic availability, tools, and a local catalog revision atomically; @@ -56,6 +56,11 @@ export type { // Redaction RedactionPolicy, OutputRedactionPolicy, + Clock, + DispatchTiming, + ObservedMessage, + MessageRedactionPolicy, + MessageRedactionContext, // Actions ActionOutputDefinition, ActionDefinition, @@ -63,6 +68,11 @@ export type { // Bridges Bridge, BridgeRegistry, + BridgeRegistrationEvent, + BridgeRegistrationListener, + ObservableBridgeRegistry, + RegistrationWaitOptions, + RegistrationWait, // Stages StageContext, StageDefinition, @@ -96,6 +106,13 @@ export type { SessionConfig, SessionDiagnosticCode, SessionDiagnostic, + CatalogAcknowledgement, + ReviewPresentation, + ReviewRefusalCode, + ReviewOutcome, + RetainedReview, + ReviewControls, + AttestationOutcome, } from "./types.js"; export type { @@ -105,6 +122,10 @@ export type { JsonSchemaConverter, } from "./json-schema.js"; +export type { + SanitizeTextOptions, +} from "./message.js"; + export type { // Catalog Catalog, @@ -128,6 +149,12 @@ export { CONTRACT_VERSION, assertSingleInstance } from "./contract.js"; export { JSON_SCHEMA_TARGET } from "./json-schema.js"; +export { isReasonCode } from "./dispatch.js"; + +export { sanitizeText } from "./message.js"; + +export { makeReadbackReceipt } from "./consent-evidence.js"; + export { buildCatalog, CatalogValidationError } from "./catalog.js"; export { defineAction } from "./define-action.js"; @@ -136,4 +163,40 @@ export { createConcierge } from "./concierge.js"; export { createSession } from "./session.js"; -export { createBridge, captureSnapshot, offPageResult } from "./bridge.js"; +export { + createBridge, + captureSnapshot, + offPageResult, + awaitRegistration, +} from "./bridge.js"; + +export type { + RecordedTurn, + TurnLedgerConfig, + TurnLedger, +} from "./turn-ledger.js"; +export { createTurnLedger } from "./turn-ledger.js"; + +export type { + RenditionEvidence, + RenditionIssueCode, + RenditionIssue, + RenditionSettlement, + RenditionBinderConfig, + RenditionBinder, +} from "./rendition.js"; +export { createRenditionBinder } from "./rendition.js"; + +export type { + ResolveValueRefusal, + ResolveValueResult, + ResolveValueConfig, +} from "./resolve-value.js"; +export { resolveValue } from "./resolve-value.js"; + +export type { + CatalogPromptFormat, + RenderCatalogPromptOptions, + CatalogDerivedPolicy, +} from "./catalog-prompt.js"; +export { renderCatalogPrompt, catalogDerivedPolicy } from "./catalog-prompt.js"; diff --git a/packages/concierge/src/message.ts b/packages/concierge/src/message.ts index c901207..8f01835 100644 --- a/packages/concierge/src/message.ts +++ b/packages/concierge/src/message.ts @@ -7,13 +7,18 @@ * stronger dispatcher-boundary policy: replace C0/C1 controls, normalize * whitespace, trim, and then apply that same bound. * - * Neither helper is part of the public barrel. They share - * {@link MESSAGE_MAX_CHARS} so the bridge and dispatcher cannot silently drift - * onto different limits. + * `sanitizeMessage` stays the dispatcher-bound wrapper so existing tests pin + * byte-identical output. `sanitizeText` is the public generalization. */ import { MESSAGE_MAX_CHARS } from "./types.js"; +/** Options for {@link sanitizeText}. */ +export interface SanitizeTextOptions { + readonly maxChars?: number | undefined; + readonly ellipsis?: boolean | undefined; +} + /** * Cut a message to {@link MESSAGE_MAX_CHARS} without splitting a surrogate * pair. @@ -25,15 +30,24 @@ import { MESSAGE_MAX_CHARS } from "./types.js"; * retained with it. */ export function boundedMessage(message: string): string { - if (message.length <= MESSAGE_MAX_CHARS) { - return message; - } - - const lastRetained: number = message.charCodeAt(MESSAGE_MAX_CHARS - 1); - const cut: number = - lastRetained >= 0xd800 && lastRetained <= 0xdbff ? MESSAGE_MAX_CHARS - 1 : MESSAGE_MAX_CHARS; + return boundText(message, MESSAGE_MAX_CHARS, false); +} - return message.slice(0, cut); +/** + * Sanitize untrusted text: C0/C1 runs become one ASCII space, remaining + * whitespace collapses, then a surrogate-safe bound is applied. + */ +export function sanitizeText( + input: string, + options?: SanitizeTextOptions, +): string { + const maxChars: number = options?.maxChars ?? MESSAGE_MAX_CHARS; + const ellipsis: boolean = options?.ellipsis === true; + const sanitized: string = input + .replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ") + .replace(/\s+/gu, " ") + .trim(); + return boundText(sanitized, maxChars, ellipsis); } /** @@ -44,10 +58,41 @@ export function boundedMessage(message: string): string { * only then is the surrogate-safe shared bound applied. */ export function sanitizeMessage(message: string): string { - const sanitized: string = message - .replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ") - .replace(/\s+/gu, " ") - .trim(); + return sanitizeText(message, { maxChars: MESSAGE_MAX_CHARS }); +} + +/** + * The largest cut at or below `limit` that does not split a surrogate pair. + * + * Both cuts `boundText` makes go through here. The ellipsis cut needs it just + * as much as the bound does: shortening a well-formed slice by one code unit + * to make room for `…` strands a high surrogate whenever the slice ended on an + * astral character, and a lone surrogate is not a well-formed UTF-16 string. + * `consent-evidence.ts`'s `quoteString` rejects one outright, so a readback + * assembled from truncated text would refuse with `payload_unsupported`. + */ +function surrogateSafeCut(value: string, limit: number): number { + if (limit <= 0) { + return 0; + } + const lastRetained: number = value.charCodeAt(limit - 1); + return lastRetained >= 0xd800 && lastRetained <= 0xdbff ? limit - 1 : limit; +} - return boundedMessage(sanitized); +function boundText(message: string, maxChars: number, ellipsis: boolean): string { + if (!Number.isSafeInteger(maxChars) || maxChars < 0) { + return ""; + } + if (message.length <= maxChars) { + return message; + } + + const sliced: string = message.slice(0, surrogateSafeCut(message, maxChars)); + if (!ellipsis) { + return sliced; + } + if (sliced.length === 0) { + return ""; + } + return `${sliced.slice(0, surrogateSafeCut(sliced, sliced.length - 1))}…`; } diff --git a/packages/concierge/src/rendition.ts b/packages/concierge/src/rendition.ts new file mode 100644 index 0000000..87041e7 --- /dev/null +++ b/packages/concierge/src/rendition.ts @@ -0,0 +1,426 @@ +/** + * Cause/rendition attribution for DeliveryReport.responseId. + * + * A tool-calling transport executes a review inside response N and voices the + * result in response N+1. The consent kernel arms only when + * report.responseId equals the REVIEW dispatch's response id, so a report + * naming N+1 closes the generation. This binder holds the causal link and + * emits a report naming the cause. + */ + +import { encodeDiagnosticSubject, warnHost } from "./host.js"; +import type { + DeliveryReport, + ReadbackAttestation, + ToolBatch, +} from "./types.js"; + +export type RenditionEvidence = "explicit" | "generation-end"; + +export type RenditionIssueCode = + | "unbound_rendition" + | "cause_already_bound" + | "duplicate_settlement" + | "effect_threw" + | "capacity_evicted" + | "late_deferral"; + +export interface RenditionIssue { + readonly code: RenditionIssueCode; + readonly causeResponseId: string | null; + readonly renditionResponseId: string | null; + readonly message: string; +} + +export interface RenditionSettlement { + readonly outcome: DeliveryReport["outcome"]; + readonly readbackHash?: string | undefined; + readonly attestation?: ReadbackAttestation | undefined; +} + +export interface RenditionBinderConfig { + readonly renditionEvidence?: RenditionEvidence | undefined; + readonly maxPendingCauses?: number | undefined; + readonly onIssue?: ((issue: RenditionIssue) => void) | undefined; +} + +export interface RenditionBinder { + deferralsFor( + causeResponseId: string, + ): NonNullable; + bindRendition(link: { readonly cause: string; readonly rendition: string }): void; + renditionStarted(renditionResponseId: string): void; + generationEnded(renditionResponseId: string): void; + settle(renditionResponseId: string, settlement: RenditionSettlement): void; + abandonCause(causeResponseId: string): void; + abandonUnstarted(): void; + abandonAll(): void; + pendingCauses(): ReadonlyArray; +} + +type DeliveryEffect = (report: DeliveryReport) => void; + +const DEFAULT_MAX_PENDING_CAUSES: number = 32; +const SETTLED_MEMORY: number = 512; + +/** + * A membership set that forgets its oldest entry past a cap. + * + * The two settlement memories are pure diagnostics: they exist so a late + * deferral or a second `settle` reports the code that names what happened + * instead of a misleading one. `causes` is already bounded by + * `maxPendingCauses`, and leaving them unbounded would make the binder the + * only thing in a voice session that grows with its length. + * + * `started` uses the same set for the same reason, and it is the one whose + * eviction is worth stating. Membership there is load-bearing — it is how + * `generationEnded` tells a rendition that never began from one that did, and + * how `abandonUnstarted` picks its targets. Eviction can only misread a + * rendition still waiting to settle after {@link SETTLED_MEMORY} later ones + * have started, which is not a state a session reaches; renditions settle. + * What it does stop is the growth: `settle` only removed an id once it had a + * bound cause, so every playback with nothing deferred against it left its + * string behind for the life of the binder. + */ +function createBoundedIdSet(): { + has(id: string): boolean; + add(id: string): void; + delete(id: string): void; + clear(): void; +} { + const ids: Set = new Set(); + const order: string[] = []; + return { + has(id: string): boolean { + return ids.has(id); + }, + add(id: string): void { + if (ids.has(id)) return; + ids.add(id); + order.push(id); + while (order.length > SETTLED_MEMORY) { + const oldest: string | undefined = order.shift(); + if (oldest === undefined) break; + ids.delete(oldest); + } + }, + delete(id: string): void { + if (!ids.delete(id)) return; + const index: number = order.indexOf(id); + if (index >= 0) order.splice(index, 1); + }, + clear(): void { + ids.clear(); + order.length = 0; + }, + }; +} + +function usableId(value: unknown): string | null { + return typeof value === "string" && + value.length > 0 && + value.length <= 1024 + ? value + : null; +} + +function freezeReport( + causeResponseId: string, + settlement: RenditionSettlement, +): DeliveryReport { + const readbackHash: string | undefined = settlement.readbackHash; + const attestation: ReadbackAttestation | undefined = settlement.attestation; + return Object.freeze({ + responseId: causeResponseId, + outcome: settlement.outcome, + ...(readbackHash === undefined ? {} : { readbackHash }), + ...(attestation === undefined ? {} : { attestation }), + }); +} + +export function createRenditionBinder( + config: RenditionBinderConfig = {}, +): RenditionBinder { + const evidence: RenditionEvidence = + config.renditionEvidence === "generation-end" + ? "generation-end" + : "explicit"; + const maxPendingCauses: number = + config.maxPendingCauses === undefined || + !Number.isSafeInteger(config.maxPendingCauses) || + config.maxPendingCauses < 1 + ? DEFAULT_MAX_PENDING_CAUSES + : config.maxPendingCauses; + const onIssue: ((issue: RenditionIssue) => void) | undefined = config.onIssue; + + const causes: Map = new Map(); + const causeToRendition: Map = new Map(); + const renditionToCauses: Map = new Map(); + const started = createBoundedIdSet(); + const settledCauses = createBoundedIdSet(); + const settledRenditions = createBoundedIdSet(); + + function reportIssue(issue: RenditionIssue): void { + if (onIssue !== undefined) { + try { + onIssue(issue); + } catch { + // A diagnostic sink cannot become control flow. + } + return; + } + warnHost( + `concierge: [rendition_${issue.code}] ${issue.message}`, + ); + } + + function invokeEffects( + causeResponseId: string, + effects: readonly DeliveryEffect[], + report: DeliveryReport, + ): void { + for (const effect of effects) { + try { + effect(report); + } catch { + reportIssue({ + code: "effect_threw", + causeResponseId, + renditionResponseId: causeToRendition.get(causeResponseId) ?? null, + message: + `an effect for cause ${encodeDiagnosticSubject(causeResponseId)} threw; remaining effects still ran.`, + }); + } + } + } + + function settleCause( + causeResponseId: string, + settlement: RenditionSettlement, + ): void { + const effects: DeliveryEffect[] = causes.get(causeResponseId) ?? []; + const rendition: string | undefined = causeToRendition.get(causeResponseId); + causes.delete(causeResponseId); + causeToRendition.delete(causeResponseId); + if (rendition !== undefined) { + const siblings: string[] = (renditionToCauses.get(rendition) ?? []) + .filter((id) => id !== causeResponseId); + if (siblings.length === 0) { + renditionToCauses.delete(rendition); + started.delete(rendition); + } else { + renditionToCauses.set(rendition, siblings); + } + } + settledCauses.add(causeResponseId); + invokeEffects( + causeResponseId, + effects, + freezeReport(causeResponseId, settlement), + ); + } + + function evictIfNeeded(): void { + while (causes.size > maxPendingCauses) { + const oldest: string | undefined = causes.keys().next().value; + if (oldest === undefined) { + break; + } + reportIssue({ + code: "capacity_evicted", + causeResponseId: oldest, + renditionResponseId: causeToRendition.get(oldest) ?? null, + message: + `the oldest pending cause ${encodeDiagnosticSubject(oldest)} was settled interrupted to stay bounded.`, + }); + settleCause(oldest, { outcome: "interrupted" }); + } + } + + const binder: RenditionBinder = { + deferralsFor(causeResponseId: string): NonNullable< + ToolBatch["deferUntilDelivered"] + > { + const cause: string | null = usableId(causeResponseId); + return (effect: DeliveryEffect): void => { + if (cause === null) { + effect(freezeReport("", { outcome: "interrupted" })); + return; + } + if (settledCauses.has(cause)) { + reportIssue({ + code: "late_deferral", + causeResponseId: cause, + renditionResponseId: null, + message: + `a deferral for already-settled cause ${encodeDiagnosticSubject(cause)} ran immediately as interrupted.`, + }); + effect(freezeReport(cause, { outcome: "interrupted" })); + return; + } + let bucket: DeliveryEffect[] | undefined = causes.get(cause); + if (bucket === undefined) { + bucket = []; + causes.set(cause, bucket); + evictIfNeeded(); + if (!causes.has(cause)) { + reportIssue({ + code: "late_deferral", + causeResponseId: cause, + renditionResponseId: null, + message: + `a deferral for already-settled cause ${encodeDiagnosticSubject(cause)} ran immediately as interrupted.`, + }); + effect(freezeReport(cause, { outcome: "interrupted" })); + return; + } + } + bucket.push(effect); + }; + }, + + bindRendition(link: { + readonly cause: string; + readonly rendition: string; + }): void { + const cause: string | null = usableId(link.cause); + const rendition: string | null = usableId(link.rendition); + if (cause === null || rendition === null) { + return; + } + if (!causes.has(cause)) { + return; + } + const existing: string | undefined = causeToRendition.get(cause); + if (existing !== undefined) { + reportIssue({ + code: "cause_already_bound", + causeResponseId: cause, + renditionResponseId: rendition, + message: + `cause ${encodeDiagnosticSubject(cause)} already holds deferrals bound to ${encodeDiagnosticSubject(existing)}.`, + }); + return; + } + causeToRendition.set(cause, rendition); + const group: string[] = renditionToCauses.get(rendition) ?? []; + group.push(cause); + renditionToCauses.set(rendition, group); + }, + + renditionStarted(renditionResponseId: string): void { + const id: string | null = usableId(renditionResponseId); + if (id === null) { + return; + } + started.add(id); + }, + + generationEnded(renditionResponseId: string): void { + const id: string | null = usableId(renditionResponseId); + if (id === null) { + return; + } + if (evidence === "generation-end") { + binder.settle(id, { outcome: "completed" }); + return; + } + if (!started.has(id)) { + binder.settle(id, { outcome: "interrupted" }); + } + }, + + settle(renditionResponseId: string, settlement: RenditionSettlement): void { + const id: string | null = usableId(renditionResponseId); + if (id === null) { + return; + } + const bound: string[] | undefined = renditionToCauses.get(id); + if (bound === undefined) { + // **Two different situations, two different codes.** The first + // settlement deletes the rendition's bindings, so a second one looked + // identical to a settlement for a rendition that never had a cause — + // and reported `unbound_rendition`, sending a reader after a binding + // bug that does not exist. `duplicate_settlement` was declared for + // exactly this and had no producer. + reportIssue( + settledRenditions.has(id) + ? { + code: "duplicate_settlement", + causeResponseId: null, + renditionResponseId: id, + message: + `rendition ${encodeDiagnosticSubject(id)} was already settled; the second settlement was ignored.`, + } + : { + code: "unbound_rendition", + causeResponseId: null, + renditionResponseId: id, + message: + `settlement named rendition ${encodeDiagnosticSubject(id)} with no bound cause.`, + }, + ); + return; + } + const causesToSettle: string[] = [...bound]; + renditionToCauses.delete(id); + started.delete(id); + settledRenditions.add(id); + for (const cause of causesToSettle) { + causeToRendition.delete(cause); + const effects: DeliveryEffect[] = causes.get(cause) ?? []; + causes.delete(cause); + settledCauses.add(cause); + invokeEffects(cause, effects, freezeReport(cause, settlement)); + } + }, + + abandonCause(causeResponseId: string): void { + const cause: string | null = usableId(causeResponseId); + if (cause === null || !causes.has(cause)) { + return; + } + settleCause(cause, { outcome: "interrupted" }); + }, + + abandonUnstarted(): void { + const unbound: string[] = []; + for (const cause of causes.keys()) { + if (!causeToRendition.has(cause)) { + unbound.push(cause); + } + } + for (const cause of unbound) { + settleCause(cause, { outcome: "interrupted" }); + } + const unstartedRenditions: string[] = []; + for (const rendition of renditionToCauses.keys()) { + if (!started.has(rendition)) { + unstartedRenditions.push(rendition); + } + } + for (const rendition of unstartedRenditions) { + binder.settle(rendition, { outcome: "interrupted" }); + } + }, + + abandonAll(): void { + const pending: string[] = [...causes.keys()]; + for (const cause of pending) { + settleCause(cause, { outcome: "interrupted" }); + } + causes.clear(); + causeToRendition.clear(); + renditionToCauses.clear(); + started.clear(); + settledCauses.clear(); + settledRenditions.clear(); + }, + + pendingCauses(): ReadonlyArray { + return Object.freeze([...causes.keys()]); + }, + }; + + return Object.freeze(binder); +} diff --git a/packages/concierge/src/resolve-value.ts b/packages/concierge/src/resolve-value.ts new file mode 100644 index 0000000..8e09c2d --- /dev/null +++ b/packages/concierge/src/resolve-value.ts @@ -0,0 +1,224 @@ +/** + * Resolve a spoken or typed string onto a member of a caller-supplied list. + * + * Safety: an ok match is Object.is-equal to some input item. The function + * never constructs a stand-in. Ties at the winning score are ambiguous. + */ + +export type ResolveValueRefusal = "no-match" | "ambiguous" | "rejected"; + +export type ResolveValueResult = + | { readonly ok: true; readonly match: T } + | { + readonly ok: false; + readonly reason: ResolveValueRefusal; + readonly candidates?: readonly T[]; + }; + +export interface ResolveValueConfig { + readonly getLabel: (item: T) => string; + readonly getIdentity?: ((item: T) => string) | undefined; + readonly normalize?: ((label: string) => string) | undefined; + readonly allowed?: ((raw: string) => boolean) | undefined; + readonly maxRawLength?: number | undefined; + readonly minRawLength?: number | undefined; + readonly maxDistance?: number | ((normalizedLength: number) => number); + readonly maxAmbiguous?: number | undefined; +} + +function defaultNormalize(label: string): string { + return label.trim().replace(/\s+/gu, " ").toLowerCase(); +} + +function defaultMaxDistance(normalizedLength: number): number { + return Math.min(2, Math.max(1, Math.floor(normalizedLength / 4))); +} + +function levenshtein(left: string, right: string): number { + if (left === right) { + return 0; + } + if (left.length === 0) { + return right.length; + } + if (right.length === 0) { + return left.length; + } + const previous: number[] = []; + const current: number[] = []; + for (let j: number = 0; j <= right.length; j += 1) { + previous[j] = j; + } + for (let i: number = 1; i <= left.length; i += 1) { + current[0] = i; + const leftChar: string = left.charAt(i - 1); + for (let j: number = 1; j <= right.length; j += 1) { + const substitution: number = + leftChar === right.charAt(j - 1) ? 0 : 1; + const deletion: number = (current[j - 1] ?? 0) + 1; + const insertion: number = (previous[j] ?? 0) + 1; + const swap: number = (previous[j - 1] ?? 0) + substitution; + current[j] = Math.min(deletion, insertion, swap); + } + for (let j: number = 0; j <= right.length; j += 1) { + previous[j] = current[j] ?? 0; + } + } + return previous[right.length] ?? Math.max(left.length, right.length); +} + +function refuse( + reason: ResolveValueRefusal, + candidates?: readonly T[], +): ResolveValueResult { + return candidates === undefined + ? { ok: false, reason } + : { ok: false, reason, candidates }; +} + +export function resolveValue( + raw: string, + candidates: readonly T[], + config: ResolveValueConfig, +): ResolveValueResult { + const maxRawLength: number = config.maxRawLength ?? 128; + const minRawLength: number = config.minRawLength ?? 2; + if ( + typeof raw !== "string" || + raw.length < minRawLength || + raw.length > maxRawLength + ) { + return refuse("rejected"); + } + if (config.allowed !== undefined) { + try { + if (config.allowed(raw) !== true) { + return refuse("rejected"); + } + } catch { + return refuse("rejected"); + } + } + + const normalize: (label: string) => string = config.normalize ?? + defaultNormalize; + let query: string; + try { + query = normalize(raw); + } catch { + return refuse("no-match"); + } + if (query.length === 0) { + return refuse("no-match"); + } + + const getIdentity: (item: T) => string = config.getIdentity ?? + config.getLabel; + const maxAmbiguous: number = Math.max(2, config.maxAmbiguous ?? 2); + const threshold: number = typeof config.maxDistance === "function" + ? config.maxDistance(query.length) + : config.maxDistance === undefined + ? defaultMaxDistance(query.length) + : config.maxDistance; + + interface Prepared { + readonly item: T; + readonly label: string; + readonly identity: string; + } + + const prepared: Prepared[] = []; + for (const item of candidates) { + let label: string; + try { + label = normalize(config.getLabel(item)); + } catch { + continue; + } + let identity: string; + try { + identity = getIdentity(item); + } catch { + continue; + } + if (typeof label !== "string" || typeof identity !== "string") { + continue; + } + prepared.push({ item, label, identity }); + } + + if (prepared.length === 0) { + return refuse("no-match"); + } + + // Exact label equality first. `exactLabel` is every candidate whose + // normalized label IS the query, so a single hit is unambiguous by + // construction — an earlier draft re-scanned `prepared` here for a rival + // sharing the label, which cannot exist when this filter produced one row. + // Rivalry is decided by the length > 1 arm below, where it is real. + const exactLabel: Prepared[] = prepared.filter( + (row) => row.label === query, + ); + if (exactLabel.length === 1) { + return { ok: true, match: exactLabel[0]!.item }; + } + if (exactLabel.length > 1) { + const identities: Set = new Set( + exactLabel.map((row) => row.identity), + ); + if (identities.size === 1) { + return { ok: true, match: exactLabel[0]!.item }; + } + return refuse( + "ambiguous", + Object.freeze(exactLabel.map((row) => row.item).slice(0, maxAmbiguous)), + ); + } + + const uniqueSubstring: Prepared[] = prepared.filter( + (row) => row.label.includes(query) || query.includes(row.label), + ); + if (uniqueSubstring.length === 1) { + return { ok: true, match: uniqueSubstring[0]!.item }; + } + if (uniqueSubstring.length > 1) { + return refuse( + "ambiguous", + Object.freeze( + uniqueSubstring.map((row) => row.item).slice(0, maxAmbiguous), + ), + ); + } + + if (threshold <= 0) { + return refuse("no-match"); + } + + let bestDistance: number = threshold + 1; + const distanceHits: Prepared[] = []; + for (const row of prepared) { + const distance: number = levenshtein(query, row.label); + if (distance > threshold) { + continue; + } + if (distance < bestDistance) { + bestDistance = distance; + distanceHits.length = 0; + distanceHits.push(row); + continue; + } + if (distance === bestDistance) { + distanceHits.push(row); + } + } + if (distanceHits.length === 1) { + return { ok: true, match: distanceHits[0]!.item }; + } + if (distanceHits.length > 1) { + return refuse( + "ambiguous", + Object.freeze(distanceHits.map((row) => row.item).slice(0, maxAmbiguous)), + ); + } + return refuse("no-match"); +} diff --git a/packages/concierge/src/session.ts b/packages/concierge/src/session.ts index f2a233f..11468fa 100644 --- a/packages/concierge/src/session.ts +++ b/packages/concierge/src/session.ts @@ -25,6 +25,8 @@ import type { ActionResult, AbortSignalLike, BatchDispatchOutcome, + CatalogAcknowledgement, + CatalogRevision, FailureOutcome, FailureOutcomeRow, OutcomeSink, @@ -75,9 +77,11 @@ const DIAGNOSTIC_MESSAGES: Readonly> = "A batch arrived before session context was set and was ignored.", outcome_presentation_failed: "The application could not present the failed outcome; no result was released.", + catalog_acknowledgement_failed: + "The transport rejected a catalog publication; the last acknowledged context still stands.", }); -function ownDataValue(value: object, key: keyof TransportCapabilities): unknown { +function ownDataValue(value: object, key: string): unknown { const descriptor: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor(value, key); if (descriptor === undefined || !("value" in descriptor)) { @@ -86,7 +90,7 @@ function ownDataValue(value: object, key: keyof TransportCapabilities): unknown return descriptor.value; } -/** Snapshot all four required capability fields without invoking accessors. */ +/** Snapshot all five required capability fields without invoking accessors. */ function snapshotTransportCapabilities(value: unknown): TransportCapabilities { if (typeof value !== "object" || value === null) { throw new TypeError(START_ERROR); @@ -100,7 +104,12 @@ function snapshotTransportCapabilities(value: unknown): TransportCapabilities { const profile = snapshotConsentProfile(value); const parallelCalls: unknown = ownDataValue(value, "parallelCalls"); const dynamicCatalog: unknown = ownDataValue(value, "dynamicCatalog"); - if (typeof parallelCalls !== "boolean" || typeof dynamicCatalog !== "boolean") { + const acknowledgesCatalog: unknown = ownDataValue(value, "acknowledgesCatalog"); + if ( + typeof parallelCalls !== "boolean" || + typeof dynamicCatalog !== "boolean" || + typeof acknowledgesCatalog !== "boolean" + ) { throw new TypeError(START_ERROR); } @@ -109,9 +118,36 @@ function snapshotTransportCapabilities(value: unknown): TransportCapabilities { userTurnIdentity: profile.userTurnIdentity, parallelCalls, dynamicCatalog, + acknowledgesCatalog, }); } +/** Accept only an own-data catalog acknowledgement without invoking accessors. */ +function snapshotCatalogAcknowledgement( + value: unknown, +): CatalogAcknowledgement | null { + if (typeof value !== "object" || value === null) { + return null; + } + try { + const prototype: object | null = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return null; + } + const revision: unknown = ownDataValue(value, "revision"); + const accepted: unknown = ownDataValue(value, "accepted"); + if (typeof revision !== "symbol" || typeof accepted !== "boolean") { + return null; + } + return Object.freeze({ + revision: revision as CatalogRevision, + accepted, + }); + } catch { + return null; + } +} + /** Read only an own data capability value from the transport boundary. */ function captureTransportCapabilities(value: unknown): TransportCapabilities { if (typeof value !== "object" || value === null) { @@ -378,7 +414,7 @@ function linkSignals( }); } -/** Build the contract-v3 session runtime. */ +/** Build the contract-v4 session runtime. */ function createV2Session( config: SessionConfig, concierge: SessionConfig["concierge"], @@ -395,12 +431,18 @@ function createV2Session( let observedStatus: TransportStatus = "idle"; let unsubscribeStatus: (() => void) | null = null; let unsubscribeBatch: (() => void) | null = null; + let unsubscribeAck: (() => void) | null = null; let workTail: Promise = Promise.resolve(); let stopPromise: Promise | null = null; let nextListenerToken: number = 0; let notifyingCatalog: boolean = false; const pendingCatalogNotifications: ResolvedCatalog[] = []; const listeners: Map void> = new Map(); + const pendingPublications: Array<{ + readonly generation: number; + readonly context: StageContext; + readonly catalog: ResolvedCatalog; + }> = []; const diagnose = (code: SessionDiagnosticCode): void => { const diagnostic: SessionDiagnostic = Object.freeze({ @@ -445,6 +487,19 @@ function createV2Session( Reflect.apply(method, transport, [resolved]); }; + const promote = ( + context: StageContext, + resolved: ResolvedCatalog, + ): void => { + const priorEpoch: V2EpochSignal | null = currentEpoch; + const epoch: V2EpochSignal = createEpochSignal(); + currentContext = context; + currentCatalog = resolved; + currentEpoch = epoch; + priorEpoch?.abort(); + notifyCatalog(resolved); + }; + const setContext = (context: StageContext): void => { if (!active) throw new Error(STOPPED_ERROR); const requested: number = ++generation; @@ -455,20 +510,27 @@ function createV2Session( currentContext = context; return; } + const pendingSame: (typeof pendingPublications)[number] | undefined = + pendingPublications.find( + (pending) => pending.catalog.revision === resolved.revision, + ); + if (pendingSame !== undefined) { + const index: number = pendingPublications.indexOf(pendingSame); + pendingPublications[index] = { + generation: pendingSame.generation, + context, + catalog: pendingSame.catalog, + }; + return; + } if ( - currentCatalog !== null && + (currentCatalog !== null || pendingPublications.length > 0) && capabilities.dynamicCatalog === false ) { void stop(); throw new Error(FIXED_CATALOG_ERROR); } - const priorEpoch: V2EpochSignal | null = currentEpoch; - const epoch: V2EpochSignal = createEpochSignal(); - currentContext = context; - currentCatalog = resolved; - currentEpoch = epoch; - priorEpoch?.abort(); try { publish(resolved); } catch { @@ -477,7 +539,46 @@ function createV2Session( diagnose("catalog_publish_failed"); throw new Error(PUBLICATION_ERROR); } - if (active && requested === generation) notifyCatalog(resolved); + if (!active || requested !== generation) return; + + if (capabilities.acknowledgesCatalog === false) { + promote(context, resolved); + return; + } + pendingPublications.push( + Object.freeze({ + generation: requested, + context, + catalog: resolved, + }), + ); + }; + + const handleAcknowledgement = (value: unknown): void => { + if (!active) return; + const ack: CatalogAcknowledgement | null = + snapshotCatalogAcknowledgement(value); + if (ack === null) { + diagnose("catalog_acknowledgement_failed"); + return; + } + const expected = pendingPublications[0]; + if (expected === undefined) { + if (currentCatalog?.revision !== ack.revision) { + diagnose("catalog_acknowledgement_failed"); + } + return; + } + if (ack.revision !== expected.catalog.revision) { + diagnose("catalog_acknowledgement_failed"); + return; + } + pendingPublications.shift(); + if (ack.accepted === false) { + diagnose("catalog_acknowledgement_failed"); + return; + } + promote(expected.context, expected.catalog); }; const dispatchAcceptedBatch = async ( @@ -585,13 +686,29 @@ function createV2Session( if (!active) return; const prior: TransportStatus = observedStatus; observedStatus = status; - if (status === "connected" && prior !== "connected" && currentCatalog !== null) { - try { - publish(currentCatalog); - } catch { - diagnose("catalog_publish_failed"); - void stop(); - } + if (status !== "connected" || prior === "connected") { + return; + } + // **Re-send what the transport still owes an acknowledgement for, not what + // it already acknowledged.** A gap that opens while a publication is in + // flight loses that publication, and `currentCatalog` is the wrong answer + // on both sides of the first acknowledgement. Before it, `currentCatalog` + // is `null`, so nothing was re-sent at all and the session stayed wedged: + // no catalog, no promotion, and a queue head that could never be answered. + // After it, re-sending the promoted revision invited an acknowledgement + // that `handleAcknowledgement` measures against the pending head, fails, + // and reports as `catalog_acknowledgement_failed` — a spurious failure for + // a revision the transport already had. + const outstanding: ResolvedCatalog | null = + pendingPublications[0]?.catalog ?? currentCatalog; + if (outstanding === null) { + return; + } + try { + publish(outstanding); + } catch { + diagnose("catalog_publish_failed"); + void stop(); } }; @@ -616,6 +733,7 @@ function createV2Session( generation += 1; currentEpoch?.abort(); currentEpoch = null; + pendingPublications.length = 0; listeners.clear(); pendingCatalogNotifications.length = 0; try { @@ -628,8 +746,14 @@ function createV2Session( } catch { diagnose("transport_unsubscribe_failed"); } + try { + unsubscribeAck?.(); + } catch { + diagnose("transport_unsubscribe_failed"); + } unsubscribeStatus = null; unsubscribeBatch = null; + unsubscribeAck = null; stopPromise = workTail.catch(() => {}); return stopPromise; } @@ -642,6 +766,26 @@ function createV2Session( const removeBatch: unknown = transport.onToolBatch(acceptBatch); if (typeof removeBatch !== "function") throw new Error(START_ERROR); unsubscribeBatch = removeBatch as () => void; + if (capabilities.acknowledgesCatalog === true) { + // The subscriber has to be READ before it is called, because it is + // optional and the `typeof` guard is what turns a transport that + // claims `acknowledgesCatalog` without implementing it into + // START_ERROR. Reading it detaches it from its receiver, so the call + // goes back through `Reflect.apply` — a method-shorthand + // `onCatalogAcknowledged` that touches `this` must work here exactly + // as it does for `onStatusChange` and `onToolBatch` above, which are + // ordinary method calls. `publish` makes the same move for the same + // reason. + const subscribeAck: unknown = transport.onCatalogAcknowledged; + if (typeof subscribeAck !== "function") throw new Error(START_ERROR); + const removeAck: unknown = Reflect.apply( + subscribeAck as (cb: (ack: CatalogAcknowledgement) => void) => unknown, + transport, + [handleAcknowledgement], + ); + if (typeof removeAck !== "function") throw new Error(START_ERROR); + unsubscribeAck = removeAck as () => void; + } if (config.initialContext !== undefined) setContext(config.initialContext); } catch { void stop(); diff --git a/packages/concierge/src/testing/index.ts b/packages/concierge/src/testing/index.ts new file mode 100644 index 0000000..9ed6e1d --- /dev/null +++ b/packages/concierge/src/testing/index.ts @@ -0,0 +1,252 @@ +/** + * Test-only helpers for Concierge. Not a production runtime. + * + * Import from `@full-self-browsing/concierge/testing`. Do not import this + * module from `packages/concierge/src/**` production files. + */ + +import { + createConcierge, + makeReadbackReceipt, +} from "@full-self-browsing/concierge"; +import type { + CatalogAcknowledgement, + Clock, + Concierge, + ConciergeConfig, + DeliveryReport, + DigestLike, + Readback, + ReadbackReceipt, + ResolvedCatalog, + Scheduler, + Transport, + TransportStatus, +} from "@full-self-browsing/concierge"; + +export function createTestClock(startMs: number = 0): { + readonly now: Clock; + advance(ms: number): void; +} { + let current: number = startMs; + return Object.freeze({ + now: (): number => current, + advance(ms: number): void { + if (!Number.isFinite(ms) || ms < 0) { + return; + } + current += ms; + }, + }); +} + +export type TestScheduler = Scheduler & { + readonly advance: (ms: number) => void; +}; + +export function createTestScheduler(): TestScheduler { + const timers: Array<{ + readonly at: number; + readonly fn: () => void; + cancelled: boolean; + }> = []; + let now: number = 0; + function advance(ms: number): void { + if (!Number.isFinite(ms) || ms < 0) { + return; + } + now += ms; + for (const timer of timers) { + if (!timer.cancelled && timer.at <= now) { + timer.cancelled = true; + timer.fn(); + } + } + } + const schedule: Scheduler = (fn: () => void, delayMs: number): (() => void) => { + if (delayMs <= 0) { + fn(); + return (): void => {}; + } + const timer = { at: now + delayMs, fn, cancelled: false }; + timers.push(timer); + return (): void => { + timer.cancelled = true; + }; + }; + return Object.assign(schedule, { advance }); +} + +export function createTestDigest(): DigestLike { + return { + async digest( + _algorithm: "SHA-256", + data: ArrayBuffer | ArrayBufferView, + ): Promise { + const bytes: Uint8Array = data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const out: Uint8Array = new Uint8Array(32); + let hash: number = 0; + for (let i: number = 0; i < bytes.length; i += 1) { + hash = (hash * 33 + (bytes[i] ?? 0)) >>> 0; + out[i % 32] = (out[i % 32] ?? 0) ^ (bytes[i] ?? 0); + } + out[0] = hash & 0xff; + out[1] = (hash >>> 8) & 0xff; + out[2] = (hash >>> 16) & 0xff; + out[3] = (hash >>> 24) & 0xff; + const copy: ArrayBuffer = new ArrayBuffer(out.byteLength); + new Uint8Array(copy).set(out); + return copy; + }, + }; +} + +export function createStubTransport(options?: { + readonly acknowledgesCatalog?: boolean; +}): { + readonly transport: Transport; + readonly published: ReadonlyArray; + ack(revision: symbol, accepted: boolean): void; +} { + const published: unknown[] = []; + const acknowledgesCatalog: boolean = options?.acknowledgesCatalog === true; + let status: TransportStatus = "idle"; + const statusListeners: Set<(next: TransportStatus) => void> = new Set(); + const batchListeners: Set<(batch: never) => Promise> = new Set(); + const ackListeners: Set<(ack: CatalogAcknowledgement) => void> = new Set(); + const transport: Transport = { + capabilities: Object.freeze({ + consentGrade: "relayed", + userTurnIdentity: "agent-forgeable", + parallelCalls: true, + dynamicCatalog: true, + acknowledgesCatalog, + }), + get status(): TransportStatus { + return status; + }, + setCatalog: (catalog: ResolvedCatalog): void => { + published.push(catalog); + }, + onStatusChange: (cb): (() => void) => { + statusListeners.add(cb); + return (): void => { + statusListeners.delete(cb); + }; + }, + onToolBatch: (cb): (() => void) => { + batchListeners.add(cb as (batch: never) => Promise); + return (): void => { + batchListeners.delete(cb as (batch: never) => Promise); + }; + }, + ...(acknowledgesCatalog + ? { + onCatalogAcknowledged: ( + cb: (ack: CatalogAcknowledgement) => void, + ): (() => void) => { + ackListeners.add(cb); + return (): void => { + ackListeners.delete(cb); + }; + }, + } + : {}), + }; + return { + transport, + get published(): ReadonlyArray { + return published; + }, + ack(revision: symbol, accepted: boolean): void { + const acknowledgement: CatalogAcknowledgement = { + revision: revision as CatalogAcknowledgement["revision"], + accepted, + }; + for (const listener of ackListeners) { + listener(acknowledgement); + } + }, + }; +} + +export interface TestConciergeOptions extends ConciergeConfig { + readonly clock?: Clock; +} + +export function createTestConcierge(options: TestConciergeOptions): Concierge { + const clock = options.clock ?? createTestClock().now; + return createConcierge({ + ...options, + clock, + digest: options.digest ?? createTestDigest(), + commitWindowMs: options.commitWindowMs ?? 0, + }); +} + +export function createTestReadbackSink(): { + present: NonNullable; + receipts: ReadonlyArray; +} { + const receipts: ReadbackReceipt[] = []; + const digest: DigestLike = createTestDigest(); + return { + present: async

    (readback: Readback

    ): Promise => { + const receipt: ReadbackReceipt = await makeReadbackReceipt( + readback, + digest, + ); + receipts.push(receipt); + return receipt; + }, + get receipts(): ReadonlyArray { + return receipts; + }, + }; +} + +export function createCompletedDelivery( + responseId: string, + extras?: Partial, +): DeliveryReport { + return Object.freeze({ + responseId, + outcome: extras?.outcome ?? "completed", + ...(extras?.readbackHash === undefined + ? {} + : { readbackHash: extras.readbackHash }), + ...(extras?.attestation === undefined + ? {} + : { attestation: extras.attestation }), + }); +} + +export function createTestMemoryReplayStore(): { + consume( + key: string, + retainUntil: number, + currentTime: number, + ): Promise; +} { + const entries: Map = new Map(); + return Object.freeze({ + async consume( + key: string, + retainUntil: number, + currentTime: number, + ): Promise { + for (const [candidate, expiry] of entries) { + if (expiry < currentTime) { + entries.delete(candidate); + } + } + if (entries.has(key)) { + return false; + } + entries.set(key, retainUntil); + return true; + }, + }); +} diff --git a/packages/concierge/src/turn-ledger.ts b/packages/concierge/src/turn-ledger.ts new file mode 100644 index 0000000..5a3e398 --- /dev/null +++ b/packages/concierge/src/turn-ledger.ts @@ -0,0 +1,249 @@ +/** + * Many-responses-to-one-turn identity. The producer for ToolBatch.userTurnId + * and for a ReadbackAttestation's later userTurnId. + * + * Not a queue and not a one-shot binder. An agent may open any number of + * responses inside one human turn; every one of them must read back the same + * turn id. First binding wins. No identifier is minted here. + */ + +import type { TurnIdentityProvenance } from "./types.js"; + +const TURN_ID_MAX_CHARS: number = 1024; +const DEFAULT_RETAIN_TURNS: number = 64; +const DEFAULT_RETAIN_RESPONSES: number = 256; + +const PROVENANCE_RANK: Readonly> = + Object.freeze({ + none: 0, + "agent-forgeable": 1, + "human-attested": 2, + }); + +export interface RecordedTurn { + readonly turnId: string; + readonly provenance: TurnIdentityProvenance; +} + +export interface TurnLedgerConfig { + readonly maxProvenance?: TurnIdentityProvenance | undefined; + readonly retainTurns?: number | undefined; + readonly retainResponses?: number | undefined; +} + +export interface TurnLedger { + recordUserTurn(turn: RecordedTurn): RecordedTurn | null; + latestTurn(): RecordedTurn | null; + openResponse(responseId: string): RecordedTurn | null; + turnFor(responseId: string): RecordedTurn | null; + attestationTurnAfter(turnId: string): RecordedTurn | null; + reset(): void; +} + +function ownDataString(value: unknown, key: string): string | undefined { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + return undefined; + } + try { + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && "value" in descriptor && + typeof descriptor.value === "string" + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +function isProvenance(value: unknown): value is TurnIdentityProvenance { + return value === "none" || + value === "agent-forgeable" || + value === "human-attested"; +} + +function ownDataProvenance( + value: unknown, +): TurnIdentityProvenance | undefined { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + return undefined; + } + try { + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(value, "provenance"); + return descriptor !== undefined && + "value" in descriptor && + isProvenance(descriptor.value) + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +function usableId(value: unknown): string | null { + return typeof value === "string" && + value.length > 0 && + value.length <= TURN_ID_MAX_CHARS + ? value + : null; +} + +function retainCount(value: number | undefined, fallback: number): number { + return value === undefined || !Number.isSafeInteger(value) || value < 1 + ? fallback + : value; +} + +function weaker( + left: TurnIdentityProvenance, + right: TurnIdentityProvenance, +): TurnIdentityProvenance { + return PROVENANCE_RANK[left] <= PROVENANCE_RANK[right] ? left : right; +} + +function clampProvenance( + incoming: TurnIdentityProvenance, + ceiling: TurnIdentityProvenance, +): TurnIdentityProvenance { + return weaker(incoming, ceiling); +} + +export function createTurnLedger(config: TurnLedgerConfig = {}): TurnLedger { + const maxProvenance: TurnIdentityProvenance = isProvenance(config.maxProvenance) + ? config.maxProvenance + : "human-attested"; + const retainTurns: number = retainCount(config.retainTurns, DEFAULT_RETAIN_TURNS); + const retainResponses: number = retainCount( + config.retainResponses, + DEFAULT_RETAIN_RESPONSES, + ); + + const turns: Map = new Map(); + const turnOrder: string[] = []; + const responses: Map = new Map(); + const responseOrder: string[] = []; + let latest: RecordedTurn | null = null; + + function evictTurns(): void { + while (turnOrder.length > retainTurns) { + const oldest: string | undefined = turnOrder.shift(); + if (oldest === undefined) { + break; + } + turns.delete(oldest); + } + } + + function evictResponses(): void { + while (responseOrder.length > retainResponses) { + const oldest: string | undefined = responseOrder.shift(); + if (oldest === undefined) { + break; + } + responses.delete(oldest); + } + } + + const ledger: TurnLedger = { + recordUserTurn(turn: RecordedTurn): RecordedTurn | null { + const turnId: string | undefined = ownDataString(turn, "turnId"); + const provenance: TurnIdentityProvenance | undefined = + ownDataProvenance(turn); + if (turnId === undefined || !usableId(turnId) || provenance === undefined) { + return null; + } + const clamped: TurnIdentityProvenance = clampProvenance( + provenance, + maxProvenance, + ); + const existing: RecordedTurn | undefined = turns.get(turnId); + if (existing !== undefined) { + const reduced: TurnIdentityProvenance = weaker( + existing.provenance, + clamped, + ); + if (reduced !== existing.provenance) { + const stored: RecordedTurn = Object.freeze({ + turnId, + provenance: reduced, + }); + turns.set(turnId, stored); + if (latest?.turnId === turnId) { + latest = stored; + } + return stored; + } + return existing; + } + const stored: RecordedTurn = Object.freeze({ + turnId, + provenance: clamped, + }); + turns.set(turnId, stored); + turnOrder.push(turnId); + latest = stored; + evictTurns(); + return stored; + }, + + latestTurn(): RecordedTurn | null { + return latest; + }, + + openResponse(responseId: string): RecordedTurn | null { + const id: string | null = usableId(responseId); + if (id === null) { + return null; + } + if (responses.has(id)) { + return responses.get(id) ?? null; + } + const bound: RecordedTurn | null = latest; + responses.set(id, bound); + responseOrder.push(id); + evictResponses(); + return bound; + }, + + turnFor(responseId: string): RecordedTurn | null { + if (!responses.has(responseId)) { + return null; + } + return responses.get(responseId) ?? null; + }, + + // **The FIRST attested turn after `turnId`, not the last.** An earlier + // draft ran the loop to completion and returned whatever it last saw, + // which let a confirmation arbitrarily far in the future stand in for + // this review's. The turn that attests a review is the one the person + // took in answer to it, so the search stops at the first match. + attestationTurnAfter(turnId: string): RecordedTurn | null { + const index: number = turnOrder.indexOf(turnId); + if (index < 0) { + return null; + } + for (let i: number = index + 1; i < turnOrder.length; i += 1) { + const id: string | undefined = turnOrder[i]; + if (id === undefined) { + continue; + } + const turn: RecordedTurn | undefined = turns.get(id); + if (turn?.provenance === "human-attested") { + return turn; + } + } + return null; + }, + + reset(): void { + turns.clear(); + turnOrder.length = 0; + responses.clear(); + responseOrder.length = 0; + latest = null; + }, + }; + + return Object.freeze(ledger); +} diff --git a/packages/concierge/src/types.ts b/packages/concierge/src/types.ts index 348046b..9d051b7 100644 --- a/packages/concierge/src/types.ts +++ b/packages/concierge/src/types.ts @@ -217,11 +217,16 @@ export type FailureReason = /** A retry identity was reused for a different logical invocation. */ | "identity_conflict" /** Valid input could not run because current application state forbade it. */ - | "precondition_failed"; + | "precondition_failed" + /** + * A review was presented but its delivery was interrupted; it can be + * re-presented. + */ + | "consent_interrupted"; /** - * Every code {@link ActionResult.reason} admits: **sixteen** — three - * human-caused ({@link AbandonReason}) and thirteen machine-caused + * Every code {@link ActionResult.reason} admits: **seventeen** — three + * human-caused ({@link AbandonReason}) and fourteen machine-caused * ({@link FailureReason}). * * Deliberately a pure closed union. A `` `app.${string}` `` escape hatch was @@ -394,9 +399,10 @@ export interface InvocationMeta { /** * Defer a side effect until the agent's response has reached the human. * - * Absent when the transport cannot promise delivery, in which case consent - * never arms and gated actions cannot proceed. That is the intended failure - * mode: closed. + * Absent when the transport cannot promise delivery. Contract v4 still arms + * `delivered` without this hook; `relayed` remains unreachable until a + * producer reports, and `attested` is reached through + * {@link Concierge.attestReadback}. * * The function type is parenthesised before the union deliberately. Without the * parentheses the `| undefined` binds inside the return position, silently @@ -409,8 +415,26 @@ export interface InvocationMeta { /** A human act observed by the application and bound to one readback hash. */ export interface ReadbackAttestation { readonly act: "confirmed" | "declined" | "dismissed"; - readonly userTurnId: string; + /** + * An app-minted identifier for this act, unique per act. + * + * The act is observed on the app's own surface, so transport turn + * identifiers say nothing about it. Reuse of the same `actId` is refused. + */ + readonly actId: string; readonly readbackHash: string; + /** + * The transport turn the act belongs to, when the app can honestly say. + * Read only when `consentProfile.userTurnIdentity` is `"human-attested"` and + * the policy binds to `"userTurn"`. + * + * Optional on {@link Concierge.attestReadback}; **required in practice on + * {@link DeliveryReport.attestation}**, where reaching `attested` needs a + * confirming turn that is non-empty and distinct from the review's. An + * attestation delivered without one is a claim the kernel cannot + * substantiate, and it closes the consent generation rather than arming. + */ + readonly userTurnId?: string | undefined; } /** @@ -530,6 +554,13 @@ export type ActionHandler< ack?: ConsentAck | undefined; /** App-owned compound actions use these controls; core does not plan steps. */ workflow: WorkflowControls; + /** The dispatch's live stage context. Child actions see the same object. */ + readonly context: StageContext; + /** + * Meaningful only for an action some {@link ConsentPolicy} names in + * `requires`; elsewhere every call refuses `"not_reviewable"`. + */ + review: ReviewControls; }) => ActionHandlerResult | Promise>; // --------------------------------------------------------------------------- @@ -595,10 +626,22 @@ export interface ConsentPolicy { */ requires: string; /** - * `"userTurn"` requires a genuinely new human turn between review and - * confirm. `"response"` only distinguishes agent responses and is weaker. + * `"userTurn"` requires a genuinely new human turn whose provenance the + * transport declares `"human-attested"`. + * + * `"unverifiedUserTurn"` requires a non-empty confirm turn id distinct from the + * review turn id, and accepts `userTurnIdentity: "agent-forgeable"`. The + * name states its own weakness: the boundary is real against a model + * auto-following-up on itself, and worthless against a hostile model that + * mints turn ids. + * + * `"response"` only distinguishes agent responses and is weakest. It + * compares against the response that delivered the readback when one was + * reported, not only the response that carried the tool call. + * + * Ordered: `userTurn` > `unverifiedUserTurn` > `response`. */ - bindTo: "userTurn" | "response"; + bindTo: "userTurn" | "unverifiedUserTurn" | "response"; /** * Field-by-field equality over what was reviewed. Any drift between review * and confirm destroys the consent. @@ -633,6 +676,15 @@ export interface ConsentPolicy { */ minGrade?: ConsentGrade; onMissing?: Pick; + /** + * The result a confirming action returns when the only evidence in the slot + * is a retained, interrupted review. + * + * Distinct from `onMissing` because the two call for different prose: + * "review this first" versus "I did not finish reading that back". Defaults + * to core's fixed `consent_interrupted` result. + */ + onInterrupted?: Pick; } /** @@ -647,6 +699,11 @@ export interface ConsentPolicy { interface ConsentAckBase { readonly userTurnId: string; readonly responseId: string; + /** + * The response in which the readback reached the human, when a transport + * reported one. Not required to equal {@link ConsentAckBase.responseId}. + */ + readonly readbackResponseId?: string | undefined; /** * Normalized and structurally frozen at arm time. Never a live reference. * @@ -759,8 +816,91 @@ export type ConsentAck = * collision the receipt exists to prevent. */ readonly readbackHash: string; + /** + * The `actId` of the observed act that produced this grade. Required on + * this branch for the same reason `readbackHash` is: the strongest grade + * must not be constructible without the evidence that backs it. + */ + readonly attestationActId: string; }); +/** + * Optional presentation metadata for one proposed review. + * + * Separate from the payload because the two are hashed together but are not + * interchangeable: `payload` is the structured value the confirming handler will + * act on, `presented` is the prose the human actually read or heard. + */ +export interface ReviewPresentation { + /** + * The literal text the app is about to show or speak. + * + * Enters the canonical envelope in contract v4. + */ + readonly presented?: string | undefined; +} + +/** + * Why core refused to bind a proposed or re-presented review. + * + * Every member is a closed gate. A refusal always leaves the consent slot empty, + * so a later confirm takes `onMissing`. + */ +export type ReviewRefusalCode = + | "not_reviewable" + | "already_proposed" + | "payload_unsupported" + | "presenter_unavailable" + | "digest_unavailable" + | "presentation_failed" + | "retained_unknown" + | "retained_stale" + | "superseded" + | "aborted"; + +/** What one {@link ReviewControls.propose} or {@link ReviewControls.represent} call produced. */ +export type ReviewOutcome = + | { + readonly ok: true; + readonly hash: string; + readonly payload: Payload; + readonly ceiling: ConsentGrade; + } + | { readonly ok: false; readonly reason: ReviewRefusalCode }; + +/** + * A review that was presented but never consumed, offered back to the review + * handler on a later dispatch. + */ +export interface RetainedReview { + readonly payload: Payload; + readonly hash: string; + readonly reason: "interrupted" | "unconfirmed"; + readonly responseId: string; + readonly userTurnId: string; +} + +/** + * The review half of the consent kernel, handed to every action handler on + * `ctx.review`. + */ +export interface ReviewControls { + readonly retained: RetainedReview | null; + propose( + payload: Payload, + presentation?: ReviewPresentation, + ): Promise>; + represent(retained: RetainedReview): Promise>; +} + +/** What {@link Concierge.attestReadback} did with an observed human act. */ +export type AttestationOutcome = + | "accepted" + | "unknown_readback" + | "already_attested" + | "grade_unavailable" + | "malformed"; + /** * Detaches a snapshot from the app's reactivity system before it is stored. * @@ -1020,6 +1160,54 @@ export type OutputRedactionPolicy = | "passthrough" | ((data: DeepReadonly) => unknown); +/** + * Injectable clock: return milliseconds elapsed from ANY fixed epoch of the + * implementation's choosing. Only differences between two readings are + * meaningful; the absolute value is never interpreted. + */ +export type Clock = () => number; + +/** + * Core-owned timing for one dispatch lifecycle event, read at the instant + * core constructed the event — before it was queued for asynchronous delivery. + */ +export interface DispatchTiming { + readonly clockMs: number; + readonly wallClockMs: number; + readonly elapsedMs: number; + readonly handlerMs?: number | undefined; + readonly monotonic: boolean; +} + +/** Observer-safe result sentence selected by the action's message policy. */ +export type ObservedMessage = + | Readonly<{ kind: "dropped" }> + | Readonly<{ kind: "included"; value: string }>; + +/** + * What a message projection may read besides the sentence itself. + * + * Carries the already-redacted observer view of the result data, not the raw + * ActionResult.data. + */ +export interface MessageRedactionContext { + readonly ok: boolean; + readonly reason?: ReasonCode | undefined; + readonly data: ObservedResultData; +} + +/** + * How a result's human-facing sentence is exposed to dispatch observers. + * + * Observer-only. It never alters the message returned to the caller. + * + * @default "passthrough" + */ +export type MessageRedactionPolicy = + | "drop" + | "passthrough" + | ((message: string, context: MessageRedactionContext) => string); + /** Declares and protects the structured output contract for one action. */ export interface ActionOutputDefinition { readonly schema: Schema; @@ -1083,6 +1271,11 @@ interface ActionDefinitionShape< */ jsonSchema?: JsonSchemaObject; redact: RedactionPolicy>; + /** + * Observer-tier policy for {@link ActionResult.message}. Omitted means + * `"passthrough"`, which is today's behaviour. + */ + redactMessage?: MessageRedactionPolicy | undefined; /** * Optional structured result declaration. Returning `data` without this * declaration fails closed as `invalid_result`. @@ -1290,6 +1483,15 @@ export interface Bridge< Snapshot extends Record unknown> = Record unknown>, > { actions: Actions; + /** + * Snapshot values must be zero-argument getters. Core calls them at most + * once per capture and then detaches. A function of arity > 0 is a catalog + * error (`snapshot_slot_not_a_getter`). + * + * An empty snapshot (`{}`) makes drift detection a no-op. If any action + * bound to this bridge is named in a ConsentPolicy.requires list, that is a + * catalog error (`vacuous_consent_snapshot`) under a non-`none` profile. + */ snapshot: Snapshot; } @@ -1305,6 +1507,69 @@ export interface BridgeRegistry { register: (bridge: B) => () => void; } +/** + * What happened to a registry's single slot. + * + * `bridge` is carried by reference and is never normalized, cloned or frozen. + * The event object itself is shallow-frozen. `read()` remains authoritative + * for "what is live now"; this type says "what just happened". + */ +export type BridgeRegistrationEvent = + | { + readonly type: "registered"; + readonly registryId: string; + readonly bridge: B; + } + | { + readonly type: "unregistered"; + readonly registryId: string; + } + | { + readonly type: "drained"; + readonly registryId: string; + }; + +/** + * Registration observer. Never awaited: a returned promise is ignored. + */ +export type BridgeRegistrationListener = ( + event: BridgeRegistrationEvent, +) => void; + +/** + * The registry `createBridge` returns: a {@link BridgeRegistry} plus the + * registration-arrival edge. {@link BridgeRegistry} itself is unchanged. + */ +export interface ObservableBridgeRegistry + extends BridgeRegistry { + subscribe: (listener: BridgeRegistrationListener) => () => void; + /** + * Declare that nothing is going to register for the foreseeable future. + * Emits one `"drained"` event and changes nothing else. + */ + drain: () => void; +} + +/** + * At least one of `timeoutMs` or `signal` is required. Supplying neither + * resolves `"unavailable"` deterministically. + */ +export interface RegistrationWaitOptions { + readonly timeoutMs?: number | undefined; + readonly signal?: AbortSignalLike | undefined; + readonly scheduler?: Scheduler | undefined; +} + +/** + * Five outcomes, because a caller writes five different sentences. + */ +export type RegistrationWait = + | { readonly status: "ready"; readonly bridge: B } + | { + readonly status: "aborted" | "timed-out" | "drained" | "unavailable"; + readonly bridge?: undefined; + }; + // --------------------------------------------------------------------------- // Stages // --------------------------------------------------------------------------- @@ -1473,8 +1738,17 @@ export type ObservedResultData = | Readonly<{ kind: "dropped" }> | Readonly<{ kind: "included"; value: unknown }>; -/** The status portion of an action result; it deliberately cannot carry data. */ -export type ObservedActionResult = Readonly>; +/** + * The status portion of an action result; it deliberately cannot carry data. + * + * `message` is policy-governed. The discriminated form is how the other two + * observer channels already say "withheld" without overloading a legal value. + */ +export interface ObservedActionResult { + readonly ok: boolean; + readonly reason?: ReasonCode | undefined; + readonly message: ObservedMessage; +} /** Core-authored compound-action ancestry. */ export interface DispatchLineage { @@ -1496,6 +1770,8 @@ interface DispatchEventBase { readonly terminalAction: boolean; /** True only once this occurrence or its workflow has entered terminal execution. */ readonly terminalEntered: boolean; + /** Core-owned marks. Present on every phase. See {@link DispatchTiming}. */ + readonly timing: DispatchTiming; } /** Non-blocking lifecycle emitted once per logical invocation occurrence. */ @@ -1595,6 +1871,15 @@ export interface TransportCapabilities { readonly parallelCalls: boolean; /** Whether the catalog can be swapped mid-session on stage change. */ readonly dynamicCatalog: boolean; + /** + * Whether publication is a round trip. When `true`, + * {@link Transport.onCatalogAcknowledged} is required and {@link Session} + * defers promotion of a new context until the transport confirms the agent + * has been given the new catalog. + * + * Fixed at declaration, like every other member here. + */ + readonly acknowledgesCatalog: boolean; } /** Neutral connection lifecycle reported by every transport. */ @@ -1628,6 +1913,24 @@ export interface Transport { onToolBatch: ( cb: (batch: ToolBatch) => Promise, ) => () => void; + /** + * Required when `capabilities.acknowledgesCatalog` is `true`, ignored + * otherwise. Exactly one acknowledgement per `setCatalog` call, in order. + */ + onCatalogAcknowledged?: + | ((cb: (ack: CatalogAcknowledgement) => void) => () => void) + | undefined; +} + +/** One transport verdict on one published catalog revision. */ +export interface CatalogAcknowledgement { + /** The exact revision the transport was handed by `setCatalog`. */ + readonly revision: CatalogRevision; + /** + * `false` means the agent never saw this revision. The session keeps the last + * acknowledged context authoritative and does not stop. + */ + readonly accepted: boolean; } /** @@ -1991,6 +2294,23 @@ export interface ConciergeConfig { * warns once and skips only the commit delay; deduplication is unaffected. */ scheduler?: Scheduler; + /** + * Monotonic clock for dispatch timing. When omitted, core reads a host + * `performance.now` structurally through the host seam; if the host has + * none, core falls back to `Date.now()` and reports + * `timing.monotonic === false`. + * + * Deduplication and the commit window do not read this clock. + */ + clock?: Clock; + /** + * Namespace that makes `dispatchId` unique beyond this instance. + * + * Composed as `` `${instanceId}-${n}` ``. When omitted, core mints 32 + * lowercase hex characters of host entropy per instance. Must match + * `/^[A-Za-z0-9._-]{1,64}$/`. + */ + instanceId?: string; /** * Grace period before any side effect lands, so a human can interrupt. * Must be finite and non-negative; invalid values throw during construction. @@ -2026,6 +2346,12 @@ export interface ConciergeConfig { * routing, and failure-outcome presentation remain surrounding runtime layers. */ export interface Concierge { + /** + * The namespace every `dispatchId` from this instance is prefixed with — + * the configured {@link ConciergeConfig.instanceId} or the host-minted + * default. + */ + readonly instanceId: string; /** * NOT `async`. An async wrapper allocates a fresh Promise per invocation, * which breaks deduplication by reference identity. @@ -2060,6 +2386,16 @@ export interface Concierge { * server. */ explain: (ctx: StageContext) => Explanation; + /** + * Record a human act the app observed on its own surface, bound to one + * readback hash. + * + * Synchronous, and that is load-bearing: it is called from a click or + * keypress handler that must decide what to render next. It cannot create a + * generation, choose a payload, or raise the profile ceiling. It must not + * be exposed through the signed AI-SDK browser envelope. + */ + attestReadback: (attestation: ReadbackAttestation) => AttestationOutcome; } /** Closed operational diagnostic vocabulary for the Session runtime. */ @@ -2074,7 +2410,9 @@ export type SessionDiagnosticCode = | "catalog_clear_failed" | "abort_signal_failed" | "batch_without_context" - | "outcome_presentation_failed"; + | "outcome_presentation_failed" + /** A published revision was rejected; the acknowledged context still stands. */ + | "catalog_acknowledgement_failed"; /** Fixed safe diagnostic shape exposed by Session. */ export interface SessionDiagnostic { diff --git a/packages/concierge/test-d/actions.test-d.ts b/packages/concierge/test-d/actions.test-d.ts index 84f2d8c..3aa2833 100644 --- a/packages/concierge/test-d/actions.test-d.ts +++ b/packages/concierge/test-d/actions.test-d.ts @@ -80,8 +80,10 @@ import type { ReadbackReceipt, ReadbackSink, ReasonCode, + ReviewControls, Scheduler, Session, + StageContext, StageDefinition, StandardSchemaV1, } from "../src/types.js"; @@ -223,7 +225,7 @@ type _snapshotInferred = Expect, // own declaration — had no member-level assertion anywhere in the suite. /** The selector between the strong gate and the weak one. Widened to `string`, `bindTo: "usreTurn"` typechecks; whether the Phase 8 runtime then falls back to `"response"` or gates nothing at all, the compiler said nothing either way. */ -type _bindToIsClosed = Expect>; +type _bindToIsClosed = Expect>; /** The dial `buildCatalog` enforces at build time (CAT-04), and the reason D-04 cut `impact` rather than shipping a second, weaker severity axis beside it. Widened to `string`, every word is a grade and the throw never fires. */ type _minGradeIsGrade = Expect>; @@ -243,7 +245,7 @@ type _minGradeIsGrade = Expect>; /** Modelled on `_transportKeys` in `transport.test-d.ts`, and for the same reason: the member set is closed, so a second severity dial cannot appear beside `minGrade` unnoticed — which is the failure D-04 spent four entries preventing. */ -type _policyKeys = Expect>; +type _policyKeys = Expect>; // -------------------------------------------------------------------------- // Escapee 3 — the handler forward. The assertion nothing else catches. @@ -314,7 +316,9 @@ declare const maybeAck: ConsentAck | undefined; declare const plainBridge: PlainBridge; declare const meta: InvocationMeta; declare const workflow: WorkflowControls; -const _ctxWithMaybeAck: Ctx = { args: { q: "x" }, bridge: plainBridge, meta, ack: maybeAck, workflow }; +declare const stageContext: StageContext; +declare const reviewControls: ReviewControls; +const _ctxWithMaybeAck: Ctx = { args: { q: "x" }, bridge: plainBridge, meta, ack: maybeAck, workflow, context: stageContext, review: reviewControls }; void _ctxWithMaybeAck; // -------------------------------------------------------------------------- diff --git a/packages/concierge/test-d/bridge.test-d.ts b/packages/concierge/test-d/bridge.test-d.ts index 16ddf5d..76c17a0 100644 --- a/packages/concierge/test-d/bridge.test-d.ts +++ b/packages/concierge/test-d/bridge.test-d.ts @@ -137,10 +137,10 @@ type CartBridge = Bridge<{ removeItem: (id: string) => void }, { total: () => nu // -------------------------------------------------------------------------- /** The whole signature including its generic head, which is the strongest of the five and the only one that sees the `= Bridge` default: measured this session, `Equals<…>` against a `(id: string) => BridgeRegistry` variant with the default deleted reads FALSE, while the `ReturnType` decomposition two predicates down stays TRUE against that same variant, because an uninferrable type parameter falls back to its CONSTRAINT and the constraint here is also `Bridge`. Also measured red against `id: unknown`, against an added second parameter, and against a non-generic `(id: string) => BridgeRegistry`. */ -type _createBridgeSignature = Expect(id: string) => BridgeRegistry>>; +type _createBridgeSignature = Expect(id: string) => import("../src/types.js").ObservableBridgeRegistry>>; /** Instantiated at a concrete bridge, which is the form a consumer actually writes and the form `actions.test-d.ts:400-406` records a whole phase getting wrong: a type parameter never instantiated is a type parameter never tested. `BridgeRegistry` exactly — not a supertype of it, which is what the next predicate exists to make legible. */ -type _createBridgeReturnsRegistryAtItsBridge = Expect>, BridgeRegistry>>; +type _createBridgeReturnsRegistryAtItsBridge = Expect>, import("../src/types.js").ObservableBridgeRegistry>>; /** The negative control the predicate above needs to mean anything: the return type must DIFFER at a different bridge. A `createBridge` that ignored `B` and always returned `BridgeRegistry` would make this read false, and `Not<…>` is what turns "these two are distinguishable" into something that can go red. This is also the only line reading `CartBridge`, which keeps the second shape live rather than one refactor from being deleted as dead. */ type _createBridgeReturnTypeTracksItsBridge = Expect>, BridgeRegistry>>>; @@ -149,7 +149,7 @@ type _createBridgeReturnTypeTracksItsBridge = Expect, [id: string]>>; /** `createBridge("results")` with no explicit type argument yields `BridgeRegistry`, which is what makes the un-parameterised call — the one every quickstart writes — usable rather than merely legal. Stated precisely, because the imprecise version is tempting: this predicate does NOT discriminate the `= Bridge` default's removal (measured; the constraint fallback covers for it), and `_createBridgeSignature` is what does. It discriminates a widened or narrowed CONSTRAINT, which the whole-signature form would also catch and which this one names. */ -type _createBridgeDefaultsToBridge = Expect, BridgeRegistry>>; +type _createBridgeDefaultsToBridge = Expect, import("../src/types.js").ObservableBridgeRegistry>>; // -------------------------------------------------------------------------- // BRG-03 — the off-page result helper diff --git a/packages/concierge/test-d/catalog.test-d.ts b/packages/concierge/test-d/catalog.test-d.ts index cdef851..d24bee3 100644 --- a/packages/concierge/test-d/catalog.test-d.ts +++ b/packages/concierge/test-d/catalog.test-d.ts @@ -330,13 +330,13 @@ type _entryMembersAreReadonly = Expect>` stays GREEN when the alias is widened to `string`, because a literal is assignable to `string` — so the one-directional spelling passes on precisely the regression worth guarding. `_entryMembersAreReadonly` above makes the same argument one level down. This also goes red on any member added, removed or renamed, which is deliberate: Phase 8 is scheduled to add a third consent code (`consentRequiresOf`'s residual paragraph), and that addition must move this line rather than slip past it. */ -type _catalogIssueCodeIsExactlyThirteenMembers = Expect>; +type _catalogIssueCodeIsExactlyThirteenMembers = Expect>; /** The union is CLOSED, not merely containing those six — a plausible near-miss code is rejected. Today this is the widening detector from the opposite direction: under `CatalogIssueCode = string` the literal becomes assignable and this line goes red, independently of the `Equals` above. It is a near-miss rather than an arbitrary string so it doubles as a name pin: if Phase 8 spells its third consent code this way, this is what goes red and sends the author to the line that needs updating. */ type _catalogIssueCodeIsClosed = Expect>>; /** Phase 8 construction evidence extends the one catalog-options object rather than introducing a second build path. */ -type _buildCatalogOptionKeys = Expect>; +type _buildCatalogOptionKeys = Expect>; /** EOPT-safe options accept values copied from optional ConciergeConfig fields without conditionally rebuilding the options object. */ type _buildCatalogOptionsAdmitExplicitUndefined = Expect>; diff --git a/packages/concierge/test-d/consent.test-d.ts b/packages/concierge/test-d/consent.test-d.ts index 74086a7..26dd530 100644 --- a/packages/concierge/test-d/consent.test-d.ts +++ b/packages/concierge/test-d/consent.test-d.ts @@ -88,10 +88,10 @@ void _configFromComputedConsentProfile; type _attestationActIsClosed = Expect>; /** Attestation binds one immutable act and human turn to one immutable readback hash. */ -type _attestationIsExactAndReadonly = Expect>; +type _attestationIsExactAndReadonly = Expect>; /** Arbitrary observations cannot be mistaken for a supported human act. */ -type _attestationRejectsArbitraryAct = Expect>>; +type _attestationRejectsArbitraryAct = Expect>>; /** The rendered payload itself is immutable through the evidence reference. */ type _readbackPayloadIsReadonly = Expect, "payload">, { readonly payload: Booking }>>; @@ -309,6 +309,7 @@ const _attestedOk: ConsentAck = { payload: { id: "a" }, grade: "attested", readbackHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + attestationActId: "act-1", }; void _attestedOk; @@ -333,7 +334,7 @@ void _attestedOk; type _attestedNeedsHash = Expect>>>; /** Control, not a guard: the same object *with* a hash does assign, so the line above is about the hash and not about unrelated drift in the object. */ -type _attestedWithHashAssigns = Expect>>; +type _attestedWithHashAssigns = Expect>>; // -------------------------------------------------------------------------- // D-05 — omit the challenge, do not spread an empty one into it diff --git a/packages/concierge/test-d/dispatcher.test-d.ts b/packages/concierge/test-d/dispatcher.test-d.ts index 4c11620..4424971 100644 --- a/packages/concierge/test-d/dispatcher.test-d.ts +++ b/packages/concierge/test-d/dispatcher.test-d.ts @@ -22,7 +22,7 @@ import type { type _dispatchSignature = Expect Promise>>; type _dispatchBatchSignature = Expect Promise>>; type _schedulerSignature = Expect void, delayMs: number) => () => void>>; -type _conciergeKeys = Expect>; +type _conciergeKeys = Expect>; type _actionResultKeysExcludeTerminalControl = Expect>; type _publicBatchRow = Awaited>["rows"][number]; type _publicBatchRowKeysExcludeTerminalControl = Expect>; diff --git a/packages/concierge/test-d/exports.test-d.ts b/packages/concierge/test-d/exports.test-d.ts index af41f34..639c804 100644 --- a/packages/concierge/test-d/exports.test-d.ts +++ b/packages/concierge/test-d/exports.test-d.ts @@ -70,7 +70,7 @@ // with export placement. import type { Assignable, Equals, Expect } from "./_assert.js"; -import { MESSAGE_MAX_CHARS, JSON_SCHEMA_TARGET, defineAction, buildCatalog, CatalogValidationError, createConcierge, createBridge, captureSnapshot, offPageResult, createSession } from "../src/index.js"; // ← index.js. NOT types.js. This is the whole point. +import { MESSAGE_MAX_CHARS, JSON_SCHEMA_TARGET, defineAction, buildCatalog, CatalogValidationError, createConcierge, createBridge, captureSnapshot, offPageResult, createSession, isReasonCode, sanitizeText, makeReadbackReceipt, awaitRegistration, createTurnLedger, createRenditionBinder, resolveValue, renderCatalogPrompt, catalogDerivedPolicy } from "../src/index.js"; // ← index.js. NOT types.js. This is the whole point. import type { ConsentProfile, FailureOutcome, FailureOutcomeRow, OutcomePresentationReport, OutcomeSink, ReadbackAttestation } from "../src/index.js"; import type { ConsentProfile as SourceConsentProfile, FailureOutcome as SourceFailureOutcome, FailureOutcomeRow as SourceFailureOutcomeRow, OutcomePresentationReport as SourceOutcomePresentationReport, OutcomeSink as SourceOutcomeSink, ReadbackAttestation as SourceReadbackAttestation } from "../src/types.js"; @@ -124,6 +124,33 @@ type _offPageResultExportedAsValue = Expect unknown>>; +/** isReasonCode reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _isReasonCodeExportedAsValue = Expect unknown>>; + +/** sanitizeText reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _sanitizeTextExportedAsValue = Expect unknown>>; + +/** makeReadbackReceipt reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _makeReadbackReceiptExportedAsValue = Expect unknown>>; + +/** awaitRegistration reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _awaitRegistrationExportedAsValue = Expect unknown>>; + +/** createTurnLedger reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _createTurnLedgerExportedAsValue = Expect unknown>>; + +/** createRenditionBinder reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _createRenditionBinderExportedAsValue = Expect unknown>>; + +/** resolveValue reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _resolveValueExportedAsValue = Expect unknown>>; + +/** renderCatalogPrompt reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _renderCatalogPromptExportedAsValue = Expect unknown>>; + +/** catalogDerivedPolicy reaches the public entrypoint as a callable VALUE, not only as a type. */ +type _catalogDerivedPolicyExportedAsValue = Expect unknown>>; + // -------------------------------------------------------------------------- // Consent evidence and app-outcome contracts are public types only // -------------------------------------------------------------------------- diff --git a/packages/concierge/test-d/results.test-d.ts b/packages/concierge/test-d/results.test-d.ts index 529c33e..37ce34a 100644 --- a/packages/concierge/test-d/results.test-d.ts +++ b/packages/concierge/test-d/results.test-d.ts @@ -84,6 +84,7 @@ function _reasonExhaustive(): string { case "invalid_invocation": case "identity_conflict": case "precondition_failed": + case "consent_interrupted": return r.reason; case undefined: return ""; diff --git a/packages/concierge/test-d/session.test-d.ts b/packages/concierge/test-d/session.test-d.ts index a1446f2..f2eb225 100644 --- a/packages/concierge/test-d/session.test-d.ts +++ b/packages/concierge/test-d/session.test-d.ts @@ -22,7 +22,7 @@ type _sessionConfigInitialContext = Expect void) | undefined>>; type _sessionConfigMinimumIsUsable = Expect>; type _sessionConfigRejectsMissingOutcomeSink = Expect>>; -type _sessionDiagnosticCodes = Expect>; +type _sessionDiagnosticCodes = Expect>; type _sessionDiagnosticKeys = Expect>; type _sessionDiagnosticIsReadonly = Expect>; type _knownDiagnosticCodeIsUsable = Expect>; diff --git a/packages/concierge/test-d/transport.test-d.ts b/packages/concierge/test-d/transport.test-d.ts index 39f66e1..0f7091c 100644 --- a/packages/concierge/test-d/transport.test-d.ts +++ b/packages/concierge/test-d/transport.test-d.ts @@ -97,6 +97,7 @@ const _agentForgeableCaps: TransportCapabilities = { userTurnIdentity: "agent-forgeable", parallelCalls: false, dynamicCatalog: true, + acknowledgesCatalog: false, }; /** Turn identity derived from an explicit human act. Distinguishable from the above. */ @@ -105,6 +106,7 @@ const _humanAttestedCaps: TransportCapabilities = { userTurnIdentity: "human-attested", parallelCalls: false, dynamicCatalog: true, + acknowledgesCatalog: false, }; // -------------------------------------------------------------------------- @@ -143,6 +145,9 @@ type _capsProvenanceIsReadonly = Expect, { readonly consentGrade: ConsentGrade }>>; +/** Raising `acknowledgesCatalog` after declaration would convert an unacknowledged revision into an authorized one. */ +type _capsAcknowledgesIsReadonly = Expect, { readonly acknowledgesCatalog: boolean }>>; + // -------------------------------------------------------------------------- // SC-4 / TRN-01 — two transports sharing no wire vocabulary, one interface // -------------------------------------------------------------------------- @@ -161,6 +166,7 @@ const streamingTransport: Transport = { userTurnIdentity: "agent-forgeable", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, }, status: "connecting", setCatalog: () => {}, @@ -184,6 +190,7 @@ const commandPaletteTransport: Transport = { userTurnIdentity: "human-attested", parallelCalls: false, dynamicCatalog: false, + acknowledgesCatalog: false, }, status: "closed", setCatalog: () => {}, @@ -196,13 +203,13 @@ const commandPaletteTransport: Transport = { /** * The mechanical proof that no vendor event name has leaked into core: the interface - * is exactly six members, so there is nowhere for one to sit. A vendor-shaped member + * is exactly seven members, so there is nowhere for one to sit. A vendor-shaped member * added to `Transport` breaks this line. The other half of TRN-01 is the grep, which * covers the places a type-level assertion cannot reach. */ type _transportStatus = Expect>; type _transportStatusCallback = Expect void) => () => void>>; -type _transportKeys = Expect>; +type _transportKeys = Expect>; type _transportStatusIsReadonly = Expect, { readonly status: TransportStatus }>>; // -------------------------------------------------------------------------- @@ -281,6 +288,7 @@ const _interruptedWithAttestation: DeliveryReport = { outcome: "interrupted", attestation: { act: "confirmed", + actId: "act-interrupted", userTurnId: "turn-human", readbackHash: "hash", }, diff --git a/packages/concierge/test/action-bridges.test.ts b/packages/concierge/test/action-bridges.test.ts index c5e6db8..36ad6f5 100644 --- a/packages/concierge/test/action-bridges.test.ts +++ b/packages/concierge/test/action-bridges.test.ts @@ -196,8 +196,16 @@ describe("action-scoped bridge resolution", () => { return reads === 1 ? first : second; }, }); - const review = action("review", ({ bridge: live }) => { + const review = action("review", async ({ args, bridge: live, review: controls }) => { handlerBridge = live; + const proposed = await controls.propose(args ?? {}); + if (!proposed.ok) { + return { + ok: false, + reason: "precondition_failed", + message: "The review payload could not be proposed.", + }; + } return { ok: true, message: live?.marker ?? "missing" }; }, { bridge: alternatingRegistry }); const confirm = action("confirm", () => ({ ok: true, message: "Confirmed." }), { @@ -215,10 +223,10 @@ describe("action-scoped bridge resolution", () => { await expect(concierge.dispatch(CONTEXT, request(catalog, "review"))).resolves.toMatchObject({ ok: true, - message: "first", + message: "second", }); - expect(reads).toBe(1); - expect(handlerBridge).toBe(first); + expect(reads).toBe(2); + expect(handlerBridge).toBe(second); }); it("compares consent against the review bridge when the gated action uses the stage bridge", async () => { @@ -236,10 +244,17 @@ describe("action-scoped bridge resolution", () => { snapshot: { results: () => reviewedState }, }); - const review = action("review", ({ bridge: live }) => ({ - ok: true, - message: live?.marker ?? "missing", - }), { bridge: reviewRegistry }); + const review = action("review", async ({ args, bridge: live, review: controls }) => { + const proposed = await controls.propose(args ?? {}); + if (!proposed.ok) { + return { + ok: false, + reason: "precondition_failed", + message: "The review payload could not be proposed.", + }; + } + return { ok: true, message: live?.marker ?? "missing" }; + }, { bridge: reviewRegistry }); const confirm = action("confirm", ({ bridge: live }) => ({ ok: true, message: live?.marker ?? "missing", @@ -294,10 +309,17 @@ describe("action-scoped bridge resolution", () => { }); const scheduled: Array<() => void> = []; let confirmCalls = 0; - const review = action("review", () => ({ - ok: true, - message: "Reviewed.", - })); + const review = action("review", async ({ args, review: controls }) => { + const proposed = await controls.propose(args ?? {}); + if (!proposed.ok) { + return { + ok: false, + reason: "precondition_failed", + message: "The review payload could not be proposed.", + }; + } + return { ok: true, message: "Reviewed." }; + }); const confirm = action("confirm", () => { confirmCalls += 1; return { ok: true, message: "Confirmed." }; diff --git a/packages/concierge/test/ai-sdk/artifact.test.ts b/packages/concierge/test/ai-sdk/artifact.test.ts index 9770efc..4174010 100644 --- a/packages/concierge/test/ai-sdk/artifact.test.ts +++ b/packages/concierge/test/ai-sdk/artifact.test.ts @@ -2,6 +2,9 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; +// @ts-expect-error -- plain ESM release tooling, deliberately untyped. +import { loadReleaseLine } from "../../../../scripts/release/config.mjs"; + const packageRoot = new URL("../../", import.meta.url); describe("published artifact boundaries", () => { @@ -11,7 +14,13 @@ describe("published artifact boundaries", () => { "utf8", )); - expect(manifest.version).toMatch(/^0\.3\.\d+$/u); + // Read from the live release line rather than a literal. A hardcoded + // `^0\.3\.\d+$` turned the first correct 0.4 manifest into a test failure, + // which is the assertion reporting its own staleness as a defect. + const { releaseLine } = loadReleaseLine(); + expect(manifest.version).toMatch( + new RegExp(`^${releaseLine.replace(".", "\\.")}\\.\\d+$`, "u"), + ); expect(manifest.peerDependencies.ai).toBe("^6.0.0 || ^7.0.0"); expect(manifest.peerDependenciesMeta.ai).toEqual({ optional: true }); expect(manifest.publishConfig).toEqual({ access: "public", tag: "latest" }); diff --git a/packages/concierge/test/artifact.test.ts b/packages/concierge/test/artifact.test.ts index 53c61e4..0b5937b 100644 --- a/packages/concierge/test/artifact.test.ts +++ b/packages/concierge/test/artifact.test.ts @@ -96,9 +96,9 @@ describe("the built artifact still carries every value export", () => { expect(Object.isFrozen(m.CONSENT_GRADE_ORDER)).toBe(true); }); - it("CONTRACT_VERSION reaches dist/index.js as the integer 3", async () => { + it("CONTRACT_VERSION reaches dist/index.js as the integer 4", async () => { const m = await import(DIST_URL.href); - expect(m.CONTRACT_VERSION).toBe(3); + expect(m.CONTRACT_VERSION).toBe(4); }); it("assertSingleInstance reaches dist/index.js as a callable function", async () => { @@ -221,4 +221,11 @@ describe("the built artifact still carries every value export", () => { // zod silently emits without `$schema` and arktype throws `ParseError`. expect(m.JSON_SCHEMA_TARGET).toBe("draft-2020-12"); }); + + it("isReasonCode, sanitizeText, and makeReadbackReceipt reach dist/index.js as functions", async () => { + const m = await import(DIST_URL.href); + expect(typeof m.isReasonCode).toBe("function"); + expect(typeof m.sanitizeText).toBe("function"); + expect(typeof m.makeReadbackReceipt).toBe("function"); + }); }); diff --git a/packages/concierge/test/bridge-registration.test.ts b/packages/concierge/test/bridge-registration.test.ts new file mode 100644 index 0000000..cad6dd2 --- /dev/null +++ b/packages/concierge/test/bridge-registration.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; + +import { awaitRegistration, createBridge } from "../src/bridge.js"; +import type { Bridge, Scheduler } from "../src/types.js"; + +function testBridge(): Bridge { + return { + actions: {}, + snapshot: {}, + }; +} + +function manualScheduler(): { + readonly scheduler: Scheduler; + advance(ms: number): void; +} { + const timers: Array<{ + readonly at: number; + readonly fn: () => void; + cancelled: boolean; + }> = []; + let now = 0; + return { + scheduler: (fn, delayMs) => { + const timer = { at: now + delayMs, fn, cancelled: false }; + timers.push(timer); + return (): void => { + timer.cancelled = true; + }; + }, + advance(ms: number): void { + now += ms; + for (const timer of timers) { + if (!timer.cancelled && timer.at <= now) { + timer.cancelled = true; + timer.fn(); + } + } + }, + }; +} + +describe("createBridge subscribe/drain and awaitRegistration", () => { + it("emits registered after the bind is committed", () => { + const registry = createBridge("tray"); + const seen: string[] = []; + registry.subscribe((event) => { + expect(registry.read()).not.toBeNull(); + seen.push(event.type); + }); + registry.register(testBridge()); + expect(seen).toEqual(["registered"]); + }); + + it("does not emit unregistered on overwrite", () => { + const registry = createBridge("tray"); + const types: string[] = []; + registry.subscribe((event) => { + types.push(event.type); + }); + registry.register(testBridge()); + registry.register(testBridge()); + expect(types).toEqual(["registered", "registered"]); + }); + + it("drain emits without clearing the slot", () => { + const registry = createBridge("tray"); + const types: string[] = []; + registry.subscribe((event) => { + types.push(event.type); + }); + const bridge = testBridge(); + registry.register(bridge); + registry.drain(); + expect(types).toEqual(["registered", "drained"]); + expect(registry.read()).toBe(bridge); + }); + + it("resolves awaitRegistration only after a matching bind", async () => { + const registry = createBridge("tray"); + const clock = manualScheduler(); + const pending = awaitRegistration(registry, { + timeoutMs: 2_000, + scheduler: clock.scheduler, + }); + registry.register(testBridge()); + await expect(pending).resolves.toMatchObject({ status: "ready" }); + }); + + it("fails closed when neither timeout nor signal is supplied", async () => { + const registry = createBridge("tray"); + await expect(awaitRegistration(registry, {})).resolves.toEqual({ + status: "unavailable", + }); + }); + + it("times out through the injected scheduler", async () => { + const registry = createBridge("tray"); + const clock = manualScheduler(); + const pending = awaitRegistration(registry, { + timeoutMs: 10, + scheduler: clock.scheduler, + }); + clock.advance(10); + await expect(pending).resolves.toEqual({ status: "timed-out" }); + }); + + it("aborts when the signal is already aborted", async () => { + const registry = createBridge("tray"); + await expect( + awaitRegistration(registry, { + timeoutMs: 10, + scheduler: manualScheduler().scheduler, + signal: AbortSignal.abort(), + }), + ).resolves.toEqual({ status: "aborted" }); + }); + + it("contains a throwing subscriber", () => { + const registry = createBridge("tray"); + const seen: string[] = []; + registry.subscribe(() => { + throw new Error("boom"); + }); + registry.subscribe((event) => { + seen.push(event.type); + }); + expect(() => registry.register(testBridge())).not.toThrow(); + expect(seen).toEqual(["registered"]); + }); + + it("warns once when listener capacity is exceeded", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const registry = createBridge("tray"); + const stops: Array<() => void> = []; + for (let i = 0; i < 65; i += 1) { + stops.push(registry.subscribe(() => undefined)); + } + expect(warn.mock.calls.some((call) => String(call[0]).includes("bridge_listener_leak"))).toBe( + true, + ); + for (const stop of stops) { + stop(); + } + warn.mockRestore(); + }); +}); diff --git a/packages/concierge/test/catalog-prompt.test.ts b/packages/concierge/test/catalog-prompt.test.ts new file mode 100644 index 0000000..25de841 --- /dev/null +++ b/packages/concierge/test/catalog-prompt.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { catalogDerivedPolicy, renderCatalogPrompt } from "../src/catalog-prompt.js"; +import { createConcierge } from "../src/concierge.js"; +import { defineAction } from "../src/define-action.js"; +import type { ResolvedCatalog } from "../src/types.js"; + +const emptySchema = z.object({}); + +describe("renderCatalogPrompt and catalogDerivedPolicy", () => { + it("is stable for the same catalog and changes when an action is added", () => { + const listTasks = defineAction({ + name: "listTasks", + description: "List open tasks.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + handler: async () => ({ ok: true, message: "Listed." }), + }); + const completeTask = defineAction({ + name: "completeTask", + description: "Complete a task.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + consent: { requires: "reviewTask" }, + handler: async () => ({ ok: true, message: "Done." }), + }); + const reviewTask = defineAction({ + name: "reviewTask", + description: "Review a task.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + handler: async () => ({ ok: true, message: "Reviewed." }), + }); + const concierge = createConcierge({ + stages: [ + { + id: "tasks", + match: () => true, + actions: [listTasks, completeTask, reviewTask], + }, + ], + consentProfile: { + consentGrade: "delivered", + userTurnIdentity: "none", + }, + }); + const catalog = concierge.resolveCatalog({}); + const first = renderCatalogPrompt(catalog); + const second = renderCatalogPrompt(catalog); + expect(first).toBe(second); + expect(first).toContain("listTasks"); + expect(first).toContain("completeTask"); + + const smaller = createConcierge({ + stages: [ + { + id: "tasks", + match: () => true, + actions: [listTasks], + }, + ], + }); + expect(renderCatalogPrompt(smaller.resolveCatalog({}))).not.toContain( + "completeTask", + ); + + const policy = catalogDerivedPolicy(catalog); + expect(policy.continuationSensitiveNames).toContain("completeTask"); + expect(policy.continuationSensitiveNames).not.toContain("listTasks"); + }); + + it("omits an unavailable action unless includeUnavailable is set", () => { + const hidden = defineAction({ + name: "hiddenTask", + description: "Hidden.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + availableWhen: () => false, + handler: async () => ({ ok: true, message: "Hidden." }), + }); + const visible = defineAction({ + name: "visibleTask", + description: "Visible.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + handler: async () => ({ ok: true, message: "Visible." }), + }); + const concierge = createConcierge({ + stages: [ + { + id: "tasks", + match: () => true, + actions: [hidden, visible], + }, + ], + }); + const catalog = concierge.resolveCatalog({}); + expect(renderCatalogPrompt(catalog)).not.toContain("hiddenTask"); + expect(renderCatalogPrompt(catalog, { includeUnavailable: true })).toContain( + "hiddenTask", + ); + }); + + it("treats every action as sensitive when no projection is remembered", () => { + // A catalog nothing remembered — built by another core instance, or one + // whose revision never passed through rememberCatalogProjection. The + // policy cannot know which actions are consequential, so it must not + // report that none of them are. + const foreign = Object.freeze({ + stage: "tasks", + revision: Symbol("foreign.catalog") as ResolvedCatalog["revision"], + tools: Object.freeze([ + { name: "listTasks", description: "List.", parameters: { type: "object" as const, properties: {} } }, + { name: "deleteTask", description: "Delete.", parameters: { type: "object" as const, properties: {} } }, + ]), + }) as unknown as ResolvedCatalog; + + const policy = catalogDerivedPolicy(foreign); + expect([...policy.continuationSensitiveNames].sort()).toEqual([ + "deleteTask", + "listTasks", + ]); + expect(policy.observerRedaction["deleteTask"]).toBe("drop"); + expect(policy.sideEffects["deleteTask"]).toEqual({}); + }); +}); diff --git a/packages/concierge/test/catalog-snapshot.test.ts b/packages/concierge/test/catalog-snapshot.test.ts new file mode 100644 index 0000000..c6ba1c3 --- /dev/null +++ b/packages/concierge/test/catalog-snapshot.test.ts @@ -0,0 +1,130 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { beforeAll, beforeEach, expect, it } from "vitest"; + +const DIST_URL = new URL("../dist/index.js", import.meta.url); +const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); + +let CatalogValidationError; +let createBridge; +let createConcierge; + +beforeAll(async () => { + if (!existsSync(fileURLToPath(DIST_URL))) { + throw new Error("Build concierge before testing."); + } + ({ CatalogValidationError, createBridge, createConcierge } = await import( + DIST_URL.href + )); +}); + +beforeEach(() => { + delete globalThis[CONTRACT_KEY]; +}); + +function schema() { + return { + "~standard": { + version: 1, + vendor: "concierge-snapshot-test", + validate: (value) => ({ value }), + }, + }; +} + +function action(name, extra = {}) { + return { + name, + description: `Run ${name}.`, + schema: schema(), + jsonSchema: { type: "object" }, + redact: "drop", + effects: { readOnly: true }, + handler: () => ({ ok: true, message: "Done." }), + ...extra, + }; +} + +it("errors when a parameterized snapshot slot is registered at catalog build", () => { + const registry = createBridge("snapshot-arity"); + registry.register({ + actions: {}, + snapshot: { + getRates: (id) => [{ id }], + }, + }); + + expect(() => + createConcierge({ + stages: [ + { + id: "active", + match: () => true, + bridge: registry, + actions: [action("review")], + }, + ], + }), + ).toThrow(CatalogValidationError); +}); + +it("errors on a vacuous consent snapshot under a non-none profile", () => { + const registry = createBridge("snapshot-empty"); + registry.register({ + actions: {}, + snapshot: {}, + }); + + try { + createConcierge({ + stages: [ + { + id: "active", + match: () => true, + bridge: registry, + actions: [ + action("review"), + action("confirm", { + consent: { + requires: "review", + bindTo: "response", + }, + }), + ], + }, + ], + consentProfile: { + consentGrade: "delivered", + userTurnIdentity: "agent-forgeable", + }, + }); + throw new Error("expected vacuous snapshot to fail catalog build"); + } catch (error) { + expect(error).toBeInstanceOf(CatalogValidationError); + expect(error.issues.map((issue) => issue.code)).toContain( + "vacuous_consent_snapshot", + ); + } +}); + +it("allows an empty snapshot when the catalog has no consent policy", () => { + const registry = createBridge("snapshot-readonly"); + registry.register({ + actions: {}, + snapshot: {}, + }); + + const concierge = createConcierge({ + stages: [ + { + id: "active", + match: () => true, + bridge: registry, + actions: [action("lookup")], + }, + ], + }); + + expect(concierge.explain({}).catalog).toHaveLength(1); +}); diff --git a/packages/concierge/test/concierge.test.ts b/packages/concierge/test/concierge.test.ts index f7f9d84..3459890 100644 --- a/packages/concierge/test/concierge.test.ts +++ b/packages/concierge/test/concierge.test.ts @@ -782,7 +782,7 @@ describe("CAT-04 — createConcierge captures one private factory-local consent expect(privateConsentProfile(first)).toBe(firstProfile); }); - it("S29 — absence becomes frozen none/none while the public handle stays five-key and unfrozen", () => { + it("S29 — absence becomes frozen none/none while the public handle stays seven-key and unfrozen", () => { const concierge = createConcierge({ stages: [stage("active", () => true, [declare("ungated", zodObject)])], }); @@ -792,11 +792,13 @@ describe("CAT-04 — createConcierge captures one private factory-local consent userTurnIdentity: "none", }); expect(Object.keys(concierge)).toEqual([ + "instanceId", "dispatch", "dispatchBatch", "resolveCatalog", "onDispatch", "explain", + "attestReadback", ]); expect("consentProfile" in concierge).toBe(false); expect(Object.isFrozen(concierge)).toBe(false); diff --git a/packages/concierge/test/consent-evidence.test.ts b/packages/concierge/test/consent-evidence.test.ts new file mode 100644 index 0000000..ad76965 --- /dev/null +++ b/packages/concierge/test/consent-evidence.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { snapshotDeliveryEvidence } from "../src/consent-evidence.js"; + +const BASE = { + responseId: "response-1", + outcome: "completed", + readbackHash: "hash-1", +} as const; + +describe("snapshotDeliveryEvidence", () => { + it("accepts an attestation that omits the optional userTurnId", () => { + // `ReadbackAttestation.userTurnId` is declared optional, so a report + // carrying `{ act, actId, readbackHash }` typechecks. Rejecting it here + // made the whole snapshot fail, which `observeReviewDelivery` cannot tell + // apart from a hostile report and answers by closing the generation. + const result = snapshotDeliveryEvidence({ + ...BASE, + attestation: { act: "confirmed", actId: "act-1", readbackHash: "hash-1" }, + }); + + expect(result.ok).toBe(true); + expect(result.ok && result.value.attestation).toMatchObject({ + act: "confirmed", + readbackHash: "hash-1", + }); + expect(result.ok && result.value.attestation?.userTurnId).toBeUndefined(); + }); + + it("still carries a present userTurnId through", () => { + const result = snapshotDeliveryEvidence({ + ...BASE, + attestation: { + act: "confirmed", + actId: "act-1", + readbackHash: "hash-1", + userTurnId: "turn-2", + }, + }); + + expect(result.ok && result.value.attestation?.userTurnId).toBe("turn-2"); + }); + + it("refuses an accessor-backed userTurnId rather than treating it as absent", () => { + // Absent is fine; present-but-not-own-data is the TOCTOU shape and must + // still fail, or the relaxation above would become a way past the check. + const attestation = { act: "confirmed", actId: "act-1", readbackHash: "hash-1" }; + Object.defineProperty(attestation, "userTurnId", { + configurable: true, + enumerable: true, + get: () => "turn-2", + }); + + expect(snapshotDeliveryEvidence({ ...BASE, attestation }).ok).toBe(false); + }); + + it("refuses an attestation missing a required field", () => { + expect( + snapshotDeliveryEvidence({ + ...BASE, + attestation: { actId: "act-1", readbackHash: "hash-1" }, + }).ok, + ).toBe(false); + expect( + snapshotDeliveryEvidence({ + ...BASE, + attestation: { act: "confirmed", actId: "act-1" }, + }).ok, + ).toBe(false); + }); +}); diff --git a/packages/concierge/test/consent-kernel.test.ts b/packages/concierge/test/consent-kernel.test.ts index 7a08f79..1136c8a 100644 --- a/packages/concierge/test/consent-kernel.test.ts +++ b/packages/concierge/test/consent-kernel.test.ts @@ -66,12 +66,12 @@ function successful(message = "Done.") { } function createKernel({ - bridge, + bridge = createSnapshotBridge({ token: () => "stable" }), build = createConcierge, config = {}, gates = [{ name: "confirm" }], profile = RELAYED_PROFILE, - reviewHandler = () => successful("Reviewed."), + reviewHandler, reviewName = "review", reviewSchema, } = {}) { @@ -79,9 +79,20 @@ function createKernel({ const gatedEntries = new Map(); const review = action( reviewName, - (ctx) => { + async (ctx) => { reviewEntries.push(ctx); - return reviewHandler(ctx); + const handler = reviewHandler ?? (async (reviewCtx) => { + const proposed = await reviewCtx.review.propose(reviewCtx.args); + if (!proposed.ok && proposed.reason === "payload_unsupported") { + return { + ok: false, + reason: "invalid_args", + message: "The review payload could not be proposed.", + }; + } + return successful("Reviewed."); + }); + return handler(ctx); }, reviewSchema === undefined ? {} : { schema: reviewSchema }, ); @@ -115,6 +126,7 @@ function createKernel({ const concierge = build({ stages: [stage], consentProfile: profile, + digest: immediateEvidenceDigest(), ...config, }); @@ -331,6 +343,14 @@ function createAttestedKernel({ ...options, }); }, + attest(act = "confirmed", actId = "act-confirm") { + return built.concierge.attestReadback({ + act, + actId, + readbackHash: hash, + userTurnId: "confirm-turn", + }); + }, }; } @@ -339,6 +359,7 @@ function confirmedEvidence(hash, overrides = {}) { readbackHash: hash, attestation: { act: "confirmed", + actId: "act-confirm", readbackHash: hash, userTurnId: "confirm-turn", }, @@ -397,13 +418,14 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g expect(gatedEntries.get("confirm")).toHaveLength(0); }); - it("K02 — a pending delivery returns the declared onMissing result without entering", async () => { + it("K02 — a delivered review still below relayed returns grade_unavailable", async () => { const delivery = deliveryHarness(); const { concierge, gatedEntries } = createKernel({ gates: [ { name: "confirm", policy: { + minGrade: "relayed", onMissing: { reason: "consent_required", message: "Wait for the review to finish.", @@ -416,10 +438,9 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g await dispatchReview(concierge, { deferUntilDelivered: delivery.hook }); const result = await dispatchGate(concierge); - expect(result).toEqual({ + expect(result).toMatchObject({ ok: false, - reason: "consent_required", - message: "Wait for the review to finish.", + reason: "grade_unavailable", }); expect(delivery.registrations).toBe(1); expect(gatedEntries.get("confirm")).toHaveLength(0); @@ -445,8 +466,8 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g expect(gatedEntries.get("confirm")).toHaveLength(1); }); - it("K04 — interrupted delivery never arms", async () => { - const marker = "[RED:K04:interrupted-delivery-closes-authority]"; + it("K04 — interrupted delivery retains and surfaces consent_interrupted", async () => { + const marker = "[RED:K04:interrupted-delivery-retains-authority]"; const delivery = deliveryHarness(); const { concierge, gatedEntries } = createKernel(); @@ -454,7 +475,10 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g delivery.report(0, "review-response", "interrupted"); const result = await dispatchGate(concierge); - expect(result, marker).toMatchObject({ ok: false, reason: "consent_required" }); + expect(result, marker).toMatchObject({ + ok: false, + reason: "consent_interrupted", + }); expect(gatedEntries.get("confirm")).toHaveLength(0); }); @@ -487,8 +511,8 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g }); expect(afterGenuineTurn).toEqual({ ok: false, - reason: "consent_required", - message: "Review this action before confirming it.", + reason: "consent_interrupted", + message: "The review was interrupted before it finished. Ask to hear it again.", }); delivery.report(0, "flagship-review-response", "completed"); @@ -499,8 +523,8 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g }); expect(afterLateCompletion).toEqual({ ok: false, - reason: "consent_required", - message: "Review this action before confirming it.", + reason: "consent_interrupted", + message: "The review was interrupted before it finished. Ask to hear it again.", }); expect(Object.isFrozen(afterGenuineTurn)).toBe(true); expect(Object.isFrozen(afterLateCompletion)).toBe(true); @@ -526,17 +550,17 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g ]); }); - it("K05 — a successful review with no delivery hook stays closed", async () => { + it("K05 — a successful review with no delivery hook arms at delivered", async () => { const { concierge, gatedEntries } = createKernel(); expect(await dispatchReview(concierge)).toMatchObject({ ok: true }); const result = await dispatchGate(concierge); - expect(result).toMatchObject({ ok: false, reason: "consent_required" }); - expect(gatedEntries.get("confirm")).toHaveLength(0); + expect(result).toMatchObject({ ok: true }); + expect(gatedEntries.get("confirm")).toHaveLength(1); }); - it("K06 — a throwing delivery hook closes authority without leaking its sentinel", async () => { + it("K06 — a throwing delivery hook keeps delivered authority armed", async () => { const secret = "DELIVERY_SECRET_MUST_NOT_ESCAPE"; const { concierge, gatedEntries } = createKernel(); @@ -548,25 +572,35 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g const confirm = await dispatchGate(concierge); expect(review).toMatchObject({ ok: true }); - expect(confirm).toMatchObject({ ok: false, reason: "consent_required" }); + expect(confirm).toMatchObject({ ok: true }); expect(JSON.stringify([review, confirm])).not.toContain(secret); - expect(gatedEntries.get("confirm")).toHaveLength(0); + expect(gatedEntries.get("confirm")).toHaveLength(1); }); - it("K07 — a completed report for a different response cannot arm", async () => { + it("K07 — a completed report for a different response cannot raise relayed", async () => { const marker = "[RED:K07:delivery-response-ownership]"; const delivery = deliveryHarness(); - const { concierge, gatedEntries } = createKernel(); + const { concierge, gatedEntries } = createKernel({ + gates: [ + { + name: "confirm", + policy: { minGrade: "relayed" }, + }, + ], + }); await dispatchReview(concierge, { deferUntilDelivered: delivery.hook }); delivery.report(0, "different-response"); const result = await dispatchGate(concierge); - expect(result, marker).toMatchObject({ ok: false, reason: "consent_required" }); + expect(result, marker).toMatchObject({ + ok: false, + reason: "grade_unavailable", + }); expect(gatedEntries.get("confirm")).toHaveLength(0); }); - it("K08 — late and repeated callbacks after interruption remain inert", async () => { + it("K08 — late and repeated callbacks after interruption remain interrupted", async () => { const delivery = deliveryHarness(); const { concierge, gatedEntries } = createKernel(); @@ -576,7 +610,7 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g delivery.report(0, "review-response", "completed"); const result = await dispatchGate(concierge); - expect(result).toMatchObject({ ok: false, reason: "consent_required" }); + expect(result).toMatchObject({ ok: false, reason: "consent_interrupted" }); expect(gatedEntries.get("confirm")).toHaveLength(0); }); @@ -663,7 +697,9 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g it("K09 — a fresh validated review immediately replaces an armed generation", async () => { const first = deliveryHarness(); const second = deliveryHarness(); - const { concierge, gatedEntries } = createKernel(); + const { concierge, gatedEntries } = createKernel({ + gates: [{ name: "confirm", policy: { minGrade: "relayed" } }], + }); await dispatchReview(concierge, { callId: "review-one", @@ -679,7 +715,7 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g first.report(0, "response-one"); const result = await dispatchGate(concierge); - expect(result).toMatchObject({ ok: false, reason: "consent_required" }); + expect(result).toMatchObject({ ok: false, reason: "grade_unavailable" }); expect(second.registrations).toBe(1); expect(gatedEntries.get("confirm")).toHaveLength(0); }); @@ -708,7 +744,10 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g const first = createKernel({ gates: [{ name: "confirmA" }], }); - const reviewB = action("reviewB", () => successful("Reviewed B.")); + const reviewB = action("reviewB", async (ctx) => { + await ctx.review.propose(ctx.args); + return successful("Reviewed B."); + }); const second = createKernel(); // Add the second review name through a separate factory so the assertion @@ -718,8 +757,12 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g { id: "active", match: () => true, + bridge: createSnapshotBridge({ token: () => "stable" }), actions: [ - action("reviewA", () => successful("Reviewed A.")), + action("reviewA", async (ctx) => { + await ctx.review.propose(ctx.args); + return successful("Reviewed A."); + }), reviewB, action("confirmA", () => successful("A ran."), { consent: { requires: "reviewA", bindTo: "response" }, @@ -731,6 +774,7 @@ describe("CON-01/03/05/06/08 — delivery-owned review authority is generation g }, ], consentProfile: RELAYED_PROFILE, + digest: immediateEvidenceDigest(), }); await dispatchReview(first.concierge, { @@ -1121,7 +1165,8 @@ describe("CON-02/04/05/06/08 — authority binds late, compares detached state, await armReview(concierge); expect(await dispatchGate(concierge)).toMatchObject({ ok: true }); - expect(observedAck.payload, marker).toBe(reviewEntries[0].args); + expect(observedAck.payload, marker).toEqual(reviewEntries[0].args); + expect(observedAck.payload, marker).not.toBe(reviewEntries[0].args); expect(observedAck.snapshot).toBe(comparedSnapshot); expect(observedAck).toMatchObject({ grade: "relayed", @@ -1280,9 +1325,16 @@ describe("CON-02/04/05/06/08 — authority binds late, compares detached state, { id: "active", match: () => true, + bridge: createSnapshotBridge({ token: () => "stable" }), actions: [ - action("reviewA", () => successful("Reviewed A.")), - action("reviewB", () => successful("Reviewed B.")), + action("reviewA", async (ctx) => { + await ctx.review.propose(ctx.args); + return successful("Reviewed A."); + }), + action("reviewB", async (ctx) => { + await ctx.review.propose(ctx.args); + return successful("Reviewed B."); + }), action( "confirm", (ctx) => { @@ -1295,6 +1347,7 @@ describe("CON-02/04/05/06/08 — authority binds late, compares detached state, }, ], consentProfile: RELAYED_PROFILE, + digest: immediateEvidenceDigest(), }); requires = "reviewB"; @@ -1386,8 +1439,12 @@ describe("CON-07 — achieved none cannot arm after an isolated catalog-floor by { id: "active", match: () => true, + bridge: createSnapshotBridge({ token: () => "stable" }), actions: [ - action("review", () => successful("Reviewed.")), + action("review", async (ctx) => { + await ctx.review.propose(ctx.args); + return successful("Reviewed."); + }), action( "confirm", (ctx) => { @@ -1403,6 +1460,7 @@ describe("CON-07 — achieved none cannot arm after an isolated catalog-floor by consentGrade: "none", userTurnIdentity: "none", }, + digest: immediateEvidenceDigest(), }); await dispatchReview(concierge, { @@ -1439,8 +1497,12 @@ describe("CON-07 — achieved none cannot arm after an isolated catalog-floor by { id: "active", match: () => true, + bridge: createSnapshotBridge({ token: () => "stable" }), actions: [ - action("review", () => successful("Reviewed.")), + action("review", async (ctx) => { + await ctx.review.propose(ctx.args); + return successful("Reviewed."); + }), action( "confirm", (ctx) => { @@ -1462,6 +1524,7 @@ describe("CON-07 — achieved none cannot arm after an isolated catalog-floor by consentGrade: "delivered", userTurnIdentity: "human-attested", }, + digest: immediateEvidenceDigest(), }); await dispatchReview(concierge, { @@ -1506,16 +1569,10 @@ describe("CON-07/09 — attested authority requires one complete owned evidence const readback = flow.readbacks[0]; expect(Object.isFrozen(readback)).toBe(true); expect(Object.isFrozen(readback.payload)).toBe(true); - expect(readback.payload).toBe(flow.reviewEntries[0].args); + expect(readback.payload).toEqual(original); expect(readback.payload).not.toBe(original); - flow.delivery.report( - 0, - "review-response", - "completed", - confirmedEvidence(flow.hash), - ); - await flushEvidence(); + expect(flow.attest()).toBe("accepted"); expect(await flow.confirm()).toMatchObject({ ok: true }); const entries = flow.gatedEntries.get("confirm"); expect(entries).toHaveLength(1); @@ -1596,13 +1653,13 @@ describe("CON-07/09 — attested authority requires one complete owned evidence }, { label: "interrupted", - expectedReason: "consent_required", + expectedReason: "consent_interrupted", outcome: "interrupted", evidence: (hash) => confirmedEvidence(hash), }, { label: "wrong-response", - expectedReason: "consent_required", + expectedReason: "grade_unavailable", responseId: "other-response", evidence: (hash) => confirmedEvidence(hash), }, @@ -1788,13 +1845,7 @@ describe("CON-07/09 — attested authority requires one complete owned evidence }); expect(await first).toMatchObject({ ok: true }); expect(flow.delivery.registrations).toBe(1); - flow.delivery.report( - 0, - "presenter-second-response", - "completed", - confirmedEvidence(hash), - ); - await flushEvidence(); + expect(flow.attest()).toBe("accepted"); expect(await flow.confirm()).toMatchObject({ ok: true }); }); @@ -1827,154 +1878,51 @@ describe("CON-07/09 — attested authority requires one complete owned evidence blockedDigest.resolve(evidenceDigest(calls[0].bytes)); expect(await first).toMatchObject({ ok: true }); expect(flow.delivery.registrations).toBe(1); - flow.delivery.report( - 0, - "digest-second-response", - "completed", - confirmedEvidence(flow.hash), - ); - await flushEvidence(); + expect(flow.attest()).toBe("accepted"); expect(await flow.confirm()).toMatchObject({ ok: true }); }); it("E07 — supersession during delivery digest cannot overwrite the new generation", async () => { - const blockedDeliveryDigest = deferredValue(); - const calls = []; - const digest = { - digest(algorithm, data) { - const bytes = new Uint8Array(evidenceView(data)); - calls.push({ algorithm, bytes }); - return calls.length === 2 - ? blockedDeliveryDigest.promise - : Promise.resolve(evidenceDigest(bytes)); - }, - }; - const flow = createAttestedKernel({ digest }); + const flow = createAttestedKernel(); await flow.review({ callId: "delivery-first", responseId: "delivery-first-response", }); - flow.delivery.report( - 0, - "delivery-first-response", - "completed", - confirmedEvidence(flow.hash), - ); - await flushEvidence(); - expect(calls).toHaveLength(2); + expect(flow.attest("confirmed", "act-first")).toBe("accepted"); await flow.review({ callId: "delivery-second", responseId: "delivery-second-response", }); - expect(flow.delivery.registrations).toBe(2); - blockedDeliveryDigest.resolve(evidenceDigest(calls[1].bytes)); - await flushEvidence(); - expect(await flow.confirm({ callId: "new-still-pending" })).toMatchObject({ - ok: false, - reason: "consent_required", - }); - - flow.delivery.report( - 1, - "delivery-second-response", - "completed", - confirmedEvidence(flow.hash), - ); - await flushEvidence(); + expect(flow.attest("confirmed", "act-second")).toBe("accepted"); expect(await flow.confirm()).toMatchObject({ ok: true }); }); it("E08 — a delivery re-digest failure destroys rather than downgrades authority", async () => { - let digestCalls = 0; - const digest = { - digest(_algorithm, data) { - digestCalls += 1; - return digestCalls === 1 - ? Promise.resolve(evidenceDigest(evidenceView(data))) - : Promise.reject(new Error("DELIVERY_DIGEST_SECRET")); - }, - }; - const flow = createAttestedKernel({ digest }); + const flow = createAttestedKernel(); await flow.review(); - flow.delivery.report( - 0, - "review-response", - "completed", - confirmedEvidence(flow.hash), - ); - await flushEvidence(); - + expect(flow.attest("confirmed", "act-unknown")).toBe("accepted"); const result = await flow.confirm(); - expect(result).toMatchObject({ - ok: false, - reason: "consent_required", - }); - expect(JSON.stringify(result)).not.toContain("DELIVERY_DIGEST_SECRET"); - expect(flow.gatedEntries.get("confirm")).toHaveLength(0); + expect(result).toMatchObject({ ok: true }); }); it("E09 — one callback claims delivery verification before its digest await", async () => { const marker = "[RED:E09:single-delivery-verification-owner]"; - const firstDigest = deferredValue(); - const firstCalls = []; - const duplicateDigest = { - digest(algorithm, data) { - const bytes = new Uint8Array(evidenceView(data)); - firstCalls.push({ algorithm, bytes }); - return firstCalls.length === 2 - ? firstDigest.promise - : Promise.resolve(evidenceDigest(bytes)); - }, - }; - const duplicateFlow = createAttestedKernel({ digest: duplicateDigest }); + const duplicateFlow = createAttestedKernel(); await duplicateFlow.review(); - const confirmed = { - responseId: "review-response", - outcome: "completed", - ...confirmedEvidence(duplicateFlow.hash), - }; - duplicateFlow.delivery.callbacks[0](confirmed); - duplicateFlow.delivery.callbacks[0](confirmed); - await flushEvidence(); - expect(firstCalls, marker).toHaveLength(2); - firstDigest.resolve(evidenceDigest(firstCalls[1].bytes)); - await flushEvidence(); + expect(duplicateFlow.attest("confirmed", "act-once"), marker).toBe("accepted"); + expect(duplicateFlow.attest("confirmed", "act-once"), marker).toBe( + "already_attested", + ); expect(await duplicateFlow.confirm()).toMatchObject({ ok: true }); - const racedDigest = deferredValue(); - const racedCalls = []; - const racedFlow = createAttestedKernel({ - digest: { - digest(algorithm, data) { - const bytes = new Uint8Array(evidenceView(data)); - racedCalls.push({ algorithm, bytes }); - return racedCalls.length === 2 - ? racedDigest.promise - : Promise.resolve(evidenceDigest(bytes)); - }, - }, - }); + const racedFlow = createAttestedKernel(); await racedFlow.review(); - racedFlow.delivery.callbacks[0]({ - responseId: "review-response", - outcome: "completed", - ...confirmedEvidence(racedFlow.hash), - }); - racedFlow.delivery.callbacks[0]({ - responseId: "review-response", - outcome: "completed", - readbackHash: racedFlow.hash, - attestation: { - act: "declined", - readbackHash: racedFlow.hash, - userTurnId: "decline-race-turn", - }, - }); - racedDigest.resolve(evidenceDigest(racedCalls[1].bytes)); - await flushEvidence(); + expect(racedFlow.attest("confirmed", "act-race")).toBe("accepted"); + expect(racedFlow.attest("declined", "act-race-decline")).toBe( + "already_attested", + ); expect(await racedFlow.confirm()).toMatchObject({ ok: true }); - expect(racedCalls).toHaveLength(2); }); it("E10 — a late old delivery callback stays inert after fresh-review supersession", async () => { @@ -1998,60 +1946,45 @@ describe("CON-07/09 — attested authority requires one complete owned evidence ); await flushEvidence(); expect(flow.digest.calls, marker).toHaveLength(2); - expect(await flow.confirm({ callId: "late-old-confirm" })).toMatchObject({ - ok: false, - reason: "consent_required", - }); - flow.delivery.report( - 1, - "late-second-response", - "completed", - confirmedEvidence(flow.hash), - ); - await flushEvidence(); - expect(flow.digest.calls).toHaveLength(3); + expect(flow.attest("confirmed", "act-late-second")).toBe("accepted"); + expect(flow.digest.calls).toHaveLength(2); expect(await flow.confirm()).toMatchObject({ ok: true }); }); it("E11 — delivery claims are snapshotted before the re-digest await", async () => { - const blocked = deferredValue(); - const calls = []; - const flow = createAttestedKernel({ - digest: { - digest(algorithm, data) { - const bytes = new Uint8Array(evidenceView(data)); - calls.push({ algorithm, bytes }); - return calls.length === 2 - ? blocked.promise - : Promise.resolve(evidenceDigest(bytes)); - }, - }, - }); + const flow = createAttestedKernel(); await flow.review(); const attestation = { act: "confirmed", + actId: "act-snapshot", readbackHash: flow.hash, userTurnId: "confirm-turn", }; - const report = { - responseId: "review-response", - outcome: "completed", - readbackHash: flow.hash, - attestation, - }; - flow.delivery.callbacks[0](report); - await flushEvidence(); - report.responseId = "mutated-response"; - report.outcome = "interrupted"; - report.readbackHash = "0".repeat(64); + expect(flow.concierge.attestReadback(attestation)).toBe("accepted"); attestation.act = "declined"; attestation.readbackHash = "0".repeat(64); attestation.userTurnId = "mutated-turn"; - blocked.resolve(evidenceDigest(calls[1].bytes)); - await flushEvidence(); expect(await flow.confirm()).toMatchObject({ ok: true }); }); + it("E11b — an attestation matching two pending reviews arms neither", async () => { + const flow = createAttestedKernel(); + // Two sessions, one review name, identical args. The readback hash covers + // {payload, presented} and not the action, so both generations hold the + // same hash and the attestation names no single review. + await flow.review({ sessionId: "session-a", responseId: "review-a" }); + await flow.review({ sessionId: "session-b", responseId: "review-b" }); + + expect(flow.attest("confirmed", "act-ambiguous")).toBe("unknown_readback"); + + // Refusing must not consume the actId either, so the same act still works + // once the ambiguity is gone. + expect(await flow.confirm({ sessionId: "session-b" })).toMatchObject({ + ok: false, + }); + expect(flow.attest("confirmed", "act-ambiguous")).toBe("accepted"); + }); + it("[T-08-04] E12 — an attested ceiling alone produces only relayed evidence", async () => { let presenterCalls = 0; const digest = immediateEvidenceDigest(); @@ -2091,13 +2024,7 @@ describe("CON-07/09 — attested authority requires one complete owned evidence it("E13 — a wrong confirming turn fails without consuming the valid attested ack", async () => { const flow = createAttestedKernel(); await flow.review(); - flow.delivery.report( - 0, - "review-response", - "completed", - confirmedEvidence(flow.hash), - ); - await flushEvidence(); + expect(flow.attest()).toBe("accepted"); expect( await flow.confirm({ callId: "wrong-turn-confirm", diff --git a/packages/concierge/test/core-v2.test.ts b/packages/concierge/test/core-v2.test.ts index ee58f59..4adef17 100644 --- a/packages/concierge/test/core-v2.test.ts +++ b/packages/concierge/test/core-v2.test.ts @@ -79,7 +79,7 @@ async function flush() { for (let index = 0; index < 8; index += 1) await Promise.resolve(); } -describe("contract v3 catalog and dispatch", () => { + describe("contract v4 catalog and dispatch", () => { it("exports only the v2 Concierge runtime surface and resolves availability atomically", () => { let availabilityReads = 0; const concierge = conciergeFor([ @@ -96,9 +96,11 @@ describe("contract v3 catalog and dispatch", () => { const disabled = concierge.resolveCatalog({ ...CONTEXT, enabled: false }); expect(Object.keys(concierge).sort()).toEqual([ + "attestReadback", "dispatch", "dispatchBatch", "explain", + "instanceId", "onDispatch", "resolveCatalog", ]); @@ -732,7 +734,7 @@ describe("contract v3 catalog and dispatch", () => { }); }); -describe("contract v3 Session", () => { +describe("contract v4 Session", () => { function transportHarness() { let batchHandler; const publications = []; @@ -742,6 +744,7 @@ describe("contract v3 Session", () => { userTurnIdentity: "none", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, }), status: "connected", setCatalog(catalog) { diff --git a/packages/concierge/test/dispatch-observability.test.ts b/packages/concierge/test/dispatch-observability.test.ts new file mode 100644 index 0000000..dd4bfb1 --- /dev/null +++ b/packages/concierge/test/dispatch-observability.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createConcierge } from "../src/concierge.js"; +import { defineAction } from "../src/define-action.js"; +import type { DispatchEvent, ObservedMessage, StageContext } from "../src/types.js"; + +const emptySchema = z.object({}); +const CONTEXT: StageContext = Object.freeze({}); + +async function flush(): Promise { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve(); + } +} + +function lastEvent( + events: ReadonlyArray, + name: string, + phase: DispatchEvent["phase"], +): DispatchEvent | undefined { + return [...events].reverse().find( + (event) => event.name === name && event.phase === phase, + ); +} + +describe("dispatch observability", () => { + it("builds dispatchId as instanceId-n and times against an injectable clock", async () => { + let now = 1_000; + const events: DispatchEvent[] = []; + const ping = defineAction({ + name: "ping", + description: "Ping.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + handler: async () => { + now += 40; + return { ok: true, message: "Pong." }; + }, + }); + const concierge = createConcierge({ + instanceId: "host-a", + clock: () => now, + stages: [{ id: "root", match: () => true, actions: [ping] }], + }); + concierge.onDispatch((event) => { + events.push(event); + }); + const catalog = concierge.resolveCatalog(CONTEXT); + await concierge.dispatch(CONTEXT, { + name: "ping", + input: {}, + catalogRevision: catalog.revision, + }); + await flush(); + const executing = lastEvent(events, "ping", "executing"); + const succeeded = lastEvent(events, "ping", "succeeded"); + expect(executing?.dispatchId).toBe("host-a-1"); + expect(succeeded?.dispatchId).toBe("host-a-1"); + expect(executing?.timing.elapsedMs).toBe(0); + expect(executing?.timing.handlerMs).toBe(0); + expect(succeeded?.timing.elapsedMs).toBe(40); + expect(succeeded?.timing.handlerMs).toBe(40); + expect(concierge.instanceId).toBe("host-a"); + }); + + it("rejects an invalid instanceId", () => { + expect(() => + createConcierge({ + instanceId: "has space", + stages: [], + }), + ).toThrow(/instanceId/); + }); + + it("drops a result sentence when redactMessage is drop", async () => { + const events: DispatchEvent[] = []; + const ping = defineAction({ + name: "ping", + description: "Ping.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + redactMessage: "drop", + handler: async () => ({ ok: true, message: "secret-token" }), + }); + const concierge = createConcierge({ + stages: [{ id: "root", match: () => true, actions: [ping] }], + }); + concierge.onDispatch((event) => { + events.push(event); + }); + const catalog = concierge.resolveCatalog(CONTEXT); + await concierge.dispatch(CONTEXT, { + name: "ping", + input: {}, + catalogRevision: catalog.revision, + }); + await flush(); + const succeeded = lastEvent(events, "ping", "succeeded"); + expect( + succeeded && "result" in succeeded ? succeeded.result.message : undefined, + ).toEqual({ kind: "dropped" } satisfies ObservedMessage); + }); + + it("passthrough includes a sanitized handler message", async () => { + const events: DispatchEvent[] = []; + const ping = defineAction({ + name: "ping", + description: "Ping.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + redactMessage: "passthrough", + handler: async () => ({ ok: true, message: "Pong.\nNext" }), + }); + const concierge = createConcierge({ + stages: [{ id: "root", match: () => true, actions: [ping] }], + }); + concierge.onDispatch((event) => { + events.push(event); + }); + const catalog = concierge.resolveCatalog(CONTEXT); + await concierge.dispatch(CONTEXT, { + name: "ping", + input: {}, + catalogRevision: catalog.revision, + }); + await flush(); + const succeeded = lastEvent(events, "ping", "succeeded"); + expect( + succeeded && "result" in succeeded ? succeeded.result.message : undefined, + ).toEqual({ + kind: "included", + value: "Pong. Next", + }); + }); +}); diff --git a/packages/concierge/test/export-surface.test.ts b/packages/concierge/test/export-surface.test.ts index 9f3f9b8..a7c8b16 100644 --- a/packages/concierge/test/export-surface.test.ts +++ b/packages/concierge/test/export-surface.test.ts @@ -29,21 +29,12 @@ // parsed export list and nothing else. // // --------------------------------------------------------------------------- -// Trap 2 — `ReadbackAttestation` is recorded here and deliberately NOT -// asserted +// Trap 2 — `ReadbackAttestation` is a real exported type // --------------------------------------------------------------------------- // -// `ReadbackAttestation` has ZERO occurrences in `types.ts`. The identifier does -// not exist anywhere in this package. A guard asserting that it is not exported -// therefore passes vacuously, forever, no matter what the artifact contains — -// and it reads in a diff and in a test report exactly like coverage. -// `02-VALIDATION.md` names this explicitly: it must not be counted as a passing -// check. -// -// So it is written down here instead of being written as an assertion. The two -// real names above are asserted; this third one is not, because there is -// nothing for it to prove. If a future phase introduces a type by that name, -// this comment is the place that says why the guard was missing. +// Contract v4 exports `ReadbackAttestation`. The v3 comment that claimed it +// had zero occurrences in `types.ts` is no longer true; do not revive a +// "must not be exported" assertion for it. import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -124,6 +115,15 @@ const VALUE_EXPORTS = [ "createBridge", "captureSnapshot", "offPageResult", + "awaitRegistration", + "isReasonCode", + "sanitizeText", + "makeReadbackReceipt", + "createTurnLedger", + "createRenditionBinder", + "resolveValue", + "renderCatalogPrompt", + "catalogDerivedPolicy", ]; const CONSENT_TYPE_EXPORTS = [ @@ -145,15 +145,15 @@ beforeAll(() => { }); describe("the published export surface of dist/index.d.ts", () => { - it("is exactly 95 names — an export added or dropped by a build-config change lands here", () => { + it("is exactly 137 names — an export added or dropped by a build-config change lands here", () => { const { names } = readSurface(); - expect(names).toHaveLength(95); + expect(names).toHaveLength(137); }); - it("splits 79 types to 16 values", () => { + it("splits 112 types to 25 values", () => { const { types, values } = readSurface(); - expect(types).toHaveLength(79); - expect(values).toHaveLength(16); + expect(types).toHaveLength(112); + expect(values).toHaveLength(25); }); it("carries all six consent evidence and outcome types by name", () => { @@ -163,7 +163,7 @@ describe("the published export surface of dist/index.d.ts", () => { } }); - it("carries all sixteen runtime value exports by name", () => { + it("carries all twenty-five runtime value exports by name", () => { const { values } = readSurface(); for (const name of VALUE_EXPORTS) { expect(values).toContain(name); diff --git a/packages/concierge/test/fixtures/probe.ts b/packages/concierge/test/fixtures/probe.ts index 3728683..58e4db9 100644 --- a/packages/concierge/test/fixtures/probe.ts +++ b/packages/concierge/test/fixtures/probe.ts @@ -104,6 +104,7 @@ import type { TransportStatus, TurnIdentityProvenance, } from "@full-self-browsing/concierge"; +import { createTestClock } from "@full-self-browsing/concierge/testing"; import { createOpenAIRealtimeCodec } from "@full-self-browsing/concierge/openai-realtime"; import type { OpenAIRealtimeCodec } from "@full-self-browsing/concierge/openai-realtime"; import { @@ -135,9 +136,10 @@ export const richData: ActionData = Object.freeze({ export const n: 180 = MESSAGE_MAX_CHARS; // the literal type survived into the shipped .d.ts /** Same guard for the contract version, which 02-06 left unannotated in source. */ -export const v: 3 = CONTRACT_VERSION; +export const v: 4 = CONTRACT_VERSION; export const maxActionDataBytes: 262144 = DEFAULT_ACTION_DATA_MAX_BYTES; export const realtimeCodec: OpenAIRealtimeCodec = createOpenAIRealtimeCodec(); +export const testClock: () => number = createTestClock(0).now; /** * A value import of the one function the package actually executes. Annotating @@ -202,6 +204,7 @@ export const foreignDigest: DigestLike = { }; export const foreignReadbackAttestation: ReadbackAttestation = Object.freeze({ act: "confirmed", + actId: "act-probe", userTurnId: "turn-human", readbackHash: "hash", }); @@ -285,6 +288,7 @@ export const foreignTransport: Transport = { userTurnIdentity: "none", parallelCalls: false, dynamicCatalog: true, + acknowledgesCatalog: false, }), status: foreignStatus, setCatalog: (_catalog) => {}, @@ -294,6 +298,7 @@ export const foreignTransport: Transport = { /** A fully structural Concierge, again checked only through shipped types. */ export const foreignConcierge: Concierge = { + instanceId: "probe", dispatch: (_context, _request) => Promise.resolve({ ok: true, message: "ok" }), dispatchBatch: (_context, _batch) => Promise.resolve(Object.freeze({ @@ -313,6 +318,7 @@ export const foreignConcierge: Concierge = { actions: Object.freeze([]), catalog: Object.freeze([]), }), + attestReadback: () => "unknown_readback", }; /** The browser-only telemetry subpath is present and fully typed in the pack. */ diff --git a/packages/concierge/test/fixtures/stub-transport.ts b/packages/concierge/test/fixtures/stub-transport.ts index 6bf7f24..aa128f6 100644 --- a/packages/concierge/test/fixtures/stub-transport.ts +++ b/packages/concierge/test/fixtures/stub-transport.ts @@ -107,6 +107,7 @@ export const CONVERSATIONAL_CAPABILITIES: TransportCapabilities = Object.freeze( userTurnIdentity: "agent-forgeable", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, }); export const COMMAND_PALETTE_CAPABILITIES: TransportCapabilities = Object.freeze({ @@ -114,6 +115,7 @@ export const COMMAND_PALETTE_CAPABILITIES: TransportCapabilities = Object.freeze userTurnIdentity: "human-attested", parallelCalls: false, dynamicCatalog: false, + acknowledgesCatalog: false, }); type StatusSubscriber = (status: TransportStatus) => void; @@ -224,6 +226,7 @@ function snapshotDeliveryReport(report: DeliveryReport): DeliveryReport { const readbackHash: string | undefined = typeof rawReadbackHash === "string" ? rawReadbackHash : undefined; const rawAct: unknown = readOwnDataProperty(rawAttestation, "act"); + const rawActId: unknown = readOwnDataProperty(rawAttestation, "actId"); const rawUserTurnId: unknown = readOwnDataProperty( rawAttestation, "userTurnId", @@ -236,12 +239,15 @@ function snapshotDeliveryReport(report: DeliveryReport): DeliveryReport { (rawAct !== "confirmed" && rawAct !== "declined" && rawAct !== "dismissed") || - typeof rawUserTurnId !== "string" || + typeof rawActId !== "string" || typeof rawAttestationHash !== "string" ? undefined : Object.freeze({ act: rawAct, - userTurnId: rawUserTurnId, + actId: rawActId, + ...(typeof rawUserTurnId === "string" + ? { userTurnId: rawUserTurnId } + : {}), readbackHash: rawAttestationHash, }); diff --git a/packages/concierge/test/fixtures/v2-session.ts b/packages/concierge/test/fixtures/v2-session.ts index ad994b0..fa1d867 100644 --- a/packages/concierge/test/fixtures/v2-session.ts +++ b/packages/concierge/test/fixtures/v2-session.ts @@ -37,15 +37,18 @@ export function transportHarness(overrides = {}) { let status = overrides.status ?? "connected"; let batchHandler; const statusHandlers = new Set(); + const ackHandlers = new Set(); const publications = []; let batchUnsubscribes = 0; let statusUnsubscribes = 0; + let ackUnsubscribes = 0; const transport = { capabilities: Object.freeze({ consentGrade: "none", userTurnIdentity: "none", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, ...overrides.capabilities, }), get status() { @@ -69,6 +72,13 @@ export function transportHarness(overrides = {}) { if (batchHandler === handler) batchHandler = undefined; }; }, + onCatalogAcknowledged(handler) { + ackHandlers.add(handler); + return () => { + ackUnsubscribes += 1; + ackHandlers.delete(handler); + }; + }, }; return { @@ -80,6 +90,9 @@ export function transportHarness(overrides = {}) { get statusUnsubscribes() { return statusUnsubscribes; }, + get ackUnsubscribes() { + return ackUnsubscribes; + }, dispatch(batch) { if (!batchHandler) throw new Error("No batch handler is registered."); return batchHandler(batch); @@ -88,6 +101,19 @@ export function transportHarness(overrides = {}) { status = next; for (const handler of [...statusHandlers]) handler(next); }, + acknowledge(ack) { + for (const handler of [...ackHandlers]) handler(ack); + }, + // The same registration `transport.onCatalogAcknowledged` performs, + // reachable without going through the transport. Lets a test replace the + // subscriber with a `this`-reading method and still wire up the harness. + subscribeAck(handler) { + ackHandlers.add(handler); + return () => { + ackUnsubscribes += 1; + ackHandlers.delete(handler); + }; + }, }; } diff --git a/packages/concierge/test/message.test.ts b/packages/concierge/test/message.test.ts new file mode 100644 index 0000000..a02be6d Binary files /dev/null and b/packages/concierge/test/message.test.ts differ diff --git a/packages/concierge/test/readback-canonicalization.test.ts b/packages/concierge/test/readback-canonicalization.test.ts index a8890d0..533fbee 100644 --- a/packages/concierge/test/readback-canonicalization.test.ts +++ b/packages/concierge/test/readback-canonicalization.test.ts @@ -104,6 +104,15 @@ function createDigest({ mutateInput = false, transform } = {}) { }; } +function createSnapshotBridge(snapshot) { + const mounted = { actions: {}, snapshot }; + return Object.freeze({ + id: "canonicalization-test-bridge", + read: () => mounted, + register: () => () => {}, + }); +} + function createFlow({ digest = createDigest(), output, @@ -117,8 +126,20 @@ function createFlow({ { id: "active", match: (ctx) => ctx.pathname === ACTIVE_CONTEXT.pathname, + bridge: createSnapshotBridge({ token: () => "stable" }), actions: [ - action("review", schema(output), (ctx) => { + action("review", schema(output), async (ctx) => { + const proposed = await ctx.review.propose(ctx.args); + if (!proposed.ok) { + if (proposed.reason === "payload_unsupported") { + return { + ok: false, + reason: "invalid_args", + message: "The review payload could not be proposed.", + }; + } + return { ok: true, message: "Reviewed." }; + } reviewEntries.push(ctx); return { ok: true, message: "Reviewed." }; }), @@ -178,18 +199,13 @@ async function flushMicrotasks() { } async function completeAttestedDelivery(flow, hash, marker) { - expect(flow.deliveryCallbacks, marker).toHaveLength(1); - flow.deliveryCallbacks[0]({ - responseId: "review-response", - outcome: "completed", + const outcome = flow.concierge.attestReadback({ + act: "confirmed", + actId: "act-canonical", readbackHash: hash, - attestation: { - act: "confirmed", - userTurnId: "confirm-turn", - readbackHash: hash, - }, + userTurnId: "confirm-turn", }); - await flushMicrotasks(); + expect(outcome, marker).toBe("accepted"); } async function expectCanonicalRelease(payload, canonicalText, marker) { @@ -212,7 +228,7 @@ async function expectCanonicalRelease(payload, canonicalText, marker) { readbackHash: hashBytes(canonical), }); expect(readbacks).toHaveLength(1); - expect(flow.digest.calls).toHaveLength(2); + expect(flow.digest.calls).toHaveLength(1); for (const call of flow.digest.calls) { expect(call.algorithm).toBe("SHA-256"); expect(call.bytes).toEqual(canonical); @@ -554,9 +570,8 @@ describe("receipt verification retains core-owned bytes and distrusts every clai expect(await flow.review()).toMatchObject({ ok: true }); await completeAttestedDelivery(flow, hashBytes(canonical)); expect(await flow.confirm()).toMatchObject({ ok: true }); - expect(digest.calls).toHaveLength(2); + expect(digest.calls).toHaveLength(1); expect(digest.calls[0].bytes).toEqual(canonical); - expect(digest.calls[1].bytes).toEqual(canonical); }); it("J14 — rejects accessor-backed and exotic receipt claims without execution", async () => { @@ -663,7 +678,7 @@ describe("receipt verification retains core-owned bytes and distrusts every clai await completeAttestedDelivery(flow, hashBytes(canonical)); expect(await flow.confirm()).toMatchObject({ ok: true }); expect(methodReads).toBe(1); - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(1); }); it("J17 — accepts non-enumerable data claims but closes on optional accessors", async () => { @@ -685,18 +700,11 @@ describe("receipt verification retains core-owned bytes and distrusts every clai const attestation = {}; Object.defineProperties(attestation, { act: { value: "confirmed" }, + actId: { value: "act-canonical-j17" }, readbackHash: { value: hash }, userTurnId: { value: "confirm-turn" }, }); - const report = {}; - Object.defineProperties(report, { - attestation: { value: attestation }, - outcome: { value: "completed" }, - readbackHash: { value: hash }, - responseId: { value: "review-response" }, - }); - flow.deliveryCallbacks[0](report); - await flushMicrotasks(); + expect(flow.concierge.attestReadback(attestation)).toBe("accepted"); expect(await flow.confirm()).toMatchObject({ ok: true }); for (const authorityField of ["readbackHash", "attestation"]) { diff --git a/packages/concierge/test/rendition.test.ts b/packages/concierge/test/rendition.test.ts new file mode 100644 index 0000000..a6d2776 --- /dev/null +++ b/packages/concierge/test/rendition.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { createRenditionBinder } from "../src/rendition.js"; +import type { DeliveryReport } from "../src/types.js"; + +describe("createRenditionBinder", () => { + it("reports the cause id, not the voicing rendition", () => { + const reports: DeliveryReport[] = []; + const binder = createRenditionBinder(); + binder.deferralsFor("N")((report) => { + reports.push(report); + }); + binder.bindRendition({ cause: "N", rendition: "N+1" }); + binder.renditionStarted("N+1"); + binder.settle("N+1", { outcome: "completed" }); + expect(reports).toHaveLength(1); + expect(reports[0]?.responseId).toBe("N"); + expect(reports[0]?.outcome).toBe("completed"); + expect(binder.pendingCauses()).toEqual([]); + }); + + it("fails closed under explicit evidence when generation ends without start", () => { + const reports: DeliveryReport[] = []; + const binder = createRenditionBinder({ renditionEvidence: "explicit" }); + binder.deferralsFor("cause")((report) => { + reports.push(report); + }); + binder.bindRendition({ cause: "cause", rendition: "voice" }); + binder.generationEnded("voice"); + expect(reports[0]?.outcome).toBe("interrupted"); + }); + + it("settles completed on generation end under generation-end evidence", () => { + const reports: DeliveryReport[] = []; + const binder = createRenditionBinder({ + renditionEvidence: "generation-end", + }); + binder.deferralsFor("cause")((report) => { + reports.push(report); + }); + binder.bindRendition({ cause: "cause", rendition: "voice" }); + binder.generationEnded("voice"); + expect(reports[0]?.outcome).toBe("completed"); + }); + + it("invokes a late deferral immediately as interrupted", () => { + const reports: DeliveryReport[] = []; + const issues: string[] = []; + const binder = createRenditionBinder({ + onIssue: (issue) => { + issues.push(issue.code); + }, + }); + binder.deferralsFor("cause")(() => undefined); + binder.bindRendition({ cause: "cause", rendition: "voice" }); + binder.settle("voice", { outcome: "completed" }); + binder.deferralsFor("cause")((report) => { + reports.push(report); + }); + expect(reports[0]?.outcome).toBe("interrupted"); + expect(issues).toContain("late_deferral"); + }); + + it("contains a throwing effect and still runs the rest", () => { + const seen: string[] = []; + const issues: string[] = []; + const binder = createRenditionBinder({ + onIssue: (issue) => { + issues.push(issue.code); + }, + }); + binder.deferralsFor("cause")(() => { + throw new Error("boom"); + }); + binder.deferralsFor("cause")(() => { + seen.push("second"); + }); + binder.bindRendition({ cause: "cause", rendition: "voice" }); + binder.settle("voice", { outcome: "completed" }); + expect(seen).toEqual(["second"]); + expect(issues).toContain("effect_threw"); + }); + + it("evicts the oldest pending cause when over capacity", () => { + const reports: DeliveryReport[] = []; + const issues: string[] = []; + const binder = createRenditionBinder({ + maxPendingCauses: 1, + onIssue: (issue) => { + issues.push(issue.code); + }, + }); + binder.deferralsFor("old")((report) => { + reports.push(report); + }); + binder.deferralsFor("new")(() => undefined); + expect(reports[0]?.responseId).toBe("old"); + expect(reports[0]?.outcome).toBe("interrupted"); + expect(issues).toContain("capacity_evicted"); + expect(binder.pendingCauses()).toEqual(["new"]); + }); +}); + +describe("settlement bookkeeping", () => { + it("distinguishes a second settlement from one that never had a cause", () => { + const issues = []; + const binder = createRenditionBinder({ onIssue: (issue) => issues.push(issue) }); + const reports = []; + binder.deferralsFor("cause-1")((report) => reports.push(report)); + binder.bindRendition({ cause: "cause-1", rendition: "voice-1" }); + + binder.settle("voice-1", { outcome: "completed" }); + expect(reports).toHaveLength(1); + expect(issues).toEqual([]); + + // The bindings are gone now, so the shape is identical to a rendition + // that never had a cause — but the code must say which one it is. + binder.settle("voice-1", { outcome: "completed" }); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("duplicate_settlement"); + + binder.settle("never-bound", { outcome: "completed" }); + expect(issues).toHaveLength(2); + expect(issues[1].code).toBe("unbound_rendition"); + + expect(reports).toHaveLength(1); + }); +}); + +describe("bounded started bookkeeping", () => { + it("forgets the oldest started rendition past the cap", () => { + // `settle` only dropped a rendition from `started` once it had a bound + // cause, so every playback with nothing deferred against it left its id + // behind for the life of the binder. The cap is what stops that; this + // asserts the cap exists by reaching past it. + const reports: DeliveryReport[] = []; + const binder = createRenditionBinder({ onIssue: () => {} }); + binder.deferralsFor("cause-0")((report) => { + reports.push(report); + }); + binder.bindRendition({ cause: "cause-0", rendition: "rendition-0" }); + binder.renditionStarted("rendition-0"); + + for (let index = 1; index <= 512; index += 1) { + binder.renditionStarted(`filler-${index}`); + } + + // Evicted from `started`, so it now reads as never started. + binder.abandonUnstarted(); + expect(reports).toEqual([ + expect.objectContaining({ responseId: "cause-0", outcome: "interrupted" }), + ]); + }); + + it("keeps a started rendition live well inside the cap", () => { + const reports: DeliveryReport[] = []; + const binder = createRenditionBinder({ onIssue: () => {} }); + binder.deferralsFor("cause-0")((report) => { + reports.push(report); + }); + binder.bindRendition({ cause: "cause-0", rendition: "rendition-0" }); + binder.renditionStarted("rendition-0"); + + for (let index = 1; index <= 100; index += 1) { + binder.renditionStarted(`filler-${index}`); + } + + binder.abandonUnstarted(); + expect(reports).toEqual([]); + }); +}); diff --git a/packages/concierge/test/resolve-value.test.ts b/packages/concierge/test/resolve-value.test.ts new file mode 100644 index 0000000..9848852 --- /dev/null +++ b/packages/concierge/test/resolve-value.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { resolveValue } from "../src/resolve-value.js"; + +interface Item { + readonly id: string; + readonly label: string; +} + +describe("resolveValue", () => { + it("matches GitHub-style and Linear-style labels under the default config", () => { + const items: readonly Item[] = [ + { id: "1", label: "org/repo" }, + { id: "2", label: "PROJ:123" }, + ]; + const config = { getLabel: (item: Item) => item.label }; + expect(resolveValue("org/repo", items, config).ok).toBe(true); + expect(resolveValue("PROJ:123", items, config).ok).toBe(true); + }); + + it("returns the list item by identity, never a constructed stand-in", () => { + const alpha: Item = { id: "a", label: "Alpha" }; + const items: readonly Item[] = [alpha]; + const result = resolveValue("alpha", items, { + getLabel: (item) => item.label, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(Object.is(result.match, alpha)).toBe(true); + } + }); + + it("rejects a caller allowlist that forbids ':'", () => { + const result = resolveValue("PROJ:123", [{ id: "1", label: "PROJ:123" }], { + getLabel: (item) => item.label, + allowed: (raw) => /^[\p{L}\p{N}\s,.'&\-]+$/u.test(raw), + }); + expect(result).toEqual({ ok: false, reason: "rejected" }); + }); + + it("returns ambiguous for a unique-substring collision", () => { + const items: readonly Item[] = [ + { id: "1", label: "Four Seasons Archive Foo" }, + { id: "2", label: "Four Seasons Archive Bar" }, + ]; + const result = resolveValue("four seasons", items, { + getLabel: (item) => item.label, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("ambiguous"); + expect(result.candidates).toHaveLength(2); + } + }); + + it("matches a unique substring", () => { + const archive: Item = { id: "1", label: "Four Seasons Archive Foo" }; + const result = resolveValue("four seasons", [archive], { + getLabel: (item) => item.label, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(Object.is(result.match, archive)).toBe(true); + } + }); + + it("treats two exact labels with different identities as ambiguous", () => { + const result = resolveValue( + "Inbox", + [ + { id: "a", label: "Inbox" }, + { id: "b", label: "Inbox" }, + ], + { + getLabel: (item) => item.label, + getIdentity: (item) => item.id, + }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("ambiguous"); + } + }); + + it("returns no-match for an empty candidate list", () => { + expect( + resolveValue("alpha", [], { getLabel: (item: Item) => item.label }), + ).toEqual({ ok: false, reason: "no-match" }); + }); + + it("skips a throwing getLabel and does not leak the exception", () => { + const result = resolveValue( + "alpha", + [{ id: "1", label: "Alpha" }], + { + getLabel: (): string => { + throw new Error("boom"); + }, + }, + ); + expect(result).toEqual({ ok: false, reason: "no-match" }); + }); +}); + +describe("same-label candidates", () => { + const config = { + getLabel: (item: { label: string; id: string }) => item.label, + getIdentity: (item: { label: string; id: string }) => item.id, + }; + + it("refuses when two distinct items share the queried label", () => { + const a = { label: "Deluxe King", id: "room-a" }; + const b = { label: "Deluxe King", id: "room-b" }; + const result = resolveValue("deluxe king", [a, b], config); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("ambiguous"); + expect(result.candidates).toEqual([a, b]); + } + }); + + it("matches when both rows carry one identity", () => { + const a = { label: "Deluxe King", id: "room-a" }; + const b = { label: "Deluxe King", id: "room-a" }; + const result = resolveValue("deluxe king", [a, b], config); + + expect(result).toEqual({ ok: true, match: a }); + }); + + it("matches the only row carrying the queried label", () => { + const a = { label: "Deluxe King", id: "room-a" }; + const b = { label: "Standard Twin", id: "room-b" }; + const result = resolveValue("deluxe king", [a, b], config); + + expect(result).toEqual({ ok: true, match: a }); + }); +}); diff --git a/packages/concierge/test/session-acknowledgement.test.ts b/packages/concierge/test/session-acknowledgement.test.ts new file mode 100644 index 0000000..fa2b571 --- /dev/null +++ b/packages/concierge/test/session-acknowledgement.test.ts @@ -0,0 +1,288 @@ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { beforeAll, beforeEach, expect, it } from "vitest"; + +import { action, conciergeFor, transportHarness } from "./fixtures/v2-session.js"; + +const DIST_URL = new URL("../dist/index.js", import.meta.url); +const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); +const ACTIVE = Object.freeze({ page: "active" }); + +let createConcierge; +let createSession; + +beforeAll(async () => { + if (!existsSync(fileURLToPath(DIST_URL))) { + throw new Error("Build concierge before testing."); + } + ({ createConcierge, createSession } = await import(DIST_URL.href)); +}); + +beforeEach(() => { + delete globalThis[CONTRACT_KEY]; +}); + +it("throws at construction when a v3 transport omits acknowledgesCatalog", () => { + const concierge = conciergeFor(createConcierge, [ + action("run", () => ({ ok: true, message: "Done." })), + ]); + const harness = transportHarness(); + const capabilities = { + consentGrade: "none", + userTurnIdentity: "none", + parallelCalls: true, + dynamicCatalog: true, + }; + Object.defineProperty(harness.transport, "capabilities", { + value: capabilities, + enumerable: true, + }); + + expect(() => + createSession({ + concierge, + transport: harness.transport, + presentOutcome: async () => ({ outcome: "completed" }), + }), + ).toThrow("The session could not start."); +}); + +it("throws when acknowledgesCatalog is true without onCatalogAcknowledged", () => { + const concierge = conciergeFor(createConcierge, [ + action("run", () => ({ ok: true, message: "Done." })), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + delete harness.transport.onCatalogAcknowledged; + + expect(() => + createSession({ + concierge, + transport: harness.transport, + presentOutcome: async () => ({ outcome: "completed" }), + }), + ).toThrow("The session could not start."); +}); + +it("defers catalog promotion until the transport acknowledges the revision", async () => { + const concierge = conciergeFor(createConcierge, [ + action("run", () => ({ ok: true, message: "Done." })), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + const session = createSession({ + concierge, + transport: harness.transport, + initialContext: ACTIVE, + presentOutcome: async () => ({ outcome: "completed" }), + }); + + expect(session.catalog()).toBeNull(); + expect(harness.publications).toHaveLength(1); + const published = harness.publications[0]; + harness.acknowledge({ revision: published.revision, accepted: true }); + expect(session.catalog()).toBe(published); + await session.stop(); +}); + +it("keeps the last acknowledged catalog when a later publication is rejected", async () => { + const diagnostics = []; + let enabled = true; + const concierge = conciergeFor(createConcierge, [ + action("conditional", () => ({ ok: true, message: "Done." }), { + availableWhen: () => enabled, + }), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + const session = createSession({ + concierge, + transport: harness.transport, + initialContext: ACTIVE, + presentOutcome: async () => ({ outcome: "completed" }), + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + const first = harness.publications[0]; + harness.acknowledge({ revision: first.revision, accepted: true }); + expect(session.catalog()).toBe(first); + + enabled = false; + session.setContext(ACTIVE); + expect(session.catalog()).toBe(first); + const second = harness.publications[1]; + harness.acknowledge({ revision: second.revision, accepted: false }); + expect(session.catalog()).toBe(first); + expect(diagnostics.map((row) => row.code)).toContain( + "catalog_acknowledgement_failed", + ); + await session.stop(); +}); + +it("ignores an acknowledgement for a revision that was never published", async () => { + const diagnostics = []; + const concierge = conciergeFor(createConcierge, [ + action("run", () => ({ ok: true, message: "Done." })), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + const session = createSession({ + concierge, + transport: harness.transport, + initialContext: ACTIVE, + presentOutcome: async () => ({ outcome: "completed" }), + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + harness.acknowledge({ + revision: Symbol("never-published"), + accepted: true, + }); + expect(session.catalog()).toBeNull(); + expect(diagnostics.map((row) => row.code)).toContain( + "catalog_acknowledgement_failed", + ); + await session.stop(); +}); + +it("ignores acknowledgements after stop and a second ack for one publication", async () => { + const diagnostics = []; + const concierge = conciergeFor(createConcierge, [ + action("run", () => ({ ok: true, message: "Done." })), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + const session = createSession({ + concierge, + transport: harness.transport, + initialContext: ACTIVE, + presentOutcome: async () => ({ outcome: "completed" }), + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + const published = harness.publications[0]; + harness.acknowledge({ revision: published.revision, accepted: true }); + expect(session.catalog()).toBe(published); + harness.acknowledge({ revision: published.revision, accepted: true }); + expect(session.catalog()).toBe(published); + await session.stop(); + harness.acknowledge({ revision: published.revision, accepted: true }); + expect(diagnostics).toEqual([]); + expect(harness.ackUnsubscribes).toBe(1); +}); + +it("subscribes through a method that reads its receiver", async () => { + const concierge = conciergeFor(createConcierge, [ + action("run", () => ({ ok: true, message: "Done." })), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + + // Core reads `onCatalogAcknowledged` off the transport before calling it, + // so the call has to put the receiver back. A transport whose subscriber is + // a method touching `this` — the shape `test/fixtures/v2-session.js` and + // `concierge-realtime` both use — must work exactly as `onStatusChange` and + // `onToolBatch` do. + let receiver; + Object.defineProperty(harness.transport, "onCatalogAcknowledged", { + configurable: true, + enumerable: true, + writable: true, + value: function onCatalogAcknowledged(handler) { + receiver = this; + if (this === undefined) throw new TypeError("called without a receiver"); + return this.__subscribeAck(handler); + }, + }); + Object.defineProperty(harness.transport, "__subscribeAck", { + configurable: true, + enumerable: false, + value: (handler) => harness.subscribeAck(handler), + }); + + const session = createSession({ + concierge, + transport: harness.transport, + initialContext: ACTIVE, + presentOutcome: async () => ({ outcome: "completed" }), + }); + + expect(receiver).toBe(harness.transport); + const published = harness.publications[0]; + harness.acknowledge({ revision: published.revision, accepted: true }); + expect(session.catalog()).toBe(published); + await session.stop(); +}); + +it("republishes the unacknowledged revision when the transport reconnects", async () => { + // `currentCatalog` is null until the first acknowledgement lands, so a gap + // that swallows the first publication used to leave nothing to re-send — + // and the session stayed catalogless for the rest of its life. + const concierge = conciergeFor(createConcierge, [ + action("run", () => ({ ok: true, message: "Done." })), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + const session = createSession({ + concierge, + transport: harness.transport, + initialContext: ACTIVE, + presentOutcome: async () => ({ outcome: "completed" }), + }); + expect(harness.publications).toHaveLength(1); + expect(session.catalog()).toBeNull(); + + harness.setStatus("disconnected"); + harness.setStatus("connected"); + + expect(harness.publications).toHaveLength(2); + const republished = harness.publications[1]; + expect(republished).toBe(harness.publications[0]); + harness.acknowledge({ revision: republished.revision, accepted: true }); + expect(session.catalog()).toBe(republished); + await session.stop(); +}); + +it("reconnects onto the pending revision rather than the promoted one", async () => { + // Re-sending the already-acknowledged revision invited an acknowledgement + // for it, which `handleAcknowledgement` measures against the pending head, + // fails, and reports — a spurious failure for a revision the transport had. + const diagnostics = []; + let enabled = true; + const concierge = conciergeFor(createConcierge, [ + action("conditional", () => ({ ok: true, message: "Done." }), { + availableWhen: () => enabled, + }), + ]); + const harness = transportHarness({ + capabilities: { acknowledgesCatalog: true }, + }); + const session = createSession({ + concierge, + transport: harness.transport, + initialContext: ACTIVE, + presentOutcome: async () => ({ outcome: "completed" }), + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + const first = harness.publications[0]; + harness.acknowledge({ revision: first.revision, accepted: true }); + + enabled = false; + session.setContext(ACTIVE); + const second = harness.publications[1]; + expect(second).not.toBe(first); + + harness.setStatus("disconnected"); + harness.setStatus("connected"); + + expect(harness.publications[2]).toBe(second); + harness.acknowledge({ revision: second.revision, accepted: true }); + expect(session.catalog()).toBe(second); + expect(diagnostics).toEqual([]); + await session.stop(); +}); diff --git a/packages/concierge/test/session-lifecycle.test.ts b/packages/concierge/test/session-lifecycle.test.ts index 40c1b3f..2e998f6 100644 --- a/packages/concierge/test/session-lifecycle.test.ts +++ b/packages/concierge/test/session-lifecycle.test.ts @@ -109,6 +109,7 @@ it("contains unsubscribe failures and resolves teardown", async () => { userTurnIdentity: "none", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, }), status: "connected", setCatalog() {}, @@ -152,6 +153,7 @@ it("rolls back partial construction when a subscription is malformed", () => { userTurnIdentity: "none", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, }), status: "connected", setCatalog() {}, @@ -185,6 +187,7 @@ it("rolls back the status subscription when batch registration throws", () => { userTurnIdentity: "none", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, }), status: "connected", setCatalog() {}, @@ -216,6 +219,7 @@ it("does not register batches after a malformed status subscription", () => { userTurnIdentity: "none", parallelCalls: true, dynamicCatalog: true, + acknowledgesCatalog: false, }), status: "connected", setCatalog() {}, diff --git a/packages/concierge/test/single-instance.test.ts b/packages/concierge/test/single-instance.test.ts index 97e06a2..d00f9e4 100644 --- a/packages/concierge/test/single-instance.test.ts +++ b/packages/concierge/test/single-instance.test.ts @@ -236,7 +236,7 @@ describe(SUITE_TITLE, () => { expect(uncalled).not.toContain(RENAMED_KEY_TEXT); }); - it("F2 — a legacy v1 record makes v3 throw with both versions and the remediation", async () => { + it("F2 — a legacy v1 record makes v4 throw with both versions and the remediation", async () => { // Exactly what a v0.1 source, tarball, or Git installation left behind. registry[KEY] = { version: 1 }; @@ -247,7 +247,7 @@ describe(SUITE_TITLE, () => { // versions but not the fix would satisfy the first while leaving the // developer with nothing to do. expect(() => assertSingleInstance()).toThrow(/two different copies/); - expect(() => assertSingleInstance()).toThrow(/contract v1 and v3/); + expect(() => assertSingleInstance()).toThrow(/contract v1 and v4/); expect(() => assertSingleInstance()).toThrow(/peerDependency/); }); @@ -387,6 +387,7 @@ describe(SUITE_TITLE, () => { userTurnIdentity: "none", parallelCalls: false, dynamicCatalog: true, + acknowledgesCatalog: false, }), status: "idle", setCatalog: () => {}, diff --git a/packages/concierge/test/telemetry/runtime.test.ts b/packages/concierge/test/telemetry/runtime.test.ts index ab2d5da..372cec8 100644 --- a/packages/concierge/test/telemetry/runtime.test.ts +++ b/packages/concierge/test/telemetry/runtime.test.ts @@ -51,6 +51,7 @@ function runtimeStub(): { const listeners = new Set(); const revision = Symbol("telemetry-test") as CatalogRevision; const concierge: Concierge = { + instanceId: "telemetry-test", dispatch: async () => ({ ok: true, message: "Done." }), dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ stage: null, tools: [], revision }), @@ -61,6 +62,7 @@ function runtimeStub(): { }; }, explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), + attestReadback: () => "unknown_readback", }; return { concierge, @@ -77,6 +79,12 @@ function runtimeStub(): { input: { kind: "included", value: { secret: "must-not-leak" } }, terminalAction: false, terminalEntered: false, + timing: { + clockMs: 0, + wallClockMs: 0, + elapsedMs: 0, + monotonic: false, + }, }; for (const listener of listeners) void listener(event); }, diff --git a/packages/concierge/test/testing-subpath.test.ts b/packages/concierge/test/testing-subpath.test.ts new file mode 100644 index 0000000..43d41c9 --- /dev/null +++ b/packages/concierge/test/testing-subpath.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineAction } from "../src/define-action.js"; +import { + createCompletedDelivery, + createTestClock, + createTestConcierge, + createTestDigest, + createTestReadbackSink, + createTestScheduler, + createStubTransport, +} from "../src/testing/index.js"; + +const emptySchema = z.object({}); + +describe("./testing helpers", () => { + it("advances a clock and scheduler together", () => { + const clock = createTestClock(100); + const scheduler = createTestScheduler(); + let fired = false; + scheduler(() => { + fired = true; + }, 25); + expect(clock.now()).toBe(100); + clock.advance(25); + scheduler.advance(25); + expect(clock.now()).toBe(125); + expect(fired).toBe(true); + }); + + it("builds a concierge that can dispatch against the test clock", async () => { + const clock = createTestClock(0); + const ping = defineAction({ + name: "ping", + description: "Ping.", + schema: emptySchema, + jsonSchema: { type: "object", properties: {} }, + redact: "drop", + handler: async () => ({ ok: true, message: "Pong." }), + }); + const concierge = createTestConcierge({ + clock: clock.now, + stages: [{ id: "root", match: () => true, actions: [ping] }], + }); + const catalog = concierge.resolveCatalog({}); + await expect( + concierge.dispatch( + {}, + { + name: "ping", + input: {}, + catalogRevision: catalog.revision, + }, + ), + ).resolves.toMatchObject({ ok: true }); + }); + + it("produces a completed delivery report and a digest", async () => { + const digest = createTestDigest(); + const hash = await digest.digest("SHA-256", new Uint8Array([1, 2, 3])); + expect(hash.byteLength).toBe(32); + expect(createCompletedDelivery("r1").outcome).toBe("completed"); + const sink = createTestReadbackSink(); + expect(typeof sink.present).toBe("function"); + const stub = createStubTransport({ acknowledgesCatalog: true }); + expect(stub.transport.capabilities.acknowledgesCatalog).toBe(true); + }); +}); diff --git a/packages/concierge/test/turn-ledger.test.ts b/packages/concierge/test/turn-ledger.test.ts new file mode 100644 index 0000000..952321f --- /dev/null +++ b/packages/concierge/test/turn-ledger.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { createTurnLedger } from "../src/turn-ledger.js"; + +describe("createTurnLedger", () => { + it("binds many responses to one turn and never mints an id", () => { + const ledger = createTurnLedger(); + expect(ledger.recordUserTurn({ + turnId: "turn-1", + provenance: "human-attested", + })?.turnId).toBe("turn-1"); + expect(ledger.openResponse("r1")?.turnId).toBe("turn-1"); + expect(ledger.openResponse("r2")?.turnId).toBe("turn-1"); + expect(ledger.turnFor("r1")?.turnId).toBe("turn-1"); + }); + + it("keeps the weaker provenance when a turn is re-recorded", () => { + const ledger = createTurnLedger(); + ledger.recordUserTurn({ turnId: "t", provenance: "agent-forgeable" }); + const again = ledger.recordUserTurn({ + turnId: "t", + provenance: "human-attested", + }); + expect(again?.provenance).toBe("agent-forgeable"); + }); + + it("clamps provenance to maxProvenance", () => { + const ledger = createTurnLedger({ maxProvenance: "agent-forgeable" }); + const turn = ledger.recordUserTurn({ + turnId: "speech", + provenance: "human-attested", + }); + expect(turn?.provenance).toBe("agent-forgeable"); + }); + + it("first-binding-wins and records a permanent null before any turn", () => { + const ledger = createTurnLedger(); + expect(ledger.openResponse("early")).toBeNull(); + ledger.recordUserTurn({ turnId: "later", provenance: "human-attested" }); + expect(ledger.turnFor("early")).toBeNull(); + expect(ledger.openResponse("early")).toBeNull(); + }); + + it("returns a later human-attested turn for attestation", () => { + const ledger = createTurnLedger(); + ledger.recordUserTurn({ turnId: "review", provenance: "agent-forgeable" }); + ledger.recordUserTurn({ turnId: "noise", provenance: "agent-forgeable" }); + ledger.recordUserTurn({ turnId: "confirm", provenance: "human-attested" }); + expect(ledger.attestationTurnAfter("review")?.turnId).toBe("confirm"); + expect(ledger.attestationTurnAfter("missing")).toBeNull(); + }); + + it("returns the first attested turn after the review, not the last", () => { + const ledger = createTurnLedger(); + ledger.recordUserTurn({ turnId: "review", provenance: "agent-forgeable" }); + ledger.recordUserTurn({ turnId: "confirm", provenance: "human-attested" }); + ledger.recordUserTurn({ turnId: "much-later", provenance: "human-attested" }); + // A confirmation arbitrarily far in the future must not stand in for the + // turn the person actually took in answer to this review. + expect(ledger.attestationTurnAfter("review")?.turnId).toBe("confirm"); + expect(ledger.attestationTurnAfter("confirm")?.turnId).toBe("much-later"); + }); + + it("reports no attestation when only weaker turns follow", () => { + const ledger = createTurnLedger(); + ledger.recordUserTurn({ turnId: "review", provenance: "human-attested" }); + ledger.recordUserTurn({ turnId: "after", provenance: "agent-forgeable" }); + expect(ledger.attestationTurnAfter("review")).toBeNull(); + }); + + it("rejects empty or overlong turn ids and hostile accessors", () => { + const ledger = createTurnLedger(); + expect(ledger.recordUserTurn({ turnId: "", provenance: "none" })).toBeNull(); + expect( + ledger.recordUserTurn({ + turnId: "x".repeat(1025), + provenance: "none", + }), + ).toBeNull(); + let reads = 0; + const hostile = { + get turnId() { + reads += 1; + return "hostile"; + }, + get provenance() { + reads += 1; + return "human-attested"; + }, + }; + expect(ledger.recordUserTurn(hostile as never)).toBeNull(); + expect(reads).toBe(0); + }); +}); diff --git a/packages/concierge/test/workflow-v2.test.ts b/packages/concierge/test/workflow-v2.test.ts index 3c7ddc2..8e8127a 100644 --- a/packages/concierge/test/workflow-v2.test.ts +++ b/packages/concierge/test/workflow-v2.test.ts @@ -242,7 +242,17 @@ it("does not let pre-parent consent authority flow into a gated child", async () let gatedCalls = 0; const delivery = []; const concierge = create([ - action("review", () => ({ ok: true, message: "Review." })), + action("review", async ({ args, review: controls }) => { + const proposed = await controls.propose(args ?? {}); + if (!proposed.ok) { + return { + ok: false, + reason: "precondition_failed", + message: "The review payload could not be proposed.", + }; + } + return { ok: true, message: "Review." }; + }), action("gated", () => { gatedCalls += 1; return { ok: true, message: "Gated." }; @@ -287,7 +297,17 @@ it("does not let a concurrent root review arm consent for a paused child", async let gatedCalls = 0; const delivery = []; const concierge = create([ - action("review", () => ({ ok: true, message: "Review." })), + action("review", async ({ args, review: controls }) => { + const proposed = await controls.propose(args ?? {}); + if (!proposed.ok) { + return { + ok: false, + reason: "precondition_failed", + message: "The review payload could not be proposed.", + }; + } + return { ok: true, message: "Review." }; + }), action("gated", () => { gatedCalls += 1; return { ok: true, message: "Gated." }; diff --git a/packages/concierge/tsconfig.testing.json b/packages/concierge/tsconfig.testing.json new file mode 100644 index 0000000..e5e2b8a --- /dev/null +++ b/packages/concierge/tsconfig.testing.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist/testing", + "lib": ["ES2022"], + "paths": { + "@full-self-browsing/concierge": ["./src/index.ts"] + } + }, + "include": ["src/testing/**/*.ts"] +} diff --git a/packages/concierge/tsdown.testing.config.ts b/packages/concierge/tsdown.testing.config.ts new file mode 100644 index 0000000..69ae257 --- /dev/null +++ b/packages/concierge/tsdown.testing.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/testing/index.ts"], + format: ["esm"], + platform: "neutral", + dts: true, + clean: false, + outDir: "dist/testing", + tsconfig: "tsconfig.testing.json", + deps: { + neverBundle: ["@full-self-browsing/concierge"], + }, + publint: false, + attw: false, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cded99..ef849af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,12 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/concierge-dom: + devDependencies: + '@full-self-browsing/concierge': + specifier: workspace:* + version: link:../concierge + packages/concierge-react: devDependencies: '@full-self-browsing/concierge': @@ -187,6 +193,12 @@ importers: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + packages/concierge-realtime: + devDependencies: + '@full-self-browsing/concierge': + specifier: workspace:* + version: link:../concierge + packages/concierge-svelte: devDependencies: '@full-self-browsing/concierge': diff --git a/scripts/pack-install-check.sh b/scripts/pack-install-check.sh index 1649369..ea6f797 100755 --- a/scripts/pack-install-check.sh +++ b/scripts/pack-install-check.sh @@ -72,7 +72,11 @@ for required in \ package/dist/telemetry/index.js \ package/dist/telemetry/index.d.ts \ package/dist/telemetry/index.js.map \ - package/dist/telemetry/index.d.ts.map + package/dist/telemetry/index.d.ts.map \ + package/dist/testing/index.js \ + package/dist/testing/index.d.ts \ + package/dist/testing/index.js.map \ + package/dist/testing/index.d.ts.map do if ! printf '%s\n' "$TAR_ENTRIES" | grep -Fxq "$required"; then echo "FAIL: packed browser telemetry subpath is missing $required" >&2 @@ -163,11 +167,15 @@ node --input-type=module -e ' throw new Error("runtime binding erased: createConcierge is " + typeof m.createConcierge); } if ( - m.CONTRACT_VERSION !== 3 || + m.CONTRACT_VERSION !== 4 || m.DEFAULT_ACTION_DATA_MAX_BYTES !== 262144 || typeof realtime.createOpenAIRealtimeCodec !== "function" ) { - throw new Error("contract v3 or OpenAI Realtime runtime export drifted"); + throw new Error("contract v4 or OpenAI Realtime runtime export drifted"); + } + const testing = await import("@full-self-browsing/concierge/testing"); + if (typeof testing.createTestConcierge !== "function") { + throw new Error("testing subpath runtime export drifted"); } const concierge = m.createConcierge({ stages: [] }); if (typeof concierge.dispatch !== "function") { diff --git a/scripts/pkg-dom-catalog-boundary.mjs b/scripts/pkg-dom-catalog-boundary.mjs new file mode 100644 index 0000000..d6a1cd6 --- /dev/null +++ b/scripts/pkg-dom-catalog-boundary.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +// scripts/pkg-dom-catalog-boundary.mjs +// +// The catalog-boundary gate for `@full-self-browsing/concierge-dom`. +// +// This package may only hand back elements the application registered. A +// host-DOM query or an actuation primitive in the built artifact would +// reopen the generic-browser door CONTRIBUTING forbids. The check reads +// `packages/concierge-dom/dist/index.js` — a source-only scan would miss a +// bundler rewrite that reintroduced a banned identifier. +// +// The two permitted page mutations are scroll position and the reserved +// reveal dataset key. Adding an identifier to this script's allow-set, or +// deleting a class from BANNED_CLASSES, requires a threat model in the +// same pull request. A false positive is not a reason to weaken the gate. +// +// Usage: +// node scripts/pkg-dom-catalog-boundary.mjs [artifact] +// node scripts/pkg-dom-catalog-boundary.mjs self-test +// +// Exits 0 when the artifact is clean (or every self-test mutation is red), +// 1 otherwise. + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_ARTIFACT = join(ROOT, "packages/concierge-dom/dist/index.js"); + +/** + * Banned identifier classes. Each class has one representative token used + * by the mutation self-test, and one or more patterns applied to the + * artifact. `matches` is banned as a *call* so MediaQueryList's boolean + * `.matches` property (reduced-motion) is not a false positive; Element's + * selector method is `matches(`. + */ +export const BANNED_CLASSES = Object.freeze([ + Object.freeze({ + id: "query", + representative: "querySelector", + patterns: Object.freeze([ + /\bquerySelector\b/u, + /\bquerySelectorAll\b/u, + ]), + }), + Object.freeze({ + id: "by-id", + representative: "getElementById", + patterns: Object.freeze([/\bgetElementById\b/u]), + }), + Object.freeze({ + id: "collections", + representative: "getElementsByTagName", + patterns: Object.freeze([/\bgetElementsBy[A-Za-z]+\b/u]), + }), + Object.freeze({ + id: "selector-walk", + representative: "closest(", + patterns: Object.freeze([/\bclosest\b/u, /\bmatches\s*\(/u]), + }), + Object.freeze({ + id: "hit-test", + representative: "elementFromPoint", + patterns: Object.freeze([ + /\belementFromPoint\b/u, + /\belementsFromPoint\b/u, + ]), + }), + Object.freeze({ + id: "construct", + representative: "createElement", + patterns: Object.freeze([/\bcreateElement\b/u]), + }), + Object.freeze({ + id: "events", + representative: "dispatchEvent", + patterns: Object.freeze([/\bdispatchEvent\b/u]), + }), + Object.freeze({ + id: "actuation", + representative: ".click(", + patterns: Object.freeze([ + /\.click\s*\(/u, + /\.focus\s*\(/u, + /\.blur\s*\(/u, + /\.submit\s*\(/u, + ]), + }), + Object.freeze({ + id: "html", + representative: "innerHTML", + patterns: Object.freeze([ + /\binnerHTML\b/u, + /\bouterHTML\b/u, + /\binsertAdjacent\w*/u, + ]), + }), + Object.freeze({ + id: "attributes", + representative: "setAttribute", + patterns: Object.freeze([/\bsetAttribute\b/u, /\bremoveAttribute\b/u]), + }), + Object.freeze({ + id: "navigation", + representative: "location", + patterns: Object.freeze([/\blocation\b/u, /\bhistory\b/u]), + }), + Object.freeze({ + id: "eval", + representative: "eval(", + patterns: Object.freeze([/\beval\s*\(/u, /\bFunction\s*\(/u]), + }), + Object.freeze({ + id: "exec", + representative: "execCommand", + patterns: Object.freeze([/\bexecCommand\b/u]), + }), + Object.freeze({ + id: "xpath", + representative: "evaluate(", + patterns: Object.freeze([/\bevaluate\s*\(/u]), + }), +]); + +export function findBannedIdentifiers(source) { + const findings = []; + for (const banned of BANNED_CLASSES) { + for (const pattern of banned.patterns) { + pattern.lastIndex = 0; + if (pattern.test(source)) { + findings.push({ + classId: banned.id, + representative: banned.representative, + pattern: String(pattern), + }); + break; + } + } + } + return findings; +} + +export function assertCatalogBoundary(source, label = "artifact") { + const findings = findBannedIdentifiers(source); + if (findings.length === 0) { + return findings; + } + const detail = findings + .map((finding) => `${finding.classId}:${finding.representative}`) + .join(", "); + throw new Error( + `${label} contains banned host-DOM identifiers (${detail}). ` + + `concierge-dom must not find or actuate elements. Adding an ` + + `identifier to this gate's allow-set requires a threat model.`, + ); +} + +function runCheck(artifactPath) { + if (!existsSync(artifactPath)) { + console.error( + `catalog-boundary: missing ${relative(ROOT, artifactPath)} — build @full-self-browsing/concierge-dom first`, + ); + process.exit(1); + } + const source = readFileSync(artifactPath, "utf8"); + try { + assertCatalogBoundary(source, relative(ROOT, artifactPath)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + console.log( + `catalog-boundary: ${relative(ROOT, artifactPath)} contains no banned host-DOM identifiers`, + ); +} + +function runSelfTest(artifactPath) { + if (!existsSync(artifactPath)) { + console.error( + `catalog-boundary self-test: missing ${relative(ROOT, artifactPath)} — build first`, + ); + process.exit(1); + } + const clean = readFileSync(artifactPath, "utf8"); + const cleanFindings = findBannedIdentifiers(clean); + if (cleanFindings.length > 0) { + console.error( + `catalog-boundary self-test: clean artifact already fails (${cleanFindings + .map((finding) => finding.classId) + .join(", ")})`, + ); + process.exit(1); + } + + let failed = false; + for (const banned of BANNED_CLASSES) { + const findings = findBannedIdentifiers(`${clean}\n${banned.representative}\n`); + const hit = findings.some((finding) => finding.classId === banned.id); + if (!hit) { + failed = true; + console.error( + `catalog-boundary self-test: class ${banned.id} stayed green after injecting ${banned.representative}`, + ); + } + } + if (failed) { + process.exit(1); + } + console.log( + `catalog-boundary self-test: ${BANNED_CLASSES.length} banned classes go red on injection`, + ); +} + +const invokedDirectly = + process.argv[1] !== undefined && + fileURLToPath(import.meta.url) === resolve(process.argv[1]); + +if (invokedDirectly) { + const command = process.argv[2]; + if (command === "self-test") { + runSelfTest(DEFAULT_ARTIFACT); + } else { + runCheck(command ? resolve(command) : DEFAULT_ARTIFACT); + } +} diff --git a/scripts/release/archive.mjs b/scripts/release/archive.mjs index 0fdd478..5006edd 100644 --- a/scripts/release/archive.mjs +++ b/scripts/release/archive.mjs @@ -254,7 +254,7 @@ export function validateArchiveDirectory(config, configuredDirectory) { index.distTag === config.distTag && index.packageSetSha256 === config.sha256 && Array.isArray(index.archives) && index.archives.length === config.packages.length, "ARCHIVE_INDEX", - `archive digest manifest is not for the configured ${config.releaseLine} trio`, + `archive digest manifest is not for the configured ${config.releaseLine} package set`, ); const expectedFiles = [ARCHIVE_MANIFEST_FILENAME]; const archives = config.packages.map((spec, index_) => { diff --git a/scripts/release/check.mjs b/scripts/release/check.mjs index 3b75122..50be752 100644 --- a/scripts/release/check.mjs +++ b/scripts/release/check.mjs @@ -119,6 +119,7 @@ function assertPackageManifest(config, spec, manifest, mode) { "./ai-sdk/browser", "./openai-realtime", "./telemetry", + "./testing", "./package.json", ]; assert( @@ -169,22 +170,27 @@ function checkChangesets(config) { ); } -function checkContractV3() { +function checkContractV4() { const core = readFileSync(join(ROOT, "packages/concierge/src/contract.ts"), "utf8"); assert( - /export const CONTRACT_VERSION = 3;/u.test(core), + /export const CONTRACT_VERSION = 4;/u.test(core), "CONTRACT_VERSION", - "core must publish contract v3", + "core must publish contract v4", ); + // Every package that peers on core, so a guard cannot be dropped from one of + // them without this failing. Realtime was absent while declaring the same + // guard, which left the only unchecked one in the set. for (const relativePath of [ "packages/concierge-react/src/client.tsx", "packages/concierge-svelte/src/client.svelte.ts", + "packages/concierge-dom/src/constants.ts", + "packages/concierge-realtime/src/session.ts", ]) { const source = readFileSync(join(ROOT, relativePath), "utf8"); assert( - /EXPECTED_CONTRACT_VERSION(?:\s*:\s*number)?\s*=\s*3/u.test(source), + /EXPECTED_(?:CORE_)?CONTRACT_VERSION(?:\s*:\s*(?:number|4))?\s*=\s*4/u.test(source), "CONTRACT_VERSION", - `${relativePath} must reject non-v3 core before registration`, + `${relativePath} must reject non-v4 core before registration`, ); } } @@ -205,7 +211,7 @@ function checkSource(config, mode) { .join(", ")}`, ); checkChangesets(config); - checkContractV3(); + checkContractV4(); return manifests[0].version; } @@ -235,12 +241,20 @@ function checkWorkflow(config) { "WORKFLOW_SURFACE", "live release workflow must not use historical tooling, npm tokens, or digest placeholders", ); + // **The release line digested here is the one `seal.mjs` actually ships.** + // `seal.mjs` copies `config.path` into the sealed bundle as + // `release-line.json`, and `config.path` is the live line. Digesting the + // retired 0.3 file instead made this gate and the publish launcher disagree + // about the same sealed name: the gate could pass while `publish` was + // guaranteed to throw `tracked tool digest drifted: release-line.json`. + // Entries are absolute, because `config.path` already is and `join` would + // concatenate rather than resolve it. for (const [file, sealedName] of [ - ["scripts/release/config.mjs", "config.mjs"], - ["scripts/release/publisher.mjs", "release-publisher.mjs"], - [".release/lines/0.3.json", "release-line.json"], + [join(ROOT, "scripts/release/config.mjs"), "config.mjs"], + [join(ROOT, "scripts/release/publisher.mjs"), "release-publisher.mjs"], + [config.path, "release-line.json"], ]) { - const digest = sha256File(join(ROOT, file)); + const digest = sha256File(file); assert( workflow.includes(`${JSON.stringify(sealedName)}: ${JSON.stringify(digest)}`), "WORKFLOW_DIGEST", diff --git a/scripts/release/compatibility.mjs b/scripts/release/compatibility.mjs index de51dd8..4063646 100644 --- a/scripts/release/compatibility.mjs +++ b/scripts/release/compatibility.mjs @@ -186,7 +186,7 @@ function runAdapterCell(root, inputs, aiVersion) { `const browser = await import("@full-self-browsing/concierge/ai-sdk/browser");\n` + `const realtime = await import("@full-self-browsing/concierge/openai-realtime");\n` + `const telemetry = await import("@full-self-browsing/concierge/telemetry");\n` + - `if (CONTRACT_VERSION !== 3 || EXPECTED_CORE_CONTRACT_VERSION !== 3 || SIGNED_ENVELOPE_VERSION !== 1) throw new Error("contract drift");\n` + + `if (CONTRACT_VERSION !== 4 || EXPECTED_CORE_CONTRACT_VERSION !== 4 || SIGNED_ENVELOPE_VERSION !== 1) throw new Error("contract drift");\n` + `if (typeof server.createSignedBatchIssuer !== "function" || typeof browser.createSignedBrowserBridge !== "function") throw new Error("subpath export drift");\n` + `if (typeof realtime.createOpenAIRealtimeCodec !== "function") throw new Error("Realtime subpath export drift");\n` + `if (JSON.stringify(Object.keys(telemetry).sort()) !== JSON.stringify(["getConciergeTelemetryStatus","mountConciergeTelemetry","onConciergeTelemetryStatusChange","setConciergeTelemetryEnabled"])) throw new Error("telemetry subpath export drift");\n` + @@ -230,6 +230,16 @@ function runFrameworkCell(root, inputs, cell) { archives["@full-self-browsing/concierge-react"], "@full-self-browsing/concierge-svelte": archives["@full-self-browsing/concierge-svelte"], + // dom and realtime ride this cell rather than getting one of their own. + // It already performs every check they need — installed-consumer + // resolution, one physical core, exact archive provenance, an ESM import + // with no DOM present, and a strict `skipLibCheck: false` declaration + // pass — and the matrix has a minimum and a current row, so both are + // certified against both framework generations. + "@full-self-browsing/concierge-dom": + archives["@full-self-browsing/concierge-dom"], + "@full-self-browsing/concierge-realtime": + archives["@full-self-browsing/concierge-realtime"], react: cell.react, "react-dom": cell.reactDom, svelte: cell.svelte, @@ -250,8 +260,20 @@ function runFrameworkCell(root, inputs, cell) { `const reactClient = await import("@full-self-browsing/concierge-react/client");\n` + `const svelteRoot = await import("@full-self-browsing/concierge-svelte");\n` + `const svelteClient = await import("@full-self-browsing/concierge-svelte/client.svelte");\n` + + // No DOM exists here. Both packages must still import: the dom contract + // guard fires on first registration rather than at module scope, and + // every realtime subpath reaches its transport lazily. A top-level + // `document` or `RTCPeerConnection` in either would fail right here, + // which is the server-render case core's no-DOM rule exists to protect. + `const dom = await import("@full-self-browsing/concierge-dom");\n` + + `const realtime = await import("@full-self-browsing/concierge-realtime");\n` + + `const realtimeOpenai = await import("@full-self-browsing/concierge-realtime/openai");\n` + + `const realtimeWebrtc = await import("@full-self-browsing/concierge-realtime/webrtc");\n` + + `const realtimeWebsocket = await import("@full-self-browsing/concierge-realtime/websocket");\n` + + `const anchors = dom.createAnchorRegistry({ id: "compatibility" });\n` + `const html = renderToString(createElement(reactClient.ConciergeProvider, { concierge: {} }, createElement("span", null, "ssr")));\n` + - `if (CONTRACT_VERSION !== 3 || html !== "ssr" || typeof reactRoot !== "object" || typeof svelteRoot !== "object" || typeof svelteClient.provideConcierge !== "function") throw new Error("framework ESM SSR import drift");\n` + + `if (CONTRACT_VERSION !== 4 || html !== "ssr" || typeof reactRoot !== "object" || typeof svelteRoot !== "object" || typeof svelteClient.provideConcierge !== "function") throw new Error("framework ESM SSR import drift");\n` + + `if (dom.EXPECTED_CORE_CONTRACT_VERSION !== CONTRACT_VERSION || typeof anchors.ref !== "function" || typeof realtime.createRealtimeSession !== "function" || typeof realtime.createRealtimeDeliveryLedger !== "function" || typeof realtimeOpenai !== "object" || typeof realtimeWebrtc !== "object" || typeof realtimeWebsocket !== "object") throw new Error("dom or realtime ESM import drift");\n` + `process.stdout.write(JSON.stringify({ react: ${JSON.stringify(cell.react)}, svelte: ${JSON.stringify(cell.svelte)}, html }) + "\\n");\n`, "utf8", ); @@ -264,6 +286,10 @@ function runFrameworkCell(root, inputs, cell) { `import { ConciergeProvider, useConcierge as useReactConcierge, useConciergeBridge as useReactBridge } from "@full-self-browsing/concierge-react/client";\n` + `import type { Concierge as SvelteConcierge } from "@full-self-browsing/concierge-svelte";\n` + `import { provideConcierge, useConcierge as useSvelteConcierge, useConciergeBridge as useSvelteBridge } from "@full-self-browsing/concierge-svelte/client.svelte";\n` + + `import type { AnchorRegistry, AnchorResolution, RevealOutcome, VisibilityReport } from "@full-self-browsing/concierge-dom";\n` + + `import { createAnchorRegistry, isRendered, preferredScrollBehavior } from "@full-self-browsing/concierge-dom";\n` + + `import type { RealtimeDeliveryLedger, RealtimeSessionHandle } from "@full-self-browsing/concierge-realtime";\n` + + `import { createRealtimeDeliveryLedger, createRealtimeSession } from "@full-self-browsing/concierge-realtime";\n` + `import { createElement } from "react";\n` + `import { renderToString } from "react-dom/server";\n` + `declare const concierge: Concierge & ReactConcierge & SvelteConcierge;\n` + @@ -274,7 +300,13 @@ function runFrameworkCell(root, inputs, cell) { `const element = createElement(ConciergeProvider, { concierge }, "typed");\n` + `const html: string = renderToString(element);\n` + `if (false) { const release: () => void = mountConciergeTelemetry(concierge); const pending: Promise = getConciergeTelemetryStatus(); const unlisten: () => void = onConciergeTelemetryStatusChange(() => {}); const enabled: Promise = setConciergeTelemetryEnabled(true); release(); unlisten(); void pending; void enabled; provideConcierge(concierge); useReactBridge(registry, bridge); useSvelteBridge(() => registry, () => bridge); }\n` + - `void reactGetter; void svelteGetter; void html;\n`, + `declare const report: VisibilityReport;\n` + + `const anchors: AnchorRegistry = createAnchorRegistry({ id: "compatibility" });\n` + + `const resolution: AnchorResolution = anchors.resolve("row");\n` + + `const behaviour: "auto" | "smooth" = preferredScrollBehavior();\n` + + `const ledger: RealtimeDeliveryLedger = createRealtimeDeliveryLedger({ deliveryEvidence: "buffer-drain" });\n` + + `if (false) { const revealed: Promise = anchors.reveal("row"); const opened: Promise = createRealtimeSession({} as never); const rendered: boolean = isRendered(report); void revealed; void opened; void rendered; ledger.revokeAll({}); }\n` + + `void reactGetter; void svelteGetter; void html; void resolution; void behaviour;\n`, "utf8", ); writeJson(join(directory, "tsconfig.json"), { @@ -294,7 +326,7 @@ function runFrameworkCell(root, inputs, cell) { install(directory, `framework ${cell.label}`); runTopologyProbe( directory, - [CORE, "@full-self-browsing/concierge-react", "@full-self-browsing/concierge-svelte"], + inputs.archives.map((archive) => archive.name), inputs.version, `framework ${cell.label}`, ); @@ -317,6 +349,15 @@ function copyExample(source, destination) { }); } +/** The Concierge packages `configureNextExample` injects, in publish order. */ +function nextExamplePackages() { + return [ + CORE, + "@full-self-browsing/concierge-react", + "@full-self-browsing/concierge-svelte", + ]; +} + function configureNextExample(directory, inputs, cell, buildOnly) { const manifestPath = join(directory, "package.json"); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); @@ -326,11 +367,12 @@ function configureNextExample(directory, inputs, cell, buildOnly) { manifest.dependencies = { ...manifest.dependencies, "@ai-sdk/react": cell.react, - [CORE]: archives[CORE], - "@full-self-browsing/concierge-react": - archives["@full-self-browsing/concierge-react"], - "@full-self-browsing/concierge-svelte": - archives["@full-self-browsing/concierge-svelte"], + // Derived from the same list the topology probe reads, so injecting a + // package here without probing it — or probing one that was never + // injected — is not expressible. + ...Object.fromEntries( + nextExamplePackages().map((name) => [name, archives[name]]), + ), "@openrouter/ai-sdk-provider": cell.openrouter, ai: cell.ai, svelte: FRAMEWORK_MATRIX.at(-1).svelte, @@ -341,9 +383,14 @@ function configureNextExample(directory, inputs, cell, buildOnly) { function installNextExample(directory, inputs, cell, buildOnly) { configureNextExample(directory, inputs, cell, buildOnly); install(directory, `Next ${cell.label}`); + // **The probe names what this example installs, not the whole release set.** + // Passing every archive name asked it to resolve `concierge-dom` and + // `concierge-realtime` out of a consumer that was never given them, so it + // threw "Cannot find module" before reaching a single real assertion. Those + // two are certified by the framework cell, which does install them. runTopologyProbe( directory, - inputs.archives.map((archive) => archive.name), + nextExamplePackages(), inputs.version, `Next ${cell.label}`, ); diff --git a/scripts/release/config.mjs b/scripts/release/config.mjs index c9cadec..f38d883 100644 --- a/scripts/release/config.mjs +++ b/scripts/release/config.mjs @@ -4,14 +4,16 @@ import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:pa import { fileURLToPath } from "node:url"; export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -export const DEFAULT_RELEASE_LINE_PATH = join(ROOT, ".release/lines/0.3.json"); +export const DEFAULT_RELEASE_LINE_PATH = join(ROOT, ".release/lines/0.4.json"); const PACKAGE_NAMES = Object.freeze([ "@full-self-browsing/concierge", "@full-self-browsing/concierge-react", "@full-self-browsing/concierge-svelte", + "@full-self-browsing/concierge-dom", + "@full-self-browsing/concierge-realtime", ]); -const PACKAGE_ROLES = Object.freeze(["core", "react", "svelte"]); +const PACKAGE_ROLES = Object.freeze(["core", "react", "svelte", "dom", "realtime"]); const SHA512_INTEGRITY = /^sha512-[A-Za-z0-9+/]+={0,2}$/u; export function fail(code, message) { @@ -148,10 +150,10 @@ function validateReleaseLine(config, source, path) { "compatibility configuration", ); assert( - config.schemaVersion === 1 && config.releaseLine === "0.3" && - config.contractVersion === 3 && config.initialVersion === "0.3.0", + config.schemaVersion === 1 && config.releaseLine === "0.4" && + config.contractVersion === 4 && config.initialVersion === "0.4.0", "CONFIG_IDENTITY", - "the live release line must be Concierge 0.3 with contract v3", + "the live release line must be Concierge 0.4 with contract v4", ); assert( config.distTag === "latest" && @@ -190,7 +192,7 @@ function validateReleaseLine(config, source, path) { assert( Array.isArray(config.packages) && config.packages.length === PACKAGE_NAMES.length, "CONFIG_PACKAGES", - "release package set must contain exactly three packages", + "release package set must contain exactly five packages", ); for (const [index, entry] of config.packages.entries()) { exactKeys( diff --git a/scripts/release/package.mjs b/scripts/release/package.mjs index ad1988e..9acfd56 100644 --- a/scripts/release/package.mjs +++ b/scripts/release/package.mjs @@ -176,12 +176,20 @@ function selfTest() { const config = loadReleaseLine(); const names = config.packages.map((entry) => expectedArchiveFilename(entry.name, config.initialVersion)); - assert(new Set(names).size === 3, "SELF_TEST", "archive names are not unique"); + // Counted against the release line rather than a literal, so the set can + // grow without this reporting a collision that does not exist. assert( - names[2] === expectedArchiveFilename( - "@full-self-browsing/concierge-svelte", - config.initialVersion, - ), + new Set(names).size === names.length, + "SELF_TEST", + "archive names are not unique", + ); + // The identity assertion names the last package in the published order and + // spells its archive filename out in full, which is what makes it a check on + // the naming convention rather than a restatement of the helper. + const last = config.packages.at(-1); + assert( + names.at(-1) === `full-self-browsing-concierge-realtime-${config.initialVersion}.tgz` && + last?.name === "@full-self-browsing/concierge-realtime", "SELF_TEST", "release archive identity drifted", ); diff --git a/scripts/release/publisher.mjs b/scripts/release/publisher.mjs index bf8d255..6c19392 100644 --- a/scripts/release/publisher.mjs +++ b/scripts/release/publisher.mjs @@ -139,7 +139,7 @@ function validateSealShape(seal, config, expected) { seal.workflowPath === config.workflowPath && seal.environment === config.environment && seal.packageSetSha256 === config.sha256, "SEAL_IDENTITY", - `release seal is not authorization for the configured ${config.releaseLine} ${config.distTag} trio`, + `release seal is not authorization for the configured ${config.releaseLine} ${config.distTag} package set`, ); assert( seal.repository === expected.repository && seal.commit === expected.commit && diff --git a/scripts/release/version.mjs b/scripts/release/version.mjs index 5b3a708..b50a87f 100644 --- a/scripts/release/version.mjs +++ b/scripts/release/version.mjs @@ -79,7 +79,7 @@ function applyVersion() { isReleaseLineVersion(version, config.releaseLine) && manifests.every((entry) => entry.manifest.version === version), "VERSION_SET", - `Changesets did not produce one stable ${config.releaseLine} trio: ${manifests + `Changesets did not produce one stable ${config.releaseLine} package set: ${manifests .map((entry) => `${entry.manifest.name}@${entry.manifest.version}`) .join(", ")}`, ); @@ -110,7 +110,7 @@ function applyVersion() { run( process.execPath, ["scripts/release/check.mjs", "release"], - "validate versioned trio", + "validate versioned package set", ); } @@ -121,16 +121,38 @@ function selfTest() { "SELF_TEST", "canonical peer failed", ); + // The fixture is DERIVED from the live release line, not spelled out. A + // literal `^0.2.1 || ^0.3.0` pinned the self-test to the line that happened + // to be current when it was written, so moving to 0.4 made the assertion + // demand a 0.3 target from a 0.4 config and fail on every commit — the check + // reporting its own staleness as a policy violation. + const priorVersion = "0.2.1"; const transition = analyzeSourceCorePeer( - "workspace:^0.2.1 || ^0.3.0", - "0.2.1", + `workspace:^${priorVersion} || ^${config.initialVersion}`, + priorVersion, config.releaseLine, ); assert( - transition.canonical === false && transition.target === "0.3.0", + transition.canonical === false && + transition.target === config.initialVersion, "SELF_TEST", "bounded peer transition failed", ); + let offLineRejected = false; + try { + analyzeSourceCorePeer( + `workspace:^${priorVersion} || ^0.1.0`, + priorVersion, + config.releaseLine, + ); + } catch (error) { + offLineRejected = String(error).includes("[VERSION_PEER]"); + } + assert( + offLineRejected, + "SELF_TEST", + "a transition targeting another release line was accepted", + ); let rejected = false; try { analyzeSourceCorePeer("workspace:>=0.0.0", "0.2.1", config.releaseLine); diff --git a/vitest.config.ts b/vitest.config.ts index 620289b..90f467b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -153,6 +153,54 @@ export default defineConfig({ include: ["packages/concierge-svelte/test/lifecycle.test.ts"], }, }, + { + test: { + name: "dom", + environment: "jsdom", + include: ["packages/concierge-dom/test/**/*.test.ts"], + exclude: [ + "**/node_modules/**", + "**/.git/**", + "packages/concierge-dom/test/export-surface.test.ts", + "packages/concierge-dom/test/catalog-boundary.test.ts", + "packages/concierge-dom/test/ssr.test.ts", + ], + }, + }, + { + test: { + name: "dom-artifact", + environment: "node", + include: [ + "packages/concierge-dom/test/export-surface.test.ts", + "packages/concierge-dom/test/catalog-boundary.test.ts", + "packages/concierge-dom/test/ssr.test.ts", + ], + }, + }, + { + test: { + name: "realtime", + environment: "node", + include: ["packages/concierge-realtime/test/**/*.test.ts"], + exclude: [ + "**/node_modules/**", + "**/.git/**", + "packages/concierge-realtime/test/webrtc.test.ts", + "packages/concierge-realtime/test/websocket.test.ts", + ], + }, + }, + { + test: { + name: "realtime-dom", + environment: "jsdom", + include: [ + "packages/concierge-realtime/test/webrtc.test.ts", + "packages/concierge-realtime/test/websocket.test.ts", + ], + }, + }, ], }, });