@coveo/thermidor

中文文档
0.9.0发布于 2026/11/11Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 2116

Experimental package for a unified, framework-agnostic headless engine for search and conversational experiences

原始使用文档
文档目录18 节
  1. The idea
  2. Public surface
  3. Opening the session
  4. Dispatching actions
  5. Submitting a prompt
  6. Component actions
  7. One request at a time
  8. Concurrent and cached actions
  9. Optimistic values
  10. Concurrency conflicts
  11. The Turn / TurnResponse model
  12. Session-scoped surfaces
  13. Validation and displayed values
  14. Injected schema, endpoint, and context
  15. Serialization
  16. Recorded interim debt and out-of-scope items
  17. Development
  18. Architecture

Coveo Thermidor

@coveo/thermidor is an experimental, framework-agnostic client for a single, stateful, intent-routing unified converse endpoint. It exposes one factory — createSession(config) — that returns a Session: a plain observable list of Turns folded from a server-sent-event (SSE) stream, plus a single action-dispatch entry point wired straight to the renderer.

The package is private, experimental, and consumed only inside this monorepo (by samples/thermidor/demo-schema-react).

There is no engine, no interface layer, no Redux/RTK, and no per-endpoint facades. References to those layers in older revisions are obsolete.

The idea

The server streams schema-defined A2UI components; the client renders what the server sends. Thermidor's job is to transmit consumer input to the endpoint and fold the streamed result into an observable list of turns — it derives no use-case behavior beyond that fold. The contracts schema, the endpoint URL, and the ambient context are all consumer-injected, so a single package build serves public, internal (private-registry schema), and proxied deployments.

Component state travels inline through the A2-UI data model: the server sends updateDataModel operations under /state/<id>, and the frozen renderer resolves each prop through an A2-UI Data_Binding ({ path: <JSON Pointer> }) against that data model. Renderers are "dumb": they receive resolved state in their props and never hydrate a controller or join a snapshot themselves. A2-UI is the only supported state source. This inline model superseded the earlier remote-controller / AG-UI StateSnapshot design.

Public surface

import {createSession} from '@coveo/thermidor';
import {ComponentContractsSchema} from '@coveo/thermidor-schema';

const session = createSession({
  organizationId: 'my-org',
  accessToken: 'xx-token',
  agentConfigurationId: '00000000-0000-0000-0000-000000000000', // the configured agent that answers the session
  trackingId: 'my-tracking-id',
  contracts: ComponentContractsSchema, // injected Zod discriminated union (Zod 3 or 4)
  navigatorContextProvider: () => getNavigatorContext(), // required; includes language, country, currency
  commerceContextProvider: () => ({cart: getCart()}),
});

session.subscribe(() => render(session.getSnapshot()));

await session.dispatchAction({name: 'submitPrompt', payload: {prompt: 'show me running shoes'}});

createSession(config) returns a Session exposing exactly:

MemberPurpose
contractsThe injected contracts schema this session validates against (readonly).
turnsReadonly observable list of Turns folded from the stream (empty initially).
initializationThe run of the initialize action that opened the session, or undefined. See Opening the session.
surfacesEvery live surface of the session, in creation order, whichever run created it.
a2uiMessagesThe session's whole renderer stream, in the A2-UI v0.9 shape. It only grows: feed each new message to one renderer once.
stateThe validated data model of every live surface, keyed by surface id.
actionErrorThe last component action that failed (ActionError: message, component, name, surfaceId, sourceComponentId), or undefined.
requestThe request being sent, or undefined when idle. See One request at a time.
subscribe(listener)Registers a listener called once per change to the turn list, the initialization, actionError or request.
getSnapshot()turns, initialization, surfaces, a2uiMessages, state, request and actionError as one immutable SessionSnapshot, replaced on change.
dispatchAction(message)The single entry point for prompts (submitPrompt) and component actions, wired directly as the renderer's onAction.
cancel()Stops the active stream.
retry(turnId)Re-submits the last turn's input when it errored: its prompt, or the action that opened it.
serialize()Serializes the transcript into a versioned SerializedSession.

subscribe and getSnapshot do not rely on this, so they can be handed by reference to any store that compares snapshots by reference, such as React's useSyncExternalStore(session.subscribe, session.getSnapshot). The package itself depends on no framework.

The concrete contracts type pinned at the createSession call site threads unbroken through Session, so component types, action names, and payloads stay fully typed at the call site (no string, no never, no unknown).

Opening the session

The session opens itself: createSession sends an initialize action a microtask after it returns, so a listener subscribed in the same task sees it start. It carries no surface or source component, since no component exists yet. While it is in flight, session.request is {kind: 'initialize'}. It goes through the one-request rule and the conflict retry, and adopts the session id and token from its run, like a prompt. The producer answers with the session's shell, such as its search box, as an ordinary surface.

