From 2ffb743ffc717a5c97d1492f7a924190f4e70fb9 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Wed, 16 Sep 2026 11:16:58 -0500 Subject: [PATCH 1/8] feat: ship Concierge 0.4 contract v4 and five-package set Bind consent to handler-proposed payloads, add catalog acknowledgement, and land the DOM, realtime, testing, and adapter surfaces in one 0.4 release. Co-authored-by: Cursor --- .changeset/concierge-dom.md | 9 + .changeset/config.json | 4 +- .changeset/contract-v4-core.md | 9 + .release/lines/0.4.json | 61 + COMPATIBILITY.md | 34 +- CONTRIBUTING.md | 24 +- HANDOFF.md | 32 +- README.md | 37 +- SUPPORT.md | 20 +- docs/bridge-registration.md | 63 ++ docs/integrations/ai-sdk.md | 8 +- docs/integrations/realtime.md | 38 + docs/migrations/0.3-to-0.4.md | 133 +++ .../next-ai-sdk/src/portfolio-concierge.ts | 24 +- examples/next-ai-sdk/test/consent.test.ts | 6 +- packages/concierge-dom/CHANGELOG.md | 7 + packages/concierge-dom/LICENSE | 21 + packages/concierge-dom/README.md | 132 +++ packages/concierge-dom/package.json | 58 + packages/concierge-dom/src/constants.ts | 19 + packages/concierge-dom/src/index.ts | 56 + packages/concierge-dom/src/registry.ts | 595 ++++++++++ packages/concierge-dom/src/types.ts | 151 +++ packages/concierge-dom/src/viewport.ts | 83 ++ packages/concierge-dom/src/visibility.ts | 104 ++ .../test/catalog-boundary.test.ts | 47 + packages/concierge-dom/test/contract.test.ts | 30 + .../concierge-dom/test/export-surface.test.ts | 99 ++ packages/concierge-dom/test/harness.ts | 25 + .../concierge-dom/test/read-untrusted.test.ts | 195 ++++ packages/concierge-dom/test/registry.test.ts | 138 +++ packages/concierge-dom/test/resolve.test.ts | 89 ++ packages/concierge-dom/test/reveal.test.ts | 281 +++++ packages/concierge-dom/test/ssr.test.ts | 50 + packages/concierge-dom/test/viewport.test.ts | 106 ++ .../concierge-dom/test/visibility.test.ts | 163 +++ packages/concierge-dom/tsconfig.json | 9 + packages/concierge-dom/tsdown.config.ts | 15 + packages/concierge-react/README.md | 9 +- packages/concierge-react/overlay/activity.tsx | 33 +- packages/concierge-react/src/client.tsx | 14 +- .../concierge-react/test-d/public.test-d.ts | 10 +- .../concierge-react/test/artifact.test.ts | 4 +- .../concierge-react/test/lifecycle.test.tsx | 52 +- packages/concierge-realtime/CHANGELOG.md | 8 + packages/concierge-realtime/LICENSE | 21 + packages/concierge-realtime/README.md | 39 + packages/concierge-realtime/package.json | 70 ++ .../concierge-realtime/src/delivery-ledger.ts | 249 ++++ packages/concierge-realtime/src/host.ts | 156 +++ packages/concierge-realtime/src/index.ts | 41 + .../concierge-realtime/src/openai/index.ts | 318 ++++++ packages/concierge-realtime/src/session.ts | 807 +++++++++++++ .../concierge-realtime/src/stop-intent.ts | 81 ++ .../concierge-realtime/src/turn-ledger.ts | 62 + packages/concierge-realtime/src/types.ts | 371 ++++++ .../concierge-realtime/src/webrtc/index.ts | 466 ++++++++ .../concierge-realtime/src/websocket/index.ts | 250 ++++ .../test/delivery-ledger.test.ts | 184 +++ .../test/export-surface.test.ts | 142 +++ packages/concierge-realtime/test/fixtures.ts | 257 +++++ .../concierge-realtime/test/no-dom.test.ts | 34 + .../concierge-realtime/test/openai.test.ts | 241 ++++ .../concierge-realtime/test/session.test.ts | 373 ++++++ .../test/stop-intent.test.ts | 32 + .../test/turn-ledger.test.ts | 52 + .../concierge-realtime/test/webrtc.test.ts | 167 +++ .../concierge-realtime/test/websocket.test.ts | 92 ++ packages/concierge-realtime/tsconfig.dom.json | 14 + packages/concierge-realtime/tsconfig.json | 9 + packages/concierge-realtime/tsdown.config.ts | 18 + .../concierge-realtime/tsdown.dom.config.ts | 16 + packages/concierge-svelte/README.md | 6 +- .../concierge-svelte/src/client.svelte.ts | 51 +- packages/concierge-svelte/test/Harness.svelte | 4 +- .../concierge-svelte/test/artifact.test.ts | 4 +- .../concierge-svelte/test/lifecycle.test.ts | 28 + packages/concierge/README.md | 10 +- packages/concierge/package.json | 8 +- packages/concierge/src/ai-sdk/index.ts | 2 +- packages/concierge/src/ai-sdk/wire.ts | 4 +- packages/concierge/src/bridge.ts | 240 +++- packages/concierge/src/catalog-prompt.ts | 197 ++++ packages/concierge/src/catalog.ts | 184 ++- packages/concierge/src/concierge.ts | 1007 +++++++++++++---- packages/concierge/src/consent-evidence.ts | 80 +- packages/concierge/src/contract.ts | 4 +- packages/concierge/src/dispatch.ts | 10 +- packages/concierge/src/host.ts | 105 ++ packages/concierge/src/index.ts | 67 +- packages/concierge/src/message.ts | 62 +- packages/concierge/src/rendition.ts | 353 ++++++ packages/concierge/src/resolve-value.ts | 237 ++++ packages/concierge/src/session.ts | 141 ++- packages/concierge/src/testing/index.ts | 252 +++++ packages/concierge/src/turn-ledger.ts | 245 ++++ packages/concierge/src/types.ts | 358 +++++- packages/concierge/test-d/actions.test-d.ts | 10 +- packages/concierge/test-d/bridge.test-d.ts | 6 +- packages/concierge/test-d/catalog.test-d.ts | 4 +- packages/concierge/test-d/consent.test-d.ts | 7 +- .../concierge/test-d/dispatcher.test-d.ts | 2 +- packages/concierge/test-d/exports.test-d.ts | 29 +- packages/concierge/test-d/results.test-d.ts | 1 + packages/concierge/test-d/session.test-d.ts | 2 +- packages/concierge/test-d/transport.test-d.ts | 12 +- .../concierge/test/action-bridges.test.ts | 46 +- packages/concierge/test/artifact.test.ts | 11 +- .../test/bridge-registration.test.ts | 147 +++ .../concierge/test/catalog-prompt.test.ts | 108 ++ .../concierge/test/catalog-snapshot.test.ts | 130 +++ packages/concierge/test/concierge.test.ts | 4 +- .../concierge/test/consent-kernel.test.ts | 343 +++--- packages/concierge/test/core-v2.test.ts | 7 +- .../test/dispatch-observability.test.ts | 139 +++ .../concierge/test/export-surface.test.ts | 38 +- packages/concierge/test/fixtures/probe.ts | 8 +- .../concierge/test/fixtures/stub-transport.ts | 10 +- .../concierge/test/fixtures/v2-session.ts | 16 + .../test/readback-canonicalization.test.ts | 56 +- packages/concierge/test/rendition.test.ts | 102 ++ packages/concierge/test/resolve-value.test.ts | 103 ++ .../test/session-acknowledgement.test.ts | 175 +++ .../concierge/test/session-lifecycle.test.ts | 4 + .../concierge/test/single-instance.test.ts | 5 +- .../concierge/test/telemetry/runtime.test.ts | 8 + .../concierge/test/testing-subpath.test.ts | 69 ++ packages/concierge/test/turn-ledger.test.ts | 76 ++ packages/concierge/test/workflow-v2.test.ts | 24 +- packages/concierge/tsconfig.testing.json | 12 + packages/concierge/tsdown.testing.config.ts | 16 + pnpm-lock.yaml | 12 + scripts/pack-install-check.sh | 14 +- scripts/pkg-dom-catalog-boundary.mjs | 226 ++++ scripts/release/check.mjs | 14 +- scripts/release/compatibility.mjs | 4 +- scripts/release/config.mjs | 14 +- vitest.config.ts | 48 + 138 files changed, 12670 insertions(+), 718 deletions(-) create mode 100644 .changeset/concierge-dom.md create mode 100644 .changeset/contract-v4-core.md create mode 100644 .release/lines/0.4.json create mode 100644 docs/bridge-registration.md create mode 100644 docs/integrations/realtime.md create mode 100644 docs/migrations/0.3-to-0.4.md create mode 100644 packages/concierge-dom/CHANGELOG.md create mode 100644 packages/concierge-dom/LICENSE create mode 100644 packages/concierge-dom/README.md create mode 100644 packages/concierge-dom/package.json create mode 100644 packages/concierge-dom/src/constants.ts create mode 100644 packages/concierge-dom/src/index.ts create mode 100644 packages/concierge-dom/src/registry.ts create mode 100644 packages/concierge-dom/src/types.ts create mode 100644 packages/concierge-dom/src/viewport.ts create mode 100644 packages/concierge-dom/src/visibility.ts create mode 100644 packages/concierge-dom/test/catalog-boundary.test.ts create mode 100644 packages/concierge-dom/test/contract.test.ts create mode 100644 packages/concierge-dom/test/export-surface.test.ts create mode 100644 packages/concierge-dom/test/harness.ts create mode 100644 packages/concierge-dom/test/read-untrusted.test.ts create mode 100644 packages/concierge-dom/test/registry.test.ts create mode 100644 packages/concierge-dom/test/resolve.test.ts create mode 100644 packages/concierge-dom/test/reveal.test.ts create mode 100644 packages/concierge-dom/test/ssr.test.ts create mode 100644 packages/concierge-dom/test/viewport.test.ts create mode 100644 packages/concierge-dom/test/visibility.test.ts create mode 100644 packages/concierge-dom/tsconfig.json create mode 100644 packages/concierge-dom/tsdown.config.ts create mode 100644 packages/concierge-realtime/CHANGELOG.md create mode 100644 packages/concierge-realtime/LICENSE create mode 100644 packages/concierge-realtime/README.md create mode 100644 packages/concierge-realtime/package.json create mode 100644 packages/concierge-realtime/src/delivery-ledger.ts create mode 100644 packages/concierge-realtime/src/host.ts create mode 100644 packages/concierge-realtime/src/index.ts create mode 100644 packages/concierge-realtime/src/openai/index.ts create mode 100644 packages/concierge-realtime/src/session.ts create mode 100644 packages/concierge-realtime/src/stop-intent.ts create mode 100644 packages/concierge-realtime/src/turn-ledger.ts create mode 100644 packages/concierge-realtime/src/types.ts create mode 100644 packages/concierge-realtime/src/webrtc/index.ts create mode 100644 packages/concierge-realtime/src/websocket/index.ts create mode 100644 packages/concierge-realtime/test/delivery-ledger.test.ts create mode 100644 packages/concierge-realtime/test/export-surface.test.ts create mode 100644 packages/concierge-realtime/test/fixtures.ts create mode 100644 packages/concierge-realtime/test/no-dom.test.ts create mode 100644 packages/concierge-realtime/test/openai.test.ts create mode 100644 packages/concierge-realtime/test/session.test.ts create mode 100644 packages/concierge-realtime/test/stop-intent.test.ts create mode 100644 packages/concierge-realtime/test/turn-ledger.test.ts create mode 100644 packages/concierge-realtime/test/webrtc.test.ts create mode 100644 packages/concierge-realtime/test/websocket.test.ts create mode 100644 packages/concierge-realtime/tsconfig.dom.json create mode 100644 packages/concierge-realtime/tsconfig.json create mode 100644 packages/concierge-realtime/tsdown.config.ts create mode 100644 packages/concierge-realtime/tsdown.dom.config.ts create mode 100644 packages/concierge/src/catalog-prompt.ts create mode 100644 packages/concierge/src/rendition.ts create mode 100644 packages/concierge/src/resolve-value.ts create mode 100644 packages/concierge/src/testing/index.ts create mode 100644 packages/concierge/src/turn-ledger.ts create mode 100644 packages/concierge/test/bridge-registration.test.ts create mode 100644 packages/concierge/test/catalog-prompt.test.ts create mode 100644 packages/concierge/test/catalog-snapshot.test.ts create mode 100644 packages/concierge/test/dispatch-observability.test.ts create mode 100644 packages/concierge/test/rendition.test.ts create mode 100644 packages/concierge/test/resolve-value.test.ts create mode 100644 packages/concierge/test/session-acknowledgement.test.ts create mode 100644 packages/concierge/test/testing-subpath.test.ts create mode 100644 packages/concierge/test/turn-ledger.test.ts create mode 100644 packages/concierge/tsconfig.testing.json create mode 100644 packages/concierge/tsdown.testing.config.ts create mode 100644 scripts/pkg-dom-catalog-boundary.mjs diff --git a/.changeset/concierge-dom.md b/.changeset/concierge-dom.md new file mode 100644 index 0000000..cbf2430 --- /dev/null +++ b/.changeset/concierge-dom.md @@ -0,0 +1,9 @@ +--- +"@full-self-browsing/concierge": minor +"@full-self-browsing/concierge-react": minor +"@full-self-browsing/concierge-svelte": minor +"@full-self-browsing/concierge-dom": minor +"@full-self-browsing/concierge-realtime": minor +--- + +Ship `@full-self-browsing/concierge-dom`: registered-element resolve, reveal, and untrusted readback. 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/.changeset/contract-v4-core.md b/.changeset/contract-v4-core.md new file mode 100644 index 0000000..8d5f72f --- /dev/null +++ b/.changeset/contract-v4-core.md @@ -0,0 +1,9 @@ +--- +"@full-self-browsing/concierge": minor +"@full-self-browsing/concierge-react": minor +"@full-self-browsing/concierge-svelte": minor +"@full-self-browsing/concierge-dom": minor +"@full-self-browsing/concierge-realtime": minor +--- + +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. 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..c633959 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,14 +1,14 @@ # 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 | | 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 +21,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,11 +44,17 @@ 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 +The release gate installs the packed public set 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 @@ -57,19 +63,21 @@ 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..100c227 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) @@ -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..20ffd49 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,14 +123,14 @@ 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 ``` @@ -209,7 +212,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 +258,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 +298,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 +317,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/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/migrations/0.3-to-0.4.md b/docs/migrations/0.3-to-0.4.md new file mode 100644 index 0000000..aeb223f --- /dev/null +++ b/docs/migrations/0.3-to-0.4.md @@ -0,0 +1,133 @@ +# 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`. + +## 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/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..1f9f581 --- /dev/null +++ b/packages/concierge-dom/CHANGELOG.md @@ -0,0 +1,7 @@ +# @full-self-browsing/concierge-dom + +## 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..897944d --- /dev/null +++ b/packages/concierge-dom/README.md @@ -0,0 +1,132 @@ +
+ +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. + +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..d8066e3 --- /dev/null +++ b/packages/concierge-dom/package.json @@ -0,0 +1,58 @@ +{ + "name": "@full-self-browsing/concierge-dom", + "version": "0.3.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..c548a9e --- /dev/null +++ b/packages/concierge-dom/src/registry.ts @@ -0,0 +1,595 @@ +/** + * 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(); + 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; + const finish = (result: FrameWait): void => { + if (settled) { + return; + } + settled = true; + pendingFrames.delete(key); + cancelScheduledFrame(); + cancelFallback(); + if (signal !== undefined) { + signal.removeEventListener("abort", onAbort); + } + settle(result); + }; + + const onAbort = (): void => { + finish("aborted"); + }; + + const cancelScheduledFrame: () => void = frame((): void => { + finish("proceed"); + }); + const cancelFallback: () => void = scheduler((): void => { + finish("proceed"); + }, frameFallbackMs); + + pendingFrames.set(key, (): void => { + finish("aborted"); + }); + + if (signal !== undefined) { + if (signal.aborted) { + finish("aborted"); + return; + } + signal.addEventListener("abort", onAbort); + } + }); + + 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, + }; + }; + + 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; + } + + let release: (() => void) | undefined; + const callback: AnchorRef = (element: HTMLElement | null): void => { + if (release !== undefined) { + release(); + release = undefined; + } + if (element !== null) { + release = register(key, element, refOptions.get(key)); + } + }; + 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(); + }; + + 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..d0b1718 --- /dev/null +++ b/packages/concierge-dom/src/types.ts @@ -0,0 +1,151 @@ +/** + * 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 returns `void` so React 18 does not warn, and so the + * `null`-on-unmount call is the unregister protocol both React majors share. + */ +export type AnchorRef = (element: HTMLElement | null) => 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..039ed41 --- /dev/null +++ b/packages/concierge-dom/src/viewport.ts @@ -0,0 +1,83 @@ +/** + * 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. */ +export function preferredScrollBehavior(): "auto" | "smooth" { + const matchMedia: typeof globalThis.matchMedia | undefined = + globalThis.matchMedia; + if (typeof matchMedia !== "function") { + return "smooth"; + } + return matchMedia(REDUCE_MOTION_QUERY).matches ? "auto" : "smooth"; +} + +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..344e7b5 --- /dev/null +++ b/packages/concierge-dom/test/read-untrusted.test.ts @@ -0,0 +1,195 @@ +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("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..beaaeb8 --- /dev/null +++ b/packages/concierge-dom/test/registry.test.ts @@ -0,0 +1,138 @@ +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("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..a0438a2 --- /dev/null +++ b/packages/concierge-dom/test/reveal.test.ts @@ -0,0 +1,281 @@ +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("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..fe4ca96 --- /dev/null +++ b/packages/concierge-dom/test/viewport.test.ts @@ -0,0 +1,106 @@ +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("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/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/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..7f5179a --- /dev/null +++ b/packages/concierge-realtime/CHANGELOG.md @@ -0,0 +1,8 @@ +# @full-self-browsing/concierge-realtime + +## 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..e59f463 --- /dev/null +++ b/packages/concierge-realtime/package.json @@ -0,0 +1,70 @@ +{ + "name": "@full-self-browsing/concierge-realtime", + "version": "0.3.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..1c44307 --- /dev/null +++ b/packages/concierge-realtime/src/delivery-ledger.ts @@ -0,0 +1,249 @@ +import type { DeliveryReport, ReadbackAttestation } from "@full-self-browsing/concierge"; +import { + createDiagnostic, + notifyDiagnostic, + resolveScheduler, + validIdentifier, +} from "./host.js"; +import type { + RealtimeDeliveryLedger, + RealtimeDeliveryLedgerConfig, +} from "./types.js"; + +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; +} + +function snapshotAttestation( + attestation: ReadbackAttestation, +): ReadbackAttestation | null { + if ( + (attestation.act !== "confirmed" && + attestation.act !== "declined" && + attestation.act !== "dismissed") || + !validIdentifier(attestation.actId) || + typeof attestation.readbackHash !== "string" + ) { + return null; + } + const userTurnId: string | undefined = attestation.userTurnId; + return Object.freeze( + userTurnId === undefined + ? { + act: attestation.act, + actId: attestation.actId, + readbackHash: attestation.readbackHash, + } + : { + act: attestation.act, + actId: attestation.actId, + readbackHash: attestation.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); + const hashesByOrigin: Map = new Map(); + const turnsByOrigin: Map = new Map(); + 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 report: DeliveryReport = Object.freeze({ + responseId: group.originResponseId, + outcome, + ...(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..e157750 --- /dev/null +++ b/packages/concierge-realtime/src/host.ts @@ -0,0 +1,156 @@ +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; +} + +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..c4a65d6 --- /dev/null +++ b/packages/concierge-realtime/test/delivery-ledger.test.ts @@ -0,0 +1,184 @@ +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 without attestation when the window elapses", () => { + 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", + readbackHash: "hash-1", + }), + ]); + expect(reports[0]?.attestation).toBeUndefined(); + }); + + 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"); + }); +}); 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/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/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/README.md b/packages/concierge/README.md index 422be31..11ce8c7 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. diff --git a/packages/concierge/package.json b/packages/concierge/package.json index 49fcd25..fed1417 100644 --- a/packages/concierge/package.json +++ b/packages/concierge/package.json @@ -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..4e14746 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; + let cancel: (() => void) | null = null; + let cancelWhenAvailable: boolean = false; + 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 (cancelWhenAvailable) { + try { + cancel(); + } catch { + // ignore + } + } + 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..2070952 --- /dev/null +++ b/packages/concierge/src/catalog-prompt.ts @@ -0,0 +1,197 @@ +/** + * 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; + } + const observerRedaction: Record> = + Object.create(null); + const sideEffects: Record = Object.create(null); + for (const tool of catalog.tools) { + observerRedaction[tool.name] = "drop"; + sideEffects[tool.name] = Object.freeze({}); + } + return Object.freeze({ + continuationSensitiveNames: Object.freeze([]), + observerRedaction: Object.freeze(observerRedaction), + sideEffects: Object.freeze(sideEffects), + }); +} diff --git a/packages/concierge/src/catalog.ts b/packages/concierge/src/catalog.ts index d6f27f2..17ebb0a 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,17 @@ 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. + */ + readonly snapshotSources?: + | ReadonlyArray<{ + readonly actionNames: ReadonlyArray; + readonly registry: BridgeRegistry; + }> + | undefined; } // --------------------------------------------------------------------------- @@ -653,6 +672,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 +1314,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 +1390,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 +1445,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 +1613,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..2e81de1 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,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { | null = null; let warnedDispatch: Set | null = null; let consentGenerations: Map | null = null; + let retainedReviews: Map | null = null; + let usedAttestationActIds: Set | null = null; let nextConsentGeneration: bigint = 0n; /** Address review authority by both its session namespace and action name. */ @@ -1383,6 +1537,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 +1600,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 +1635,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 +1644,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 +1912,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 +1945,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { function allocateDispatchId(): string { nextDispatchId += 1n; - return `dispatch-${nextDispatchId}`; + return `${instanceId}-${nextDispatchId}`; } function drainDispatchEvents(): void { @@ -1772,9 +1974,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 +2058,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 +2906,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 +2955,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 +2990,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 +3015,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 +3063,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 ( + Object.keys(snapshot).length === 0 + ) { + 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 ( - currentReview?.generation === reviewingClaim.generation && - currentReview.status === "reviewing" && - currentReview.responseId === reviewingClaim.responseId + needsAttested && + typeof capturedConsent.presentReadback === "function" ) { - const snapshotBridgeId: string = - bridgeRegistry?.id ?? stage?.id ?? "cross-stage"; - reviewingGeneration = Object.freeze({ + 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 +3315,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 +3350,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 +3375,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 +3443,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 +3455,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 +3481,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 +3501,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { meta, ack: consentAck, workflow, + context: occurrence?.context ?? {}, + review: reviewControls, }); } catch { closeOwnedReview(); @@ -3055,9 +3551,6 @@ export function createConcierge(config: ConciergeConfig): Concierge { handlerResult, ); - if (reviewingGeneration === null) { - return normalizedResult; - } if (!normalizedResult.ok) { closeOwnedReview(); return normalizedResult; @@ -3066,91 +3559,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 +3682,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 +3695,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 +3732,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { meta, resolution, root, + startedAt: readClock(), + enteredHandlerAt: null, }; } @@ -3485,8 +3947,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 +3963,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 +4182,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 +4736,126 @@ 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"; + } + usedAttestationActIds ??= new Set(); + if (usedAttestationActIds.has(actId)) { + return "already_attested"; + } + if (consentGenerations === null) { + return "unknown_readback"; + } + let matched: { slotKey: string; generation: ConsentGenerationBase } | null = + null; + for (const [slotKey, generation] of consentGenerations) { + if ( + "payload" in generation && + generation.readbackHash === readbackHash + ) { + matched = { slotKey, generation }; + break; + } + } + 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") { + usedAttestationActIds.add(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"; + } + usedAttestationActIds.add(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..ad24d39 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, 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..aec3c93 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,26 @@ 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 }); +} - 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 lastRetained: number = message.charCodeAt(maxChars - 1); + const cut: number = + lastRetained >= 0xd800 && lastRetained <= 0xdbff ? maxChars - 1 : maxChars; + const sliced: string = message.slice(0, cut); + if (!ellipsis) { + return sliced; + } + if (sliced.length === 0) { + return ""; + } + return `${sliced.slice(0, Math.max(0, sliced.length - 1))}…`; } diff --git a/packages/concierge/src/rendition.ts b/packages/concierge/src/rendition.ts new file mode 100644 index 0000000..52cb8d7 --- /dev/null +++ b/packages/concierge/src/rendition.ts @@ -0,0 +1,353 @@ +/** + * 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; + +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: Set = new Set(); + const settledCauses: Set = new Set(); + + 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) { + reportIssue({ + 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); + 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(); + }, + + 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..ef6ab67 --- /dev/null +++ b/packages/concierge/src/resolve-value.ts @@ -0,0 +1,237 @@ +/** + * 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"); + } + + const exactIdentity: Prepared[] = prepared.filter( + (row) => row.label === query, + ); + if (exactIdentity.length === 1) { + const only: Prepared = exactIdentity[0]!; + const sameLabelDifferentId: boolean = prepared.some( + (row) => + row.label === query && + row.identity !== only.identity && + !Object.is(row.item, only.item), + ); + if (sameLabelDifferentId) { + const tied: T[] = exactIdentity + .filter((row, index, rows) => + rows.findIndex((other) => other.identity === row.identity) === index + ) + .map((row) => row.item) + .slice(0, maxAmbiguous); + if (tied.length >= 2) { + return refuse("ambiguous", Object.freeze(tied)); + } + } + return { ok: true, match: only.item }; + } + if (exactIdentity.length > 1) { + const identities: Set = new Set( + exactIdentity.map((row) => row.identity), + ); + if (identities.size === 1) { + return { ok: true, match: exactIdentity[0]!.item }; + } + return refuse( + "ambiguous", + Object.freeze(exactIdentity.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..f0034ba 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 ( @@ -616,6 +717,7 @@ function createV2Session( generation += 1; currentEpoch?.abort(); currentEpoch = null; + pendingPublications.length = 0; listeners.clear(); pendingCatalogNotifications.length = 0; try { @@ -628,8 +730,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 +750,15 @@ 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) { + const subscribeAck: unknown = transport.onCatalogAcknowledged; + if (typeof subscribeAck !== "function") throw new Error(START_ERROR); + const removeAck: unknown = (subscribeAck as ( + cb: (ack: CatalogAcknowledgement) => void, + ) => unknown)(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..b0cfbbf --- /dev/null +++ b/packages/concierge/src/turn-ledger.ts @@ -0,0 +1,245 @@ +/** + * 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; + }, + + attestationTurnAfter(turnId: string): RecordedTurn | null { + const index: number = turnOrder.indexOf(turnId); + if (index < 0) { + return null; + } + let found: RecordedTurn | null = 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") { + found = turn; + } + } + return found; + }, + + 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..614b383 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,20 @@ 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"`. + */ + readonly userTurnId?: string | undefined; } /** @@ -530,6 +548,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 +620,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 +670,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 +693,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 +810,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 +1154,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 +1265,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 +1477,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 +1501,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 +1732,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 +1764,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 +1865,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 +1907,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 +2288,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 +2340,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 +2380,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 +2404,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/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..50c8410 --- /dev/null +++ b/packages/concierge/test/catalog-prompt.test.ts @@ -0,0 +1,108 @@ +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"; + +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", + ); + }); +}); 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-kernel.test.ts b/packages/concierge/test/consent-kernel.test.ts index 7a08f79..af08ba2 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,57 +1946,24 @@ 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 }); }); @@ -2091,13 +2006,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..ecc2485 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,9 @@ export function transportHarness(overrides = {}) { status = next; for (const handler of [...statusHandlers]) handler(next); }, + acknowledge(ack) { + for (const handler of [...ackHandlers]) handler(ack); + }, }; } 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..5ea7e48 --- /dev/null +++ b/packages/concierge/test/rendition.test.ts @@ -0,0 +1,102 @@ +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"]); + }); +}); diff --git a/packages/concierge/test/resolve-value.test.ts b/packages/concierge/test/resolve-value.test.ts new file mode 100644 index 0000000..9caf733 --- /dev/null +++ b/packages/concierge/test/resolve-value.test.ts @@ -0,0 +1,103 @@ +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" }); + }); +}); diff --git a/packages/concierge/test/session-acknowledgement.test.ts b/packages/concierge/test/session-acknowledgement.test.ts new file mode 100644 index 0000000..8ac0742 --- /dev/null +++ b/packages/concierge/test/session-acknowledgement.test.ts @@ -0,0 +1,175 @@ +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); +}); 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..3309069 --- /dev/null +++ b/packages/concierge/test/turn-ledger.test.ts @@ -0,0 +1,76 @@ +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("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/check.mjs b/scripts/release/check.mjs index 3b75122..6b4eb5b 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,23 @@ 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", ); for (const relativePath of [ "packages/concierge-react/src/client.tsx", "packages/concierge-svelte/src/client.svelte.ts", + "packages/concierge-dom/src/constants.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 +207,7 @@ function checkSource(config, mode) { .join(", ")}`, ); checkChangesets(config); - checkContractV3(); + checkContractV4(); return manifests[0].version; } diff --git a/scripts/release/compatibility.mjs b/scripts/release/compatibility.mjs index de51dd8..e690079 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` + @@ -251,7 +251,7 @@ function runFrameworkCell(root, inputs, cell) { `const svelteRoot = await import("@full-self-browsing/concierge-svelte");\n` + `const svelteClient = await import("@full-self-browsing/concierge-svelte/client.svelte");\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` + `process.stdout.write(JSON.stringify({ react: ${JSON.stringify(cell.react)}, svelte: ${JSON.stringify(cell.svelte)}, html }) + "\\n");\n`, "utf8", ); 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/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", + ], + }, + }, ], }, }); From 9a7db18473795e43749d366b9532d4e89093c0c4 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Wed, 16 Sep 2026 12:38:32 -0500 Subject: [PATCH 2/8] fix: four correctness defects in the 0.4 surface ref(key) held one release slot for a key that holds a set of registrations, so a second node mounted under the same key silently unregistered the first -- the responsive case concierge-dom's README says the package exists to solve. Track a release per element and return it as a cleanup so React 19 releases exactly the node it attached; React 18's element-less ref(null) prefers disconnected nodes and falls back to newest-first. sanitizeText's ellipsis branch shortened an already surrogate-safe slice by one code unit, stranding a high surrogate whenever the slice ended on an astral character. readUntrusted is the only caller passing ellipsis, and the malformed string then failed prepareReadback with payload_unsupported. Both cuts now go through one helper. waitForFrame closed over two const cancellers declared below finish, so an injected frame or scheduler that fires synchronously reached them in the temporal dead zone and reveal rejected with a ReferenceError instead of returning an outcome. attestationTurnAfter ran its loop to completion and returned the last attested turn, letting a confirmation arbitrarily far in the future stand in for the one the person took in answer to the review. --- packages/concierge-dom/src/registry.ts | 141 +++++++++++++++--- packages/concierge-dom/src/types.ts | 16 +- .../concierge-dom/test/read-untrusted.test.ts | 12 ++ packages/concierge-dom/test/registry.test.ts | 76 ++++++++++ packages/concierge-dom/test/reveal.test.ts | 47 ++++++ packages/concierge/src/message.ts | 25 +++- packages/concierge/src/turn-ledger.ts | 10 +- packages/concierge/test/message.test.ts | Bin 0 -> 3108 bytes packages/concierge/test/turn-ledger.test.ts | 18 +++ 9 files changed, 317 insertions(+), 28 deletions(-) create mode 100644 packages/concierge/test/message.test.ts diff --git a/packages/concierge-dom/src/registry.ts b/packages/concierge-dom/src/registry.ts index c548a9e..bc76d85 100644 --- a/packages/concierge-dom/src/registry.ts +++ b/packages/concierge-dom/src/registry.ts @@ -254,6 +254,12 @@ export function createAnchorRegistry( 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(); @@ -348,15 +354,31 @@ export function createAnchorRegistry( ): 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(); - cancelFallback(); - if (signal !== undefined) { + cancelScheduledFrame?.(); + cancelScheduledFrame = undefined; + cancelFallback?.(); + cancelFallback = undefined; + if (abortListenerAttached && signal !== undefined) { + abortListenerAttached = false; signal.removeEventListener("abort", onAbort); } settle(result); @@ -366,13 +388,8 @@ export function createAnchorRegistry( finish("aborted"); }; - const cancelScheduledFrame: () => void = frame((): void => { - finish("proceed"); - }); - const cancelFallback: () => void = scheduler((): void => { - finish("proceed"); - }, frameFallbackMs); - + // 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"); }); @@ -383,7 +400,26 @@ export function createAnchorRegistry( 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 = ( @@ -536,6 +572,28 @@ export function createAnchorRegistry( }; }; + /** + * 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); @@ -545,15 +603,58 @@ export function createAnchorRegistry( return existing; } - let release: (() => void) | undefined; - const callback: AnchorRef = (element: HTMLElement | null): void => { - if (release !== undefined) { - release(); - release = undefined; + // **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 (element !== null) { - release = register(key, element, refOptions.get(key)); + + 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; @@ -577,6 +678,12 @@ export function createAnchorRegistry( 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 = { diff --git a/packages/concierge-dom/src/types.ts b/packages/concierge-dom/src/types.ts index d0b1718..0af2a49 100644 --- a/packages/concierge-dom/src/types.ts +++ b/packages/concierge-dom/src/types.ts @@ -40,10 +40,20 @@ export interface AnchorOptions { } /** - * A ref callback that returns `void` so React 18 does not warn, and so the - * `null`-on-unmount call is the unregister protocol both React majors share. + * 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; +export type AnchorRef = ( + element: HTMLElement | null, +) => (() => void) | void; /** A Svelte `use:` action. Same registration, different calling convention. */ export type AnchorAction = (node: HTMLElement) => { destroy: () => void }; diff --git a/packages/concierge-dom/test/read-untrusted.test.ts b/packages/concierge-dom/test/read-untrusted.test.ts index 344e7b5..61d3748 100644 --- a/packages/concierge-dom/test/read-untrusted.test.ts +++ b/packages/concierge-dom/test/read-untrusted.test.ts @@ -117,6 +117,18 @@ describe("AnchorRegistry.readUntrusted", () => { 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"); diff --git a/packages/concierge-dom/test/registry.test.ts b/packages/concierge-dom/test/registry.test.ts index beaaeb8..1bc776a 100644 --- a/packages/concierge-dom/test/registry.test.ts +++ b/packages/concierge-dom/test/registry.test.ts @@ -96,6 +96,82 @@ describe("createAnchorRegistry", () => { 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"); diff --git a/packages/concierge-dom/test/reveal.test.ts b/packages/concierge-dom/test/reveal.test.ts index a0438a2..eea7e23 100644 --- a/packages/concierge-dom/test/reveal.test.ts +++ b/packages/concierge-dom/test/reveal.test.ts @@ -260,6 +260,53 @@ describe("AnchorRegistry.reveal", () => { 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; diff --git a/packages/concierge/src/message.ts b/packages/concierge/src/message.ts index aec3c93..8f01835 100644 --- a/packages/concierge/src/message.ts +++ b/packages/concierge/src/message.ts @@ -61,6 +61,24 @@ export function sanitizeMessage(message: string): string { 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; +} + function boundText(message: string, maxChars: number, ellipsis: boolean): string { if (!Number.isSafeInteger(maxChars) || maxChars < 0) { return ""; @@ -69,15 +87,12 @@ function boundText(message: string, maxChars: number, ellipsis: boolean): string return message; } - const lastRetained: number = message.charCodeAt(maxChars - 1); - const cut: number = - lastRetained >= 0xd800 && lastRetained <= 0xdbff ? maxChars - 1 : maxChars; - const sliced: string = message.slice(0, cut); + const sliced: string = message.slice(0, surrogateSafeCut(message, maxChars)); if (!ellipsis) { return sliced; } if (sliced.length === 0) { return ""; } - return `${sliced.slice(0, Math.max(0, sliced.length - 1))}…`; + return `${sliced.slice(0, surrogateSafeCut(sliced, sliced.length - 1))}…`; } diff --git a/packages/concierge/src/turn-ledger.ts b/packages/concierge/src/turn-ledger.ts index b0cfbbf..5a3e398 100644 --- a/packages/concierge/src/turn-ledger.ts +++ b/packages/concierge/src/turn-ledger.ts @@ -213,12 +213,16 @@ export function createTurnLedger(config: TurnLedgerConfig = {}): TurnLedger { 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; } - let found: RecordedTurn | null = null; for (let i: number = index + 1; i < turnOrder.length; i += 1) { const id: string | undefined = turnOrder[i]; if (id === undefined) { @@ -226,10 +230,10 @@ export function createTurnLedger(config: TurnLedgerConfig = {}): TurnLedger { } const turn: RecordedTurn | undefined = turns.get(id); if (turn?.provenance === "human-attested") { - found = turn; + return turn; } } - return found; + return null; }, reset(): void { diff --git a/packages/concierge/test/message.test.ts b/packages/concierge/test/message.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a02be6dac2c00c3629195061aba27f036bbf7caa GIT binary patch literal 3108 zcmcgu-EP}96yB~1UC5}TSU?jL$ub}-(zHO60ZoDKB0x<`#}*q()JQ6^YZ&O$ z?7{XVJCsOS@=uf9%om0w^Zb6_`3`wrN@ZXPDe6S=2@V10B_?JF+`tN^O6CweaD&^ijpD@&&LOy~mhsz@(P(A!^<{;D zPtfKQ3?UrA`ERg10ID+#5-9{LHEJNgMo9sxDm1Vng~!YmU$TUXG#M5^fpXpR0Ip|Y zH%1r$u%CXOz@51(AOQ#lgUHC=F$@??APEL%6nsVBE(e9CQWO;P(kx&q6)-4p#teTz zpr=9>hrBR2Lj`sBpOXWdBy?;EIh()9n9`?k7*kN6Nt48${I_l6=y=PxQ-MJCKrLh* z3}$TJmM6-#uF0C7jjWOk(`V*y&vgrU)k%R;OnZ%7T?C?(=bm~rbD_u@HQ))&NBYhrcUrSpEU-@!}g`m@RWE}p?`0i z+88y{7*aTonf3n9_rE=KEqHV!5?IVk=1v|vCk~;8>8T`y3TZ4xwpIoipJkvcr6h3; ziSL%<)DNm11Rc>J3p!RZt~L+@I68t`J_lx<2}p=xEHS8v0}8=Bq9E!fGDQNN8y&%g zvwCra&QOfsy;`Nf5oVL<&n}COjGJhBA&lW(0&|+tXM^Tz{t1Z>7pH4gEM; z2J8oZ{L6APsaJy)F=4#JrpJU2B)6Za5r3%5zbYoeINrq!PSZd+9a5_)DWKuS2UH7( z7)zAYcD6|vZ7;VV-{MBoVl69`U}sb|Yk1veZ!r!9nvPf%RX!o{IET?$`xbsZhxnxP z{F0srojH3F+a{;3s?gT!VOQ(2pWF2aJ_EMBm#PD`{=!r^fqzzdEX|DrHR { 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(); From 20cab5155e591e911d355b835a98600639040f78 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Wed, 16 Sep 2026 12:41:39 -0500 Subject: [PATCH 3/8] fix: close fail-open defaults, a lost receiver, and a TOCTOU read catalogDerivedPolicy reported continuationSensitiveNames: [] when no projection was remembered for a revision -- a catalog built by another core instance, or one that never passed through rememberCatalogProjection. That reads as restraint and is the opposite: a consumer using the field to decide whether to continue past an action would have treated every destructive and consent-gated action as safe. Unknown sensitivity is now reported as sensitive, matching the "drop" redaction default beside it. onCatalogAcknowledged had to be read off the transport before calling it, because the typeof guard is what rejects a transport that claims acknowledgesCatalog without implementing it -- but the call then went out unbound, so a method-shorthand subscriber touching `this` would throw. It goes back through Reflect.apply, as publish already does. snapshotAttestation read act, actId and readbackHash once to validate and again to build the frozen copy, so an accessor-backed attestation could pass validIdentifier and hand a different actId to the record. It now reads every field once through host.ts's own-data helpers, so no getter runs at all. attestReadback matched on readbackHash alone and took the first hit in Map insertion order. The hash covers {payload, presented} and not the action, so two reviews with byte-identical readbacks were conflated and the kernel could arm one the person had not heard. Two matches now refuse. preferredScrollBehavior returned "smooth" when matchMedia was absent. It governs every reveal() and scrollViewport() that omits behavior, so a host that cannot report the reduced-motion preference must not be animated on the assumption it is fine. --- packages/concierge-dom/src/viewport.ts | 18 ++++- packages/concierge-dom/test/viewport.test.ts | 24 +++++++ .../concierge-realtime/src/delivery-ledger.ts | 44 ++++++++----- .../test/delivery-ledger.test.ts | 66 +++++++++++++++++++ packages/concierge/src/catalog-prompt.ts | 15 ++++- packages/concierge/src/concierge.ts | 20 +++++- packages/concierge/src/session.ts | 17 ++++- .../concierge/test/catalog-prompt.test.ts | 24 +++++++ .../concierge/test/consent-kernel.test.ts | 18 +++++ .../concierge/test/fixtures/v2-session.ts | 10 +++ .../test/session-acknowledgement.test.ts | 44 +++++++++++++ 11 files changed, 275 insertions(+), 25 deletions(-) diff --git a/packages/concierge-dom/src/viewport.ts b/packages/concierge-dom/src/viewport.ts index 039ed41..17c5e07 100644 --- a/packages/concierge-dom/src/viewport.ts +++ b/packages/concierge-dom/src/viewport.ts @@ -17,14 +17,26 @@ import type { 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. */ +/** + * 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 "smooth"; + return "auto"; + } + try { + return matchMedia(REDUCE_MOTION_QUERY).matches ? "auto" : "smooth"; + } catch { + return "auto"; } - return matchMedia(REDUCE_MOTION_QUERY).matches ? "auto" : "smooth"; } export function readViewportPosition(): ViewportPosition { diff --git a/packages/concierge-dom/test/viewport.test.ts b/packages/concierge-dom/test/viewport.test.ts index fe4ca96..506a286 100644 --- a/packages/concierge-dom/test/viewport.test.ts +++ b/packages/concierge-dom/test/viewport.test.ts @@ -71,6 +71,30 @@ describe("viewport helpers", () => { 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, diff --git a/packages/concierge-realtime/src/delivery-ledger.ts b/packages/concierge-realtime/src/delivery-ledger.ts index 1c44307..b83aea4 100644 --- a/packages/concierge-realtime/src/delivery-ledger.ts +++ b/packages/concierge-realtime/src/delivery-ledger.ts @@ -1,7 +1,9 @@ import type { DeliveryReport, ReadbackAttestation } from "@full-self-browsing/concierge"; import { + asRecord, createDiagnostic, notifyDiagnostic, + ownData, resolveScheduler, validIdentifier, } from "./host.js"; @@ -22,32 +24,40 @@ interface DeliveryGroup { 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 ( - (attestation.act !== "confirmed" && - attestation.act !== "declined" && - attestation.act !== "dismissed") || - !validIdentifier(attestation.actId) || - typeof attestation.readbackHash !== "string" + (act !== "confirmed" && act !== "declined" && act !== "dismissed") || + !validIdentifier(actId) || + typeof readbackHash !== "string" || + (userTurnId !== undefined && typeof userTurnId !== "string") ) { return null; } - const userTurnId: string | undefined = attestation.userTurnId; return Object.freeze( userTurnId === undefined - ? { - act: attestation.act, - actId: attestation.actId, - readbackHash: attestation.readbackHash, - } - : { - act: attestation.act, - actId: attestation.actId, - readbackHash: attestation.readbackHash, - userTurnId, - }, + ? { act, actId, readbackHash } + : { act, actId, readbackHash, userTurnId }, ); } diff --git a/packages/concierge-realtime/test/delivery-ledger.test.ts b/packages/concierge-realtime/test/delivery-ledger.test.ts index c4a65d6..3b36734 100644 --- a/packages/concierge-realtime/test/delivery-ledger.test.ts +++ b/packages/concierge-realtime/test/delivery-ledger.test.ts @@ -182,3 +182,69 @@ describe("createRealtimeDeliveryLedger", () => { 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); + }); +}); diff --git a/packages/concierge/src/catalog-prompt.ts b/packages/concierge/src/catalog-prompt.ts index 2070952..4c30803 100644 --- a/packages/concierge/src/catalog-prompt.ts +++ b/packages/concierge/src/catalog-prompt.ts @@ -182,15 +182,28 @@ export function catalogDerivedPolicy( 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: Object.freeze(continuationSensitiveNames), observerRedaction: Object.freeze(observerRedaction), sideEffects: Object.freeze(sideEffects), }); diff --git a/packages/concierge/src/concierge.ts b/packages/concierge/src/concierge.ts index 2e81de1..f57ed17 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -4784,17 +4784,35 @@ export function createConcierge(config: ConciergeConfig): Concierge { 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 }; - break; } } + 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"; } diff --git a/packages/concierge/src/session.ts b/packages/concierge/src/session.ts index f0034ba..f75287a 100644 --- a/packages/concierge/src/session.ts +++ b/packages/concierge/src/session.ts @@ -751,11 +751,22 @@ function createV2Session( 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 = (subscribeAck as ( - cb: (ack: CatalogAcknowledgement) => void, - ) => unknown)(handleAcknowledgement); + 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; } diff --git a/packages/concierge/test/catalog-prompt.test.ts b/packages/concierge/test/catalog-prompt.test.ts index 50c8410..25de841 100644 --- a/packages/concierge/test/catalog-prompt.test.ts +++ b/packages/concierge/test/catalog-prompt.test.ts @@ -4,6 +4,7 @@ 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({}); @@ -105,4 +106,27 @@ describe("renderCatalogPrompt and catalogDerivedPolicy", () => { "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/consent-kernel.test.ts b/packages/concierge/test/consent-kernel.test.ts index af08ba2..1136c8a 100644 --- a/packages/concierge/test/consent-kernel.test.ts +++ b/packages/concierge/test/consent-kernel.test.ts @@ -1967,6 +1967,24 @@ describe("CON-07/09 — attested authority requires one complete owned evidence 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(); diff --git a/packages/concierge/test/fixtures/v2-session.ts b/packages/concierge/test/fixtures/v2-session.ts index ecc2485..fa1d867 100644 --- a/packages/concierge/test/fixtures/v2-session.ts +++ b/packages/concierge/test/fixtures/v2-session.ts @@ -104,6 +104,16 @@ export function transportHarness(overrides = {}) { 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/session-acknowledgement.test.ts b/packages/concierge/test/session-acknowledgement.test.ts index 8ac0742..5e9037b 100644 --- a/packages/concierge/test/session-acknowledgement.test.ts +++ b/packages/concierge/test/session-acknowledgement.test.ts @@ -173,3 +173,47 @@ it("ignores acknowledgements after stop and a second ack for one publication", a 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(); +}); From 808f1ad9f0adf610cd18136df00d9f06d3966eac Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Wed, 16 Sep 2026 12:44:59 -0500 Subject: [PATCH 4/8] fix: bound the session-lifetime structures and drop dead branches Three structures grew with session length in packages whose whole point is a long-lived voice session. The realtime delivery ledger kept every settled group in the array findOpenGroup, observeAttestation and revokeAll scan, so the scan cost grew with the session's history; settled groups now leave it, which is behaviour-neutral because every reader already skipped them. hashesByOrigin and turnsByOrigin outlive the group that consumed them and cannot simply be deleted, so they are bounded instead, reusing the eviction shape createRealtimeTurnLedger already had for byResponse. The rendition binder's settled-cause memory and core's attestation actId set are likewise bounded. The actId bound carries its reasoning at the declaration, because capping a replay set usually is not safe: this one is defence in depth behind the generation-identity check, the already-attested status check, and closeConsentGeneration, any of which refuses a replay on its own. duplicate_settlement was declared in RenditionIssueCode with no producer, while the situation it names reported unbound_rendition -- the first settlement deletes the bindings, so a second one looked exactly like a rendition that never had a cause and sent a reader after a binding bug that does not exist. awaitRegistration carried a cancel-when-available flag that was never set. Every path that settles before the timer is armed returns before reaching the scheduler, so no handle ever owes cancellation there. resolveValue re-scanned its candidates for a rival sharing the queried label inside the branch where the filter had already produced exactly one row, so the branch could never fire. Real rivalry is decided one arm below. --- .../concierge-realtime/src/delivery-ledger.ts | 23 +++++- packages/concierge-realtime/src/host.ts | 48 ++++++++++++ .../test/delivery-ledger.test.ts | 51 +++++++++++++ packages/concierge/src/bridge.ts | 16 ++-- packages/concierge/src/concierge.ts | 35 ++++++++- packages/concierge/src/rendition.ts | 73 +++++++++++++++++-- packages/concierge/src/resolve-value.ts | 37 +++------- packages/concierge/test/rendition.test.ts | 26 +++++++ packages/concierge/test/resolve-value.test.ts | 35 +++++++++ 9 files changed, 297 insertions(+), 47 deletions(-) diff --git a/packages/concierge-realtime/src/delivery-ledger.ts b/packages/concierge-realtime/src/delivery-ledger.ts index b83aea4..64e64d8 100644 --- a/packages/concierge-realtime/src/delivery-ledger.ts +++ b/packages/concierge-realtime/src/delivery-ledger.ts @@ -1,6 +1,7 @@ import type { DeliveryReport, ReadbackAttestation } from "@full-self-browsing/concierge"; import { asRecord, + createBoundedStore, createDiagnostic, notifyDiagnostic, ownData, @@ -12,6 +13,8 @@ import type { RealtimeDeliveryLedgerConfig, } from "./types.js"; +const DEFAULT_MAX_TRACKED_ORIGINS: number = 256; + interface DeliveryGroup { readonly originResponseId: string; originTurnId: string | null; @@ -73,8 +76,22 @@ export function createRealtimeDeliveryLedger( config: RealtimeDeliveryLedgerConfig, ): RealtimeDeliveryLedger { const scheduler = resolveScheduler(config.scheduler); - const hashesByOrigin: Map = new Map(); - const turnsByOrigin: Map = new Map(); + // 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(); @@ -134,6 +151,8 @@ export function createRealtimeDeliveryLedger( 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); const report: DeliveryReport = Object.freeze({ responseId: group.originResponseId, outcome, diff --git a/packages/concierge-realtime/src/host.ts b/packages/concierge-realtime/src/host.ts index e157750..d5f3846 100644 --- a/packages/concierge-realtime/src/host.ts +++ b/packages/concierge-realtime/src/host.ts @@ -144,6 +144,54 @@ export function asRecord(value: unknown): object | 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 = diff --git a/packages/concierge-realtime/test/delivery-ledger.test.ts b/packages/concierge-realtime/test/delivery-ledger.test.ts index 3b36734..ab3da2d 100644 --- a/packages/concierge-realtime/test/delivery-ledger.test.ts +++ b/packages/concierge-realtime/test/delivery-ledger.test.ts @@ -248,3 +248,54 @@ describe("attestation snapshotting", () => { 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", () => { + const ledger = createRealtimeDeliveryLedger({}); + ledger.attachReadbackHash("origin-keep", "hash-keep"); + 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"); + + expect(reports).toHaveLength(1); + expect(reports[0].readbackHash).toBe("hash-keep"); + }); +}); diff --git a/packages/concierge/src/bridge.ts b/packages/concierge/src/bridge.ts index 4e14746..1f4b130 100644 --- a/packages/concierge/src/bridge.ts +++ b/packages/concierge/src/bridge.ts @@ -412,8 +412,15 @@ export function awaitRegistration( 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 cancelWhenAvailable: boolean = false; let firedDuringRegistration: boolean = false; let registrationComplete: boolean = false; let unsubscribe: (() => void) | null = null; @@ -502,13 +509,6 @@ export function awaitRegistration( } cancel = scheduledCancel as () => void; registrationComplete = true; - if (cancelWhenAvailable) { - try { - cancel(); - } catch { - // ignore - } - } if (firedDuringRegistration && !settled) { finish({ status: "timed-out" }, false); } diff --git a/packages/concierge/src/concierge.ts b/packages/concierge/src/concierge.ts index f57ed17..e931bde 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -1505,7 +1505,35 @@ export function createConcierge(config: ConciergeConfig): Concierge { 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. */ @@ -4777,8 +4805,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { ) { return "malformed"; } - usedAttestationActIds ??= new Set(); - if (usedAttestationActIds.has(actId)) { + if (usedAttestationActIds?.has(actId) === true) { return "already_attested"; } if (consentGenerations === null) { @@ -4833,7 +4860,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { return "already_attested"; } if (act === "declined" || act === "dismissed") { - usedAttestationActIds.add(actId); + rememberActId(actId); consentGenerations.set( matched.slotKey, Object.freeze({ ...matched.generation, status: act }), @@ -4849,7 +4876,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { if (!("payload" in current)) { return "unknown_readback"; } - usedAttestationActIds.add(actId); + rememberActId(actId); consentGenerations.set( matched.slotKey, Object.freeze({ diff --git a/packages/concierge/src/rendition.ts b/packages/concierge/src/rendition.ts index 52cb8d7..93ea150 100644 --- a/packages/concierge/src/rendition.ts +++ b/packages/concierge/src/rendition.ts @@ -61,6 +61,44 @@ export interface RenditionBinder { 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 below 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 these two unbounded would make the binder + * the only thing in a voice session that grows with its length. + */ +function createSettledMemory(): { + has(id: string): boolean; + add(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); + } + }, + clear(): void { + ids.clear(); + order.length = 0; + }, + }; +} function usableId(value: unknown): string | null { return typeof value === "string" && @@ -103,7 +141,8 @@ export function createRenditionBinder( const causeToRendition: Map = new Map(); const renditionToCauses: Map = new Map(); const started: Set = new Set(); - const settledCauses: Set = new Set(); + const settledCauses = createSettledMemory(); + const settledRenditions = createSettledMemory(); function reportIssue(issue: RenditionIssue): void { if (onIssue !== undefined) { @@ -282,18 +321,35 @@ export function createRenditionBinder( } const bound: string[] | undefined = renditionToCauses.get(id); if (bound === undefined) { - reportIssue({ - code: "unbound_rendition", - causeResponseId: null, - renditionResponseId: id, - message: - `settlement named rendition ${encodeDiagnosticSubject(id)} with no bound cause.`, - }); + // **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) ?? []; @@ -342,6 +398,7 @@ export function createRenditionBinder( renditionToCauses.clear(); started.clear(); settledCauses.clear(); + settledRenditions.clear(); }, pendingCauses(): ReadonlyArray { diff --git a/packages/concierge/src/resolve-value.ts b/packages/concierge/src/resolve-value.ts index ef6ab67..8e09c2d 100644 --- a/packages/concierge/src/resolve-value.ts +++ b/packages/concierge/src/resolve-value.ts @@ -151,40 +151,27 @@ export function resolveValue( return refuse("no-match"); } - const exactIdentity: Prepared[] = prepared.filter( + // 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 (exactIdentity.length === 1) { - const only: Prepared = exactIdentity[0]!; - const sameLabelDifferentId: boolean = prepared.some( - (row) => - row.label === query && - row.identity !== only.identity && - !Object.is(row.item, only.item), - ); - if (sameLabelDifferentId) { - const tied: T[] = exactIdentity - .filter((row, index, rows) => - rows.findIndex((other) => other.identity === row.identity) === index - ) - .map((row) => row.item) - .slice(0, maxAmbiguous); - if (tied.length >= 2) { - return refuse("ambiguous", Object.freeze(tied)); - } - } - return { ok: true, match: only.item }; + if (exactLabel.length === 1) { + return { ok: true, match: exactLabel[0]!.item }; } - if (exactIdentity.length > 1) { + if (exactLabel.length > 1) { const identities: Set = new Set( - exactIdentity.map((row) => row.identity), + exactLabel.map((row) => row.identity), ); if (identities.size === 1) { - return { ok: true, match: exactIdentity[0]!.item }; + return { ok: true, match: exactLabel[0]!.item }; } return refuse( "ambiguous", - Object.freeze(exactIdentity.map((row) => row.item).slice(0, maxAmbiguous)), + Object.freeze(exactLabel.map((row) => row.item).slice(0, maxAmbiguous)), ); } diff --git a/packages/concierge/test/rendition.test.ts b/packages/concierge/test/rendition.test.ts index 5ea7e48..8eddc27 100644 --- a/packages/concierge/test/rendition.test.ts +++ b/packages/concierge/test/rendition.test.ts @@ -100,3 +100,29 @@ describe("createRenditionBinder", () => { 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); + }); +}); diff --git a/packages/concierge/test/resolve-value.test.ts b/packages/concierge/test/resolve-value.test.ts index 9caf733..9848852 100644 --- a/packages/concierge/test/resolve-value.test.ts +++ b/packages/concierge/test/resolve-value.test.ts @@ -101,3 +101,38 @@ describe("resolveValue", () => { 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 }); + }); +}); From ba0927121d52a96aa460f09c50d0d49b63661bdd Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Wed, 16 Sep 2026 12:47:57 -0500 Subject: [PATCH 5/8] docs: describe the release set, ref protocol, and snapshot walk as they are RELEASING.md was still the 0.3 document -- a trio, .release/lines/0.3.json, contract v3 -- while CONTRIBUTING.md and .release/lines/0.4.json declare the five-package v4 set, and CONTRIBUTING points contributors here for the ceremony. It now names the five packages in publish order and records where the set is enforced. It also records what the compatibility gate does not cover. scripts/release/compatibility.mjs installs core, React, and Svelte archives into consumer cells; concierge-dom and concierge-realtime are in the release set but have no cell, so their certification is package.mjs, attw, publint, and the seal. Silence there is a gap, not a pass. buildCatalog's snapshotSources walk reads registry.read(), so a catalog built at module scope inspects nothing and reports nothing. That reads as a clean bill of health and is not one. The JSDoc and migration guide now say so, and say that the runtime consent_stale refusal is the gate that holds without the rebuild. The concierge-dom README documents the ref cleanup protocol and what the element-less React 18 detach can and cannot recover. --- .changeset/contract-v4-core.md | 2 + RELEASING.md | 84 +++++++++++++++++++------------ docs/migrations/0.3-to-0.4.md | 7 +++ packages/concierge-dom/README.md | 10 ++++ packages/concierge/src/catalog.ts | 12 +++++ 5 files changed, 83 insertions(+), 32 deletions(-) diff --git a/.changeset/contract-v4-core.md b/.changeset/contract-v4-core.md index 8d5f72f..a36c3a5 100644 --- a/.changeset/contract-v4-core.md +++ b/.changeset/contract-v4-core.md @@ -7,3 +7,5 @@ --- 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. diff --git a/RELEASING.md b/RELEASING.md index 859d3c8..48f6d57 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,12 @@ 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. +`scripts/release/compatibility.mjs` covers core, React, and Svelte only. +`concierge-dom` and `concierge-realtime` are in the fixed release set but not +yet in this matrix, so their archives are certified by `package.mjs`, `attw`, +`publint`, and the seal — not by an installed-consumer cell. Treat that as a +known gap in the gate, not as a pass. + To prepare the exact AI 7 example in a new path for a local browser run: ```sh @@ -198,7 +216,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 +225,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 +245,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 +283,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 +326,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/docs/migrations/0.3-to-0.4.md b/docs/migrations/0.3-to-0.4.md index aeb223f..a608677 100644 --- a/docs/migrations/0.3-to-0.4.md +++ b/docs/migrations/0.3-to-0.4.md @@ -66,6 +66,13 @@ 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 diff --git a/packages/concierge-dom/README.md b/packages/concierge-dom/README.md index 897944d..75579f9 100644 --- a/packages/concierge-dom/README.md +++ b/packages/concierge-dom/README.md @@ -53,6 +53,16 @@ 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. diff --git a/packages/concierge/src/catalog.ts b/packages/concierge/src/catalog.ts index 17ebb0a..bc96c2f 100644 --- a/packages/concierge/src/catalog.ts +++ b/packages/concierge/src/catalog.ts @@ -423,6 +423,18 @@ export interface BuildCatalogOptions { * 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<{ From 46ed5186f557fc9d3d2510e0fb74a7409aec0806 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Thu, 17 Sep 2026 11:27:02 -0500 Subject: [PATCH 6/8] fix: stop the delivery path from revoking the consent it should arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core reads DeliveryReport.readbackHash in exactly one place — to substantiate a claim to `attested` — and answers a claim it cannot substantiate by closing the consent generation. Three separate paths handed it claims that were never claims. The realtime attestation hold settled with the hash and no attestation when it elapsed, so an unanswered readback revoked consent instead of resting at `relayed`, and a person confirming a moment later got `unknown_readback`. Since `attested` is unreachable without attestationWindowMs, that was the whole attested realtime path. The hash now travels with the attestation or not at all. snapshotAttestation required an own-data `userTurnId` that ReadbackAttestation declares optional, so a report that typechecks failed the snapshot outright — indistinguishable from a hostile one, and answered the same way. Absent is now accepted and the refusal stays with validConfirm, which already demands a non-empty confirming turn distinct from the review's; a `declined` act without one is recorded as the human decision it is rather than erased. A session that reconnected while a publication was unacknowledged republished `currentCatalog`, which is null before the first acknowledgement — nothing was re-sent and the session never promoted a catalog at all. After it, re-sending the promoted revision drew an acknowledgement that failed against the pending head. Reconnect now re-sends the revision the transport still owes. Also bounds the rendition binder's `started` set, the last structure in that file still growing with the session: settle dropped an id only once it had a bound cause, so every playback with nothing deferred against it left its string behind. --- .../concierge-realtime/src/delivery-ledger.ts | 19 ++++- .../test/delivery-ledger.test.ts | 28 +++++++- packages/concierge/src/consent-evidence.ts | 16 ++++- packages/concierge/src/rendition.ts | 34 ++++++--- packages/concierge/src/session.ts | 30 ++++++-- packages/concierge/src/types.ts | 6 ++ .../concierge/test/consent-evidence.test.ts | 71 +++++++++++++++++++ packages/concierge/test/rendition.test.ts | 43 +++++++++++ .../test/session-acknowledgement.test.ts | 69 ++++++++++++++++++ 9 files changed, 294 insertions(+), 22 deletions(-) create mode 100644 packages/concierge/test/consent-evidence.test.ts diff --git a/packages/concierge-realtime/src/delivery-ledger.ts b/packages/concierge-realtime/src/delivery-ledger.ts index 64e64d8..3d7a61d 100644 --- a/packages/concierge-realtime/src/delivery-ledger.ts +++ b/packages/concierge-realtime/src/delivery-ledger.ts @@ -153,10 +153,27 @@ export function createRealtimeDeliveryLedger( 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, - ...(group.readbackHash === undefined ? {} : { readbackHash: group.readbackHash }), + ...(attestation === undefined || group.readbackHash === undefined + ? {} + : { readbackHash: group.readbackHash }), ...(attestation === undefined ? {} : { attestation }), }); runEffects(group, report); diff --git a/packages/concierge-realtime/test/delivery-ledger.test.ts b/packages/concierge-realtime/test/delivery-ledger.test.ts index ab3da2d..6f83402 100644 --- a/packages/concierge-realtime/test/delivery-ledger.test.ts +++ b/packages/concierge-realtime/test/delivery-ledger.test.ts @@ -129,7 +129,13 @@ describe("createRealtimeDeliveryLedger", () => { ]); }); - it("emits a completed report without attestation when the window elapses", () => { + 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({ @@ -152,10 +158,10 @@ describe("createRealtimeDeliveryLedger", () => { expect.objectContaining({ responseId: "origin", outcome: "completed", - readbackHash: "hash-1", }), ]); expect(reports[0]?.attestation).toBeUndefined(); + expect(reports[0]).not.toHaveProperty("readbackHash"); }); it("settles interruption immediately and keeps playing groups when asked", () => { @@ -284,8 +290,18 @@ describe("bounded bookkeeping", () => { }); it("still reports an origin's readback hash after many later responses", () => { - const ledger = createRealtimeDeliveryLedger({}); + // 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}`); } @@ -294,6 +310,12 @@ describe("bounded bookkeeping", () => { 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/src/consent-evidence.ts b/packages/concierge/src/consent-evidence.ts index ad24d39..3ae75d1 100644 --- a/packages/concierge/src/consent-evidence.ts +++ b/packages/concierge/src/consent-evidence.ts @@ -771,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", @@ -781,7 +793,7 @@ function snapshotAttestation( ); if ( act === null || - userTurnId === null || + (hasUserTurnId && userTurnId === null) || readbackHash === null || !shapeStillMatches(value, shape) ) { @@ -790,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/rendition.ts b/packages/concierge/src/rendition.ts index 93ea150..87041e7 100644 --- a/packages/concierge/src/rendition.ts +++ b/packages/concierge/src/rendition.ts @@ -66,15 +66,26 @@ const SETTLED_MEMORY: number = 512; /** * A membership set that forgets its oldest entry past a cap. * - * The two settlement memories below 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 these two unbounded would make the binder - * the only thing in a voice session that grows with its length. + * 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 createSettledMemory(): { +function createBoundedIdSet(): { has(id: string): boolean; add(id: string): void; + delete(id: string): void; clear(): void; } { const ids: Set = new Set(); @@ -93,6 +104,11 @@ function createSettledMemory(): { 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; @@ -140,9 +156,9 @@ export function createRenditionBinder( const causes: Map = new Map(); const causeToRendition: Map = new Map(); const renditionToCauses: Map = new Map(); - const started: Set = new Set(); - const settledCauses = createSettledMemory(); - const settledRenditions = createSettledMemory(); + const started = createBoundedIdSet(); + const settledCauses = createBoundedIdSet(); + const settledRenditions = createBoundedIdSet(); function reportIssue(issue: RenditionIssue): void { if (onIssue !== undefined) { diff --git a/packages/concierge/src/session.ts b/packages/concierge/src/session.ts index f75287a..11468fa 100644 --- a/packages/concierge/src/session.ts +++ b/packages/concierge/src/session.ts @@ -686,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(); } }; diff --git a/packages/concierge/src/types.ts b/packages/concierge/src/types.ts index 614b383..9d051b7 100644 --- a/packages/concierge/src/types.ts +++ b/packages/concierge/src/types.ts @@ -427,6 +427,12 @@ export interface ReadbackAttestation { * 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; } 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/rendition.test.ts b/packages/concierge/test/rendition.test.ts index 8eddc27..a6d2776 100644 --- a/packages/concierge/test/rendition.test.ts +++ b/packages/concierge/test/rendition.test.ts @@ -126,3 +126,46 @@ describe("settlement bookkeeping", () => { 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/session-acknowledgement.test.ts b/packages/concierge/test/session-acknowledgement.test.ts index 5e9037b..fa2b571 100644 --- a/packages/concierge/test/session-acknowledgement.test.ts +++ b/packages/concierge/test/session-acknowledgement.test.ts @@ -217,3 +217,72 @@ it("subscribes through a method that reads its receiver", async () => { 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(); +}); From a6d8d2e5526f89749e1855fdcb1e7f8b57683e57 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Thu, 17 Sep 2026 11:44:47 -0500 Subject: [PATCH 7/8] ci: point the pipeline at the 0.4 five-package set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has failed on every 0.4 commit, dying in the first step before build, typecheck or test ever ran. Three gates were still written for the 0.3 trio. version.mjs spelled its self-test fixture as a literal `^0.2.1 || ^0.3.0` and checked it against `config.releaseLine`, so moving the line to 0.4 made the check demand a 0.3 target from a 0.4 config and fail — a gate reporting its own staleness as a policy violation. The fixture now derives from the live line, and a transition targeting a foreign line is asserted to be rejected. package.mjs asserted `new Set(names).size === 3`, so adding the fourth and fifth packages made a uniqueness check fail for a set that is unique. It now counts the release line, and the identity assertion names the last package in the published order. The runtime job required `CONTRACT_VERSION === 3`, which contract v4 could never satisfy. It now requires 4 across core, ai-sdk and dom, and imports concierge-dom and all three concierge-realtime subpaths — both packages ship in the fixed set and nothing installed them into a consumer before. --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++------- scripts/release/package.mjs | 19 ++++++++++++++----- scripts/release/version.mjs | 28 +++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 15 deletions(-) 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/scripts/release/package.mjs b/scripts/release/package.mjs index ad1988e..19d366b 100644 --- a/scripts/release/package.mjs +++ b/scripts/release/package.mjs @@ -176,12 +176,21 @@ 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, not against a literal.** The cap was + // `=== 3` for the 0.3 trio, so adding the fourth and fifth packages made a + // uniqueness check fail for a set that is in fact unique. 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/version.mjs b/scripts/release/version.mjs index 5b3a708..8126e13 100644 --- a/scripts/release/version.mjs +++ b/scripts/release/version.mjs @@ -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); From e95a7b91930409075e3b9b38d7417f1d071bf94b Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Fri, 18 Sep 2026 03:52:07 -0500 Subject: [PATCH 8/8] release: prepare the 0.4.0 five-package set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `2ffb743` declared the 0.4 line but never touched the release workflow, whose last commit is the 0.3 release. Everything here follows from that. The publish launcher required `contractVersion === 3`, so a v4 seal could only ever throw. Its pinned tool digests were stale for `config.mjs` and named the retired `0.3.json` for a sealed file that `seal.mjs` fills from the live line — a split-brain `check.mjs` shared, digesting 0.3 while the seal carried 0.4, so the gate could pass while publish was guaranteed to fail. `check.mjs` now digests `config.path`, and all three pins are recomputed. `compatibility.mjs` probed the whole release set inside the Next example while injecting only three of it, so the topology probe threw "Cannot find module" before reaching an assertion — latent, because CI has never got that far. The probe now reads the same list that builds the manifest. The framework cells carry all five archives, which is what retires the certification gap `RELEASING.md` disclosed: dom and realtime are imported from their exact archives with no DOM present, typecheck under `skipLibCheck: false`, and prove one physical core. That gate immediately caught a missing required field in the realtime consumer probe. `checkContractV4` guarded four of the five packages; realtime declared the same guard unchecked. Versions move to 0.4.0 through `version.mjs apply`. The bounded core-peer transition `RELEASING.md` prescribes for a pre-1.0 minor is required, not optional: without it Changesets reads the new core as out of range for every dependent peer and bumps the set to 1.0.0. `artifact.test.ts` pinned `^0\.3\.\d+$`, turning the first correct 0.4 manifest into a failure; it now reads the live release line. Consumer-facing documentation was two lines behind: SECURITY.md told installers to verify four packages at 0.2.x, and core's README — which ships inside the tarball — claimed contract 3 and linked only the 0.2→0.3 migration. The root README never mentioned the two packages being published for the first time. --- .changeset/concierge-dom.md | 9 --- .changeset/contract-v4-core.md | 11 ---- .github/workflows/release.yml | 14 ++--- COMPATIBILITY.md | 11 +++- HANDOFF.md | 2 +- README.md | 19 ++++++ RELEASING.md | 12 ++-- SECURITY.md | 6 +- docs/integrations/structured-results.md | 2 +- examples/next-ai-sdk/README.md | 2 +- packages/concierge-dom/CHANGELOG.md | 9 +++ packages/concierge-dom/package.json | 2 +- packages/concierge-react/CHANGELOG.md | 9 +++ packages/concierge-react/package.json | 2 +- packages/concierge-realtime/CHANGELOG.md | 9 +++ packages/concierge-realtime/package.json | 2 +- packages/concierge-svelte/CHANGELOG.md | 9 +++ packages/concierge-svelte/package.json | 2 +- packages/concierge/CHANGELOG.md | 9 +++ packages/concierge/README.md | 8 +-- packages/concierge/package.json | 2 +- .../concierge/test/ai-sdk/artifact.test.ts | 11 +++- scripts/release/archive.mjs | 2 +- scripts/release/check.mjs | 20 ++++-- scripts/release/compatibility.mjs | 63 ++++++++++++++++--- scripts/release/package.mjs | 5 +- scripts/release/publisher.mjs | 2 +- scripts/release/version.mjs | 4 +- 28 files changed, 188 insertions(+), 70 deletions(-) delete mode 100644 .changeset/concierge-dom.md delete mode 100644 .changeset/contract-v4-core.md diff --git a/.changeset/concierge-dom.md b/.changeset/concierge-dom.md deleted file mode 100644 index cbf2430..0000000 --- a/.changeset/concierge-dom.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@full-self-browsing/concierge": minor -"@full-self-browsing/concierge-react": minor -"@full-self-browsing/concierge-svelte": minor -"@full-self-browsing/concierge-dom": minor -"@full-self-browsing/concierge-realtime": minor ---- - -Ship `@full-self-browsing/concierge-dom`: registered-element resolve, reveal, and untrusted readback. diff --git a/.changeset/contract-v4-core.md b/.changeset/contract-v4-core.md deleted file mode 100644 index a36c3a5..0000000 --- a/.changeset/contract-v4-core.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@full-self-browsing/concierge": minor -"@full-self-browsing/concierge-react": minor -"@full-self-browsing/concierge-svelte": minor -"@full-self-browsing/concierge-dom": minor -"@full-self-browsing/concierge-realtime": minor ---- - -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. 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/COMPATIBILITY.md b/COMPATIBILITY.md index c633959..0893b9d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -9,6 +9,8 @@ fixed release set and share runtime contract v4. | --- | --- | --- | | Node.js | `>=22.12.0` | 22.12 floor consumer and Node 24 CI/publisher | | `@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 | @@ -55,9 +57,12 @@ contract. Other AI SDK providers can consume the same `ToolSet`. - CommonJS output and `require()` are not supported. Use ESM imports. The release gate installs the packed public set 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 +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. diff --git a/HANDOFF.md b/HANDOFF.md index 100c227..42bfc4c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -52,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 diff --git a/README.md b/README.md index 20ffd49..9abf04c 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,25 @@ pnpm add @full-self-browsing/concierge@^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. diff --git a/RELEASING.md b/RELEASING.md index 48f6d57..08e8a99 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -199,11 +199,13 @@ 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. -`scripts/release/compatibility.mjs` covers core, React, and Svelte only. -`concierge-dom` and `concierge-realtime` are in the fixed release set but not -yet in this matrix, so their archives are certified by `package.mjs`, `attw`, -`publint`, and the seal — not by an installed-consumer cell. Treat that as a -known gap in the gate, not as a pass. +`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: 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/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/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/packages/concierge-dom/CHANGELOG.md b/packages/concierge-dom/CHANGELOG.md index 1f9f581..748a7fb 100644 --- a/packages/concierge-dom/CHANGELOG.md +++ b/packages/concierge-dom/CHANGELOG.md @@ -1,5 +1,14 @@ # @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 diff --git a/packages/concierge-dom/package.json b/packages/concierge-dom/package.json index d8066e3..c2743e3 100644 --- a/packages/concierge-dom/package.json +++ b/packages/concierge-dom/package.json @@ -1,6 +1,6 @@ { "name": "@full-self-browsing/concierge-dom", - "version": "0.3.0", + "version": "0.4.0", "description": "Framework-neutral DOM helpers for @full-self-browsing/concierge", "keywords": [ "ai", 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/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-realtime/CHANGELOG.md b/packages/concierge-realtime/CHANGELOG.md index 7f5179a..8896da4 100644 --- a/packages/concierge-realtime/CHANGELOG.md +++ b/packages/concierge-realtime/CHANGELOG.md @@ -1,5 +1,14 @@ # @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 diff --git a/packages/concierge-realtime/package.json b/packages/concierge-realtime/package.json index e59f463..2cf4318 100644 --- a/packages/concierge-realtime/package.json +++ b/packages/concierge-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@full-self-browsing/concierge-realtime", - "version": "0.3.0", + "version": "0.4.0", "description": "Realtime voice session runtime for @full-self-browsing/concierge", "keywords": [ "ai", 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/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/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 11ce8c7..6f7ad59 100644 --- a/packages/concierge/README.md +++ b/packages/concierge/README.md @@ -174,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 fed1417..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", 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/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 6b4eb5b..50be752 100644 --- a/scripts/release/check.mjs +++ b/scripts/release/check.mjs @@ -177,10 +177,14 @@ function checkContractV4() { "CONTRACT_VERSION", "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( @@ -237,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 e690079..4063646 100644 --- a/scripts/release/compatibility.mjs +++ b/scripts/release/compatibility.mjs @@ -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 !== 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/package.mjs b/scripts/release/package.mjs index 19d366b..9acfd56 100644 --- a/scripts/release/package.mjs +++ b/scripts/release/package.mjs @@ -176,9 +176,8 @@ function selfTest() { const config = loadReleaseLine(); const names = config.packages.map((entry) => expectedArchiveFilename(entry.name, config.initialVersion)); - // **Counted against the release line, not against a literal.** The cap was - // `=== 3` for the 0.3 trio, so adding the fourth and fifth packages made a - // uniqueness check fail for a set that is in fact 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( new Set(names).size === names.length, "SELF_TEST", 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 8126e13..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", ); }