From e145a9b7e59d5f93327a856314747001c8f4ac42 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Mon, 27 Jul 2026 22:25:50 +0200 Subject: [PATCH 1/5] wip-demo --- packages/mock-converse-api/src/server.test.ts | 69 ++++ .../src/converse/generate-response.ts | 4 + .../src/converse/templates/response9.ts | 122 +++++++ .../src/converse/templates/templates.ts | 5 +- packages/thermidor/src/index.ts | 2 +- .../api/generative/generative-runtime.test.ts | 15 +- .../api/generative/generative-runtime.ts | 11 +- .../features/generative/generative-actions.ts | 4 +- .../features/generative/generative-slice.ts | 6 +- .../features/generative/generative-types.ts | 20 +- .../src/internal/features/generative/index.ts | 2 +- .../converse/converse-controller.ts | 9 +- pnpm-lock.yaml | 298 ++++++++++++------ samples/README.md | 14 +- .../generative-angular/src/app/a2ui-parser.ts | 4 +- .../generative-angular/src/app/models.ts | 4 +- .../src/app/services/conversation.service.ts | 7 +- .../AgentResponse/AgentResponse.tsx | 11 +- samples/thermidor/schema-contract-react/.env | 6 + .../schema-contract-react/.env.example | 6 + .../thermidor/schema-contract-react/README.md | 24 ++ .../schema-contract-react/index.html | 12 + .../schema-contract-react/package.json | 26 ++ .../schema-contract-react/src/App.tsx | 92 ++++++ .../src/a2ui/adapter.test.ts | 73 +++++ .../schema-contract-react/src/a2ui/adapter.ts | 111 +++++++ .../src/a2ui/components.tsx | 112 +++++++ .../schema-contract-react/src/env.ts | 30 ++ .../schema-contract-react/src/main.tsx | 5 + .../schema-contract-react/src/styles.css | 201 ++++++++++++ .../src/use-controller.ts | 20 ++ .../schema-contract-react/tsconfig.json | 15 + .../schema-contract-react/vite.config.ts | 22 ++ 33 files changed, 1233 insertions(+), 129 deletions(-) create mode 100644 packages/platform-mock-api/src/converse/templates/response9.ts create mode 100644 samples/thermidor/schema-contract-react/.env create mode 100644 samples/thermidor/schema-contract-react/.env.example create mode 100644 samples/thermidor/schema-contract-react/README.md create mode 100644 samples/thermidor/schema-contract-react/index.html create mode 100644 samples/thermidor/schema-contract-react/package.json create mode 100644 samples/thermidor/schema-contract-react/src/App.tsx create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/adapter.ts create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/components.tsx create mode 100644 samples/thermidor/schema-contract-react/src/env.ts create mode 100644 samples/thermidor/schema-contract-react/src/main.tsx create mode 100644 samples/thermidor/schema-contract-react/src/styles.css create mode 100644 samples/thermidor/schema-contract-react/src/use-controller.ts create mode 100644 samples/thermidor/schema-contract-react/tsconfig.json create mode 100644 samples/thermidor/schema-contract-react/vite.config.ts diff --git a/packages/mock-converse-api/src/server.test.ts b/packages/mock-converse-api/src/server.test.ts index 64bdc6be809..6d3122cee0b 100644 --- a/packages/mock-converse-api/src/server.test.ts +++ b/packages/mock-converse-api/src/server.test.ts @@ -125,6 +125,75 @@ describe('createMockConverseServer', () => { expect(res.headers['content-type']).toBe('text/event-stream'); }); + it('streams the Thermidor schema catalog example for its dedicated prompt', async () => { + await startServer(); + const res = await makeRequest( + server, + { + method: 'POST', + path: '/rest/organizations/myorg/commerce/unstable/agentic/converse', + headers: {'Content-Type': 'application/json'}, + }, + JSON.stringify({message: 'Show the Thermidor catalog'}) + ); + + const events = res.body + .split('\n\n') + .filter(Boolean) + .map( + (frame) => JSON.parse(frame.split('\n')[1].replace('data:', '')) as Record + ); + const activity = events.find((event) => event['type'] === 'ACTIVITY_SNAPSHOT'); + + expect(activity).toMatchObject({ + type: 'ACTIVITY_SNAPSHOT', + messageId: 'commerce-catalog-example', + activityType: 'a2ui-surface', + replace: true, + content: { + a2ui_operations: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'commerce-catalog-example', + catalogId: 'https://schema.thermidor.coveo.com/a2-ui/catalog.json', + }, + }, + { + version: 'v0.9', + updateComponents: { + components: [{component: 'ProductCarousel'}, {component: 'Cart'}], + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'commerce-catalog-example', + }, + }, + ], + }, + }); + + const content = activity?.['content'] as {a2ui_operations: Array>}; + const dataModelOperation = content.a2ui_operations.find( + (operation) => 'updateDataModel' in operation + )!['updateDataModel'] as { + value: { + controllers: Record; + }; + }; + + expect(dataModelOperation.value.controllers['featured-products'].products).toEqual( + expect.arrayContaining([expect.objectContaining({permanentid: 'trail-running-shoes-001'})]) + ); + expect(dataModelOperation.value.controllers['shopping-cart'].items).toEqual( + expect.arrayContaining([ + expect.objectContaining({productId: 'trail-running-shoes-001', quantity: 1}), + ]) + ); + }); + it('returns 400 for invalid JSON payload', async () => { await startServer(); const res = await makeRequest( diff --git a/packages/platform-mock-api/src/converse/generate-response.ts b/packages/platform-mock-api/src/converse/generate-response.ts index 73ac34e9c8f..2a18f1115ac 100644 --- a/packages/platform-mock-api/src/converse/generate-response.ts +++ b/packages/platform-mock-api/src/converse/generate-response.ts @@ -25,6 +25,10 @@ const PROMPT_TEMPLATE_MAP: ReadonlyArray = [ prompt: 'i like cold-water surfing. compare wetsuits for it', templateId: 'response8', }, + { + prompt: 'show the thermidor catalog', + templateId: 'thermidor-schema-catalog', + }, ]; const FALLBACK_TEMPLATE_ID: TemplateId = 'response5'; diff --git a/packages/platform-mock-api/src/converse/templates/response9.ts b/packages/platform-mock-api/src/converse/templates/response9.ts new file mode 100644 index 00000000000..1daee0cff0d --- /dev/null +++ b/packages/platform-mock-api/src/converse/templates/response9.ts @@ -0,0 +1,122 @@ +import { + ActivitySnapshot, + RunFinished, + RunStarted, + TurnComplete, + TurnStarted, + textMessage, + type ConverseEvent, +} from '../events.js'; + +const thermidorSchemaCatalogResponseEvents: ConverseEvent[] = [ + TurnStarted(), + RunStarted(), + ...textMessage( + 'thermidor-schema-catalog-message', + 'Here are featured products and the current cart from the Thermidor catalog contract.' + ), + ActivitySnapshot({ + messageId: 'commerce-catalog-example', + activityType: 'a2ui-surface', + replace: true, + content: { + a2ui_operations: [ + { + version: 'v0.9', + createSurface: { + surfaceId: 'commerce-catalog-example', + catalogId: 'https://schema.thermidor.coveo.com/a2-ui/catalog.json', + sendDataModel: true, + }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'commerce-catalog-example', + components: [ + { + id: 'featured-products', + component: 'ProductCarousel', + controllers: { + productListController: { + controllerId: 'featured-products', + controllerSchema: + 'https://schema.thermidor.coveo.com/controllers/product-list.schema.json', + }, + }, + }, + { + id: 'cart', + component: 'Cart', + controllers: { + cartController: { + controllerId: 'shopping-cart', + controllerSchema: + 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + }, + }, + }, + ], + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'commerce-catalog-example', + value: { + controllers: { + 'featured-products': { + products: [ + { + permanentid: 'trail-running-shoes-001', + ec_name: 'Peak Trail Running Shoes', + ec_shortdesc: 'Responsive trail shoes for everyday adventures.', + ec_brand: 'Thermidor Outdoor', + ec_category: ['Footwear', 'Trail Running'], + ec_price: 129.99, + ec_promo_price: 99.99, + ec_images: [ + 'https://images.example.com/products/trail-running-shoes-001.jpg', + ], + ec_in_stock: true, + ec_rating: 4.7, + clickUri: '/products/trail-running-shoes-001', + additionalFields: {}, + }, + { + permanentid: 'summit-pack-020', + ec_name: 'Summit Day Pack', + ec_shortdesc: 'A compact, weather-ready 20 L day pack.', + ec_brand: 'Thermidor Outdoor', + ec_category: ['Bags', 'Day Packs'], + ec_price: 89.99, + ec_images: ['https://images.example.com/products/summit-pack-020.jpg'], + ec_in_stock: true, + ec_rating: 4.4, + clickUri: '/products/summit-pack-020', + additionalFields: {}, + }, + ], + }, + 'shopping-cart': { + items: [ + { + productId: 'trail-running-shoes-001', + name: 'Peak Trail Running Shoes', + price: 99.99, + quantity: 1, + }, + ], + }, + }, + }, + }, + }, + ], + }, + }), + RunFinished(), + TurnComplete(), +]; + +export {thermidorSchemaCatalogResponseEvents}; diff --git a/packages/platform-mock-api/src/converse/templates/templates.ts b/packages/platform-mock-api/src/converse/templates/templates.ts index a2567904d6b..906fe2c8f66 100644 --- a/packages/platform-mock-api/src/converse/templates/templates.ts +++ b/packages/platform-mock-api/src/converse/templates/templates.ts @@ -7,6 +7,7 @@ import {response5Events} from './response5.js'; import {response6Events} from './response6.js'; import {response7Events} from './response7.js'; import {response8Events} from './response8.js'; +import {thermidorSchemaCatalogResponseEvents} from './response9.js'; type TemplateId = | 'response1' @@ -16,7 +17,8 @@ type TemplateId = | 'response5' | 'response6' | 'response7' - | 'response8'; + | 'response8' + | 'thermidor-schema-catalog'; const templateEvents = { response1: response1Events, @@ -27,6 +29,7 @@ const templateEvents = { response6: response6Events, response7: response7Events, response8: response8Events, + 'thermidor-schema-catalog': thermidorSchemaCatalogResponseEvents, } satisfies Record; const getTemplateEvents = (templateId: TemplateId): ConverseEvent[] => templateEvents[templateId]; diff --git a/packages/thermidor/src/index.ts b/packages/thermidor/src/index.ts index 89328378f10..d6631066899 100644 --- a/packages/thermidor/src/index.ts +++ b/packages/thermidor/src/index.ts @@ -35,7 +35,7 @@ export type { Supports, } from '@/src/internal/utils/index.js'; export type { - A2UISurface, + Activity, AgentMessage, AgentResponse, ReasoningMessageStep, diff --git a/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts b/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts index 0cc70b6ee57..e67684d5ca1 100644 --- a/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts +++ b/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts @@ -44,7 +44,7 @@ function createMockStatePort(): GenerativeStatePort { initAgentResponse: vi.fn(), startMessage: vi.fn(), appendMessageDelta: vi.fn(), - appendSurface: vi.fn(), + appendActivity: vi.fn(), startToolCall: vi.fn(), appendToolCallArgs: vi.fn(), completeToolCall: vi.fn(), @@ -453,16 +453,16 @@ describe('GenerativeRuntime', () => { ); }); - it('handles ACTIVITY_SNAPSHOT by appending surface', async () => { + it('normalizes ACTIVITY_SNAPSHOT before appending an activity', async () => { const config = createMockConfig(); const engine = createMockEngine(); - const surface = {component: 'product-card', data: {id: 'p1'}}; + const payload = {component: 'product-card', data: {id: 'p1'}}; setupSuccessfulStream([ { type: 'ACTIVITY_SNAPSHOT', messageId: 'm1', activityType: 'ui-surface', - content: surface, + content: payload, replace: false, } as ConversationStreamEvent, {type: 'turn_complete'} as ConversationStreamEvent, @@ -471,7 +471,12 @@ describe('GenerativeRuntime', () => { const runtime = GenerativeRuntime.getInstance(engine, 'activity', config); await runtime.submit('Hello'); - expect(config.statePort.appendSurface).toHaveBeenCalledWith('generated-id-1', surface); + expect(config.statePort.appendActivity).toHaveBeenCalledWith('generated-id-1', { + id: 'm1', + kind: 'ui-surface', + payload, + replace: false, + }); }); it('handles commerce_search_api_response by routing when hydration succeeds', async () => { diff --git a/packages/thermidor/src/internal/api/generative/generative-runtime.ts b/packages/thermidor/src/internal/api/generative/generative-runtime.ts index 4dae1e84240..f36c1b334a5 100644 --- a/packages/thermidor/src/internal/api/generative/generative-runtime.ts +++ b/packages/thermidor/src/internal/api/generative/generative-runtime.ts @@ -9,7 +9,7 @@ import {createConversationEndpointRequestSelector} from '@/src/internal/api/conv import {getOrCreateConfigurationSelectors} from '@/src/internal/features/configuration/index.js'; import {generateId} from '@/src/internal/utils/index.js'; import type { - A2UISurface, + Activity, RoutedUseCase, TurnStatus, UseCaseInterfaceMap, @@ -23,7 +23,7 @@ export interface GenerativeStatePort { initAgentResponse(turnId: string): void; startMessage(turnId: string, role: string): void; appendMessageDelta(turnId: string, delta: string): void; - appendSurface(turnId: string, surface: A2UISurface): void; + appendActivity(turnId: string, activity: Activity): void; startToolCall(turnId: string, toolCallId: string, toolName: string): void; appendToolCallArgs(turnId: string, toolCallId: string, delta: string): void; completeToolCall(turnId: string, toolCallId: string, result: string): void; @@ -253,7 +253,12 @@ export class GenerativeRuntime { case 'ACTIVITY_SNAPSHOT': { this.ensureAgentResponse(turnId); - this.statePort.appendSurface(turnId, event.content as Record); + this.statePort.appendActivity(turnId, { + id: event.messageId, + kind: event.activityType, + payload: event.content as Record, + replace: event.replace, + }); return {turnId, isTerminal: false}; } diff --git a/packages/thermidor/src/internal/features/generative/generative-actions.ts b/packages/thermidor/src/internal/features/generative/generative-actions.ts index 122f4f59c72..94782ea509c 100644 --- a/packages/thermidor/src/internal/features/generative/generative-actions.ts +++ b/packages/thermidor/src/internal/features/generative/generative-actions.ts @@ -2,7 +2,7 @@ import {createAction} from '@reduxjs/toolkit'; import {type CacheKey, createCacheKey} from '@/src/internal/utils/index.js'; import {getHandleInternals} from '@/src/internal/utils/index.js'; import type {InterfaceHandle} from '@/src/internal/utils/index.js'; -import type {A2UISurface, GenerativeState, RoutedUseCase, TurnStatus} from './generative-types.js'; +import type {Activity, GenerativeState, RoutedUseCase, TurnStatus} from './generative-types.js'; type GenerativeActions = ReturnType; @@ -26,7 +26,7 @@ export function createGenerativeActions(interfaceId: string) { appendMessageDelta: createAction<{turnId: string; delta: string}>( `${prefix}/appendMessageDelta` ), - appendSurface: createAction<{turnId: string; surface: A2UISurface}>(`${prefix}/appendSurface`), + appendActivity: createAction<{turnId: string; activity: Activity}>(`${prefix}/appendActivity`), startToolCall: createAction<{ turnId: string; toolCallId: string; diff --git a/packages/thermidor/src/internal/features/generative/generative-slice.ts b/packages/thermidor/src/internal/features/generative/generative-slice.ts index aeedc9ec770..c070d35ae02 100644 --- a/packages/thermidor/src/internal/features/generative/generative-slice.ts +++ b/packages/thermidor/src/internal/features/generative/generative-slice.ts @@ -57,7 +57,7 @@ export function createGenerativeSlice( if (turn) { turn.agentResponse = { messages: [], - surfaces: [], + activities: [], reasoningSteps: [], }; } @@ -75,10 +75,10 @@ export function createGenerativeSlice( messages[messages.length - 1].content += payload.delta; } }) - .addCase(actions.appendSurface, (state, {payload}) => { + .addCase(actions.appendActivity, (state, {payload}) => { const turn = state.turns.find((t) => t.id === payload.turnId); if (turn?.agentResponse) { - turn.agentResponse.surfaces.push(payload.surface); + turn.agentResponse.activities.push(payload.activity); } }) .addCase(actions.startToolCall, (state, {payload}) => { diff --git a/packages/thermidor/src/internal/features/generative/generative-types.ts b/packages/thermidor/src/internal/features/generative/generative-types.ts index d91688607ab..fce91337799 100644 --- a/packages/thermidor/src/internal/features/generative/generative-types.ts +++ b/packages/thermidor/src/internal/features/generative/generative-types.ts @@ -117,9 +117,12 @@ export interface AgentResponse { messages: AgentMessage[]; /** - * The opaque A2UI surfaces received during streaming. + * Structured activities emitted by the agent during streaming. + * + * Thermidor keeps each activity opaque; applications select and interpret the + * activity kinds they support. */ - surfaces: A2UISurface[]; + activities: Activity[]; /** * An ordered sequence of reasoning steps that preserves the temporal @@ -189,9 +192,18 @@ export interface AgentMessage { } /** - * Opaque surface data passed through from `/converse` without interpretation. + * A normalized, structured activity emitted by an agent. + * + * `kind` and `payload` deliberately do not prescribe a presentation protocol. + * Applications can use them for A2-UI, another UI protocol, or a non-visual + * integration without Thermidor taking a dependency on any of those choices. */ -export type A2UISurface = Record; +export interface Activity { + id: string | undefined; + kind: string | undefined; + payload: Record; + replace: boolean | undefined; +} export interface GenerativeState { /** diff --git a/packages/thermidor/src/internal/features/generative/index.ts b/packages/thermidor/src/internal/features/generative/index.ts index 5b6a3f8333d..5f5ee9b45ef 100644 --- a/packages/thermidor/src/internal/features/generative/index.ts +++ b/packages/thermidor/src/internal/features/generative/index.ts @@ -12,7 +12,7 @@ export { } from './routed-interface-registry.js'; export type {RoutedInterfaceEntry, RoutedInterfaceRegistry} from './routed-interface-registry.js'; export type { - A2UISurface, + Activity, AgentMessage, AgentResponse, GenerativeState, diff --git a/packages/thermidor/src/public/controllers/converse/converse-controller.ts b/packages/thermidor/src/public/controllers/converse/converse-controller.ts index 7de201a8d9a..a8816c08a02 100644 --- a/packages/thermidor/src/public/controllers/converse/converse-controller.ts +++ b/packages/thermidor/src/public/controllers/converse/converse-controller.ts @@ -97,12 +97,8 @@ class ConverseControllerImpl extends BaseController { appendMessageDelta: (turnId, delta) => { this.engine.mutate(this.#actions.appendMessageDelta({turnId, delta})); }, - appendSurface: (turnId, surface) => { - this.engine.mutate(this.#actions.appendSurface({turnId, surface})); - const ops = (surface as {operations?: unknown[]}).operations; - if (Array.isArray(ops)) { - options.onSurfaceOperation?.(ops); - } + appendActivity: (turnId, activity) => { + this.engine.mutate(this.#actions.appendActivity({turnId, activity})); }, startToolCall: (turnId, toolCallId, toolName) => { this.engine.mutate(this.#actions.startToolCall({turnId, toolCallId, toolName})); @@ -238,5 +234,4 @@ export interface ConverseControllerState { export interface ConverseControllerOptions { interface: GenerativeInterface; conversationToRestore?: SerializedConverseState; - onSurfaceOperation?: (operations: unknown[]) => void; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb99f32a0f2..ae51ec881e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,7 +483,7 @@ importers: devDependencies: '@angular-devkit/build-angular': specifier: 'catalog:' - version: 21.2.17(7e6d0542722e3ce068cf3323a964064c) + version: 21.2.17(80d26e843c839fab20396dae6982b00b) '@angular/cli': specifier: 'catalog:' version: 21.2.17(@types/node@24.12.4)(chokidar@5.0.0) @@ -2101,6 +2101,37 @@ importers: specifier: 'catalog:' version: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + samples/thermidor/schema-contract-react: + dependencies: + '@coveo/thermidor': + specifier: workspace:* + version: link:../../../packages/thermidor + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.2.17 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.3(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + samples/thermidor/search-react: dependencies: '@coveo/thermidor': @@ -16801,13 +16832,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@21.2.17(7e6d0542722e3ce068cf3323a964064c)': + '@angular-devkit/build-angular@21.2.17(80d26e843c839fab20396dae6982b00b)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.17(chokidar@5.0.0) '@angular-devkit/build-webpack': 0.2102.17(chokidar@5.0.0)(webpack-dev-server@5.2.5(tslib@2.8.1)(webpack@5.105.2(esbuild@0.28.1)(postcss@8.5.12)))(webpack@5.105.2(esbuild@0.28.1)(postcss@8.5.12)) '@angular-devkit/core': 21.2.17(chokidar@5.0.0) - '@angular/build': 21.2.17(2579aedd5157518c28138f5f823a7c91) + '@angular/build': 21.2.17(6133a8bd1a48a56825f649b47a4a697e) '@angular/compiler-cli': 21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3) '@babel/core': 7.29.7 '@babel/generator': 7.29.1 @@ -16864,7 +16895,7 @@ snapshots: '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) esbuild: 0.28.1 - jest: 30.3.0(@types/node@24.12.4) + jest: 30.3.0(@types/node@24.12.4)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)) ng-packagr: 20.3.2(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3))(tailwindcss@4.3.2)(tslib@2.8.1)(typescript@6.0.3) tailwindcss: 4.3.2 transitivePeerDependencies: @@ -16934,7 +16965,7 @@ snapshots: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) tslib: 2.8.1 - '@angular/build@21.2.17(2579aedd5157518c28138f5f823a7c91)': + '@angular/build@21.2.17(5b911479a7812c42761d62528d4640b4)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.17(chokidar@5.0.0) @@ -16944,7 +16975,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@24.12.4) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.5 esbuild: 0.28.1 @@ -16957,7 +16988,7 @@ snapshots: parse5-html-rewriting-stream: 8.0.0 picomatch: 4.0.4 piscina: 5.2.0 - rolldown: 1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + rolldown: 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) sass: 1.97.3 semver: 7.7.4 source-map-support: 0.5.21 @@ -16965,18 +16996,17 @@ snapshots: tslib: 2.8.1 typescript: 6.0.3 undici: 7.24.4 - vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - less: 4.4.2 + less: 4.6.7 lmdb: 3.5.1 - ng-packagr: 20.3.2(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3))(tailwindcss@4.3.2)(tslib@2.8.1)(typescript@6.0.3) - postcss: 8.5.12 + postcss: 8.5.16 tailwindcss: 4.3.2 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -16992,7 +17022,7 @@ snapshots: - tsx - yaml - '@angular/build@21.2.17(5b911479a7812c42761d62528d4640b4)': + '@angular/build@21.2.17(6133a8bd1a48a56825f649b47a4a697e)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.17(chokidar@5.0.0) @@ -17002,7 +17032,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@24.12.4) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.5 esbuild: 0.28.1 @@ -17015,7 +17045,7 @@ snapshots: parse5-html-rewriting-stream: 8.0.0 picomatch: 4.0.4 piscina: 5.2.0 - rolldown: 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + rolldown: 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) sass: 1.97.3 semver: 7.7.4 source-map-support: 0.5.21 @@ -17023,17 +17053,18 @@ snapshots: tslib: 2.8.1 typescript: 6.0.3 undici: 7.24.4 - vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - less: 4.6.7 + less: 4.4.2 lmdb: 3.5.1 - postcss: 8.5.16 + ng-packagr: 20.3.2(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3))(tailwindcss@4.3.2)(tslib@2.8.1)(typescript@6.0.3) + postcss: 8.5.12 tailwindcss: 4.3.2 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -17090,7 +17121,7 @@ snapshots: lmdb: 3.5.1 postcss: 8.5.16 tailwindcss: 4.3.2 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -19717,6 +19748,42 @@ snapshots: - supports-color - ts-node + '@jest/core@30.3.0(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3))': + dependencies: + '@jest/console': 30.3.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 26.1.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.3.0 + jest-config: 30.3.0(@types/node@26.1.0)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)) + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-resolve-dependencies: 30.3.0 + jest-runner: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + jest-watcher: 30.3.0 + pretty-format: 30.3.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + '@jest/core@30.3.0(ts-node@10.9.2(@types/node@26.1.0)(typescript@6.0.3))': dependencies: '@jest/console': 30.3.0 @@ -21763,9 +21830,9 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.1.4': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -23388,13 +23455,13 @@ snapshots: - utf-8-validate - vite - '@vitest/browser-playwright@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.60.0)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser-playwright@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: - '@vitest/browser': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) - playwright: 1.60.0 + '@vitest/browser': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + playwright: 1.62.0-alpha-2026-06-29 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw @@ -23416,32 +23483,32 @@ snapshots: - vite optional: true - '@vitest/browser-playwright@4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(playwright@1.60.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser-playwright@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: - '@vitest/browser': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) - playwright: 1.60.0 + '@vitest/browser': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + playwright: 1.62.0-alpha-2026-06-29 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite + optional: true - '@vitest/browser-playwright@4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser-playwright@4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(playwright@1.60.0)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: - '@vitest/browser': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) - playwright: 1.62.0-alpha-2026-06-29 + '@vitest/browser': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + playwright: 1.60.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - optional: true '@vitest/browser-playwright@4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: @@ -23471,51 +23538,51 @@ snapshots: - vite optional: true - '@vitest/browser@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite + optional: true - '@vitest/browser@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - optional: true - '@vitest/browser@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23524,16 +23591,16 @@ snapshots: - vite optional: true - '@vitest/browser@4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': + '@vitest/browser@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23594,6 +23661,16 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.6(@types/node@24.12.4)(typescript@6.0.3) + vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) + optional: true + '@vitest/mocker@4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 @@ -23622,16 +23699,6 @@ snapshots: msw: 2.14.6(@types/node@24.12.4)(typescript@6.0.3) vite: 8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) - '@vitest/mocker@4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.14.6(@types/node@26.1.0)(typescript@6.0.3) - vite: 7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) - optional: true - '@vitest/mocker@4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 @@ -27573,15 +27640,15 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(@types/node@24.12.4): + jest-cli@30.3.0(@types/node@24.12.4)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)): dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@26.1.0)(typescript@6.0.3)) + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@24.12.4) + jest-config: 30.3.0(@types/node@24.12.4)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.3 @@ -27643,7 +27710,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@24.12.4): + jest-config@30.3.0(@types/node@24.12.4)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@babel/core': 7.29.7 '@jest/get-type': 30.1.0 @@ -27670,6 +27737,40 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 24.12.4 + ts-node: 10.9.2(@types/node@24.12.4)(typescript@6.0.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + + jest-config@30.3.0(@types/node@26.1.0)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 26.1.0 + ts-node: 10.9.2(@types/node@24.12.4)(typescript@6.0.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -28221,12 +28322,12 @@ snapshots: - supports-color - ts-node - jest@30.3.0(@types/node@24.12.4): + jest@30.3.0(@types/node@24.12.4)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)): dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@26.1.0)(typescript@6.0.3)) + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)) '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@24.12.4) + jest-cli: 30.3.0(@types/node@24.12.4)(ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -31524,7 +31625,7 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0): + rolldown@1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.113.0 '@rolldown/pluginutils': 1.0.0-rc.4 @@ -31539,7 +31640,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.4 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.4 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.4 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.4 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.4 transitivePeerDependencies: @@ -32726,6 +32827,25 @@ snapshots: lit-analyzer: 2.0.3 web-component-analyzer: 2.0.0 + ts-node@10.9.2(@types/node@24.12.4)(typescript@6.0.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.12.4 + acorn: 8.17.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 6.0.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + ts-node@10.9.2(@types/node@26.1.0)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -33348,10 +33468,10 @@ snapshots: terser: 5.48.0 yaml: 2.9.0 - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -33368,19 +33488,20 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.60.0)(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) jsdom: 28.1.0 transitivePeerDependencies: - msw + optional: true - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -33397,20 +33518,19 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) + vite: 8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.60.0)(vite@8.1.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) jsdom: 28.1.0 transitivePeerDependencies: - msw - optional: true - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -33427,19 +33547,20 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.60.0)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) jsdom: 28.1.0 transitivePeerDependencies: - msw + optional: true - vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -33456,15 +33577,14 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) + vite: 8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.0 - '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@7.3.5(@types/node@26.1.0)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) + '@types/node': 24.12.4 + '@vitest/browser-playwright': 4.1.9(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(playwright@1.62.0-alpha-2026-06-29)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.9) jsdom: 28.1.0 transitivePeerDependencies: - msw - optional: true vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: diff --git a/samples/README.md b/samples/README.md index 732e17ed132..292a0ec10c7 100644 --- a/samples/README.md +++ b/samples/README.md @@ -51,11 +51,15 @@ Server-side rendering with Headless controllers for improved performance and SEO Samples using `@coveo/thermidor` for upcoming conversational and search experiences. -| Sample | Description | Framework | Use Case | -| ----------------------------------------------------- | ----------------------------------------------------------------- | ------------ | ------------ | -| [conversation-react](./thermidor/conversation-react/) | React + Vite conversational integration sample | React + Vite | Conversation | -| [commerce-react](./thermidor/commerce-react/) | React + Vite commerce sample with ProductList, Pagination | React + Vite | Commerce | -| [search-react](./thermidor/search-react/) | React + Vite search sample with SearchBox, ResultList, Pagination | React + Vite | Search | +| Sample | Description | Framework | Use Case | +| ----------------------------------------------------------- | ------------------------------------------------------------------ | ------------ | ------------ | +| [conversation-react](./thermidor/conversation-react/) | React + Vite conversational integration sample | React + Vite | Conversation | +| [commerce-react](./thermidor/commerce-react/) | React + Vite commerce sample with ProductList, Pagination | React + Vite | Commerce | +| [search-react](./thermidor/search-react/) | React + Vite search sample with SearchBox, ResultList, Pagination | React + Vite | Search | +| [generative-react](./thermidor/generative-react/) | React + Vite generative conversation and custom A2-UI sample | React + Vite | Conversation | +| [generative-angular](./thermidor/generative-angular/) | Angular generative conversation and custom A2-UI sample | Angular | Conversation | +| [demo-react](./thermidor/demo-react/) | React demo with landing, conversation, and routed search views | React + Vite | Conversation | +| [schema-contract-react](./thermidor/schema-contract-react/) | v0.9 Commerce Catalog contract rendered from normalized activities | React + Vite | Contract | ## 🚀 Quick Start diff --git a/samples/thermidor/generative-angular/src/app/a2ui-parser.ts b/samples/thermidor/generative-angular/src/app/a2ui-parser.ts index 7066744ab2f..e11a25ea6f6 100644 --- a/samples/thermidor/generative-angular/src/app/a2ui-parser.ts +++ b/samples/thermidor/generative-angular/src/app/a2ui-parser.ts @@ -6,8 +6,8 @@ * * Single public entry point: `parseSurfaces()` */ -import type {A2UISurface} from '@coveo/thermidor'; import type { + A2UISurface, A2UIOperation, ActivitySnapshotContent, BundleDisplayTier, @@ -328,7 +328,7 @@ function deduplicate(surfaces: RenderableCommerceSurface[]): RenderableCommerceS * Parse raw A2UI surface records into deduplicated, render-ready surfaces. */ export function parseSurfaces( - rawSurfaces: A2UISurface[] | undefined, + rawSurfaces: Record[] | undefined, options: {turnComplete: boolean} = {turnComplete: false} ): RenderableCommerceSurface[] { if (!rawSurfaces || rawSurfaces.length === 0) return []; diff --git a/samples/thermidor/generative-angular/src/app/models.ts b/samples/thermidor/generative-angular/src/app/models.ts index 89f0465dfe2..ecc036621ff 100644 --- a/samples/thermidor/generative-angular/src/app/models.ts +++ b/samples/thermidor/generative-angular/src/app/models.ts @@ -1,7 +1,9 @@ // Shared frontend contract types for the Angular sample. // These types re-export Thermidor's canonical conversation types and define // the sample-specific commerce surface shapes used across the app. -export type {Turn, A2UISurface, ReasoningStep, RoutedInterface} from '@coveo/thermidor'; +export type {Turn, ReasoningStep, RoutedInterface} from '@coveo/thermidor'; + +export type A2UISurface = Record; export type ProductRecord = { ec_product_id: string; diff --git a/samples/thermidor/generative-angular/src/app/services/conversation.service.ts b/samples/thermidor/generative-angular/src/app/services/conversation.service.ts index 026f3b44d2e..36802b8cadd 100644 --- a/samples/thermidor/generative-angular/src/app/services/conversation.service.ts +++ b/samples/thermidor/generative-angular/src/app/services/conversation.service.ts @@ -4,6 +4,7 @@ import { buildConverseController, type ConverseController, type ConverseControllerState, + type Activity, type SerializedConverseState, type ReasoningStep, } from '@coveo/thermidor'; @@ -82,8 +83,10 @@ export class ConversationService { let turnComplete = true; for (const turn of turns) { - if (turn.agentResponse?.surfaces?.length) { - latestSurfaces = turn.agentResponse.surfaces; + if (turn.agentResponse?.activities?.length) { + latestSurfaces = turn.agentResponse.activities.map( + (activity: Activity) => activity.payload + ); turnComplete = turn.status !== 'streaming'; } } diff --git a/samples/thermidor/generative-react/src/components/AgentResponse/AgentResponse.tsx b/samples/thermidor/generative-react/src/components/AgentResponse/AgentResponse.tsx index c65257e1427..0acfc6c9fbd 100644 --- a/samples/thermidor/generative-react/src/components/AgentResponse/AgentResponse.tsx +++ b/samples/thermidor/generative-react/src/components/AgentResponse/AgentResponse.tsx @@ -11,9 +11,9 @@ export interface AgentResponseProps { } export function AgentResponse({agentResponse, isStreaming, onAction}: AgentResponseProps) { - const {messages, reasoningSteps, surfaces} = agentResponse; + const {messages, reasoningSteps, activities} = agentResponse; - const hasContent = messages.length > 0 || reasoningSteps.length > 0 || surfaces.length > 0; + const hasContent = messages.length > 0 || reasoningSteps.length > 0 || activities.length > 0; if (!hasContent) { return null; @@ -25,7 +25,12 @@ export function AgentResponse({agentResponse, isStreaming, onAction}: AgentRespo )} {messages.length > 0 && } - {surfaces.length > 0 && } + {activities.length > 0 && ( + activity.payload)} + onAction={onAction} + /> + )} ); } diff --git a/samples/thermidor/schema-contract-react/.env b/samples/thermidor/schema-contract-react/.env new file mode 100644 index 00000000000..815a628fab0 --- /dev/null +++ b/samples/thermidor/schema-contract-react/.env @@ -0,0 +1,6 @@ +VITE_COVEO_ORGANIZATION_ID=your-organization-id +VITE_COVEO_ACCESS_TOKEN=your-access-token +VITE_COVEO_TRACKING_ID=thermidor-schema-contract-sample +VITE_COVEO_LANGUAGE=en +VITE_COVEO_COUNTRY=US +VITE_COVEO_CURRENCY=USD diff --git a/samples/thermidor/schema-contract-react/.env.example b/samples/thermidor/schema-contract-react/.env.example new file mode 100644 index 00000000000..815a628fab0 --- /dev/null +++ b/samples/thermidor/schema-contract-react/.env.example @@ -0,0 +1,6 @@ +VITE_COVEO_ORGANIZATION_ID=your-organization-id +VITE_COVEO_ACCESS_TOKEN=your-access-token +VITE_COVEO_TRACKING_ID=thermidor-schema-contract-sample +VITE_COVEO_LANGUAGE=en +VITE_COVEO_COUNTRY=US +VITE_COVEO_CURRENCY=USD diff --git a/samples/thermidor/schema-contract-react/README.md b/samples/thermidor/schema-contract-react/README.md new file mode 100644 index 00000000000..d0a010d4a42 --- /dev/null +++ b/samples/thermidor/schema-contract-react/README.md @@ -0,0 +1,24 @@ +# Thermidor schema-contract React sample + +This sample proves the client-side path for the v0.9 Thermidor Commerce Catalog contract: + +`ConverseController` → normalized Thermidor `Activity` → sample-owned A2-UI adapter → sample-owned component catalog. + +Thermidor only exposes opaque activity `kind` and `payload` values. The sample is the layer that recognizes the `a2ui-surface` kind and renders the `ProductCarousel` and `Cart` components advertised by the schema catalog. + +## Run with the contract mock + +From `integration/ui-kit`, start the mock API in one terminal: + +```bash +pnpm --filter @coveo/mock-converse-api build +pnpm --filter @coveo/mock-converse-api start +``` + +Copy `.env.example` to `.env`, fill the Coveo configuration values, then start the sample in another terminal: + +```bash +pnpm --filter @samples/thermidor-schema-contract-react dev:mock +``` + +Submit **Show the Thermidor catalog**. The mock streams the catalog example from `thermidor-schema`, including the controller advertisements and concrete product-list/cart state. diff --git a/samples/thermidor/schema-contract-react/index.html b/samples/thermidor/schema-contract-react/index.html new file mode 100644 index 00000000000..5af6e8e7a36 --- /dev/null +++ b/samples/thermidor/schema-contract-react/index.html @@ -0,0 +1,12 @@ + + + + + + Thermidor schema contract + + +
+ + + diff --git a/samples/thermidor/schema-contract-react/package.json b/samples/thermidor/schema-contract-react/package.json new file mode 100644 index 00000000000..0a63d0a4289 --- /dev/null +++ b/samples/thermidor/schema-contract-react/package.json @@ -0,0 +1,26 @@ +{ + "name": "@samples/thermidor-schema-contract-react", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "dev:mock": "VITE_COVEO_ENDPOINT=http://localhost:3456 vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@coveo/thermidor": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/samples/thermidor/schema-contract-react/src/App.tsx b/samples/thermidor/schema-contract-react/src/App.tsx new file mode 100644 index 00000000000..cb878792d97 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/App.tsx @@ -0,0 +1,92 @@ +import {useEffect, useRef, useState} from 'react'; +import { + buildConverseController, + buildGenerativeInterface, + Engine, + type ConverseController, + type GenerativeInterface, +} from '@coveo/thermidor'; +import {CatalogSurfaceRenderer} from './a2ui/components.js'; +import {toCatalogSurfaces} from './a2ui/adapter.js'; +import {getSampleConfiguration} from './env.js'; +import {useController} from './use-controller.js'; + +const CONTRACT_PROMPT = 'Show the Thermidor catalog'; + +export default function App() { + const engineRef = useRef(null); + engineRef.current ??= new Engine({ + configuration: getSampleConfiguration(), + navigatorContextProvider: getNavigatorContext, + }); + + const interfaceRef = useRef(null); + interfaceRef.current ??= buildGenerativeInterface({engine: engineRef.current}); + + const [controller, state] = useController(() => + buildConverseController({interface: interfaceRef.current!}) + ); + const [prompt, setPrompt] = useState(CONTRACT_PROMPT); + const turn = state.activeTurn; + const surfaces = toCatalogSurfaces(turn?.agentResponse?.activities ?? []); + + useEffect(() => { + return () => { + interfaceRef.current?.dispose(); + engineRef.current?.dispose(); + }; + }, []); + + function submit(event: React.FormEvent) { + event.preventDefault(); + controller.submit({prompt}); + } + + return ( +
+
+

Thermidor + v0.9 catalog contract

+

Server-owned commerce state, client-owned rendering.

+

+ Thermidor stays UI-less. This sample recognizes an a2ui-surface activity, + resolves the advertised controllers, and renders its local A2-UI component catalog. +

+
+ +
+ +
+ setPrompt(event.target.value)} + disabled={state.isStreaming} + /> + +
+
+ + {turn?.agentResponse?.messages.map((message, index) => ( +

+ {message.content} +

+ ))} + {turn?.status === 'error' &&

{turn.error}

} + {surfaces.map((surface) => ( + + ))} + {!turn &&

Run the pre-filled prompt to render the schema example.

} +
+ ); +} + +function getNavigatorContext() { + return { + clientId: crypto.randomUUID(), + location: window.location.href, + referrer: document.referrer || null, + userAgent: navigator.userAgent || null, + }; +} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts new file mode 100644 index 00000000000..3eaa0b8a217 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts @@ -0,0 +1,73 @@ +import {describe, expect, it} from 'vitest'; +import {toCatalogSurfaces} from './adapter.js'; + +describe('toCatalogSurfaces', () => { + it('keeps advertised components bound to their externally owned controller state', () => { + const [surface] = toCatalogSurfaces([ + { + id: 'catalog', + kind: 'a2ui-surface', + replace: true, + payload: { + a2ui_operations: [ + { + version: 'v0.9', + createSurface: {surfaceId: 'catalog', catalogId: 'catalog.json'}, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'catalog', + components: [ + { + id: 'featured-products', + component: 'ProductCarousel', + controllers: { + productListController: { + controllerId: 'featured-products', + controllerSchema: 'product-list.schema.json', + }, + }, + }, + ], + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'catalog', + value: {controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}}, + }, + }, + ], + }, + }, + ]); + + expect(surface).toMatchObject({ + id: 'catalog', + catalogId: 'catalog.json', + components: [{component: 'ProductCarousel'}], + controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}, + }); + }); + + it('resets prior surfaces when an activity declares replacement semantics', () => { + const surfaces = toCatalogSurfaces([ + { + id: 'old', + kind: 'a2ui-surface', + replace: false, + payload: {a2ui_operations: [{version: 'v0.9', createSurface: {surfaceId: 'old'}}]}, + }, + { + id: 'new', + kind: 'a2ui-surface', + replace: true, + payload: {a2ui_operations: [{version: 'v0.9', createSurface: {surfaceId: 'new'}}]}, + }, + ]); + + expect(surfaces.map(({id}) => id)).toEqual(['new']); + }); +}); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/adapter.ts b/samples/thermidor/schema-contract-react/src/a2ui/adapter.ts new file mode 100644 index 00000000000..7cecda710e5 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/adapter.ts @@ -0,0 +1,111 @@ +import type {Activity} from '@coveo/thermidor'; + +type ControllerAdvertisement = { + controllerId: string; + controllerSchema: string; +}; + +type CatalogComponent = { + id: string; + component: string; + controllers: Record; +}; + +export type CatalogSurface = { + id: string; + catalogId: string; + components: CatalogComponent[]; + controllers: Record>; +}; + +type MutableCatalogSurface = CatalogSurface; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function getOperations(activity: Activity): Record[] { + if (activity.kind !== 'a2ui-surface' || !isRecord(activity.payload)) { + return []; + } + + const operations = activity.payload['a2ui_operations']; + return Array.isArray(operations) ? operations.filter(isRecord) : []; +} + +function getOrCreateSurface( + surfaces: Map, + id: string, + catalogId = '' +): MutableCatalogSurface { + const existing = surfaces.get(id); + if (existing) { + return existing; + } + + const surface = {id, catalogId, components: [], controllers: {}}; + surfaces.set(id, surface); + return surface; +} + +/** + * This is the sample's A2-UI boundary. It interprets only the v0.9 catalog + * operations and keeps the application-facing result independent of Thermidor. + */ +export function toCatalogSurfaces(activities: Activity[]): CatalogSurface[] { + const surfaces = new Map(); + + for (const activity of activities) { + const operations = getOperations(activity); + if (operations.length === 0) { + continue; + } + if (activity.replace) { + surfaces.clear(); + } + + for (const operation of operations) { + const createSurface = operation['createSurface']; + if (isRecord(createSurface) && typeof createSurface['surfaceId'] === 'string') { + getOrCreateSurface( + surfaces, + createSurface['surfaceId'], + typeof createSurface['catalogId'] === 'string' ? createSurface['catalogId'] : '' + ); + continue; + } + + const updateComponents = operation['updateComponents']; + if (isRecord(updateComponents) && typeof updateComponents['surfaceId'] === 'string') { + const surface = getOrCreateSurface(surfaces, updateComponents['surfaceId']); + surface.components = Array.isArray(updateComponents['components']) + ? updateComponents['components'].filter(isCatalogComponent) + : []; + continue; + } + + const updateDataModel = operation['updateDataModel']; + if (!isRecord(updateDataModel) || typeof updateDataModel['surfaceId'] !== 'string') { + continue; + } + const value = updateDataModel['value']; + const controllers = + isRecord(value) && isRecord(value['controllers']) ? value['controllers'] : {}; + const surface = getOrCreateSurface(surfaces, updateDataModel['surfaceId']); + surface.controllers = Object.fromEntries( + Object.entries(controllers).filter(([, state]) => isRecord(state)) + ) as Record>; + } + } + + return [...surfaces.values()]; +} + +function isCatalogComponent(value: unknown): value is CatalogComponent { + return ( + isRecord(value) && + typeof value['id'] === 'string' && + typeof value['component'] === 'string' && + isRecord(value['controllers']) + ); +} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx new file mode 100644 index 00000000000..9fe1b840706 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx @@ -0,0 +1,112 @@ +import type {ComponentType} from 'react'; +import type {CatalogSurface} from './adapter.js'; + +type ComponentBinding = { + component: CatalogSurface['components'][number]; + controllers: CatalogSurface['controllers']; +}; + +type Product = { + permanentid: string; + ec_name: string; + ec_shortdesc: string; + ec_brand: string; + ec_price: number; + ec_promo_price?: number; + ec_images: string[]; + ec_in_stock: boolean; + ec_rating: number; +}; + +type CartItem = { + productId: string; + name: string; + price: number; + quantity: number; +}; + +const A2UI_COMPONENTS: Record> = { + ProductCarousel, + Cart, +}; + +export function CatalogSurfaceRenderer({surface}: {surface: CatalogSurface}) { + return ( +
+ {surface.components.map((component) => { + const Component = A2UI_COMPONENTS[component.component]; + return Component ? ( + + ) : ( +

+ No sample renderer is registered for {component.component}. +

+ ); + })} +
+ ); +} + +function ProductCarousel({component, controllers}: ComponentBinding) { + const controllerId = component.controllers['productListController']?.controllerId; + const products = controllerId ? asArray(controllers[controllerId]?.['products']) : []; + + return ( +
+
+
+

ProductCarousel

+

Featured products

+
+ controller: {controllerId} +
+
+ {products.map((product) => { + const price = product.ec_promo_price ?? product.ec_price; + return ( +
+ +

{product.ec_brand}

+

{product.ec_name}

+

{product.ec_shortdesc}

+
+ ${price.toFixed(2)} + ★ {product.ec_rating} +
+ {product.ec_in_stock ? 'In stock' : 'Out of stock'} +
+ ); + })} +
+
+ ); +} + +function Cart({component, controllers}: ComponentBinding) { + const controllerId = component.controllers['cartController']?.controllerId; + const items = controllerId ? asArray(controllers[controllerId]?.['items']) : []; + const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); + + return ( + + ); +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} diff --git a/samples/thermidor/schema-contract-react/src/env.ts b/samples/thermidor/schema-contract-react/src/env.ts new file mode 100644 index 00000000000..f8344f500a5 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/env.ts @@ -0,0 +1,30 @@ +const requiredKeys = [ + 'VITE_COVEO_ORGANIZATION_ID', + 'VITE_COVEO_ACCESS_TOKEN', + 'VITE_COVEO_TRACKING_ID', + 'VITE_COVEO_LANGUAGE', + 'VITE_COVEO_COUNTRY', + 'VITE_COVEO_CURRENCY', +] as const; + +function required(key: (typeof requiredKeys)[number]): string { + const value = import.meta.env[key]?.trim(); + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +export function getSampleConfiguration() { + return { + organizationId: required('VITE_COVEO_ORGANIZATION_ID'), + accessToken: required('VITE_COVEO_ACCESS_TOKEN'), + trackingId: required('VITE_COVEO_TRACKING_ID'), + language: required('VITE_COVEO_LANGUAGE'), + country: required('VITE_COVEO_COUNTRY'), + currency: required('VITE_COVEO_CURRENCY'), + endpoint: import.meta.env.DEV + ? window.location.origin + : import.meta.env.VITE_COVEO_ENDPOINT?.trim(), + }; +} diff --git a/samples/thermidor/schema-contract-react/src/main.tsx b/samples/thermidor/schema-contract-react/src/main.tsx new file mode 100644 index 00000000000..656ef2ed1cb --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/main.tsx @@ -0,0 +1,5 @@ +import {createRoot} from 'react-dom/client'; +import App from './App.js'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render(); diff --git a/samples/thermidor/schema-contract-react/src/styles.css b/samples/thermidor/schema-contract-react/src/styles.css new file mode 100644 index 00000000000..e2730e297ab --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/styles.css @@ -0,0 +1,201 @@ +:root { + color: #17202a; + background: #f6f8fb; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input { + font: inherit; +} +.page-shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 56px 0 80px; +} +.hero { + max-width: 760px; +} +.eyebrow { + margin: 0 0 8px; + color: #3867d6; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} +h1, +h2, +h3, +p { + margin-top: 0; +} +h1 { + margin-bottom: 16px; + font-size: clamp(2.2rem, 5vw, 4rem); + line-height: 1.05; +} +.hero > p:last-child { + color: #4d5968; + font-size: 1.1rem; + line-height: 1.6; +} +code { + color: #254da3; +} +.prompt { + display: grid; + gap: 8px; + margin: 36px 0 20px; +} +.prompt label { + font-weight: 700; +} +.prompt div { + display: flex; + gap: 8px; +} +.prompt input { + flex: 1; + min-width: 0; + border: 1px solid #b9c4d4; + border-radius: 8px; + padding: 12px 14px; + background: white; +} +.prompt button { + border: 0; + border-radius: 8px; + padding: 12px 18px; + background: #244db6; + color: white; + font-weight: 700; + cursor: pointer; +} +.prompt button:disabled { + cursor: wait; + opacity: 0.65; +} +.agent-message, +.hint, +.error { + padding: 14px 16px; + border-radius: 8px; + background: #e7edf7; + line-height: 1.5; +} +.error { + color: #9c1c1c; + background: #fbe7e7; +} +.catalog-surface { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 24px; + align-items: start; + margin-top: 24px; +} +.product-carousel, +.cart { + border: 1px solid #dce3ef; + border-radius: 14px; + padding: 22px; + background: white; + box-shadow: 0 10px 30px rgba(30, 48, 79, 0.06); +} +.section-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} +.section-heading h2, +.cart h2 { + margin-bottom: 20px; +} +.controller-id { + color: #5b687a; + font-size: 0.8rem; +} +.product-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.product-card { + overflow: hidden; + border: 1px solid #e1e7f0; + border-radius: 10px; + padding: 14px; +} +.product-card img { + width: 100%; + height: 132px; + border-radius: 6px; + background: #e7ecf3; + object-fit: cover; +} +.product-card h3 { + margin-bottom: 8px; + font-size: 1.05rem; +} +.product-card p:not(.brand) { + min-height: 42px; + margin-bottom: 14px; + color: #596779; + font-size: 0.9rem; + line-height: 1.4; +} +.brand { + margin: 12px 0 6px; + color: #64748b; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} +.product-meta, +.cart-line, +.cart-total { + display: flex; + justify-content: space-between; + gap: 12px; +} +.product-meta { + margin-bottom: 8px; +} +.product-card small { + color: #167544; + font-weight: 700; +} +.cart-line { + padding: 12px 0; + border-bottom: 1px solid #e6ebf2; + line-height: 1.4; +} +.cart-total { + margin-top: 18px; + font-size: 1.1rem; +} +.unsupported-component { + color: #8b5c00; +} +@media (max-width: 760px) { + .catalog-surface { + grid-template-columns: 1fr; + } + .product-grid { + grid-template-columns: 1fr; + } + .section-heading { + display: block; + } + .controller-id { + display: block; + margin-bottom: 16px; + } +} diff --git a/samples/thermidor/schema-contract-react/src/use-controller.ts b/samples/thermidor/schema-contract-react/src/use-controller.ts new file mode 100644 index 00000000000..bfd5424f155 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/use-controller.ts @@ -0,0 +1,20 @@ +import {useCallback, useRef, useSyncExternalStore} from 'react'; +import type {Controller} from '@coveo/thermidor'; + +type StateOf = T extends Controller ? TState : never; + +export function useController>( + factory: () => TController +): [TController, StateOf] { + const controllerRef = useRef(null); + controllerRef.current ??= factory(); + const controller = controllerRef.current; + + const subscribe = useCallback( + (onStoreChange: () => void) => controller.subscribe(onStoreChange), + [controller] + ); + const getSnapshot = useCallback(() => controller.state as StateOf, [controller]); + + return [controller, useSyncExternalStore(subscribe, getSnapshot, getSnapshot)]; +} diff --git a/samples/thermidor/schema-contract-react/tsconfig.json b/samples/thermidor/schema-contract-react/tsconfig.json new file mode 100644 index 00000000000..52cd885c82a --- /dev/null +++ b/samples/thermidor/schema-contract-react/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "Node16", + "moduleResolution": "Node16", + "noEmit": true, + "types": ["vitest/globals", "vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/samples/thermidor/schema-contract-react/vite.config.ts b/samples/thermidor/schema-contract-react/vite.config.ts new file mode 100644 index 00000000000..d39de1f8a93 --- /dev/null +++ b/samples/thermidor/schema-contract-react/vite.config.ts @@ -0,0 +1,22 @@ +import react from '@vitejs/plugin-react'; +import {defineConfig, loadEnv} from 'vite'; + +export default defineConfig(({mode}) => { + const endpoint = loadEnv(mode, process.cwd(), '').VITE_COVEO_ENDPOINT?.trim(); + + return { + plugins: [react()], + server: { + ...(endpoint + ? { + proxy: { + '/rest': { + target: endpoint, + changeOrigin: true, + }, + }, + } + : {}), + }, + }; +}); From 73b0b034c0147e9f1400c18679eb7071a6d670c2 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Mon, 27 Jul 2026 23:02:26 +0200 Subject: [PATCH 2/5] close-2-native --- packages/mock-converse-api/src/server.test.ts | 6 +- .../src/converse/templates/response9.ts | 5 + pnpm-lock.yaml | 103 ++++++--- .../thermidor/schema-contract-react/README.md | 4 +- .../schema-contract-react/package.json | 4 +- .../schema-contract-react/src/App.tsx | 27 ++- .../src/a2ui/adapter.test.ts | 73 ------ .../schema-contract-react/src/a2ui/adapter.ts | 111 --------- .../src/a2ui/components.test.ts | 27 +++ .../src/a2ui/components.tsx | 211 +++++++++--------- .../src/a2ui/controllers.test.ts | 48 ++++ .../src/a2ui/controllers.tsx | 176 +++++++++++++++ .../src/a2ui/surfaces.test.ts | 26 +++ .../src/a2ui/surfaces.tsx | 76 +++++++ 14 files changed, 573 insertions(+), 324 deletions(-) delete mode 100644 samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts delete mode 100644 samples/thermidor/schema-contract-react/src/a2ui/adapter.ts create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/components.test.ts create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/surfaces.test.ts create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/surfaces.tsx diff --git a/packages/mock-converse-api/src/server.test.ts b/packages/mock-converse-api/src/server.test.ts index 6d3122cee0b..164243cdc38 100644 --- a/packages/mock-converse-api/src/server.test.ts +++ b/packages/mock-converse-api/src/server.test.ts @@ -162,7 +162,11 @@ describe('createMockConverseServer', () => { { version: 'v0.9', updateComponents: { - components: [{component: 'ProductCarousel'}, {component: 'Cart'}], + components: [ + {id: 'root', component: 'Column', children: ['featured-products', 'cart']}, + {component: 'ProductCarousel'}, + {component: 'Cart'}, + ], }, }, { diff --git a/packages/platform-mock-api/src/converse/templates/response9.ts b/packages/platform-mock-api/src/converse/templates/response9.ts index 1daee0cff0d..161ef2c8d5b 100644 --- a/packages/platform-mock-api/src/converse/templates/response9.ts +++ b/packages/platform-mock-api/src/converse/templates/response9.ts @@ -34,6 +34,11 @@ const thermidorSchemaCatalogResponseEvents: ConverseEvent[] = [ updateComponents: { surfaceId: 'commerce-catalog-example', components: [ + { + id: 'root', + component: 'Column', + children: ['featured-products', 'cart'], + }, { id: 'featured-products', component: 'ProductCarousel', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae51ec881e9..3590cf96f00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,7 +483,7 @@ importers: devDependencies: '@angular-devkit/build-angular': specifier: 'catalog:' - version: 21.2.17(80d26e843c839fab20396dae6982b00b) + version: 21.2.17(1928eecfa89eed155901caad367f4cb8) '@angular/cli': specifier: 'catalog:' version: 21.2.17(@types/node@24.12.4)(chokidar@5.0.0) @@ -2103,6 +2103,9 @@ importers: samples/thermidor/schema-contract-react: dependencies: + '@copilotkit/a2ui-renderer': + specifier: 1.61.2 + version: 1.61.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@coveo/thermidor': specifier: workspace:* version: link:../../../packages/thermidor @@ -2112,6 +2115,9 @@ importers: react-dom: specifier: 19.2.7 version: 19.2.7(react@19.2.7) + zod: + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@types/react': specifier: 'catalog:' @@ -2210,6 +2216,9 @@ packages: '@75lb/nature': optional: true + '@a2ui/web_core@0.9.0': + resolution: {integrity: sha512-TsMWuEeuVDsScGIGPy/fWIZu+EOBRfhx6KwjKh3VwY1AwysRenQM8zDr8VrSk14Wck/aBgVxk2zWVrMCK2/s6A==} + '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} @@ -3418,6 +3427,17 @@ packages: '@colordx/core@5.5.0': resolution: {integrity: sha512-3PxTH8itZzltK0U9jTwVVnjLXvnDYuq3m+QXsHkENxWiPRh4WaoLcs1SQjqgZ55kS+QyirpH5BVwzP2gMVG6EQ==} + '@copilotkit/a2ui-renderer@1.61.2': + resolution: {integrity: sha512-NrW3R7gd7kL8Co6oirbUkV61m3V9JQ9ugT8YM2jJDC0Zr6ivkaU0g/6jF1aYK/9nmydQMWtIRib5YQvr/Svbdw==} + peerDependencies: + react: 19.2.7 + react-dom: 19.2.7 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@coveo/atomic-component-health-check@2.2.19': resolution: {integrity: sha512-n/llxt8mLYW5P9G8R0ozbrMrqtnDT03oMix7tQ796+F2AFwL6F3FcXyRPVMqsWuTnKH6jOwZ04HcAoP+868Ejw==} hasBin: true @@ -6490,6 +6510,9 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@prettier-apex/apex-ast-serializer-darwin-arm64@2.2.6': resolution: {integrity: sha512-XzrGnEVQq/JH/rKPktpdL8/agocjDCnSrY4MuHxMs5V3OnV/4MJdMHp0frQhqOjbhnUIjewypX5XWcVi0xqRBQ==} cpu: [arm64] @@ -9875,6 +9898,9 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -16671,9 +16697,6 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -16687,6 +16710,13 @@ snapshots: lodash: 4.18.1 typical: 7.3.0 + '@a2ui/web_core@0.9.0': + dependencies: + '@preact/signals-core': 1.14.4 + date-fns: 4.4.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + '@acemir/cssom@0.9.31': {} '@actions/github@9.1.1': @@ -16832,13 +16862,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@21.2.17(80d26e843c839fab20396dae6982b00b)': + '@angular-devkit/build-angular@21.2.17(1928eecfa89eed155901caad367f4cb8)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.17(chokidar@5.0.0) '@angular-devkit/build-webpack': 0.2102.17(chokidar@5.0.0)(webpack-dev-server@5.2.5(tslib@2.8.1)(webpack@5.105.2(esbuild@0.28.1)(postcss@8.5.12)))(webpack@5.105.2(esbuild@0.28.1)(postcss@8.5.12)) '@angular-devkit/core': 21.2.17(chokidar@5.0.0) - '@angular/build': 21.2.17(6133a8bd1a48a56825f649b47a4a697e) + '@angular/build': 21.2.17(2579aedd5157518c28138f5f823a7c91) '@angular/compiler-cli': 21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3) '@babel/core': 7.29.7 '@babel/generator': 7.29.1 @@ -16965,7 +16995,7 @@ snapshots: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) tslib: 2.8.1 - '@angular/build@21.2.17(5b911479a7812c42761d62528d4640b4)': + '@angular/build@21.2.17(2579aedd5157518c28138f5f823a7c91)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.17(chokidar@5.0.0) @@ -16975,7 +17005,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@24.12.4) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.5 esbuild: 0.28.1 @@ -16988,7 +17018,7 @@ snapshots: parse5-html-rewriting-stream: 8.0.0 picomatch: 4.0.4 piscina: 5.2.0 - rolldown: 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + rolldown: 1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) sass: 1.97.3 semver: 7.7.4 source-map-support: 0.5.21 @@ -16996,17 +17026,18 @@ snapshots: tslib: 2.8.1 typescript: 6.0.3 undici: 7.24.4 - vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - less: 4.6.7 + less: 4.4.2 lmdb: 3.5.1 - postcss: 8.5.16 + ng-packagr: 20.3.2(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3))(tailwindcss@4.3.2)(tslib@2.8.1)(typescript@6.0.3) + postcss: 8.5.12 tailwindcss: 4.3.2 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -17022,7 +17053,7 @@ snapshots: - tsx - yaml - '@angular/build@21.2.17(6133a8bd1a48a56825f649b47a4a697e)': + '@angular/build@21.2.17(5b911479a7812c42761d62528d4640b4)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.17(chokidar@5.0.0) @@ -17032,7 +17063,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@24.12.4) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.5 esbuild: 0.28.1 @@ -17045,7 +17076,7 @@ snapshots: parse5-html-rewriting-stream: 8.0.0 picomatch: 4.0.4 piscina: 5.2.0 - rolldown: 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + rolldown: 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) sass: 1.97.3 semver: 7.7.4 source-map-support: 0.5.21 @@ -17053,18 +17084,17 @@ snapshots: tslib: 2.8.1 typescript: 6.0.3 undici: 7.24.4 - vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)) '@angular/platform-server': 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@21.2.17)(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/animations@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) - less: 4.4.2 + less: 4.6.7 lmdb: 3.5.1 - ng-packagr: 20.3.2(@angular/compiler-cli@21.2.17(@angular/compiler@21.2.17)(typescript@6.0.3))(tailwindcss@4.3.2)(tslib@2.8.1)(typescript@6.0.3) - postcss: 8.5.12 + postcss: 8.5.16 tailwindcss: 4.3.2 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.4.2)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.12.4)(typescript@6.0.3))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(less@4.6.7)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -18562,6 +18592,17 @@ snapshots: '@colordx/core@5.5.0': {} + '@copilotkit/a2ui-renderer@1.61.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@a2ui/web_core': 0.9.0 + clsx: 2.1.1 + lit: 3.3.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@coveo/atomic-component-health-check@2.2.19': dependencies: chalk: 4.1.2 @@ -21557,6 +21598,8 @@ snapshots: '@popperjs/core@2.11.8': {} + '@preact/signals-core@1.14.4': {} + '@prettier-apex/apex-ast-serializer-darwin-arm64@2.2.6': optional: true @@ -21830,9 +21873,9 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.1.4': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -25399,6 +25442,8 @@ snapshots: dataloader@1.4.0: {} + date-fns@4.4.0: {} + dateformat@4.6.3: {} dayjs@1.11.21: {} @@ -28554,7 +28599,7 @@ snapshots: tinyglobby: 0.2.17 unbash: 4.0.2 yaml: 2.9.0 - zod: 4.4.3 + zod: 4.3.6 koa-bodyparser@4.4.1: dependencies: @@ -31625,7 +31670,7 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + rolldown@1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0): dependencies: '@oxc-project/types': 0.113.0 '@rolldown/pluginutils': 1.0.0-rc.4 @@ -31640,7 +31685,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.4 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.4 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.4 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.4 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.4 transitivePeerDependencies: @@ -34149,6 +34194,10 @@ snapshots: yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.3.6): dependencies: zod: 4.3.6 @@ -34159,8 +34208,6 @@ snapshots: zod@4.3.6: {} - zod@4.4.3: {} - zone.js@0.15.1: {} zwitch@2.0.4: {} diff --git a/samples/thermidor/schema-contract-react/README.md b/samples/thermidor/schema-contract-react/README.md index d0a010d4a42..d038c7e1cef 100644 --- a/samples/thermidor/schema-contract-react/README.md +++ b/samples/thermidor/schema-contract-react/README.md @@ -2,9 +2,9 @@ This sample proves the client-side path for the v0.9 Thermidor Commerce Catalog contract: -`ConverseController` → normalized Thermidor `Activity` → sample-owned A2-UI adapter → sample-owned component catalog. +`ConverseController` → normalized Thermidor `Activity` → raw A2-UI messages → CopilotKit A2-UI renderer/catalog. -Thermidor only exposes opaque activity `kind` and `payload` values. The sample is the layer that recognizes the `a2ui-surface` kind and renders the `ProductCarousel` and `Cart` components advertised by the schema catalog. +Thermidor only exposes opaque activity `kind` and `payload` values. The sample passes the `a2ui-surface` operations to CopilotKit unchanged. Its local `ProductCarousel` and `Cart` components create their advertised controllers from `updateComponents`, hydrate them from `updateDataModel`, and subscribe to later data-model updates. CopilotKit provides only renderer and catalog state; it does not replace Thermidor's conversational endpoint or runtime. ## Run with the contract mock diff --git a/samples/thermidor/schema-contract-react/package.json b/samples/thermidor/schema-contract-react/package.json index 0a63d0a4289..d6a30a162fd 100644 --- a/samples/thermidor/schema-contract-react/package.json +++ b/samples/thermidor/schema-contract-react/package.json @@ -11,9 +11,11 @@ "test": "vitest run" }, "dependencies": { + "@copilotkit/a2ui-renderer": "1.61.2", "@coveo/thermidor": "workspace:*", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "zod": "3.25.76" }, "devDependencies": { "@types/react": "catalog:", diff --git a/samples/thermidor/schema-contract-react/src/App.tsx b/samples/thermidor/schema-contract-react/src/App.tsx index cb878792d97..900f6de6726 100644 --- a/samples/thermidor/schema-contract-react/src/App.tsx +++ b/samples/thermidor/schema-contract-react/src/App.tsx @@ -1,4 +1,4 @@ -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useMemo, useRef, useState} from 'react'; import { buildConverseController, buildGenerativeInterface, @@ -6,14 +6,24 @@ import { type ConverseController, type GenerativeInterface, } from '@coveo/thermidor'; -import {CatalogSurfaceRenderer} from './a2ui/components.js'; -import {toCatalogSurfaces} from './a2ui/adapter.js'; +import {A2UIProvider} from '@copilotkit/a2ui-renderer'; +import {thermidorCatalog} from './a2ui/components.js'; +import {ThermidorControllerProvider} from './a2ui/controllers.js'; +import {getA2UIMessages, ThermidorA2UISurfaces} from './a2ui/surfaces.js'; import {getSampleConfiguration} from './env.js'; import {useController} from './use-controller.js'; const CONTRACT_PROMPT = 'Show the Thermidor catalog'; export default function App() { + return ( + + + + ); +} + +function ContractSample() { const engineRef = useRef(null); engineRef.current ??= new Engine({ configuration: getSampleConfiguration(), @@ -28,7 +38,10 @@ export default function App() { ); const [prompt, setPrompt] = useState(CONTRACT_PROMPT); const turn = state.activeTurn; - const surfaces = toCatalogSurfaces(turn?.agentResponse?.activities ?? []); + const a2uiMessages = useMemo( + () => getA2UIMessages(turn?.agentResponse?.activities ?? []), + [turn?.agentResponse?.activities] + ); useEffect(() => { return () => { @@ -74,9 +87,9 @@ export default function App() {

))} {turn?.status === 'error' &&

{turn.error}

} - {surfaces.map((surface) => ( - - ))} + + + {!turn &&

Run the pre-filled prompt to render the schema example.

} ); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts deleted file mode 100644 index 3eaa0b8a217..00000000000 --- a/samples/thermidor/schema-contract-react/src/a2ui/adapter.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {describe, expect, it} from 'vitest'; -import {toCatalogSurfaces} from './adapter.js'; - -describe('toCatalogSurfaces', () => { - it('keeps advertised components bound to their externally owned controller state', () => { - const [surface] = toCatalogSurfaces([ - { - id: 'catalog', - kind: 'a2ui-surface', - replace: true, - payload: { - a2ui_operations: [ - { - version: 'v0.9', - createSurface: {surfaceId: 'catalog', catalogId: 'catalog.json'}, - }, - { - version: 'v0.9', - updateComponents: { - surfaceId: 'catalog', - components: [ - { - id: 'featured-products', - component: 'ProductCarousel', - controllers: { - productListController: { - controllerId: 'featured-products', - controllerSchema: 'product-list.schema.json', - }, - }, - }, - ], - }, - }, - { - version: 'v0.9', - updateDataModel: { - surfaceId: 'catalog', - value: {controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}}, - }, - }, - ], - }, - }, - ]); - - expect(surface).toMatchObject({ - id: 'catalog', - catalogId: 'catalog.json', - components: [{component: 'ProductCarousel'}], - controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}, - }); - }); - - it('resets prior surfaces when an activity declares replacement semantics', () => { - const surfaces = toCatalogSurfaces([ - { - id: 'old', - kind: 'a2ui-surface', - replace: false, - payload: {a2ui_operations: [{version: 'v0.9', createSurface: {surfaceId: 'old'}}]}, - }, - { - id: 'new', - kind: 'a2ui-surface', - replace: true, - payload: {a2ui_operations: [{version: 'v0.9', createSurface: {surfaceId: 'new'}}]}, - }, - ]); - - expect(surfaces.map(({id}) => id)).toEqual(['new']); - }); -}); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/adapter.ts b/samples/thermidor/schema-contract-react/src/a2ui/adapter.ts deleted file mode 100644 index 7cecda710e5..00000000000 --- a/samples/thermidor/schema-contract-react/src/a2ui/adapter.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type {Activity} from '@coveo/thermidor'; - -type ControllerAdvertisement = { - controllerId: string; - controllerSchema: string; -}; - -type CatalogComponent = { - id: string; - component: string; - controllers: Record; -}; - -export type CatalogSurface = { - id: string; - catalogId: string; - components: CatalogComponent[]; - controllers: Record>; -}; - -type MutableCatalogSurface = CatalogSurface; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function getOperations(activity: Activity): Record[] { - if (activity.kind !== 'a2ui-surface' || !isRecord(activity.payload)) { - return []; - } - - const operations = activity.payload['a2ui_operations']; - return Array.isArray(operations) ? operations.filter(isRecord) : []; -} - -function getOrCreateSurface( - surfaces: Map, - id: string, - catalogId = '' -): MutableCatalogSurface { - const existing = surfaces.get(id); - if (existing) { - return existing; - } - - const surface = {id, catalogId, components: [], controllers: {}}; - surfaces.set(id, surface); - return surface; -} - -/** - * This is the sample's A2-UI boundary. It interprets only the v0.9 catalog - * operations and keeps the application-facing result independent of Thermidor. - */ -export function toCatalogSurfaces(activities: Activity[]): CatalogSurface[] { - const surfaces = new Map(); - - for (const activity of activities) { - const operations = getOperations(activity); - if (operations.length === 0) { - continue; - } - if (activity.replace) { - surfaces.clear(); - } - - for (const operation of operations) { - const createSurface = operation['createSurface']; - if (isRecord(createSurface) && typeof createSurface['surfaceId'] === 'string') { - getOrCreateSurface( - surfaces, - createSurface['surfaceId'], - typeof createSurface['catalogId'] === 'string' ? createSurface['catalogId'] : '' - ); - continue; - } - - const updateComponents = operation['updateComponents']; - if (isRecord(updateComponents) && typeof updateComponents['surfaceId'] === 'string') { - const surface = getOrCreateSurface(surfaces, updateComponents['surfaceId']); - surface.components = Array.isArray(updateComponents['components']) - ? updateComponents['components'].filter(isCatalogComponent) - : []; - continue; - } - - const updateDataModel = operation['updateDataModel']; - if (!isRecord(updateDataModel) || typeof updateDataModel['surfaceId'] !== 'string') { - continue; - } - const value = updateDataModel['value']; - const controllers = - isRecord(value) && isRecord(value['controllers']) ? value['controllers'] : {}; - const surface = getOrCreateSurface(surfaces, updateDataModel['surfaceId']); - surface.controllers = Object.fromEntries( - Object.entries(controllers).filter(([, state]) => isRecord(state)) - ) as Record>; - } - } - - return [...surfaces.values()]; -} - -function isCatalogComponent(value: unknown): value is CatalogComponent { - return ( - isRecord(value) && - typeof value['id'] === 'string' && - typeof value['component'] === 'string' && - isRecord(value['controllers']) - ); -} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts new file mode 100644 index 00000000000..97e975d8530 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts @@ -0,0 +1,27 @@ +import {describe, expect, it} from 'vitest'; +import {thermidorCatalogDefinitions} from './components.js'; + +describe('thermidorCatalogDefinitions', () => { + it('accepts the controller advertisements supplied by the catalog message', () => { + expect( + thermidorCatalogDefinitions.ProductCarousel.props.safeParse({ + controllers: { + productListController: { + controllerId: 'featured-products', + controllerSchema: 'product-list.schema.json', + }, + }, + }).success + ).toBe(true); + expect( + thermidorCatalogDefinitions.Cart.props.safeParse({ + controllers: { + cartController: { + controllerId: 'shopping-cart', + controllerSchema: 'cart.schema.json', + }, + }, + }).success + ).toBe(true); + }); +}); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx index 9fe1b840706..28fb7aa525c 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx @@ -1,112 +1,121 @@ -import type {ComponentType} from 'react'; -import type {CatalogSurface} from './adapter.js'; +import { + createCatalog, + type CatalogDefinitions, + type CatalogRenderers, +} from '@copilotkit/a2ui-renderer'; +import {z} from 'zod'; +import {useAdvertisedController} from './controllers.js'; -type ComponentBinding = { - component: CatalogSurface['components'][number]; - controllers: CatalogSurface['controllers']; -}; +export const THERMIDOR_CATALOG_ID = 'https://schema.thermidor.coveo.com/a2-ui/catalog.json'; -type Product = { - permanentid: string; - ec_name: string; - ec_shortdesc: string; - ec_brand: string; - ec_price: number; - ec_promo_price?: number; - ec_images: string[]; - ec_in_stock: boolean; - ec_rating: number; -}; +const controllerAdvertisement = z.object({ + controllerId: z.string(), + controllerSchema: z.string(), +}); -type CartItem = { - productId: string; - name: string; - price: number; - quantity: number; -}; +const product = z.object({ + permanentid: z.string(), + ec_name: z.string(), + ec_shortdesc: z.string(), + ec_brand: z.string(), + ec_price: z.number(), + ec_promo_price: z.number().optional(), + ec_images: z.array(z.string()), + ec_in_stock: z.boolean(), + ec_rating: z.number(), +}); -const A2UI_COMPONENTS: Record> = { - ProductCarousel, - Cart, -}; +const cartItem = z.object({ + productId: z.string(), + name: z.string(), + price: z.number(), + quantity: z.number(), +}); -export function CatalogSurfaceRenderer({surface}: {surface: CatalogSurface}) { - return ( -
- {surface.components.map((component) => { - const Component = A2UI_COMPONENTS[component.component]; - return Component ? ( - - ) : ( -

- No sample renderer is registered for {component.component}. -

- ); - })} -
- ); -} +type Product = z.infer; +type CartItem = z.infer; -function ProductCarousel({component, controllers}: ComponentBinding) { - const controllerId = component.controllers['productListController']?.controllerId; - const products = controllerId ? asArray(controllers[controllerId]?.['products']) : []; +export const thermidorCatalogDefinitions = { + ProductCarousel: { + description: 'A responsive product carousel backed by a product-list controller.', + props: z.object({ + controllers: z.object({productListController: controllerAdvertisement}), + }), + }, + Cart: { + description: 'A shopping-cart summary backed by a cart controller.', + props: z.object({ + controllers: z.object({cartController: controllerAdvertisement}), + }), + }, +} satisfies CatalogDefinitions; - return ( -
-
-
-

ProductCarousel

-

Featured products

-
- controller: {controllerId} -
-
- {products.map((product) => { - const price = product.ec_promo_price ?? product.ec_price; - return ( -
- -

{product.ec_brand}

-

{product.ec_name}

-

{product.ec_shortdesc}

-
- ${price.toFixed(2)} - ★ {product.ec_rating} -
- {product.ec_in_stock ? 'In stock' : 'Out of stock'} -
- ); - })} -
-
- ); -} - -function Cart({component, controllers}: ComponentBinding) { - const controllerId = component.controllers['cartController']?.controllerId; - const items = controllerId ? asArray(controllers[controllerId]?.['items']) : []; - const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); +const renderers = { + ProductCarousel: ({props}) => { + const state = useAdvertisedController<{products?: Product[]}>( + props.controllers.productListController + ); + const products = Array.isArray(state.products) ? state.products : []; - return ( - - ); -} +
+ {products.map((product) => { + const price = product.ec_promo_price ?? product.ec_price; + return ( +
+ +

{product.ec_brand}

+

{product.ec_name}

+

{product.ec_shortdesc}

+
+ ${price.toFixed(2)} + ★ {product.ec_rating} +
+ {product.ec_in_stock ? 'In stock' : 'Out of stock'} +
+ ); + })} +
+ + ); + }, + Cart: ({props}) => { + const state = useAdvertisedController<{items?: CartItem[]}>(props.controllers.cartController); + const items = Array.isArray(state.items) ? state.items : []; + const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); + + return ( + + ); + }, +} satisfies CatalogRenderers; -function asArray(value: unknown): T[] { - return Array.isArray(value) ? (value as T[]) : []; -} +export const thermidorCatalog = createCatalog(thermidorCatalogDefinitions, renderers, { + catalogId: THERMIDOR_CATALOG_ID, + includeBasicCatalog: true, +}); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts new file mode 100644 index 00000000000..8b97cd6b75a --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it, vi} from 'vitest'; +import {ThermidorControllerRegistry} from './controllers.js'; + +describe('ThermidorControllerRegistry', () => { + it('hydrates a component-created controller and notifies it about subsequent data-model updates', () => { + const registry = new ThermidorControllerRegistry(); + const advertisement = { + controllerId: 'featured-products', + controllerSchema: 'product-list.schema.json', + }; + registry.synchronize([ + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'catalog', + value: {controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}}, + }, + }, + ]); + + const controller = registry.getOrCreate(advertisement); + expect(controller.snapshot).toEqual({products: [{permanentid: 'p1'}]}); + + const listener = vi.fn(); + const unsubscribe = controller.subscribe(listener); + registry.synchronize([ + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'catalog', + value: {controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}}, + }, + }, + { + version: 'v0.9', + updateDataModel: { + surfaceId: 'catalog', + path: '/controllers/featured-products/products', + value: [{permanentid: 'p2'}], + }, + }, + ]); + + expect(controller.snapshot).toEqual({products: [{permanentid: 'p2'}]}); + expect(listener).toHaveBeenCalledOnce(); + unsubscribe(); + }); +}); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx b/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx new file mode 100644 index 00000000000..f57a840e60f --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx @@ -0,0 +1,176 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useSyncExternalStore, + type ReactNode, +} from 'react'; + +export type ControllerAdvertisement = { + controllerId: string; + controllerSchema: string; +}; + +type A2UIMessage = Record; +type ControllerState = Record; + +class AdvertisedController { + private listeners = new Set<() => void>(); + private serializedState: string; + + public constructor( + public readonly advertisement: ControllerAdvertisement, + private state: ControllerState + ) { + this.serializedState = JSON.stringify(state); + } + + public get snapshot(): ControllerState { + return this.state; + } + + public hydrate(state: ControllerState): void { + const serializedState = JSON.stringify(state); + if (serializedState === this.serializedState) { + return; + } + + this.state = state; + this.serializedState = serializedState; + this.listeners.forEach((listener) => listener()); + } + + public subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +export class ThermidorControllerRegistry { + private controllers = new Map(); + private controllerStates = new Map(); + + public synchronize(messages: A2UIMessage[]): void { + const dataModels = new Map(); + + for (const message of messages) { + const updateDataModel = message['updateDataModel']; + if (!isRecord(updateDataModel) || typeof updateDataModel['surfaceId'] !== 'string') { + continue; + } + + const surfaceId = updateDataModel['surfaceId']; + dataModels.set( + surfaceId, + applyDataModelUpdate( + dataModels.get(surfaceId), + typeof updateDataModel['path'] === 'string' ? updateDataModel['path'] : '/', + updateDataModel['value'] + ) + ); + } + + const controllerStates = new Map(); + for (const dataModel of dataModels.values()) { + const advertisedStates = + isRecord(dataModel) && isRecord(dataModel['controllers']) ? dataModel['controllers'] : {}; + for (const [controllerId, state] of Object.entries(advertisedStates)) { + if (isRecord(state)) { + controllerStates.set(controllerId, state); + } + } + } + + this.controllerStates = controllerStates; + for (const controller of this.controllers.values()) { + controller.hydrate(controllerStates.get(controller.advertisement.controllerId) ?? {}); + } + } + + public getOrCreate(advertisement: ControllerAdvertisement): AdvertisedController { + const existing = this.controllers.get(advertisement.controllerId); + if (existing && existing.advertisement.controllerSchema === advertisement.controllerSchema) { + return existing; + } + + const controller = new AdvertisedController( + advertisement, + this.controllerStates.get(advertisement.controllerId) ?? {} + ); + this.controllers.set(advertisement.controllerId, controller); + return controller; + } +} + +const ControllerRegistryContext = createContext(null); + +export function ThermidorControllerProvider({ + children, + messages, +}: { + children: ReactNode; + messages: A2UIMessage[]; +}) { + const registryRef = useRef(null); + registryRef.current ??= new ThermidorControllerRegistry(); + const serializedMessages = useMemo(() => JSON.stringify(messages), [messages]); + + useEffect(() => { + registryRef.current!.synchronize(JSON.parse(serializedMessages) as A2UIMessage[]); + }, [serializedMessages]); + + return ( + + {children} + + ); +} + +export function useAdvertisedController( + advertisement: ControllerAdvertisement +): T { + const registry = useContext(ControllerRegistryContext); + if (!registry) { + throw new Error('Advertised controllers require ThermidorControllerProvider.'); + } + + const controller = useMemo( + () => registry.getOrCreate(advertisement), + [advertisement.controllerId, advertisement.controllerSchema, registry] + ); + const subscribe = useCallback( + (listener: () => void) => controller.subscribe(listener), + [controller] + ); + const getSnapshot = useCallback(() => controller.snapshot as T, [controller]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +function applyDataModelUpdate(dataModel: unknown, path: string, value: unknown): unknown { + const segments = path + .split('/') + .filter(Boolean) + .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); + if (segments.length === 0) { + return value; + } + + const root = isRecord(dataModel) ? {...dataModel} : {}; + let target: Record = root; + for (const segment of segments.slice(0, -1)) { + const current = target[segment]; + const next = isRecord(current) ? {...current} : {}; + target[segment] = next; + target = next; + } + target[segments.at(-1)!] = value; + return root; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/surfaces.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/surfaces.test.ts new file mode 100644 index 00000000000..266a256e6ae --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/surfaces.test.ts @@ -0,0 +1,26 @@ +import {describe, expect, it} from 'vitest'; +import {getA2UIMessages} from './surfaces.js'; + +describe('getA2UIMessages', () => { + it('passes raw A2-UI operations through and honors activity replacement', () => { + const previousOperation = {version: 'v0.9', createSurface: {surfaceId: 'old'}}; + const replacementOperation = {version: 'v0.9', createSurface: {surfaceId: 'catalog'}}; + + expect( + getA2UIMessages([ + { + id: 'old', + kind: 'a2ui-surface', + replace: false, + payload: {a2ui_operations: [previousOperation]}, + }, + { + id: 'catalog', + kind: 'a2ui-surface', + replace: true, + payload: {a2ui_operations: [replacementOperation]}, + }, + ]) + ).toEqual([replacementOperation]); + }); +}); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/surfaces.tsx b/samples/thermidor/schema-contract-react/src/a2ui/surfaces.tsx new file mode 100644 index 00000000000..8ee4bd4038b --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/surfaces.tsx @@ -0,0 +1,76 @@ +import {useEffect, useMemo, useRef} from 'react'; +import {A2UIRenderer, useA2UI} from '@copilotkit/a2ui-renderer'; +import type {Activity} from '@coveo/thermidor'; + +type A2UIMessage = Record; + +/** Extracts opaque A2-UI messages without translating their protocol payloads. */ +export function getA2UIMessages(activities: Activity[]): A2UIMessage[] { + const messages: A2UIMessage[] = []; + + for (const activity of activities) { + if (activity.kind !== 'a2ui-surface' || !isRecord(activity.payload)) { + continue; + } + const operations = activity.payload['a2ui_operations']; + if (!Array.isArray(operations)) { + continue; + } + if (activity.replace) { + messages.length = 0; + } + messages.push(...operations.filter(isRecord)); + } + + return messages; +} + +export function ThermidorA2UISurfaces({messages}: {messages: A2UIMessage[]}) { + const {clearSurfaces, processMessages} = useA2UI(); + const serializedMessages = useMemo(() => JSON.stringify(messages), [messages]); + const surfaceIds = useMemo(() => getSurfaceIds(messages), [messages]); + const actionsRef = useRef({clearSurfaces, processMessages}); + actionsRef.current = {clearSurfaces, processMessages}; + + useEffect(() => { + const {clearSurfaces, processMessages} = actionsRef.current; + clearSurfaces(); + if (serializedMessages !== '[]') { + processMessages(JSON.parse(serializedMessages) as A2UIMessage[]); + } + }, [serializedMessages]); + + return ( + <> + {surfaceIds.map((surfaceId) => ( +
+ +
+ ))} + + ); +} + +function getSurfaceIds(messages: A2UIMessage[]): string[] { + const surfaceIds = new Set(); + for (const message of messages) { + const createSurface = message['createSurface']; + if (isRecord(createSurface) && typeof createSurface['surfaceId'] === 'string') { + surfaceIds.add(createSurface['surfaceId']); + continue; + } + const deleteSurface = message['deleteSurface']; + if (isRecord(deleteSurface) && typeof deleteSurface['surfaceId'] === 'string') { + surfaceIds.delete(deleteSurface['surfaceId']); + } + } + return [...surfaceIds]; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} From 6a80abe751564e0ff2657012965fbc999187cd8a Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Mon, 27 Jul 2026 23:41:17 +0200 Subject: [PATCH 3/5] generative-ctrls --- packages/mock-converse-api/src/server.test.ts | 20 +- .../src/converse/templates/response9.ts | 102 +++++----- .../api/generative/generative-runtime.test.ts | 8 +- .../api/generative/generative-runtime.ts | 9 + .../features/generative/generative-actions.ts | 3 + .../features/generative/generative-slice.ts | 7 + .../features/generative/generative-types.ts | 7 + .../converse/converse-controller.ts | 3 + .../thermidor/src/public/controllers/index.ts | 9 + .../remote/remote-controller.test.ts | 91 +++++++++ .../controllers/remote/remote-controller.ts | 130 +++++++++++++ .../thermidor/schema-contract-react/README.md | 4 +- .../schema-contract-react/src/App.tsx | 77 ++++---- .../src/a2ui/components.tsx | 148 ++++++++------- .../src/a2ui/controllers.test.ts | 55 ++---- .../src/a2ui/controllers.tsx | 179 ++---------------- 16 files changed, 474 insertions(+), 378 deletions(-) create mode 100644 packages/thermidor/src/public/controllers/remote/remote-controller.test.ts create mode 100644 packages/thermidor/src/public/controllers/remote/remote-controller.ts diff --git a/packages/mock-converse-api/src/server.test.ts b/packages/mock-converse-api/src/server.test.ts index 164243cdc38..c7610273001 100644 --- a/packages/mock-converse-api/src/server.test.ts +++ b/packages/mock-converse-api/src/server.test.ts @@ -144,6 +144,7 @@ describe('createMockConverseServer', () => { (frame) => JSON.parse(frame.split('\n')[1].replace('data:', '')) as Record ); const activity = events.find((event) => event['type'] === 'ACTIVITY_SNAPSHOT'); + const stateSnapshot = events.find((event) => event['type'] === 'STATE_SNAPSHOT'); expect(activity).toMatchObject({ type: 'ACTIVITY_SNAPSHOT', @@ -169,29 +170,18 @@ describe('createMockConverseServer', () => { ], }, }, - { - version: 'v0.9', - updateDataModel: { - surfaceId: 'commerce-catalog-example', - }, - }, ], }, }); - const content = activity?.['content'] as {a2ui_operations: Array>}; - const dataModelOperation = content.a2ui_operations.find( - (operation) => 'updateDataModel' in operation - )!['updateDataModel'] as { - value: { - controllers: Record; - }; + const snapshot = stateSnapshot?.['snapshot'] as { + controllers: Record; }; - expect(dataModelOperation.value.controllers['featured-products'].products).toEqual( + expect(snapshot.controllers['featured-products'].products).toEqual( expect.arrayContaining([expect.objectContaining({permanentid: 'trail-running-shoes-001'})]) ); - expect(dataModelOperation.value.controllers['shopping-cart'].items).toEqual( + expect(snapshot.controllers['shopping-cart'].items).toEqual( expect.arrayContaining([ expect.objectContaining({productId: 'trail-running-shoes-001', quantity: 1}), ]) diff --git a/packages/platform-mock-api/src/converse/templates/response9.ts b/packages/platform-mock-api/src/converse/templates/response9.ts index 161ef2c8d5b..8ed8a22a309 100644 --- a/packages/platform-mock-api/src/converse/templates/response9.ts +++ b/packages/platform-mock-api/src/converse/templates/response9.ts @@ -2,12 +2,59 @@ import { ActivitySnapshot, RunFinished, RunStarted, + StateSnapshot, TurnComplete, TurnStarted, textMessage, type ConverseEvent, } from '../events.js'; +const thermidorCatalogState = { + controllers: { + 'featured-products': { + products: [ + { + permanentid: 'trail-running-shoes-001', + ec_name: 'Peak Trail Running Shoes', + ec_shortdesc: 'Responsive trail shoes for everyday adventures.', + ec_brand: 'Thermidor Outdoor', + ec_category: ['Footwear', 'Trail Running'], + ec_price: 129.99, + ec_promo_price: 99.99, + ec_images: ['https://images.example.com/products/trail-running-shoes-001.jpg'], + ec_in_stock: true, + ec_rating: 4.7, + clickUri: '/products/trail-running-shoes-001', + additionalFields: {}, + }, + { + permanentid: 'summit-pack-020', + ec_name: 'Summit Day Pack', + ec_shortdesc: 'A compact, weather-ready 20 L day pack.', + ec_brand: 'Thermidor Outdoor', + ec_category: ['Bags', 'Day Packs'], + ec_price: 89.99, + ec_images: ['https://images.example.com/products/summit-pack-020.jpg'], + ec_in_stock: true, + ec_rating: 4.4, + clickUri: '/products/summit-pack-020', + additionalFields: {}, + }, + ], + }, + 'shopping-cart': { + items: [ + { + productId: 'trail-running-shoes-001', + name: 'Peak Trail Running Shoes', + price: 99.99, + quantity: 1, + }, + ], + }, + }, +}; + const thermidorSchemaCatalogResponseEvents: ConverseEvent[] = [ TurnStarted(), RunStarted(), @@ -15,6 +62,7 @@ const thermidorSchemaCatalogResponseEvents: ConverseEvent[] = [ 'thermidor-schema-catalog-message', 'Here are featured products and the current cart from the Thermidor catalog contract.' ), + StateSnapshot(thermidorCatalogState), ActivitySnapshot({ messageId: 'commerce-catalog-example', activityType: 'a2ui-surface', @@ -26,7 +74,6 @@ const thermidorSchemaCatalogResponseEvents: ConverseEvent[] = [ createSurface: { surfaceId: 'commerce-catalog-example', catalogId: 'https://schema.thermidor.coveo.com/a2-ui/catalog.json', - sendDataModel: true, }, }, { @@ -64,59 +111,6 @@ const thermidorSchemaCatalogResponseEvents: ConverseEvent[] = [ ], }, }, - { - version: 'v0.9', - updateDataModel: { - surfaceId: 'commerce-catalog-example', - value: { - controllers: { - 'featured-products': { - products: [ - { - permanentid: 'trail-running-shoes-001', - ec_name: 'Peak Trail Running Shoes', - ec_shortdesc: 'Responsive trail shoes for everyday adventures.', - ec_brand: 'Thermidor Outdoor', - ec_category: ['Footwear', 'Trail Running'], - ec_price: 129.99, - ec_promo_price: 99.99, - ec_images: [ - 'https://images.example.com/products/trail-running-shoes-001.jpg', - ], - ec_in_stock: true, - ec_rating: 4.7, - clickUri: '/products/trail-running-shoes-001', - additionalFields: {}, - }, - { - permanentid: 'summit-pack-020', - ec_name: 'Summit Day Pack', - ec_shortdesc: 'A compact, weather-ready 20 L day pack.', - ec_brand: 'Thermidor Outdoor', - ec_category: ['Bags', 'Day Packs'], - ec_price: 89.99, - ec_images: ['https://images.example.com/products/summit-pack-020.jpg'], - ec_in_stock: true, - ec_rating: 4.4, - clickUri: '/products/summit-pack-020', - additionalFields: {}, - }, - ], - }, - 'shopping-cart': { - items: [ - { - productId: 'trail-running-shoes-001', - name: 'Peak Trail Running Shoes', - price: 99.99, - quantity: 1, - }, - ], - }, - }, - }, - }, - }, ], }, }), diff --git a/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts b/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts index e67684d5ca1..075e166b880 100644 --- a/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts +++ b/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts @@ -45,6 +45,7 @@ function createMockStatePort(): GenerativeStatePort { startMessage: vi.fn(), appendMessageDelta: vi.fn(), appendActivity: vi.fn(), + setStateSnapshot: vi.fn(), startToolCall: vi.fn(), appendToolCallArgs: vi.fn(), completeToolCall: vi.fn(), @@ -636,17 +637,20 @@ describe('GenerativeRuntime', () => { expect(config.statePort.completeTurn).toHaveBeenCalledWith('generated-id-1'); }); - it('ignores STATE_SNAPSHOT events', async () => { + it('stores STATE_SNAPSHOT events as opaque Engine state', async () => { const config = createMockConfig(); const engine = createMockEngine(); + const snapshot = {controllers: {'featured-products': {products: []}}}; setupSuccessfulStream([ - {type: 'STATE_SNAPSHOT'} as ConversationStreamEvent, + {type: 'STATE_SNAPSHOT', snapshot} as ConversationStreamEvent, {type: 'turn_complete'} as ConversationStreamEvent, ]); const runtime = GenerativeRuntime.getInstance(engine, 'state-snap', config); await runtime.submit('Hello'); + expect(config.statePort.initAgentResponse).toHaveBeenCalledWith('generated-id-1'); + expect(config.statePort.setStateSnapshot).toHaveBeenCalledWith('generated-id-1', snapshot); expect(config.statePort.completeTurn).toHaveBeenCalled(); }); diff --git a/packages/thermidor/src/internal/api/generative/generative-runtime.ts b/packages/thermidor/src/internal/api/generative/generative-runtime.ts index f36c1b334a5..1421d1e855e 100644 --- a/packages/thermidor/src/internal/api/generative/generative-runtime.ts +++ b/packages/thermidor/src/internal/api/generative/generative-runtime.ts @@ -24,6 +24,7 @@ export interface GenerativeStatePort { startMessage(turnId: string, role: string): void; appendMessageDelta(turnId: string, delta: string): void; appendActivity(turnId: string, activity: Activity): void; + setStateSnapshot(turnId: string, state: Record): void; startToolCall(turnId: string, toolCallId: string, toolName: string): void; appendToolCallArgs(turnId: string, toolCallId: string, delta: string): void; completeToolCall(turnId: string, toolCallId: string, result: string): void; @@ -248,6 +249,8 @@ export class GenerativeRuntime { } case 'STATE_SNAPSHOT': { + this.ensureAgentResponse(turnId); + this.statePort.setStateSnapshot(turnId, asRecord(event.snapshot)); return {turnId, isTerminal: false}; } @@ -309,6 +312,12 @@ export class GenerativeRuntime { } } +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) { return error.message; diff --git a/packages/thermidor/src/internal/features/generative/generative-actions.ts b/packages/thermidor/src/internal/features/generative/generative-actions.ts index 94782ea509c..c898a5d978b 100644 --- a/packages/thermidor/src/internal/features/generative/generative-actions.ts +++ b/packages/thermidor/src/internal/features/generative/generative-actions.ts @@ -27,6 +27,9 @@ export function createGenerativeActions(interfaceId: string) { `${prefix}/appendMessageDelta` ), appendActivity: createAction<{turnId: string; activity: Activity}>(`${prefix}/appendActivity`), + setStateSnapshot: createAction<{turnId: string; state: Record}>( + `${prefix}/setStateSnapshot` + ), startToolCall: createAction<{ turnId: string; toolCallId: string; diff --git a/packages/thermidor/src/internal/features/generative/generative-slice.ts b/packages/thermidor/src/internal/features/generative/generative-slice.ts index c070d35ae02..5a97093376a 100644 --- a/packages/thermidor/src/internal/features/generative/generative-slice.ts +++ b/packages/thermidor/src/internal/features/generative/generative-slice.ts @@ -56,6 +56,7 @@ export function createGenerativeSlice( const turn = state.turns.find((t) => t.id === payload.turnId); if (turn) { turn.agentResponse = { + state: {}, messages: [], activities: [], reasoningSteps: [], @@ -81,6 +82,12 @@ export function createGenerativeSlice( turn.agentResponse.activities.push(payload.activity); } }) + .addCase(actions.setStateSnapshot, (state, {payload}) => { + const turn = state.turns.find((t) => t.id === payload.turnId); + if (turn?.agentResponse) { + turn.agentResponse.state = payload.state; + } + }) .addCase(actions.startToolCall, (state, {payload}) => { const turn = state.turns.find((t) => t.id === payload.turnId); if (turn?.agentResponse) { diff --git a/packages/thermidor/src/internal/features/generative/generative-types.ts b/packages/thermidor/src/internal/features/generative/generative-types.ts index fce91337799..bdb01a4d1b9 100644 --- a/packages/thermidor/src/internal/features/generative/generative-types.ts +++ b/packages/thermidor/src/internal/features/generative/generative-types.ts @@ -111,6 +111,13 @@ export type RoutedInterface = { }[RoutedUseCase]; export interface AgentResponse { + /** + * The latest server-owned AG-UI state snapshot for this turn. + * + * Thermidor retains this opaque object without coupling it to a UI protocol. + */ + state: Record; + /** * The ordered messages received from the agent during streaming. */ diff --git a/packages/thermidor/src/public/controllers/converse/converse-controller.ts b/packages/thermidor/src/public/controllers/converse/converse-controller.ts index a8816c08a02..42952c735a9 100644 --- a/packages/thermidor/src/public/controllers/converse/converse-controller.ts +++ b/packages/thermidor/src/public/controllers/converse/converse-controller.ts @@ -100,6 +100,9 @@ class ConverseControllerImpl extends BaseController { appendActivity: (turnId, activity) => { this.engine.mutate(this.#actions.appendActivity({turnId, activity})); }, + setStateSnapshot: (turnId, state) => { + this.engine.mutate(this.#actions.setStateSnapshot({turnId, state})); + }, startToolCall: (turnId, toolCallId, toolName) => { this.engine.mutate(this.#actions.startToolCall({turnId, toolCallId, toolName})); }, diff --git a/packages/thermidor/src/public/controllers/index.ts b/packages/thermidor/src/public/controllers/index.ts index 8c799d08eb9..4615ba13fbe 100644 --- a/packages/thermidor/src/public/controllers/index.ts +++ b/packages/thermidor/src/public/controllers/index.ts @@ -38,6 +38,15 @@ export type { ProductListControllerProduct, ProductListControllerState, } from './product-list/product-list-controller.js'; +export {buildRemoteController, selectRemoteControllerState} from './remote/remote-controller.js'; +export type { + RemoteController, + RemoteControllerAction, + RemoteControllerActionDispatcher, + RemoteControllerOptions, + RemoteControllerSource, + RemoteControllerState, +} from './remote/remote-controller.js'; export {buildPaginationController} from './pagination/pagination-controller.js'; export type { PaginationController, diff --git a/packages/thermidor/src/public/controllers/remote/remote-controller.test.ts b/packages/thermidor/src/public/controllers/remote/remote-controller.test.ts new file mode 100644 index 00000000000..e3a6be8fe03 --- /dev/null +++ b/packages/thermidor/src/public/controllers/remote/remote-controller.test.ts @@ -0,0 +1,91 @@ +import {describe, expect, it, vi} from 'vitest'; +import { + buildRemoteController, + selectRemoteControllerState, + type RemoteControllerSource, +} from './remote-controller.js'; + +describe('buildRemoteController', () => { + it('selects its server-owned state from the active conversation turn', () => { + const source = createSource({controllers: {cart: {items: [{productId: 'p1'}]}}}); + const controller = buildRemoteController({ + source, + controllerId: 'cart', + dispatchAction: vi.fn(), + }); + + expect(controller.state).toEqual({items: [{productId: 'p1'}]}); + }); + + it('notifies subscribers when its snapshot slice changes, but not for another controller', () => { + const source = createSource({controllers: {cart: {items: []}, products: {products: []}}}); + const controller = buildRemoteController({ + source, + controllerId: 'cart', + dispatchAction: vi.fn(), + }); + const callback = vi.fn(); + + controller.subscribe(callback); + source.setSnapshot({controllers: {cart: controller.state, products: {products: ['p1']}}}); + expect(callback).not.toHaveBeenCalled(); + + source.setSnapshot({controllers: {cart: {items: [{productId: 'p1'}]}}}); + expect(callback).toHaveBeenCalledWith({items: [{productId: 'p1'}]}); + }); + + it('dispatches controller actions without locally changing server-owned state', async () => { + const dispatchAction = vi.fn(); + const controller = buildRemoteController({ + source: createSource({controllers: {cart: {items: []}}}), + controllerId: 'cart', + dispatchAction, + }); + + await controller.dispatch('updateItemQuantity', {item: {productId: 'p1', quantity: 2}}); + + expect(dispatchAction).toHaveBeenCalledWith({ + controllerId: 'cart', + action: 'updateItemQuantity', + payload: {item: {productId: 'p1', quantity: 2}}, + }); + expect(controller.state).toEqual({items: []}); + }); + + it('rejects an unnamed action', async () => { + const controller = buildRemoteController({ + source: createSource({controllers: {}}), + controllerId: 'cart', + dispatchAction: vi.fn(), + }); + + await expect(controller.dispatch(' ', {})).rejects.toThrow('action name is required'); + }); +}); + +describe('selectRemoteControllerState', () => { + it('returns the stable empty state when no matching snapshot entry exists', () => { + const state = {activeTurn: {agentResponse: {state: {controllers: {}}}}}; + + expect(selectRemoteControllerState(state as never, 'missing')).toEqual({}); + }); +}); + +function createSource(snapshot: Record) { + const listeners = new Set<() => void>(); + const source = { + state: {activeTurn: {agentResponse: {state: snapshot}}}, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setSnapshot(nextSnapshot: Record) { + source.state = {activeTurn: {agentResponse: {state: nextSnapshot}}}; + listeners.forEach((listener) => listener()); + }, + }; + + return source as unknown as RemoteControllerSource & { + setSnapshot(snapshot: Record): void; + }; +} diff --git a/packages/thermidor/src/public/controllers/remote/remote-controller.ts b/packages/thermidor/src/public/controllers/remote/remote-controller.ts new file mode 100644 index 00000000000..3bdf0ee1032 --- /dev/null +++ b/packages/thermidor/src/public/controllers/remote/remote-controller.ts @@ -0,0 +1,130 @@ +import type {Controller} from '../controller-types.js'; +import type {ConverseController, ConverseControllerState} from '../converse/converse-controller.js'; + +export type RemoteControllerState = Record; + +export interface RemoteControllerAction { + controllerId: string; + action: string; + payload: TPayload; +} + +/** + * Delivers a controller action to the application's server transport. + * + * Thermidor deliberately does not prescribe that transport: the server owns the + * controller state and must acknowledge a mutation by publishing a later + * `STATE_SNAPSHOT`. + */ +export type RemoteControllerActionDispatcher = ( + action: RemoteControllerAction +) => void | Promise; + +/** + * A controller state source backed by Thermidor's active conversation turn. + */ +export type RemoteControllerSource = Pick; + +class RemoteControllerImpl< + TState extends RemoteControllerState, +> implements RemoteController { + readonly controllerId: string; + + constructor( + private readonly source: RemoteControllerSource, + controllerId: string, + private readonly dispatchAction: RemoteControllerActionDispatcher + ) { + this.controllerId = controllerId; + } + + get state(): TState { + return selectRemoteControllerState(this.source.state, this.controllerId) as TState; + } + + subscribe(callback: (state: TState) => void): () => void { + let previousState = this.state; + + return this.source.subscribe(() => { + const nextState = this.state; + if (nextState === previousState) { + return; + } + + previousState = nextState; + callback(nextState); + }); + } + + dispatch(action: string, payload: TPayload): Promise { + if (!action.trim()) { + return Promise.reject(new Error('A controller action name is required.')); + } + + return Promise.resolve().then(() => + this.dispatchAction({ + controllerId: this.controllerId, + action, + payload, + }) + ); + } +} + +/** + * Creates a controller for one server-owned entry in the active AG-UI state + * snapshot. The controller never mutates its local state; action results arrive + * through a subsequent snapshot from the server. + */ +export function buildRemoteController( + options: RemoteControllerOptions +): RemoteController { + return new RemoteControllerImpl( + options.source, + options.controllerId, + options.dispatchAction + ); +} + +export interface RemoteController< + TState extends RemoteControllerState = RemoteControllerState, +> extends Controller { + /** The runtime key used to select this controller's state from `controllers`. */ + readonly controllerId: string; + + /** + * Emits an action for this controller through the configured server transport. + * State remains server-owned and changes only when the server sends a snapshot. + */ + dispatch(action: string, payload: TPayload): Promise; +} + +export interface RemoteControllerOptions { + source: RemoteControllerSource; + controllerId: string; + dispatchAction: RemoteControllerActionDispatcher; +} + +const EMPTY_REMOTE_CONTROLLER_STATE: RemoteControllerState = {}; + +export function selectRemoteControllerState( + state: ConverseControllerState, + controllerId: string +): RemoteControllerState { + const snapshot = state.activeTurn?.agentResponse?.state; + if (!isRecord(snapshot)) { + return EMPTY_REMOTE_CONTROLLER_STATE; + } + + const controllers = snapshot['controllers']; + if (!isRecord(controllers)) { + return EMPTY_REMOTE_CONTROLLER_STATE; + } + + const controllerState = controllers[controllerId]; + return isRecord(controllerState) ? controllerState : EMPTY_REMOTE_CONTROLLER_STATE; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/samples/thermidor/schema-contract-react/README.md b/samples/thermidor/schema-contract-react/README.md index d038c7e1cef..5e53ac16952 100644 --- a/samples/thermidor/schema-contract-react/README.md +++ b/samples/thermidor/schema-contract-react/README.md @@ -2,9 +2,9 @@ This sample proves the client-side path for the v0.9 Thermidor Commerce Catalog contract: -`ConverseController` → normalized Thermidor `Activity` → raw A2-UI messages → CopilotKit A2-UI renderer/catalog. +`AG-UI STATE_SNAPSHOT` → Thermidor Engine state → advertised controller; `ACTIVITY_SNAPSHOT` → raw A2-UI messages → CopilotKit renderer/catalog. -Thermidor only exposes opaque activity `kind` and `payload` values. The sample passes the `a2ui-surface` operations to CopilotKit unchanged. Its local `ProductCarousel` and `Cart` components create their advertised controllers from `updateComponents`, hydrate them from `updateDataModel`, and subscribe to later data-model updates. CopilotKit provides only renderer and catalog state; it does not replace Thermidor's conversational endpoint or runtime. +Thermidor normalizes AG-UI state snapshots into the active turn's Engine-backed `agentResponse.state` and retains A2-UI activities as opaque `kind` and `payload` values. The sample passes `a2ui-surface` operations to CopilotKit unchanged. Its local `ProductCarousel` and `Cart` renderers select the matching advertised `controllerId` slice from Thermidor Engine state and subscribe to future Engine updates. CopilotKit provides only renderer and catalog state; it does not replace Thermidor's conversational endpoint or runtime. ## Run with the contract mock diff --git a/samples/thermidor/schema-contract-react/src/App.tsx b/samples/thermidor/schema-contract-react/src/App.tsx index 900f6de6726..f06897ab65a 100644 --- a/samples/thermidor/schema-contract-react/src/App.tsx +++ b/samples/thermidor/schema-contract-react/src/App.tsx @@ -7,8 +7,7 @@ import { type GenerativeInterface, } from '@coveo/thermidor'; import {A2UIProvider} from '@copilotkit/a2ui-renderer'; -import {thermidorCatalog} from './a2ui/components.js'; -import {ThermidorControllerProvider} from './a2ui/controllers.js'; +import {createThermidorCatalog} from './a2ui/components.js'; import {getA2UIMessages, ThermidorA2UISurfaces} from './a2ui/surfaces.js'; import {getSampleConfiguration} from './env.js'; import {useController} from './use-controller.js'; @@ -16,11 +15,7 @@ import {useController} from './use-controller.js'; const CONTRACT_PROMPT = 'Show the Thermidor catalog'; export default function App() { - return ( - - - - ); + return ; } function ContractSample() { @@ -36,6 +31,8 @@ function ContractSample() { const [controller, state] = useController(() => buildConverseController({interface: interfaceRef.current!}) ); + const catalogRef = useRef | null>(null); + catalogRef.current ??= createThermidorCatalog(controller); const [prompt, setPrompt] = useState(CONTRACT_PROMPT); const turn = state.activeTurn; const a2uiMessages = useMemo( @@ -56,42 +53,42 @@ function ContractSample() { } return ( -
-
-

Thermidor + v0.9 catalog contract

-

Server-owned commerce state, client-owned rendering.

-

- Thermidor stays UI-less. This sample recognizes an a2ui-surface activity, - resolves the advertised controllers, and renders its local A2-UI component catalog. -

-
+ +
+
+

Thermidor + v0.9 catalog contract

+

Server-owned commerce state, client-owned rendering.

+

+ Thermidor stays UI-less. This sample recognizes an a2ui-surface activity, + resolves the advertised controllers, and renders its local A2-UI component catalog. +

+
-
- -
- setPrompt(event.target.value)} - disabled={state.isStreaming} - /> - -
-
+
+ +
+ setPrompt(event.target.value)} + disabled={state.isStreaming} + /> + +
+
- {turn?.agentResponse?.messages.map((message, index) => ( -

- {message.content} -

- ))} - {turn?.status === 'error' &&

{turn.error}

} - + {turn?.agentResponse?.messages.map((message, index) => ( +

+ {message.content} +

+ ))} + {turn?.status === 'error' &&

{turn.error}

} -
- {!turn &&

Run the pre-filled prompt to render the schema example.

} -
+ {!turn &&

Run the pre-filled prompt to render the schema example.

} +
+ ); } diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx index 28fb7aa525c..4b57e393242 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx @@ -3,8 +3,9 @@ import { type CatalogDefinitions, type CatalogRenderers, } from '@copilotkit/a2ui-renderer'; +import type {RemoteControllerActionDispatcher} from '@coveo/thermidor'; import {z} from 'zod'; -import {useAdvertisedController} from './controllers.js'; +import {type EngineStateSource, useAdvertisedController} from './controllers.js'; export const THERMIDOR_CATALOG_ID = 'https://schema.thermidor.coveo.com/a2-ui/catalog.json'; @@ -50,72 +51,89 @@ export const thermidorCatalogDefinitions = { }, } satisfies CatalogDefinitions; -const renderers = { - ProductCarousel: ({props}) => { - const state = useAdvertisedController<{products?: Product[]}>( - props.controllers.productListController - ); - const products = Array.isArray(state.products) ? state.products : []; +export function createThermidorCatalog( + stateSource: EngineStateSource, + dispatchAction: RemoteControllerActionDispatcher = rejectUnhandledControllerAction +) { + const renderers = { + ProductCarousel: ({props}) => { + const [, state] = useAdvertisedController<{products?: Product[]}>( + stateSource, + props.controllers.productListController, + dispatchAction + ); + const products = Array.isArray(state.products) ? state.products : []; - return ( -
-
-
-

ProductCarousel

-

Featured products

+ return ( +
+
+
+

ProductCarousel

+

Featured products

+
+ + controller: {props.controllers.productListController.controllerId} +
- - controller: {props.controllers.productListController.controllerId} - -
-
- {products.map((product) => { - const price = product.ec_promo_price ?? product.ec_price; - return ( -
- -

{product.ec_brand}

-

{product.ec_name}

-

{product.ec_shortdesc}

-
- ${price.toFixed(2)} - ★ {product.ec_rating} -
- {product.ec_in_stock ? 'In stock' : 'Out of stock'} -
- ); - })} -
-
- ); - }, - Cart: ({props}) => { - const state = useAdvertisedController<{items?: CartItem[]}>(props.controllers.cartController); - const items = Array.isArray(state.items) ? state.items : []; - const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); +
+ {products.map((product) => { + const price = product.ec_promo_price ?? product.ec_price; + return ( +
+ +

{product.ec_brand}

+

{product.ec_name}

+

{product.ec_shortdesc}

+
+ ${price.toFixed(2)} + ★ {product.ec_rating} +
+ {product.ec_in_stock ? 'In stock' : 'Out of stock'} +
+ ); + })} +
+ + ); + }, + Cart: ({props}) => { + const [, state] = useAdvertisedController<{items?: CartItem[]}>( + stateSource, + props.controllers.cartController, + dispatchAction + ); + const items = Array.isArray(state.items) ? state.items : []; + const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); - return ( - + ); + }, + } satisfies CatalogRenderers; -export const thermidorCatalog = createCatalog(thermidorCatalogDefinitions, renderers, { - catalogId: THERMIDOR_CATALOG_ID, - includeBasicCatalog: true, -}); + return createCatalog(thermidorCatalogDefinitions, renderers, { + catalogId: THERMIDOR_CATALOG_ID, + includeBasicCatalog: true, + }); +} + +function rejectUnhandledControllerAction(): Promise { + return Promise.reject( + new Error('No server transport was configured for the advertised controller action.') + ); +} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts index 8b97cd6b75a..3c86ee76e11 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts +++ b/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts @@ -1,48 +1,19 @@ -import {describe, expect, it, vi} from 'vitest'; -import {ThermidorControllerRegistry} from './controllers.js'; +import {describe, expect, it} from 'vitest'; +import {selectRemoteControllerState} from '@coveo/thermidor'; -describe('ThermidorControllerRegistry', () => { - it('hydrates a component-created controller and notifies it about subsequent data-model updates', () => { - const registry = new ThermidorControllerRegistry(); - const advertisement = { - controllerId: 'featured-products', - controllerSchema: 'product-list.schema.json', - }; - registry.synchronize([ - { - version: 'v0.9', - updateDataModel: { - surfaceId: 'catalog', - value: {controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}}, +describe('selectRemoteControllerState', () => { + it('selects the advertised controller slice from the active Thermidor turn', () => { + const state = { + activeTurn: { + agentResponse: { + state: {controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}}, }, }, - ]); + } as unknown as Parameters[0]; - const controller = registry.getOrCreate(advertisement); - expect(controller.snapshot).toEqual({products: [{permanentid: 'p1'}]}); - - const listener = vi.fn(); - const unsubscribe = controller.subscribe(listener); - registry.synchronize([ - { - version: 'v0.9', - updateDataModel: { - surfaceId: 'catalog', - value: {controllers: {'featured-products': {products: [{permanentid: 'p1'}]}}}, - }, - }, - { - version: 'v0.9', - updateDataModel: { - surfaceId: 'catalog', - path: '/controllers/featured-products/products', - value: [{permanentid: 'p2'}], - }, - }, - ]); - - expect(controller.snapshot).toEqual({products: [{permanentid: 'p2'}]}); - expect(listener).toHaveBeenCalledOnce(); - unsubscribe(); + expect(selectRemoteControllerState(state, 'featured-products')).toEqual({ + products: [{permanentid: 'p1'}], + }); + expect(selectRemoteControllerState(state, 'unknown-controller')).toEqual({}); }); }); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx b/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx index f57a840e60f..1589bd85905 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx +++ b/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx @@ -1,176 +1,39 @@ +import {useCallback, useMemo, useSyncExternalStore} from 'react'; import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useSyncExternalStore, - type ReactNode, -} from 'react'; + buildRemoteController, + type RemoteController, + type RemoteControllerActionDispatcher, + type RemoteControllerSource, +} from '@coveo/thermidor'; export type ControllerAdvertisement = { controllerId: string; controllerSchema: string; }; -type A2UIMessage = Record; type ControllerState = Record; -class AdvertisedController { - private listeners = new Set<() => void>(); - private serializedState: string; - - public constructor( - public readonly advertisement: ControllerAdvertisement, - private state: ControllerState - ) { - this.serializedState = JSON.stringify(state); - } - - public get snapshot(): ControllerState { - return this.state; - } - - public hydrate(state: ControllerState): void { - const serializedState = JSON.stringify(state); - if (serializedState === this.serializedState) { - return; - } - - this.state = state; - this.serializedState = serializedState; - this.listeners.forEach((listener) => listener()); - } - - public subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } -} - -export class ThermidorControllerRegistry { - private controllers = new Map(); - private controllerStates = new Map(); - - public synchronize(messages: A2UIMessage[]): void { - const dataModels = new Map(); - - for (const message of messages) { - const updateDataModel = message['updateDataModel']; - if (!isRecord(updateDataModel) || typeof updateDataModel['surfaceId'] !== 'string') { - continue; - } - - const surfaceId = updateDataModel['surfaceId']; - dataModels.set( - surfaceId, - applyDataModelUpdate( - dataModels.get(surfaceId), - typeof updateDataModel['path'] === 'string' ? updateDataModel['path'] : '/', - updateDataModel['value'] - ) - ); - } - - const controllerStates = new Map(); - for (const dataModel of dataModels.values()) { - const advertisedStates = - isRecord(dataModel) && isRecord(dataModel['controllers']) ? dataModel['controllers'] : {}; - for (const [controllerId, state] of Object.entries(advertisedStates)) { - if (isRecord(state)) { - controllerStates.set(controllerId, state); - } - } - } - - this.controllerStates = controllerStates; - for (const controller of this.controllers.values()) { - controller.hydrate(controllerStates.get(controller.advertisement.controllerId) ?? {}); - } - } - - public getOrCreate(advertisement: ControllerAdvertisement): AdvertisedController { - const existing = this.controllers.get(advertisement.controllerId); - if (existing && existing.advertisement.controllerSchema === advertisement.controllerSchema) { - return existing; - } - - const controller = new AdvertisedController( - advertisement, - this.controllerStates.get(advertisement.controllerId) ?? {} - ); - this.controllers.set(advertisement.controllerId, controller); - return controller; - } -} - -const ControllerRegistryContext = createContext(null); - -export function ThermidorControllerProvider({ - children, - messages, -}: { - children: ReactNode; - messages: A2UIMessage[]; -}) { - const registryRef = useRef(null); - registryRef.current ??= new ThermidorControllerRegistry(); - const serializedMessages = useMemo(() => JSON.stringify(messages), [messages]); - - useEffect(() => { - registryRef.current!.synchronize(JSON.parse(serializedMessages) as A2UIMessage[]); - }, [serializedMessages]); - - return ( - - {children} - - ); -} +export type EngineStateSource = RemoteControllerSource; export function useAdvertisedController( - advertisement: ControllerAdvertisement -): T { - const registry = useContext(ControllerRegistryContext); - if (!registry) { - throw new Error('Advertised controllers require ThermidorControllerProvider.'); - } - + source: EngineStateSource, + advertisement: ControllerAdvertisement, + dispatchAction: RemoteControllerActionDispatcher +): [RemoteController, T] { const controller = useMemo( - () => registry.getOrCreate(advertisement), - [advertisement.controllerId, advertisement.controllerSchema, registry] + () => + buildRemoteController({ + source, + controllerId: advertisement.controllerId, + dispatchAction, + }), + [advertisement.controllerId, dispatchAction, source] ); const subscribe = useCallback( - (listener: () => void) => controller.subscribe(listener), + (listener: () => void) => controller.subscribe(() => listener()), [controller] ); - const getSnapshot = useCallback(() => controller.snapshot as T, [controller]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} - -function applyDataModelUpdate(dataModel: unknown, path: string, value: unknown): unknown { - const segments = path - .split('/') - .filter(Boolean) - .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); - if (segments.length === 0) { - return value; - } - - const root = isRecord(dataModel) ? {...dataModel} : {}; - let target: Record = root; - for (const segment of segments.slice(0, -1)) { - const current = target[segment]; - const next = isRecord(current) ? {...current} : {}; - target[segment] = next; - target = next; - } - target[segments.at(-1)!] = value; - return root; -} + const getSnapshot = useCallback(() => controller.state, [controller]); -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + return [controller, useSyncExternalStore(subscribe, getSnapshot, getSnapshot)]; } From 758ca44931b98be58a25fd50ca96a80aff188160 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Mon, 27 Jul 2026 23:58:27 +0200 Subject: [PATCH 4/5] generated schema --- .../src/a2ui/components.test.ts | 58 +++++++++- .../src/a2ui/components.tsx | 60 ++++------- .../src/a2ui/generated/catalog-components.ts | 100 ++++++++++++++++++ 3 files changed, 178 insertions(+), 40 deletions(-) create mode 100644 samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts index 97e975d8530..89fbf5c8868 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts @@ -1,5 +1,6 @@ import {describe, expect, it} from 'vitest'; import {thermidorCatalogDefinitions} from './components.js'; +import {cartItemSchema, productSchema} from './generated/catalog-components.js'; describe('thermidorCatalogDefinitions', () => { it('accepts the controller advertisements supplied by the catalog message', () => { @@ -8,7 +9,8 @@ describe('thermidorCatalogDefinitions', () => { controllers: { productListController: { controllerId: 'featured-products', - controllerSchema: 'product-list.schema.json', + controllerSchema: + 'https://schema.thermidor.coveo.com/controllers/product-list.schema.json', }, }, }).success @@ -18,10 +20,62 @@ describe('thermidorCatalogDefinitions', () => { controllers: { cartController: { controllerId: 'shopping-cart', - controllerSchema: 'cart.schema.json', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', }, }, }).success ).toBe(true); }); + + it('validates generated Product and CartItem values against their JSON Schema constraints', () => { + expect( + productSchema.safeParse({ + permanentid: 'p1', + ec_name: 'Trail shoes', + ec_rating: null, + additionalFields: {}, + children: [{permanentid: 'p1-blue', ec_name: 'Trail shoes', additionalFields: {}}], + }).success + ).toBe(true); + expect( + productSchema.safeParse({ + permanentid: 'p1', + ec_name: 'Trail shoes', + ec_rating: 6, + additionalFields: {}, + }).success + ).toBe(false); + expect( + cartItemSchema.safeParse({productId: 'p1', name: 'Trail shoes', price: 0, quantity: 1}) + .success + ).toBe(false); + expect( + cartItemSchema.safeParse({productId: 'p1', name: 'Trail shoes', price: 99.99, quantity: 1.5}) + .success + ).toBe(false); + }); + + it('enforces the controller contract literals and closed binding objects from JSON Schema', () => { + expect( + thermidorCatalogDefinitions.ProductCarousel.props.safeParse({ + controllers: { + productListController: { + controllerId: 'featured-products', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + }, + }, + }).success + ).toBe(false); + expect( + thermidorCatalogDefinitions.Cart.props.safeParse({ + controllers: { + cartController: { + controllerId: 'shopping-cart', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + unexpected: true, + }, + }, + }).success + ).toBe(false); + }); }); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx index 4b57e393242..a0cc43cc525 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx @@ -4,50 +4,24 @@ import { type CatalogRenderers, } from '@copilotkit/a2ui-renderer'; import type {RemoteControllerActionDispatcher} from '@coveo/thermidor'; -import {z} from 'zod'; import {type EngineStateSource, useAdvertisedController} from './controllers.js'; +import { + cartPropsSchema, + productCarouselPropsSchema, + type CartItem, + type Product, +} from './generated/catalog-components.js'; export const THERMIDOR_CATALOG_ID = 'https://schema.thermidor.coveo.com/a2-ui/catalog.json'; -const controllerAdvertisement = z.object({ - controllerId: z.string(), - controllerSchema: z.string(), -}); - -const product = z.object({ - permanentid: z.string(), - ec_name: z.string(), - ec_shortdesc: z.string(), - ec_brand: z.string(), - ec_price: z.number(), - ec_promo_price: z.number().optional(), - ec_images: z.array(z.string()), - ec_in_stock: z.boolean(), - ec_rating: z.number(), -}); - -const cartItem = z.object({ - productId: z.string(), - name: z.string(), - price: z.number(), - quantity: z.number(), -}); - -type Product = z.infer; -type CartItem = z.infer; - export const thermidorCatalogDefinitions = { ProductCarousel: { description: 'A responsive product carousel backed by a product-list controller.', - props: z.object({ - controllers: z.object({productListController: controllerAdvertisement}), - }), + props: productCarouselPropsSchema, }, Cart: { description: 'A shopping-cart summary backed by a cart controller.', - props: z.object({ - controllers: z.object({cartController: controllerAdvertisement}), - }), + props: cartPropsSchema, }, } satisfies CatalogDefinitions; @@ -80,15 +54,25 @@ export function createThermidorCatalog( const price = product.ec_promo_price ?? product.ec_price; return (
- +

{product.ec_brand}

{product.ec_name}

{product.ec_shortdesc}

- ${price.toFixed(2)} - ★ {product.ec_rating} + + {price === undefined ? 'Price unavailable' : `$${price.toFixed(2)}`} + + + {product.ec_rating == null ? 'Not rated' : `★ ${product.ec_rating}`} +
- {product.ec_in_stock ? 'In stock' : 'Out of stock'} + + {product.ec_in_stock === undefined + ? 'Availability unknown' + : product.ec_in_stock + ? 'In stock' + : 'Out of stock'} +
); })} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts b/samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts new file mode 100644 index 00000000000..1cc844d7b10 --- /dev/null +++ b/samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts @@ -0,0 +1,100 @@ +/* + * This file is generated from integration/thermidor-schema/a2-ui/catalog.json. + * Run `npm run generate:sample-zod` in integration/thermidor-schema after changing the catalog. + */ +import {z} from 'zod'; + +const productCarouselControllersSchema = z + .object({ + productListController: z + .object({ + controllerId: z.string().min(1), + controllerSchema: z.literal( + 'https://schema.thermidor.coveo.com/controllers/product-list.schema.json' + ), + }) + .strict(), + }) + .strict(); + +export const productCarouselPropsSchema = z + .object({ + controllers: productCarouselControllersSchema, + }) + .strict(); + +const cartControllersSchema = z + .object({ + cartController: z + .object({ + controllerId: z.string().min(1), + controllerSchema: z.literal( + 'https://schema.thermidor.coveo.com/controllers/cart.schema.json' + ), + }) + .strict(), + }) + .strict(); + +export const cartPropsSchema = z + .object({ + controllers: cartControllersSchema, + }) + .strict(); + +export interface Product { + permanentid: string; + ec_name: string; + ec_description?: string; + ec_shortdesc?: string; + ec_brand?: string; + ec_category?: string[]; + ec_price?: number; + ec_promo_price?: number; + ec_images?: string[]; + ec_thumbnails?: string[]; + ec_in_stock?: boolean; + ec_rating?: number | null; + ec_color?: string; + ec_item_group_id?: string; + ec_item_group_name?: string; + clickUri?: string; + additionalFields: Record; + children?: Product[]; +} + +export const productSchema: z.ZodType = z.lazy(() => + z + .object({ + permanentid: z.string(), + ec_name: z.string(), + ec_description: z.string().optional(), + ec_shortdesc: z.string().optional(), + ec_brand: z.string().optional(), + ec_category: z.array(z.string()).optional(), + ec_price: z.number().optional(), + ec_promo_price: z.number().optional(), + ec_images: z.array(z.string().url()).optional(), + ec_thumbnails: z.array(z.string().url()).optional(), + ec_in_stock: z.boolean().optional(), + ec_rating: z.number().min(0).max(5).nullable().optional(), + ec_color: z.string().optional(), + ec_item_group_id: z.string().optional(), + ec_item_group_name: z.string().optional(), + clickUri: z.string().optional(), + additionalFields: z.record(z.unknown()), + children: z.array(productSchema).optional(), + }) + .strict() +); + +export const cartItemSchema = z + .object({ + productId: z.string(), + name: z.string(), + price: z.number().gt(0), + quantity: z.number().int().min(1), + }) + .strict(); + +export type CartItem = z.infer; From 1985882220b08fef63faf3dc8dcede3a12ecb3b6 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Tue, 28 Jul 2026 04:31:57 +0200 Subject: [PATCH 5/5] generated ctrl --- packages/thermidor-contracts/package.json | 42 ++++ .../src/generated/catalog-contracts.ts | 65 +++++- packages/thermidor-contracts/src/index.ts | 1 + packages/thermidor-contracts/tsdown.config.ts | 11 ++ packages/thermidor/package.json | 4 +- .../conversation-endpoint-types.ts | 27 ++- .../src/internal/api/conversation/index.ts | 3 + .../api/generative/generative-runtime.test.ts | 52 +++++ .../api/generative/generative-runtime.ts | 83 ++++++-- .../converse/converse-controller.test.ts | 21 ++ .../converse/converse-controller.ts | 10 + .../thermidor/src/public/controllers/index.ts | 8 +- .../remote/remote-controller.test.ts | 49 +++-- .../controllers/remote/remote-controller.ts | 187 ++++++++++++------ pnpm-lock.yaml | 36 +++- pnpm-workspace.yaml | 2 + .../schema-contract-react/package.json | 3 +- .../src/a2ui/components.test.ts | 26 ++- .../src/a2ui/components.tsx | 34 +--- .../src/a2ui/controllers.test.ts | 49 ++++- .../src/a2ui/controllers.tsx | 41 ++-- 21 files changed, 591 insertions(+), 163 deletions(-) create mode 100644 packages/thermidor-contracts/package.json rename samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts => packages/thermidor-contracts/src/generated/catalog-contracts.ts (56%) create mode 100644 packages/thermidor-contracts/src/index.ts create mode 100644 packages/thermidor-contracts/tsdown.config.ts diff --git a/packages/thermidor-contracts/package.json b/packages/thermidor-contracts/package.json new file mode 100644 index 00000000000..91eddf173eb --- /dev/null +++ b/packages/thermidor-contracts/package.json @@ -0,0 +1,42 @@ +{ + "name": "@coveo/thermidor-contracts", + "version": "0.0.1", + "description": "Generated Zod contracts for Thermidor controller and A2-UI schemas", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/coveo/ui-kit.git", + "directory": "packages/thermidor-contracts" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "clean": "node ../../utils/ci/rm-rf.mjs dist" + }, + "dependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "tsdown": "0.22.0", + "typescript": "catalog:" + }, + "engines": { + "node": "^22.11.0 || ^24.11.0" + } +} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts b/packages/thermidor-contracts/src/generated/catalog-contracts.ts similarity index 56% rename from samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts rename to packages/thermidor-contracts/src/generated/catalog-contracts.ts index 1cc844d7b10..5de184a4d45 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/generated/catalog-components.ts +++ b/packages/thermidor-contracts/src/generated/catalog-contracts.ts @@ -1,6 +1,6 @@ /* - * This file is generated from integration/thermidor-schema/a2-ui/catalog.json. - * Run `npm run generate:sample-zod` in integration/thermidor-schema after changing the catalog. + * This file is generated from the Thermidor A2-UI catalog and controller JSON Schemas. + * Run `npm run generate:thermidor-contracts` in integration/thermidor-schema after changing the catalog. */ import {z} from 'zod'; @@ -98,3 +98,64 @@ export const cartItemSchema = z .strict(); export type CartItem = z.infer; + +export const productListControllerStateSchema = z + .object({ + products: z.array(productSchema), + }) + .strict(); + +export type ProductListControllerState = z.infer; + +export type ProductListController = z.infer; + +export const productListControllerContract = z + .object({ + schemaId: z.literal('https://schema.thermidor.coveo.com/controllers/product-list.schema.json'), + state: productListControllerStateSchema, + }) + .strict(); + +export const cartControllerStateSchema = z + .object({ + items: z.array(cartItemSchema), + }) + .strict(); + +export type CartControllerState = z.infer; + +export const cartControllerContractSetItemsPayloadSchema = z + .object({ + items: z.array(cartItemSchema), + }) + .strict(); + +export type SetItemsPayload = z.infer; + +export const cartControllerContractUpdateItemQuantityPayloadSchema = z + .object({ + item: cartItemSchema, + }) + .strict(); + +export type UpdateItemQuantityPayload = z.infer< + typeof cartControllerContractUpdateItemQuantityPayloadSchema +>; + +export type CartController = z.infer; + +export const cartControllerContract = z + .object({ + schemaId: z.literal('https://schema.thermidor.coveo.com/controllers/cart.schema.json'), + state: cartControllerStateSchema, + setItems: cartControllerContractSetItemsPayloadSchema, + updateItemQuantity: cartControllerContractUpdateItemQuantityPayloadSchema, + }) + .strict(); + +export const controllerContracts = z.discriminatedUnion('schemaId', [ + productListControllerContract, + cartControllerContract, +]); + +export type ControllerContracts = z.infer; diff --git a/packages/thermidor-contracts/src/index.ts b/packages/thermidor-contracts/src/index.ts new file mode 100644 index 00000000000..7f850b75f41 --- /dev/null +++ b/packages/thermidor-contracts/src/index.ts @@ -0,0 +1 @@ +export * from './generated/catalog-contracts.js'; diff --git a/packages/thermidor-contracts/tsdown.config.ts b/packages/thermidor-contracts/tsdown.config.ts new file mode 100644 index 00000000000..de957210f94 --- /dev/null +++ b/packages/thermidor-contracts/tsdown.config.ts @@ -0,0 +1,11 @@ +import {defineConfig} from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'dist', + format: 'esm', + unbundle: true, + dts: true, + fixedExtension: false, + treeshake: false, +}); diff --git a/packages/thermidor/package.json b/packages/thermidor/package.json index 4a954721c25..766d731590e 100644 --- a/packages/thermidor/package.json +++ b/packages/thermidor/package.json @@ -35,7 +35,9 @@ }, "dependencies": { "@ag-ui/core": "0.0.57", - "@reduxjs/toolkit": "catalog:" + "@coveo/thermidor-contracts": "workspace:*", + "@reduxjs/toolkit": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@microsoft/api-extractor": "7.58.9", diff --git a/packages/thermidor/src/internal/api/conversation/conversation-endpoint-types.ts b/packages/thermidor/src/internal/api/conversation/conversation-endpoint-types.ts index 764671d47cb..05e47468cf9 100644 --- a/packages/thermidor/src/internal/api/conversation/conversation-endpoint-types.ts +++ b/packages/thermidor/src/internal/api/conversation/conversation-endpoint-types.ts @@ -7,13 +7,12 @@ export interface CoveoConversationCartItem { quantity: number; } -export interface CoveoConversationEndpointRequest { +export interface CoveoConversationEndpointRequestBase { trackingId?: string; language?: string; country?: string; currency?: string; clientId?: string; - message: string; context: { user: { userAgent?: string | null; @@ -33,6 +32,30 @@ export interface CoveoConversationEndpointRequest { facets?: Array<{facetId: string; selectedValues: string[]}>; } +export interface CoveoConversationMessageRequest extends CoveoConversationEndpointRequestBase { + message: string; +} + +/** + * A schema-derived mutation for one server-owned controller state entry. + * `controllerSchema` identifies the generated contract that defines `action` + * and validates `payload`; `controllerId` identifies its runtime snapshot key. + */ +export interface CoveoConversationControllerAction { + controllerId: string; + controllerSchema: string; + action: string; + payload: unknown; +} + +export interface CoveoConversationActionRequest extends CoveoConversationEndpointRequestBase { + action: CoveoConversationControllerAction; +} + +export type CoveoConversationEndpointRequest = + | CoveoConversationMessageRequest + | CoveoConversationActionRequest; + export interface CoveoConversationEndpointResponse { stream: ReadableStream; } diff --git a/packages/thermidor/src/internal/api/conversation/index.ts b/packages/thermidor/src/internal/api/conversation/index.ts index 2fea3504301..1fc70c63f47 100644 --- a/packages/thermidor/src/internal/api/conversation/index.ts +++ b/packages/thermidor/src/internal/api/conversation/index.ts @@ -7,8 +7,11 @@ export type { } from './conversation-endpoint-client.js'; export type { CoveoConversationCartItem, + CoveoConversationActionRequest, + CoveoConversationControllerAction, CoveoConversationEndpointRequest, CoveoConversationEndpointResponse, + CoveoConversationMessageRequest, } from './conversation-endpoint-types.js'; export {readConversationEventStream} from './conversation-event-stream.js'; export type { diff --git a/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts b/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts index 075e166b880..4af317a74a7 100644 --- a/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts +++ b/packages/thermidor/src/internal/api/generative/generative-runtime.test.ts @@ -37,6 +37,7 @@ vi.mock('@/src/internal/utils/index.js', () => ({ function createMockStatePort(): GenerativeStatePort { return { + getActiveTurnId: vi.fn().mockReturnValue('active-turn-id'), createTurn: vi.fn(), setActiveTurnId: vi.fn(), replaceTurnId: vi.fn(), @@ -280,6 +281,57 @@ describe('GenerativeRuntime', () => { }); }); + describe('dispatchAction', () => { + it('posts a schema-derived action and applies its state snapshot to the active turn', async () => { + const config = createMockConfig(); + const engine = createMockEngine(); + const {mockClient} = setupSuccessfulStream([ + { + type: 'STATE_SNAPSHOT', + snapshot: {controllers: {'shopping-cart': {items: []}}}, + } as ConversationStreamEvent, + ]); + + const runtime = GenerativeRuntime.getInstance(engine, 'dispatch-action', config); + await runtime.dispatchAction({ + controllerId: 'shopping-cart', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + action: 'updateItemQuantity', + payload: {item: {productId: 'p1', quantity: 2}}, + }); + + expect(mockClient.call).toHaveBeenCalledWith( + expect.objectContaining({ + action: { + controllerId: 'shopping-cart', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + action: 'updateItemQuantity', + payload: {item: {productId: 'p1', quantity: 2}}, + }, + }), + expect.anything() + ); + expect(config.statePort.setStateSnapshot).toHaveBeenCalledWith('active-turn-id', { + controllers: {'shopping-cart': {items: []}}, + }); + }); + + it('rejects when no active turn can receive the resulting snapshot', async () => { + const config = createMockConfig(); + vi.mocked(config.statePort.getActiveTurnId).mockReturnValue(undefined); + const runtime = GenerativeRuntime.getInstance(createMockEngine(), 'no-active-turn', config); + + await expect( + runtime.dispatchAction({ + controllerId: 'shopping-cart', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + action: 'updateItemQuantity', + payload: {}, + }) + ).rejects.toThrow('without an active conversation turn'); + }); + }); + describe('stream consumption', () => { it('fails the turn when stream ends without a terminal event', async () => { const config = createMockConfig(); diff --git a/packages/thermidor/src/internal/api/generative/generative-runtime.ts b/packages/thermidor/src/internal/api/generative/generative-runtime.ts index 1421d1e855e..07910f2b610 100644 --- a/packages/thermidor/src/internal/api/generative/generative-runtime.ts +++ b/packages/thermidor/src/internal/api/generative/generative-runtime.ts @@ -1,6 +1,9 @@ import { readConversationEventStream, type ConversationStreamEvent, + type CoveoConversationActionRequest, + type CoveoConversationControllerAction, + type CoveoConversationMessageRequest, createConversationEndpointClient, } from '@/src/internal/api/conversation/index.js'; import type {FullEngine} from '@/src/internal/engine/index.js'; @@ -16,6 +19,7 @@ import type { } from '@/src/internal/features/generative/index.js'; export interface GenerativeStatePort { + getActiveTurnId(): string | undefined; createTurn(payload: {id: string; prompt: string; status: TurnStatus}): void; setActiveTurnId(id: string): void; replaceTurnId(oldId: string, newId: string): void; @@ -118,30 +122,48 @@ export class GenerativeRuntime { await this.executeStream(turnId); } + /** + * Sends a schema-derived UI action through the same authenticated conversation + * transport as prompts. The gateway owns the mutation and replies with a + * stream, including the resulting `STATE_SNAPSHOT` for the active turn. + */ + async dispatchAction(action: CoveoConversationControllerAction): Promise { + const turnId = this.statePort.getActiveTurnId(); + if (!turnId) { + throw new Error('Cannot dispatch a controller action without an active conversation turn.'); + } + + const {message: _message, ...requestBase} = this.createConversationRequest(); + const request: CoveoConversationActionRequest = {...requestBase, action}; + const clientConfig = this.engine.read(this.configSelectors.getEndpointClientConfiguration); + const client = createConversationEndpointClient(); + const result = await client.call(request, clientConfig); + + if (!result.success) { + throw new Error(result.error); + } + + let streamError: unknown; + await readConversationEventStream({ + stream: result.data.stream, + onEvent: (event) => { + this.dispatchEvent(turnId, event); + }, + onError: (error) => { + streamError = error; + }, + }); + + if (streamError) { + throw streamError; + } + } + private async executeStream(turnId: string): Promise { try { - const {cart, ...fromState} = this.engine.read(this.buildRequest); - const navigatorContext = this.engine.getNavigatorContextProvider()?.(); const clientConfig = this.engine.read(this.configSelectors.getEndpointClientConfiguration); - - const request = { - ...fromState, - clientId: navigatorContext?.clientId ?? undefined, - context: { - user: { - userAgent: navigatorContext?.userAgent ?? null, - }, - view: { - url: navigatorContext?.location ?? null, - referrer: navigatorContext?.referrer ?? null, - }, - ...(cart ? {cart} : {}), - }, - targetEngine: 'AGENT_CORE' as const, - }; - const client = createConversationEndpointClient(); - const result = await client.call(request, clientConfig); + const result = await client.call(this.createConversationRequest(), clientConfig); if (!result.success) { this.statePort.failTurn(turnId, result.error); @@ -154,6 +176,27 @@ export class GenerativeRuntime { } } + private createConversationRequest(): CoveoConversationMessageRequest { + const {cart, ...fromState} = this.engine.read(this.buildRequest); + const navigatorContext = this.engine.getNavigatorContextProvider()?.(); + + return { + ...fromState, + clientId: navigatorContext?.clientId ?? undefined, + context: { + user: { + userAgent: navigatorContext?.userAgent ?? null, + }, + view: { + url: navigatorContext?.location ?? null, + referrer: navigatorContext?.referrer ?? null, + }, + ...(cart ? {cart} : {}), + }, + targetEngine: 'AGENT_CORE' as const, + }; + } + private async consumeStream(turnId: string, stream: ReadableStream): Promise { let activeTurnId = turnId; let terminalEventReceived = false; diff --git a/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts b/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts index bd7651177c4..72deae041b4 100644 --- a/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts +++ b/packages/thermidor/src/public/controllers/converse/converse-controller.test.ts @@ -11,11 +11,13 @@ import { } from '@/src/public/interfaces/generative.js'; import {buildConverseController} from './converse-controller.js'; import type {SerializedConverseState} from './converse-controller-serialization.js'; +import type {RemoteControllerAction} from '../remote/remote-controller.js'; const TEST_ID = 'test-generative'; const mockSubmit = vi.fn<(prompt: string) => Promise>(); const mockResubmit = vi.fn<(turnId: string, prompt: string) => Promise>(); +const mockDispatchAction = vi.fn<(action: RemoteControllerAction) => Promise>(); const mockSetConversationSession = vi.fn<(sessionId: string | undefined, token: string | undefined) => void>(); const mockGetConversationSessionId = vi.fn<() => string | undefined>(); @@ -26,6 +28,7 @@ vi.mock('@/src/internal/api/generative/index.js', () => ({ getInstance: vi.fn(() => ({ submit: mockSubmit, resubmit: mockResubmit, + dispatchAction: mockDispatchAction, setConversationSession: mockSetConversationSession, getConversationSessionId: mockGetConversationSessionId, getConversationToken: mockGetConversationToken, @@ -53,11 +56,13 @@ describe('buildConverseController', () => { vi.clearAllMocks(); mockSubmit.mockReset(); mockResubmit.mockReset(); + mockDispatchAction.mockReset(); mockSetConversationSession.mockReset(); mockGetConversationSessionId.mockReset(); mockGetConversationToken.mockReset(); mockSubmit.mockResolvedValue(); mockResubmit.mockResolvedValue(); + mockDispatchAction.mockResolvedValue(); mockGetConversationSessionId.mockReturnValue(undefined); mockGetConversationToken.mockReturnValue(undefined); engine = createTestEngine(); @@ -195,6 +200,22 @@ describe('buildConverseController', () => { }); }); + describe('dispatchAction()', () => { + it('delegates a schema-derived controller action to the runtime', async () => { + const controller = buildController(); + const action = { + controllerId: 'shopping-cart', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + action: 'updateItemQuantity', + payload: {item: {productId: 'p1', quantity: 2}}, + }; + + await controller.dispatchAction(action); + + expect(mockDispatchAction).toHaveBeenCalledWith(action); + }); + }); + describe('retry()', () => { it('calls runtime.resubmit when the turn exists and has error status', () => { const controller = buildController(); diff --git a/packages/thermidor/src/public/controllers/converse/converse-controller.ts b/packages/thermidor/src/public/controllers/converse/converse-controller.ts index 42952c735a9..928db8cebf6 100644 --- a/packages/thermidor/src/public/controllers/converse/converse-controller.ts +++ b/packages/thermidor/src/public/controllers/converse/converse-controller.ts @@ -15,6 +15,7 @@ import { getHandleInternals, } from '@/src/internal/utils/index.js'; import type {GenerativeInterface, Controller} from '@/src/internal/utils/index.js'; +import type {RemoteControllerAction} from '../remote/remote-controller.js'; import { deserializeToGenerativeState, SerializedConverseState, @@ -65,6 +66,9 @@ class ConverseControllerImpl extends BaseController { generativeInterface: options.interface, cartInterface: options.interface, statePort: { + getActiveTurnId: () => { + return this.engine.read(this.#selectors.getActiveTurnId); + }, createTurn: (payload) => { this.engine.mutate(this.#actions.createTurn(payload)); }, @@ -214,6 +218,10 @@ class ConverseControllerImpl extends BaseController { } this.#runtime.resubmit(id, turn.prompt); } + + dispatchAction(action: RemoteControllerAction): Promise { + return this.#runtime.dispatchAction(action); + } } export const buildConverseController = (options: ConverseControllerOptions): ConverseController => @@ -226,6 +234,8 @@ export interface ConverseController extends Controller submit(options: {prompt: string}): void; selectTurn(options: {id: string}): void; retry(options: {id: string}): void; + /** Sends a schema-derived remote controller action to the AG-UI gateway. */ + dispatchAction(action: RemoteControllerAction): Promise; } export interface ConverseControllerState { diff --git a/packages/thermidor/src/public/controllers/index.ts b/packages/thermidor/src/public/controllers/index.ts index 4615ba13fbe..1b4a2c1562e 100644 --- a/packages/thermidor/src/public/controllers/index.ts +++ b/packages/thermidor/src/public/controllers/index.ts @@ -42,10 +42,14 @@ export {buildRemoteController, selectRemoteControllerState} from './remote/remot export type { RemoteController, RemoteControllerAction, - RemoteControllerActionDispatcher, + RemoteControllerActionNameForSchema, + RemoteControllerActionPayloadForSchema, + AdvertisedRemoteController, RemoteControllerOptions, + RemoteControllerSchemaId, RemoteControllerSource, - RemoteControllerState, + RemoteControllerStateForSchema, + RemoteControllerActionsForSchema, } from './remote/remote-controller.js'; export {buildPaginationController} from './pagination/pagination-controller.js'; export type { diff --git a/packages/thermidor/src/public/controllers/remote/remote-controller.test.ts b/packages/thermidor/src/public/controllers/remote/remote-controller.test.ts index e3a6be8fe03..39ea915cdfb 100644 --- a/packages/thermidor/src/public/controllers/remote/remote-controller.test.ts +++ b/packages/thermidor/src/public/controllers/remote/remote-controller.test.ts @@ -1,65 +1,74 @@ import {describe, expect, it, vi} from 'vitest'; +import {cartControllerContract} from '@coveo/thermidor-contracts'; import { buildRemoteController, selectRemoteControllerState, type RemoteControllerSource, } from './remote-controller.js'; +const cartContract = cartControllerContract.shape.schemaId.value; +const cartItem = {productId: 'p1', name: 'Product', price: 10, quantity: 2}; + describe('buildRemoteController', () => { it('selects its server-owned state from the active conversation turn', () => { - const source = createSource({controllers: {cart: {items: [{productId: 'p1'}]}}}); + const source = createSource({controllers: {cart: {items: [cartItem]}}}); const controller = buildRemoteController({ source, controllerId: 'cart', - dispatchAction: vi.fn(), + contract: cartContract, }); - expect(controller.state).toEqual({items: [{productId: 'p1'}]}); + expect(controller.state).toEqual({items: [cartItem]}); }); it('notifies subscribers when its snapshot slice changes, but not for another controller', () => { - const source = createSource({controllers: {cart: {items: []}, products: {products: []}}}); + const cart = {items: []}; + const source = createSource({controllers: {cart, products: {products: []}}}); const controller = buildRemoteController({ source, controllerId: 'cart', - dispatchAction: vi.fn(), + contract: cartContract, }); const callback = vi.fn(); controller.subscribe(callback); - source.setSnapshot({controllers: {cart: controller.state, products: {products: ['p1']}}}); + source.setSnapshot({controllers: {cart, products: {products: ['p1']}}}); expect(callback).not.toHaveBeenCalled(); - source.setSnapshot({controllers: {cart: {items: [{productId: 'p1'}]}}}); - expect(callback).toHaveBeenCalledWith({items: [{productId: 'p1'}]}); + source.setSnapshot({controllers: {cart: {items: [cartItem]}}}); + expect(callback).toHaveBeenCalledWith({items: [cartItem]}); }); - it('dispatches controller actions without locally changing server-owned state', async () => { - const dispatchAction = vi.fn(); + it('dispatches schema-derived actions without locally changing server-owned state', async () => { + const source = createSource({controllers: {cart: {items: []}}}); const controller = buildRemoteController({ - source: createSource({controllers: {cart: {items: []}}}), + source, controllerId: 'cart', - dispatchAction, + contract: cartContract, }); - await controller.dispatch('updateItemQuantity', {item: {productId: 'p1', quantity: 2}}); + await controller.dispatch('updateItemQuantity', {item: cartItem}); - expect(dispatchAction).toHaveBeenCalledWith({ + expect(source.dispatchAction).toHaveBeenCalledWith({ controllerId: 'cart', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', action: 'updateItemQuantity', - payload: {item: {productId: 'p1', quantity: 2}}, + payload: {item: cartItem}, }); expect(controller.state).toEqual({items: []}); }); - it('rejects an unnamed action', async () => { + it('returns undefined for an invalid snapshot and rejects an invalid action payload', async () => { const controller = buildRemoteController({ - source: createSource({controllers: {}}), + source: createSource({controllers: {cart: {items: 'invalid'}}}), controllerId: 'cart', - dispatchAction: vi.fn(), + contract: cartContract, }); - await expect(controller.dispatch(' ', {})).rejects.toThrow('action name is required'); + expect(controller.state).toBeUndefined(); + await expect( + controller.dispatch('updateItemQuantity', {item: {...cartItem, quantity: 0}}) + ).rejects.toThrow('Invalid payload'); }); }); @@ -75,6 +84,7 @@ function createSource(snapshot: Record) { const listeners = new Set<() => void>(); const source = { state: {activeTurn: {agentResponse: {state: snapshot}}}, + dispatchAction: vi.fn().mockResolvedValue(undefined), subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); @@ -86,6 +96,7 @@ function createSource(snapshot: Record) { }; return source as unknown as RemoteControllerSource & { + dispatchAction: ReturnType; setSnapshot(snapshot: Record): void; }; } diff --git a/packages/thermidor/src/public/controllers/remote/remote-controller.ts b/packages/thermidor/src/public/controllers/remote/remote-controller.ts index 3bdf0ee1032..aa941907bf2 100644 --- a/packages/thermidor/src/public/controllers/remote/remote-controller.ts +++ b/packages/thermidor/src/public/controllers/remote/remote-controller.ts @@ -1,48 +1,111 @@ +import type {z} from 'zod'; +import {controllerContracts, type ControllerContracts} from '@coveo/thermidor-contracts'; +import type {ConverseController} from '../converse/converse-controller.js'; import type {Controller} from '../controller-types.js'; -import type {ConverseController, ConverseControllerState} from '../converse/converse-controller.js'; -export type RemoteControllerState = Record; - -export interface RemoteControllerAction { +export interface RemoteControllerAction { controllerId: string; - action: string; + controllerSchema: string; + action: TAction; payload: TPayload; } -/** - * Delivers a controller action to the application's server transport. - * - * Thermidor deliberately does not prescribe that transport: the server owns the - * controller state and must acknowledge a mutation by publishing a later - * `STATE_SNAPSHOT`. - */ -export type RemoteControllerActionDispatcher = ( - action: RemoteControllerAction -) => void | Promise; +export type RemoteControllerSchemaId = ControllerContracts['schemaId']; + +type ControllerContractSchema = (typeof controllerContracts)['options'][number]; + +type ControllerContractSchemaFor = Extract< + ControllerContractSchema, + {shape: {schemaId: {value: TSchema}}} +>; + +export type RemoteControllerActionNameForSchema = Exclude< + keyof Extract, + 'schemaId' | 'state' +> & + string; + +export type RemoteControllerActionPayloadForSchema< + TSchema extends RemoteControllerSchemaId, + TAction extends RemoteControllerActionNameForSchema, +> = + Extract extends Record + ? TPayload + : never; + +export type RemoteControllerStateForSchema = z.infer< + Extract< + (typeof controllerContracts)['options'][number], + {shape: {schemaId: {value: TSchema}}} + >['shape']['state'] +>; + +export type RemoteControllerActionsForSchema = Omit< + Extract, + 'schemaId' | 'state' +>; /** * A controller state source backed by Thermidor's active conversation turn. */ -export type RemoteControllerSource = Pick; +export type RemoteControllerSource = Pick< + ConverseController, + 'state' | 'subscribe' | 'dispatchAction' +>; + +export interface RemoteController extends Controller< + RemoteControllerStateForSchema | undefined +> { + readonly controllerId: string; + dispatch>( + action: TAction, + payload: RemoteControllerActionPayloadForSchema + ): Promise; +} + +export type AdvertisedRemoteController = + RemoteController; + +export interface RemoteControllerOptions { + source: RemoteControllerSource; + controllerId: string; + /** The static controller schema ID advertised by the A2-UI component. */ + contract: TSchema; +} class RemoteControllerImpl< - TState extends RemoteControllerState, -> implements RemoteController { + TSchema extends RemoteControllerSchemaId, +> implements RemoteController { readonly controllerId: string; + #lastRawState: unknown; + #lastValidatedState: RemoteControllerStateForSchema | undefined; constructor( private readonly source: RemoteControllerSource, controllerId: string, - private readonly dispatchAction: RemoteControllerActionDispatcher + private readonly contract: ControllerContractSchemaFor ) { this.controllerId = controllerId; } - get state(): TState { - return selectRemoteControllerState(this.source.state, this.controllerId) as TState; + get state(): RemoteControllerStateForSchema | undefined { + const rawState = selectRemoteControllerState(this.source.state, this.controllerId); + if (rawState === this.#lastRawState) { + return this.#lastValidatedState; + } + + this.#lastRawState = rawState; + const result = this.contract.shape.state.safeParse(rawState); + this.#lastValidatedState = + result.success && isRemoteControllerState(this.contract, result.data) + ? result.data + : undefined; + return this.#lastValidatedState; } - subscribe(callback: (state: TState) => void): () => void { + subscribe( + callback: (state: RemoteControllerStateForSchema | undefined) => void + ): () => void { let previousState = this.state; return this.source.subscribe(() => { @@ -56,18 +119,28 @@ class RemoteControllerImpl< }); } - dispatch(action: string, payload: TPayload): Promise { - if (!action.trim()) { - return Promise.reject(new Error('A controller action name is required.')); + dispatch>( + action: TAction, + payload: RemoteControllerActionPayloadForSchema + ): Promise { + const actionSchema = Object.entries(this.contract.shape).find(([name]) => name === action)?.[1]; + if (!actionSchema) { + return Promise.reject(new Error(`Unknown controller action ${this.controllerId}/${action}.`)); + } + + const result = actionSchema.safeParse(payload); + if (!result.success) { + return Promise.reject( + new Error(`Invalid payload for controller action ${this.controllerId}/${action}.`) + ); } - return Promise.resolve().then(() => - this.dispatchAction({ - controllerId: this.controllerId, - action, - payload, - }) - ); + return this.source.dispatchAction({ + controllerId: this.controllerId, + controllerSchema: this.contract.shape.schemaId.value, + action, + payload: result.data, + }); } } @@ -76,41 +149,41 @@ class RemoteControllerImpl< * snapshot. The controller never mutates its local state; action results arrive * through a subsequent snapshot from the server. */ -export function buildRemoteController( - options: RemoteControllerOptions -): RemoteController { - return new RemoteControllerImpl( - options.source, - options.controllerId, - options.dispatchAction - ); + +export function buildRemoteController( + options: RemoteControllerOptions +): RemoteController { + const schema = findControllerContract(options.contract); + return new RemoteControllerImpl(options.source, options.controllerId, schema); } -export interface RemoteController< - TState extends RemoteControllerState = RemoteControllerState, -> extends Controller { - /** The runtime key used to select this controller's state from `controllers`. */ - readonly controllerId: string; +function findControllerContract( + schemaId: TSchema +): ControllerContractSchemaFor { + const contract = controllerContracts.options.find( + (candidate): candidate is ControllerContractSchemaFor => + candidate.shape.schemaId.value === schemaId + ); + if (!contract) { + throw new Error(`Unknown controller contract ${schemaId}.`); + } - /** - * Emits an action for this controller through the configured server transport. - * State remains server-owned and changes only when the server sends a snapshot. - */ - dispatch(action: string, payload: TPayload): Promise; + return contract; } -export interface RemoteControllerOptions { - source: RemoteControllerSource; - controllerId: string; - dispatchAction: RemoteControllerActionDispatcher; +function isRemoteControllerState( + contract: ControllerContractSchemaFor, + state: unknown +): state is RemoteControllerStateForSchema { + return contract.shape.state.safeParse(state).success; } -const EMPTY_REMOTE_CONTROLLER_STATE: RemoteControllerState = {}; +const EMPTY_REMOTE_CONTROLLER_STATE = {}; export function selectRemoteControllerState( - state: ConverseControllerState, + state: RemoteControllerSource['state'], controllerId: string -): RemoteControllerState { +): unknown { const snapshot = state.activeTurn?.agentResponse?.state; if (!isRecord(snapshot)) { return EMPTY_REMOTE_CONTROLLER_STATE; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3590cf96f00..9527938d95c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ catalogs: yaml: specifier: 2.9.0 version: 2.9.0 + zod: + specifier: 3.25.76 + version: 3.25.76 overrides: braces: 3.0.3 @@ -1230,9 +1233,15 @@ importers: '@ag-ui/core': specifier: 0.0.57 version: 0.0.57 + '@coveo/thermidor-contracts': + specifier: workspace:* + version: link:../thermidor-contracts '@reduxjs/toolkit': specifier: 'catalog:' version: 2.12.0(react@19.2.7) + zod: + specifier: 'catalog:' + version: 3.25.76 devDependencies: '@microsoft/api-extractor': specifier: 7.58.9 @@ -1247,6 +1256,19 @@ importers: specifier: 'catalog:' version: 4.1.9(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(jsdom@28.1.0)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + packages/thermidor-contracts: + dependencies: + zod: + specifier: 'catalog:' + version: 3.25.76 + devDependencies: + tsdown: + specifier: 0.22.0 + version: 0.22.0(oxc-resolver@11.23.0)(publint@0.3.21)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + samples/atomic/commerce-react: dependencies: '@coveo/atomic-react': @@ -2109,6 +2131,9 @@ importers: '@coveo/thermidor': specifier: workspace:* version: link:../../../packages/thermidor + '@coveo/thermidor-contracts': + specifier: workspace:* + version: link:../../../packages/thermidor-contracts react: specifier: 19.2.7 version: 19.2.7 @@ -2116,7 +2141,7 @@ importers: specifier: 19.2.7 version: 19.2.7(react@19.2.7) zod: - specifier: 3.25.76 + specifier: 'catalog:' version: 3.25.76 devDependencies: '@types/react': @@ -16697,6 +16722,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -17482,7 +17510,7 @@ snapshots: '@babel/generator@8.0.0-rc.6': dependencies: - '@babel/parser': 8.0.0-rc.6 + '@babel/parser': 8.0.4 '@babel/types': 8.0.4 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -28599,7 +28627,7 @@ snapshots: tinyglobby: 0.2.17 unbash: 4.0.2 yaml: 2.9.0 - zod: 4.3.6 + zod: 4.4.3 koa-bodyparser@4.4.1: dependencies: @@ -34208,6 +34236,8 @@ snapshots: zod@4.3.6: {} + zod@4.4.3: {} + zone.js@0.15.1: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a212d91091e..6e6ea7dfbe1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - packages/mock-converse-api - packages/headless - packages/thermidor + - packages/thermidor-contracts - packages/quantic - packages/headless-react - packages/atomic-hosted-page @@ -98,6 +99,7 @@ catalog: vite: 8.1.2 vitest: 4.1.9 yaml: 2.9.0 + zod: 3.25.76 minimumReleaseAge: 10080 diff --git a/samples/thermidor/schema-contract-react/package.json b/samples/thermidor/schema-contract-react/package.json index d6a30a162fd..1b372176d45 100644 --- a/samples/thermidor/schema-contract-react/package.json +++ b/samples/thermidor/schema-contract-react/package.json @@ -13,9 +13,10 @@ "dependencies": { "@copilotkit/a2ui-renderer": "1.61.2", "@coveo/thermidor": "workspace:*", + "@coveo/thermidor-contracts": "workspace:*", "react": "catalog:", "react-dom": "catalog:", - "zod": "3.25.76" + "zod": "catalog:" }, "devDependencies": { "@types/react": "catalog:", diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts index 89fbf5c8868..1011174b55e 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.test.ts @@ -1,6 +1,11 @@ import {describe, expect, it} from 'vitest'; import {thermidorCatalogDefinitions} from './components.js'; -import {cartItemSchema, productSchema} from './generated/catalog-components.js'; +import { + cartControllerContract, + cartItemSchema, + productListControllerContract, + productSchema, +} from '@coveo/thermidor-contracts'; describe('thermidorCatalogDefinitions', () => { it('accepts the controller advertisements supplied by the catalog message', () => { @@ -78,4 +83,23 @@ describe('thermidorCatalogDefinitions', () => { }).success ).toBe(false); }); + + it('validates generated controller state and action contracts', () => { + expect( + productListControllerContract.shape.state.safeParse({ + products: [{permanentid: 'p1', ec_name: 'Trail shoes', additionalFields: {}}], + }).success + ).toBe(true); + expect(cartControllerContract.shape.state.safeParse({items: []}).success).toBe(true); + expect( + cartControllerContract.shape.setItems.safeParse({ + items: [{productId: 'p1', name: 'Trail shoes', price: 99.99, quantity: 1}], + }).success + ).toBe(true); + expect( + cartControllerContract.shape.updateItemQuantity.safeParse({ + item: {productId: 'p1', name: 'Trail shoes', price: 99.99, quantity: 0}, + }).success + ).toBe(false); + }); }); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx index a0cc43cc525..c563372cd35 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/components.tsx +++ b/samples/thermidor/schema-contract-react/src/a2ui/components.tsx @@ -3,14 +3,8 @@ import { type CatalogDefinitions, type CatalogRenderers, } from '@copilotkit/a2ui-renderer'; -import type {RemoteControllerActionDispatcher} from '@coveo/thermidor'; import {type EngineStateSource, useAdvertisedController} from './controllers.js'; -import { - cartPropsSchema, - productCarouselPropsSchema, - type CartItem, - type Product, -} from './generated/catalog-components.js'; +import {cartPropsSchema, productCarouselPropsSchema} from '@coveo/thermidor-contracts'; export const THERMIDOR_CATALOG_ID = 'https://schema.thermidor.coveo.com/a2-ui/catalog.json'; @@ -25,18 +19,14 @@ export const thermidorCatalogDefinitions = { }, } satisfies CatalogDefinitions; -export function createThermidorCatalog( - stateSource: EngineStateSource, - dispatchAction: RemoteControllerActionDispatcher = rejectUnhandledControllerAction -) { +export function createThermidorCatalog(stateSource: EngineStateSource) { const renderers = { ProductCarousel: ({props}) => { - const [, state] = useAdvertisedController<{products?: Product[]}>( + const controller = useAdvertisedController( stateSource, - props.controllers.productListController, - dispatchAction + props.controllers.productListController ); - const products = Array.isArray(state.products) ? state.products : []; + const products = controller.state?.products ?? []; return (
@@ -81,12 +71,8 @@ export function createThermidorCatalog( ); }, Cart: ({props}) => { - const [, state] = useAdvertisedController<{items?: CartItem[]}>( - stateSource, - props.controllers.cartController, - dispatchAction - ); - const items = Array.isArray(state.items) ? state.items : []; + const controller = useAdvertisedController(stateSource, props.controllers.cartController); + const items = controller.state?.items ?? []; const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); return ( @@ -115,9 +101,3 @@ export function createThermidorCatalog( includeBasicCatalog: true, }); } - -function rejectUnhandledControllerAction(): Promise { - return Promise.reject( - new Error('No server transport was configured for the advertised controller action.') - ); -} diff --git a/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts b/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts index 3c86ee76e11..9cfc1f6b447 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts +++ b/samples/thermidor/schema-contract-react/src/a2ui/controllers.test.ts @@ -1,5 +1,10 @@ -import {describe, expect, it} from 'vitest'; -import {selectRemoteControllerState} from '@coveo/thermidor'; +import {describe, expect, it, vi} from 'vitest'; +import { + buildRemoteController, + selectRemoteControllerState, + type RemoteControllerSource, +} from '@coveo/thermidor'; +import {cartControllerContract, type CartController} from '@coveo/thermidor-contracts'; describe('selectRemoteControllerState', () => { it('selects the advertised controller slice from the active Thermidor turn', () => { @@ -16,4 +21,44 @@ describe('selectRemoteControllerState', () => { }); expect(selectRemoteControllerState(state, 'unknown-controller')).toEqual({}); }); + + it('defines CartController as an inferred Zod contract object', () => { + const contract: CartController = { + schemaId: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + state: {items: []}, + setItems: {items: []}, + updateItemQuantity: { + item: {productId: 'p1', name: 'Product', price: 10, quantity: 2}, + }, + }; + + expect(cartControllerContract.parse(contract)).toEqual(contract); + }); + + it('builds a remote controller from the advertised CartController schema ID', async () => { + const dispatchAction = vi.fn(); + const source = { + state: { + activeTurn: {agentResponse: {state: {controllers: {'shopping-cart': {items: []}}}}}, + }, + subscribe: () => () => undefined, + dispatchAction, + } as unknown as RemoteControllerSource; + const controller = buildRemoteController({ + source, + controllerId: 'shopping-cart', + contract: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + }); + + await controller.dispatch('updateItemQuantity', { + item: {productId: 'p1', name: 'Product', price: 10, quantity: 2}, + }); + + expect(dispatchAction).toHaveBeenCalledWith({ + controllerId: 'shopping-cart', + controllerSchema: 'https://schema.thermidor.coveo.com/controllers/cart.schema.json', + action: 'updateItemQuantity', + payload: {item: {productId: 'p1', name: 'Product', price: 10, quantity: 2}}, + }); + }); }); diff --git a/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx b/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx index 1589bd85905..1a94e6f9358 100644 --- a/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx +++ b/samples/thermidor/schema-contract-react/src/a2ui/controllers.tsx @@ -1,39 +1,28 @@ -import {useCallback, useMemo, useSyncExternalStore} from 'react'; +import {useMemo} from 'react'; import { buildRemoteController, - type RemoteController, - type RemoteControllerActionDispatcher, + type AdvertisedRemoteController, type RemoteControllerSource, } from '@coveo/thermidor'; +import type {ControllerContracts} from '@coveo/thermidor-contracts'; -export type ControllerAdvertisement = { +type ControllerSchemaId = ControllerContracts['schemaId']; + +export type ControllerAdvertisement = { controllerId: string; - controllerSchema: string; + controllerSchema: TSchema; }; -type ControllerState = Record; - export type EngineStateSource = RemoteControllerSource; -export function useAdvertisedController( +type AdvertisedController = AdvertisedRemoteController; + +export function useAdvertisedController( source: EngineStateSource, - advertisement: ControllerAdvertisement, - dispatchAction: RemoteControllerActionDispatcher -): [RemoteController, T] { - const controller = useMemo( - () => - buildRemoteController({ - source, - controllerId: advertisement.controllerId, - dispatchAction, - }), - [advertisement.controllerId, dispatchAction, source] + {controllerId, controllerSchema: contract}: ControllerAdvertisement +): AdvertisedController { + return useMemo( + () => buildRemoteController({source, controllerId, contract}), + [controllerId, contract, source] ); - const subscribe = useCallback( - (listener: () => void) => controller.subscribe(() => listener()), - [controller] - ); - const getSnapshot = useCallback(() => controller.state, [controller]); - - return [controller, useSyncExternalStore(subscribe, getSnapshot, getSnapshot)]; }