Its run is recorded as session.initialization (activities, status, error), never as a turn. A failed initialize is not retried: initialization.status becomes 'error', and the first prompt still opens the session. cancel() stops it like any request. A restored session never sends it, and neither does a session created with initialize: false, for tests or for a server that creates a session while rendering.

Dispatching actions

session.dispatchAction is the single consumer-facing action entry point. It accepts two kinds of message.

Submitting a prompt

A prompt typed outside A2-UI composition, such as in the integrator's search box, is a SubmitPromptAction:

session.dispatchAction({name: 'submitPrompt', payload: {prompt: 'show me running shoes'}});

It opens a new streaming turn, records the prompt as the turn's input.prompt, and POSTs it to the converse endpoint. It needs no rendered component or active turn, so it also opens the first turn of a session. Sent while another request is in flight, it waits for it (see One request at a time), and retry(turnId) re-drives a turn it opened.

Component actions

A component action is the standard A2-UI client-to-server message (A2uiClientMessage) that the frozen renderer hands to its onAction handler, so it wires with no adapter:

<A2UIProvider catalog={catalog} onAction={session.dispatchAction}>{/* … */}</A2UIProvider>

Given a message, dispatchAction unwraps its userAction, recovers the dispatching component's discriminant from the session's live surfaces, whichever run created them, validates the action payload against the component's injected contract internally, and — on success — POSTs the action to the converse endpoint.

An action the producer announces as opening a turn, such as a follow-up chip or a search option, is answered as a new turn of the conversation, as a prompt is. The producer announces it with the surface, in the AG-UI metadata of its snapshot (metadata.turnOpeningActions, by surface id), so the consumer declares nothing and the turn opens as soon as the shopper picks the action. Such an action opens a new streaming turn whose input.action records it (component, action name, surface id, source node and payload), and that turn becomes the active turn. The surfaces of the turn it was sent from keep accepting actions. It needs no active turn, so a turn-opening action on the shell opens the first turn of a session. Any other action only updates the surfaces: its surface snapshots are recorded with the active turn, or with session.initialization before the first turn.

It is fire-and-forget: the returned Promise always resolves and never rejects, so the consumer needs no .catch. A message with no userAction, no sourceComponentId, a node that resolves to no component, or an internal validation failure is dropped with a dev-only warning and nothing is sent.

One request at a time

Thermidor sends one request at a time per session, initialize, prompts and component actions alike, except the concurrent actions the producer announces. A dispatch made while a request is in flight is withheld: nothing is sent, and its promise resolves at once with a dev-only warning. Each dispatchAction promise resolves once its own request ends.

  • session.request is the request being sent ({kind: 'initialize'}, {kind: 'prompt', turnId} or {kind: 'action', component, name, surfaceId, sourceComponentId, turnId?}, with no turnId before the first turn), or undefined when idle. An action's run counts, a follow-up included. Read it synchronously, or through subscribe, to lock or dim the interface, and to hold back a dispatch until the session is free. component is the dispatching node's discriminant, so a consumer can key its own action lists, such as the actions that keep its surfaces usable, by component and action name, typed by the contracts with ComponentActionNames.
  • cancel() stops the request in flight, but session.request stays set until it has unwound, a few microtasks later, so a dispatch made right after cancel() is withheld. Wait for request to become undefined, through subscribe, before sending the next one.

Concurrent and cached actions

The producer can answer some actions beside the turn, such as instant products for the highlighted suggestion. It announces them with the surface, in the AG-UI metadata of its snapshot, by surface id, as it announces turn-opening actions:

"metadata": {
  "concurrentActions": {"<surfaceId>": {"fetchInstantProducts": {"cache": {"ttlMs": 60000}}}}
}

Each concurrent action can carry a cache policy, the lifetime of its answers; without one, its answers are not reused. The latest announcement for a surface wins, and an empty entry revokes it. A renderer only dispatches: it holds no cache, no retry and no wait for a free session.

  • A concurrent action is sent at once, whatever request is in flight, and never sets session.request. A prompt or cancel() does not stop it, and it is withheld until the session has its token.
  • Only the latest dispatch from a node is applied. An older request in flight is aborted, unless the action is cached: then it runs to the end, so its answer is cached, and a dispatch with the same payload joins it instead of sending another.
  • A cached action dispatched again with the same payload within the announced lifetime is answered from the session's cache, and sends nothing. The cache holds the last 100 answers, lives in memory and is never serialized.
  • Its answer may only update the data model of nodes the session already holds; anything else, such as a new surface or component, is dropped with a dev-only warning, and the answer's metadata is not read. Its writes reach session.a2uiMessages and session.state, but no run records them: a later recorded write to the same path replaces them, session.serialize() leaves them out, and a restored session shows the state the last recorded run left, so the renderer asks again.
  • Its failure, including a refused request, is a dev-only warning. It sets no session.actionError and changes no run.

