From d91560d8ffca34d82c153ae5ebbf00d226c06aa7 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Tue, 25 Aug 2026 18:48:18 -0500 Subject: [PATCH 1/5] feat: add structured results and Realtime codec --- .github/workflows/ci.yml | 12 +- .github/workflows/release.yml | 6 +- .release/lines/{0.2.json => 0.3.json} | 6 +- COMPATIBILITY.md | 26 +- CONTRIBUTING.md | 15 +- README.md | 37 +- RELEASING.md | 20 +- SUPPORT.md | 20 +- docs/integrations/ai-sdk.md | 11 +- docs/integrations/openai-realtime.md | 108 ++++++ docs/integrations/structured-results.md | 106 ++++++ docs/migrations/0.2-to-0.3.md | 111 ++++++ packages/concierge-react/CHANGELOG.md | 7 + packages/concierge-react/README.md | 4 +- packages/concierge-react/package.json | 2 +- packages/concierge-react/src/client.tsx | 2 +- .../concierge-react/test/artifact.test.ts | 4 +- .../concierge-react/test/lifecycle.test.tsx | 4 +- packages/concierge-svelte/CHANGELOG.md | 8 + packages/concierge-svelte/README.md | 4 +- packages/concierge-svelte/package.json | 2 +- .../concierge-svelte/src/client.svelte.ts | 2 +- .../concierge-svelte/test/artifact.test.ts | 2 +- .../concierge-svelte/test/lifecycle.test.ts | 4 +- packages/concierge/CHANGELOG.md | 16 + packages/concierge/README.md | 54 ++- packages/concierge/package.json | 6 +- packages/concierge/src/ai-sdk/index.ts | 10 +- packages/concierge/src/ai-sdk/wire.ts | 4 +- packages/concierge/src/bridge.ts | 9 +- packages/concierge/src/catalog.ts | 142 ++++++- packages/concierge/src/concierge.ts | 171 +++++++-- packages/concierge/src/contract.ts | 8 +- packages/concierge/src/define-action.ts | 7 +- packages/concierge/src/dispatch.ts | 327 ++++++++++++++-- packages/concierge/src/index.ts | 9 +- .../concierge/src/openai-realtime/index.ts | 291 ++++++++++++++ packages/concierge/src/types.ts | 146 ++++++- packages/concierge/test-d/ai-sdk.test-d.ts | 2 +- packages/concierge/test-d/bridge.test-d.ts | 4 +- packages/concierge/test-d/catalog.test-d.ts | 2 +- packages/concierge/test-d/concierge.test-d.ts | 2 +- .../concierge/test-d/dispatcher.test-d.ts | 2 +- packages/concierge/test-d/results.test-d.ts | 7 +- .../test-d/structured-results.test-d.ts | 126 ++++++ .../concierge/test/action-bridges.test.ts | 166 ++++++++ .../concierge/test/ai-sdk/adapter.test.ts | 18 +- .../concierge/test/ai-sdk/artifact.test.ts | 2 +- .../test/ai-sdk/signed-bridge.test.ts | 14 +- packages/concierge/test/artifact.test.ts | 4 +- .../concierge/test/bridge-snapshot.test.ts | 2 +- packages/concierge/test/concierge.test.ts | 10 +- packages/concierge/test/core-v2.test.ts | 4 +- packages/concierge/test/dispatcher.test.ts | 9 +- .../concierge/test/export-surface.test.ts | 13 +- packages/concierge/test/fixtures/probe.ts | 13 +- .../concierge/test/fixtures/stub-transport.ts | 10 +- .../concierge/test/openai-realtime.test.ts | 246 ++++++++++++ .../concierge/test/single-instance.test.ts | 6 +- .../concierge/test/structured-results.test.ts | 359 ++++++++++++++++++ .../concierge/test/telemetry/runtime.test.ts | 2 +- packages/concierge/tsdown.config.ts | 2 +- scripts/pack-install-check.sh | 12 + scripts/release/check.mjs | 17 +- scripts/release/compatibility.mjs | 19 +- scripts/release/config.mjs | 8 +- scripts/release/package.mjs | 2 +- scripts/release/version.mjs | 8 +- 68 files changed, 2574 insertions(+), 240 deletions(-) rename .release/lines/{0.2.json => 0.3.json} (94%) create mode 100644 docs/integrations/openai-realtime.md create mode 100644 docs/integrations/structured-results.md create mode 100644 docs/migrations/0.2-to-0.3.md create mode 100644 packages/concierge/src/openai-realtime/index.ts create mode 100644 packages/concierge/test-d/structured-results.test-d.ts create mode 100644 packages/concierge/test/action-bridges.test.ts create mode 100644 packages/concierge/test/openai-realtime.test.ts create mode 100644 packages/concierge/test/structured-results.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a93137..15b028d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Validate v0.2 source and release policy + - name: Validate v0.3 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.2-archives-${{ github.sha }} + name: v0.3-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.2-archives-${{ github.sha }} + name: v0.3-archives-${{ github.sha }} path: archives - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -88,8 +88,8 @@ jobs: await import("@full-self-browsing/concierge-react"); await import("@full-self-browsing/concierge-svelte"); assertSingleInstance(); - if (CONTRACT_VERSION !== 2 || adapter.EXPECTED_CORE_CONTRACT_VERSION !== 2) { - throw new Error("contract v2 did not survive the packed install"); + if (CONTRACT_VERSION !== 3 || adapter.EXPECTED_CORE_CONTRACT_VERSION !== 3) { + throw new Error("contract v3 did not survive the packed install"); } NODE @@ -111,7 +111,7 @@ jobs: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: v0.2-archives-${{ github.sha }} + name: v0.3-archives-${{ github.sha }} path: archives - run: node scripts/release/compatibility.mjs "$(realpath archives)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 624efd6..f7f6f47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 !== 2 + seal.distTag !== "latest" || seal.contractVersion !== 3 ) throw new Error("release seal identity or digest drifted"); const pinned = { - "config.mjs": "f6ecee16c34c1cf27b6f2eeee3701314c2b4324db152791111cecc6ab51299a7", + "config.mjs": "704e722e96934ac6f4534a28953e185aafd369713dd38f2964d3fa62523e10a4", "release-publisher.mjs": "a6757a8a8c5f4ef67ab6318e8f492844c6b100bf0f12051a6fd1d5b9bd9f0636", - "release-line.json": "06706bfef1b06028d3066a6e4b938df9ecfbaa196ea0bafba5ae39f1a32d3da0", + "release-line.json": "2fd76bdd314bfa509ede3a0e8b4a044c50fe556a1fcec015378986a2b6df899c", }; const expected = ["release-seal.json"]; for (const record of seal.tools) { diff --git a/.release/lines/0.2.json b/.release/lines/0.3.json similarity index 94% rename from .release/lines/0.2.json rename to .release/lines/0.3.json index eae48ea..bc14cbd 100644 --- a/.release/lines/0.2.json +++ b/.release/lines/0.3.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "releaseLine": "0.2", - "contractVersion": 2, - "initialVersion": "0.2.0", + "releaseLine": "0.3", + "contractVersion": 3, + "initialVersion": "0.3.0", "distTag": "latest", "registry": "https://registry.npmjs.org/", "repository": "fullselfbrowsing/Concierge", diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 46d492e..40a6e47 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,14 +1,14 @@ # Compatibility -Concierge 0.2 is a supported public preview. The three public packages form one -fixed release set and share runtime contract v2. +Concierge 0.3 is a supported public preview. The three public packages form one +fixed release set and share runtime contract v3. ## 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.2.0` | Same patch as every adapter | +| `@full-self-browsing/concierge` | `^0.3.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.2.0 +## AI SDK stacks certified for 0.3.0 | Cell | `ai` | `@ai-sdk/react` | OpenRouter provider | Purpose | | --- | ---: | ---: | ---: | --- | @@ -42,8 +42,10 @@ contract. Other AI SDK providers can consume the same `ToolSet`. to a fail-closed unavailable module under the browser condition. - `@full-self-browsing/concierge/ai-sdk/browser` needs WebCrypto. Its IndexedDB 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. - The full Next example declares the Node runtime. Edge deployment is not part - of the 0.2 support matrix. + of the 0.3 support matrix. - CommonJS output and `require()` are not supported. Use ESM imports. The release gate installs only the packed trio into foreign temporary @@ -55,17 +57,19 @@ WebKit before the OIDC publish job can start. ## Version mixing -Do not mix 0.1 and 0.2 packages. All adapters keep core as a peer dependency, -and every runtime entry checks contract v2 before registration or dispatch. +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 +or dispatch. Upgrade the trio and regenerate the lockfile together: ```sh -pnpm up @full-self-browsing/concierge@^0.2 \ - @full-self-browsing/concierge-react@^0.2 \ - @full-self-browsing/concierge-svelte@^0.2 +pnpm up @full-self-browsing/concierge@^0.3 \ + @full-self-browsing/concierge-react@^0.3 \ + @full-self-browsing/concierge-svelte@^0.3 pnpm why @full-self-browsing/concierge ``` The final command should converge on one physical core version. See the -[0.1-to-0.2 migration guide](./docs/migrations/0.1-to-0.2.md) for API changes. +[0.2-to-0.3 migration guide](./docs/migrations/0.2-to-0.3.md) for API changes +and backward-compatible adoption guidance. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec73de2..b4c1bfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,11 +69,12 @@ 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 v2 changes +## Contract v3 changes -Contract v2 includes atomic `ResolvedCatalog` revisions, object-form dispatch, -explicit terminal batch outcomes, dispatch lifecycle events, compound-action -lineage, and the signed AI bridge's core dependencies. +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. An additive implementation detail does not require a contract bump. A change that lets two versions disagree about bridge shape, revision capability, @@ -85,7 +86,7 @@ or signed dispatch interpretation does. Contract changes require: 3. mismatch mutations proving failure occurs before registration or dispatch; 4. a migration guide and compatibility update. -Contract v2 is fixed throughout `0.2.x`. +Contract v3 is fixed throughout `0.3.x`. ## Tests and checks @@ -142,7 +143,7 @@ adapter dependency. Core exposes AI SDK support only through explicit subpaths and keeps `ai` as the optional peer `^6.0.0 || ^7.0.0`. For a pre-1.0 minor transition only, commit a bounded old/new source range such -as `workspace:^0.2.3 || ^0.3.0`. Never use `>=0.0.0`. The release versioner +as `workspace:^0.3.3 || ^0.4.0`. Never use `>=0.0.0`. The release versioner validates that the second arm is the actual output and normalizes the Version Packages PR back to `workspace:^`, which pnpm packs as the new compatible minor. @@ -156,7 +157,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.2.json`, `scripts/release/`, and +contract is `.release/lines/0.3.json`, `scripts/release/`, and `.github/workflows/release.yml`. ## Pull requests diff --git a/README.md b/README.md index 204e9c3..5d08784 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-v2-1B998B?style=for-the-badge) +![Contract](https://img.shields.io/badge/runtime_contract-v3-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,8 +42,9 @@ deduplication, workflow execution, and structured results. It does not own the model, chat interface, planning loop, authentication system, or server authorization policy. -Version `0.2.1` is a supported public preview. The current release uses runtime -contract v2 and ships as one synchronized set of three packages. +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. ### Why Concierge @@ -101,11 +102,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 | v2 throughout `0.2.x` | +| Runtime contract | v3 throughout `0.3.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.2` support matrix. See [COMPATIBILITY.md](./COMPATIBILITY.md) +is not part of the `0.3` support matrix. See [COMPATIBILITY.md](./COMPATIBILITY.md) for the full certified matrix and runtime boundaries. ## Install @@ -119,14 +120,14 @@ pnpm add @full-self-browsing/concierge zod Add the matching framework adapter when needed: ```sh -pnpm add @full-self-browsing/concierge@^0.2 \ - @full-self-browsing/concierge-react@^0.2 \ +pnpm add @full-self-browsing/concierge@^0.3 \ + @full-self-browsing/concierge-react@^0.3 \ zod ``` ```sh -pnpm add @full-self-browsing/concierge@^0.2 \ - @full-self-browsing/concierge-svelte@^0.2 \ +pnpm add @full-self-browsing/concierge@^0.3 \ + @full-self-browsing/concierge-svelte@^0.3 \ zod ``` @@ -208,6 +209,7 @@ 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 | The React adapter includes `ConciergeActivityOverlay` for a configurable edge glow and optional “Powered by FSB” badge. Applications with their own activity @@ -247,8 +249,12 @@ can consume the same `ToolSet`. terminal outcomes. * Compound actions use core-owned child dispatch, cleanup, delay, and bounded lineage. +* Declared structured results are schema-validated, detached, recursively + frozen, bounded, and independently redacted for observers. +* An action-scoped bridge takes precedence over its stage bridge; existing + stage fallback remains unchanged. * `onDispatch` receives redacted lifecycle events without controlling them. -* Mixed `0.1` and `0.2` installations fail before bridge registration or +* Mixed contract-v2 and contract-v3 installations fail before bridge registration or dispatch. ## Telemetry and privacy @@ -270,7 +276,7 @@ stop-and-erase process. ## Security model The action catalog is a least-authority boundary, not an authentication system. -Concierge does not authenticate users, and a client consent record is not +Concierge does not authenticate a user, and a client consent record is not server authorization. A server that performs a protected effect must independently authenticate the current principal, authorize the exact action and payload under current policy, reject replay, and make the effect idempotent @@ -288,9 +294,9 @@ public issue. ## Public preview and support -The documented `0.2` surface is supported as a public preview. Patches do not -intentionally break documented exports or contract v2 wire shapes. Only the -latest `0.2.x` patch receives fixes. +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. 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 @@ -305,10 +311,13 @@ and exclusions. | [React adapter](./packages/concierge-react/README.md) | Context, bridge registration, live state, and activity visuals | | [Svelte adapter](./packages/concierge-svelte/README.md) | Context, bridge registration, rune-aware snapshots, and lifecycle | | [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 | | [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 | | [Migration from 0.1](./docs/migrations/0.1-to-0.2.md) | Contract v2 and API migration guidance | +| [Migration from 0.2](./docs/migrations/0.2-to-0.3.md) | Contract v3 upgrade and backward-compatible adoption guidance | | [Migration from a custom AI SDK adapter](./docs/migrations/custom-ai-sdk-to-concierge.md) | Adoption guidance for existing model tool integrations | | [Support policy](./SUPPORT.md) | Supported surface, maintenance window, and help channels | diff --git a/RELEASING.md b/RELEASING.md index ef8f050..859d3c8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,13 +1,13 @@ -# Releasing Concierge 0.2 +# Releasing Concierge 0.3 -Concierge publishes one fixed trio at one stable `0.2.x` version under the +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. -The live release identity is `.release/lines/0.2.json`. Historical Phase 09 +The live release identity is `.release/lines/0.3.json`. Historical Phase 09 scripts and `.planning` evidence reproduce the unpublished v0.1 milestone; they -do not authorize a 0.2 release and must not be edited into the current flow. +do not authorize a 0.3 release and must not be edited into the current flow. ## Fixed release set @@ -19,7 +19,7 @@ Publish order is load-bearing: 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 v2 is fixed throughout the 0.2 line. +must agree. Contract v3 is fixed throughout the 0.3 line. ## One-time registry bootstrap @@ -71,7 +71,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.2 or newer.");' > index.js +printf '%s\n' 'throw new Error("This is an inert Concierge registry bootstrap; install 0.3 or newer.");' > index.js npm pkg set main='./index.js' npm pkg set exports='./index.js' @@ -145,9 +145,9 @@ job must run on a GitHub-hosted runner and receive only `id-token: write`. A release Changeset names all three public packages at the same bump level. They are one exact fixed group in `.changeset/config.json`. -For a 0.2 patch, keep every adapter's source core peer at `workspace:^`. For a +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.2.3 || ^0.3.0`; the version wrapper verifies the target and +`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. @@ -207,9 +207,9 @@ 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.2.x` version; +- gives all three packages one stable `0.3.x` version; - updates all three changelogs; -- retains contract v2 for a patch; +- retains contract v3 for a patch; - leaves adapter core peers as canonical `workspace:^`; - contains only expected manifest, changelog, and lockfile changes. diff --git a/SUPPORT.md b/SUPPORT.md index f4bcf46..ef0ca43 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,20 +1,20 @@ # Support policy -Concierge 0.2 is a supported public preview. “Supported” means its documented +Concierge 0.3 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.2 support contract includes: +The 0.3 support contract includes: -- documented exports from the four public package export maps; -- runtime contract v2 and the documented signed-envelope v1 wire fields; +- documented exports from the public package export maps; +- runtime contract v3 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.2.x` patches, maintainers will not intentionally break those surfaces. +For `0.3.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,11 +28,15 @@ The following are not stable public surface: ## Maintenance window -Only the latest `0.2.x` patch receives fixes. The 0.2 line is supported until +Only the latest `0.3.x` patch receives general fixes. The 0.3 line is supported until the later of: -- six months after `0.2.0` is published; or -- 90 days after `0.3.0` is published. +- six months after `0.3.0` is published; or +- 90 days after `0.4.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. 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/integrations/ai-sdk.md b/docs/integrations/ai-sdk.md index fbc5539..58bb086 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.2 \ - @full-self-browsing/concierge-react@^0.2 \ +pnpm add @full-self-browsing/concierge@^0.3 \ + @full-self-browsing/concierge-react@^0.3 \ 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 v2 before doing work. +check core contract v3 before doing work. ## 1. Convert an atomic catalog @@ -158,7 +158,7 @@ interface SignedToolBatchEnvelopeV1 { } ``` -The canonical claims bind contract v2, audience, session, catalog stage and +The canonical claims bind contract v3, 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. @@ -244,7 +244,8 @@ const uiUpdates = adapter.toToolOutputUpdates(prepared, completedReport); Both methods verify response ID, row count, tool call IDs, names, and output indices. A mismatch throws `ConciergeAISDKCorrelationError`; never zip unrelated -arrays or guess by tool name. +arrays or guess by tool name. Declared result `data` is preserved as JSON in +both helpers and is never encoded as a nested JSON string. ## Testing diff --git a/docs/integrations/openai-realtime.md b/docs/integrations/openai-realtime.md new file mode 100644 index 0000000..52b7f77 --- /dev/null +++ b/docs/integrations/openai-realtime.md @@ -0,0 +1,108 @@ +# OpenAI Realtime integration + +`@full-self-browsing/concierge/openai-realtime` is a protocol codec for an +application-owned OpenAI Realtime connection. It does not create a peer +connection, fetch credentials, own media tracks, inspect transcripts, schedule +application continuations, or send `response.create`. + +```ts +import { createOpenAIRealtimeCodec } from + "@full-self-browsing/concierge/openai-realtime"; + +const codec = createOpenAIRealtimeCodec(); +``` + +## Publish and acknowledge a catalog + +Resolve one atomic catalog and place the converted tools in `session.update`: + +```ts +const catalog = concierge.resolveCatalog(currentContext); + +sendRealtime({ + type: "session.update", + session: { + type: "realtime", + tools: codec.toSessionTools(catalog), + tool_choice: "auto", + }, +}); +``` + +Sending the event does not activate the revision. Serialize catalog +publications and wait for the corresponding successful `session.updated` +before allowing a response to begin under `catalog.revision`. If the update is +rejected, keep the previous acknowledged revision, pause or cancel new +responses, and report only a bounded application diagnostic. + +When `response.created` arrives, bind its response ID to the currently +acknowledged local revision. Never infer a revision later from the current +page, and never fall back to an unrevisioned dispatch. + +## Extract one complete batch + +Wait for `response.done`. Do not execute from argument-delta or output-item +events. + +```ts +const acknowledgedRevision = revisionByResponseId.get(event.response.id); +if (acknowledgedRevision === undefined) return; + +const batch = codec.extractCompletedBatch({ + response: event, + sessionId, + userTurnId, + catalogRevision: acknowledgedRevision, + signal: responseAbort.signal, + deferUntilDelivered, +}); + +if (batch === null) return; +const outcome = await concierge.dispatchBatch(currentContext, batch); +``` + +The codec accepts a completed `response.done` event or its `response` member. +It preserves `response.id`, exact `call_id`, function name, raw JSON arguments, +and the response-output position. Multiple function calls become one ordered +batch. Duplicate IDs, incomplete calls, cancelled or failed responses, and +malformed event shapes fail closed. Malformed argument JSON remains raw until +core classifies it as `invalid_args`. + +## Send correlated results + +For a completed outcome, send every returned event before requesting the next +model response: + +```ts +for (const outputEvent of codec.toFunctionCallOutputEvents(outcome)) { + sendRealtime(outputEvent); +} + +if (outcome.kind === "completed") { + // Exactly one caller-owned follow-up after every output item is sent. + sendRealtime({ type: "response.create" }); +} +``` + +Each event is `conversation.item.create` with a `function_call_output` item, +the exact original call ID, and one JSON encoding of the normalized Concierge +result. Structured `data` stays a JSON object or array inside that result; it +is not double-stringified. Terminal outcomes emit no ordinary result events, +so the application can tear down its session without generating a follow-up. + +This matches OpenAI's documented flow: complete function calls are available +on `response.done`, function results return through +`conversation.item.create`, and the caller sends `response.create` when it +wants a follow-up response. See the [official Realtime conversations guide](https://developers.openai.com/api/docs/guides/realtime-conversations). + +## Interruption and consent + +Model generation completing is not evidence that audio reached the user. Call +the delivery hook with `outcome: "completed"` only after playback completes. +Speech interruption, output-buffer clearing, cancellation, navigation, +disconnect, and teardown map to `outcome: "interrupted"` and must abort any +pending commit window. + +For acoustic or VAD-derived turns, declare `userTurnIdentity` as +`"agent-forgeable"`. Use `"human-attested"` only for a separate explicit act, +such as a click or keypress, that model output cannot perform. diff --git a/docs/integrations/structured-results.md b/docs/integrations/structured-results.md new file mode 100644 index 0000000..b93e6dc --- /dev/null +++ b/docs/integrations/structured-results.md @@ -0,0 +1,106 @@ +# Structured action results + +Concierge 0.3 lets an action return schema-controlled JSON data to the calling +agent while keeping observer exposure independent and explicit. + +## Declare the output + +An action may return `data` only when it declares `output.schema` and +`output.redact`. The schema is any Standard Schema validator, including Zod, +Valibot, and ArkType. + +```ts +import { defineAction } from "@full-self-browsing/concierge"; +import { z } from "zod"; + +const visibleResultsSchema = z.object({ + kind: z.literal("visible-results"), + hotels: z.array(z.object({ + id: z.string(), + name: z.string(), + nightlyRate: z.number().finite(), + })), +}); + +export const getVisibleResults = defineAction({ + name: "getVisibleResults", + description: "Read the hotels visible on the current results page.", + schema: z.object({}), + redact: "drop", + output: { + schema: visibleResultsSchema, + redact: "drop", + }, + effects: { readOnly: true, destructive: false, idempotent: true }, + handler: ({ bridge }) => ({ + ok: true, + message: "Read the visible hotel results.", + data: { + kind: "visible-results", + hotels: bridge?.snapshot.visibleHotels() ?? [], + }, + }), +}); +``` + +`defineAction` infers the handler's data type from the output schema. Existing +actions without an output declaration continue to return only `ok`, `reason`, +and `message`. + +## Runtime boundary + +After the handler settles, Concierge: + +1. reads only guarded own result properties; +2. validates `data` with the declared output schema; +3. uses the validator's transformed output; +4. rejects non-JSON values, sparse arrays, accessors, exotic instances, + repeated aliases, and cycles; +5. detaches and recursively freezes the accepted value; and +6. enforces `ConciergeConfig.maxActionDataBytes`. + +The default limit is `DEFAULT_ACTION_DATA_MAX_BYTES`, currently 262,144 UTF-8 +JSON bytes. An output that fails any step becomes a bounded +`invalid_result`; rejected data is never copied into diagnostics. + +`data` may accompany success or failure. Use `precondition_failed` when the +call is structurally valid but current application state blocks it, and keep +domain-specific detail inside the output schema: + +```ts +return { + ok: false, + reason: "precondition_failed", + message: "More than one hotel matched.", + data: { + kind: "domain-failure", + code: "hotel-ambiguous", + candidateIds: ["hotel-a", "hotel-b"], + }, +}; +``` + +The 180-character `message` limit and closed core `ReasonCode` union remain in +place. Put long authoritative narration in validated data rather than opening +either field. + +## Observer redaction + +`output.redact` controls only `onDispatch` observer exposure. It never removes +validated data from the result sent back to the agent. + +- `"drop"` emits `{ kind: "dropped" }`. +- `"passthrough"` emits a second detached, frozen copy. +- A projection emits only its returned JSON-safe subset. + +Terminal lifecycle events expose the status under `event.result` and the +separate output decision under `event.resultData`. A throwing, unsafe, or +oversized projection fails closed to `dropped`. Built-in telemetry and +`FailureOutcome` never include structured data. + +## Transport behavior + +`DispatchRow.result`, ordered batches, workflow child results, AI SDK result +parts, signed browser reports, and the OpenAI Realtime codec all carry `data` +as an ordinary JSON value. Adapters must not flatten it, silently discard it, +or encode it as a nested JSON string. diff --git a/docs/migrations/0.2-to-0.3.md b/docs/migrations/0.2-to-0.3.md new file mode 100644 index 0000000..71c93b3 --- /dev/null +++ b/docs/migrations/0.2-to-0.3.md @@ -0,0 +1,111 @@ +# Migrate from 0.2 to 0.3 + +Concierge 0.3 moves the synchronized package family to runtime contract v3. +The contract bump prevents a mixed installation from silently dropping +structured output or resolving the wrong bridge. The signed envelope remains +version 1; its claims now bind core contract 3. + +## Upgrade the fixed package set + +Upgrade every installed Concierge package together and regenerate the +lockfile: + +```sh +pnpm up @full-self-browsing/concierge@^0.3 \ + @full-self-browsing/concierge-react@^0.3 \ + @full-self-browsing/concierge-svelte@^0.3 + +pnpm why @full-self-browsing/concierge +``` + +The final command should show one physical 0.3 core. A 0.3 adapter deliberately +rejects contract-v2 core before bridge registration or dispatch. + +## Existing actions remain valid + +No changes are required for an action that returns only `ok`, `reason`, and +`message`. Its normalized runtime shape is unchanged. Existing stage-level +bridges, direct dispatch, batch dispatch, sessions, consent policies, +workflows, and React/Svelte bridge hooks remain supported. + +Do not add placeholder output declarations to old actions. Declare an output +only when the action intentionally returns structured data: + +```ts +const before = defineAction({ + // Existing 0.2 declaration remains valid. + handler: () => ({ ok: true, message: "Done." }), + // ...name, description, schema, redact, and effects +}); + +const after = defineAction({ + // ...existing fields + output: { + schema: resultSchema, + redact: "drop", + }, + handler: () => ({ + ok: true, + message: "Found results.", + data: { kind: "results", items: [] }, + }), +}); +``` + +Returning `data` without `output.schema` now fails closed as `invalid_result`. +Accepted data is transformed by the schema, detached, recursively frozen, and +bounded by `maxActionDataBytes` (256 KiB by default). + +## Update observer consumers + +Terminal `onDispatch` events now keep structured output separate: + +```ts +if (event.phase === "succeeded" || event.phase === "failed" || + event.phase === "cancelled") { + consumeStatus(event.result); // ok, reason, message only + consumeRedactedData(event.resultData); +} +``` + +`resultData` is `absent`, `dropped`, or `included`. Output declarations require +their own redaction policy. Existing input redaction remains unchanged. + +`explain()` adds an `actions` array with each visible action's effective bridge +ID and registration state. Code that constructs a structural `Explanation` +fixture must add `actions: []`; code that only reads existing fields continues +to work. + +## Adopt action-scoped bridges incrementally + +Stage bridges still work. Add `action.bridge` only when actions in one stage +need independent mounted capabilities. Resolution order is action bridge, +stage bridge, then `null`, including for cross-stage actions. +An action-declared registry that is currently unmounted resolves to `null`; it +does not substitute the stage bridge. + +```ts +const showGallery = defineAction({ + // ...existing declaration + bridge: galleryRegistry, +}); +``` + +No generic selector, DOM lookup, or arbitrary invocation metadata was added. +Application-specific continuation behavior belongs on a typed bridge or in the +transport adapter keyed by response and user-turn IDs. + +## Update integrations + +- AI SDK result helpers and signed browser completed reports now preserve + `data`. Remove code that manually reconstructs only `ok`, `reason`, and + `message`. +- Update literal contract assertions from 2 to 3. Keep + `SIGNED_ENVELOPE_VERSION` at 1. +- For OpenAI Realtime, use the optional + `@full-self-browsing/concierge/openai-realtime` codec and retain ownership of + WebRTC, audio, interruption, transcript privacy, and `response.create`. + +See [structured results](../integrations/structured-results.md) and the +[OpenAI Realtime guide](../integrations/openai-realtime.md) for complete +examples. diff --git a/packages/concierge-react/CHANGELOG.md b/packages/concierge-react/CHANGELOG.md index c378ef7..9f78e98 100644 --- a/packages/concierge-react/CHANGELOG.md +++ b/packages/concierge-react/CHANGELOG.md @@ -1,5 +1,12 @@ # @full-self-browsing/concierge-react +## 0.3.0 + +### Minor Changes + +- Upgrade the runtime guard to Concierge contract v3. Existing provider, + bridge hook, telemetry, and action-activity APIs remain unchanged. + ## 0.2.1 ### Patch Changes diff --git a/packages/concierge-react/README.md b/packages/concierge-react/README.md index ac793ee..52c5e87 100644 --- a/packages/concierge-react/README.md +++ b/packages/concierge-react/README.md @@ -10,9 +10,9 @@ 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.2 is a public preview of contract 2. It supports React 18 and 19, +Version 0.3 is a public preview of contract 3. It supports React 18 and 19, requires Node 22.12 or newer for server rendering, and does not support Edge -runtimes in the 0.2 line. +runtimes in the 0.3 line. The existing provider and bridge hooks are unchanged. ## Entry points diff --git a/packages/concierge-react/package.json b/packages/concierge-react/package.json index 856a44b..a1e5762 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.2.1", + "version": "0.3.0", "private": false, "description": "React bindings and optional action visuals for @full-self-browsing/concierge", "keywords": [ diff --git a/packages/concierge-react/src/client.tsx b/packages/concierge-react/src/client.tsx index cabb93a..a07be06 100644 --- a/packages/concierge-react/src/client.tsx +++ b/packages/concierge-react/src/client.tsx @@ -21,7 +21,7 @@ export type { ConciergeProviderProps, } from "../overlay/activity.js"; -const EXPECTED_CONTRACT_VERSION: number = 2; +const EXPECTED_CONTRACT_VERSION: number = 3; export function useConciergeValue(value: T): () => T { const valueRef = useRef(value); diff --git a/packages/concierge-react/test/artifact.test.ts b/packages/concierge-react/test/artifact.test.ts index a925226..e794fdf 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*2\b/u); + expect(clientSource).toMatch(/EXPECTED_CONTRACT_VERSION\s*=\s*3\b/u); await withoutBrowserGlobals(async () => { const [root, client, core] = await Promise.all([ @@ -128,7 +128,7 @@ describe("the built @full-self-browsing/concierge-react entries", () => { revision: Symbol("artifact-catalog"), }), onDispatch: () => () => undefined, - explain: () => ({ stage: null, stages: [], catalog: [] }), + explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), }; function ServerConsumer() { diff --git a/packages/concierge-react/test/lifecycle.test.tsx b/packages/concierge-react/test/lifecycle.test.tsx index 3423bae..be7a0e0 100644 --- a/packages/concierge-react/test/lifecycle.test.tsx +++ b/packages/concierge-react/test/lifecycle.test.tsx @@ -54,7 +54,7 @@ function conciergeStub(): Concierge { dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ stage: null, tools: [], revision }), onDispatch: () => () => undefined, - explain: () => ({ stage: null, stages: [], catalog: [] }), + explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), }; } @@ -75,7 +75,7 @@ function activityConciergeStub(): { listeners.delete(listener); }; }, - explain: () => ({ stage: null, stages: [], catalog: [] }), + explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), }; return { diff --git a/packages/concierge-svelte/CHANGELOG.md b/packages/concierge-svelte/CHANGELOG.md index c5eae09..454e931 100644 --- a/packages/concierge-svelte/CHANGELOG.md +++ b/packages/concierge-svelte/CHANGELOG.md @@ -1,5 +1,13 @@ # @full-self-browsing/concierge-svelte +## 0.3.0 + +### Minor Changes + +- Upgrade the runtime guard to Concierge contract v3. Existing context, + bridge registration, telemetry, and snapshot normalization APIs remain + unchanged. + ## 0.2.1 ### Patch Changes diff --git a/packages/concierge-svelte/README.md b/packages/concierge-svelte/README.md index 311507f..b3791c1 100644 --- a/packages/concierge-svelte/README.md +++ b/packages/concierge-svelte/README.md @@ -10,9 +10,9 @@ 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.2 is a public preview of contract 2. It supports Svelte 5, requires +Version 0.3 is a public preview of contract 3. It supports Svelte 5, requires Node 22.12 or newer for server rendering, and does not support Edge runtimes in -the 0.2 line. +the 0.3 line. The existing context and bridge APIs are unchanged. ## Entry points diff --git a/packages/concierge-svelte/package.json b/packages/concierge-svelte/package.json index 9494024..06bae0f 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.2.1", + "version": "0.3.0", "private": false, "description": "Svelte bindings for @full-self-browsing/concierge", "keywords": [ diff --git a/packages/concierge-svelte/src/client.svelte.ts b/packages/concierge-svelte/src/client.svelte.ts index db9db47..f8bd27a 100644 --- a/packages/concierge-svelte/src/client.svelte.ts +++ b/packages/concierge-svelte/src/client.svelte.ts @@ -12,7 +12,7 @@ import type { } from "@full-self-browsing/concierge"; import { mountConciergeTelemetry } from "@full-self-browsing/concierge/telemetry"; -const EXPECTED_CONTRACT_VERSION: number = 2; +const EXPECTED_CONTRACT_VERSION: number = 3; const CONCIERGE_CONTEXT: symbol = Symbol( "@full-self-browsing/concierge-svelte.context", ); diff --git a/packages/concierge-svelte/test/artifact.test.ts b/packages/concierge-svelte/test/artifact.test.ts index 0e5fe45..70d9afe 100644 --- a/packages/concierge-svelte/test/artifact.test.ts +++ b/packages/concierge-svelte/test/artifact.test.ts @@ -186,7 +186,7 @@ describe("the built @full-self-browsing/concierge-svelte entries", () => { "upgrade or reinstall", "registry.register(bridge)", ]); - expect(clientSource).toMatch(/EXPECTED_CONTRACT_VERSION\s*=\s*2\b/u); + expect(clientSource).toMatch(/EXPECTED_CONTRACT_VERSION\s*=\s*3\b/u); expect(adapterSource).toContain("return $state.snapshot(value);"); expect(adapterSource).not.toMatch(/\b(?:as|any)\b/u); diff --git a/packages/concierge-svelte/test/lifecycle.test.ts b/packages/concierge-svelte/test/lifecycle.test.ts index a958cac..2a6a01d 100644 --- a/packages/concierge-svelte/test/lifecycle.test.ts +++ b/packages/concierge-svelte/test/lifecycle.test.ts @@ -45,7 +45,7 @@ function conciergeStub(): Concierge { dispatchBatch: async () => ({ kind: "completed", rows: [] }), resolveCatalog: () => ({ stage: null, tools: [], revision }), onDispatch: () => () => undefined, - explain: () => ({ stage: null, stages: [], catalog: [] }), + explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), }; } @@ -66,7 +66,7 @@ function dispatchConciergeStub(): { listeners.delete(listener); }; }, - explain: () => ({ stage: null, stages: [], catalog: [] }), + explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), }; return { diff --git a/packages/concierge/CHANGELOG.md b/packages/concierge/CHANGELOG.md index 2d7765f..daff762 100644 --- a/packages/concierge/CHANGELOG.md +++ b/packages/concierge/CHANGELOG.md @@ -1,5 +1,21 @@ # @full-self-browsing/concierge +## 0.3.0 + +### Minor Changes + +- Add declared, Standard Schema-validated structured action results with + detached, recursively frozen, size-bounded JSON data and explicit observer + redaction. +- Add action-scoped bridge registries with action-first precedence, stage + fallback, cross-stage support, and action-level `explain()` details. +- Add the framework-neutral `openai-realtime` codec for acknowledged catalog + publication, completed function-call extraction, and correlated output events. +- Preserve structured results through batches, workflows, AI SDK helpers, and + signed browser reports; add the closed `precondition_failed` reason. +- Move the synchronized package family to runtime contract v3. Existing + data-less actions and stage-scoped bridges remain supported unchanged. + ## 0.2.1 ### Patch Changes diff --git a/packages/concierge/README.md b/packages/concierge/README.md index 98c8ebf..422be31 100644 --- a/packages/concierge/README.md +++ b/packages/concierge/README.md @@ -11,8 +11,9 @@ 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.2 is a public preview of contract 2. It requires Node 22.12 or newer; -Edge runtimes are not supported in the 0.2 line. +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. ## Install @@ -23,6 +24,8 @@ pnpm add @full-self-browsing/concierge 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`. 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. @@ -98,6 +101,43 @@ catalog revision returns `identity_conflict`. A superseded local revision returns `catalog_stale`. Malformed JSON in a transport batch returns `invalid_args`; it is never replaced with an empty object. +## Structured results and action bridges + +An action may opt into schema-controlled JSON output. Existing actions need no +change: + +```ts +import { defineAction } from "@full-self-browsing/concierge"; +import { z } from "zod"; + +const readResults = defineAction({ + name: "readResults", + description: "Read the visible search results.", + schema: z.object({}), + redact: "drop", + output: { + schema: z.object({ + kind: z.literal("results"), + ids: z.array(z.string()), + }), + redact: "drop", + }, + bridge: resultsRegistry, + effects: { readOnly: true, destructive: false, idempotent: true }, + handler: ({ bridge }) => ({ + ok: true, + message: "Read the visible results.", + data: { kind: "results", ids: bridge?.snapshot.visibleIds() ?? [] }, + }), +}); +``` + +Validated data is transformed by the schema, detached, recursively frozen, +and limited to 256 KiB by default. `output.redact` independently controls +observer exposure; it does not remove data from the agent result. An action's +bridge takes precedence over its owning stage bridge, then falls back to +`null`. This works for stage and cross-stage actions. + ## Lifecycle and workflows `concierge.onDispatch(listener)` observes immutable `accepted`, `waiting`, @@ -132,18 +172,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-2 transport implements +catalog aborts the prior epoch. A contract-3 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.2 exports, failure reasons, wire fields, peer ranges, and contract -2 remain compatible throughout `0.2.x`. Breaking changes require a synchronized -0.3 release and migration notes. See the +Documented 0.3 exports, failure reasons, wire fields, peer ranges, and contract +3 remain compatible throughout `0.3.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.1 to 0.2 migration guide](https://github.com/fullselfbrowsing/Concierge/blob/main/docs/migrations/0.1-to-0.2.md). +and [0.2 to 0.3 migration guide](https://github.com/fullselfbrowsing/Concierge/blob/main/docs/migrations/0.2-to-0.3.md). ## License diff --git a/packages/concierge/package.json b/packages/concierge/package.json index aae6bab..49fcd25 100644 --- a/packages/concierge/package.json +++ b/packages/concierge/package.json @@ -1,6 +1,6 @@ { "name": "@full-self-browsing/concierge", - "version": "0.2.1", + "version": "0.3.0", "description": "Typed, consent-gated actions that let an AI agent operate your web app", "keywords": [ "ai", @@ -44,6 +44,10 @@ "types": "./dist/ai-sdk/browser.d.ts", "default": "./dist/ai-sdk/browser.js" }, + "./openai-realtime": { + "types": "./dist/openai-realtime/index.d.ts", + "default": "./dist/openai-realtime/index.js" + }, "./telemetry": { "types": "./dist/telemetry/index.d.ts", "default": "./dist/telemetry/index.js" diff --git a/packages/concierge/src/ai-sdk/index.ts b/packages/concierge/src/ai-sdk/index.ts index ea8cad4..0add167 100644 --- a/packages/concierge/src/ai-sdk/index.ts +++ b/packages/concierge/src/ai-sdk/index.ts @@ -204,11 +204,17 @@ function invalid( } function jsonResult(result: Readonly): JSONValue { - const output: { ok: boolean; message: string; reason?: string } = { + const output: { + ok: boolean; + message: string; + reason?: string; + data?: JSONValue; + } = { ok: result.ok, message: result.message, }; if (result.reason !== undefined) output.reason = result.reason; + if (result.data !== undefined) output.data = result.data as JSONValue; return output; } @@ -246,7 +252,7 @@ export function createAISDKAdapter(input: Readonly<{ input.concierge as ConciergeWithResolution; if (typeof concierge.resolveCatalog !== "function") { throw new ConciergeAISDKConfigurationError( - "Core contract v2 must provide resolveCatalog().", + "Core contract v3 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 c15bb4c..5dedffe 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 = 2 as const; +export const EXPECTED_CORE_CONTRACT_VERSION = 3 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: 2; + readonly contractVersion: 3; 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 2b4a9f7..51e84d1 100644 --- a/packages/concierge/src/bridge.ts +++ b/packages/concierge/src/bridge.ts @@ -332,14 +332,17 @@ export function createBridge(id: string): BridgeRegis * for exactly this case. Adding a member is a breaking change by design, and * this helper needs no private escape hatch. */ -export function offPageResult(what: string, where: string): ActionResult { +export function offPageResult( + what: string, + where: string, +): ActionResult & { readonly data?: never } { const message: string = `${what} is not available because the ${where} is not open. ` + `Open the ${where} and try again.`; return { - ok: false, - reason: "no_bridge", + ok: false as const, + reason: "no_bridge" as const, message: boundedMessage(message), }; } diff --git a/packages/concierge/src/catalog.ts b/packages/concierge/src/catalog.ts index 41729d0..d6f27f2 100644 --- a/packages/concierge/src/catalog.ts +++ b/packages/concierge/src/catalog.ts @@ -117,6 +117,8 @@ export type CatalogIssueCode = | "schema_not_emittable" | "schema_root_not_object" | "redaction_missing" + | "output_schema_invalid" + | "output_redaction_missing" | "consent_target_missing" | "consent_self_reference" | "consent_grade_unavailable" @@ -296,15 +298,16 @@ const validatorByEntry: WeakMap = new WeakMap< CatalogEntry, CatalogValidator >(); +const outputValidatorByEntry: WeakMap = + new WeakMap(); function unusableValidator(): never { throw new TypeError("The catalog entry has no captured validator."); } /** Read and bind the consumer-owned Standard Schema capability exactly once. */ -function captureValidator(action: AnyActionDefinition): CatalogValidator { +function captureSchemaValidator(schema: unknown): CatalogValidator { try { - const schema: unknown = action.schema; if ((typeof schema !== "object" && typeof schema !== "function") || schema === null) { return unusableValidator; } @@ -322,6 +325,10 @@ function captureValidator(action: AnyActionDefinition): CatalogValidator { } } +function captureValidator(action: AnyActionDefinition): CatalogValidator { + return captureSchemaValidator(action.schema); +} + /** Invoke only the capability captured while the catalog entry was built. */ export function validateCatalogEntry( entry: CatalogEntry, @@ -330,6 +337,19 @@ export function validateCatalogEntry( return (validatorByEntry.get(entry) ?? unusableValidator)(value); } +/** Whether this entry declared a structured action result validator. */ +export function hasCatalogEntryOutput(entry: CatalogEntry): boolean { + return outputValidatorByEntry.has(entry); +} + +/** Validate structured action data with the capability captured at build time. */ +export function validateCatalogEntryOutput( + entry: CatalogEntry, + value: unknown, +): unknown { + return (outputValidatorByEntry.get(entry) ?? unusableValidator)(value); +} + /** * The frozen result of a successful build. * @@ -445,8 +465,7 @@ type PropertyBag = Record; * truthful zero-based index instead of requiring a synthetic action name or * escaping through a property-read `TypeError`. */ -function hasStandardSchema(action: AnyActionDefinition): boolean { - const schema: unknown = action.schema; +function isStandardSchema(schema: unknown): boolean { if ((typeof schema !== "object" && typeof schema !== "function") || schema === null) { return false; } @@ -457,6 +476,43 @@ function hasStandardSchema(action: AnyActionDefinition): boolean { return typeof (std as PropertyBag)["vendor"] === "string"; } +function hasStandardSchema(action: AnyActionDefinition): boolean { + return isStandardSchema(action.schema); +} + +type CapturedOutputDeclaration = Readonly<{ + schema: unknown; + redact: unknown; +}>; + +/** Read an optional output declaration without trusting inherited properties. */ +function captureOutputDeclaration( + action: AnyActionDefinition, +): CapturedOutputDeclaration | null | false { + try { + const view: PropertyBag = action as unknown as PropertyBag; + if (!Object.hasOwn(view, "output")) { + return null; + } + const output: unknown = view["output"]; + if (output === undefined) return null; + if (typeof output !== "object" || output === null) { + return false; + } + const declaration: PropertyBag = output as PropertyBag; + return { + schema: Object.hasOwn(declaration, "schema") + ? declaration["schema"] + : undefined, + redact: Object.hasOwn(declaration, "redact") + ? declaration["redact"] + : undefined, + }; + } catch { + return false; + } +} + /** * Read `redact` as the untyped value it actually is at runtime. * @@ -1005,7 +1061,9 @@ export function buildCatalog( const seenNames: Set = new Set(); const entries: CatalogEntry[] = []; const names: string[] = []; - const validators: Set = new Set(); + const freezeSkips: Set = new Set(); + const capturedOutputs: Map = + new Map(); // What CAT-03's post-pass iterates: one entry per DISTINCT declared name, // whatever else went wrong with that declaration. Neither array already in @@ -1089,6 +1147,33 @@ export function buildCatalog( }); } + const outputDeclaration: CapturedOutputDeclaration | null | false = + captureOutputDeclaration(action); + if (outputDeclaration === false || + (outputDeclaration !== null && !isStandardSchema(outputDeclaration.schema))) { + issues.push({ + code: "output_schema_invalid", + action: action.name, + problem: "its `output.schema` is missing or is not a Standard Schema validator, so returned data cannot be validated.", + fix: "remove the `output` declaration when the action returns no data, or provide a Standard Schema validator in `output.schema`.", + }); + } else if (outputDeclaration !== null) { + if ( + outputDeclaration.redact !== "drop" && + outputDeclaration.redact !== "passthrough" && + typeof outputDeclaration.redact !== "function" + ) { + issues.push({ + code: "output_redaction_missing", + action: action.name, + problem: "its structured output declares no valid observer redaction policy.", + fix: 'add `output.redact: "drop"`, `"passthrough"`, or a safe projection function.', + }); + } else { + capturedOutputs.set(action, outputDeclaration); + } + } + // DX-03 — reach `vendorOf` only once the read is known to be safe. if (!hasStandardSchema(action)) { issues.push({ @@ -1238,23 +1323,56 @@ export function buildCatalog( // then actually hold down. `deepFreeze`'s accessor skip covers what the // spread does not flatten: `effects`, `consent`, `parameters`, and anything // nested below them. + const capturedOutput: CapturedOutputDeclaration | undefined = + capturedOutputs.get(action); + const outputCopy: AnyActionDefinition["output"] | undefined = + capturedOutput === undefined + ? undefined + : Object.freeze({ + schema: capturedOutput.schema, + redact: capturedOutput.redact, + }) as AnyActionDefinition["output"]; const detachedAction: AnyActionDefinition = emission.source === "explicit" - ? { ...action, jsonSchema: parameters } - : { ...action }; + ? { + ...action, + jsonSchema: parameters, + ...(outputCopy === undefined ? {} : { output: outputCopy }), + } + : { + ...action, + ...(outputCopy === undefined ? {} : { output: outputCopy }), + }; const normalized: AnyActionDefinition = redactionMissing ? { ...detachedAction, redact: "drop" } : detachedAction; - // The `deepFreeze` skip set. Only object-shaped validators need an entry: - // an arktype instance is a `function` and is skipped by the walk's own - // `typeof !== "object"` guard. See `hasStandardSchema`. + // The `deepFreeze` skip set. Consumer-owned validator internals and live + // bridge registries must not be frozen. An arktype validator is a function + // and is skipped by the walk's own `typeof !== "object"` guard. const validator: unknown = normalized.schema; if (typeof validator === "object" && validator !== null) { - validators.add(validator); + freezeSkips.add(validator); + } + if ( + capturedOutput !== undefined && + typeof capturedOutput.schema === "object" && + capturedOutput.schema !== null + ) { + freezeSkips.add(capturedOutput.schema); + } + const actionBridge: unknown = normalized.bridge; + if (typeof actionBridge === "object" && actionBridge !== null) { + freezeSkips.add(actionBridge); } const entry: CatalogEntry = { action: normalized, parameters }; validatorByEntry.set(entry, captureValidator(normalized)); + if (capturedOutput !== undefined) { + outputValidatorByEntry.set( + entry, + captureSchemaValidator(capturedOutput.schema), + ); + } entries.push(entry); names.push(action.name); } @@ -1354,5 +1472,5 @@ export function buildCatalog( diagnostics, }; - return deepFreeze(catalog, validators, new WeakSet()); + return deepFreeze(catalog, freezeSkips, new WeakSet()); } diff --git a/packages/concierge/src/concierge.ts b/packages/concierge/src/concierge.ts index d51e10d..8e29fc5 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -29,6 +29,7 @@ import { isAbortSignalLike, isAborted, normalizeActionResult, + snapshotActionData, snapshotInvocationValue, validateArguments, waitForCommit, @@ -38,9 +39,13 @@ import { readHostScheduler, warnHost, } from "./host.js"; -import { USER_CANCELLED, USER_DECLINED } from "./types.js"; +import { + DEFAULT_ACTION_DATA_MAX_BYTES, + USER_CANCELLED, + USER_DECLINED, +} from "./types.js"; import type { ArgumentValidation, CommitWaitOutcome } from "./dispatch.js"; -import type { InvocationValueSnapshot } from "./dispatch.js"; +import type { ActionDataSnapshot, InvocationValueSnapshot } from "./dispatch.js"; import type { Catalog, CatalogEntry } from "./catalog.js"; import type { DeliveryEvidenceSnapshot, @@ -51,10 +56,12 @@ import type { } from "./consent-evidence.js"; import type { ActionResult, + ActionExplanation, AbortSignalLike, AnyActionDefinition, BatchDispatchOutcome, Bridge, + BridgeRegistry, CatalogRevision, ChildActionRequest, Concierge, @@ -75,6 +82,8 @@ import type { InvocationIdentity, InvocationMeta, ObservedInput, + ObservedActionResult, + ObservedResultData, ResolvedCatalog, Scheduler, StageContext, @@ -119,6 +128,12 @@ const NEVER_ABORTED_SIGNAL: AbortSignalLike = /* @__PURE__ */ Object.freeze({ const DROPPED_INPUT: ObservedInput = /* @__PURE__ */ Object.freeze({ kind: "dropped" as const, }); +const ABSENT_RESULT_DATA: ObservedResultData = /* @__PURE__ */ Object.freeze({ + kind: "absent" as const, +}); +const DROPPED_RESULT_DATA: ObservedResultData = /* @__PURE__ */ Object.freeze({ + kind: "dropped" as const, +}); interface DispatchExecutionState { terminalEntered: boolean; @@ -832,7 +847,7 @@ function snapshotDispatchEnvelopeInput( }; } -/** Snapshot the complete v2 request before any asynchronous work begins. */ +/** Snapshot the complete dispatch request before asynchronous work begins. */ function snapshotDispatchRequest(request: unknown): DispatchRequestSnapshot { const envelope: DispatchEnvelopeSnapshot = snapshotDispatchEnvelope(request); return envelope.ok @@ -887,6 +902,15 @@ function snapshotAction(action: AnyActionDefinition): AnyActionDefinition { consent: snapshotConsentPolicy(snapshot.consent), }; } + if (snapshot.output !== undefined) { + snapshot = { + ...snapshot, + output: Object.freeze({ + schema: snapshot.output.schema, + redact: snapshot.output.redact, + }), + }; + } return snapshot; } catch { throw new TypeError( @@ -1025,6 +1049,45 @@ function resolveBridge(stage: ConciergeConfig["stages"][number]): Bridge | null } } +/** Select an action-local registry before the owning stage fallback. */ +function effectiveBridgeRegistry( + action: AnyActionDefinition, + stage: ConciergeConfig["stages"][number] | undefined, +): BridgeRegistry | undefined { + return action.bridge ?? stage?.bridge; +} + +/** Read the effective registry exactly once for one dispatch occurrence. */ +function resolveActionBridge( + action: AnyActionDefinition, + stage: ConciergeConfig["stages"][number] | undefined, +): Bridge | null { + const registry: BridgeRegistry | undefined = + effectiveBridgeRegistry(action, stage); + if (registry === undefined) return null; + try { + return registry.read() ?? null; + } catch { + return null; + } +} + +function actionBridgeStatus( + action: AnyActionDefinition, + stage: ConciergeConfig["stages"][number] | undefined, +): ActionExplanation["bridge"] { + const registry: BridgeRegistry | undefined = + effectiveBridgeRegistry(action, stage); + if (registry === undefined) return null; + let registered: boolean = false; + try { + registered = registry.read() != null; + } catch { + registered = false; + } + return { id: registry.id, registered }; +} + /** * Everything `explain` can honestly say about one stage's bridge. * @@ -1116,6 +1179,10 @@ export function createConcierge(config: ConciergeConfig): Concierge { config.dedupeWindowMs ?? 600, "dedupeWindowMs", ); + const maxActionDataBytes: number = validateWorkflowLimit( + config.maxActionDataBytes ?? DEFAULT_ACTION_DATA_MAX_BYTES, + "maxActionDataBytes", + ); const maxWorkflowDepth: number = validateWorkflowLimit( config.maxWorkflowDepth ?? 16, "maxWorkflowDepth", @@ -1300,11 +1367,13 @@ export function createConcierge(config: ConciergeConfig): Concierge { /** Detach one already-resolved bridge without reading its registry again. */ function captureResolvedSnapshot( index: number | null, + action: AnyActionDefinition, bridge: Bridge | null, ): Readonly> { const stage: ConciergeConfig["stages"][number] | undefined = index === null ? undefined : stages[index]; - const bridgeId: string = stage?.bridge?.id ?? stage?.id ?? "cross-stage"; + const bridgeId: string = + effectiveBridgeRegistry(action, stage)?.id ?? stage?.id ?? "cross-stage"; return Object.freeze( captureSnapshot( bridge as Bridge, @@ -1314,16 +1383,6 @@ export function createConcierge(config: ConciergeConfig): Concierge { ); } - /** Capture the active bridge early without replacing its later live resolve. */ - function captureReviewSnapshot( - index: number | null, - ): Readonly> { - const stage: ConciergeConfig["stages"][number] | undefined = - index === null ? undefined : stages[index]; - const bridge: Bridge | null = stage === undefined ? null : resolveBridge(stage); - return captureResolvedSnapshot(index, bridge); - } - /** Arm one owned pending generation from snapshotted delivery evidence. */ async function observeReviewDelivery( slotKey: string, @@ -1764,6 +1823,52 @@ export function createConcierge(config: ConciergeConfig): Concierge { return Object.freeze({ kind: "included", value: snapshot.value }); } + function observedResultStatus(result: ActionResult): ObservedActionResult { + return Object.freeze( + result.reason === undefined + ? { ok: result.ok, message: result.message } + : { ok: result.ok, reason: result.reason, message: result.message }, + ); + } + + function observedResultDataFor( + entry: CatalogEntry | null, + result: ActionResult, + ): ObservedResultData { + if (result.data === undefined) return ABSENT_RESULT_DATA; + if (entry === null) return DROPPED_RESULT_DATA; + const output: AnyActionDefinition["output"] = entry.action.output; + if (output === undefined || output.redact === "drop") { + return DROPPED_RESULT_DATA; + } + + let exposed: unknown = result.data; + if (typeof output.redact === "function") { + try { + exposed = output.redact(result.data); + } catch { + warnDispatchOnce( + `output-redaction-threw:${entry.action.name}`, + `concierge: [output_redaction_failed] action ${encodeDiagnosticSubject(entry.action.name)}: its result projection threw, so observer data was dropped. Fix: make the projection total and return JSON-safe data.`, + ); + return DROPPED_RESULT_DATA; + } + } + + const snapshot: ActionDataSnapshot = snapshotActionData( + exposed, + maxActionDataBytes, + ); + if (!snapshot.ok) { + warnDispatchOnce( + `output-redaction-invalid:${entry.action.name}`, + `concierge: [output_redaction_failed] action ${encodeDiagnosticSubject(entry.action.name)}: its observer result projection was not safe bounded JSON data, so observer data was dropped. Fix: return acyclic plain JSON data without aliases or accessors.`, + ); + return DROPPED_RESULT_DATA; + } + return Object.freeze({ kind: "included", value: snapshot.value }); + } + function eventTerminalPhase(result: ActionResult): "succeeded" | "failed" | "cancelled" { if (result.ok) return "succeeded"; switch (result.reason) { @@ -2397,6 +2502,10 @@ export function createConcierge(config: ConciergeConfig): Concierge { ); } + const stage: ConciergeConfig["stages"][number] | undefined = + index === null ? undefined : stages[index]; + const bridge: Bridge | null = resolveActionBridge(entry.action, stage); + let reviewingGeneration: | (ConsentGenerationBase & { readonly status: "reviewing" }) | null = null; @@ -2410,7 +2519,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { readbackHash: null, responseId: meta.responseId ?? "", sessionId: consentSessionId, - snapshot: captureReviewSnapshot(index), + snapshot: captureResolvedSnapshot(index, entry.action, bridge), status: "reviewing", userTurnId: meta.userTurnId ?? "", verifiedReadback: null, @@ -2514,9 +2623,6 @@ export function createConcierge(config: ConciergeConfig): Concierge { ); } - const stage: ConciergeConfig["stages"][number] | undefined = - index === null ? undefined : stages[index]; - const bridge: Bridge | null = stage === undefined ? null : resolveBridge(stage); if (isAborted(signal)) { closeOwnedReview(); return authoredResult( @@ -2614,7 +2720,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { let snapshotsMatch: boolean = false; try { const currentSnapshot: Readonly> = - captureResolvedSnapshot(index, bridge); + captureResolvedSnapshot(index, entry.action, bridge); const comparator: ConsentPolicy["snapshotEquality"] = policy.snapshotEquality; snapshotsMatch = comparator === undefined @@ -2752,7 +2858,9 @@ export function createConcierge(config: ConciergeConfig): Concierge { } } - const normalizedResult: ActionResult = normalizeActionResult(handlerResult, { + const normalizedResult: ActionResult = await normalizeActionResult(handlerResult, { + entry, + maximumDataBytes: maxActionDataBytes, successReason: (): void => { warnDispatchOnce( `success-reason:${name}`, @@ -2928,7 +3036,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { input: observation.input, terminalAction: entry.action.terminal === true, phase: eventTerminalPhase(result), - result, + result: observedResultStatus(result), + resultData: observedResultDataFor(entry, result), terminalEntered: occurrence.root.terminalRef !== null, }); return result; @@ -3190,7 +3299,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { terminalEntered: inherited?.root.terminalRef !== null && inherited?.root.terminalRef !== undefined, phase: eventTerminalPhase(result), - result, + result: observedResultStatus(result), + resultData: observedResultDataFor(null, result), }); return trackDispatchPromise(Promise.resolve(result), executionState); } @@ -3918,11 +4028,26 @@ export function createConcierge(config: ConciergeConfig): Concierge { // a documented refusal to early-out on `Object.isFrozen` is not a saving — // those are three properties a re-implementation rediscovers as bug // reports. + const visible: AtomicCatalogResolution = resolveForIndex(activeIndex, ctx); + const activeStage: ConciergeConfig["stages"][number] | undefined = + activeIndex === null ? undefined : stages[activeIndex]; + const actionRows: ActionExplanation[] = visible.names.flatMap( + (name): ActionExplanation[] => { + const entry: CatalogEntry | undefined = catalog.byName[name]; + return entry === undefined + ? [] + : [{ + name, + bridge: actionBridgeStatus(entry.action, activeStage), + }]; + }, + ); return deepFreeze( { stage: activeIndex === null ? null : (stages[activeIndex]?.id ?? null), stages: rows, - catalog: resolveForIndex(activeIndex, ctx).names, + actions: actionRows, + catalog: visible.names, }, NO_SKIP, new WeakSet(), diff --git a/packages/concierge/src/contract.ts b/packages/concierge/src/contract.ts index a35b0eb..7da66f7 100644 --- a/packages/concierge/src/contract.ts +++ b/packages/concierge/src/contract.ts @@ -44,7 +44,7 @@ * * Deliberately unannotated, matching {@link MESSAGE_MAX_CHARS} in `./types.ts` * rather than an explicit literal annotation. Under `isolatedDeclarations` - * the literal type `2` survives into the emitted `.d.ts` + * the literal type `3` survives into the emitted `.d.ts` * either way, so both forms preserve the signal a consumer — and this package's * own type tests — read to pin the value; the annotation would buy nothing and * would leave this file and `types.ts` disagreeing on house style with no @@ -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 v2 ships `2`. + * Contract v3 ships `3`. * * 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 = 2; +export const CONTRACT_VERSION = 3; /** * The cross-realm slot where two independently-resolved copies of core meet. @@ -86,7 +86,7 @@ const REGISTRY_KEY: symbol = Symbol.for("@fullselfbrowsing/concierge.contract"); /** * What one copy of core leaves behind for the next one to find. * - * `version` is `number`, not the literal `2`: the record this reads may have + * `version` is `number`, not the literal `3`: the record this reads may have * been written by a *different* version of this file, which is the entire case * the guard exists to detect. Typing it as the literal would make the mismatch * branch unreachable to the checker and the comparison a compile error. diff --git a/packages/concierge/src/define-action.ts b/packages/concierge/src/define-action.ts index c46a1c6..7e01d2e 100644 --- a/packages/concierge/src/define-action.ts +++ b/packages/concierge/src/define-action.ts @@ -224,10 +224,11 @@ export function defineAction< B = unknown, Snap = unknown, Ack = unknown, + Output extends StandardSchemaV1 | never = never, >( - def: Omit, "description"> & { + def: Omit, "description"> & { description: LiteralDescription; }, -): ActionDefinition { - return def as ActionDefinition; +): ActionDefinition { + return def as ActionDefinition; } diff --git a/packages/concierge/src/dispatch.ts b/packages/concierge/src/dispatch.ts index b05d35a..70d8229 100644 --- a/packages/concierge/src/dispatch.ts +++ b/packages/concierge/src/dispatch.ts @@ -10,10 +10,15 @@ */ import { sanitizeMessage } from "./message.js"; -import { validateCatalogEntry } from "./catalog.js"; +import { + hasCatalogEntryOutput, + validateCatalogEntry, + validateCatalogEntryOutput, +} from "./catalog.js"; import type { CatalogEntry } from "./catalog.js"; import type { AbortSignalLike, + ActionData, ActionResult, ReasonCode, Scheduler, @@ -199,6 +204,185 @@ export function snapshotInvocationValue( } } +// --------------------------------------------------------------------------- +// Structured action-result data +// --------------------------------------------------------------------------- + +export type ActionDataSnapshot = + | { readonly ok: true; readonly value: ActionData } + | { readonly ok: false }; + +interface JsonSizeBudget { + readonly maximum: number; + used: number; +} + +function addJsonBytes(budget: JsonSizeBudget, count: number): void { + budget.used += count; + if (!Number.isSafeInteger(budget.used) || budget.used > budget.maximum) { + throw new TypeError("Action data exceeds the configured size bound."); + } +} + +/** Count UTF-8 bytes without depending on DOM's TextEncoder. */ +function utf8ByteLength(value: string): number { + let bytes: number = 0; + for (let index: number = 0; index < value.length; index += 1) { + const code: number = value.charCodeAt(index); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next: number = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} + +function addQuotedJsonString(budget: JsonSizeBudget, value: string): void { + const encoded: string | undefined = JSON.stringify(value); + if (encoded === undefined) { + throw new TypeError("Action data strings must be JSON encodable."); + } + addJsonBytes(budget, utf8ByteLength(encoded)); +} + +/** Clone only dense, unaliased JSON data while accounting for its wire size. */ +function cloneActionData( + value: unknown, + seen: WeakSet, + budget: JsonSizeBudget, +): ActionData { + if (value === null) { + addJsonBytes(budget, 4); + return null; + } + if (typeof value === "boolean") { + addJsonBytes(budget, value ? 4 : 5); + return value; + } + if (typeof value === "string") { + addQuotedJsonString(budget, value); + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Action data numbers must be finite."); + } + addJsonBytes(budget, String(Object.is(value, -0) ? 0 : value).length); + return value; + } + if (typeof value !== "object") { + throw new TypeError("Action result data must be JSON-safe data."); + } + if (seen.has(value)) { + throw new TypeError("Action result data cannot contain cycles or aliases."); + } + seen.add(value); + + if (Array.isArray(value)) { + const lengthSnapshot: ArrayLengthSnapshot = snapshotArrayLength( + value, + MAX_INVOCATION_ARRAY_LENGTH, + ); + if (!lengthSnapshot.ok) { + throw new TypeError("Action data arrays exceed the supported bound."); + } + const keys: readonly PropertyKey[] = Reflect.ownKeys(value); + for (const key of keys) { + if (key === "length") continue; + if (typeof key !== "string" || !/^(?:0|[1-9]\d*)$/u.test(key)) { + throw new TypeError("Action data arrays cannot carry extra properties."); + } + const index: number = Number(key); + if (!Number.isSafeInteger(index) || index >= lengthSnapshot.value) { + throw new TypeError("Action data arrays contain an invalid index."); + } + } + + addJsonBytes(budget, 2); + const clone: ActionData[] = []; + for (let index: number = 0; index < lengthSnapshot.value; index += 1) { + if (index > 0) addJsonBytes(budget, 1); + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(value, String(index)); + if ( + descriptor === undefined || + !("value" in descriptor) || + descriptor.enumerable !== true + ) { + throw new TypeError("Action data arrays must be dense data properties."); + } + clone.push(cloneActionData(descriptor.value, seen, budget)); + } + return clone; + } + + const prototype: object | null = Object.getPrototypeOf(value); + if (prototype !== null && prototype !== Object.prototype) { + throw new TypeError("Action data objects must be plain objects."); + } + + const keys: readonly PropertyKey[] = Reflect.ownKeys(value); + addJsonBytes(budget, 2); + const clone: Record = Object.create( + prototype === null ? null : Object.prototype, + ) as Record; + let entryIndex: number = 0; + for (const key of keys) { + if (typeof key !== "string") { + throw new TypeError("Action data cannot contain symbol keys."); + } + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !("value" in descriptor) || + descriptor.enumerable !== true + ) { + throw new TypeError("Action data objects must use enumerable data properties."); + } + if (entryIndex > 0) addJsonBytes(budget, 1); + addQuotedJsonString(budget, key); + addJsonBytes(budget, 1); + Object.defineProperty(clone, key, { + configurable: true, + enumerable: true, + value: cloneActionData(descriptor.value, seen, budget), + writable: true, + }); + entryIndex += 1; + } + return clone; +} + +/** Detach, bound, and recursively freeze validated action-result data. */ +export function snapshotActionData( + value: unknown, + maximumBytes: number, +): ActionDataSnapshot { + try { + const detached: ActionData = cloneActionData( + value, + new WeakSet(), + { maximum: maximumBytes, used: 0 }, + ); + freezeInvocationValue(detached, new WeakSet()); + return { ok: true, value: detached }; + } catch { + return { ok: false }; + } +} + // --------------------------------------------------------------------------- // Deduplication keys // --------------------------------------------------------------------------- @@ -573,7 +757,12 @@ export interface ResultWarnings { readonly reasonlessFailure: () => void; } -/** Runtime membership check for the closed fifteen-code vocabulary. */ +export interface ResultNormalizationOptions extends ResultWarnings { + readonly entry: CatalogEntry; + readonly maximumDataBytes: number; +} + +/** Runtime membership check for the closed sixteen-code vocabulary. */ export function isReasonCode(value: unknown): value is ReasonCode { switch (value) { case "declined": @@ -591,6 +780,7 @@ export function isReasonCode(value: unknown): value is ReasonCode { case "catalog_stale": case "invalid_invocation": case "identity_conflict": + case "precondition_failed": return true; default: return false; @@ -632,32 +822,95 @@ function notify(warn: () => void): void { /** * Normalize an untrusted handler return into one fresh ActionResult. * - * Only `ok`, `reason`, and `message` are read, each inside the same guarded - * property boundary. Extra fields never cross into the returned object. A - * valid reason on a success is stripped because the effect may already have - * landed; a reasonless failure is preserved because inventing a cause would be - * dishonest. Both contradictions warn through instance-owned latches. + * Only `ok`, `reason`, `message`, and optional declared `data` are read through + * guarded own-property boundaries. Other fields never cross into the returned + * object. A valid reason on a success is stripped because the effect may + * already have landed; a reasonless failure is preserved because inventing a + * cause would be dishonest. Both contradictions warn through instance-owned + * latches. */ -export function normalizeActionResult( +type OwnResultProperty = + | Readonly<{ ok: true; present: false }> + | Readonly<{ ok: true; present: true; value: unknown }> + | Readonly<{ ok: false }>; + +function readOwnResultProperty( + result: object, + key: "ok" | "reason" | "message" | "data", +): OwnResultProperty { + try { + if (!Object.prototype.hasOwnProperty.call(result, key)) { + return { ok: true, present: false }; + } + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(result, key); + if (descriptor === undefined) return { ok: false }; + if ("value" in descriptor) { + return { ok: true, present: true, value: descriptor.value }; + } + return { + ok: true, + present: true, + value: descriptor.get?.call(result), + }; + } catch { + return { ok: false }; + } +} + +async function validateResultData( + entry: CatalogEntry, value: unknown, - warnings: ResultWarnings, -): ActionResult { + maximumBytes: number, +): Promise { + if (!hasCatalogEntryOutput(entry) || value === undefined) { + return { ok: false }; + } + try { + const validation: unknown = await validateCatalogEntryOutput(entry, value); + if (typeof validation !== "object" || validation === null) { + return { ok: false }; + } + const issues: unknown = (validation as { readonly issues?: unknown }).issues; + if (issues !== undefined || !("value" in validation)) { + return { ok: false }; + } + return snapshotActionData( + (validation as { readonly value: unknown }).value, + maximumBytes, + ); + } catch { + return { ok: false }; + } +} + +export async function normalizeActionResult( + value: unknown, + options: ResultNormalizationOptions, +): Promise { if (typeof value !== "object" || value === null) { return invalidResult(); } - let ok: unknown; - let reason: unknown; - let message: unknown; - try { - const result = value as Record; - ok = result["ok"]; - reason = result["reason"]; - message = result["message"]; - } catch { + const okProperty: OwnResultProperty = readOwnResultProperty(value, "ok"); + const reasonProperty: OwnResultProperty = readOwnResultProperty(value, "reason"); + const messageProperty: OwnResultProperty = readOwnResultProperty(value, "message"); + const dataProperty: OwnResultProperty = readOwnResultProperty(value, "data"); + if ( + !okProperty.ok || !okProperty.present || + !reasonProperty.ok || + !messageProperty.ok || !messageProperty.present || + !dataProperty.ok + ) { return invalidResult(); } + const ok: unknown = okProperty.value; + const reason: unknown = reasonProperty.present + ? reasonProperty.value + : undefined; + const message: unknown = messageProperty.value; + if (typeof ok !== "boolean" || typeof message !== "string") { return invalidResult(); } @@ -665,17 +918,43 @@ export function normalizeActionResult( return invalidResult(); } + let data: ActionData | undefined; + if (dataProperty.present) { + const normalizedData: ActionDataSnapshot = await validateResultData( + options.entry, + dataProperty.value, + options.maximumDataBytes, + ); + if (!normalizedData.ok) return invalidResult(); + data = normalizedData.value; + } + + const finish = ( + resultOk: boolean, + resultMessage: string, + resultReason?: ReasonCode | undefined, + ): ActionResult => { + const status: ActionResult = authoredResult( + resultOk, + resultMessage, + resultReason, + ); + return data === undefined + ? status + : Object.freeze({ ...status, data }); + }; + if (ok) { if (reason !== undefined) { - notify(warnings.successReason); + notify(options.successReason); } - return authoredResult(true, message); + return finish(true, message); } if (reason === undefined) { - notify(warnings.reasonlessFailure); - return authoredResult(false, message); + notify(options.reasonlessFailure); + return finish(false, message); } - return authoredResult(false, message, reason); + return finish(false, message, reason); } diff --git a/packages/concierge/src/index.ts b/packages/concierge/src/index.ts index a964636..f5de14f 100644 --- a/packages/concierge/src/index.ts +++ b/packages/concierge/src/index.ts @@ -1,5 +1,5 @@ /** - * @full-self-browsing/concierge contract v2. + * @full-self-browsing/concierge contract v3. * * The framework-neutral core declares typed, consent-gated actions; resolves * stage, dynamic availability, tools, and a local catalog revision atomically; @@ -22,6 +22,7 @@ export type { JsonSchemaObject, AbortSignalLike, // Results + ActionData, ActionResult, AbandonReason, FailureReason, @@ -54,7 +55,9 @@ export type { SideEffects, // Redaction RedactionPolicy, + OutputRedactionPolicy, // Actions + ActionOutputDefinition, ActionDefinition, AnyActionDefinition, // Bridges @@ -77,6 +80,8 @@ export type { DispatchRef, BatchDispatchOutcome, ObservedInput, + ObservedResultData, + ObservedActionResult, DispatchLineage, DispatchEvent, DispatchListener, @@ -85,6 +90,7 @@ export type { Concierge, ConciergeConfig, Explanation, + ActionExplanation, StageExplanation, Session, SessionConfig, @@ -115,6 +121,7 @@ export { USER_DECLINED, CONSENT_GRADE_ORDER, MESSAGE_MAX_CHARS, + DEFAULT_ACTION_DATA_MAX_BYTES, } from "./types.js"; export { CONTRACT_VERSION, assertSingleInstance } from "./contract.js"; diff --git a/packages/concierge/src/openai-realtime/index.ts b/packages/concierge/src/openai-realtime/index.ts new file mode 100644 index 0000000..f6569b1 --- /dev/null +++ b/packages/concierge/src/openai-realtime/index.ts @@ -0,0 +1,291 @@ +import type { + AbortSignalLike, + BatchDispatchOutcome, + CatalogRevision, + EmittedTool, + InvocationMeta, + ResolvedCatalog, + ToolBatch, + ToolCall, +} from "../types.js"; + +/** A function tool in the shape accepted by Realtime `session.update`. */ +export interface OpenAIRealtimeSessionTool { + readonly type: "function"; + readonly name: string; + readonly description: string; + readonly parameters: EmittedTool["parameters"]; +} + +/** One caller-sendable Realtime function result event. */ +export interface OpenAIRealtimeFunctionCallOutputEvent { + readonly type: "conversation.item.create"; + readonly item: Readonly<{ + type: "function_call_output"; + call_id: string; + output: string; + }>; +} + +export interface ExtractOpenAIRealtimeBatchInput { + /** A `response.done` server event, or its completed `response` member. */ + readonly response: unknown; + readonly sessionId: string; + readonly userTurnId: string; + /** The revision whose `session.update` publication was acknowledged. */ + readonly catalogRevision: CatalogRevision; + readonly signal?: AbortSignalLike | undefined; + readonly deferUntilDelivered?: InvocationMeta["deferUntilDelivered"]; +} + +/** + * Pure protocol translation for an app-owned OpenAI Realtime connection. + * + * The caller publishes `toSessionTools()` in `session.update` and must wait for + * the corresponding successful `session.updated` event before binding that + * catalog revision to a response. The codec never sends events, starts a + * response, or treats model generation as proof of audio delivery. + */ +export interface OpenAIRealtimeCodec { + toSessionTools( + catalog: ResolvedCatalog, + ): ReadonlyArray; + extractCompletedBatch(input: ExtractOpenAIRealtimeBatchInput): ToolBatch | null; + toFunctionCallOutputEvents( + outcome: BatchDispatchOutcome, + ): ReadonlyArray; +} + +interface DataProperty { + readonly found: boolean; + readonly value: unknown; +} + +const ABSENT_PROPERTY: DataProperty = Object.freeze({ + found: false, + value: undefined, +}); + +const EMPTY_EVENTS: ReadonlyArray = + Object.freeze([]); + +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; +} + +function dataProperty(record: object, key: string): DataProperty | null { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(record, key); + } catch { + return null; + } + if (descriptor === undefined) return ABSENT_PROPERTY; + if (!("value" in descriptor) || descriptor.enumerable !== true) return null; + return Object.freeze({ found: true, value: descriptor.value }); +} + +function requiredData(record: object, key: string): unknown { + const property: DataProperty | null = dataProperty(record, key); + if (property === null || property.found === false) { + throw new TypeError("Malformed OpenAI Realtime event."); + } + return property.value; +} + +function validIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 1_024; +} + +function completedResponse(value: unknown): object | null { + let record: object | null = asRecord(value); + if (record === null) return null; + const type: DataProperty | null = dataProperty(record, "type"); + if (type === null) return null; + if (type.found) { + if (type.value !== "response.done") return null; + try { + record = asRecord(requiredData(record, "response")); + } catch { + return null; + } + if (record === null) return null; + } + try { + if (requiredData(record, "status") !== "completed") return null; + } catch { + return null; + } + return record; +} + +function denseArrayValue(values: unknown[], index: number): unknown { + const descriptor: PropertyDescriptor | undefined = + Object.getOwnPropertyDescriptor(values, String(index)); + if ( + descriptor === undefined || + !("value" in descriptor) || + descriptor.enumerable !== true + ) { + throw new TypeError("Malformed OpenAI Realtime response output."); + } + return descriptor.value; +} + +function extractCalls(response: object): ReadonlyArray | null { + let outputValue: unknown; + try { + outputValue = requiredData(response, "output"); + } catch { + return null; + } + if (!Array.isArray(outputValue)) return null; + + const calls: ToolCall[] = []; + const callIds: Set = new Set(); + try { + for (let index: number = 0; index < outputValue.length; index += 1) { + const item: object | null = asRecord(denseArrayValue(outputValue, index)); + if (item === null) return null; + const type: unknown = requiredData(item, "type"); + if (type !== "function_call") continue; + + if (requiredData(item, "status") !== "completed") return null; + const callId: unknown = requiredData(item, "call_id"); + const name: unknown = requiredData(item, "name"); + const argumentsText: unknown = requiredData(item, "arguments"); + if ( + !validIdentifier(callId) || + !validIdentifier(name) || + typeof argumentsText !== "string" || + callIds.has(callId) + ) { + return null; + } + callIds.add(callId); + calls.push(Object.freeze({ + callId, + name, + arguments: argumentsText, + outputIndex: index, + })); + } + } catch { + return null; + } + return calls.length === 0 ? null : Object.freeze(calls); +} + +function sessionTool(tool: EmittedTool): OpenAIRealtimeSessionTool { + return Object.freeze({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }); +} + +/** Create a stateless codec for an app-owned OpenAI Realtime connection. */ +export function createOpenAIRealtimeCodec(): OpenAIRealtimeCodec { + const toolsCache: WeakMap< + ReadonlyArray, + ReadonlyArray + > = new WeakMap< + ReadonlyArray, + ReadonlyArray + >(); + + function toSessionTools( + catalog: ResolvedCatalog, + ): ReadonlyArray { + let tools: ReadonlyArray | undefined = + toolsCache.get(catalog.tools); + if (tools === undefined) { + tools = Object.freeze(catalog.tools.map(sessionTool)); + toolsCache.set(catalog.tools, tools); + } + return tools; + } + + function extractCompletedBatch( + input: ExtractOpenAIRealtimeBatchInput, + ): ToolBatch | null { + const response: object | null = completedResponse(input.response); + if ( + response === null || + !validIdentifier(input.sessionId) || + !validIdentifier(input.userTurnId) || + typeof input.catalogRevision !== "symbol" + ) { + return null; + } + let responseId: unknown; + try { + responseId = requiredData(response, "id"); + } catch { + return null; + } + if (!validIdentifier(responseId)) return null; + const calls: ReadonlyArray | null = extractCalls(response); + if (calls === null) return null; + + const batch: { + sessionId: string; + responseId: string; + catalogRevision: CatalogRevision; + userTurnId: string; + calls: ReadonlyArray; + signal?: AbortSignalLike; + deferUntilDelivered?: InvocationMeta["deferUntilDelivered"]; + } = { + sessionId: input.sessionId, + responseId, + catalogRevision: input.catalogRevision, + userTurnId: input.userTurnId, + calls, + }; + if (input.signal !== undefined) batch.signal = input.signal; + if (input.deferUntilDelivered !== undefined) { + batch.deferUntilDelivered = input.deferUntilDelivered; + } + return Object.freeze(batch); + } + + function toFunctionCallOutputEvents( + outcome: BatchDispatchOutcome, + ): ReadonlyArray { + if (outcome.kind === "terminal") return EMPTY_EVENTS; + const serialized: string[] = []; + try { + for (const row of outcome.rows) { + const output: string | undefined = JSON.stringify(row.result); + if (output === undefined) return EMPTY_EVENTS; + serialized.push(output); + } + } catch { + return EMPTY_EVENTS; + } + return Object.freeze(outcome.rows.map((row, index) => Object.freeze({ + type: "conversation.item.create" as const, + item: Object.freeze({ + type: "function_call_output" as const, + call_id: row.callId, + output: serialized[index] as string, + }), + }))); + } + + return Object.freeze({ + toSessionTools, + extractCompletedBatch, + toFunctionCallOutputEvents, + }); +} diff --git a/packages/concierge/src/types.ts b/packages/concierge/src/types.ts index 16babcb..348046b 100644 --- a/packages/concierge/src/types.ts +++ b/packages/concierge/src/types.ts @@ -52,6 +52,15 @@ export interface AbortSignalLike { // Results // --------------------------------------------------------------------------- +/** JSON-safe structured data an action may deliberately return to an agent. */ +export type ActionData = + | null + | boolean + | string + | number + | ReadonlyArray + | { readonly [key: string]: ActionData }; + /** * The universal return type of every action. * @@ -123,6 +132,14 @@ export interface ActionResult { * its result boundary. */ readonly message: string; + /** + * Declared, schema-validated structured output for the calling agent. + * + * The dispatcher detaches and recursively freezes this value. It is never + * copied into telemetry or dispatch observers implicitly; the action's + * output redaction policy governs that separate channel. + */ + readonly data?: ActionData | undefined; } /** @@ -143,7 +160,7 @@ export type AbandonReason = /** * Why an action did not run, when the cause was the machine rather than the - * human. Twelve codes — with {@link AbandonReason}'s three, that is the fifteen + * human. Thirteen codes — with {@link AbandonReason}'s three, that is the sixteen * {@link ReasonCode} admits. * * Adding a member here is a breaking change *by design*, and the breakage is @@ -198,11 +215,13 @@ export type FailureReason = /** Invocation identity was present but incomplete or malformed. */ | "invalid_invocation" /** A retry identity was reused for a different logical invocation. */ - | "identity_conflict"; + | "identity_conflict" + /** Valid input could not run because current application state forbade it. */ + | "precondition_failed"; /** - * Every code {@link ActionResult.reason} admits: **fifteen** — three - * human-caused ({@link AbandonReason}) and twelve machine-caused + * Every code {@link ActionResult.reason} admits: **sixteen** — three + * human-caused ({@link AbandonReason}) and thirteen machine-caused * ({@link FailureReason}). * * Deliberately a pure closed union. A `` `app.${string}` `` escape hatch was @@ -295,6 +314,9 @@ export const USER_DECLINED: Readonly<{ */ export const MESSAGE_MAX_CHARS = 180; +/** Default UTF-8 JSON size limit for one validated action result's data. */ +export const DEFAULT_ACTION_DATA_MAX_BYTES = 262_144; + // --------------------------------------------------------------------------- // Invocation // --------------------------------------------------------------------------- @@ -303,8 +325,7 @@ export const MESSAGE_MAX_CHARS = 180; * The recursive data population the dispatcher can detach and freeze without * changing application semantics. * - * This source-module export is intentionally not re-exported by `index.ts`, so - * the package keeps its established 65-name public surface. + * This source-module export is intentionally not re-exported by `index.ts`. */ export type InvocationData = | null @@ -481,21 +502,35 @@ export interface WorkflowControls { cleanup(fn: () => void | Promise): () => void; } +type ActionHandlerResult = Omit & + ([Data] extends [never] + ? { readonly data?: never } + : { readonly data?: DeepReadonly | undefined }); + +/** Keep erased output schemas usable while rejecting non-JSON concrete data. */ +type ActionDataOutputContract = + unknown extends Output + ? Accepted + : [Output] extends [ActionData] + ? Accepted + : never; + export type ActionHandler< Args, B, Snapshot = unknown, AckPayload = unknown, + Data = never, > = (ctx: { args: DeepReadonly; - /** `null` when the owning stage's bridge is not mounted. Always check it. */ + /** `null` when neither the action nor owning stage bridge is mounted. */ bridge: B | null; meta: Readonly; /** Present only for actions declaring `consent.requires`. */ ack?: ConsentAck | undefined; /** App-owned compound actions use these controls; core does not plan steps. */ workflow: WorkflowControls; -}) => ActionResult | Promise; +}) => ActionHandlerResult | Promise>; // --------------------------------------------------------------------------- // Consent @@ -979,6 +1014,18 @@ export type RedactionPolicy = | "passthrough" | ((args: Args) => unknown); +/** How validated action data is exposed to dispatch observers. */ +export type OutputRedactionPolicy = + | "drop" + | "passthrough" + | ((data: DeepReadonly) => unknown); + +/** Declares and protects the structured output contract for one action. */ +export interface ActionOutputDefinition { + readonly schema: Schema; + readonly redact: OutputRedactionPolicy>; +} + // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- @@ -1007,12 +1054,14 @@ export type RedactionPolicy = * rename's clothes. The constrained spelling lives on {@link BridgeRegistry} and * {@link StageDefinition}, which is where a bridge is actually registered. */ -export interface ActionDefinition< +interface ActionDefinitionShape< Name extends string = string, Schema extends StandardSchemaV1 = StandardSchemaV1, B = unknown, Snapshot = unknown, AckPayload = unknown, + OutputSchema extends StandardSchemaV1 | never = never, + ActionBridge extends BridgeRegistry = BridgeRegistry, > { name: Name; /** @@ -1034,6 +1083,15 @@ export interface ActionDefinition< */ jsonSchema?: JsonSchemaObject; redact: RedactionPolicy>; + /** + * Optional structured result declaration. Returning `data` without this + * declaration fails closed as `invalid_result`. + */ + output?: [OutputSchema] extends [never] + ? never + : ActionOutputDefinition; + /** Action-local bridge. It takes precedence over the owning stage bridge. */ + bridge?: ActionBridge; /** * App-owned catalog minimization evaluated once during atomic resolution. * Only a literal `true` makes the action available; errors fail closed. @@ -1054,7 +1112,16 @@ export interface ActionDefinition< */ handler: InvocationOutputContract< InferOutput, - ActionHandler, B, Snapshot, AckPayload> + ActionDataOutputContract< + InferOutput, + ActionHandler< + InferOutput, + B, + Snapshot, + AckPayload, + NoInfer> + > + > >; effects?: SideEffects; /** @@ -1113,6 +1180,27 @@ export interface ActionDefinition< terminal?: boolean; } +/** + * One concrete action declaration. Existing positional parameters retain their + * order; structured output is appended as the sixth parameter. + */ +export type ActionDefinition< + Name extends string = string, + Schema extends StandardSchemaV1 = StandardSchemaV1, + B = unknown, + Snapshot = unknown, + AckPayload = unknown, + OutputSchema extends StandardSchemaV1 | never = never, +> = ActionDefinitionShape< + Name, + Schema, + B, + Snapshot, + AckPayload, + OutputSchema, + BridgeRegistry +>; + /** * The erased collection view: an action of *some* shape, for the two places that * hold many of them at once. @@ -1138,15 +1226,19 @@ export interface ActionDefinition< * typechecked. Only the collection is erased, and a declaration that never enters * one keeps full typing throughout. * - * The concrete declaration remains fully typed; only heterogeneous collection - * storage uses this erasure. + * Output validators and action-local bridge registries are erased here for the + * same reason: one array may contain unrelated result schemas and unrelated + * mounted surfaces. The concrete declaration remains fully typed; only + * heterogeneous collection storage uses this erasure. */ -export type AnyActionDefinition = ActionDefinition< +export type AnyActionDefinition = ActionDefinitionShape< string, StandardSchemaV1, B, any, - any + any, + StandardSchemaV1, + BridgeRegistry >; // --------------------------------------------------------------------------- @@ -1233,7 +1325,7 @@ export interface StageDefinition { * the erased-to-`unknown` form this used to carry stopped accepting any of them * the moment `Snapshot` became real. */ - actions: ReadonlyArray>; + actions: ReadonlyArray>; bridge?: BridgeRegistry; } @@ -1329,7 +1421,7 @@ export interface InvocationIdentity { readonly outputIndex: number; } -/** Object-form v2 request. The catalog revision is always explicit. */ +/** Object-form dispatch request. The catalog revision is always explicit. */ export interface DispatchRequest { readonly name: string; readonly input: unknown; @@ -1375,6 +1467,15 @@ export type ObservedInput = | Readonly<{ kind: "dropped" }> | Readonly<{ kind: "included"; value: unknown }>; +/** Observer-safe structured output selected independently from agent output. */ +export type ObservedResultData = + | Readonly<{ kind: "absent" }> + | Readonly<{ kind: "dropped" }> + | Readonly<{ kind: "included"; value: unknown }>; + +/** The status portion of an action result; it deliberately cannot carry data. */ +export type ObservedActionResult = Readonly>; + /** Core-authored compound-action ancestry. */ export interface DispatchLineage { readonly rootDispatchId: string; @@ -1407,7 +1508,8 @@ export type DispatchEvent = | (DispatchEventBase & Readonly<{ phase: "executing" }>) | (DispatchEventBase & Readonly<{ phase: "succeeded" | "failed" | "cancelled"; - result: Readonly; + result: ObservedActionResult; + resultData: ObservedResultData; }>); export type DispatchListener = @@ -1650,6 +1752,12 @@ export interface StageExplanation { readonly bridge: { readonly id: string; readonly registered: boolean } | null; } +/** Effective bridge state for one action currently visible to the agent. */ +export interface ActionExplanation { + readonly name: string; + readonly bridge: { readonly id: string; readonly registered: boolean } | null; +} + /** * The structured answer to DX-01's three questions — which stage is active, * which bridges are registered, and what the agent can currently see — as one @@ -1669,6 +1777,8 @@ export interface Explanation { readonly stage: string | null; /** Every declared stage, in declaration order, matched or not. */ readonly stages: ReadonlyArray; + /** Effective action-local or stage-fallback bridge for each visible action. */ + readonly actions: ReadonlyArray; /** * The action **names** the agent can currently call — not the * {@link EmittedTool} array. @@ -1896,6 +2006,8 @@ export interface ConciergeConfig { * @default 600 */ dedupeWindowMs?: number; + /** Maximum UTF-8 JSON bytes accepted in one action result's `data`. */ + maxActionDataBytes?: number; /** Maximum nested child-action depth. @default 16 */ maxWorkflowDepth?: number; /** Maximum unique child steps admitted by one root workflow. @default 256 */ diff --git a/packages/concierge/test-d/ai-sdk.test-d.ts b/packages/concierge/test-d/ai-sdk.test-d.ts index b8bb6eb..0e4de03 100644 --- a/packages/concierge/test-d/ai-sdk.test-d.ts +++ b/packages/concierge/test-d/ai-sdk.test-d.ts @@ -79,7 +79,7 @@ if (prepared.kind === "ready") { void tools; void contextUpdate; -// @ts-expect-error — turn identity is mandatory in contract v2. +// @ts-expect-error — turn identity is mandatory in contract v3. adapter.prepareStep({ catalog, responseId: "r", toolCalls: [] }); adapter.prepareStep({ catalog, responseId: "r", userTurnId: "turn", toolCalls: [{ diff --git a/packages/concierge/test-d/bridge.test-d.ts b/packages/concierge/test-d/bridge.test-d.ts index 0e2617c..16ddf5d 100644 --- a/packages/concierge/test-d/bridge.test-d.ts +++ b/packages/concierge/test-d/bridge.test-d.ts @@ -155,8 +155,8 @@ type _createBridgeDefaultsToBridge = Expect ActionResult>>; +/** `Equals`, not `Assignable`, per `actions.test-d.ts:469-476`: an `Assignable` spelling "stays true when the field is widened to `unknown`, to a bare function type, or to a union that swallows the declared type — which is exactly the silent-widening regression worth guarding". CONTEXT settles the two-string form, so this shape is stable and a change to it should be a decision rather than a drive-by. The data-less `ActionResult` status is pinned in the same breath: `offPageResult` cannot produce structured data, and keeping that fact in its return type lets legacy handlers use it without falsely widening their result after structured outputs were added. Intersecting rather than omitting keeps the public `data` property readable as `undefined` for source compatibility. */ +type _offPageResultSignature = Expect ActionResult & { readonly data?: never }>>; /** The executable form of the sentence above, so the choice of spelling is measured rather than asserted. Both parameters widened to `unknown` and the result is STILL assignable to the declared shape — this predicate reads true, and it is the proof that an `Assignable` spelling of the line above would have gone on passing through precisely the regression it exists to catch. If TypeScript ever changed this, the argument in the header would be wrong and this line is what would say so. */ type _assignableStaysTrueUnderTheWideningEqualsCatches = Expect ActionResult, (what: string, where: string) => ActionResult>>; diff --git a/packages/concierge/test-d/catalog.test-d.ts b/packages/concierge/test-d/catalog.test-d.ts index 1538aed..cdef851 100644 --- a/packages/concierge/test-d/catalog.test-d.ts +++ b/packages/concierge/test-d/catalog.test-d.ts @@ -330,7 +330,7 @@ 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 _catalogIssueCodeIsExactlyElevenMembers = 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>>; diff --git a/packages/concierge/test-d/concierge.test-d.ts b/packages/concierge/test-d/concierge.test-d.ts index 4617d0c..36e40bb 100644 --- a/packages/concierge/test-d/concierge.test-d.ts +++ b/packages/concierge/test-d/concierge.test-d.ts @@ -132,7 +132,7 @@ type _stageExplanationMembersAreReadonly = Expect>; +type _explanationHasExactlyFourFields = Expect>; /** One spelling of "no stage" across atomic catalogs and explanations. */ type _explanationStageIsNullableString = Expect>; diff --git a/packages/concierge/test-d/dispatcher.test-d.ts b/packages/concierge/test-d/dispatcher.test-d.ts index 244f4e6..4c11620 100644 --- a/packages/concierge/test-d/dispatcher.test-d.ts +++ b/packages/concierge/test-d/dispatcher.test-d.ts @@ -23,7 +23,7 @@ type _dispatchSignature = Expect Promise>>; type _schedulerSignature = Expect void, delayMs: number) => () => void>>; type _conciergeKeys = Expect>; -type _actionResultKeysExcludeTerminalControl = Expect>; +type _actionResultKeysExcludeTerminalControl = Expect>; type _publicBatchRow = Awaited>["rows"][number]; type _publicBatchRowKeysExcludeTerminalControl = Expect>; type _toolCallEnvelopeIsReadonly = Expect, { readonly callId: string; readonly name: string; readonly arguments: string; readonly outputIndex: number }>>; diff --git a/packages/concierge/test-d/results.test-d.ts b/packages/concierge/test-d/results.test-d.ts index 2962b04..529c33e 100644 --- a/packages/concierge/test-d/results.test-d.ts +++ b/packages/concierge/test-d/results.test-d.ts @@ -1,4 +1,4 @@ -// SC-2 — `ActionResult.reason` is a closed twelve-code union — and SC-7d — +// SC-2 — `ActionResult.reason` is a closed sixteen-code union — and SC-7d — // `MESSAGE_MAX_CHARS` keeps its literal type. // // This file declares nothing to the outside world. The imports below already @@ -56,10 +56,10 @@ type _reasonAdmitsUndefined = Expect; +}; + +declare const inputSchema: StandardSchemaV1; +declare const outputSchema: StandardSchemaV1; + +const rich = defineAction({ + name: "getVisibleResults", + description: "Read the visible hotel results.", + schema: inputSchema, + redact: "drop", + output: { schema: outputSchema, redact: "drop" }, + handler: ({ args }) => ({ + ok: true, + message: `Found results for ${args.query}.`, + data: { + kind: "visible-results", + hotels: [{ id: "hotel-1", price: 125 }], + }, + }), +}); + +type RichHandlerResult = Awaited>; +type _richDataInferred = Expect | undefined>>; +type _richResultIsBroadResult = Expect>; +type _richOutputIsActionData = Expect, ActionData>>; + +const failureWithData = defineAction({ + name: "findHotel", + description: "Find one hotel by its name.", + schema: inputSchema, + redact: "drop", + output: { schema: outputSchema, redact: "passthrough" }, + handler: () => ({ + ok: false, + reason: "precondition_failed", + message: "More than one hotel matched.", + data: { kind: "visible-results" as const, hotels: [] }, + }), +}); + +const legacy = defineAction({ + name: "legacyAction", + description: "Run the legacy action.", + schema: inputSchema, + redact: "drop", + handler: () => ({ ok: true, message: "Done." }), +}); + +defineAction({ + name: "undeclaredData", + description: "Try to return undeclared data.", + schema: inputSchema, + redact: "drop", + // @ts-expect-error - an action without output.schema cannot return data + handler: () => ({ ok: true, message: "Invalid.", data: { hidden: true } }), +}); + +type ResultsBridge = Bridge< + { select: (id: string) => void }, + { selectedId: () => string | null } +>; +declare const resultsRegistry: BridgeRegistry; +type CartBridge = Bridge< + { remove: (id: string) => void }, + { total: () => number } +>; +declare const cartRegistry: BridgeRegistry; + +const bridged = defineAction({ + name: "selectResult", + description: "Select one visible result.", + schema: inputSchema, + redact: "drop", + bridge: resultsRegistry, + handler: ({ args, bridge }) => { + bridge?.actions.select(args.query); + return { ok: true, message: "Selected." }; + }, +}); + +type BridgedContext = Parameters<(typeof bridged)["handler"]>[0]; +type _actionBridgeInferred = Expect>; + +defineAction<"wrongBridge", "Reject a mismatched action bridge.", typeof inputSchema, ResultsBridge>({ + name: "wrongBridge", + description: "Reject a mismatched action bridge.", + schema: inputSchema, + redact: "drop", + // @ts-expect-error - a concrete action bridge must match the handler bridge + bridge: cartRegistry, + handler: () => ({ ok: true, message: "Done." }), +}); + +const erased: ReadonlyArray> = [ + legacy, + rich, + failureWithData, + bridged, +]; +const config: ConciergeConfig = { + stages: [{ id: "results", match: () => true, actions: erased }], + crossStage: [rich, bridged], +}; +void config; + +type LegacyHandler = ActionHandler; +type LegacyReturn = Awaited>; +type _legacyHandlerCannotReturnData = Expect>>; diff --git a/packages/concierge/test/action-bridges.test.ts b/packages/concierge/test/action-bridges.test.ts new file mode 100644 index 0000000..21b5b33 --- /dev/null +++ b/packages/concierge/test/action-bridges.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { createBridge, createConcierge } from "../dist/index.js"; + +const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); +const CONTEXT = Object.freeze({ page: "active" }); + +beforeEach(() => { + delete (globalThis as Record)[CONTRACT_KEY]; +}); + +function schema() { + return { + "~standard": { + version: 1, + vendor: "action-bridges-test", + validate: (value: unknown) => ({ value }), + }, + }; +} + +function action( + name: string, + handler: (context: Record) => unknown, + options: Record = {}, +) { + return { + name, + description: `Run ${name}.`, + schema: schema(), + jsonSchema: { type: "object" }, + redact: "drop", + effects: { readOnly: true }, + handler, + ...options, + }; +} + +function bridge(marker: string) { + return { + marker, + actions: {}, + snapshot: { marker: () => marker }, + }; +} + +function request(catalog: { revision: symbol }, name: string, callId = name) { + return { + name, + input: {}, + catalogRevision: catalog.revision, + identity: { + sessionId: "session-1", + responseId: "response-1", + callId, + userTurnId: "turn-1", + outputIndex: 0, + }, + }; +} + +describe("action-scoped bridge resolution", () => { + it("supports heterogeneous action bridges with action precedence and stage fallback", async () => { + const stageRegistry = createBridge("stage"); + const resultsRegistry = createBridge("results"); + const galleryRegistry = createBridge("gallery"); + stageRegistry.register(bridge("stage")); + resultsRegistry.register(bridge("results")); + galleryRegistry.register(bridge("gallery")); + + const actions = [ + action("results", ({ bridge: live }) => ({ ok: true, message: live?.marker ?? "missing" }), { + bridge: resultsRegistry, + }), + action("gallery", ({ bridge: live }) => ({ ok: true, message: live?.marker ?? "missing" }), { + bridge: galleryRegistry, + }), + action("fallback", ({ bridge: live }) => ({ ok: true, message: live?.marker ?? "missing" })), + ]; + const concierge = createConcierge({ + stages: [{ + id: "active", + match: (ctx: { page?: string }) => ctx.page === "active", + bridge: stageRegistry, + actions, + }], + }); + const catalog = concierge.resolveCatalog(CONTEXT); + + await expect(concierge.dispatch(CONTEXT, request(catalog, "results"))).resolves.toMatchObject({ message: "results" }); + await expect(concierge.dispatch(CONTEXT, request(catalog, "gallery"))).resolves.toMatchObject({ message: "gallery" }); + await expect(concierge.dispatch(CONTEXT, request(catalog, "fallback"))).resolves.toMatchObject({ message: "stage" }); + + expect(concierge.explain(CONTEXT).actions).toEqual([ + { name: "results", bridge: { id: "results", registered: true } }, + { name: "gallery", bridge: { id: "gallery", registered: true } }, + { name: "fallback", bridge: { id: "stage", registered: true } }, + ]); + }); + + it("uses an action bridge for a cross-stage action and returns null when it is unmounted", async () => { + const stageRegistry = createBridge("stage"); + const crossRegistry = createBridge("cross"); + stageRegistry.register(bridge("stage")); + const unregister = crossRegistry.register(bridge("cross")); + const cross = action("navigate", ({ bridge: live }) => ({ + ok: true, + message: live?.marker ?? "missing", + }), { bridge: crossRegistry }); + const concierge = createConcierge({ + stages: [{ + id: "active", + match: (ctx: { page?: string }) => ctx.page === "active", + bridge: stageRegistry, + actions: [], + }], + crossStage: [cross], + }); + const catalog = concierge.resolveCatalog(CONTEXT); + + await expect(concierge.dispatch(CONTEXT, request(catalog, "navigate", "mounted"))).resolves.toMatchObject({ message: "cross" }); + unregister(); + await expect(concierge.dispatch(CONTEXT, request(catalog, "navigate", "unmounted"))).resolves.toMatchObject({ message: "missing" }); + expect(concierge.explain(CONTEXT).actions).toEqual([ + { name: "navigate", bridge: { id: "cross", registered: false } }, + ]); + }); + + it("reads one effective bridge once and reuses it for consent capture and the handler", async () => { + const first = bridge("first"); + const second = bridge("second"); + let reads = 0; + let handlerBridge: unknown; + const alternatingRegistry = Object.freeze({ + id: "alternating", + register: () => () => undefined, + read: () => { + reads += 1; + return reads === 1 ? first : second; + }, + }); + const review = action("review", ({ bridge: live }) => { + handlerBridge = live; + return { ok: true, message: live?.marker ?? "missing" }; + }, { bridge: alternatingRegistry }); + const confirm = action("confirm", () => ({ ok: true, message: "Confirmed." }), { + consent: { requires: "review", bindTo: "response" }, + effects: { readOnly: false, destructive: true, idempotent: false }, + }); + const concierge = createConcierge({ + stages: [{ id: "active", match: () => true, actions: [review, confirm] }], + consentProfile: { + consentGrade: "delivered", + userTurnIdentity: "agent-forgeable", + }, + }); + const catalog = concierge.resolveCatalog(CONTEXT); + + await expect(concierge.dispatch(CONTEXT, request(catalog, "review"))).resolves.toMatchObject({ + ok: true, + message: "first", + }); + expect(reads).toBe(1); + expect(handlerBridge).toBe(first); + }); +}); diff --git a/packages/concierge/test/ai-sdk/adapter.test.ts b/packages/concierge/test/ai-sdk/adapter.test.ts index a2d9304..67e50f5 100644 --- a/packages/concierge/test/ai-sdk/adapter.test.ts +++ b/packages/concierge/test/ai-sdk/adapter.test.ts @@ -291,7 +291,11 @@ describe("AI SDK-neutral tool and step adapter", () => { callId: "call-1", name: "setTheme", outputIndex: 0, - result: { ok: true, message: "Theme changed." }, + result: { + ok: true, + message: "Theme changed.", + data: { theme: "dark", applied: true }, + }, }], }; @@ -301,13 +305,21 @@ describe("AI SDK-neutral tool and step adapter", () => { toolName: "setTheme", output: { type: "json", - value: { ok: true, message: "Theme changed." }, + value: { + ok: true, + message: "Theme changed.", + data: { theme: "dark", applied: true }, + }, }, }]); expect(adapter.toToolOutputUpdates(preparedResult.value, report)).toEqual([{ tool: "setTheme", toolCallId: "call-1", - output: { ok: true, message: "Theme changed." }, + output: { + ok: true, + message: "Theme changed.", + data: { theme: "dark", applied: true }, + }, }]); expect(() => adapter.toToolResultParts(preparedResult.value, { ...report, diff --git a/packages/concierge/test/ai-sdk/artifact.test.ts b/packages/concierge/test/ai-sdk/artifact.test.ts index 57b90f7..9770efc 100644 --- a/packages/concierge/test/ai-sdk/artifact.test.ts +++ b/packages/concierge/test/ai-sdk/artifact.test.ts @@ -11,7 +11,7 @@ describe("published artifact boundaries", () => { "utf8", )); - expect(manifest.version).toMatch(/^0\.2\.\d+$/u); + expect(manifest.version).toMatch(/^0\.3\.\d+$/u); expect(manifest.peerDependencies.ai).toBe("^6.0.0 || ^7.0.0"); expect(manifest.peerDependenciesMeta.ai).toEqual({ optional: true }); expect(manifest.publishConfig).toEqual({ access: "public", tag: "latest" }); diff --git a/packages/concierge/test/ai-sdk/signed-bridge.test.ts b/packages/concierge/test/ai-sdk/signed-bridge.test.ts index 83d8434..161ab21 100644 --- a/packages/concierge/test/ai-sdk/signed-bridge.test.ts +++ b/packages/concierge/test/ai-sdk/signed-bridge.test.ts @@ -428,7 +428,11 @@ describe("signed server-to-browser dispatch", () => { callId: call?.callId, name: call?.name, outputIndex: call?.outputIndex, - result: { ok: true, message: "Theme changed." }, + result: { + ok: true, + message: "Theme changed.", + data: { theme: "dark", applied: true }, + }, }], }; }, @@ -450,6 +454,14 @@ describe("signed server-to-browser dispatch", () => { const report = await bridge.accept(fixture.envelope); expect(report).toMatchObject({ kind: "completed" }); + expect(report).toMatchObject({ + rows: [{ + callId: "call-1", + result: { + data: { theme: "dark", applied: true }, + }, + }], + }); expect(observedBatch?.catalogRevision).toBe(revision); expect(observedBatch?.responseId).toBe("response-1"); expect(await bridge.accept(fixture.envelope)).toEqual({ diff --git a/packages/concierge/test/artifact.test.ts b/packages/concierge/test/artifact.test.ts index 6e61d5f..53c61e4 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 2", async () => { + it("CONTRACT_VERSION reaches dist/index.js as the integer 3", async () => { const m = await import(DIST_URL.href); - expect(m.CONTRACT_VERSION).toBe(2); + expect(m.CONTRACT_VERSION).toBe(3); }); it("assertSingleInstance reaches dist/index.js as a callable function", async () => { diff --git a/packages/concierge/test/bridge-snapshot.test.ts b/packages/concierge/test/bridge-snapshot.test.ts index 797f1e4..2668433 100644 --- a/packages/concierge/test/bridge-snapshot.test.ts +++ b/packages/concierge/test/bridge-snapshot.test.ts @@ -1462,7 +1462,7 @@ describe("BRG-03 / DX-02 — a handler given bridge: null returns a sentence, no expect(result.ok).toBe(false); // `toBe("no_bridge")`, not `toBeDefined()`. `ReasonCode` is a CLOSED union - // of twelve members whose additions are breaking changes by design, and + // of sixteen members whose additions are breaking changes by design, and // `no_bridge` is the one declared for exactly this case. A handler placing // any other member here would be lying to the model about why it stopped. expect(result.reason).toBe("no_bridge"); diff --git a/packages/concierge/test/concierge.test.ts b/packages/concierge/test/concierge.test.ts index fe010ee..f7f9d84 100644 --- a/packages/concierge/test/concierge.test.ts +++ b/packages/concierge/test/concierge.test.ts @@ -1094,17 +1094,19 @@ describe("SEC-03 — the tool list handed to the agent cannot be tampered with", }); describe("DX-01 — explain() answers \"why didn't my action fire\"", () => { - it("S16 — the returned object has exactly three fields: stage, stages, catalog", () => { + it("S16 — the returned object has exactly four bounded diagnostic fields", () => { const concierge = canonical(); const explanation = concierge.explain({ pathname: "/results" }); - // Pinned at exactly three, and the reason is disclosure rather than tidiness. + // Pinned at exactly four, and the reason is disclosure rather than tidiness. // `explain` is a developer-facing diagnostic that a devtools panel or a log // line will render wholesale, so a fourth field carrying the CONTEXT or an // action's arguments would ship user data into whatever reads it. A future // field cannot be added without this case going red, which is the point. - expect(Object.keys(explanation)).toHaveLength(3); - expect(Object.keys(explanation)).toEqual(["stage", "stages", "catalog"]); + // `actions` contains only names and effective bridge state, never context or + // invocation arguments. + expect(Object.keys(explanation)).toHaveLength(4); + expect(Object.keys(explanation)).toEqual(["stage", "stages", "actions", "catalog"]); }); it("S17 — explain().stage agrees with atomic resolution across matcher outcomes", () => { diff --git a/packages/concierge/test/core-v2.test.ts b/packages/concierge/test/core-v2.test.ts index de4a0f0..ee58f59 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 v2 catalog and dispatch", () => { +describe("contract v3 catalog and dispatch", () => { it("exports only the v2 Concierge runtime surface and resolves availability atomically", () => { let availabilityReads = 0; const concierge = conciergeFor([ @@ -732,7 +732,7 @@ describe("contract v2 catalog and dispatch", () => { }); }); -describe("contract v2 Session", () => { +describe("contract v3 Session", () => { function transportHarness() { let batchHandler; const publications = []; diff --git a/packages/concierge/test/dispatcher.test.ts b/packages/concierge/test/dispatcher.test.ts index b071262..e67c8f3 100644 --- a/packages/concierge/test/dispatcher.test.ts +++ b/packages/concierge/test/dispatcher.test.ts @@ -1630,7 +1630,7 @@ async function withFakeNow(initial, run) { }); }); - it("[R45] accepts every member of the closed twelve-reason vocabulary", async () => { + it("[R45] accepts every member of the closed sixteen-reason vocabulary", async () => { const reasons = [ "declined", "cancelled", @@ -1644,6 +1644,10 @@ async function withFakeNow(initial, run) { "consent_required", "consent_stale", "grade_unavailable", + "catalog_stale", + "invalid_invocation", + "identity_conflict", + "precondition_failed", ]; const concierge = conciergeFor( reasons.map((reason) => @@ -1938,6 +1942,9 @@ async function withFakeNow(initial, run) { { id: "first", matched: true, bridge: { id: "original", registered: true } }, { id: "second", matched: false, bridge: null }, ], + actions: [ + { name: "safe-stage-action", bridge: { id: "original", registered: true } }, + ], catalog: ["safe-stage-action"], }, otherCalls: 0, diff --git a/packages/concierge/test/export-surface.test.ts b/packages/concierge/test/export-surface.test.ts index 9586a5a..9f3f9b8 100644 --- a/packages/concierge/test/export-surface.test.ts +++ b/packages/concierge/test/export-surface.test.ts @@ -108,6 +108,7 @@ const VALUE_EXPORTS = [ "USER_DECLINED", "CONSENT_GRADE_ORDER", "MESSAGE_MAX_CHARS", + "DEFAULT_ACTION_DATA_MAX_BYTES", "CONTRACT_VERSION", "assertSingleInstance", "JSON_SCHEMA_TARGET", @@ -144,15 +145,15 @@ beforeAll(() => { }); describe("the published export surface of dist/index.d.ts", () => { - it("is exactly 88 names — an export added or dropped by a build-config change lands here", () => { + it("is exactly 95 names — an export added or dropped by a build-config change lands here", () => { const { names } = readSurface(); - expect(names).toHaveLength(88); + expect(names).toHaveLength(95); }); - it("splits 73 types to 15 values", () => { + it("splits 79 types to 16 values", () => { const { types, values } = readSurface(); - expect(types).toHaveLength(73); - expect(values).toHaveLength(15); + expect(types).toHaveLength(79); + expect(values).toHaveLength(16); }); it("carries all six consent evidence and outcome types by name", () => { @@ -162,7 +163,7 @@ describe("the published export surface of dist/index.d.ts", () => { } }); - it("carries all fifteen runtime value exports by name", () => { + it("carries all sixteen 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 9b22129..3728683 100644 --- a/packages/concierge/test/fixtures/probe.ts +++ b/packages/concierge/test/fixtures/probe.ts @@ -63,6 +63,7 @@ import { MESSAGE_MAX_CHARS, + DEFAULT_ACTION_DATA_MAX_BYTES, CONTRACT_VERSION, CONSENT_GRADE_ORDER, USER_CANCELLED, @@ -74,6 +75,7 @@ import { createSession, } from "@full-self-browsing/concierge"; import type { + ActionData, ActionResult, CatalogRevision, Concierge, @@ -102,6 +104,8 @@ import type { TransportStatus, TurnIdentityProvenance, } from "@full-self-browsing/concierge"; +import { createOpenAIRealtimeCodec } from "@full-self-browsing/concierge/openai-realtime"; +import type { OpenAIRealtimeCodec } from "@full-self-browsing/concierge/openai-realtime"; import { getConciergeTelemetryStatus, mountConciergeTelemetry, @@ -119,6 +123,10 @@ import type { * deliberately — its declared `| undefined` is what makes omission legal. */ export const r: ActionResult = { ok: true, message: "ok" }; +export const richData: ActionData = Object.freeze({ + kind: "probe", + values: Object.freeze([1, true, null]), +}); /** * The whole point of the harness, in one line. If the emitted declaration ever @@ -127,7 +135,9 @@ export const r: ActionResult = { ok: true, message: "ok" }; 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: 2 = CONTRACT_VERSION; +export const v: 3 = CONTRACT_VERSION; +export const maxActionDataBytes: 262144 = DEFAULT_ACTION_DATA_MAX_BYTES; +export const realtimeCodec: OpenAIRealtimeCodec = createOpenAIRealtimeCodec(); /** * A value import of the one function the package actually executes. Annotating @@ -300,6 +310,7 @@ export const foreignConcierge: Concierge = { Object.freeze({ stage: null, stages: Object.freeze([]), + actions: Object.freeze([]), catalog: Object.freeze([]), }), }; diff --git a/packages/concierge/test/fixtures/stub-transport.ts b/packages/concierge/test/fixtures/stub-transport.ts index 084f8ca..6bf7f24 100644 --- a/packages/concierge/test/fixtures/stub-transport.ts +++ b/packages/concierge/test/fixtures/stub-transport.ts @@ -182,9 +182,13 @@ function snapshotActionResult(result: ActionResult): ActionResult { if (Object.isFrozen(result)) return result; const reason: ActionResult["reason"] = result.reason; - return reason === undefined - ? Object.freeze({ ok: result.ok, message: result.message }) - : Object.freeze({ ok: result.ok, reason, message: result.message }); + const data: ActionResult["data"] = result.data; + return Object.freeze({ + ok: result.ok, + ...(reason === undefined ? {} : { reason }), + message: result.message, + ...(data === undefined ? {} : { data }), + }); } function readOwnDataProperty(value: unknown, key: string): unknown { diff --git a/packages/concierge/test/openai-realtime.test.ts b/packages/concierge/test/openai-realtime.test.ts new file mode 100644 index 0000000..7a79e34 --- /dev/null +++ b/packages/concierge/test/openai-realtime.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { createConcierge } from "../dist/index.js"; +import { createOpenAIRealtimeCodec } from "../dist/openai-realtime/index.js"; + +const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); + +beforeEach(() => { + delete (globalThis as Record)[CONTRACT_KEY]; +}); + +function completedResponse(output: unknown[], overrides: Record = {}) { + return { + type: "response.done", + response: { + id: "response-1", + status: "completed", + output, + ...overrides, + }, + }; +} + +function functionCall(callId: string, name: string, argumentsText: string) { + return { + type: "function_call", + status: "completed", + call_id: callId, + name, + arguments: argumentsText, + }; +} + +function schema() { + return { + "~standard": { + version: 1, + vendor: "openai-realtime-test", + validate: (value: unknown) => ({ value }), + }, + }; +} + +describe("OpenAI Realtime protocol codec", () => { + it("publishes catalog tools and extracts every completed function call in response order", () => { + const codec = createOpenAIRealtimeCodec(); + const revision = Symbol("acknowledged-catalog"); + const tools = Object.freeze([Object.freeze({ + type: "function", + name: "searchHotels", + description: "Search for hotels.", + parameters: Object.freeze({ type: "object", properties: Object.freeze({}) }), + })]); + const catalog = Object.freeze({ stage: "results", revision, tools }); + + const sessionTools = codec.toSessionTools(catalog); + expect(sessionTools).toEqual(tools); + expect(sessionTools).toBe(codec.toSessionTools(catalog)); + expect(Object.isFrozen(sessionTools)).toBe(true); + expect(Object.isFrozen(sessionTools[0])).toBe(true); + + const event = completedResponse([ + { type: "message", status: "completed", content: [] }, + functionCall("call-search", "searchHotels", "{\"city\":\"Oslo\"}"), + { type: "message", status: "completed", content: [] }, + functionCall("call-map", "showMap", "{\"hotelId\":\"h-1\"}"), + ]); + const batch = codec.extractCompletedBatch({ + response: event, + sessionId: "session-1", + userTurnId: "turn-1", + catalogRevision: revision, + }); + + expect(batch).toEqual({ + sessionId: "session-1", + responseId: "response-1", + userTurnId: "turn-1", + catalogRevision: revision, + calls: [ + { + callId: "call-search", + name: "searchHotels", + arguments: "{\"city\":\"Oslo\"}", + outputIndex: 1, + }, + { + callId: "call-map", + name: "showMap", + arguments: "{\"hotelId\":\"h-1\"}", + outputIndex: 3, + }, + ], + }); + expect(Object.isFrozen(batch)).toBe(true); + expect(Object.isFrozen(batch?.calls)).toBe(true); + }); + + it("fails closed for duplicates, malformed call records, and non-completed responses", () => { + const codec = createOpenAIRealtimeCodec(); + const base = { + sessionId: "session-1", + userTurnId: "turn-1", + catalogRevision: Symbol("catalog"), + }; + expect(codec.extractCompletedBatch({ + ...base, + response: completedResponse([ + functionCall("duplicate", "first", "{}"), + functionCall("duplicate", "second", "{}"), + ]), + })).toBeNull(); + expect(codec.extractCompletedBatch({ + ...base, + response: completedResponse([ + { ...functionCall("incomplete", "first", "{}"), status: "in_progress" }, + ]), + })).toBeNull(); + expect(codec.extractCompletedBatch({ + ...base, + response: completedResponse( + [functionCall("cancelled", "first", "{}")], + { status: "cancelled" }, + ), + })).toBeNull(); + expect(codec.extractCompletedBatch({ + ...base, + response: { + type: "response.output_item.done", + response: completedResponse([]).response, + }, + })).toBeNull(); + + let accessorReads = 0; + const hostile: Record = { + type: "function_call", + status: "completed", + call_id: "call-hostile", + name: "hostile", + }; + Object.defineProperty(hostile, "arguments", { + enumerable: true, + get() { + accessorReads += 1; + return "{}"; + }, + }); + expect(codec.extractCompletedBatch({ + ...base, + response: completedResponse([hostile]), + })).toBeNull(); + expect(accessorReads).toBe(0); + }); + + it("lets core classify malformed argument JSON and rejects a stale catalog revision", async () => { + let activePage = "old"; + const action = { + name: "search", + description: "Search for hotels.", + schema: schema(), + jsonSchema: { type: "object" }, + redact: "drop", + effects: { readOnly: true }, + handler: () => ({ ok: true, message: "Searched." }), + availableWhen: () => activePage === "old", + }; + const concierge = createConcierge({ + stages: [{ id: "active", match: () => true, actions: [action] }], + }); + const oldCatalog = concierge.resolveCatalog({ page: "old" }); + const codec = createOpenAIRealtimeCodec(); + const malformed = codec.extractCompletedBatch({ + response: completedResponse([functionCall("bad-json", "search", "{")]), + sessionId: "session-1", + userTurnId: "turn-1", + catalogRevision: oldCatalog.revision, + }); + if (malformed === null) throw new Error("Expected a raw completed batch."); + const malformedOutcome = await concierge.dispatchBatch({ page: "old" }, malformed); + expect(malformedOutcome.rows[0]?.result).toMatchObject({ + ok: false, + reason: "invalid_args", + }); + + activePage = "new"; + const currentCatalog = concierge.resolveCatalog({ page: "new" }); + expect(currentCatalog.revision).not.toBe(oldCatalog.revision); + const stale = codec.extractCompletedBatch({ + response: completedResponse([functionCall("stale", "search", "{}")]), + sessionId: "session-1", + userTurnId: "turn-2", + catalogRevision: oldCatalog.revision, + }); + if (stale === null) throw new Error("Expected a raw completed batch."); + const staleOutcome = await concierge.dispatchBatch({ page: "new" }, stale); + expect(staleOutcome.rows[0]?.result).toMatchObject({ + ok: false, + reason: "catalog_stale", + }); + }); + + it("encodes structured results once with exact call ids and emits nothing for terminal outcomes", () => { + const codec = createOpenAIRealtimeCodec(); + const completed = { + kind: "completed", + rows: [ + { + dispatchId: "dispatch-1", + callId: "original-call-id", + name: "search", + outputIndex: 4, + result: { + ok: true, + message: "Found hotels.", + data: { kind: "visible-results", hotels: [] }, + }, + }, + ], + } as const; + const events = codec.toFunctionCallOutputEvents(completed); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "conversation.item.create", + item: { + type: "function_call_output", + call_id: "original-call-id", + }, + }); + expect(JSON.parse(events[0]?.item.output ?? "null")).toEqual(completed.rows[0].result); + expect(JSON.parse(events[0]?.item.output ?? "null").data).not.toBeTypeOf("string"); + expect(events.some((event) => event.type === ("response.create" as never))).toBe(false); + + expect(codec.toFunctionCallOutputEvents({ + kind: "terminal", + rows: completed.rows, + enteredBy: { + dispatchId: "dispatch-1", + callId: "original-call-id", + name: "search", + outputIndex: 4, + lineage: { rootDispatchId: "dispatch-1", depth: 0 }, + }, + })).toEqual([]); + }); +}); diff --git a/packages/concierge/test/single-instance.test.ts b/packages/concierge/test/single-instance.test.ts index 72a45c0..97e06a2 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 v2 throw with both versions and the remediation", async () => { + it("F2 — a legacy v1 record makes v3 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 v2/); + expect(() => assertSingleInstance()).toThrow(/contract v1 and v3/); expect(() => assertSingleInstance()).toThrow(/peerDependency/); }); @@ -379,7 +379,7 @@ describe(SUITE_TITLE, () => { tools: Object.freeze([]), }), onDispatch: () => () => {}, - explain: () => Object.freeze({ stage: null, stages: [], catalog: [] }), + explain: () => Object.freeze({ stage: null, stages: [], catalog: [], actions: [] }), }; const transport = { capabilities: Object.freeze({ diff --git a/packages/concierge/test/structured-results.test.ts b/packages/concierge/test/structured-results.test.ts new file mode 100644 index 0000000..630014a --- /dev/null +++ b/packages/concierge/test/structured-results.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { createConcierge } from "../dist/index.js"; + +const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); +const CONTEXT = Object.freeze({ page: "active" }); + +beforeEach(() => { + delete (globalThis as Record)[CONTRACT_KEY]; +}); + +function schema(validate = (value: unknown) => ({ value })) { + return { + "~standard": { + version: 1, + vendor: "structured-results-test", + validate, + }, + }; +} + +function action( + name: string, + handler: (context: Record) => unknown, + options: Record = {}, +) { + return { + name, + description: `Run ${name}.`, + schema: schema(), + jsonSchema: { type: "object" }, + redact: "drop", + effects: { readOnly: true }, + handler, + ...options, + }; +} + +function richAction( + name: string, + handler: (context: Record) => unknown, + outputValidate = (value: unknown) => ({ value }), + redact: unknown = "drop", +) { + return action(name, handler, { + output: { schema: schema(outputValidate), redact }, + }); +} + +function conciergeFor( + actions: ReadonlyArray>, + extra: Record = {}, +) { + return createConcierge({ + stages: [{ id: "active", match: (ctx: { page?: string }) => ctx.page === "active", actions }], + ...extra, + }); +} + +function identity(callId: string, outputIndex = 0) { + return { + sessionId: "session-1", + responseId: "response-1", + callId, + userTurnId: "turn-1", + outputIndex, + }; +} + +async function dispatch( + concierge: ReturnType, + name: string, + input: unknown = {}, + callId = name, +) { + const catalog = concierge.resolveCatalog(CONTEXT); + return concierge.dispatch(CONTEXT, { + name, + input, + catalogRevision: catalog.revision, + identity: identity(callId), + }); +} + +async function flushEvents() { + for (let index = 0; index < 8; index += 1) await Promise.resolve(); +} + +describe("declared structured action results", () => { + it("rejects incomplete structured-output declarations while legacy actions remain valid", () => { + expect(() => conciergeFor([ + action("missingRedaction", () => ({ ok: true, message: "Done." }), { + output: { schema: schema() }, + }), + ])).toThrow(/output_redaction_missing/u); + + expect(() => conciergeFor([ + action("invalidSchema", () => ({ ok: true, message: "Done." }), { + output: { schema: {}, redact: "drop" }, + }), + ])).toThrow(/output_schema_invalid/u); + + expect(() => conciergeFor([ + action("legacy", () => ({ ok: true, message: "Still valid." })), + ])).not.toThrow(); + }); + + it("preserves transformed data on success, failure, direct, batch, and workflow paths", async () => { + const concierge = conciergeFor([ + richAction( + "success", + () => ({ ok: true, message: "Found results.", data: { count: "2" } }), + (value: { count: string }) => ({ value: { kind: "results", count: Number(value.count) } }), + ), + richAction( + "failure", + () => ({ + ok: false, + reason: "precondition_failed", + message: "The current state prevents this action.", + data: { kind: "domain-failure", code: "hotel-ambiguous" }, + }), + ), + richAction( + "child", + () => ({ ok: true, message: "Child complete.", data: { value: 7 } }), + ), + richAction( + "parent", + async ({ workflow }: { workflow: { run(input: unknown): Promise<{ data?: unknown }> } }) => { + const child = await workflow.run({ stepId: "read", name: "child", input: {} }); + return { ok: true, message: "Parent complete.", data: child.data }; + }, + ), + ]); + + await expect(dispatch(concierge, "success")).resolves.toEqual({ + ok: true, + message: "Found results.", + data: { kind: "results", count: 2 }, + }); + await expect(dispatch(concierge, "failure")).resolves.toEqual({ + ok: false, + reason: "precondition_failed", + message: "The current state prevents this action.", + data: { kind: "domain-failure", code: "hotel-ambiguous" }, + }); + await expect(dispatch(concierge, "parent")).resolves.toMatchObject({ + ok: true, + data: { value: 7 }, + }); + + const catalog = concierge.resolveCatalog(CONTEXT); + const outcome = await concierge.dispatchBatch(CONTEXT, { + sessionId: "session-1", + responseId: "response-batch", + userTurnId: "turn-batch", + catalogRevision: catalog.revision, + calls: [ + { callId: "batch-success", name: "success", arguments: "{}", outputIndex: 0 }, + { callId: "batch-failure", name: "failure", arguments: "{}", outputIndex: 1 }, + ], + }); + expect(outcome.kind).toBe("completed"); + expect(outcome.rows.map((row) => row.result.data)).toEqual([ + { kind: "results", count: 2 }, + { kind: "domain-failure", code: "hotel-ambiguous" }, + ]); + }); + + it("detaches and recursively freezes data while preserving dedupe Promise identity", async () => { + const original = { nested: { values: ["first"] } }; + let handlerCalls = 0; + const concierge = conciergeFor([ + richAction("read", () => { + handlerCalls += 1; + return { ok: true, message: "Read state.", data: original }; + }), + ]); + const catalog = concierge.resolveCatalog(CONTEXT); + const request = { + name: "read", + input: {}, + catalogRevision: catalog.revision, + identity: identity("same-call"), + }; + const first = concierge.dispatch(CONTEXT, request); + const second = concierge.dispatch(CONTEXT, request); + expect(second).toBe(first); + const result = await first; + + original.nested.values[0] = "mutated"; + original.nested.values.push("later"); + expect(result.data).toEqual({ nested: { values: ["first"] } }); + expect(result.data).not.toBe(original); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.data)).toBe(true); + expect(Object.isFrozen(result.data.nested)).toBe(true); + expect(Object.isFrozen(result.data.nested.values)).toBe(true); + expect(handlerCalls).toBe(1); + }); + + it("fails closed for undeclared, rejected, unsafe, aliased, and oversized data", async () => { + const shared = { value: "shared" }; + const cycle: Record = {}; + cycle.self = cycle; + const accessor: Record = {}; + let accessorReads = 0; + Object.defineProperty(accessor, "secret", { + enumerable: true, + get() { + accessorReads += 1; + throw new Error("RAW-ACCESSOR-SECRET"); + }, + }); + class Exotic { + value = "private"; + } + const sparse = new Array(2); + sparse[1] = "present"; + const disguisedExotic = Object.create( + Object.create(null), + ) as Record; + disguisedExotic.value = "private"; + const invalidValues: unknown[] = [ + cycle, + { first: shared, second: shared }, + accessor, + new Exotic(), + disguisedExotic, + new Date(0), + new Map(), + new Set(), + sparse, + { value: undefined }, + { value: 1n }, + { value: Symbol("secret") }, + { value: () => "secret" }, + { value: Number.NaN }, + { value: Number.POSITIVE_INFINITY }, + ]; + + for (const [index, data] of invalidValues.entries()) { + const concierge = conciergeFor([ + richAction(`invalid${index}`, () => ({ ok: true, message: "Unsafe.", data })), + ]); + await expect(dispatch(concierge, `invalid${index}`)).resolves.toEqual({ + ok: false, + reason: "invalid_result", + message: "The action returned an invalid result.", + }); + } + expect(accessorReads).toBe(0); + + const rejected = conciergeFor([ + richAction( + "rejected", + () => ({ ok: true, message: "Rejected.", data: { raw: "RAW-SCHEMA-SECRET" } }), + () => ({ issues: [{ message: "do not expose this" }] }), + ), + ]); + await expect(dispatch(rejected, "rejected")).resolves.toMatchObject({ + ok: false, + reason: "invalid_result", + }); + + const undeclared = conciergeFor([ + action("undeclared", () => ({ ok: true, message: "Extra.", data: { hidden: true } })), + ]); + await expect(dispatch(undeclared, "undeclared")).resolves.toMatchObject({ + ok: false, + reason: "invalid_result", + }); + + const oversized = conciergeFor([ + richAction("oversized", () => ({ ok: true, message: "Large.", data: "0123456789" })), + ], { maxActionDataBytes: 5 }); + await expect(dispatch(oversized, "oversized")).resolves.toMatchObject({ + ok: false, + reason: "invalid_result", + }); + }); + + it("keeps legacy result shape and isolates observer output through explicit redaction", async () => { + const events: Array> = []; + const projectionSource = { id: "hotel-1", privateEmail: "private@example.test" }; + const sharedProjection = { safe: true }; + const concierge = conciergeFor([ + action("legacy", () => ({ ok: true, message: "Legacy complete.", ignored: "extra" })), + richAction("drop", () => ({ ok: true, message: "Drop.", data: projectionSource })), + richAction( + "passthrough", + () => ({ ok: true, message: "Pass.", data: projectionSource }), + undefined, + "passthrough", + ), + richAction( + "project", + () => ({ ok: true, message: "Project.", data: projectionSource }), + undefined, + (data: { id: string }) => ({ id: data.id }), + ), + richAction( + "throwingProjection", + () => ({ ok: true, message: "Throw.", data: projectionSource }), + undefined, + () => { + throw new Error("RAW-PROJECTION-SECRET"); + }, + ), + richAction( + "invalidProjection", + () => ({ ok: true, message: "Alias.", data: projectionSource }), + undefined, + () => ({ first: sharedProjection, second: sharedProjection }), + ), + ]); + concierge.onDispatch((event) => events.push(event as unknown as Record)); + + const realConsole = globalThis.console; + const warnings: string[] = []; + globalThis.console = { ...realConsole, warn: (message) => warnings.push(String(message)) }; + try { + const legacy = await dispatch(concierge, "legacy"); + expect(legacy).toEqual({ ok: true, message: "Legacy complete." }); + expect(Object.keys(legacy)).toEqual(["ok", "message"]); + for (const name of ["drop", "passthrough", "project", "throwingProjection", "invalidProjection"]) { + await dispatch(concierge, name); + } + await flushEvents(); + } finally { + globalThis.console = realConsole; + } + + const terminal = new Map( + events + .filter((event) => ["succeeded", "failed", "cancelled"].includes(String(event.phase))) + .map((event) => [event.name, event]), + ); + expect(terminal.get("legacy")?.resultData).toEqual({ kind: "absent" }); + expect(terminal.get("drop")?.resultData).toEqual({ kind: "dropped" }); + expect(terminal.get("passthrough")?.resultData).toEqual({ + kind: "included", + value: projectionSource, + }); + expect(terminal.get("project")?.resultData).toEqual({ + kind: "included", + value: { id: "hotel-1" }, + }); + expect(terminal.get("throwingProjection")?.resultData).toEqual({ kind: "dropped" }); + expect(terminal.get("invalidProjection")?.resultData).toEqual({ kind: "dropped" }); + expect(terminal.get("passthrough")?.result).not.toHaveProperty("data"); + const included = terminal.get("passthrough")?.resultData as { value: unknown }; + expect(included.value).not.toBe(projectionSource); + expect(Object.isFrozen(included.value)).toBe(true); + expect(warnings.join(" ")).not.toContain("RAW-PROJECTION-SECRET"); + expect(warnings.join(" ")).not.toContain("private@example.test"); + }); +}); diff --git a/packages/concierge/test/telemetry/runtime.test.ts b/packages/concierge/test/telemetry/runtime.test.ts index 3e4ba8b..ab2d5da 100644 --- a/packages/concierge/test/telemetry/runtime.test.ts +++ b/packages/concierge/test/telemetry/runtime.test.ts @@ -60,7 +60,7 @@ function runtimeStub(): { listeners.delete(listener); }; }, - explain: () => ({ stage: null, stages: [], catalog: [] }), + explain: () => ({ stage: null, stages: [], catalog: [], actions: [] }), }; return { concierge, diff --git a/packages/concierge/tsdown.config.ts b/packages/concierge/tsdown.config.ts index fa62d57..a9e4a69 100644 --- a/packages/concierge/tsdown.config.ts +++ b/packages/concierge/tsdown.config.ts @@ -14,7 +14,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/openai-realtime/index.ts"], // ESM only, not dual. The dual-package hazard is unusually expensive for this // design: two core instances null the bridge registry, split the dedup window // so a retried call double-fires, and hide consent armed on one instance from diff --git a/scripts/pack-install-check.sh b/scripts/pack-install-check.sh index 5363215..1649369 100755 --- a/scripts/pack-install-check.sh +++ b/scripts/pack-install-check.sh @@ -65,6 +65,10 @@ if printf '%s\n' "$TAR_ENTRIES" | grep -Eq 'stub-transport|(^|/)package/(test|te exit 1 fi for required in \ + package/dist/openai-realtime/index.js \ + package/dist/openai-realtime/index.d.ts \ + package/dist/openai-realtime/index.js.map \ + package/dist/openai-realtime/index.d.ts.map \ package/dist/telemetry/index.js \ package/dist/telemetry/index.d.ts \ package/dist/telemetry/index.js.map \ @@ -147,6 +151,7 @@ echo "==> typechecking the probe against the shipped .d.ts (skipLibCheck: false) echo "==> importing the shipped runtime" node --input-type=module -e ' const m = await import("@full-self-browsing/concierge"); + const realtime = await import("@full-self-browsing/concierge/openai-realtime"); const telemetry = await import("@full-self-browsing/concierge/telemetry"); if (m.MESSAGE_MAX_CHARS !== 180) { throw new Error("runtime binding erased: MESSAGE_MAX_CHARS is " + String(m.MESSAGE_MAX_CHARS)); @@ -157,6 +162,13 @@ node --input-type=module -e ' if (typeof m.createConcierge !== "function") { throw new Error("runtime binding erased: createConcierge is " + typeof m.createConcierge); } + if ( + m.CONTRACT_VERSION !== 3 || + m.DEFAULT_ACTION_DATA_MAX_BYTES !== 262144 || + typeof realtime.createOpenAIRealtimeCodec !== "function" + ) { + throw new Error("contract v3 or OpenAI Realtime runtime export drifted"); + } const concierge = m.createConcierge({ stages: [] }); if (typeof concierge.dispatch !== "function") { throw new Error("runtime createConcierge did not return a callable artifact"); diff --git a/scripts/release/check.mjs b/scripts/release/check.mjs index 85fa10b..3b75122 100644 --- a/scripts/release/check.mjs +++ b/scripts/release/check.mjs @@ -117,6 +117,7 @@ function assertPackageManifest(config, spec, manifest, mode) { "./ai-sdk", "./ai-sdk/server", "./ai-sdk/browser", + "./openai-realtime", "./telemetry", "./package.json", ]; @@ -124,7 +125,7 @@ function assertPackageManifest(config, spec, manifest, mode) { JSON.stringify(Object.keys(manifest.exports ?? {})) === JSON.stringify(expectedExports), "PACKAGE_EXPORTS", - "Core, AI SDK, and telemetry subpath exports drifted", + "Core, AI SDK, OpenAI Realtime, and telemetry subpath exports drifted", ); assert( manifest.exports["./ai-sdk/server"]?.browser === @@ -168,12 +169,12 @@ function checkChangesets(config) { ); } -function checkContractV2() { +function checkContractV3() { const core = readFileSync(join(ROOT, "packages/concierge/src/contract.ts"), "utf8"); assert( - /export const CONTRACT_VERSION = 2;/u.test(core), + /export const CONTRACT_VERSION = 3;/u.test(core), "CONTRACT_VERSION", - "core must publish contract v2", + "core must publish contract v3", ); for (const relativePath of [ "packages/concierge-react/src/client.tsx", @@ -181,9 +182,9 @@ function checkContractV2() { ]) { const source = readFileSync(join(ROOT, relativePath), "utf8"); assert( - /EXPECTED_CONTRACT_VERSION(?:\s*:\s*number)?\s*=\s*2/u.test(source), + /EXPECTED_CONTRACT_VERSION(?:\s*:\s*number)?\s*=\s*3/u.test(source), "CONTRACT_VERSION", - `${relativePath} must reject non-v2 core before registration`, + `${relativePath} must reject non-v3 core before registration`, ); } } @@ -204,7 +205,7 @@ function checkSource(config, mode) { .join(", ")}`, ); checkChangesets(config); - checkContractV2(); + checkContractV3(); return manifests[0].version; } @@ -237,7 +238,7 @@ function checkWorkflow(config) { for (const [file, sealedName] of [ ["scripts/release/config.mjs", "config.mjs"], ["scripts/release/publisher.mjs", "release-publisher.mjs"], - [".release/lines/0.2.json", "release-line.json"], + [".release/lines/0.3.json", "release-line.json"], ]) { const digest = sha256File(join(ROOT, file)); assert( diff --git a/scripts/release/compatibility.mjs b/scripts/release/compatibility.mjs index 34744b6..de51dd8 100644 --- a/scripts/release/compatibility.mjs +++ b/scripts/release/compatibility.mjs @@ -180,16 +180,29 @@ function runAdapterCell(root, inputs, aiVersion) { }); writeFileSync( join(directory, "smoke.mjs"), - `import { CONTRACT_VERSION } from "@full-self-browsing/concierge";\n` + + `import { CONTRACT_VERSION, createConcierge } from "@full-self-browsing/concierge";\n` + `import { EXPECTED_CORE_CONTRACT_VERSION, SIGNED_ENVELOPE_VERSION, toAISDKTools } from "@full-self-browsing/concierge/ai-sdk";\n` + `const server = await import("@full-self-browsing/concierge/ai-sdk/server");\n` + `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 !== 2 || EXPECTED_CORE_CONTRACT_VERSION !== 2 || SIGNED_ENVELOPE_VERSION !== 1) throw new Error("contract drift");\n` + + `if (CONTRACT_VERSION !== 3 || EXPECTED_CORE_CONTRACT_VERSION !== 3 || 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` + `const tools = toAISDKTools([{ type: "function", name: "probe", description: "Probe", parameters: { type: "object", properties: { value: { type: "string" } }, required: ["value"], additionalProperties: false } }]);\n` + `if (!Object.isFrozen(tools) || typeof tools.probe !== "object" || tools.probe === null || !("inputSchema" in tools.probe) || "execute" in tools.probe) throw new Error("tool conversion drift");\n` + + `const standard = { "~standard": { version: 1, vendor: "release-smoke", validate: (value) => ({ value }) } };\n` + + `const concierge = createConcierge({ stages: [{ id: "active", match: () => true, actions: [{ name: "richProbe", description: "Return structured data.", schema: standard, jsonSchema: { type: "object", additionalProperties: false }, redact: "drop", effects: { readOnly: true }, output: { schema: standard, redact: "drop" }, handler: () => ({ ok: true, message: "Complete.", data: { value: 7 } }) }] }] });\n` + + `const catalog = concierge.resolveCatalog({});\n` + + `const codec = realtime.createOpenAIRealtimeCodec();\n` + + `const sessionTools = codec.toSessionTools(catalog);\n` + + `const batch = codec.extractCompletedBatch({ response: { type: "response.done", response: { id: "response-1", status: "completed", output: [{ type: "function_call", status: "completed", call_id: "call-1", name: "richProbe", arguments: "{}" }] } }, sessionId: "session-1", userTurnId: "turn-1", catalogRevision: catalog.revision });\n` + + `if (batch === null || batch.calls[0]?.callId !== "call-1" || sessionTools[0]?.name !== "richProbe") throw new Error("Realtime request translation drift");\n` + + `const outcome = await concierge.dispatchBatch({}, batch);\n` + + `const resultEvents = codec.toFunctionCallOutputEvents(outcome);\n` + + `const result = JSON.parse(resultEvents[0]?.item.output ?? "null");\n` + + `if (resultEvents[0]?.item.call_id !== "call-1" || result?.data?.value !== 7 || resultEvents.some((event) => event.type === "response.create")) throw new Error("Realtime structured result drift");\n` + `process.stdout.write(JSON.stringify({ ai: ${JSON.stringify(aiVersion)}, contract: CONTRACT_VERSION, tools: Object.keys(tools) }) + "\\n");\n`, "utf8", ); @@ -238,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 !== 2 || html !== "ssr" || typeof reactRoot !== "object" || typeof svelteRoot !== "object" || typeof svelteClient.provideConcierge !== "function") throw new Error("framework ESM SSR import drift");\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` + `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 1ba28b3..c9cadec 100644 --- a/scripts/release/config.mjs +++ b/scripts/release/config.mjs @@ -4,7 +4,7 @@ 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.2.json"); +export const DEFAULT_RELEASE_LINE_PATH = join(ROOT, ".release/lines/0.3.json"); const PACKAGE_NAMES = Object.freeze([ "@full-self-browsing/concierge", @@ -148,10 +148,10 @@ function validateReleaseLine(config, source, path) { "compatibility configuration", ); assert( - config.schemaVersion === 1 && config.releaseLine === "0.2" && - config.contractVersion === 2 && config.initialVersion === "0.2.0", + config.schemaVersion === 1 && config.releaseLine === "0.3" && + config.contractVersion === 3 && config.initialVersion === "0.3.0", "CONFIG_IDENTITY", - "the live release line must be Concierge 0.2 with contract v2", + "the live release line must be Concierge 0.3 with contract v3", ); assert( config.distTag === "latest" && diff --git a/scripts/release/package.mjs b/scripts/release/package.mjs index 542b227..ad1988e 100644 --- a/scripts/release/package.mjs +++ b/scripts/release/package.mjs @@ -66,7 +66,7 @@ function enumeratePacked(config, directory, version) { assert( files.length === config.packages.length, "PACK_COUNT", - `packing produced ${files.length} archives instead of four`, + `packing produced ${files.length} archives instead of ${config.packages.length}`, ); return config.packages.map((spec) => { const expected = expectedArchiveFilename(spec.name, version); diff --git a/scripts/release/version.mjs b/scripts/release/version.mjs index b994255..5b3a708 100644 --- a/scripts/release/version.mjs +++ b/scripts/release/version.mjs @@ -122,18 +122,18 @@ function selfTest() { "canonical peer failed", ); const transition = analyzeSourceCorePeer( - "workspace:^0.1.0 || ^0.2.0", - "0.1.0", + "workspace:^0.2.1 || ^0.3.0", + "0.2.1", config.releaseLine, ); assert( - transition.canonical === false && transition.target === "0.2.0", + transition.canonical === false && transition.target === "0.3.0", "SELF_TEST", "bounded peer transition failed", ); let rejected = false; try { - analyzeSourceCorePeer("workspace:>=0.0.0", "0.1.0", config.releaseLine); + analyzeSourceCorePeer("workspace:>=0.0.0", "0.2.1", config.releaseLine); } catch (error) { rejected = String(error).includes("[VERSION_PEER]"); } From 9978f0db14a47196bc25ff4e5de9d54e1884b34a Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Tue, 25 Aug 2026 23:13:09 -0500 Subject: [PATCH 2/5] fix: address 0.3 review findings --- .github/workflows/ci.yml | 1 + packages/concierge/src/concierge.ts | 34 +++++---- packages/concierge/src/dispatch.ts | 2 +- .../concierge/test/action-bridges.test.ts | 74 ++++++++++++++++++- .../concierge/test/structured-results.test.ts | 28 +++++++ packages/concierge/tsconfig.test-d.json | 2 +- 6 files changed, 122 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15b028d..90d8fd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,5 +135,6 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm build - run: pnpm --filter @full-self-browsing/concierge-example-next-ai-sdk exec playwright install --with-deps chromium - run: pnpm --filter @full-self-browsing/concierge-example-next-ai-sdk exec playwright test --project=chromium diff --git a/packages/concierge/src/concierge.ts b/packages/concierge/src/concierge.ts index 8e29fc5..ce01fb2 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -404,6 +404,8 @@ interface ConsentGenerationBase { readonly responseId: string; readonly sessionId: string | null; readonly snapshot: Readonly>; + readonly snapshotBridgeId: string; + readonly snapshotBridgeRegistry: BridgeRegistry | undefined; readonly userTurnId: string; readonly verifiedReadback: VerifiedReadbackEvidence | null; } @@ -1057,13 +1059,10 @@ function effectiveBridgeRegistry( return action.bridge ?? stage?.bridge; } -/** Read the effective registry exactly once for one dispatch occurrence. */ -function resolveActionBridge( - action: AnyActionDefinition, - stage: ConciergeConfig["stages"][number] | undefined, +/** Read one already-selected registry, containing consumer failures as null. */ +function resolveBridgeRegistry( + registry: BridgeRegistry | undefined, ): Bridge | null { - const registry: BridgeRegistry | undefined = - effectiveBridgeRegistry(action, stage); if (registry === undefined) return null; try { return registry.read() ?? null; @@ -1366,14 +1365,9 @@ export function createConcierge(config: ConciergeConfig): Concierge { /** Detach one already-resolved bridge without reading its registry again. */ function captureResolvedSnapshot( - index: number | null, - action: AnyActionDefinition, + bridgeId: string, bridge: Bridge | null, ): Readonly> { - const stage: ConciergeConfig["stages"][number] | undefined = - index === null ? undefined : stages[index]; - const bridgeId: string = - effectiveBridgeRegistry(action, stage)?.id ?? stage?.id ?? "cross-stage"; return Object.freeze( captureSnapshot( bridge as Bridge, @@ -2504,12 +2498,16 @@ export function createConcierge(config: ConciergeConfig): Concierge { const stage: ConciergeConfig["stages"][number] | undefined = index === null ? undefined : stages[index]; - const bridge: Bridge | null = resolveActionBridge(entry.action, stage); + const bridgeRegistry: BridgeRegistry | undefined = + effectiveBridgeRegistry(entry.action, stage); + const bridge: Bridge | null = resolveBridgeRegistry(bridgeRegistry); let reviewingGeneration: | (ConsentGenerationBase & { readonly status: "reviewing" }) | null = null; if (replacesReviewAuthority) { + const snapshotBridgeId: string = + bridgeRegistry?.id ?? stage?.id ?? "cross-stage"; nextConsentGeneration += 1n; reviewingGeneration = Object.freeze({ confirmationUserTurnId: null, @@ -2519,7 +2517,9 @@ export function createConcierge(config: ConciergeConfig): Concierge { readbackHash: null, responseId: meta.responseId ?? "", sessionId: consentSessionId, - snapshot: captureResolvedSnapshot(index, entry.action, bridge), + snapshot: captureResolvedSnapshot(snapshotBridgeId, bridge), + snapshotBridgeId, + snapshotBridgeRegistry: bridgeRegistry, status: "reviewing", userTurnId: meta.userTurnId ?? "", verifiedReadback: null, @@ -2719,8 +2719,12 @@ export function createConcierge(config: ConciergeConfig): Concierge { let snapshotsMatch: boolean = false; try { + const snapshotBridge: Bridge | null = + owned.snapshotBridgeRegistry === bridgeRegistry + ? bridge + : resolveBridgeRegistry(owned.snapshotBridgeRegistry); const currentSnapshot: Readonly> = - captureResolvedSnapshot(index, entry.action, bridge); + captureResolvedSnapshot(owned.snapshotBridgeId, snapshotBridge); const comparator: ConsentPolicy["snapshotEquality"] = policy.snapshotEquality; snapshotsMatch = comparator === undefined diff --git a/packages/concierge/src/dispatch.ts b/packages/concierge/src/dispatch.ts index 70d8229..f8fc847 100644 --- a/packages/concierge/src/dispatch.ts +++ b/packages/concierge/src/dispatch.ts @@ -919,7 +919,7 @@ export async function normalizeActionResult( } let data: ActionData | undefined; - if (dataProperty.present) { + if (dataProperty.present && dataProperty.value !== undefined) { const normalizedData: ActionDataSnapshot = await validateResultData( options.entry, dataProperty.value, diff --git a/packages/concierge/test/action-bridges.test.ts b/packages/concierge/test/action-bridges.test.ts index 21b5b33..bf0f639 100644 --- a/packages/concierge/test/action-bridges.test.ts +++ b/packages/concierge/test/action-bridges.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createBridge, createConcierge } from "../dist/index.js"; +import type { DeliveryReport } from "../dist/index.js"; const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); const CONTEXT = Object.freeze({ page: "active" }); @@ -44,14 +45,19 @@ function bridge(marker: string) { }; } -function request(catalog: { revision: symbol }, name: string, callId = name) { +function request( + catalog: { revision: symbol }, + name: string, + callId = name, + responseId = "response-1", +) { return { name, input: {}, catalogRevision: catalog.revision, identity: { sessionId: "session-1", - responseId: "response-1", + responseId, callId, userTurnId: "turn-1", outputIndex: 0, @@ -163,4 +169,68 @@ describe("action-scoped bridge resolution", () => { expect(reads).toBe(1); expect(handlerBridge).toBe(first); }); + + it("compares consent against the review bridge when the gated action uses the stage bridge", async () => { + const stageRegistry = createBridge("stage"); + const reviewRegistry = createBridge("review"); + const reviewedState = { selected: "hotel-1" }; + stageRegistry.register({ + marker: "stage", + actions: {}, + snapshot: { cart: () => ({ total: 125 }) }, + }); + reviewRegistry.register({ + marker: "review", + actions: {}, + snapshot: { results: () => reviewedState }, + }); + + const review = action("review", ({ bridge: live }) => ({ + ok: true, + message: live?.marker ?? "missing", + }), { bridge: reviewRegistry }); + const confirm = action("confirm", ({ bridge: live }) => ({ + ok: true, + message: live?.marker ?? "missing", + }), { + consent: { requires: "review", bindTo: "response" }, + effects: { readOnly: false, destructive: true, idempotent: false }, + }); + const concierge = createConcierge({ + stages: [{ + id: "active", + match: () => true, + bridge: stageRegistry, + actions: [review, confirm], + }], + consentProfile: { + consentGrade: "relayed", + userTurnIdentity: "human-attested", + }, + }); + const catalog = concierge.resolveCatalog(CONTEXT); + + async function armReview(callId: string, responseId: string) { + let observeDelivery: ((report: DeliveryReport) => void) | undefined; + await concierge.dispatch(CONTEXT, { + ...request(catalog, "review", callId, responseId), + deferUntilDelivered(effect) { + observeDelivery = effect; + }, + }); + expect(observeDelivery).toBeTypeOf("function"); + observeDelivery?.({ responseId, outcome: "completed" }); + } + + await armReview("review-one", "review-response-one"); + await expect(concierge.dispatch(CONTEXT, { + ...request(catalog, "confirm", "confirm-one", "confirm-response-one"), + })).resolves.toEqual({ ok: true, message: "stage" }); + + await armReview("review-two", "review-response-two"); + reviewedState.selected = "hotel-2"; + await expect(concierge.dispatch(CONTEXT, { + ...request(catalog, "confirm", "confirm-two", "confirm-response-two"), + })).resolves.toMatchObject({ ok: false, reason: "consent_stale" }); + }); }); diff --git a/packages/concierge/test/structured-results.test.ts b/packages/concierge/test/structured-results.test.ts index 630014a..d03232e 100644 --- a/packages/concierge/test/structured-results.test.ts +++ b/packages/concierge/test/structured-results.test.ts @@ -168,6 +168,34 @@ describe("declared structured action results", () => { ]); }); + it("treats explicit undefined data as absent", async () => { + const concierge = conciergeFor([ + richAction( + "successWithoutData", + () => ({ ok: true, message: "Nothing to return.", data: undefined }), + ), + richAction( + "failureWithoutData", + () => ({ + ok: false, + reason: "precondition_failed", + message: "Nothing was available.", + data: undefined, + }), + ), + ]); + + await expect(dispatch(concierge, "successWithoutData")).resolves.toEqual({ + ok: true, + message: "Nothing to return.", + }); + await expect(dispatch(concierge, "failureWithoutData")).resolves.toEqual({ + ok: false, + reason: "precondition_failed", + message: "Nothing was available.", + }); + }); + it("detaches and recursively freezes data while preserving dedupe Promise identity", async () => { const original = { nested: { values: ["first"] } }; let handlerCalls = 0; diff --git a/packages/concierge/tsconfig.test-d.json b/packages/concierge/tsconfig.test-d.json index c6e0ea9..6a1e975 100644 --- a/packages/concierge/tsconfig.test-d.json +++ b/packages/concierge/tsconfig.test-d.json @@ -9,6 +9,6 @@ // by `noEmit`; omitting this override produces TS6059 naming the first test-d file. "rootDir": "." }, - "include": ["src/*.ts", "test-d/**/*.ts"], + "include": ["src/*.ts", "src/openai-realtime/**/*.ts", "test-d/**/*.ts"], "exclude": ["test-d/ai-sdk.test-d.ts"] } From f3012b4da068e452fd391f67c72c9ccd42b42d54 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Tue, 25 Aug 2026 23:46:26 -0500 Subject: [PATCH 3/5] fix: resolve follow-up 0.3 review findings --- HANDOFF.md | 29 +++-- examples/next-ai-sdk/README.md | 2 +- packages/concierge/src/concierge.ts | 60 ++++++--- packages/concierge/src/dispatch.ts | 1 + packages/concierge/src/session.ts | 2 +- .../concierge/test/action-bridges.test.ts | 117 +++++++++++++++++- .../concierge/test/structured-results.test.ts | 26 +++- 7 files changed, 202 insertions(+), 35 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index a97d392..e1bce8c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,23 +2,26 @@ ## Current state -Concierge 0.2 is a supported-public-preview implementation built around runtime -contract v2. The repository contains: +Concierge 0.3 is a supported-public-preview implementation built around runtime +contract v3. The repository contains: - a framework-neutral action catalog, atomic catalog revisions, direct and batch dispatch, consent, deduplication, cancellation, terminal control, - compound workflows, lifecycle observation, bridges, and sessions; + compound workflows, lifecycle observation, structured results, + action-scoped and stage-scoped bridges, and sessions; - React and Svelte lifecycle adapters that inject the same core instance and do no server-side registration; - an AI SDK 6/7 adapter with provider-neutral tools, strict call preparation, result correlation, and an ES256 signed server-to-browser dispatch bridge; +- an app-owned OpenAI Realtime protocol codec for acknowledged catalogs, + 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 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.2.x` version: +The public package set is one fixed trio at a shared `0.3.x` version: 1. `@full-self-browsing/concierge` 2. `@full-self-browsing/concierge-react` @@ -33,7 +36,7 @@ the exact ceremony is in [RELEASING.md](./RELEASING.md). 1. [README.md](./README.md) — public product and security promise. 2. [COMPATIBILITY.md](./COMPATIBILITY.md) and [SUPPORT.md](./SUPPORT.md) — the - 0.2 support contract. + 0.3 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) @@ -45,9 +48,9 @@ the exact ceremony is in [RELEASING.md](./RELEASING.md). 7. [CONTRIBUTING.md](./CONTRIBUTING.md) — invariants and test expectations. 8. [RELEASING.md](./RELEASING.md) — registry setup and protected release flow. -Use `.planning/` when investigating how v0.1 decisions and evidence were -derived. Its Phase 09 scripts and receipts are historical reproduction inputs, -not the live 0.2 release authority. +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. ## Locked boundaries @@ -68,7 +71,7 @@ not the live 0.2 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 v2 throughout `0.2.x`. +- All packages remain ESM-only and contract v3 throughout `0.3.x`. ## Signed bridge invariants @@ -86,7 +89,7 @@ application-supplied stronger store. ## Live release authority -- `.release/lines/0.2.json` — strict package set, contract, destination, +- `.release/lines/0.3.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. @@ -128,9 +131,9 @@ three trusted-publisher records name `release.yml` plus `npm-production`. ## Known limitations -- 0.2 is public preview, not a commercial-SLA release. +- 0.3 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.2 Next matrix. +- Edge runtime is not in the 0.3 Next matrix. - Live model-provider calls are intentionally outside release authorization. -- Only the latest 0.2 patch is maintained under [SUPPORT.md](./SUPPORT.md). +- Only the latest 0.3 patch is maintained under [SUPPORT.md](./SUPPORT.md). diff --git a/examples/next-ai-sdk/README.md b/examples/next-ai-sdk/README.md index e4e1e3c..11df370 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-v2 migration +This Next 16 App Router application is the complete contract-v3 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/src/concierge.ts b/packages/concierge/src/concierge.ts index ce01fb2..25c0c99 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -410,8 +410,15 @@ interface ConsentGenerationBase { readonly verifiedReadback: VerifiedReadbackEvidence | null; } +interface ConsentReviewClaim { + readonly generation: bigint; + readonly responseId: string; + readonly sessionId: string | null; + readonly status: "reviewing"; +} + type ConsentGeneration = - | (ConsentGenerationBase & { readonly status: "reviewing" }) + | ConsentReviewClaim | (ConsentGenerationBase & { readonly status: "pendingDelivery" }) | (ConsentGenerationBase & { readonly status: "verifyingDelivery" }) | (ConsentGenerationBase & { @@ -2500,39 +2507,28 @@ export function createConcierge(config: ConciergeConfig): Concierge { index === null ? undefined : stages[index]; const bridgeRegistry: BridgeRegistry | undefined = effectiveBridgeRegistry(entry.action, stage); - const bridge: Bridge | null = resolveBridgeRegistry(bridgeRegistry); + let reviewingClaim: ConsentReviewClaim | null = null; let reviewingGeneration: | (ConsentGenerationBase & { readonly status: "reviewing" }) | null = null; if (replacesReviewAuthority) { - const snapshotBridgeId: string = - bridgeRegistry?.id ?? stage?.id ?? "cross-stage"; nextConsentGeneration += 1n; - reviewingGeneration = Object.freeze({ - confirmationUserTurnId: null, + reviewingClaim = Object.freeze({ generation: nextConsentGeneration, - payload: validatedSnapshot.value, - preparedReadback, - readbackHash: null, responseId: meta.responseId ?? "", sessionId: consentSessionId, - snapshot: captureResolvedSnapshot(snapshotBridgeId, bridge), - snapshotBridgeId, - snapshotBridgeRegistry: bridgeRegistry, status: "reviewing", - userTurnId: meta.userTurnId ?? "", - verifiedReadback: null, }); consentGenerations ??= new Map(); - consentGenerations.set(actionConsentSlotKey, reviewingGeneration); + consentGenerations.set(actionConsentSlotKey, reviewingClaim); } const closeOwnedReview = (): void => { - if (reviewingGeneration !== null) { + if (reviewingClaim !== null) { closeConsentGeneration( actionConsentSlotKey, - reviewingGeneration.generation, + reviewingClaim.generation, ); } }; @@ -2623,6 +2619,36 @@ export function createConcierge(config: ConciergeConfig): Concierge { ); } + const bridge: Bridge | null = resolveBridgeRegistry(bridgeRegistry); + if (reviewingClaim !== null) { + const currentReview: ConsentGeneration | undefined = + consentGenerations?.get(actionConsentSlotKey); + if ( + currentReview?.generation === reviewingClaim.generation && + currentReview.status === "reviewing" && + currentReview.responseId === reviewingClaim.responseId + ) { + const snapshotBridgeId: string = + bridgeRegistry?.id ?? stage?.id ?? "cross-stage"; + reviewingGeneration = Object.freeze({ + confirmationUserTurnId: null, + generation: reviewingClaim.generation, + payload: validatedSnapshot.value, + preparedReadback, + readbackHash: null, + responseId: reviewingClaim.responseId, + sessionId: consentSessionId, + snapshot: captureResolvedSnapshot(snapshotBridgeId, bridge), + snapshotBridgeId, + snapshotBridgeRegistry: bridgeRegistry, + status: "reviewing", + userTurnId: meta.userTurnId ?? "", + verifiedReadback: null, + }); + consentGenerations?.set(actionConsentSlotKey, reviewingGeneration); + } + } + if (isAborted(signal)) { closeOwnedReview(); return authoredResult( diff --git a/packages/concierge/src/dispatch.ts b/packages/concierge/src/dispatch.ts index f8fc847..6c9cceb 100644 --- a/packages/concierge/src/dispatch.ts +++ b/packages/concierge/src/dispatch.ts @@ -351,6 +351,7 @@ function cloneActionData( ) { throw new TypeError("Action data objects must use enumerable data properties."); } + if (descriptor.value === undefined) continue; if (entryIndex > 0) addJsonBytes(budget, 1); addQuotedJsonString(budget, key); addJsonBytes(budget, 1); diff --git a/packages/concierge/src/session.ts b/packages/concierge/src/session.ts index 0e69196..f2a233f 100644 --- a/packages/concierge/src/session.ts +++ b/packages/concierge/src/session.ts @@ -378,7 +378,7 @@ function linkSignals( }); } -/** Build the contract-v2 session runtime. */ +/** Build the contract-v3 session runtime. */ function createV2Session( config: SessionConfig, concierge: SessionConfig["concierge"], diff --git a/packages/concierge/test/action-bridges.test.ts b/packages/concierge/test/action-bridges.test.ts index bf0f639..c5e6db8 100644 --- a/packages/concierge/test/action-bridges.test.ts +++ b/packages/concierge/test/action-bridges.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createBridge, createConcierge } from "../dist/index.js"; +import { createBridge, createConcierge, offPageResult } from "../dist/index.js"; import type { DeliveryReport } from "../dist/index.js"; const CONTRACT_KEY = Symbol.for("@fullselfbrowsing/concierge.contract"); @@ -65,6 +65,10 @@ function request( }; } +async function flushMicrotasks() { + for (let index = 0; index < 8; index += 1) await Promise.resolve(); +} + describe("action-scoped bridge resolution", () => { it("supports heterogeneous action bridges with action precedence and stage fallback", async () => { const stageRegistry = createBridge("stage"); @@ -132,6 +136,53 @@ describe("action-scoped bridge resolution", () => { ]); }); + it("resolves the effective bridge after the commit window", async () => { + const stageRegistry = createBridge("stage"); + const unregister = stageRegistry.register(bridge("mounted")); + const scheduled: Array<() => void> = []; + let handlerBridge: unknown = "not-called"; + const destructive = action("destructive", ({ bridge: live }) => { + handlerBridge = live; + return live === null + ? offPageResult("The selected result", "results page") + : { ok: true, message: live.marker }; + }, { + effects: { readOnly: false, destructive: true, idempotent: false }, + }); + const concierge = createConcierge({ + stages: [{ + id: "active", + match: () => true, + bridge: stageRegistry, + actions: [destructive], + }], + commitWindowMs: 25, + scheduler(callback) { + scheduled.push(callback); + return () => undefined; + }, + }); + const catalog = concierge.resolveCatalog(CONTEXT); + + const result = concierge.dispatch( + CONTEXT, + request(catalog, "destructive"), + ); + await flushMicrotasks(); + expect(scheduled).toHaveLength(1); + + unregister(); + expect(concierge.explain(CONTEXT).actions).toEqual([ + { name: "destructive", bridge: { id: "stage", registered: false } }, + ]); + scheduled.shift()?.(); + + await expect(result).resolves.toEqual( + offPageResult("The selected result", "results page"), + ); + expect(handlerBridge).toBe(null); + }); + it("reads one effective bridge once and reuses it for consent capture and the handler", async () => { const first = bridge("first"); const second = bridge("second"); @@ -233,4 +284,68 @@ describe("action-scoped bridge resolution", () => { ...request(catalog, "confirm", "confirm-two", "confirm-response-two"), })).resolves.toMatchObject({ ok: false, reason: "consent_stale" }); }); + + it("refreshes the consent snapshot after a gated action's commit window", async () => { + const stageRegistry = createBridge("stage"); + const unregister = stageRegistry.register({ + marker: "stage", + actions: {}, + snapshot: { selection: () => ({ id: "hotel-1" }) }, + }); + const scheduled: Array<() => void> = []; + let confirmCalls = 0; + const review = action("review", () => ({ + ok: true, + message: "Reviewed.", + })); + const confirm = action("confirm", () => { + confirmCalls += 1; + return { ok: true, message: "Confirmed." }; + }, { + consent: { requires: "review", bindTo: "response" }, + effects: { readOnly: false, destructive: true, idempotent: false }, + }); + const concierge = createConcierge({ + stages: [{ + id: "active", + match: () => true, + bridge: stageRegistry, + actions: [review, confirm], + }], + commitWindowMs: 25, + scheduler(callback) { + scheduled.push(callback); + return () => undefined; + }, + consentProfile: { + consentGrade: "relayed", + userTurnIdentity: "human-attested", + }, + }); + const catalog = concierge.resolveCatalog(CONTEXT); + let observeDelivery: ((report: DeliveryReport) => void) | undefined; + await concierge.dispatch(CONTEXT, { + ...request(catalog, "review", "review", "review-response"), + deferUntilDelivered(effect) { + observeDelivery = effect; + }, + }); + observeDelivery?.({ responseId: "review-response", outcome: "completed" }); + + const result = concierge.dispatch( + CONTEXT, + request(catalog, "confirm", "confirm", "confirm-response"), + ); + await flushMicrotasks(); + expect(scheduled).toHaveLength(1); + + unregister(); + scheduled.shift()?.(); + + await expect(result).resolves.toMatchObject({ + ok: false, + reason: "consent_stale", + }); + expect(confirmCalls).toBe(0); + }); }); diff --git a/packages/concierge/test/structured-results.test.ts b/packages/concierge/test/structured-results.test.ts index d03232e..54f9178 100644 --- a/packages/concierge/test/structured-results.test.ts +++ b/packages/concierge/test/structured-results.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; import { createConcierge } from "../dist/index.js"; @@ -168,7 +169,7 @@ describe("declared structured action results", () => { ]); }); - it("treats explicit undefined data as absent", async () => { + it("treats explicit undefined data as absent and omits undefined object fields", async () => { const concierge = conciergeFor([ richAction( "successWithoutData", @@ -183,6 +184,23 @@ describe("declared structured action results", () => { data: undefined, }), ), + action( + "optionalField", + () => ({ + ok: true, + message: "Optional field omitted.", + data: { id: "x", note: undefined }, + }), + { + output: { + schema: z.object({ + id: z.string(), + note: z.string().optional(), + }), + redact: "drop", + }, + }, + ), ]); await expect(dispatch(concierge, "successWithoutData")).resolves.toEqual({ @@ -194,6 +212,11 @@ describe("declared structured action results", () => { reason: "precondition_failed", message: "Nothing was available.", }); + await expect(dispatch(concierge, "optionalField")).resolves.toEqual({ + ok: true, + message: "Optional field omitted.", + data: { id: "x" }, + }); }); it("detaches and recursively freezes data while preserving dedupe Promise identity", async () => { @@ -260,7 +283,6 @@ describe("declared structured action results", () => { new Map(), new Set(), sparse, - { value: undefined }, { value: 1n }, { value: Symbol("secret") }, { value: () => "secret" }, From f1734c1b3e3bee9347c443e76216b34f9ab86d6c Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Thu, 27 Aug 2026 06:19:32 -0500 Subject: [PATCH 4/5] fix: align structured result limits and diagnostics --- docs/integrations/structured-results.md | 7 +- packages/concierge/src/concierge.ts | 6 + packages/concierge/src/dispatch.ts | 16 +- .../concierge/test/structured-results.test.ts | 170 ++++++++++++++---- 4 files changed, 161 insertions(+), 38 deletions(-) diff --git a/docs/integrations/structured-results.md b/docs/integrations/structured-results.md index b93e6dc..9e7775c 100644 --- a/docs/integrations/structured-results.md +++ b/docs/integrations/structured-results.md @@ -60,8 +60,11 @@ After the handler settles, Concierge: 6. enforces `ConciergeConfig.maxActionDataBytes`. The default limit is `DEFAULT_ACTION_DATA_MAX_BYTES`, currently 262,144 UTF-8 -JSON bytes. An output that fails any step becomes a bounded -`invalid_result`; rejected data is never copied into diagnostics. +JSON bytes. Output arrays do not inherit the separate tool-argument entry +limit; their accepted size is governed by this byte budget. An output that +fails any step becomes a bounded `invalid_result`. Concierge emits one generic +host warning per affected action, but rejected data, validator issues, and +caught exceptions are never copied into diagnostics. `data` may accompany success or failure. Use `precondition_failed` when the call is structurally valid but current application state blocks it, and keep diff --git a/packages/concierge/src/concierge.ts b/packages/concierge/src/concierge.ts index 25c0c99..552098f 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -2890,6 +2890,12 @@ export function createConcierge(config: ConciergeConfig): Concierge { const normalizedResult: ActionResult = await normalizeActionResult(handlerResult, { entry, + invalidData: (): void => { + warnDispatchOnce( + `result-data-invalid:${name}`, + `concierge: [invalid_result] action ${encodeDiagnosticSubject(name)}: its handler returned structured data that was undeclared, rejected by its output schema, or not safe bounded JSON data, so the whole result was rejected. Fix: declare a matching \`output.schema\` and return acyclic plain JSON data without aliases or accessors within \`maxActionDataBytes\`.`, + ); + }, maximumDataBytes: maxActionDataBytes, successReason: (): void => { warnDispatchOnce( diff --git a/packages/concierge/src/dispatch.ts b/packages/concierge/src/dispatch.ts index 6c9cceb..71e9710 100644 --- a/packages/concierge/src/dispatch.ts +++ b/packages/concierge/src/dispatch.ts @@ -224,6 +224,14 @@ function addJsonBytes(budget: JsonSizeBudget, count: number): void { } } +/** Bound dense output arrays by the JSON bytes still available to encode them. */ +function maximumActionDataArrayLength(budget: JsonSizeBudget): number { + const remaining: number = budget.maximum - budget.used; + // A non-empty dense array needs one byte per value, one per separator, and + // two brackets: `2 * length + 1`. Empty arrays are checked by addJsonBytes. + return remaining < 3 ? 0 : Math.floor((remaining - 1) / 2); +} + /** Count UTF-8 bytes without depending on DOM's TextEncoder. */ function utf8ByteLength(value: string): number { let bytes: number = 0; @@ -292,7 +300,7 @@ function cloneActionData( if (Array.isArray(value)) { const lengthSnapshot: ArrayLengthSnapshot = snapshotArrayLength( value, - MAX_INVOCATION_ARRAY_LENGTH, + maximumActionDataArrayLength(budget), ); if (!lengthSnapshot.ok) { throw new TypeError("Action data arrays exceed the supported bound."); @@ -754,6 +762,7 @@ export function waitForCommit( /** Warn-once callbacks owned by one Concierge instance. */ export interface ResultWarnings { + readonly invalidData: () => void; readonly successReason: () => void; readonly reasonlessFailure: () => void; } @@ -926,7 +935,10 @@ export async function normalizeActionResult( dataProperty.value, options.maximumDataBytes, ); - if (!normalizedData.ok) return invalidResult(); + if (!normalizedData.ok) { + notify(options.invalidData); + return invalidResult(); + } data = normalizedData.value; } diff --git a/packages/concierge/test/structured-results.test.ts b/packages/concierge/test/structured-results.test.ts index 54f9178..48eb857 100644 --- a/packages/concierge/test/structured-results.test.ts +++ b/packages/concierge/test/structured-results.test.ts @@ -1,3 +1,5 @@ +import { Buffer } from "node:buffer"; + import { beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; @@ -251,9 +253,46 @@ describe("declared structured action results", () => { expect(handlerCalls).toBe(1); }); - it("fails closed for undeclared, rejected, unsafe, aliased, and oversized data", async () => { - const shared = { value: "shared" }; - const cycle: Record = {}; + it("governs arrays above 10,000 entries only by their exact JSON byte size", async () => { + const data = { ids: Array.from({ length: 10_001 }, (_, index) => index) }; + const exactBytes = Buffer.byteLength(JSON.stringify(data), "utf8"); + const accepted = conciergeFor([ + richAction("acceptedLargeArray", () => ({ + ok: true, + message: "Large array accepted.", + data, + })), + ], { maxActionDataBytes: exactBytes }); + const rejected = conciergeFor([ + richAction("rejectedLargeArray", () => ({ + ok: true, + message: "Large array rejected.", + data, + })), + ], { maxActionDataBytes: exactBytes - 1 }); + + const acceptedResult = await dispatch(accepted, "acceptedLargeArray"); + expect(acceptedResult).toMatchObject({ + ok: true, + data: { ids: expect.any(Array) }, + }); + const ids = (acceptedResult.data as { ids: number[] }).ids; + expect(ids).toHaveLength(10_001); + expect([ids[0], ids.at(-1)]).toEqual([0, 10_000]); + expect(Object.isFrozen(ids)).toBe(true); + + await expect(dispatch(rejected, "rejectedLargeArray")).resolves.toEqual({ + ok: false, + reason: "invalid_result", + message: "The action returned an invalid result.", + }); + }); + + it("fails closed and warns once without exposing rejected structured data", async () => { + const shared = { value: "RAW-ALIAS-SECRET" }; + const cycle: Record = { + secret: "RAW-CYCLE-SECRET", + }; cycle.self = cycle; const accessor: Record = {}; let accessorReads = 0; @@ -289,46 +328,109 @@ describe("declared structured action results", () => { { value: Number.NaN }, { value: Number.POSITIVE_INFINITY }, ]; + const warningActions: string[] = []; + const realConsole = globalThis.console; + const warnings: string[] = []; + globalThis.console = { + ...realConsole, + warn: (message) => warnings.push(String(message)), + }; - for (const [index, data] of invalidValues.entries()) { - const concierge = conciergeFor([ - richAction(`invalid${index}`, () => ({ ok: true, message: "Unsafe.", data })), - ]); - await expect(dispatch(concierge, `invalid${index}`)).resolves.toEqual({ + async function expectRejectedTwice( + concierge: ReturnType, + name: string, + ) { + warningActions.push(name); + const first = await dispatch(concierge, name, {}, `${name}-first`); + const second = await dispatch(concierge, name, {}, `${name}-second`); + expect(first).toEqual({ ok: false, reason: "invalid_result", message: "The action returned an invalid result.", }); + expect(second).toEqual(first); } - expect(accessorReads).toBe(0); - const rejected = conciergeFor([ - richAction( - "rejected", - () => ({ ok: true, message: "Rejected.", data: { raw: "RAW-SCHEMA-SECRET" } }), - () => ({ issues: [{ message: "do not expose this" }] }), - ), - ]); - await expect(dispatch(rejected, "rejected")).resolves.toMatchObject({ - ok: false, - reason: "invalid_result", - }); + try { + for (const [index, invalidData] of invalidValues.entries()) { + const name = `invalid${index}`; + await expectRejectedTwice(conciergeFor([ + richAction(name, () => ({ + ok: true, + message: "Unsafe.", + data: invalidData, + })), + ]), name); + } - const undeclared = conciergeFor([ - action("undeclared", () => ({ ok: true, message: "Extra.", data: { hidden: true } })), - ]); - await expect(dispatch(undeclared, "undeclared")).resolves.toMatchObject({ - ok: false, - reason: "invalid_result", - }); + await expectRejectedTwice(conciergeFor([ + richAction( + "rejected", + () => ({ + ok: true, + message: "Rejected.", + data: { raw: "RAW-SCHEMA-SECRET" }, + }), + () => ({ issues: [{ message: "RAW-SCHEMA-ISSUE-SECRET" }] }), + ), + ]), "rejected"); - const oversized = conciergeFor([ - richAction("oversized", () => ({ ok: true, message: "Large.", data: "0123456789" })), - ], { maxActionDataBytes: 5 }); - await expect(dispatch(oversized, "oversized")).resolves.toMatchObject({ - ok: false, - reason: "invalid_result", - }); + await expectRejectedTwice(conciergeFor([ + richAction( + "schemaThrows", + () => ({ + ok: true, + message: "Rejected.", + data: { raw: "RAW-SCHEMA-THROW-DATA-SECRET" }, + }), + () => { + throw new Error("RAW-SCHEMA-THROW-SECRET"); + }, + ), + ]), "schemaThrows"); + + await expectRejectedTwice(conciergeFor([ + action("undeclared", () => ({ + ok: true, + message: "Extra.", + data: { hidden: "RAW-UNDECLARED-SECRET" }, + })), + ]), "undeclared"); + + await expectRejectedTwice(conciergeFor([ + richAction("oversized", () => ({ + ok: true, + message: "Large.", + data: "RAW-OVERSIZED-SECRET", + })), + ], { maxActionDataBytes: 5 }), "oversized"); + } finally { + globalThis.console = realConsole; + } + + expect(accessorReads).toBe(0); + expect(warnings).toHaveLength(warningActions.length); + for (const name of warningActions) { + expect(warnings.some((warning) => + warning.includes(`action "${name}"`) && + warning.includes("[invalid_result]") && + warning.includes("maxActionDataBytes") + )).toBe(true); + } + const warningText = warnings.join("\n"); + for (const secret of [ + "RAW-ACCESSOR-SECRET", + "RAW-ALIAS-SECRET", + "RAW-CYCLE-SECRET", + "RAW-SCHEMA-SECRET", + "RAW-SCHEMA-ISSUE-SECRET", + "RAW-SCHEMA-THROW-DATA-SECRET", + "RAW-SCHEMA-THROW-SECRET", + "RAW-UNDECLARED-SECRET", + "RAW-OVERSIZED-SECRET", + ]) { + expect(warningText).not.toContain(secret); + } }); it("keeps legacy result shape and isolates observer output through explicit redaction", async () => { From d846dbc97763dc6700959d6a14f6fa807b4ead07 Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Thu, 27 Aug 2026 14:34:20 -0500 Subject: [PATCH 5/5] fix: preserve workflow result contracts --- docs/integrations/structured-results.md | 15 + packages/concierge/src/concierge.ts | 249 +++++++++++--- .../concierge/test/structured-results.test.ts | 304 ++++++++++++++++++ 3 files changed, 525 insertions(+), 43 deletions(-) diff --git a/docs/integrations/structured-results.md b/docs/integrations/structured-results.md index 9e7775c..d7569cf 100644 --- a/docs/integrations/structured-results.md +++ b/docs/integrations/structured-results.md @@ -101,6 +101,21 @@ separate output decision under `event.resultData`. A throwing, unsafe, or oversized projection fails closed to `dropped`. Built-in telemetry and `FailureOutcome` never include structured data. +## Compound-action propagation + +When a workflow latches a child failure or terminal result, every enclosing +action remains an output boundary. Child data is validated and transformed by +each parent's `output.schema`; a parent with no output declaration, or whose +schema rejects the data, settles as `invalid_result` without data. That rejected +data cannot reappear through another enclosing action. + +Observer exposure only narrows as the result propagates. A child `drop` remains +dropped, while a parent may further restrict a child passthrough or projection. +Once a projection has removed fields, later projections receive only that +observer-safe view, never the original child data. Incompatible, throwing, or +unsafe projection composition fails closed to `dropped` without changing the +agent-facing result. + ## Transport behavior `DispatchRow.result`, ordered batches, workflow child results, AI SDK result diff --git a/packages/concierge/src/concierge.ts b/packages/concierge/src/concierge.ts index 552098f..f7110f1 100644 --- a/packages/concierge/src/concierge.ts +++ b/packages/concierge/src/concierge.ts @@ -128,10 +128,10 @@ const NEVER_ABORTED_SIGNAL: AbortSignalLike = /* @__PURE__ */ Object.freeze({ const DROPPED_INPUT: ObservedInput = /* @__PURE__ */ Object.freeze({ kind: "dropped" as const, }); -const ABSENT_RESULT_DATA: ObservedResultData = /* @__PURE__ */ Object.freeze({ +const ABSENT_RESULT_DATA: Readonly<{ kind: "absent" }> = /* @__PURE__ */ Object.freeze({ kind: "absent" as const, }); -const DROPPED_RESULT_DATA: ObservedResultData = /* @__PURE__ */ Object.freeze({ +const DROPPED_RESULT_DATA: Readonly<{ kind: "dropped" }> = /* @__PURE__ */ Object.freeze({ kind: "dropped" as const, }); @@ -323,13 +323,24 @@ interface V2DedupeRecord { settledAt: number | null; } +type WorkflowObservedResultData = + | Readonly<{ kind: "absent" }> + | Readonly<{ kind: "dropped" }> + | Readonly<{ kind: "included"; value: unknown; full: boolean }>; + +interface WorkflowOutcome { + readonly result: ActionResult; + readonly observedData: WorkflowObservedResultData; + readonly validatedEntry: CatalogEntry | null; +} + interface WorkflowRootState { readonly rootDispatchId: string; readonly signal: AbortSignalLike; readonly executionState: DispatchExecutionState; steps: number; - failure: ActionResult | null; - terminalResult: ActionResult | null; + failure: WorkflowOutcome | null; + terminalResult: WorkflowOutcome | null; terminalRef: DispatchRef | null; } @@ -358,6 +369,8 @@ interface WorkflowRuntime { interface PipelineObservation { accepted: boolean; input: ObservedInput; + resultSource: unknown; + resultSourcePresent: boolean; } type InvocationMetaSnapshot = @@ -1832,10 +1845,29 @@ export function createConcierge(config: ConciergeConfig): Concierge { ); } - function observedResultDataFor( + function snapshotObservedResultData( + entry: CatalogEntry, + exposed: unknown, + full: boolean, + ): WorkflowObservedResultData { + const snapshot: ActionDataSnapshot = snapshotActionData( + exposed, + maxActionDataBytes, + ); + if (!snapshot.ok) { + warnDispatchOnce( + `output-redaction-invalid:${entry.action.name}`, + `concierge: [output_redaction_failed] action ${encodeDiagnosticSubject(entry.action.name)}: its observer result projection was not safe bounded JSON data, so observer data was dropped. Fix: return acyclic plain JSON data without aliases or accessors.`, + ); + return DROPPED_RESULT_DATA; + } + return Object.freeze({ kind: "included", value: snapshot.value, full }); + } + + function captureObservedResultData( entry: CatalogEntry | null, result: ActionResult, - ): ObservedResultData { + ): WorkflowObservedResultData { if (result.data === undefined) return ABSENT_RESULT_DATA; if (entry === null) return DROPPED_RESULT_DATA; const output: AnyActionDefinition["output"] = entry.action.output; @@ -1856,18 +1888,139 @@ export function createConcierge(config: ConciergeConfig): Concierge { } } - const snapshot: ActionDataSnapshot = snapshotActionData( + return snapshotObservedResultData( + entry, exposed, - maxActionDataBytes, + output.redact === "passthrough", ); - if (!snapshot.ok) { + } + + function narrowObservedResultData( + entry: CatalogEntry, + result: ActionResult, + inherited: WorkflowObservedResultData, + ): WorkflowObservedResultData { + if (result.data === undefined) return ABSENT_RESULT_DATA; + if (inherited.kind !== "included") return inherited; + + const output: AnyActionDefinition["output"] = entry.action.output; + if (output === undefined || output.redact === "drop") { + return DROPPED_RESULT_DATA; + } + if (output.redact === "passthrough") { + return inherited.full + ? snapshotObservedResultData(entry, result.data, true) + : inherited; + } + + let exposed: unknown; + try { + exposed = output.redact( + inherited.full ? result.data : inherited.value, + ); + } catch { warnDispatchOnce( - `output-redaction-invalid:${entry.action.name}`, - `concierge: [output_redaction_failed] action ${encodeDiagnosticSubject(entry.action.name)}: its observer result projection was not safe bounded JSON data, so observer data was dropped. Fix: return acyclic plain JSON data without aliases or accessors.`, + `output-redaction-threw:${entry.action.name}`, + `concierge: [output_redaction_failed] action ${encodeDiagnosticSubject(entry.action.name)}: its result projection threw, so observer data was dropped. Fix: make the projection total and return JSON-safe data.`, ); return DROPPED_RESULT_DATA; } - return Object.freeze({ kind: "included", value: snapshot.value }); + return snapshotObservedResultData(entry, exposed, false); + } + + function exposeObservedResultData( + observed: WorkflowObservedResultData, + ): ObservedResultData { + return observed.kind === "included" + ? Object.freeze({ kind: "included", value: observed.value }) + : observed; + } + + function observedResultDataFor( + entry: CatalogEntry | null, + result: ActionResult, + ): ObservedResultData { + return exposeObservedResultData(captureObservedResultData(entry, result)); + } + + function workflowOutcome( + result: ActionResult, + entry: CatalogEntry | null, + ): WorkflowOutcome { + return Object.freeze({ + result, + observedData: captureObservedResultData(entry, result), + validatedEntry: entry, + }); + } + + function normalizeResultForEntry( + entry: CatalogEntry, + name: string, + value: unknown, + ): Promise { + return normalizeActionResult(value, { + entry, + invalidData: (): void => { + warnDispatchOnce( + `result-data-invalid:${name}`, + `concierge: [invalid_result] action ${encodeDiagnosticSubject(name)}: its result carried structured data that was undeclared, rejected by its output schema, or not safe bounded JSON data, so the whole result was rejected. Fix: declare a matching \`output.schema\` and return acyclic plain JSON data without aliases or accessors within \`maxActionDataBytes\`.`, + ); + }, + maximumDataBytes: maxActionDataBytes, + successReason: (): void => { + warnDispatchOnce( + `success-reason:${name}`, + `concierge: [invalid_result] action ${encodeDiagnosticSubject(name)}: its handler returned a success carrying a failure reason, so the reason was removed. Fix: omit \`reason\` when \`ok\` is true.`, + ); + }, + reasonlessFailure: (): void => { + warnDispatchOnce( + `reasonless-failure:${name}`, + `concierge: [invalid_result] action ${encodeDiagnosticSubject(name)}: its handler returned a failure without a reason, so the result carries no machine-readable cause. Fix: return one of the declared \`ReasonCode\` values when \`ok\` is false.`, + ); + }, + }); + } + + async function adaptWorkflowOutcome( + outcome: WorkflowOutcome, + entry: CatalogEntry, + name: string, + normalizedResult: ActionResult, + observation: PipelineObservation, + ): Promise { + let result: ActionResult = outcome.result; + if ( + observation.resultSourcePresent && + observation.resultSource === outcome.result + ) { + result = normalizedResult; + } else if ( + result.data !== undefined && + outcome.validatedEntry !== entry + ) { + result = await normalizeResultForEntry(entry, name, result); + } + + return Object.freeze({ + result, + observedData: narrowObservedResultData( + entry, + result, + outcome.observedData, + ), + validatedEntry: entry, + }); + } + + function replaceWorkflowOutcome( + root: WorkflowRootState, + previous: WorkflowOutcome, + next: WorkflowOutcome, + ): void { + if (root.failure === previous) root.failure = next; + if (root.terminalResult === previous) root.terminalResult = next; } function eventTerminalPhase(result: ActionResult): "succeeded" | "failed" | "cancelled" { @@ -1886,8 +2039,11 @@ export function createConcierge(config: ConciergeConfig): Concierge { function latchWorkflowFailure( root: WorkflowRootState, result: ActionResult, + entry: CatalogEntry | null = null, ): void { - if (root.failure === null && !result.ok) root.failure = result; + if (root.failure === null && !result.ok) { + root.failure = workflowOutcome(result, entry); + } } function workflowRuntimeFor(occurrence: V2Occurrence): WorkflowRuntime { @@ -2413,10 +2569,13 @@ export function createConcierge(config: ConciergeConfig): Concierge { } if (cleanupFailed) { if (occurrence.root.failure === null) { - occurrence.root.failure = authoredResult( - false, - "Something went wrong during cleanup.", - "handler_error", + occurrence.root.failure = workflowOutcome( + authoredResult( + false, + "Something went wrong during cleanup.", + "handler_error", + ), + null, ); } else { warnDispatchOnce( @@ -2888,28 +3047,13 @@ export function createConcierge(config: ConciergeConfig): Concierge { } } - const normalizedResult: ActionResult = await normalizeActionResult(handlerResult, { + observation.resultSource = handlerResult; + observation.resultSourcePresent = true; + const normalizedResult: ActionResult = await normalizeResultForEntry( entry, - invalidData: (): void => { - warnDispatchOnce( - `result-data-invalid:${name}`, - `concierge: [invalid_result] action ${encodeDiagnosticSubject(name)}: its handler returned structured data that was undeclared, rejected by its output schema, or not safe bounded JSON data, so the whole result was rejected. Fix: declare a matching \`output.schema\` and return acyclic plain JSON data without aliases or accessors within \`maxActionDataBytes\`.`, - ); - }, - maximumDataBytes: maxActionDataBytes, - successReason: (): void => { - warnDispatchOnce( - `success-reason:${name}`, - `concierge: [invalid_result] action ${encodeDiagnosticSubject(name)}: its handler returned a success carrying a failure reason, so the reason was removed. Fix: omit \`reason\` when \`ok\` is true.`, - ); - }, - reasonlessFailure: (): void => { - warnDispatchOnce( - `reasonless-failure:${name}`, - `concierge: [invalid_result] action ${encodeDiagnosticSubject(name)}: its handler returned a failure without a reason, so the result carries no machine-readable cause. Fix: return one of the declared \`ReasonCode\` values when \`ok\` is false.`, - ); - }, - }); + name, + handlerResult, + ); if (reviewingGeneration === null) { return normalizedResult; @@ -3025,6 +3169,8 @@ export function createConcierge(config: ConciergeConfig): Concierge { const observation: PipelineObservation = { accepted: false, input: DROPPED_INPUT, + resultSource: undefined, + resultSourcePresent: false, }; const workflow: WorkflowRuntime = workflowRuntimeFor(occurrence); let result: ActionResult; @@ -3047,20 +3193,37 @@ export function createConcierge(config: ConciergeConfig): Concierge { workflow.seal(); await workflow.drain(); - latchWorkflowFailure(occurrence.root, result); + latchWorkflowFailure(occurrence.root, result, entry); await workflow.unwind(); if ( occurrence.root.terminalRef?.dispatchId === occurrence.dispatchId && occurrence.root.terminalResult === null ) { - occurrence.root.terminalResult = occurrence.root.failure ?? result; + occurrence.root.terminalResult = occurrence.root.failure ?? + workflowOutcome(result, entry); } + let outcome: WorkflowOutcome; if (occurrence.root.terminalResult !== null) { - result = occurrence.root.terminalResult; + outcome = occurrence.root.terminalResult; } else if (occurrence.root.failure !== null) { - result = occurrence.root.failure; + outcome = occurrence.root.failure; + } else { + outcome = workflowOutcome(result, entry); + } + + if (outcome.result !== result) { + const adapted: WorkflowOutcome = await adaptWorkflowOutcome( + outcome, + entry, + name, + result, + observation, + ); + replaceWorkflowOutcome(occurrence.root, outcome, adapted); + outcome = adapted; } + result = outcome.result; emitDispatch({ dispatchId: occurrence.dispatchId, @@ -3073,7 +3236,7 @@ export function createConcierge(config: ConciergeConfig): Concierge { terminalAction: entry.action.terminal === true, phase: eventTerminalPhase(result), result: observedResultStatus(result), - resultData: observedResultDataFor(entry, result), + resultData: exposeObservedResultData(outcome.observedData), terminalEntered: occurrence.root.terminalRef !== null, }); return result; diff --git a/packages/concierge/test/structured-results.test.ts b/packages/concierge/test/structured-results.test.ts index 48eb857..66d637b 100644 --- a/packages/concierge/test/structured-results.test.ts +++ b/packages/concierge/test/structured-results.test.ts @@ -171,6 +171,310 @@ describe("declared structured action results", () => { ]); }); + it("keeps propagated observer data within child and parent redaction policies", async () => { + const events: Array> = []; + const parentProjectionInputs: unknown[] = []; + const concierge = conciergeFor([ + richAction( + "dropChild", + () => ({ + ok: false, + reason: "precondition_failed", + message: "Child failed.", + data: { safe: "visible", secret: "child-only" }, + }), + ), + richAction( + "dropParentPassthrough", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "child", name: "dropChild", input: {} }); + return { ok: true, message: "Parent complete." }; + }, + undefined, + "passthrough", + ), + richAction( + "passChild", + () => ({ + ok: false, + reason: "precondition_failed", + message: "Child failed.", + data: { safe: "visible", secret: "child-only" }, + }), + undefined, + "passthrough", + ), + richAction( + "passParentDrop", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "child", name: "passChild", input: {} }); + return { ok: true, message: "Parent complete." }; + }, + ), + richAction( + "projectChild", + () => ({ + ok: false, + reason: "precondition_failed", + message: "Child failed.", + data: { safe: "visible", secret: "child-only" }, + }), + undefined, + (data: { safe: string }) => ({ safe: data.safe }), + ), + richAction( + "projectParent", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "child", name: "projectChild", input: {} }); + return { ok: true, message: "Parent complete." }; + }, + undefined, + (data: { safe: string }) => { + parentProjectionInputs.push(data); + return { safe: data.safe, parent: true }; + }, + ), + richAction( + "throwingParent", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "child", name: "projectChild", input: {} }); + return { ok: true, message: "Parent complete." }; + }, + undefined, + () => { + throw new Error("RAW-PARENT-PROJECTION-THROW"); + }, + ), + ]); + concierge.onDispatch((event) => events.push(event as unknown as Record)); + + const realConsole = globalThis.console; + const warnings: string[] = []; + globalThis.console = { ...realConsole, warn: (message) => warnings.push(String(message)) }; + try { + for (const name of [ + "dropParentPassthrough", + "passParentDrop", + "projectParent", + "throwingParent", + ]) { + await expect(dispatch(concierge, name)).resolves.toMatchObject({ + ok: false, + data: { safe: "visible", secret: "child-only" }, + }); + } + await flushEvents(); + } finally { + globalThis.console = realConsole; + } + + const terminal = new Map( + events + .filter((event) => ["succeeded", "failed", "cancelled"].includes(String(event.phase))) + .map((event) => [event.name, event]), + ); + expect(terminal.get("dropChild")?.resultData).toEqual({ kind: "dropped" }); + expect(terminal.get("dropParentPassthrough")?.resultData).toEqual({ kind: "dropped" }); + expect(terminal.get("passChild")?.resultData).toEqual({ + kind: "included", + value: { safe: "visible", secret: "child-only" }, + }); + expect(terminal.get("passParentDrop")?.resultData).toEqual({ kind: "dropped" }); + expect(terminal.get("projectChild")?.resultData).toEqual({ + kind: "included", + value: { safe: "visible" }, + }); + expect(terminal.get("projectParent")?.resultData).toEqual({ + kind: "included", + value: { safe: "visible", parent: true }, + }); + expect(terminal.get("throwingParent")?.resultData).toEqual({ kind: "dropped" }); + expect(parentProjectionInputs).toEqual([{ safe: "visible" }]); + expect(warnings.join("\n")).toContain('action "throwingParent"'); + expect(warnings.join("\n")).not.toContain("RAW-PARENT-PROJECTION-THROW"); + }); + + it("enforces every enclosing output contract without resurrecting child data", async () => { + const events: Array> = []; + let directValidations = 0; + let ignoredValidations = 0; + const transform = (value: { count: string }) => ({ + value: { count: Number(value.count) }, + }); + const concierge = conciergeFor([ + richAction( + "contractChild", + () => ({ + ok: false, + reason: "precondition_failed", + message: "Child failed.", + data: { count: "2", secret: "RAW-CHILD-DATA-SECRET" }, + }), + undefined, + "passthrough", + ), + richAction( + "directParent", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => + workflow.run({ stepId: "child", name: "contractChild", input: {} }), + (value: { count: string }) => { + directValidations += 1; + return transform(value); + }, + "passthrough", + ), + richAction( + "ignoredParent", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "child", name: "contractChild", input: {} }); + return { ok: true, message: "Parent complete." }; + }, + (value: { count: string }) => { + ignoredValidations += 1; + return transform(value); + }, + "passthrough", + ), + action( + "undeclaredInner", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "child", name: "contractChild", input: {} }); + return { ok: true, message: "Inner complete." }; + }, + ), + richAction( + "outerParent", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "inner", name: "undeclaredInner", input: {} }); + return { ok: true, message: "Outer complete." }; + }, + undefined, + "passthrough", + ), + richAction( + "rejectingParent", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "child", name: "contractChild", input: {} }); + return { ok: true, message: "Parent complete." }; + }, + () => ({ issues: [{ message: "RAW-PARENT-SCHEMA-ISSUE" }] }), + "passthrough", + ), + ]); + concierge.onDispatch((event) => events.push(event as unknown as Record)); + + const realConsole = globalThis.console; + const warnings: string[] = []; + globalThis.console = { ...realConsole, warn: (message) => warnings.push(String(message)) }; + try { + await expect(dispatch(concierge, "directParent")).resolves.toEqual({ + ok: false, + reason: "precondition_failed", + message: "Child failed.", + data: { count: 2 }, + }); + await expect(dispatch(concierge, "ignoredParent")).resolves.toEqual({ + ok: false, + reason: "precondition_failed", + message: "Child failed.", + data: { count: 2 }, + }); + for (const name of ["outerParent", "rejectingParent"]) { + await expect(dispatch(concierge, name, {}, `${name}-one`)).resolves.toEqual({ + ok: false, + reason: "invalid_result", + message: "The action returned an invalid result.", + }); + await expect(dispatch(concierge, name, {}, `${name}-two`)).resolves.toEqual({ + ok: false, + reason: "invalid_result", + message: "The action returned an invalid result.", + }); + } + await flushEvents(); + } finally { + globalThis.console = realConsole; + } + + expect(directValidations).toBe(1); + expect(ignoredValidations).toBe(1); + const parentEvents = events.filter((event) => + ["directParent", "ignoredParent", "undeclaredInner", "outerParent", "rejectingParent"] + .includes(String(event.name)) && + ["succeeded", "failed", "cancelled"].includes(String(event.phase)) + ); + expect(parentEvents.filter((event) => event.name === "directParent")[0]?.resultData) + .toEqual({ kind: "included", value: { count: 2 } }); + expect(parentEvents.filter((event) => event.name === "ignoredParent")[0]?.resultData) + .toEqual({ kind: "included", value: { count: 2 } }); + expect(parentEvents + .filter((event) => ["undeclaredInner", "outerParent", "rejectingParent"].includes(String(event.name))) + .every((event) => JSON.stringify(event.resultData) === JSON.stringify({ kind: "absent" }))) + .toBe(true); + + const warningText = warnings.join("\n"); + expect(warnings.filter((warning) => warning.includes('action "undeclaredInner"'))).toHaveLength(1); + expect(warnings.filter((warning) => warning.includes('action "rejectingParent"'))).toHaveLength(1); + expect(warningText).not.toContain("RAW-CHILD-DATA-SECRET"); + expect(warningText).not.toContain("RAW-PARENT-SCHEMA-ISSUE"); + }); + + it("preserves terminal child results without weakening their observer policy", async () => { + const events: Array> = []; + const terminalChild = richAction( + "terminalChild", + () => ({ + ok: true, + message: "Terminal child complete.", + data: { secret: "terminal-child-only" }, + }), + ); + const concierge = conciergeFor([ + { ...terminalChild, terminal: true }, + richAction( + "terminalParent", + async ({ workflow }: { workflow: { run(input: unknown): Promise } }) => { + await workflow.run({ stepId: "terminal", name: "terminalChild", input: {} }); + return { ok: true, message: "Parent complete." }; + }, + undefined, + "passthrough", + ), + ]); + concierge.onDispatch((event) => events.push(event as unknown as Record)); + + const catalog = concierge.resolveCatalog(CONTEXT); + const outcome = await concierge.dispatchBatch(CONTEXT, { + sessionId: "session-terminal", + responseId: "response-terminal", + userTurnId: "turn-terminal", + catalogRevision: catalog.revision, + calls: [{ + callId: "terminal-parent", + name: "terminalParent", + arguments: "{}", + outputIndex: 0, + }], + }); + await flushEvents(); + + expect(outcome.kind).toBe("terminal"); + if (outcome.kind !== "terminal") throw new Error("expected terminal outcome"); + expect(outcome.enteredBy.name).toBe("terminalChild"); + expect(outcome.rows[0]?.result).toEqual({ + ok: true, + message: "Terminal child complete.", + data: { secret: "terminal-child-only" }, + }); + const terminal = new Map( + events + .filter((event) => ["succeeded", "failed", "cancelled"].includes(String(event.phase))) + .map((event) => [event.name, event]), + ); + expect(terminal.get("terminalChild")?.resultData).toEqual({ kind: "dropped" }); + expect(terminal.get("terminalParent")?.resultData).toEqual({ kind: "dropped" }); + }); + it("treats explicit undefined data as absent and omits undefined object fields", async () => { const concierge = conciergeFor([ richAction(