Optimistic values

To show a gesture's expected value until the session has answered it, show that value from the moment the gesture is sent until its dispatch promise resolves, then give way to the producer's value. A renderer that returns its onAction handler's promise from its own dispatch hands back the session.dispatchAction promise, so awaiting the renderer's dispatch is enough. Thermidor doesn't depend on a UI framework, so any state mechanism works. With React's useOptimistic, for example, a transition that awaits the dispatch lasts exactly that long:

const [values, addOptimisticValues] = useOptimistic(producerValues);

startTransition(async () => {
  addOptimisticValues(values.map((v) => (v.value === 'Nike' ? {...v, state: 'selected'} : v)));
  await renderer.dispatchAction({event: {name: 'toggleSelect', context: {value: 'Nike'}}});
});

The optimistic value gives way to the producer's once the gesture is over, whatever the outcome, so a failed or withheld gesture needs no rollback. Thermidor never computes the expected value: the integrator does.

Concurrency conflicts

When the Gateway is still busy with another request for the session, it rejects the new one (409, AGENT:ACTION_IN_FLIGHT). Thermidor retries automatically for up to 30 seconds, and a conflict that recovers shows nothing. If it gives up:

  • an action that does not open a turn ends without changing any turn, which releases its optimistic hold, and session.actionError reports it until the next action or prompt is sent;
  • an action that opens a turn sets session.actionError too, and its turn becomes error, retryable with retry(turnId) while it is the last turn;
  • a prompt's turn becomes error, retryable with retry(turnId) while it is the last turn.

The Turn / TurnResponse model

Each turn pairs one input with its streamed response:

interface Turn {
  id: string;
  input: {prompt?: string; action?: TurnAction}; // the prompt, or the action that opened the turn
  response: TurnResponse;
  status: 'streaming' | 'complete' | 'error';
  error?: string; // present iff status === 'error'
}

interface TurnResponse {
  activities: Activity[]; // raw snapshot log, latest snapshot per messageId, defaults to []
  surfaces: DiscoveredSurface[]; // the live surfaces this turn created
  agent?: {messages: AgentMessage[]; reasoningSteps: ReasoningStep[]}; // present only when the router invoked an agent
}

activities is routing-neutral and always present. agent appears only when the router invoked an agent for the turn. Consumers read turn.input.prompt, turn.response.surfaces (where to draw the turn's blocks) and turn.response.agent?.messages / turn.response.agent?.reasoningSteps.

Session-scoped surfaces

Surfaces live in the session, not in the turn that created them. The session derives them from one ordered activity log: the initialization's activities, then each turn's. A surface lives from its createSurface until its deleteSurface, whichever runs carry them, and a createSurface for a live id replaces that surface, so a producer mints a new id for each new surface.

  • session.surfaces lists every live surface in creation order; a surface the agent deletes leaves it, and one it re-creates moves to the end. Each turn's response.surfaces lists the live surfaces that turn created; find one no turn created, such as the shell, by its rootComponentType. A loading placeholder is listed only while the run that created it streams: while its turn or the initialization streams, or while the request in flight streams into that run and had not found it already left behind; one the agent left behind (a cancelled run, an error, a restored session) leaves the lists, so a consumer draws a skeleton for every listed surface whose isLoading holds.
  • session.a2uiMessages is the whole session's renderer stream, in arrival order. It only grows, so a single renderer is fed each new message once. Before a createSurface that replaces a live surface, it carries a deleteSurface for it, and it leaves out an operation on a surface that is not live, since a renderer refuses both. It exists because Agent Gateway emits A2-UI v1.0 while every available renderer consumes v0.9: Thermidor downgrades the stream on the consumer's behalf (recorded interim debt, see below).
  • session.state is the validated data model of every live surface, keyed by surface id, whichever run wrote it, with the answers to concurrent actions on top.
  • An action resolves its source component across the session's live surfaces, so the surfaces of earlier runs keep accepting actions. Its run only updates the surfaces: its surface snapshots are recorded with the active turn, or with the initialization before the first turn, and it never changes the status or response of a turn or of the initialization. When it fails, whether the endpoint rejects it, it streams RUN_ERROR, its stream ends without a terminal event or its request cannot be built, session.actionError identifies the action (message, component, name, surfaceId, sourceComponentId), so a consumer can show the failure next to the block it came from.

Each DiscoveredSurface carries surfaceId, rootComponentType, and isLoading, which is true while the surface's data model declares isLoading: true (a loading placeholder for rootComponentType).

Validation and displayed values

  • Client-side validation is retained, transparently. The injected contracts schema stays the sole validation source, but the consumer never runs Zod and writes no adapter glue. Inbound updateDataModel ops are validated before they are applied to session.state, against the components of every live surface; outbound action payloads are validated in the private dispatch path before the POST. Both boundaries live inside the core. session.a2uiMessages is derived from the raw stream, so the renderer still receives an op that validation drops from session.state, and the component state seeded by createSurface.dataModel, which is never validated.
  • Displayed-value formatting is agent-produced. The renderer shows the state it receives through its { path } bindings; Thermidor does not reformat displayed values.

Injected schema, endpoint, and context

  • Contracts schema — a consumer-injected Zod discriminated union (contracts) is the sole source for component-state and action-payload validation. Thermidor accepts it through a structural typing seam (ContractsSchema / ComponentContractSchema / ParsableSchema) that reads only public Zod API, so it declares no dependency on any specific schema package. It declares zod as a peer dependency spanning both majors (3.25.76 || ^4), so validation runs through the single shared zod instance resolved by the injected schema. A consumer whose renderer is built on Zod 3 injects a Zod 3 schema, such as @coveo/thermidor-schema/zod3.
  • Endpoint — when endpoint is provided, the client POSTs to it verbatim. When absent, it resolves https://{orgId}.org.coveo.com and appends the fixed converse path /api/preview/organizations/{orgId}/agents/commerce/agui/converse.
  • Context providers — navigatorContextProvider (required) and commerceContextProvider are synchronous functions read fresh per request. The navigator context carries the request's language, country and currency. Context is never stored on the session and never serialized.

Serialization

session.serialize() produces a versioned SerializedSession (integer version >= 1), distinct from the runtime Turn type. Pass it back as sessionToRestore in createSession to continue a conversation. The initialization, the turns and the session's recorded state are persisted, without the answers to concurrent actions; the surfaces and the renderer stream are never persisted (they are re-derived from the initialization's and the turns' activities on restore); a turn or initialization persisted mid-stream is downgraded to error with 'Stream was interrupted', preserving what it holds.

Recorded interim debt and out-of-scope items

The following are intentionally not yet implemented and are recorded as debt or deferred work:

  • Interim surface derivation — surfaces is a typed projection derived from activities, annotated as interim; the core interprets no rootComponentType; consumers read it. It is retired once the server sends typed routing.
  • The A2-UI v0.9 renderer projection — session.a2uiMessages downgrades Agent Gateway's v1.0 stream for today's v0.9 renderers. Removed in a breaking change once a v1.0-capable renderer exists.
  • Renderer stream bypasses validation — inbound ops that in-transit validation drops from session.state still reach the renderer through session.a2uiMessages, and so does the component state seeded by createSurface.dataModel, which is never validated. A fix must cover both paths.
  • Enforced action-payload typing at the renderer boundary — the renderer hands dispatchAction a loosely typed action message, so per-component action typing is applied by convention (the consumer opts into the generated XxxAction types) rather than enforced at the boundary. Recoverable later if the package owns binding resolution.
  • Package rename — deferred as low-stakes; the package remains @coveo/thermidor.

Development

pnpm --filter @coveo/thermidor build      # build project
pnpm --filter @coveo/thermidor test       # run unit + property tests
pnpm --filter @coveo/thermidor test:dts   # validate public API surface (incl. DX type test)

Architecture

See the Architecture Guide for the internal structure, and the accepted decisions of record in docs/internal/adr.

版本与发布记录

@coveo/thermidor 共发布 15 个版本,首次发布于 2026/06/25;过去 12 个月发布了 15 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.9.0latest2026/11/113 天前
0.8.12026/11/094 天前
0.8.02026/11/0410 天前
0.7.02026/11/0311 天前
0.6.02026/10/2717 天前
0.5.02026/10/2025 天前

关于 @coveo/thermidor 的常见问题

@coveo/thermidor 是什么?

Experimental package for a unified, framework-agnostic headless engine for search and conversational experiences

如何安装 @coveo/thermidor?

在项目目录运行 npm install @coveo/thermidor,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:zod(3.25.76 || ^4)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@coveo/thermidor 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@coveo/thermidor 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@coveo/thermidor 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js ^22.11.0 || ^24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@coveo/thermidor 可以商用吗?

@coveo/thermidor 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@coveo/thermidor 的下载量有多少?

根据 npm 官方下载统计,@coveo/thermidor 在 2026/10/11 – 2026/11/09 这 30 天内被下载 2,116 次,日均约 71 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@coveo/thermidor 还在维护吗?

最新版本 0.9.0 发布于 2026/11/11(3 天前);至今共发布 15 个版本,其中过去 12 个月发布了 15 个;首次发布于 2026/06/25。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。