From 755b9289473f65b197cafcdee94e975adde0746c Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 14 Jul 2026 18:33:01 -0700 Subject: [PATCH 1/9] refactor(transaction): app-defined verbs + server-composed transactions Reshape the assistant feature into a transaction system, layered on top of feat/define-use-assistant for merge back into #930: - defineTransaction({ ... }) replaces defineAssistant: verb names are app-defined (banner, narration, blogPost, ...) instead of the fixed capability set. Two verb kinds: chatVerb(cb) for conversational surfaces (several per endpoint now supported) and verb({ input, execute }) for one-shots with Standard-Schema-validated inputs (400 + issues on failure). - Transactions: a verb's execute receives ctx; ctx.call(siblingVerb, input) runs sub-work inside the same request, streamed live to the client as tagged transaction:sub-run:* events (parentRunId-scoped), including chat verbs run to completion ({ text, structured }). One request, one abort scope: stop()/disconnect trips req.signal/ctx.signal server-side. - Client: TransactionClient composes one ChatClient per chat verb and one GenerationClient per one-shot verb, demuxing sub-run events into live per-verb subRuns state. TransactionSystem infers every surface from the server definition ('~verbs' phantom): chat tools/outputSchema per verb, run(input)/result typed from each verb's schema and execute return. - Hooks: useTransaction (react/solid/vue) and createTransaction (svelte) replace useAssistant/createAssistant; per-verb options nest under options.verbs to avoid name collisions. - E2E: transaction routes/spec/fixtures replace the assistant ones and add ctx.call sub-run coverage; blog-studio example reworked around a one-click blogPost transaction with live sub-run steps plus user-driven regenerate verbs; docs rewritten (overview/transactions/scenarios), ai-core skill and changeset updated. pnpm test:pr green across all 66 projects; transaction e2e spec passes. Co-Authored-By: Claude Fable 5 --- .changeset/assistant-multi-capability.md | 10 - .changeset/transaction-verbs.md | 10 + docs/assistant/multiple-assistants.md | 280 ------------ docs/assistant/overview.md | 355 --------------- docs/assistant/scenarios.md | 252 ---------- docs/config.json | 21 +- docs/transaction/overview.md | 417 +++++++++++++++++ docs/transaction/scenarios.md | 204 +++++++++ docs/transaction/transactions.md | 312 +++++++++++++ .../src/routes/api.blog-studio.ts | 161 +++++-- .../ts-react-chat/src/routes/blog-studio.tsx | 369 +++++++++------ packages/ai-client/package.json | 6 +- packages/ai-client/src/assistant-client.ts | 90 ---- packages/ai-client/src/assistant-types.ts | 400 ---------------- packages/ai-client/src/assistant.ts | 9 - packages/ai-client/src/transaction-client.ts | 196 ++++++++ ...tructured.ts => transaction-structured.ts} | 2 +- packages/ai-client/src/transaction-types.ts | 372 +++++++++++++++ packages/ai-client/src/transaction.ts | 18 + .../ai-client/tests/assistant-client.test.ts | 165 ------- .../tests/transaction-client.test.ts | 276 +++++++++++ ...test.ts => transaction-structured.test.ts} | 2 +- packages/ai-client/vite.config.ts | 2 +- packages/ai-react/package.json | 9 +- packages/ai-react/src/assistant.ts | 1 - packages/ai-react/src/transaction.ts | 1 + packages/ai-react/src/use-assistant.ts | 248 ---------- packages/ai-react/src/use-transaction.ts | 227 +++++++++ .../ai-react/tests/use-assistant.test.tsx | 151 ------ .../ai-react/tests/use-transaction.test.tsx | 204 +++++++++ packages/ai-react/vite.config.ts | 2 +- packages/ai-solid/package.json | 9 +- packages/ai-solid/src/assistant.ts | 1 - packages/ai-solid/src/transaction.ts | 1 + packages/ai-solid/src/use-assistant.ts | 337 -------------- packages/ai-solid/src/use-transaction.ts | 323 +++++++++++++ .../ai-solid/tests/use-assistant.test.tsx | 180 -------- .../ai-solid/tests/use-transaction.test.tsx | 224 +++++++++ packages/ai-solid/tsdown.config.ts | 2 +- packages/ai-svelte/package.json | 11 +- packages/ai-svelte/src/assistant.ts | 1 - .../ai-svelte/src/create-assistant.svelte.ts | 307 ------------- .../src/create-transaction.svelte.ts | 311 +++++++++++++ packages/ai-svelte/src/transaction.ts | 1 + .../tests/create-assistant.svelte.test.ts | 102 ----- .../tests/create-transaction.svelte.test.ts | 187 ++++++++ packages/ai-vue/package.json | 9 +- packages/ai-vue/src/assistant.ts | 1 - packages/ai-vue/src/transaction.ts | 1 + packages/ai-vue/src/use-assistant.ts | 263 ----------- packages/ai-vue/src/use-transaction.ts | 296 ++++++++++++ packages/ai-vue/tests/use-assistant.test.ts | 152 ------ packages/ai-vue/tests/use-transaction.test.ts | 236 ++++++++++ packages/ai-vue/tsdown.config.ts | 2 +- packages/ai/package.json | 6 +- packages/ai/skills/ai-core/SKILL.md | 2 + packages/ai/skills/ai-core/assistant/SKILL.md | 389 ---------------- .../skills/ai-core/chat-experience/SKILL.md | 8 +- .../custom-backend-integration/SKILL.md | 9 +- .../skills/ai-core/media-generation/SKILL.md | 9 +- .../ai/skills/ai-core/transaction/SKILL.md | 389 ++++++++++++++++ packages/ai/src/activities/assistant/index.ts | 131 ------ packages/ai/src/activities/assistant/types.ts | 118 ----- .../ai/src/activities/transaction/index.ts | 431 ++++++++++++++++++ .../ai/src/activities/transaction/types.ts | 195 ++++++++ packages/ai/src/assistant.ts | 13 - packages/ai/src/transaction.ts | 26 ++ .../tests/activities/assistant/index.test.ts | 140 ------ .../activities/chat/chat-result-meta.test.ts | 2 +- .../activities/transaction/index.test.ts | 308 +++++++++++++ packages/ai/vite.config.ts | 2 +- pnpm-lock.yaml | 12 + testing/e2e/fixtures/assistant/basic.json | 10 - testing/e2e/fixtures/transaction/basic.json | 28 ++ testing/e2e/src/lib/feature-support.ts | 2 +- testing/e2e/src/lib/features.ts | 2 +- testing/e2e/src/lib/types.ts | 4 +- testing/e2e/src/routeTree.gen.ts | 84 ++-- testing/e2e/src/routes/api.assistant.ts | 52 --- testing/e2e/src/routes/api.transaction.ts | 116 +++++ testing/e2e/src/routes/assistant.tsx | 128 ------ testing/e2e/src/routes/transaction.tsx | 179 ++++++++ testing/e2e/tests/assistant.spec.ts | 62 --- testing/e2e/tests/transaction.spec.ts | 119 +++++ 84 files changed, 6083 insertions(+), 4622 deletions(-) delete mode 100644 .changeset/assistant-multi-capability.md create mode 100644 .changeset/transaction-verbs.md delete mode 100644 docs/assistant/multiple-assistants.md delete mode 100644 docs/assistant/overview.md delete mode 100644 docs/assistant/scenarios.md create mode 100644 docs/transaction/overview.md create mode 100644 docs/transaction/scenarios.md create mode 100644 docs/transaction/transactions.md delete mode 100644 packages/ai-client/src/assistant-client.ts delete mode 100644 packages/ai-client/src/assistant-types.ts delete mode 100644 packages/ai-client/src/assistant.ts create mode 100644 packages/ai-client/src/transaction-client.ts rename packages/ai-client/src/{assistant-structured.ts => transaction-structured.ts} (96%) create mode 100644 packages/ai-client/src/transaction-types.ts create mode 100644 packages/ai-client/src/transaction.ts delete mode 100644 packages/ai-client/tests/assistant-client.test.ts create mode 100644 packages/ai-client/tests/transaction-client.test.ts rename packages/ai-client/tests/{assistant-structured.test.ts => transaction-structured.test.ts} (97%) delete mode 100644 packages/ai-react/src/assistant.ts create mode 100644 packages/ai-react/src/transaction.ts delete mode 100644 packages/ai-react/src/use-assistant.ts create mode 100644 packages/ai-react/src/use-transaction.ts delete mode 100644 packages/ai-react/tests/use-assistant.test.tsx create mode 100644 packages/ai-react/tests/use-transaction.test.tsx delete mode 100644 packages/ai-solid/src/assistant.ts create mode 100644 packages/ai-solid/src/transaction.ts delete mode 100644 packages/ai-solid/src/use-assistant.ts create mode 100644 packages/ai-solid/src/use-transaction.ts delete mode 100644 packages/ai-solid/tests/use-assistant.test.tsx create mode 100644 packages/ai-solid/tests/use-transaction.test.tsx delete mode 100644 packages/ai-svelte/src/assistant.ts delete mode 100644 packages/ai-svelte/src/create-assistant.svelte.ts create mode 100644 packages/ai-svelte/src/create-transaction.svelte.ts create mode 100644 packages/ai-svelte/src/transaction.ts delete mode 100644 packages/ai-svelte/tests/create-assistant.svelte.test.ts create mode 100644 packages/ai-svelte/tests/create-transaction.svelte.test.ts delete mode 100644 packages/ai-vue/src/assistant.ts create mode 100644 packages/ai-vue/src/transaction.ts delete mode 100644 packages/ai-vue/src/use-assistant.ts create mode 100644 packages/ai-vue/src/use-transaction.ts delete mode 100644 packages/ai-vue/tests/use-assistant.test.ts create mode 100644 packages/ai-vue/tests/use-transaction.test.ts delete mode 100644 packages/ai/skills/ai-core/assistant/SKILL.md create mode 100644 packages/ai/skills/ai-core/transaction/SKILL.md delete mode 100644 packages/ai/src/activities/assistant/index.ts delete mode 100644 packages/ai/src/activities/assistant/types.ts create mode 100644 packages/ai/src/activities/transaction/index.ts create mode 100644 packages/ai/src/activities/transaction/types.ts delete mode 100644 packages/ai/src/assistant.ts create mode 100644 packages/ai/src/transaction.ts delete mode 100644 packages/ai/tests/activities/assistant/index.test.ts create mode 100644 packages/ai/tests/activities/transaction/index.test.ts delete mode 100644 testing/e2e/fixtures/assistant/basic.json create mode 100644 testing/e2e/fixtures/transaction/basic.json delete mode 100644 testing/e2e/src/routes/api.assistant.ts create mode 100644 testing/e2e/src/routes/api.transaction.ts delete mode 100644 testing/e2e/src/routes/assistant.tsx create mode 100644 testing/e2e/src/routes/transaction.tsx delete mode 100644 testing/e2e/tests/assistant.spec.ts create mode 100644 testing/e2e/tests/transaction.spec.ts diff --git a/.changeset/assistant-multi-capability.md b/.changeset/assistant-multi-capability.md deleted file mode 100644 index c02ea564b..000000000 --- a/.changeset/assistant-multi-capability.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@tanstack/ai': minor -'@tanstack/ai-client': minor -'@tanstack/ai-react': minor -'@tanstack/ai-solid': minor -'@tanstack/ai-vue': minor -'@tanstack/ai-svelte': minor ---- - -Add defineAssistant (server) and useAssistant (client) for multi-capability assistants. diff --git a/.changeset/transaction-verbs.md b/.changeset/transaction-verbs.md new file mode 100644 index 000000000..5c211ca91 --- /dev/null +++ b/.changeset/transaction-verbs.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +--- + +Add defineTransaction (server) and useTransaction/createTransaction (client): declare app-defined verbs — chatVerb for conversational surfaces, verb for one-shot work with schema-validated inputs — behind one endpoint and drive them from one fully typed client hook. A verb's execute can compose sibling verbs via ctx.call into a server-composed transaction: one request runs the whole pipeline, every sub-run streams back live, and a single stop()/disconnect aborts everything. diff --git a/docs/assistant/multiple-assistants.md b/docs/assistant/multiple-assistants.md deleted file mode 100644 index ae865ba10..000000000 --- a/docs/assistant/multiple-assistants.md +++ /dev/null @@ -1,280 +0,0 @@ ---- -title: Multiple Assistants -id: multiple-assistants -order: 3 -description: "Give each product surface its own assistant — a support page with chat only, a content studio with chat + image + speech — each with its own route, its own useAssistant, and its own system prompt. Learn when to split into several assistants versus one broad one." -keywords: - - tanstack ai - - assistant - - defineAssistant - - useAssistant - - multiple assistants - - system prompt - - routes ---- - -**One `defineAssistant` per product surface.** An assistant bundles a *fixed* set of capabilities behind one endpoint with one system prompt. When different pages or features want different capability sets or different instructions, don't stretch a single assistant to cover them all — define one per surface. Each gets its own route and its own `useAssistant` on its own page. - -A typical app ends up with a few: - -| Surface | Capabilities | Why its own assistant | -|---|---|---| -| Customer **support** page | `chat` only | Tight, support-flavored system prompt; no image/speech to expose | -| **Content studio** page | `chat` + `image` + `speech` | A creative workflow that chains capabilities | -| Internal **dashboard** | `chat` + `summarize` | Different auth boundary; different instructions | - -## Two assistants, two routes - -Give each assistant its own module and its own handler. A small helper keeps the shared adapter config — the model choice and any provider options — in one place, so both assistants stay in sync without duplicating it: - -```ts -// api/assistants.ts -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' - -// One place to change the model / provider options for every assistant. -const textModel = () => openaiText('gpt-5.5') - -// Support: chat only, with a support-flavored system prompt. -export const supportAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: textModel(), - messages: req.messages, - systemPrompts: [ - 'You are a customer-support agent for Acme. Be concise and friendly.', - ], - }), -}) - -// Studio: chat + image + speech, for a creative workflow. -export const studioAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: textModel(), - messages: req.messages, - systemPrompts: ['You are a creative copywriter.'], - }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, - speech: (req) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }), -}) - -// Two routes — one per assistant. -export const supportPOST = (request: Request) => supportAssistant.handler(request) -export const studioPOST = (request: Request) => studioAssistant.handler(request) -``` - -In a real app these are two route files — `/api/support` and `/api/studio` — each exporting its own `POST`. The `supportPOST` / `studioPOST` names above just let both live in one snippet. - -## Two pages, two clients - -Each page calls `useAssistant` against *its own* assistant and *its own* connection. The support page only ever sees `assistant.chat`, because that's all `supportAssistant` declared: - -```tsx -// pages/SupportPage.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiText } from '@tanstack/ai-openai' - -const textModel = () => openaiText('gpt-5.5') - -// The same object your /api/support route exports. -const supportAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: textModel(), - messages: req.messages, - systemPrompts: [ - 'You are a customer-support agent for Acme. Be concise and friendly.', - ], - }), -}) - -function SupportPage() { - const assistant = useAssistant(supportAssistant, { - connection: fetchServerSentEvents('/api/support'), - }) - - return ( -
- - {assistant.chat.messages.map((message) => ( -

- {message.parts.find((part) => part.type === 'text')?.content} -

- ))} -
- ) -} -``` - -The studio page points at `/api/studio` and gets `assistant.chat`, `assistant.image`, and `assistant.speech`: - -```tsx -// pages/StudioPage.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' - -const textModel = () => openaiText('gpt-5.5') - -// The same object your /api/studio route exports. -const studioAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: textModel(), - messages: req.messages, - systemPrompts: ['You are a creative copywriter.'], - }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, - speech: (req) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }), -}) - -function StudioPage() { - const assistant = useAssistant(studioAssistant, { - connection: fetchServerSentEvents('/api/studio'), - }) - - return ( -
- - - {assistant.image.result?.images[0]?.url && ( - - )} -
- ) -} -``` - -Each page's `assistant` object is typed to exactly its assistant's capabilities — `SupportPage` has no `assistant.image` to misuse, and there's no way to accidentally call the support route with an image request. - -## Two assistants on one page - -Multiple assistants aren't only for *separate* pages. You can co-locate two **specialized** assistants in the same component — each keeping its own route, connection, auth boundary, and type surface — and compose them in one UI. The glue is the resolved `await` returns: every method hands its result straight back, so one assistant's output flows into the other's input without any reactive-state juggling. - -Here a campaign builder pairs a **structured-output copy** assistant (its own `/api/copy` route) with a **media** assistant (its own `/api/media` route): - -```tsx -// pages/CampaignBuilder.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -const TaglineSchema = z.object({ headline: z.string(), subhead: z.string() }) - -// Copy: structured-output chat, its own /api/copy route. -const copyAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - outputSchema: TaglineSchema, - stream: true, - }), -}) - -// Media: image + speech, its own /api/media route. -const mediaAssistant = defineAssistant({ - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ adapter: openaiImage('gpt-image-2'), prompt: req.prompt }) - }, - speech: (req) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }), -}) - -function CampaignBuilder() { - const copy = useAssistant(copyAssistant, { - connection: fetchServerSentEvents('/api/copy'), - }) - const media = useAssistant(mediaAssistant, { - connection: fetchServerSentEvents('/api/media'), - }) - - async function build(brief: string) { - // One assistant drafts the copy... - const tagline = await copy.chat.sendMessage(`Write a tagline for: ${brief}`) - if (!tagline) return - // ...the other turns it into art and audio. Two routes, one workflow. - await media.image.generate({ prompt: tagline.headline }) - await media.speech.generate({ text: tagline.subhead }) - } - - return ( -
- -

{copy.chat.partial.headline ?? 'Drafting…'}

- {media.image.result?.images[0]?.url && ( - - )} -
- ) -} -``` - -Each assistant keeps its own connection, route, auth boundary, and type surface: `copy` has no `.image` and `media` has no `.chat`, so neither can misuse the other's capabilities or route. Yet they compose cleanly in one component, because each method resolves to its result — `copy.chat.sendMessage` hands back the validated `{ headline, subhead }` (its callback declared `outputSchema`), which threads directly into `media.image.generate` and `media.speech.generate`. The reactive fields (`copy.chat.partial`, `media.image.result`) are still there for rendering. - -Contrast this with a single broad assistant that declared all three capabilities behind **one** route: that's the right choice when the capabilities truly share a connection and boundary — see [Scenarios](./scenarios) for that one-assistant chaining shape. Reach for two assistants on one page when the capabilities need to appear together but have **different boundaries** — separate routes, auth, or models — that you don't want to collapse into one endpoint. - -## Split, or one broad assistant? - -Both are valid. Choose by how the capabilities actually relate: - -**Split into multiple assistants when:** - -- **Different product surfaces.** A support widget and a content studio are different features with different users — separate assistants keep their prompts, capabilities, and routes independent. -- **Different capability sets.** If support never needs image generation, don't expose it there. A narrower assistant is a narrower attack surface and a simpler client type. -- **Different auth or rate-limit boundaries.** Separate routes let you guard `/api/support` and `/api/studio` independently. - -**Use one broad assistant when:** - -- **The capabilities are always used together** on the same page or in the same workflow — that's exactly the [pipeline / content-workflow](./scenarios) shape, where one capability feeds the next. -- **They share a system prompt and auth boundary.** If splitting would just duplicate the same configuration twice, keep it as one. - -**Co-locate multiple assistants on one page when:** the capabilities need to appear together in the same UI but have **different boundaries** — separate routes, auth, or models. This is the middle ground between the two options above: separate assistants (so each keeps its own boundary and type surface), composed in one component via their resolved `await` returns (see [Two assistants on one page](#two-assistants-on-one-page)). - -The rule of thumb: **split by surface, not by capability count.** A single page that happens to use three capabilities is one assistant; three pages that each use chat are three assistants. And when one page needs two *different* boundaries side by side, that's two assistants on one page. - -## Next - -- [Assistant Overview](./overview) — capabilities, typing, and when to reach for an assistant at all. -- [Scenarios](./scenarios) — chaining capabilities within a single assistant. diff --git a/docs/assistant/overview.md b/docs/assistant/overview.md deleted file mode 100644 index 4663c843a..000000000 --- a/docs/assistant/overview.md +++ /dev/null @@ -1,355 +0,0 @@ ---- -title: Assistant Overview -id: assistant-overview -order: 1 -description: "An assistant bundles several AI capabilities — chat, image, speech, and more — behind ONE endpoint and hands you ONE typed client. Learn when to reach for an assistant instead of the single-capability hooks, and how to define and drive one." -keywords: - - tanstack ai - - assistant - - defineAssistant - - useAssistant - - multi-capability - - chat - - image generation - - text-to-speech ---- - -**An assistant bundles multiple AI capabilities — `chat`, `image`, `speech`, and more — behind a single request handler on the server, and gives you back a single typed client on the browser.** You declare the capabilities once with `defineAssistant()`; `useAssistant()` returns one `assistant` object keyed by exactly the capabilities you declared, all sharing one connection. - -`defineAssistant()` lives on the `/assistant` subpath of `@tanstack/ai`, and `useAssistant()` on the `/assistant` subpath of `@tanstack/ai-react` (Solid, Vue, and Svelte follow the same pattern) — not the package root. - -```ts -// api/assistant.ts -import { chat, generateImage } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiText } from '@tanstack/ai-openai' - -export const blogAssistant = defineAssistant({ - chat: (req) => - chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), - image: (req) => { - // `req.prompt` arrives as `unknown` — narrow it before use. - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, -}) - -export const POST = (request: Request) => blogAssistant.handler(request) -``` - -That one route now serves both chat and image generation. On the client, one hook drives both: - -```tsx -// components/Assistant.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, generateImage } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiText } from '@tanstack/ai-openai' - -// The same object your server route exports — share it from one module in a -// real app; repeated here so this snippet type-checks on its own. -const blogAssistant = defineAssistant({ - chat: (req) => - chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, -}) - -function Assistant() { - const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - }) - - return ( -
- - {assistant.chat.messages.map((message) => ( -

- {message.parts.find((part) => part.type === 'text')?.content} -

- ))} - - - {assistant.image.result?.images[0]?.url && ( - - )} -
- ) -} -``` - -`assistant.chat` behaves exactly like [`useChat`](../chat/streaming) — streaming, tool calls, and message parts all work the same way. Each one-shot capability (`assistant.image`, `assistant.speech`, ...) behaves like its matching single hook ([`useGenerateImage`](../media/image-generation#hook-api), and so on): a `.generate(input)` method plus reactive `.result`, `.isLoading`, and `.error`. - -Both `assistant..generate(input)` and `assistant.chat.sendMessage(...)` **resolve to their result**, so you can chain them with a single `await` — `generate` resolves to the generation result (or `null`), and `sendMessage` resolves to the structured `final` (when the chat callback set `outputSchema`) or the messages array otherwise (`const result = await assistant.image.generate(...)`). The reactive `.result` / `.messages` / `.partial` / `.final` remain for rendering. → Chaining these awaited returns is exactly where an assistant pays off; see [Scenarios](./scenarios). - -## When should you reach for an assistant? - -An assistant is not "a better `useChat`." It earns its place only when you have **two or more capabilities that live on the same surface** — a page, a workflow, a pipeline. If you need exactly one capability, the single hook is simpler and there is nothing to gain from the extra layer. - -| Your situation | Reach for | Why | -|---|---|---| -| One page needs chat **and** image **and** speech | An assistant | One connection, one client object, one route to deploy and secure | -| One capability feeds the next (generate an image → write copy about it → narrate it) | An assistant | Typed handoffs between capabilities on the same `assistant` object | -| A multi-step content pipeline behind one endpoint | An assistant | The whole workflow is one deployable unit with one auth boundary | -| A page that only sends chat messages | [`useChat`](../chat/streaming) | No second capability to share — the assistant layer adds nothing | -| A page that only generates images | [`useGenerateImage`](../media/generation-hooks) | Same — a single hook is the whole story | - -**The honest tradeoff:** an assistant centralizes routing and typing across capabilities, but it also couples them into one definition and one endpoint. If two features never appear together and want different auth or system prompts, don't force them into one assistant — give each its own (see [Multiple Assistants](./multiple-assistants)). Reach for an assistant when capabilities *share* something (a connection, a page, or each other's output); keep them separate when they don't. - -## Defining capabilities (server) - -Each capability is a plain callback: `(req) => `. The `chat` capability returns the stream `chat()` produces; one-shot capabilities (`image`, `speech`, `audio`, `video`, `transcription`, `summarize`) return either a `Promise` of their result or — with `stream: true` — an `AsyncIterable`. Every key is optional; the client only ever sees the capabilities you declare. - -`defineAssistant()` itself is inert: none of these callbacks run, and no adapter is constructed, until a request actually reaches `blogAssistant.handler`. The handler discriminates each incoming request by capability — routed there by the client — parses it into the matching request shape (`AssistantChatRequest`, `AssistantImageRequest`, `AssistantSpeechRequest`, ...), and streams whatever the callback returns back over Server-Sent Events. - -Because one-shot request fields such as `req.prompt` arrive as `unknown` from the wire, narrow them with a `typeof` check (as above) before handing them to the adapter — never assume the shape. - -## Typed chat: tools and structured output - -The `chat` capability inherits `useChat`'s full type inference, driven entirely by what the server callback passes to `chat()` — you don't re-declare anything on the client. - -**Callback tools auto-type `assistant.chat.messages`.** Pass `tools` to `chat()` in the server callback and the tool-call/result parts on `assistant.chat.messages` are typed from those tools, with nothing to register on the client: - -```tsx -// components/WeatherChat.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, toolDefinition } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -const getWeatherDef = toolDefinition({ - name: 'get_weather', - description: 'Get the current weather for a city', - inputSchema: z.object({ city: z.string() }), - outputSchema: z.object({ tempF: z.number(), conditions: z.string() }), -}) - -const getWeather = getWeatherDef.server(async ({ city }) => { - return { tempF: 72, conditions: `Sunny in ${city}` } -}) - -// The same object your server route exports — share it from one module in a -// real app; repeated here so this snippet type-checks on its own. -const blogAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - tools: [getWeather], - }), -}) - -function WeatherChat() { - const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - }) - - const weatherCall = assistant.chat.messages - .at(-1) - ?.parts.find( - (part) => part.type === 'tool-call' && part.name === 'get_weather', - ) - - return ( -
- - {weatherCall?.type === 'tool-call' && weatherCall.output && ( -

{weatherCall.output.conditions}

- )} -
- ) -} -``` - -No `chat: { tools }` option was passed to `useAssistant` — `weatherCall.output` is still narrowed to `{ tempF: number; conditions: string }`, inferred entirely from `tools: [getWeather]` on the server callback. - -**`chat: { tools }` is optional — you need it only for client-executed tools.** A [client tool](../tools/client-tools)'s `.client()` implementation runs in the browser, so its code can't cross the wire. The server callback sees only the tool's *definition* (for typing and to tell the model it exists); the client registers the runtime implementation via `chat: { tools }`. Types still come from the server callback either way: - -```tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, toolDefinition } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -const showToastDef = toolDefinition({ - name: 'show_toast', - description: 'Show a browser notification', - inputSchema: z.object({ message: z.string() }), -}) - -const showToast = showToastDef.client((input) => { - console.log(input.message) - return { ok: true } -}) - -// The same object your server route exports — share it from one module in a -// real app; repeated here so this snippet type-checks on its own. -const blogAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - tools: [showToastDef], // definition only — the client executes it - }), -}) - -function useAssistantWithClientTool() { - return useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - chat: { tools: [showToast] }, - }) -} -``` - -**`outputSchema` → typed `assistant.chat.partial` / `.final`.** If the `chat` callback passes an `outputSchema` to `chat()`, `assistant.chat` picks up a progressively-parsed `partial` (`DeepPartial`) and a validated terminal `final` — the same inference [`useChat({ outputSchema })`](../structured-outputs/streaming) gives you: - -```tsx -// components/BlogOutlineForm.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -const BlogOutlineSchema = z.object({ - title: z.string(), - sections: z.array(z.string()), -}) - -// The same object your server route exports — share it from one module in a -// real app; repeated here so this snippet type-checks on its own. -const blogAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - outputSchema: BlogOutlineSchema, - stream: true, - }), -}) - -function BlogOutlineForm() { - const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - }) - - return ( -
- -

Title: {assistant.chat.partial.title ?? '…'}

-
    - {assistant.chat.partial.sections?.map((section, i) => ( -
  • {section}
  • - ))} -
- {assistant.chat.final && ( -
{JSON.stringify(assistant.chat.final, null, 2)}
- )} -
- ) -} -``` - -`partial` and `final` only exist on the type when the `chat` callback declares `outputSchema` — omit it and `assistant.chat` has no such fields, exactly like `useChat` without `outputSchema`. See [Structured Outputs](../structured-outputs/overview) for the full story. - -## Transforming one-shot results - -Each one-shot capability accepts an optional `onResult` transform, keyed by the -capability name on `useAssistant`'s options — the same shape as the standalone -generation hooks. It runs on the raw backend result before it lands on -`assistant..result`, and **its return type becomes that -capability's `result` type**. Use it to reshape a result once, at the hook, -instead of deriving it in every component that reads it: - -```tsx -// components/CoverArt.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, generateImage } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiText } from '@tanstack/ai-openai' - -const blogAssistant = defineAssistant({ - chat: (req) => - chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, -}) - -function CoverArt() { - const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - // `result` is the raw `ImageGenerationResult`; the return type flows - // through to `assistant.image.result`. - image: { onResult: (result) => result.images[0]?.url ?? null }, - }) - - return ( -
- - {/* `assistant.image.result` is now `string | null`, not the raw result */} - {assistant.image.result && } -
- ) -} -``` - -Return `undefined` (or nothing) from `onResult` to keep the raw result. Each -capability's option also takes `forwardedProps` to merge extra fields into that -capability's request body. - -## Which page do I read? - -| You want to… | Read | -|---|---| -| Chain capabilities end-to-end — run one after another and thread each result into the next (a pipeline, a content workflow) | [Scenarios](./scenarios) | -| Give different pages or features their own capability sets, routes, and system prompts | [Multiple Assistants](./multiple-assistants) | -| Understand the underlying chat surface (streaming, tools, message parts) | [Chat: Streaming](../chat/streaming) | -| Drive a single capability without an assistant | [Media & Generation Hooks](../media/generation-hooks) | diff --git a/docs/assistant/scenarios.md b/docs/assistant/scenarios.md deleted file mode 100644 index 28ac3171f..000000000 --- a/docs/assistant/scenarios.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -title: Assistant Scenarios -id: assistant-scenarios -order: 2 -description: "Journey-style recipes for chaining assistant capabilities: a sequential pipeline (image → chat → speech) and a one-endpoint content workflow (draft → hero image → audio version). See why an assistant beats wiring separate hooks when steps feed each other." -keywords: - - tanstack ai - - assistant - - useAssistant - - pipeline - - content workflow - - chaining capabilities - - multimodal ---- - -**These are point A → point B recipes.** Each one takes a concrete goal and walks the full flow across an assistant's capabilities. The thread running through both: because every capability hangs off the same `assistant` object, the output of one step is right there — typed — for the next step to consume. Wiring the same flow with separate `useChat` / `useGenerateImage` / `useGenerateSpeech` hooks means three connections, three client objects, and manually shuttling values between them. - -If you only need one capability, you don't need any of this — reach for the single hook. See [When should you reach for an assistant?](./overview#when-should-you-reach-for-an-assistant) on the overview. - -## Recipe 1 — Sequential pipeline - -**Goal:** generate an image, have the model write copy *about* that image, then narrate the copy aloud — each step awaiting the last and threading its result forward. - -Declare all three capabilities in one assistant on the server: - -```ts -// api/assistant.ts -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' - -export const studioAssistant = defineAssistant({ - chat: (req) => - chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, - speech: (req) => - generateSpeech({ - adapter: openaiSpeech('tts-1'), - text: req.text, - voice: req.voice, - }), -}) - -export const POST = (request: Request) => studioAssistant.handler(request) -``` - -On the client, the whole pipeline is one function. Each `await` resolves when that step finishes, and its result is on the same `assistant` object for the next step to read: - -```tsx -// components/Pipeline.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' - -// The same object your server route exports — share it from one module in a -// real app; repeated here so this snippet type-checks on its own. -const studioAssistant = defineAssistant({ - chat: (req) => - chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, - speech: (req) => - generateSpeech({ - adapter: openaiSpeech('tts-1'), - text: req.text, - voice: req.voice, - }), -}) - -function Pipeline() { - const assistant = useAssistant(studioAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - }) - - async function run(subject: string) { - // 1. Generate an image — the result comes straight back from the await. - const image = await assistant.image.generate({ prompt: subject }) - const imageUrl = image?.images[0]?.url - if (!imageUrl) return - - // 2. Hand the image to chat and ask for copy about it. - // sendMessage resolves to the messages (no outputSchema here). - const messages = await assistant.chat.sendMessage({ - content: [ - { type: 'image', source: { type: 'url', value: imageUrl } }, - { type: 'text', content: 'Write a short, punchy caption for this image.' }, - ], - }) - - // 3. Pull the reply text out of the returned messages and narrate it. - const caption = messages - .at(-1) - ?.parts.find((part) => part.type === 'text')?.content - if (!caption) return - - await assistant.speech.generate({ text: caption }) - } - - return ( -
- - {assistant.image.result?.images[0]?.url && ( - - )} -
- ) -} -``` - -**Why an assistant here:** each step's `await` hands back its own result — `const image = await assistant.image.generate(...)`, `const messages = await assistant.chat.sendMessage(...)` — and you thread that returned value straight into the next call. No reactive-state reads between steps: the image URL feeds chat as [multimodal input](../media/image-generation#image-conditioned-generation), and the chat reply (pulled from the returned `messages`) feeds speech — all typed, all on one connection. With three separate hooks you'd hand-carry each value across three independent clients and manage three connections. The reactive `assistant.image.result` / `assistant.chat.messages` are still there for *rendering* (see the JSX below); the pipeline just doesn't need them. See [Text-to-Speech](../media/text-to-speech#playing-audio-in-the-browser) for turning `assistant.speech.result?.audio` into playable audio. - -## Recipe 2 — Content workflow (CMS) - -**Goal:** a "content" assistant powering a mini CMS. From one prompt it (a) drafts a blog post as a **typed object** (`title` + `body`) via structured-output chat, (b) generates a hero image from the drafted title, and (c) produces an audio version of the body via speech — all behind **one endpoint**. - -The server declares the structured-output `chat`, plus `image` and `speech`: - -```ts -// api/content.ts -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -const BlogPostSchema = z.object({ - title: z.string(), - body: z.string(), -}) - -export const contentAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - outputSchema: BlogPostSchema, - stream: true, - }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, - speech: (req) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }), -}) - -export const POST = (request: Request) => contentAssistant.handler(request) -``` - -The client orchestrates the workflow off the typed `final` object — `assistant.chat.final` is narrowed to `{ title: string; body: string } | null` because the `chat` callback declared `outputSchema`: - -```tsx -// components/ContentStudio.tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -const BlogPostSchema = z.object({ - title: z.string(), - body: z.string(), -}) - -// The same object your server route exports — share it from one module in a -// real app; repeated here so this snippet type-checks on its own. -const contentAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - outputSchema: BlogPostSchema, - stream: true, - }), - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - }) - }, - speech: (req) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }), -}) - -function ContentStudio() { - const assistant = useAssistant(contentAssistant, { - connection: fetchServerSentEvents('/api/content'), - }) - - async function publish(topic: string) { - // (a) Draft the post as a typed object — sendMessage resolves to the - // validated `final` because the chat callback declared outputSchema. - const draft = await assistant.chat.sendMessage( - `Write a short blog post about ${topic}.`, - ) // { title: string; body: string } | null - if (!draft) return - - // (b) Generate a hero image from the drafted title. - await assistant.image.generate({ prompt: `Hero image for: ${draft.title}` }) - - // (c) Produce an audio version of the body. - await assistant.speech.generate({ text: draft.body }) - } - - return ( -
- - {/* Live progress while the draft streams in */} -

{assistant.chat.partial.title ?? 'Drafting…'}

-

{assistant.chat.partial.body}

- {assistant.image.result?.images[0]?.url && ( - {assistant.chat.final?.title - )} -
- ) -} -``` - -**Why an assistant here:** `sendMessage` resolves to the validated structured `final` — `const draft = await assistant.chat.sendMessage(...)` hands you the typed `{ title, body }` object directly, which you thread into the next calls (`draft.title` becomes the image prompt, `draft.body` becomes the speech text). These are ordinary typed property reads on the awaited return, not values you serialized out of one hook and back into another, and not a reactive-state read after the await. The entire workflow ships as a single endpoint (`/api/content`) with one auth boundary and one connection, which is exactly the shape a content-management feature wants. Because the `chat` callback declares `outputSchema`, you also get `assistant.chat.partial` for a live preview while the draft streams — see [Structured Outputs: Streaming UIs](../structured-outputs/streaming). - -## Next - -- [Multiple Assistants](./multiple-assistants) — when different pages or features each need their own assistant. -- [Assistant Overview](./overview) — the full capability + typing reference. -- [Client Tools](../tools/client-tools) — run tool implementations in the browser inside `assistant.chat`. diff --git a/docs/config.json b/docs/config.json index 65dc9e66d..1c716b5a3 100644 --- a/docs/config.json +++ b/docs/config.json @@ -309,25 +309,22 @@ ] }, { - "label": "Assistant", + "label": "Transaction", "children": [ { "label": "Overview", - "to": "assistant/overview", - "addedAt": "2026-07-13", - "updatedAt": "2026-07-14" + "to": "transaction/overview", + "addedAt": "2026-07-14" }, { - "label": "Scenarios", - "to": "assistant/scenarios", - "addedAt": "2026-07-14", - "updatedAt": "2026-07-14" + "label": "Transactions", + "to": "transaction/transactions", + "addedAt": "2026-07-14" }, { - "label": "Multiple Assistants", - "to": "assistant/multiple-assistants", - "addedAt": "2026-07-14", - "updatedAt": "2026-07-14" + "label": "Scenarios", + "to": "transaction/scenarios", + "addedAt": "2026-07-14" } ] }, diff --git a/docs/transaction/overview.md b/docs/transaction/overview.md new file mode 100644 index 000000000..c34bc2b90 --- /dev/null +++ b/docs/transaction/overview.md @@ -0,0 +1,417 @@ +--- +title: Transaction Overview +id: transaction-overview +order: 1 +description: "A transaction endpoint declares verbs — app-named units of AI work like `drafting`, `heroImage`, or `narration` — behind ONE request handler, and hands you ONE typed client. Learn the two kinds of verbs, how the client infers everything from the server definition, and when to reach for a transaction endpoint instead of the single-capability hooks." +keywords: + - tanstack ai + - transaction + - defineTransaction + - useTransaction + - verbs + - chat + - image generation + - text-to-speech +--- + +**A transaction endpoint declares _verbs_ — app-named units of AI work — behind a single request handler on the server, and gives you back a single typed client in the browser.** You declare the verbs once with `defineTransaction()`; `useTransaction()` returns one object keyed by exactly the verbs you declared, all sharing one connection. And because a verb can compose its siblings server-side, one client call can run a whole multi-step pipeline as a single request — a [transaction](./transactions). + +Verb names are *your* domain language, not a fixed set of library nouns. A blog studio declares `drafting`, `heroImage`, `narration`, and `blogPost`; a video tool might declare `storyboard`, `thumbnail`, and `voiceover`. The library doesn't care what you call them — it routes on the names you choose and types the client from them. + +`defineTransaction()` lives on the `/transaction` subpath of `@tanstack/ai`, and `useTransaction()` on the `/transaction` subpath of `@tanstack/ai-react` (Solid and Vue follow the same pattern; Svelte exports `createTransaction` from `@tanstack/ai-svelte/transaction`) — not the package root. + +```ts +// api/blog-studio.ts +import { chat, generateImage, generateSpeech } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +export const blogTransaction = defineTransaction({ + // A conversational verb: message history in, streamed chat out. + drafting: chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + systemPrompts: ['You are a seasoned staff writer.'], + }), + ), + + // A one-shot verb: schema-validated input in, typed result out. + heroImage: verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + }), + }), + + narration: verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => + generateSpeech({ + adapter: openaiSpeech('tts-1'), + text: input.text, + voice: 'alloy', + }), + }), +}) + +export const POST = (request: Request) => blogTransaction.handler(request) +``` + +That one route now serves conversational drafting, image generation, and narration. On the client, one hook drives all three: + +```tsx +// components/BlogStudio.tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { chat, generateImage, generateSpeech } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +// The same definition your server route exports — share it from one module in +// a real app; repeated here so this snippet type-checks on its own. See +// "Sharing the definition with the client" below. +const blogTransaction = defineTransaction({ + drafting: chatVerb((req) => + chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), + ), + heroImage: verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + }), + }), + narration: verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => + generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }), + }), +}) + +function BlogStudio() { + const txn = useTransaction(blogTransaction, { + connection: fetchServerSentEvents('/api/blog-studio'), + }) + + return ( +
+ + {txn.drafting.messages.map((message) => ( +

+ {message.parts.find((part) => part.type === 'text')?.content} +

+ ))} + + + {txn.heroImage.result?.images[0]?.url && ( + + )} +
+ ) +} +``` + +`txn.drafting` behaves exactly like [`useChat`](../chat/streaming) — streaming, tool calls, and message parts all work the same way. Each one-shot verb (`txn.heroImage`, `txn.narration`, ...) is a typed run/result surface: a `.run(input)` method — its `input` typed by the verb's schema, its resolved value by `execute`'s return — plus reactive `.result`, `.isLoading`, `.error`, `.status`, `.stop()`, `.reset()`, and live [`.subRuns`](./transactions#watching-sub-runs-live). + +Both `txn..run(input)` and `txn..sendMessage(...)` **resolve to their result**, so you can chain them with a single `await` — `run` resolves to the verb's result (or `null`), and `sendMessage` resolves to the structured `final` (when the chat callback set `outputSchema`) or the messages array otherwise. When steps should be composed *server-side* instead — one request, one abort scope — declare a verb that calls its siblings with `ctx.call`; that's a [transaction](./transactions). + +## The two kinds of verbs + +**`chatVerb(callback)` — conversational.** The callback receives the parsed chat request (`messages`, `threadId`, `runId`, client-declared `tools`, `forwardedProps`, and the raw `request`) and returns the stream `chat()` produces. On the client it becomes the full chat surface: `sendMessage`, `messages`, tool calls, approvals, structured output. + +**`verb({ input, execute })` — one-shot.** `input` is a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, ...) describing the input; the handler validates every incoming request against it **at runtime** before `execute` runs (a mismatch gets a `400` with the validation issues — the callback never sees a malformed input). `execute` receives the validated `req` (with `input`, `threadId`, `runId`, an abort `signal`, and the raw `request`) plus a composition context `ctx`, and returns a `Promise` of the result. On the client it becomes the `run`/`result` surface. + +You can declare **several verbs of the same kind** — including several chat verbs. Each gets its own conversation state, system prompt, and even model: + +```ts +// api/support.ts +import { chat } from '@tanstack/ai' +import { chatVerb, defineTransaction } from '@tanstack/ai/transaction' +import { openaiText } from '@tanstack/ai-openai' + +export const supportTransaction = defineTransaction({ + // The main conversation the user sees. + primaryChat: chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + systemPrompts: [ + 'You are a customer-support agent for Acme. Be concise and friendly.', + ], + }), + ), + // A second, independent chat surface: condense the ticket for handoff. + summaryChat: chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + systemPrompts: [ + 'Summarize the conversation you are given as a terse handoff note.', + ], + }), + ), +}) + +export const POST = (request: Request) => supportTransaction.handler(request) +``` + +On the client, `txn.primaryChat` and `txn.summaryChat` are two fully independent chat surfaces — separate `messages`, separate `sendMessage` — behind the same endpoint. + +`defineTransaction()` itself is **inert**: none of these callbacks run, and no adapter is constructed, until a request actually reaches `handler`. The handler discriminates each incoming request by a `verb` field the client sends, routes it to the matching callback, and streams the result back over Server-Sent Events. Undeclared verbs get a `400` before any callback runs. + +## Sharing the definition with the client + +`useTransaction` needs the definition's **type** to build the typed client — verb names and kinds at runtime, input/result/tool/schema types at compile time. Because the definition is inert, the cleanest way is often a shared isomorphic module. When your server route builds the definition inside the handler (for example, to keep provider SDKs out of the browser bundle), mirror the definition client-side instead: declare the same verb names, kinds, input schemas, and `outputSchema`s, and let the mirrored `execute`/callback bodies carry the result types — they **never run in the browser**. The [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/routes/blog-studio.tsx) shows this pattern end to end, sharing the Zod schema and prompt helpers from the API route module so the two sides can't drift. + +## When should you reach for a transaction endpoint? + +A transaction endpoint is not "a better `useChat`." It earns its place when you have **two or more verbs that live on the same surface** — a page, a workflow, a pipeline — or when the server should compose steps into a single request. If you need exactly one chat box or one image button, the single hook is simpler and there is nothing to gain from the extra layer. + +| Your situation | Reach for | Why | +|---|---|---| +| One page needs chat **and** images **and** narration | A transaction endpoint | One connection, one typed client, one route to deploy and secure | +| One verb's output feeds the next (draft a post → illustrate it → narrate it) as a single unit of work | A [transaction](./transactions) | Server-side composition via `ctx.call`: one request, live sub-run streaming, one abort scope | +| The user drives each step by hand (regenerate the image, re-narrate) | The same verbs, called directly from the client | `txn.heroImage.run(...)` — same endpoint, same types | +| A page that only sends chat messages | [`useChat`](../chat/streaming) | No second verb to share — the transaction layer adds nothing | +| A page that only generates images | [`useGenerateImage`](../media/generation-hooks) | Same — a single hook is the whole story | + +**The honest tradeoff:** a transaction endpoint centralizes routing and typing across verbs, but it also couples them into one definition and one endpoint. If two features never appear together and want different auth or system prompts, don't force them into one definition — give each surface its own (see [Scenarios](./scenarios)). + +## Typed chat verbs: tools and structured output + +A chat verb inherits `useChat`'s full type inference, driven entirely by what its server callback passes to `chat()` — you don't re-declare anything on the client. + +**Callback tools auto-type the verb's `messages`.** Pass `tools` to `chat()` in the callback and the tool-call/result parts on `txn..messages` are typed from those tools, with nothing to register on the client: + +```tsx +// components/WeatherChat.tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { chat, toolDefinition } from '@tanstack/ai' +import { chatVerb, defineTransaction } from '@tanstack/ai/transaction' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const getWeatherDef = toolDefinition({ + name: 'get_weather', + description: 'Get the current weather for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ tempF: z.number(), conditions: z.string() }), +}) + +const getWeather = getWeatherDef.server(async ({ city }) => { + return { tempF: 72, conditions: `Sunny in ${city}` } +}) + +// The same definition your server route exports — share it from one module in +// a real app; repeated here so this snippet type-checks on its own. +const weatherTransaction = defineTransaction({ + primaryChat: chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + tools: [getWeather], + }), + ), +}) + +function WeatherChat() { + const txn = useTransaction(weatherTransaction, { + connection: fetchServerSentEvents('/api/weather'), + }) + + const weatherCall = txn.primaryChat.messages + .at(-1) + ?.parts.find( + (part) => part.type === 'tool-call' && part.name === 'get_weather', + ) + + return ( +
+ + {weatherCall?.type === 'tool-call' && weatherCall.output && ( +

{weatherCall.output.conditions}

+ )} +
+ ) +} +``` + +No tools option was passed to `useTransaction` — `weatherCall.output` is still narrowed to `{ tempF: number; conditions: string }`, inferred entirely from `tools: [getWeather]` on the server callback. + +**Per-verb `tools` are only for client-executed tools.** A [client tool](../tools/client-tools)'s `.client()` implementation runs in the browser, so its code can't cross the wire. The server callback sees only the tool's *definition* (for typing and to tell the model it exists); the client registers the runtime implementation under the verb's entry in the nested `verbs` option. Types still come from the server callback either way: + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { chat, toolDefinition } from '@tanstack/ai' +import { chatVerb, defineTransaction } from '@tanstack/ai/transaction' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const showToastDef = toolDefinition({ + name: 'show_toast', + description: 'Show a browser notification', + inputSchema: z.object({ message: z.string() }), +}) + +const showToast = showToastDef.client((input) => { + console.log(input.message) + return { ok: true } +}) + +// The same definition your server route exports — share it from one module in +// a real app; repeated here so this snippet type-checks on its own. +const toastTransaction = defineTransaction({ + primaryChat: chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + tools: [showToastDef], // definition only — the client executes it + }), + ), +}) + +function useToastChat() { + return useTransaction(toastTransaction, { + connection: fetchServerSentEvents('/api/toast'), + verbs: { + primaryChat: { tools: [showToast] }, + }, + }) +} +``` + +**`outputSchema` → typed `partial` / `final`.** If a chat verb's callback passes an `outputSchema` to `chat()`, that verb's surface picks up a progressively-parsed `partial` (`DeepPartial`) and a validated terminal `final` — the same inference [`useChat({ outputSchema })`](../structured-outputs/streaming) gives you: + +```tsx +// components/BlogOutlineForm.tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { chat } from '@tanstack/ai' +import { chatVerb, defineTransaction } from '@tanstack/ai/transaction' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const BlogOutlineSchema = z.object({ + title: z.string(), + sections: z.array(z.string()), +}) + +// The same definition your server route exports — share it from one module in +// a real app; repeated here so this snippet type-checks on its own. +const outlineTransaction = defineTransaction({ + drafting: chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + outputSchema: BlogOutlineSchema, + stream: true, + }), + ), +}) + +function BlogOutlineForm() { + const txn = useTransaction(outlineTransaction, { + connection: fetchServerSentEvents('/api/outline'), + }) + + return ( +
+ +

Title: {txn.drafting.partial.title ?? '…'}

+
    + {txn.drafting.partial.sections?.map((section, i) => ( +
  • {section}
  • + ))} +
+ {txn.drafting.final && ( +
{JSON.stringify(txn.drafting.final, null, 2)}
+ )} +
+ ) +} +``` + +`partial` and `final` only exist on the type when the callback declares `outputSchema` — omit it and the verb's surface has no such fields, exactly like `useChat` without `outputSchema`. See [Structured Outputs](../structured-outputs/overview) for the full story. + +## Transforming one-shot results + +Each one-shot verb's entry in the nested `verbs` option accepts an `onResult` transform. It runs on the raw backend result before it lands on `txn..result`, and **its return type becomes that verb's `result` type**. Use it to reshape a result once, at the hook, instead of deriving it in every component that reads it: + +```tsx +// components/CoverArt.tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { generateImage } from '@tanstack/ai' +import { defineTransaction, verb } from '@tanstack/ai/transaction' +import { openaiImage } from '@tanstack/ai-openai' +import { z } from 'zod' + +const coverTransaction = defineTransaction({ + heroImage: verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + }), + }), +}) + +function CoverArt() { + const txn = useTransaction(coverTransaction, { + connection: fetchServerSentEvents('/api/cover'), + verbs: { + // `result` is the raw `ImageGenerationResult`; the return type flows + // through to `txn.heroImage.result`. + heroImage: { onResult: (result) => result.images[0]?.url ?? null }, + }, + }) + + return ( +
+ + {/* `txn.heroImage.result` is now `string | null`, not the raw result */} + {txn.heroImage.result && } +
+ ) +} +``` + +Return `undefined` (or nothing) from `onResult` to keep the raw result. Each verb's entry also takes `forwardedProps` to merge extra fields into that verb's request body. The per-verb options are nested under `verbs` (rather than spread at the top level) so your verb names can never collide with `connection` / `id` / `threadId`. + +## Which page do I read? + +| You want to… | Read | +|---|---| +| Compose verbs server-side — one client call runs a whole pipeline as a single request with live progress and one abort scope | [Transactions](./transactions) | +| Give different pages or features their own verb sets, routes, and system prompts — and decide between user-driven verbs, server-composed transactions, and plain `useChat` | [Scenarios](./scenarios) | +| Understand the underlying chat surface (streaming, tools, message parts) | [Chat: Streaming](../chat/streaming) | +| Drive a single capability without a transaction endpoint | [Media & Generation Hooks](../media/generation-hooks) | diff --git a/docs/transaction/scenarios.md b/docs/transaction/scenarios.md new file mode 100644 index 000000000..d3b7265e1 --- /dev/null +++ b/docs/transaction/scenarios.md @@ -0,0 +1,204 @@ +--- +title: Scenarios +id: transaction-scenarios +order: 3 +description: "One transaction definition per product surface — a support page with a single chat verb, a content studio with drafting + image + narration — each with its own route, its own useTransaction, and its own system prompts. Plus the decision guide: user-driven verbs, server-composed transactions, or plain useChat?" +keywords: + - tanstack ai + - transaction + - defineTransaction + - useTransaction + - multiple endpoints + - system prompt + - routes +--- + +**One `defineTransaction` per product surface.** A transaction definition bundles a set of verbs behind one endpoint. When different pages or features want different verb sets, different instructions, or different auth, don't stretch a single definition to cover them all — define one per surface. Each gets its own route and its own `useTransaction` on its own page. + +A typical app ends up with a few: + +| Surface | Verbs | Why its own definition | +|---|---|---| +| Customer **support** page | `supportChat` | Tight, support-flavored system prompt; nothing else to expose | +| **Content studio** page | `drafting` + `heroImage` + `narration` + `blogPost` | A creative workflow, including a server-composed [transaction](./transactions) | +| Internal **dashboard** | `analystChat` + `weeklyDigest` | Different auth boundary; different instructions | + +## Two definitions, two routes + +Give each definition its own module and its own handler. A small helper keeps the shared adapter config — the model choice and any provider options — in one place, so the surfaces stay in sync without duplicating it: + +```ts +// api/transactions.ts +import { chat, generateImage, generateSpeech } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +// One place to change the model / provider options for every surface. +const textModel = () => openaiText('gpt-5.5') + +// Support: a single chat verb, with a support-flavored system prompt. +export const supportTransaction = defineTransaction({ + supportChat: chatVerb((req) => + chat({ + adapter: textModel(), + messages: req.messages, + systemPrompts: [ + 'You are a customer-support agent for Acme. Be concise and friendly.', + ], + }), + ), +}) + +// Studio: drafting + image + narration, for a creative workflow. +export const studioTransaction = defineTransaction({ + drafting: chatVerb((req) => + chat({ + adapter: textModel(), + messages: req.messages, + systemPrompts: ['You are a creative copywriter.'], + }), + ), + heroImage: verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + }), + }), + narration: verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => + generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }), + }), +}) + +// Two routes — one per definition. +export const supportPOST = (request: Request) => + supportTransaction.handler(request) +export const studioPOST = (request: Request) => + studioTransaction.handler(request) +``` + +In a real app these are two route files — `/api/support` and `/api/studio` — each exporting its own `POST`. The `supportPOST` / `studioPOST` names above just let both live in one snippet. + +## Two pages, two clients + +Each page calls `useTransaction` against *its own* definition and *its own* connection. The support page only ever sees `txn.supportChat`, because that's all `supportTransaction` declared: + +```tsx +// pages/SupportPage.tsx +import { chat } from '@tanstack/ai' +import { chatVerb, defineTransaction } from '@tanstack/ai/transaction' +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { openaiText } from '@tanstack/ai-openai' + +// The same definition your server route exports — share it from one module +// in a real app; mirrored here so this snippet type-checks on its own. It +// is inert in the browser: the callback never runs client-side. +const supportTransaction = defineTransaction({ + supportChat: chatVerb((req) => + chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), + ), +}) + +function SupportPage() { + const txn = useTransaction(supportTransaction, { + connection: fetchServerSentEvents('/api/support'), + }) + + return ( +
+ + {txn.supportChat.messages.map((message) => ( +

+ {message.parts.find((part) => part.type === 'text')?.content} +

+ ))} +
+ ) +} +``` + +The studio page points at `/api/studio` and gets `txn.drafting`, `txn.heroImage`, and `txn.narration`: + +```tsx +// pages/StudioPage.tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { studioTransaction } from '../api/transactions' + +function StudioPage() { + const txn = useTransaction(studioTransaction, { + connection: fetchServerSentEvents('/api/studio'), + }) + + return ( +
+ + + {txn.heroImage.result?.images[0]?.url && ( + + )} +
+ ) +} +``` + +Each page's `txn` object is typed to exactly its definition's verbs — `SupportPage` has no `txn.heroImage` to misuse, and there's no way to accidentally call the support route with an image request. (When a route builds its definition inside the handler, mirror it client-side as described in [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client).) + +## Split, or one broad definition? + +Both are valid. Choose by how the verbs actually relate: + +**Split into multiple definitions when:** + +- **Different product surfaces.** A support widget and a content studio are different features with different users — separate definitions keep their prompts, verbs, and routes independent. +- **Different verb sets.** If support never needs image generation, don't expose it there. A narrower definition is a narrower attack surface and a simpler client type. +- **Different auth or rate-limit boundaries.** Separate routes let you guard `/api/support` and `/api/studio` independently. + +**Use one broad definition when:** + +- **The verbs are always used together** on the same page or in the same workflow — especially when a composing verb needs to `ctx.call` them: siblings must live in the same definition to be composed into a [transaction](./transactions). +- **They share a system prompt and auth boundary.** If splitting would just duplicate the same configuration twice, keep it as one. + +The rule of thumb: **split by surface, not by verb count.** A single page that happens to use four verbs is one definition; three pages that each use chat are three definitions. + +## User-driven verbs, server-composed transactions, or just `useChat`? + +The same pipeline can be built three ways. Pick by *who orchestrates* and *what the unit of work is*: + +**Just `useChat` (or a single generation hook)** when there's exactly one kind of AI work on the page. A chat box is a chat box — the transaction layer adds a definition, a routing discriminator, and nothing else. Reach for `defineTransaction` only once a second verb shows up. + +**User-driven verbs** when the *user* orchestrates: each step is its own gesture — draft in the chat, then click "illustrate", then click "narrate", regenerate any step at will. The client chains awaited returns (`const messages = await txn.drafting.sendMessage(...)`, then `await txn.heroImage.run(...)`), each step is its own request, and each can be retried independently. This is the loosest coupling: steps can be abandoned halfway, reordered, or repeated. + +**A server-composed transaction** when the pipeline is *one unit of work* from the user's point of view: one click should produce the finished artifact or fail as a whole. Declare a composing verb and let `ctx.call` run the steps server-side — one request, live sub-run progress, one abort scope, intermediate values that never round-trip through the browser, and a single typed result. See [Transactions](./transactions). + +| | `useChat` / single hook | User-driven verbs | Server-composed transaction | +|---|---|---|---| +| Orchestrated by | — (single step) | The client, step by step | The server, inside `execute` | +| Requests | One per interaction | One per step | **One for the whole pipeline** | +| Cancel | Per request | Per step | One `stop()` aborts everything | +| Intermediate values | — | Round-trip through the browser | Stay on the server (streamed live as sub-runs) | +| Failure | Per request | Per step; earlier steps keep their results | The run fails as a unit (unless `execute` catches) | +| Best for | A page with one AI feature | Exploratory, editable workflows | One-click pipelines with a definite finished artifact | + +These compose: the blog studio in [Transactions](./transactions) declares `blogPost` (the one-click transaction) *alongside* `heroImage` and `narration` — the same one-shot verbs the transaction composes are also driven directly by the user for "regenerate" buttons. Server-composed for the first pass, user-driven for the touch-ups, one definition and one endpoint for both. + +## Next + +- [Transaction Overview](./overview) — the two verb kinds, client typing, and when to reach for a transaction endpoint at all. +- [Transactions](./transactions) — composing verbs with `ctx.call`: sub-runs, abort semantics, and error behavior. +- [Client Tools](../tools/client-tools) — run tool implementations in the browser inside a chat verb. diff --git a/docs/transaction/transactions.md b/docs/transaction/transactions.md new file mode 100644 index 000000000..1273f43ec --- /dev/null +++ b/docs/transaction/transactions.md @@ -0,0 +1,312 @@ +--- +title: Transactions +id: transaction-transactions +order: 2 +description: "Compose verbs server-side with ctx.call: one client call runs a whole pipeline — draft a post, illustrate it, narrate it — as a SINGLE request, with every step streamed back live as a sub-run and one abort scope covering the lot. Learn the sub-run protocol, cancellation semantics, and error behavior." +keywords: + - tanstack ai + - transaction + - ctx.call + - sub-runs + - defineTransaction + - useTransaction + - abort + - cancellation +--- + +**A transaction is a one-shot verb that composes its sibling verbs server-side.** Its `execute` receives a context `ctx`, and `ctx.call(siblingVerb, input)` runs that sibling *inside the same request* — the sub-run's stream is forwarded live to the client as tagged events, and the `await` resolves with the sibling's result for the next step to consume. One client call, one HTTP request, one abort scope for the entire pipeline. + +This is the closest thing to transactional semantics this domain allows: you can't roll back tokens a model already generated, but you *can* treat the pipeline as a single unit of work — one call starts it, live progress streams out of it, cancelling it halts the orchestration and aborts the in-flight provider work, and the UI unwinds its partial state. + +## The worked example: a blog studio + +One click turns a topic into a finished post: draft the article as a typed object, then — in parallel — generate a hero image and record a voice-over, both derived from the validated draft. This is the [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/routes/blog-studio.tsx) from the repository, condensed. + +### Server: a `blogPost` verb that composes its siblings + +```ts +// api/blog-studio.ts +import { chat, generateImage, generateSpeech } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +export const BlogPostSchema = z.object({ + title: z.string(), + subtitle: z.string(), + body: z.string().describe('The full post as Markdown'), +}) + +// Conversational verb: writes the post as a typed object +// (structured output + streaming). +const drafting = chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + systemPrompts: ['You are a seasoned staff writer.'], + outputSchema: BlogPostSchema, + stream: true, + threadId: req.threadId, + runId: req.runId, + }), +) + +// One-shot verb: a hero image from a prompt. +const heroImage = verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + size: '1536x1024', + }), +}) + +// One-shot verb: narrate a piece of text. +const narration = verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => + generateSpeech({ + adapter: openaiSpeech('tts-1'), + text: input.text, + voice: 'alloy', + }), +}) + +// The transaction: one client call composes the three verbs above, +// entirely server-side. +const blogPost = verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + // 1. Draft the post. `ctx.call` on a chat verb resolves with the + // accumulated text and the structured output; re-validate it so a + // half-finished draft fails the run with a clear error. + const draft = await ctx.call(drafting, [ + { role: 'user', content: `Write a blog post about: ${input.topic}` }, + ]) + const parsed = BlogPostSchema.safeParse(draft.structured) + if (!parsed.success) { + throw new Error( + `Drafting did not produce a valid blog post: ${parsed.error.message}`, + ) + } + const post = parsed.data + + // 2. Illustrate and narrate in parallel, both derived from the + // validated draft. + const [hero, audio] = await Promise.all([ + ctx.call(heroImage, { + prompt: `Editorial hero image for "${post.title}". ${post.subtitle}.`, + }), + ctx.call(narration, { text: post.body }), + ]) + + // 3. The return value becomes the run's final result on the client + // (`txn.blogPost.result`). + return { post, hero, audio } + }, +}) + +export const blogTransaction = defineTransaction({ + drafting, + heroImage, + narration, + blogPost, +}) + +export const POST = (request: Request) => blogTransaction.handler(request) +``` + +`ctx.call` has two shapes, both typed end to end: + +- **`ctx.call(oneShotVerb, input)`** validates `input` against the sibling's schema, runs its `execute`, and resolves with its result — typed as that verb's result type. +- **`ctx.call(chatVerbObj, messages)`** runs the chat verb's callback to completion server-side and resolves with `{ text, structured }` — the accumulated assistant text plus the structured output when the callback declared an `outputSchema`. `structured` arrives as `unknown`; re-validate it with `safeParse` (as above) rather than trusting the shape. + +### Client: one call, live progress, typed result + +```tsx +// components/BlogStudio.tsx +import { chat, generateImage, generateSpeech } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +// The same definition the server route above exports — share it from one +// module in a real app; mirrored here so this snippet type-checks on its +// own. It is inert in the browser: no callback ever runs client-side. +const BlogPostSchema = z.object({ + title: z.string(), + subtitle: z.string(), + body: z.string(), +}) +const drafting = chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + outputSchema: BlogPostSchema, + stream: true, + }), +) +const heroImage = verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt }), +}) +const narration = verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => + generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }), +}) +const blogPostVerb = verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const draft = await ctx.call(drafting, [ + { role: 'user', content: `Write a blog post about: ${input.topic}` }, + ]) + const post = BlogPostSchema.parse(draft.structured) + const [hero, audio] = await Promise.all([ + ctx.call(heroImage, { prompt: post.title }), + ctx.call(narration, { text: post.body }), + ]) + return { post, hero, audio } + }, +}) +const blogTransaction = defineTransaction({ + drafting, + heroImage, + narration, + blogPost: blogPostVerb, +}) + +function BlogStudio() { + const txn = useTransaction(blogTransaction, { + connection: fetchServerSentEvents('/api/blog-studio'), + }) + + const { blogPost } = txn + // One entry per `ctx.call` the server made, keyed by verb name. + const draftingRun = blogPost.subRuns.find((run) => run.verb === 'drafting') + const heroRun = blogPost.subRuns.find((run) => run.verb === 'heroImage') + const narrationRun = blogPost.subRuns.find((run) => run.verb === 'narration') + + return ( +
+ + {blogPost.isLoading && ( + + )} + +
    +
  1. Writing: {draftingRun?.status ?? 'pending'}
  2. +
  3. Illustrating: {heroRun?.status ?? 'pending'}
  4. +
  5. Recording voice-over: {narrationRun?.status ?? 'pending'}
  6. +
+ + {blogPost.result && ( +
+

{blogPost.result.post.title}

+

{blogPost.result.post.subtitle}

+
+ )} + {blogPost.error &&

{blogPost.error.message}

} +
+ ) +} +``` + +`blogPost.result` is typed as `{ post, hero, audio } | null` — inferred from `execute`'s return type on the server definition, nothing re-declared on the client. (If your route builds the definition inside the handler, mirror it client-side as described in [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client) — the mirrored bodies never execute in the browser.) + +And because `heroImage` and `narration` are ordinary declared verbs, the *same* client can also drive them directly — `txn.heroImage.run({ prompt })` for a "regenerate hero image" button — without another endpoint or another definition. Server-composed and user-driven are two ways to call the same verbs. + +## Watching sub-runs live + +Everything a transaction does streams back over the single SSE response. Each `ctx.call` is wrapped in tagged `CUSTOM` events (the names live on `TRANSACTION_EVENTS`, exported from `@tanstack/ai/transaction`): + +| Event | When | +|---|---| +| `transaction:sub-run:started` | A `ctx.call` began; carries `{ runId, parentRunId, verb, index }` | +| `transaction:sub-run:chunk` | A chunk of the sub-run's own stream, forwarded live | +| `transaction:sub-run:result` | The sub-run completed; carries its `result` | +| `transaction:sub-run:error` | The sub-run threw; carries its `message` | + +You rarely touch these directly — the client demultiplexes them into `txn..subRuns`, an array of live sub-run states in server start order: + +```ts +interface TransactionSubRun { + runId: string + verb: string // the sub-verb's name in the definition + index: number // 0-based server start order + status: 'running' | 'success' | 'error' + result: unknown // set once the sub-run completes + text: string // accumulated streamed text, for chat-verb sub-runs + error?: string +} +``` + +For chat-verb sub-runs, `text` accumulates the streamed assistant text as it arrives — a live word count or progress indicator falls out for free. One caveat from the worked example: when the chat callback declares an `outputSchema`, the streamed text is the structured output's partial JSON, so show progress by length (`${run.text.length} chars drafted`) rather than rendering it verbatim. The array resets when a new run starts, and `subRuns` is empty for verbs that don't compose siblings. + +The verb's own return value travels as a terminal `generation:result` event and lands in `txn..result` — the same place it lands for a simple, non-composing verb. + +## One request, one abort scope + +Because the entire pipeline is a single HTTP request, cancellation is one gesture end to end: + +- **Client:** `txn.blogPost.stop()` aborts the fetch. The reactive surfaces unwind — `isLoading` drops, in-flight sub-runs stop updating. +- **Server:** the aborted request trips `req.signal` (also available as `ctx.signal` — both are the request's `AbortSignal`). `ctx.call` refuses to start new sub-runs once the signal is aborted, and chat sub-runs stop consuming their stream. +- **Providers:** pass the signal into the activities your verbs run so the upstream provider calls are cancelled too, not just orphaned: + +```ts +import { chat } from '@tanstack/ai' +import { verb } from '@tanstack/ai/transaction' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +const tagline = verb({ + input: z.object({ product: z.string() }), + execute: async (req) => { + // `chat()` takes an AbortController; chain it off the request signal so + // a client disconnect / stop() aborts this provider call mid-flight. + const abortController = new AbortController() + req.signal.addEventListener('abort', () => abortController.abort(), { + once: true, + }) + const text = await chat({ + adapter: openaiText('gpt-5.5'), + messages: [ + { role: 'user', content: `Write a tagline for ${req.input.product}` }, + ], + stream: false, + abortController, + }) + return { tagline: text } + }, +}) +``` + +The same applies inside a composing verb: `ctx.signal` is the one signal for the whole transaction, so every provider call at any depth hangs off the same scope. Cancel once, and the orchestration halts, in-flight sub-work aborts, and nothing keeps burning tokens in the background. + +## Error behavior + +A transaction fails as a unit: + +- If a sub-run throws (or a `ctx.call` input fails its sibling's schema validation), the server emits `transaction:sub-run:error` for that sub-run and the error propagates out of `ctx.call` into your `execute` — unless you catch it. An uncaught error fails the whole run: the stream terminates with a `RUN_ERROR` event instead of `RUN_FINISHED`. +- On the client, the failed step shows up in `subRuns` with `status: 'error'` and its `error` message, `txn..error` is set, and `txn..result` stays `null`. Completed sub-runs keep their `success` status, so the UI can show exactly which step failed. +- Want a step to be best-effort instead? Wrap that `ctx.call` in `try`/`catch` inside `execute` and return a partial result — the run then finishes normally. The example above deliberately does *not* do this: a post without its image is a failed post. +- Validation failures of the *top-level* input (the object passed to `txn..run(...)`) never reach `execute` at all — the handler responds `400` with the schema issues. + +## Next + +- [Transaction Overview](./overview) — the two verb kinds, client typing, tools, and structured output. +- [Scenarios](./scenarios) — one definition per product surface, and choosing between user-driven verbs, server-composed transactions, and plain `useChat`. diff --git a/examples/ts-react-chat/src/routes/api.blog-studio.ts b/examples/ts-react-chat/src/routes/api.blog-studio.ts index 9db94c5e4..73757b1cc 100644 --- a/examples/ts-react-chat/src/routes/api.blog-studio.ts +++ b/examples/ts-react-chat/src/routes/api.blog-studio.ts @@ -1,19 +1,20 @@ import { createFileRoute } from '@tanstack/react-router' import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' import { z } from 'zod' /** - * The shape a blog post is drafted into. The `chat` capability declares this - * as its `outputSchema`, so `assistant.chat.sendMessage(...)` resolves to a - * validated `{ title, subtitle, body } | null` on the client — no manual - * parsing, and `assistant.chat.partial` streams a live draft. + * The shape a blog post is drafted into. The `drafting` chat verb declares + * this as its `outputSchema`, so its structured output is schema-validated — + * both when the client talks to it directly and when the `blogPost` + * transaction runs it server-side via `ctx.call`. * * Exported so the client page (`blog-studio.tsx`) can import the same schema - * and stay in type-sync. Only this schema lives at module scope; the actual - * `defineAssistant` (with its server adapters) is built inside the POST - * handler so importing the schema never pulls provider SDKs into the browser + * and stay in type-sync. Only client-safe values (this schema and the two + * pure string helpers below) live at module scope; the actual + * `defineTransaction` (with its server adapters) is built inside the POST + * handler so importing them never pulls provider SDKs into the browser * bundle. */ export const BlogPostSchema = z.object({ @@ -35,47 +36,135 @@ const SYSTEM_PROMPT = 'body as GitHub-flavored Markdown with section headings and tight ' + 'paragraphs. Be vivid and concrete; avoid filler and clichés.' +/** + * Build the hero-image prompt from a drafted post. Used by the server-side + * `blogPost` transaction and by the client's "Regenerate hero image" button, + * so both produce the same style of illustration. + */ +export function heroPromptFor(post: BlogPost): string { + return ( + `A striking editorial hero image for a blog post titled ` + + `"${post.title}". ${post.subtitle}. Modern, clean, cinematic, ` + + `high quality, no text.` + ) +} + +/** + * Prepare the post body for narration: strip Markdown so TTS doesn't read the + * syntax aloud, and cap the length at a sentence boundary (OpenAI TTS rejects + * input over 4096 characters, and long posts easily exceed it). Used by the + * server-side `blogPost` transaction and by the client's "Re-narrate" button. + */ +export function forNarration(markdown: string, max = 4000): string { + const plain = markdown + .replace(/^#{1,6}\s+/gm, '') // headings + .replace(/^\s*[-*+]\s+/gm, '') // list bullets + .replace(/^\s*>\s?/gm, '') // blockquotes + .replace(/\*\*(.*?)\*\*/g, '$1') // bold + .replace(/\*(.*?)\*/g, '$1') // italic + .replace(/`([^`]+)`/g, '$1') // inline code + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → link text + .replace(/\n{3,}/g, '\n\n') + .trim() + if (plain.length <= max) return plain + const clipped = plain.slice(0, max) + const boundary = Math.max( + clipped.lastIndexOf('. '), + clipped.lastIndexOf('\n'), + ) + return (boundary > max / 2 ? clipped.slice(0, boundary + 1) : clipped).trim() +} + export const Route = createFileRoute('/api/blog-studio')({ server: { handlers: { - // One endpoint, three capabilities. `defineAssistant` is inert until + // One endpoint, four verbs. `defineTransaction` is inert until // `handler(request)` runs, at which point it parses the AG-UI request, - // routes by the `capability` discriminator the client sends, and streams - // the result back over SSE. + // routes by the `verb` discriminator the client sends, and streams the + // result back over SSE. POST: async ({ request }) => { - const assistant = defineAssistant({ - // Write the post as a typed object (structured output + streaming). - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - systemPrompts: [SYSTEM_PROMPT], - outputSchema: BlogPostSchema, - stream: true, - threadId: req.threadId, - runId: req.runId, - }), - // Generate a landscape hero / OG image from the drafted title. - image: (req) => { - if (typeof req.prompt !== 'string') { - throw new Error('image prompt must be a string') - } - return generateImage({ + // Conversational verb: writes the post as a typed object + // (structured output + streaming). + const drafting = chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + systemPrompts: [SYSTEM_PROMPT], + outputSchema: BlogPostSchema, + stream: true, + threadId: req.threadId, + runId: req.runId, + }), + ) + + // One-shot verb: a landscape hero / OG image from a prompt. + const heroImage = verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, + prompt: input.prompt, size: '1536x1024', - }) - }, - // Narrate the post body. - speech: (req) => + }), + }) + + // One-shot verb: narrate a piece of text. + const narration = verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => generateSpeech({ adapter: openaiSpeech('tts-1'), - text: req.text, + text: input.text, voice: 'alloy', }), }) - return assistant.handler(request) + // The transaction: one client call composes the three verbs above, + // entirely server-side. Each `ctx.call` streams back to the client as + // a live, tagged sub-run of this single request — and the whole + // pipeline shares one abort scope (client disconnect / stop() cancels + // everything). + const blogPost = verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + // 1. Draft the post. `ctx.call` on a chat verb resolves with the + // accumulated text and the structured output; re-validate it so + // a half-finished draft fails the run with a clear error. + const draft = await ctx.call(drafting, [ + { + role: 'user', + content: `Write a blog post about: ${input.topic}`, + }, + ]) + const parsed = BlogPostSchema.safeParse(draft.structured) + if (!parsed.success) { + throw new Error( + `Drafting did not produce a valid blog post: ${parsed.error.message}`, + ) + } + const post = parsed.data + + // 2. Illustrate and narrate in parallel, both derived from the + // validated draft. + const [hero, audio] = await Promise.all([ + ctx.call(heroImage, { prompt: heroPromptFor(post) }), + ctx.call(narration, { text: forNarration(post.body) }), + ]) + + // 3. The return value becomes the run's final result on the + // client (`txn.blogPost.result`). + return { post, hero, audio } + }, + }) + + const transaction = defineTransaction({ + drafting, + heroImage, + narration, + blogPost, + }) + + return transaction.handler(request) }, }, }, diff --git a/examples/ts-react-chat/src/routes/blog-studio.tsx b/examples/ts-react-chat/src/routes/blog-studio.tsx index 2519c7047..1fd0147e2 100644 --- a/examples/ts-react-chat/src/routes/blog-studio.tsx +++ b/examples/ts-react-chat/src/routes/blog-studio.tsx @@ -1,10 +1,11 @@ import type { FormEvent, ReactNode } from 'react' import { createFileRoute } from '@tanstack/react-router' import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' +import { useTransaction } from '@tanstack/ai-react/transaction' import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { @@ -15,35 +16,71 @@ import { Newspaper, PenLine, Sparkles, + Square, Volume2, Wand2, } from 'lucide-react' -import { BlogPostSchema } from './api.blog-studio' +import type { ImageGenerationResult, TTSResult } from '@tanstack/ai' +import { BlogPostSchema, forNarration, heroPromptFor } from './api.blog-studio' -// Client-side assistant definition. `defineAssistant` is INERT in the browser -// — these callbacks never run; `useAssistant` only reads the declared -// capability names and their return types off this value to build the fully -// typed client. Every request is sent to the `/api/blog-studio` route, which -// owns the real callbacks. Mirroring the three capabilities (and the chat -// `outputSchema`) here keeps the client types in sync with the server. +// Client-side transaction definition. `defineTransaction` is INERT in the +// browser — these callbacks never run; `useTransaction` only reads the +// declared verb names/kinds and their input/result types off this value to +// build the fully typed client. Every request is sent to the +// `/api/blog-studio` route, which owns the real callbacks. Mirroring the four +// verbs (and the drafting `outputSchema`) here keeps the client types in sync +// with the server. // // Single-provider openai adapters are used directly (not the multi-provider // factories) so the browser bundle doesn't pull in every provider SDK. -const blogAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - outputSchema: BlogPostSchema, - stream: true, - }), - image: (req) => +const drafting = chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + outputSchema: BlogPostSchema, + stream: true, + }), +) + +const heroImage = verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => generateImage({ adapter: openaiImage('gpt-image-2'), - prompt: typeof req.prompt === 'string' ? req.prompt : '', + prompt: input.prompt, }), - speech: (req) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: req.text }), +}) + +const narration = verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => + generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }), +}) + +const blogTransaction = defineTransaction({ + drafting, + heroImage, + narration, + // Mirrors the server's composition so `txn.blogPost.result` is typed as + // `{ post, hero, audio }` — the body never executes in the browser. + blogPost: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const draft = await ctx.call(drafting, [ + { role: 'user', content: `Write a blog post about: ${input.topic}` }, + ]) + const parsed = BlogPostSchema.safeParse(draft.structured) + if (!parsed.success) { + throw new Error('Drafting did not produce a valid blog post') + } + const post = parsed.data + const [hero, audio] = await Promise.all([ + ctx.call(heroImage, { prompt: heroPromptFor(post) }), + ctx.call(narration, { text: forNarration(post.body) }), + ]) + return { post, hero, audio } + }, + }), }) export const Route = createFileRoute('/blog-studio')({ @@ -52,11 +89,12 @@ export const Route = createFileRoute('/blog-studio')({ type StepState = 'pending' | 'active' | 'done' | 'failed' -// The one-shot generation status maps directly onto a step state. -function statusToStep( - status: 'idle' | 'generating' | 'success' | 'error', +// Map a live sub-run's status onto a step state. `undefined` means the +// server hasn't started that sub-run yet. +function subRunToStep( + status: 'running' | 'success' | 'error' | undefined, ): StepState { - return status === 'generating' + return status === 'running' ? 'active' : status === 'success' ? 'done' @@ -65,100 +103,84 @@ function statusToStep( : 'pending' } -// Prepare the post body for narration: strip Markdown so TTS doesn't read the -// syntax aloud, and cap the length at a sentence boundary (OpenAI TTS rejects -// input over 4096 characters, and long posts easily exceed it). -function forNarration(markdown: string, max = 4000): string { - const plain = markdown - .replace(/^#{1,6}\s+/gm, '') // headings - .replace(/^\s*[-*+]\s+/gm, '') // list bullets - .replace(/^\s*>\s?/gm, '') // blockquotes - .replace(/\*\*(.*?)\*\*/g, '$1') // bold - .replace(/\*(.*?)\*/g, '$1') // italic - .replace(/`([^`]+)`/g, '$1') // inline code - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → link text - .replace(/\n{3,}/g, '\n\n') - .trim() - if (plain.length <= max) return plain - const clipped = plain.slice(0, max) - const boundary = Math.max( - clipped.lastIndexOf('. '), - clipped.lastIndexOf('\n'), +// The image result carries either a URL or base64 data, provider-dependent. +function imageUrlOf(result: ImageGenerationResult | null): string | null { + const image = result?.images[0] + if (!image) return null + return ( + image.url ?? + (image.b64Json ? `data:image/png;base64,${image.b64Json}` : null) ) - return (boundary > max / 2 ? clipped.slice(0, boundary + 1) : clipped).trim() +} + +// The TTS result is base64 audio + a content type → a ready-to-play data URL. +function audioSrcOf(result: TTSResult | null): string | null { + if (!result) return null + return `data:${result.contentType ?? 'audio/mpeg'};base64,${result.audio}` } function BlogStudio() { - const assistant = useAssistant(blogAssistant, { + const txn = useTransaction(blogTransaction, { connection: fetchServerSentEvents('/api/blog-studio'), - // Transform the raw TTS result into a ready-to-play data URL at the hook. - // `result` is typed as the raw `TTSResult`; `assistant.speech.result` - // becomes `{ src: string } | null` — no component state, no cleanup. - speech: { - onResult: (result) => ({ - src: `data:${result.contentType ?? 'audio/mpeg'};base64,${result.audio}`, - }), - }, }) - // Everything below is derived from the assistant's reactive state — no - // useState / useEffect. The chat carries loading/partial/final; each one-shot - // carries result/status/error. - const { chat: post, image, speech } = assistant - const draft = post.partial - const finished = post.final - const title = finished?.title ?? draft.title - const subtitle = finished?.subtitle ?? draft.subtitle - const body = finished?.body ?? draft.body ?? '' + // Everything below is derived from the transaction's reactive state — no + // useState / useEffect. `blogPost` is the one-click transaction; `heroImage` + // and `narration` are the same verbs, driven directly by the user for + // regeneration. + const { blogPost } = txn + const heroRerun = txn.heroImage + const narrationRerun = txn.narration - const heroImage = image.result?.images[0] - const imageUrl = - heroImage?.url ?? - (heroImage?.b64Json ? `data:image/png;base64,${heroImage.b64Json}` : null) - // `speech.result` is the `onResult` transform's output — `{ src } | null`. - const audioSrc = speech.result?.src ?? null + const post = blogPost.result?.post ?? null + // Prefer an individually regenerated hero / narration over the one the + // transaction produced. + const imageUrl = imageUrlOf(heroRerun.result ?? blogPost.result?.hero ?? null) + const audioSrc = audioSrcOf( + narrationRerun.result ?? blogPost.result?.audio ?? null, + ) - const isRunning = post.isLoading || image.isLoading || speech.isLoading - const hasRun = post.messages.length > 0 || isRunning - const writingStep: StepState = post.error - ? 'failed' - : post.isLoading - ? 'active' - : hasRun - ? 'done' - : 'pending' - const showArticle = Boolean(finished) || Boolean(title) || Boolean(body) + // Live sub-run state, demultiplexed from the single SSE response: one entry + // per `ctx.call` the server made, keyed by verb name. + const subRuns = blogPost.subRuns + const draftingRun = subRuns.find((run) => run.verb === 'drafting') + const heroRun = subRuns.find((run) => run.verb === 'heroImage') + const narrationRun = subRuns.find((run) => run.verb === 'narration') + + const isRunning = blogPost.isLoading + const hasRun = blogPost.status !== 'idle' + + // While drafting streams, its `text` is partial JSON (the structured + // output in flight) — don't render it; show progress by length instead. + const draftedChars = draftingRun?.text.length ?? 0 + const writingStep = subRunToStep(draftingRun?.status) - async function run(e: FormEvent) { + const heroBusy = heroRerun.isLoading || heroRun?.status === 'running' + const heroFailed = heroRerun.status === 'error' || heroRun?.status === 'error' + const narrationBusy = + narrationRerun.isLoading || narrationRun?.status === 'running' + const narrationFailed = + narrationRerun.status === 'error' || narrationRun?.status === 'error' + + function run(e: FormEvent) { e.preventDefault() const topic = String( new FormData(e.currentTarget).get('topic') ?? '', ).trim() if (!topic || isRunning) return - // Reset the surfaces so a re-run starts clean (clears prior draft/results). - post.clear() - image.reset() - speech.reset() - - // 1. Draft the post. `sendMessage` resolves to the schema-validated result; - // re-validate before chaining so a failed run doesn't proceed with a - // half-finished draft. - const parsed = BlogPostSchema.safeParse( - await post.sendMessage(`Write a blog post about: ${topic}`), - ) - if (!parsed.success) return - const draftedPost = parsed.data + // Reset the surfaces so a re-run starts clean: the previous post, and any + // individually regenerated hero/narration that would shadow the new + // transaction's results. + blogPost.reset() + heroRerun.reset() + narrationRerun.reset() - // 2. Illustrate and narrate in parallel — fire and forget; the surfaces' - // reactive result/status/error drive the UI. `generate()` never rejects. - void image.generate({ - prompt: - `A striking editorial hero image for a blog post titled ` + - `"${draftedPost.title}". ${draftedPost.subtitle}. Modern, clean, ` + - `cinematic, high quality, no text.`, - }) - void speech.generate({ text: forNarration(draftedPost.body) }) + // ONE call. The server composes drafting → (illustrate ∥ narrate) and + // streams every sub-run live into `blogPost.subRuns`; the final + // `{ post, hero, audio }` lands in `blogPost.result`. Fire and forget — + // the reactive surfaces drive the UI. + void blogPost.run({ topic }) } return ( @@ -175,9 +197,9 @@ function BlogStudio() { Turn a topic into a finished post

- One assistant writes the article, then illustrates it and records a - voice-over in parallel — chained from a single prompt over one - endpoint. + One transaction writes the article, then illustrates it and records + a voice-over in parallel — composed on the server from a single + request, with every step streamed back live.

@@ -195,18 +217,30 @@ function BlogStudio() { disabled={isRunning} className="mb-3 w-full rounded-lg border border-stone-300 bg-white px-4 py-3 text-stone-800 shadow-sm placeholder:text-stone-400 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/30 disabled:opacity-60" /> - + {isRunning && ( + )} - {isRunning ? 'Working…' : 'Write the post'} - +
{hasRun && ( @@ -215,47 +249,92 @@ function BlogStudio() { label="Writing the post" icon={} state={writingStep} + detail={ + writingStep === 'active' && draftedChars > 0 + ? `${draftedChars} chars drafted` + : undefined + } /> } - state={statusToStep(image.status)} + state={subRunToStep(heroRun?.status)} /> } - state={statusToStep(speech.status)} + state={subRunToStep(narrationRun?.status)} /> )} - {post.error && ( + {post && ( +
+ + Touch up + + {/* The same verbs the transaction composed, driven directly by + the user — inputs derived from the current post. */} + + +
+ )} + + {blogPost.error && (
- {post.error.message} + {blogPost.error.message}
)} {/* Right: the post */}
- {showArticle ? ( + {post ? (
{/* Hero image */}
- {imageUrl ? ( + {imageUrl && !heroBusy ? ( {title ) : (
- {image.status === 'generating' ? ( + {heroBusy ? (
Illustrating…
- ) : image.status === 'error' ? ( + ) : heroFailed ? (
@@ -271,11 +350,9 @@ function BlogStudio() {

- {title ?? 'Untitled'} + {post.title}

- {subtitle && ( -

{subtitle}

- )} +

{post.subtitle}

{/* Byline + voice-over */}
@@ -283,17 +360,17 @@ function BlogStudio() {
Written & narrated by TanStack AI - {audioSrc ? ( -
- -
- ) : speech.status === 'generating' ? ( + {narrationBusy ? ( Recording voice-over… - ) : speech.status === 'error' ? ( + ) : audioSrc ? ( +
+ +
+ ) : narrationFailed ? ( Voice-over unavailable @@ -304,11 +381,22 @@ function BlogStudio() { {/* Body */}
- {body} + {post.body}
+ ) : isRunning ? ( +
+
+ +

+ {draftedChars > 0 + ? `Drafting the article… ${draftedChars} characters so far.` + : 'Starting the transaction…'} +

+
+
) : (
@@ -329,10 +417,12 @@ function StepRow({ label, icon, state, + detail, }: { label: string icon: ReactNode state: StepState + detail?: string }) { const cls = state === 'active' @@ -354,6 +444,11 @@ function StepRow({ icon )} {label} + {detail && ( + + {detail} + + )} ) } diff --git a/packages/ai-client/package.json b/packages/ai-client/package.json index 55d348ad7..b7d471e9c 100644 --- a/packages/ai-client/package.json +++ b/packages/ai-client/package.json @@ -43,9 +43,9 @@ "types": "./dist/esm/devtools.d.ts", "import": "./dist/esm/devtools.js" }, - "./assistant": { - "types": "./dist/esm/assistant.d.ts", - "import": "./dist/esm/assistant.js" + "./transaction": { + "types": "./dist/esm/transaction.d.ts", + "import": "./dist/esm/transaction.js" } }, "files": [ diff --git a/packages/ai-client/src/assistant-client.ts b/packages/ai-client/src/assistant-client.ts deleted file mode 100644 index 284ba8f39..000000000 --- a/packages/ai-client/src/assistant-client.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ChatClient } from './chat-client.js' -import { GenerationClient } from './generation-client.js' -import type { AssistantDefinition } from '@tanstack/ai/assistant' -import type { AnyClientTool } from '@tanstack/ai/client' -import type { - AssistantClientOptions, - OneShotCapabilityName, - OneShotCapabilityOptions, -} from './assistant-types.js' - -/** - * Composes one `ChatClient` and/or one `GenerationClient` per one-shot - * capability declared on an `AssistantDefinition`, sharing the single - * `connection` adapter passed in `options`. - * - * No connection wrapping happens here: each sub-client tags its own - * requests with the capability name — `ChatClient` via - * `forwardedProps: { capability: 'chat' }`, `GenerationClient` via - * `body: { capability: }` — so a single server endpoint can route - * on that field. - */ -export class AssistantClient< - TDef extends AssistantDefinition = AssistantDefinition, - TChatTools extends ReadonlyArray = [], -> { - /** The chat sub-client, present only if the definition declares `chat`. */ - chat?: ChatClient - private readonly oneShots = new Map>() - readonly capabilities: ReadonlyArray - - constructor(options: AssistantClientOptions) { - const { assistant, connection, id, threadId, chat, callbacks } = options - this.capabilities = assistant.capabilities - - for (const capability of assistant.capabilities) { - if (capability === 'chat') { - this.chat = new ChatClient({ - connection, - id: id ? `${id}:chat` : undefined, - threadId, - tools: chat?.tools, - forwardedProps: { ...chat?.forwardedProps, capability: 'chat' }, - ...callbacks?.chat, - }) - continue - } - - const oneShotCapability = capability as OneShotCapabilityName - const capOptions: OneShotCapabilityOptions | undefined = - options[oneShotCapability] - this.oneShots.set( - oneShotCapability, - new GenerationClient({ - connection, - id: id ? `${id}:${oneShotCapability}` : undefined, - // Merge per-capability forwardedProps under the routing discriminator. - body: { - ...capOptions?.forwardedProps, - capability: oneShotCapability, - }, - // Forward the result transform, if the caller supplied one. - ...(capOptions?.onResult !== undefined && { - onResult: capOptions.onResult, - }), - ...callbacks?.oneShot?.(oneShotCapability), - }), - ) - } - } - - /** Whether the assistant declares the given capability. */ - has(capability: string): boolean { - return this.capabilities.includes(capability) - } - - /** The one-shot `GenerationClient` for a declared capability, if any. */ - get( - capability: OneShotCapabilityName, - ): GenerationClient | undefined { - return this.oneShots.get(capability) - } - - /** Tears down the chat client and every one-shot client. */ - dispose(): void { - this.chat?.dispose() - for (const oneShot of this.oneShots.values()) { - oneShot.dispose() - } - } -} diff --git a/packages/ai-client/src/assistant-types.ts b/packages/ai-client/src/assistant-types.ts deleted file mode 100644 index ae9def5e9..000000000 --- a/packages/ai-client/src/assistant-types.ts +++ /dev/null @@ -1,400 +0,0 @@ -// packages/ai-client/src/assistant-types.ts -import type { - AudioGenerationResult, - ImageGenerationResult, - InferChatSchema, - InferChatTools, - SummarizationResult, - TTSResult, - TranscriptionResult, - VideoJobResult, -} from '@tanstack/ai' -import type { AssistantDefinition } from '@tanstack/ai/assistant' -import type { - AnyClientTool, - InferSchemaType, - ModelMessage, - SchemaInput, -} from '@tanstack/ai/client' -import type { ConnectConnectionAdapter } from './connection-adapters.js' -import type { - ChatClientOptions, - ChatClientState, - ConnectionStatus, - MultimodalContent, - UIMessage, -} from './types.js' -import type { - AudioGenerateInput, - GenerationClientOptions, - GenerationClientState, - ImageGenerateInput, - InferGenerationOutput, - SpeechGenerateInput, - SummarizeGenerateInput, - TranscriptionGenerateInput, - VideoGenerateInput, -} from './generation-types.js' - -/** - * Reactive state callbacks forwarded into the underlying sub-clients. - * - * `ChatClient` and `GenerationClient` only accept these callbacks via their - * constructors (`updateOptions` does not accept them), so `AssistantClient` - * must thread them through at construction time rather than attaching them - * post-construction. - */ -export interface AssistantClientCallbacks { - /** Reactive state callbacks forwarded to the `ChatClient` constructor. */ - chat?: Pick< - ChatClientOptions, - | 'onMessagesChange' - | 'onLoadingChange' - | 'onErrorChange' - | 'onStatusChange' - | 'onSubscriptionChange' - | 'onConnectionStatusChange' - | 'onSessionGeneratingChange' - > - /** - * Reactive state callbacks forwarded to a one-shot capability's - * `GenerationClient` constructor. Invoked once per declared one-shot - * capability so each sub-client can be wired independently. - */ - oneShot?: ( - capability: OneShotCapabilityName, - ) => Pick< - GenerationClientOptions, - 'onResultChange' | 'onLoadingChange' | 'onErrorChange' | 'onStatusChange' - > -} - -/** - * Per-one-shot-capability options. Mirrors the standalone generation hooks: - * an optional `onResult` transform (its return type becomes the capability's - * `result` type) plus extra `forwardedProps` merged into the request body. - */ -export interface OneShotCapabilityOptions { - /** - * Transform the raw backend result before it is stored on the surface. - * `assistant..result` becomes this function's (non-nullish) - * return type. Return `undefined`/`null`/`void` to keep the raw result. - */ - onResult?: (result: TResult) => unknown - /** Extra fields merged into this capability's request body. */ - forwardedProps?: Record -} - -/** Options for AssistantClient (framework-agnostic core). */ -export interface AssistantClientOptions< - TDef extends AssistantDefinition, - /** - * @deprecated Chat tool typing is now inferred from the assistant - * definition's chat callback (`TDef`), not from this generic. Retained only - * so the framework hooks (`ai-react`/`ai-solid`/`ai-vue`/`ai-svelte`), which - * still thread it through, keep compiling until they are updated. It no - * longer influences the chat surface's message/tool types. - */ - TChatTools extends ReadonlyArray = [], -> { - assistant: TDef - connection: ConnectConnectionAdapter - id?: string - threadId?: string - /** Chat-only options mirrored onto the underlying ChatClient. */ - chat?: { - tools?: TChatTools - forwardedProps?: Record - } - /** Per-capability one-shot options (`onResult` transform, forwardedProps). */ - image?: OneShotCapabilityOptions - audio?: OneShotCapabilityOptions - speech?: OneShotCapabilityOptions - video?: OneShotCapabilityOptions - transcription?: OneShotCapabilityOptions - summarize?: OneShotCapabilityOptions - /** - * Reactive state callbacks forwarded into the sub-clients' constructors. - * Set by framework hooks (not users) to wire up reactive state. - */ - callbacks?: AssistantClientCallbacks -} - -/** Maps declared capability names to their client generate-input type. */ -export interface GenerateInputByCapability { - image: ImageGenerateInput - audio: AudioGenerateInput - speech: SpeechGenerateInput - video: VideoGenerateInput - transcription: TranscriptionGenerateInput - summarize: SummarizeGenerateInput -} - -/** Maps declared capability names to their result type. */ -export interface ResultByCapability { - image: ImageGenerationResult - audio: AudioGenerationResult - speech: TTSResult - video: VideoJobResult - transcription: TranscriptionResult - summarize: SummarizationResult -} - -export type OneShotCapabilityName = keyof GenerateInputByCapability - -/** - * Recursive partial — every property and every nested array element is - * optional. Types the in-flight `partial` value the chat surface exposes while - * a structured-output stream is still arriving (the JSON has shape but is - * incomplete). Mirrors the `DeepPartial` used by the framework `useChat` - * hooks. - */ -export type DeepPartial = - T extends ReadonlyArray - ? Array> - : T extends object - ? { [K in keyof T]?: DeepPartial } - : T - -/** - * Map a captured chat-callback tool (server / definition / client) to a - * `definition`-shaped object so it satisfies the `AnyClientTool` constraint on - * `UIMessage`/`ToolCallPart` **without** relaxing the core message types. - * - * The chat callback's `tools` are typically server (`.server()`) or bare - * definition tools, whose `__toolSide` is `'server'` / `'definition'` — - * `AnyClientTool` rejects `'server'`. We rebuild each tool as a - * `'definition'`-side object, preserving the exact fields the `Infer*` helpers - * and `ToolCallPartForTool` read (`name`, `inputSchema`, `outputSchema`, - * `needsApproval`). `description` is required by `AnyClientTool`'s underlying - * `Tool` shape, so it is kept as `string` (its value never feeds message - * typing). - */ -type ToClientToolShape = TTool extends { - name: infer TName extends string -} - ? { - __toolSide: 'definition' - name: TName - description: string - inputSchema?: TTool extends { inputSchema?: infer TIn } ? TIn : undefined - outputSchema?: TTool extends { outputSchema?: infer TOut } - ? TOut - : undefined - needsApproval?: TTool extends { needsApproval?: infer TNa } ? TNa : false - } - : never - -/** Map a captured tool tuple to a client-tool-shaped tuple (see above). */ -type ToClientTools = TTools extends readonly [ - infer THead, - ...infer TRest, -] - ? [ToClientToolShape, ...ToClientTools] - : [] - -/** Recover the chat callback's return type from an assistant definition. */ -type ChatCallbackReturn = - TDef extends AssistantDefinition - ? TCaps extends { chat: (...args: Array) => infer TRet } - ? TRet - : never - : never - -/** - * Client-tool-shaped tuple recovered from the chat callback's captured tools. - * Drives the `messages` tool-call/result part typing on the chat surface. - */ -export type ChatToolsOf = ToClientTools< - NonNullable>> -> - -/** Structured-output schema recovered from the chat callback (or `undefined`). */ -export type ChatSchemaOf = InferChatSchema> - -/** The base chat capability surface — mirrors the frameworks' useChat return. */ -export interface AssistantChatSurfaceBase< - TTools extends ReadonlyArray = [], -> { - /** Current messages in the conversation. */ - messages: Array> - - /** - * Append a message to the conversation. - */ - append: (message: ModelMessage | UIMessage) => Promise - - /** - * Reload the last assistant message. - */ - reload: () => Promise - - /** - * Stop the current response generation. - */ - stop: () => void - - /** - * Clear all messages. - */ - clear: () => void - - /** - * Set messages manually. - */ - setMessages: (messages: Array>) => void - - /** - * Add the result of a client-side tool execution. - */ - addToolResult: (result: { - toolCallId: string - tool: string - output: any - state?: 'output-available' | 'output-error' - errorText?: string - }) => Promise - - /** - * Respond to a tool approval request. - */ - addToolApprovalResponse: (response: { - id: string // approval.id, not toolCallId - approved: boolean - }) => Promise - - /** - * Whether a response is currently being generated. - */ - isLoading: boolean - - /** - * Current error, if any. - */ - error: Error | undefined - - /** - * Current status of the chat client. - */ - status: ChatClientState - - /** - * Whether the subscription loop is currently active. - */ - isSubscribed: boolean - - /** - * Current connection lifecycle status. - */ - connectionStatus: ConnectionStatus - - /** - * Whether the shared session is actively generating. - */ - sessionGenerating: boolean -} - -/** - * The chat capability surface. Extends {@link AssistantChatSurfaceBase} and, - * when the chat callback declared an `outputSchema` (`TSchema extends - * SchemaInput`), adds the typed structured-output fields `partial` / `final` - * — mirroring the framework `useChat` hooks' conditional return. When no - * schema was declared, neither field is present. - */ -export type AssistantChatSurface< - TTools extends ReadonlyArray = [], - TSchema = undefined, -> = AssistantChatSurfaceBase & { - /** - * Send a message and get a response. Can be a simple string or multimodal - * content with images, audio, etc. - * - * Resolves once the assistant turn completes: to the schema-validated final - * structured output (`InferSchemaType | null`) when the chat - * callback declared an `outputSchema`, otherwise to the resulting messages - * array (`Array>`). - */ - sendMessage: ( - content: string | MultimodalContent, - ) => Promise< - TSchema extends SchemaInput - ? InferSchemaType | null - : Array> - > -} & (TSchema extends SchemaInput - ? { - /** - * Live, progressively-parsed structured output while the stream is - * still arriving. Resets on every new run. - */ - partial: DeepPartial> - /** - * Final, schema-validated structured output. `null` until the terminal - * structured-output event arrives. Resets on every new run. - */ - final: InferSchemaType | null - } - : Record) - -/** The one-shot capability surface — mirrors useGeneration return. */ -export interface AssistantGenerationSurface { - generate: (input: TInput) => Promise - result: TResult | null - isLoading: boolean - error: Error | undefined - status: GenerationClientState - stop: () => void - reset: () => void -} - -/** - * The full typed system returned by useAssistant. - * - * The `chat` surface is fully inferred from the assistant definition's chat - * callback — its captured tools type `chat.messages`' tool-call/result parts, - * and its `outputSchema` (if any) adds typed `chat.partial` / `chat.final`. - */ -/** - * The stored `result` type for a one-shot capability `K`: the `onResult` - * transform's return type when the options declare one (via - * {@link InferGenerationOutput}), otherwise the raw backend result. - */ -export type OneShotResultType< - TOptions, - TCap extends OneShotCapabilityName, -> = TCap extends keyof TOptions - ? TOptions[TCap] extends { onResult?: infer TFn } - ? InferGenerationOutput - : ResultByCapability[TCap] - : ResultByCapability[TCap] - -export type AssistantSystem< - TDef extends AssistantDefinition, - /** - * @deprecated No longer used for chat tool typing — chat tools are inferred - * from `TDef`'s chat callback. Retained so the framework hooks that still - * pass a second generic keep compiling; it has no effect on the surface. - */ - TChatTools extends ReadonlyArray = [], - /** - * The user's options object. Its per-capability `onResult` transforms drive - * each one-shot capability's `result` type. Defaults to `unknown` (no - * transforms → raw backend result types). - */ - TOptions = unknown, -> = - // `TChatTools` is deprecated/unused for typing; the constraint always holds, - // so this reads the parameter (keeping it for hook back-compat) without - // affecting the resulting surface. - [TChatTools] extends [ReadonlyArray] - ? { - [K in keyof TDef['~caps'] & string]: K extends 'chat' - ? AssistantChatSurface, ChatSchemaOf> - : K extends OneShotCapabilityName - ? AssistantGenerationSurface< - GenerateInputByCapability[K], - OneShotResultType - > - : never - } - : never diff --git a/packages/ai-client/src/assistant.ts b/packages/ai-client/src/assistant.ts deleted file mode 100644 index 40e7f3c07..000000000 --- a/packages/ai-client/src/assistant.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { AssistantClient } from './assistant-client' -export { computeStructuredParts } from './assistant-structured' -export type { - AssistantClientOptions, - AssistantSystem, - AssistantChatSurface, - AssistantGenerationSurface, - OneShotCapabilityName, -} from './assistant-types' diff --git a/packages/ai-client/src/transaction-client.ts b/packages/ai-client/src/transaction-client.ts new file mode 100644 index 000000000..47175cb47 --- /dev/null +++ b/packages/ai-client/src/transaction-client.ts @@ -0,0 +1,196 @@ +import { ChatClient } from './chat-client.js' +import { GenerationClient } from './generation-client.js' +import type { StreamChunk } from '@tanstack/ai' +import type { TransactionDefinition } from '@tanstack/ai/transaction' +import type { + ChatVerbOptions, + OneShotVerbOptions, + TransactionClientOptions, + TransactionSubRun, +} from './transaction-types.js' + +/** CUSTOM event names of the transaction sub-run protocol (server-emitted). */ +const SUB_RUN_EVENTS = { + STARTED: 'transaction:sub-run:started', + CHUNK: 'transaction:sub-run:chunk', + RESULT: 'transaction:sub-run:result', + ERROR: 'transaction:sub-run:error', +} as const + +/** + * Composes one `ChatClient` per chat verb and one `GenerationClient` per + * one-shot verb declared on a `TransactionDefinition`, sharing the single + * `connection` adapter passed in `options`. + * + * Each sub-client tags its own requests with the verb name — `ChatClient` + * via `forwardedProps: { verb }`, `GenerationClient` via `body: { verb }` — + * so the single server endpoint can route on that field. + * + * For one-shot verbs, the client also demultiplexes the transaction sub-run + * events (`transaction:sub-run:*`) the server emits when the verb's + * `execute` composes siblings via `ctx.call`, exposing them as live + * per-verb {@link TransactionSubRun} state. + */ +export class TransactionClient< + TDef extends TransactionDefinition = TransactionDefinition, +> { + private readonly chats = new Map>() + private readonly oneShots = new Map< + string, + GenerationClient + >() + private readonly subRuns = new Map>() + readonly verbs: ReadonlyArray + + constructor(options: TransactionClientOptions) { + const { transaction, connection, id, threadId, verbs, callbacks } = options + this.verbs = transaction.verbs + + for (const verbName of transaction.verbs) { + const kind = transaction.verbKinds[verbName] + if (kind === 'chat') { + const verbOptions: ChatVerbOptions | undefined = + verbs?.[verbName as keyof typeof verbs] + this.chats.set( + verbName, + new ChatClient({ + connection, + id: id ? `${id}:${verbName}` : undefined, + threadId, + tools: verbOptions?.tools as any, + forwardedProps: { ...verbOptions?.forwardedProps, verb: verbName }, + ...callbacks?.chat?.(verbName), + }), + ) + continue + } + + const verbOptions: OneShotVerbOptions | undefined = + verbs?.[verbName as keyof typeof verbs] + const oneShotCallbacks = callbacks?.oneShot?.(verbName) + this.subRuns.set(verbName, []) + this.oneShots.set( + verbName, + new GenerationClient({ + connection, + id: id ? `${id}:${verbName}` : undefined, + // Merge per-verb forwardedProps under the routing discriminator. + body: { ...verbOptions?.forwardedProps, verb: verbName }, + ...(verbOptions?.onResult !== undefined && { + onResult: verbOptions.onResult, + }), + onChunk: (chunk) => { + this.handleSubRunChunk( + verbName, + chunk, + oneShotCallbacks?.onSubRunsChange, + ) + }, + ...oneShotCallbacks, + }), + ) + } + } + + /** Whether the transaction declares the given verb. */ + has(verbName: string): boolean { + return this.verbs.includes(verbName) + } + + /** The `ChatClient` for a declared chat verb, if any. */ + chat(verbName: string): ChatClient | undefined { + return this.chats.get(verbName) + } + + /** The `GenerationClient` for a declared one-shot verb, if any. */ + oneShot(verbName: string): GenerationClient | undefined { + return this.oneShots.get(verbName) + } + + /** Live sub-run state for a one-shot verb's current/last run. */ + getSubRuns(verbName: string): Array { + return this.subRuns.get(verbName) ?? [] + } + + /** + * Demultiplex `transaction:sub-run:*` CUSTOM events from a one-shot verb's + * response stream into that verb's sub-run array. A RUN_STARTED chunk + * (the root run beginning) resets the array. + */ + private handleSubRunChunk( + verbName: string, + chunk: StreamChunk, + notify: ((subRuns: Array) => void) | undefined, + ): void { + const type = (chunk as { type?: string }).type + if (type === 'RUN_STARTED') { + this.subRuns.set(verbName, []) + notify?.([]) + return + } + if (type !== 'CUSTOM') return + const { name, value } = chunk as { + name?: string + value?: { + runId?: string + verb?: string + index?: number + result?: unknown + message?: string + chunk?: { type?: string; delta?: unknown } + } + } + if (!name || !name.startsWith('transaction:sub-run:') || !value) return + + const current = this.subRuns.get(verbName) ?? [] + const next = current.slice() + const at = next.findIndex((s) => s.runId === value.runId) + const existing = at === -1 ? undefined : next[at] + + if (name === SUB_RUN_EVENTS.STARTED) { + next.push({ + runId: value.runId ?? '', + verb: value.verb ?? 'verb', + index: value.index ?? next.length, + status: 'running', + result: null, + text: '', + }) + } else if (!existing) { + return + } else if (name === SUB_RUN_EVENTS.CHUNK) { + const inner = value.chunk + if ( + inner?.type === 'TEXT_MESSAGE_CONTENT' && + typeof inner.delta === 'string' + ) { + next[at] = { ...existing, text: existing.text + inner.delta } + } else { + return + } + } else if (name === SUB_RUN_EVENTS.RESULT) { + next[at] = { ...existing, status: 'success', result: value.result } + } else if (name === SUB_RUN_EVENTS.ERROR) { + next[at] = { + ...existing, + status: 'error', + error: value.message ?? 'Sub-run failed', + } + } else { + return + } + + this.subRuns.set(verbName, next) + notify?.(next) + } + + /** Tears down every chat and one-shot sub-client. */ + dispose(): void { + for (const chat of this.chats.values()) { + chat.dispose() + } + for (const oneShot of this.oneShots.values()) { + oneShot.dispose() + } + } +} diff --git a/packages/ai-client/src/assistant-structured.ts b/packages/ai-client/src/transaction-structured.ts similarity index 96% rename from packages/ai-client/src/assistant-structured.ts rename to packages/ai-client/src/transaction-structured.ts index 03b4b9d78..7b65b3c75 100644 --- a/packages/ai-client/src/assistant-structured.ts +++ b/packages/ai-client/src/transaction-structured.ts @@ -3,7 +3,7 @@ import type { UIMessage } from './types.js' /** * Computes the "active" structured-output `partial`/`final` values from a * `UIMessage` history — the framework-agnostic core of `useChat`'s - * `partial`/`final` fields, shared by the assistant hooks. + * `partial`/`final` fields, shared by the transaction hooks. * * The "active" structured-output part is the one on the assistant message * that follows the latest user message. No such message exists between diff --git a/packages/ai-client/src/transaction-types.ts b/packages/ai-client/src/transaction-types.ts new file mode 100644 index 000000000..4c8366e18 --- /dev/null +++ b/packages/ai-client/src/transaction-types.ts @@ -0,0 +1,372 @@ +// packages/ai-client/src/transaction-types.ts +import type { InferChatSchema, InferChatTools } from '@tanstack/ai' +import type { + ChatVerb, + OneShotVerb, + TransactionDefinition, +} from '@tanstack/ai/transaction' +import type { + AnyClientTool, + InferSchemaType, + ModelMessage, + SchemaInput, +} from '@tanstack/ai/client' +import type { ConnectConnectionAdapter } from './connection-adapters.js' +import type { + ChatClientOptions, + ChatClientState, + ConnectionStatus, + MultimodalContent, + UIMessage, +} from './types.js' +import type { + GenerationClientOptions, + GenerationClientState, + InferGenerationOutput, +} from './generation-types.js' + +/** + * Live state of one sub-run inside a transaction run — a sibling verb the + * server-side `execute` invoked via `ctx.call`. Demultiplexed from the + * single SSE response by `TransactionClient`. + */ +export interface TransactionSubRun { + runId: string + /** The sub-verb's name in the transaction definition. */ + verb: string + /** 0-based order in which the server started this sub-run. */ + index: number + status: 'running' | 'success' | 'error' + /** The sub-run's result, once it completes. */ + result: unknown + /** Accumulated streamed text, for chat-verb sub-runs. */ + text: string + error?: string +} + +/** Per-chat-verb client options. */ +export interface ChatVerbOptions { + /** Client-executed tools for this chat verb. */ + tools?: ReadonlyArray + /** Extra fields merged into this verb's request body. */ + forwardedProps?: Record +} + +/** + * Per-one-shot-verb client options: an optional `onResult` transform (its + * return type becomes the verb surface's `result` type) plus extra + * `forwardedProps` merged into the request body. + */ +export interface OneShotVerbOptions { + /** + * Transform the raw backend result before it is stored on the surface. + * Return `undefined`/`null`/`void` to keep the raw result. + */ + onResult?: (result: TResult) => unknown + /** Extra fields merged into this verb's request body. */ + forwardedProps?: Record +} + +/** Maps each declared verb name to its per-verb client options shape. */ +export type VerbOptionsMap> = { + [K in keyof TDef['~verbs'] & string]?: TDef['~verbs'][K] extends ChatVerb + ? ChatVerbOptions + : TDef['~verbs'][K] extends OneShotVerb + ? OneShotVerbOptions + : never +} + +/** + * Reactive state callbacks forwarded into the underlying sub-clients' + * constructors. Set by the framework hooks (not users) to wire up reactive + * state. `ChatClient` and `GenerationClient` only accept these via their + * constructors, so `TransactionClient` threads them through at construction + * time. + */ +export interface TransactionClientCallbacks { + /** Reactive state callbacks for each chat verb's `ChatClient`. */ + chat?: (verb: string) => Pick< + ChatClientOptions, + | 'onMessagesChange' + | 'onLoadingChange' + | 'onErrorChange' + | 'onStatusChange' + | 'onSubscriptionChange' + | 'onConnectionStatusChange' + | 'onSessionGeneratingChange' + > + /** Reactive state callbacks for each one-shot verb's `GenerationClient`. */ + oneShot?: (verb: string) => Pick< + GenerationClientOptions, + 'onResultChange' | 'onLoadingChange' | 'onErrorChange' | 'onStatusChange' + > & { + /** Invoked whenever the verb's live sub-run state changes. */ + onSubRunsChange?: (subRuns: Array) => void + } +} + +/** Options for TransactionClient (framework-agnostic core). */ +export interface TransactionClientOptions< + TDef extends TransactionDefinition, +> { + transaction: TDef + connection: ConnectConnectionAdapter + id?: string + threadId?: string + /** + * Per-verb options, keyed by the verb names declared on the definition. + * Nested (rather than spread at the top level) so verb names can never + * collide with `connection`/`id`/`threadId`/`callbacks`. + */ + verbs?: VerbOptionsMap + /** Set by framework hooks (not users) to wire up reactive state. */ + callbacks?: TransactionClientCallbacks +} + +/** + * Recursive partial — every property and every nested array element is + * optional. Types the in-flight `partial` value the chat surface exposes + * while a structured-output stream is still arriving. Mirrors the + * `DeepPartial` used by the framework `useChat` hooks. + */ +export type DeepPartial = + T extends ReadonlyArray + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T + +/** + * Map a captured chat-callback tool (server / definition / client) to a + * `definition`-shaped object so it satisfies the `AnyClientTool` constraint + * on `UIMessage`/`ToolCallPart` **without** relaxing the core message types. + * + * The chat callback's `tools` are typically server (`.server()`) or bare + * definition tools, whose `__toolSide` is `'server'` / `'definition'` — + * `AnyClientTool` rejects `'server'`. We rebuild each tool as a + * `'definition'`-side object, preserving the exact fields the `Infer*` + * helpers read (`name`, `inputSchema`, `outputSchema`, `needsApproval`). + */ +type ToClientToolShape = TTool extends { + name: infer TName extends string +} + ? { + __toolSide: 'definition' + name: TName + description: string + inputSchema?: TTool extends { inputSchema?: infer TIn } ? TIn : undefined + outputSchema?: TTool extends { outputSchema?: infer TOut } + ? TOut + : undefined + needsApproval?: TTool extends { needsApproval?: infer TNa } ? TNa : false + } + : never + +/** Map a captured tool tuple to a client-tool-shaped tuple (see above). */ +type ToClientTools = TTools extends readonly [ + infer THead, + ...infer TRest, +] + ? [ToClientToolShape, ...ToClientTools] + : [] + +/** The return type of a chat verb's captured callback. */ +type ChatVerbReturn = + TVerb extends ChatVerb + ? TCallback extends (...args: Array) => infer TRet + ? TRet + : never + : never + +/** + * Client-tool-shaped tuple recovered from a chat verb's captured tools. + * Drives the `messages` tool-call/result part typing on that chat surface. + */ +export type ChatToolsOfVerb = ToClientTools< + NonNullable>> +> + +/** Structured-output schema recovered from a chat verb (or `undefined`). */ +export type ChatSchemaOfVerb = InferChatSchema> + +/** The base chat verb surface — mirrors the frameworks' useChat return. */ +export interface TransactionChatSurfaceBase< + TTools extends ReadonlyArray = [], +> { + /** Current messages in the conversation. */ + messages: Array> + + /** + * Append a message to the conversation. + */ + append: (message: ModelMessage | UIMessage) => Promise + + /** + * Reload the last assistant message. + */ + reload: () => Promise + + /** + * Stop the current response generation. + */ + stop: () => void + + /** + * Clear all messages. + */ + clear: () => void + + /** + * Set messages manually. + */ + setMessages: (messages: Array>) => void + + /** + * Add the result of a client-side tool execution. + */ + addToolResult: (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => Promise + + /** + * Respond to a tool approval request. + */ + addToolApprovalResponse: (response: { + id: string // approval.id, not toolCallId + approved: boolean + }) => Promise + + /** + * Whether a response is currently being generated. + */ + isLoading: boolean + + /** + * Current error, if any. + */ + error: Error | undefined + + /** + * Current status of the chat client. + */ + status: ChatClientState + + /** + * Whether the subscription loop is currently active. + */ + isSubscribed: boolean + + /** + * Current connection lifecycle status. + */ + connectionStatus: ConnectionStatus + + /** + * Whether the shared session is actively generating. + */ + sessionGenerating: boolean +} + +/** + * The chat verb surface. Extends {@link TransactionChatSurfaceBase} and, + * when the callback declared an `outputSchema` (`TSchema extends + * SchemaInput`), adds the typed structured-output fields `partial` / `final` + * — mirroring the framework `useChat` hooks' conditional return. + */ +export type TransactionChatSurface< + TTools extends ReadonlyArray = [], + TSchema = undefined, +> = TransactionChatSurfaceBase & { + /** + * Send a message and get a response. Can be a simple string or multimodal + * content with images, audio, etc. + * + * Resolves once the turn completes: to the schema-validated final + * structured output (`InferSchemaType | null`) when the callback + * declared an `outputSchema`, otherwise to the resulting messages array. + */ + sendMessage: ( + content: string | MultimodalContent, + ) => Promise< + TSchema extends SchemaInput + ? InferSchemaType | null + : Array> + > +} & (TSchema extends SchemaInput + ? { + /** + * Live, progressively-parsed structured output while the stream is + * still arriving. Resets on every new run. + */ + partial: DeepPartial> + /** + * Final, schema-validated structured output. `null` until the + * terminal structured-output event arrives. Resets on every new run. + */ + final: InferSchemaType | null + } + : Record) + +/** The one-shot verb surface — a typed run/result pair plus live sub-runs. */ +export interface TransactionVerbSurface { + /** Invoke the verb. Resolves with its (transformed) result, or null. */ + run: (input: TInput) => Promise + result: TResult | null + isLoading: boolean + error: Error | undefined + status: GenerationClientState + stop: () => void + reset: () => void + /** + * Live state of the sub-runs the server-side `execute` spawned via + * `ctx.call` during the current/last run, in start order. Empty for verbs + * that don't compose siblings. + */ + subRuns: Array +} + +/** + * The stored `result` type for a one-shot verb `K`: the `onResult` + * transform's return type when the options declare one, otherwise the raw + * result type captured from the verb's `execute`. + */ +export type VerbResultType< + TOptions, + TVerbName extends string, + TResult, +> = TOptions extends { verbs?: infer TVerbOptions } + ? TVerbName extends keyof TVerbOptions + ? NonNullable extends { onResult?: infer TFn } + ? InferGenerationOutput + : TResult + : TResult + : TResult + +/** + * The full typed system returned by useTransaction: one surface per declared + * verb. Chat verbs get the conversational surface (tools and structured + * output inferred from their callback); one-shot verbs get a typed + * `run`/`result` surface (input from their schema, result from `execute`, + * transformed by the options' `onResult` when declared). + */ +export type TransactionSystem< + TDef extends TransactionDefinition, + /** + * The user's options object. Its per-verb `onResult` transforms drive each + * one-shot verb's `result` type. Defaults to `unknown` (no transforms). + */ + TOptions = unknown, +> = { + [K in keyof TDef['~verbs'] & string]: TDef['~verbs'][K] extends ChatVerb + ? TransactionChatSurface< + ChatToolsOfVerb, + ChatSchemaOfVerb + > + : TDef['~verbs'][K] extends OneShotVerb + ? TransactionVerbSurface> + : never +} diff --git a/packages/ai-client/src/transaction.ts b/packages/ai-client/src/transaction.ts new file mode 100644 index 000000000..4dff6f079 --- /dev/null +++ b/packages/ai-client/src/transaction.ts @@ -0,0 +1,18 @@ +export { TransactionClient } from './transaction-client' +export { computeStructuredParts } from './transaction-structured' +export type { + ChatSchemaOfVerb, + ChatToolsOfVerb, + ChatVerbOptions, + DeepPartial, + OneShotVerbOptions, + TransactionChatSurface, + TransactionChatSurfaceBase, + TransactionClientCallbacks, + TransactionClientOptions, + TransactionSubRun, + TransactionSystem, + TransactionVerbSurface, + VerbOptionsMap, + VerbResultType, +} from './transaction-types' diff --git a/packages/ai-client/tests/assistant-client.test.ts b/packages/ai-client/tests/assistant-client.test.ts deleted file mode 100644 index f4f9a7846..000000000 --- a/packages/ai-client/tests/assistant-client.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' -import { chat, toolDefinition } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { z } from 'zod' -import { AssistantClient } from '../src/assistant-client.js' -import { fetchServerSentEvents } from '../src/connection-adapters.js' -import type { AnyTextAdapter } from '@tanstack/ai' -import type { AssistantSystem } from '../src/assistant-types.js' - -// Declared, never executed — used only inside chat callbacks that the type -// tests never invoke (`defineAssistant` only runs `Object.keys`). Ambient, so -// it emits no runtime binding and is never read. -declare const adapter: AnyTextAdapter - -describe('AssistantClient', () => { - it('creates one sub-client per declared capability', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - const client = new AssistantClient({ - assistant, - connection: fetchServerSentEvents('/api/assistant'), - }) - - expect(client.has('chat')).toBe(true) - expect(client.has('image')).toBe(true) - expect(client.has('speech')).toBe(false) - expect(client.chat).toBeDefined() - expect(client.get('image')).toBeDefined() - expect(client.capabilities).toEqual(['chat', 'image']) - }) - - it('tags each sub-client with its capability', () => { - const assistant = defineAssistant({ - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - const client = new AssistantClient({ - assistant, - connection: fetchServerSentEvents('/api/assistant'), - }) - - expect(client.chat).toBeUndefined() - expect(client.has('chat')).toBe(false) - expect(client.get('image')).toBeDefined() - }) - - it('dispose() tears down the chat client and every one-shot client', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({ id: '', model: '', images: [] }) as any, - speech: async () => ({}) as any, - }) - const client = new AssistantClient({ - assistant, - connection: fetchServerSentEvents('/api/assistant'), - }) - - const chatDisposeCalls: Array = [] - const imageDisposeCalls: Array = [] - const speechDisposeCalls: Array = [] - client.chat!.dispose = () => { - chatDisposeCalls.push(true) - } - client.get('image')!.dispose = () => { - imageDisposeCalls.push(true) - } - client.get('speech')!.dispose = () => { - speechDisposeCalls.push(true) - } - - client.dispose() - - expect(chatDisposeCalls).toHaveLength(1) - expect(imageDisposeCalls).toHaveLength(1) - expect(speechDisposeCalls).toHaveLength(1) - }) - - it('AssistantSystem exposes only declared capabilities, typed', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - type Sys = AssistantSystem - expectTypeOf().toHaveProperty('chat') - expectTypeOf().toHaveProperty('image') - // @ts-expect-error speech was not declared - expectTypeOf().toHaveProperty('speech') - expectTypeOf().toMatchTypeOf<{ - id: string - model: string - images: Array - } | null>() - }) -}) - -/** - * Type-only tests: the chat surface is inferred from the chat callback's - * return — its captured tools type the message tool-call parts, and its - * `outputSchema` (if any) adds typed `partial` / `final`. `defineAssistant` is - * called for real (only `Object.keys` runs); the `chat(...)` callbacks are - * never invoked, so a declared `AnyTextAdapter` never runs. - */ -describe('AssistantSystem chat surface inference', () => { - it('callback tools narrow the chat messages tool-call parts', () => { - const weatherDef = toolDefinition({ - name: 'get_weather', - description: 'Get the weather for a city', - inputSchema: z.object({ city: z.string() }), - outputSchema: z.object({ tempC: z.number() }), - }) - - const assistant = defineAssistant({ - chat: (req) => - chat({ adapter, messages: req.messages, tools: [weatherDef] }), - }) - - type Sys = AssistantSystem - type Part = Sys['chat']['messages'][number]['parts'][number] - type WeatherCall = Extract - - // The tool-call part named `get_weather` exists and its input/output are - // narrowed to the tool's schema-inferred types. - expectTypeOf().toEqualTypeOf<'get_weather'>() - expectTypeOf().toEqualTypeOf< - { city: string } | undefined - >() - expectTypeOf().toEqualTypeOf< - { tempC: number } | undefined - >() - }) - - it('outputSchema adds typed partial/final to the chat surface', () => { - const outputSchema = z.object({ answer: z.string(), score: z.number() }) - - const assistant = defineAssistant({ - chat: (req) => - chat({ adapter, messages: req.messages, outputSchema, stream: true }), - }) - - type ChatSurface = AssistantSystem['chat'] - - expectTypeOf().toEqualTypeOf<{ - answer: string - score: number - } | null>() - expectTypeOf().toEqualTypeOf<{ - answer?: string - score?: number - }>() - }) - - it('a plain chat (no schema) exposes no partial/final', () => { - const assistant = defineAssistant({ - chat: (req) => chat({ adapter, messages: req.messages }), - }) - - type ChatSurface = AssistantSystem['chat'] - - // @ts-expect-error no outputSchema → no `partial` on the chat surface - expectTypeOf().toHaveProperty('partial') - // @ts-expect-error no outputSchema → no `final` on the chat surface - expectTypeOf().toHaveProperty('final') - }) -}) diff --git a/packages/ai-client/tests/transaction-client.test.ts b/packages/ai-client/tests/transaction-client.test.ts new file mode 100644 index 000000000..5ad0373de --- /dev/null +++ b/packages/ai-client/tests/transaction-client.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { chat, toolDefinition } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { z } from 'zod' +import { TransactionClient } from '../src/transaction-client.js' +import { fetchServerSentEvents } from '../src/connection-adapters.js' +import type { AnyTextAdapter } from '@tanstack/ai' +import type { + TransactionSubRun, + TransactionSystem, +} from '../src/transaction-types.js' + +// Declared, never executed — used only inside chat callbacks that the type +// tests never invoke (`defineTransaction` only runs `Object.keys`). Ambient, +// so it emits no runtime binding and is never read. +declare const adapter: AnyTextAdapter + +const emptyChatVerb = () => chatVerb(async function* () {} as any) + +describe('TransactionClient', () => { + it('creates one sub-client per declared verb, by kind', () => { + const txn = defineTransaction({ + primaryChat: emptyChatVerb(), + banner: verb({ execute: async () => ({ url: 'x' }) }), + }) + const client = new TransactionClient({ + transaction: txn, + connection: fetchServerSentEvents('/api/transaction'), + }) + + expect(client.has('primaryChat')).toBe(true) + expect(client.has('banner')).toBe(true) + expect(client.has('speech')).toBe(false) + expect(client.chat('primaryChat')).toBeDefined() + expect(client.oneShot('banner')).toBeDefined() + expect(client.chat('banner')).toBeUndefined() + expect(client.oneShot('primaryChat')).toBeUndefined() + expect(client.verbs).toEqual(['primaryChat', 'banner']) + }) + + it('supports multiple chat verbs, each with its own ChatClient', () => { + const txn = defineTransaction({ + primaryChat: emptyChatVerb(), + summaryChat: emptyChatVerb(), + }) + const client = new TransactionClient({ + transaction: txn, + connection: fetchServerSentEvents('/api/transaction'), + }) + + expect(client.chat('primaryChat')).toBeDefined() + expect(client.chat('summaryChat')).toBeDefined() + expect(client.chat('primaryChat')).not.toBe(client.chat('summaryChat')) + }) + + it('dispose() tears down every sub-client', () => { + const txn = defineTransaction({ + primaryChat: emptyChatVerb(), + banner: verb({ execute: async () => ({}) }), + narration: verb({ execute: async () => ({}) }), + }) + const client = new TransactionClient({ + transaction: txn, + connection: fetchServerSentEvents('/api/transaction'), + }) + + const disposed: Array = [] + client.chat('primaryChat')!.dispose = () => { + disposed.push('primaryChat') + } + client.oneShot('banner')!.dispose = () => { + disposed.push('banner') + } + client.oneShot('narration')!.dispose = () => { + disposed.push('narration') + } + + client.dispose() + + expect(disposed.sort()).toEqual(['banner', 'narration', 'primaryChat']) + }) + + it('demuxes transaction sub-run events into per-verb state', () => { + const banner = verb({ execute: async () => ({ url: 'x' }) }) + const txn = defineTransaction({ + banner, + blogPost: verb({ + execute: async (_r, ctx) => ctx.call(banner, undefined as never), + }), + }) + const changes: Array> = [] + const client = new TransactionClient({ + transaction: txn, + connection: fetchServerSentEvents('/api/transaction'), + callbacks: { + oneShot: (verbName) => + verbName === 'blogPost' + ? { onSubRunsChange: (s) => changes.push(s) } + : {}, + }, + }) + + // Drive the demux directly through the GenerationClient's onChunk hook, + // simulating the server's event order. + const onChunk = (chunk: any) => + (client as any).handleSubRunChunk( + 'blogPost', + chunk, + changes.length >= 0 ? (s: any) => changes.push(s) : undefined, + ) + + onChunk({ type: 'RUN_STARTED', runId: 'r1', threadId: 't1' }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r1-sub-0', parentRunId: 'r1', verb: 'banner', index: 0 }, + }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:chunk', + value: { + runId: 'r1-sub-0', + verb: 'banner', + index: 0, + chunk: { type: 'TEXT_MESSAGE_CONTENT', delta: 'Hel' }, + }, + }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:chunk', + value: { + runId: 'r1-sub-0', + verb: 'banner', + index: 0, + chunk: { type: 'TEXT_MESSAGE_CONTENT', delta: 'lo' }, + }, + }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:result', + value: { + runId: 'r1-sub-0', + verb: 'banner', + index: 0, + result: { url: 'img' }, + }, + }) + + const subRuns = client.getSubRuns('blogPost') + expect(subRuns).toHaveLength(1) + expect(subRuns[0]).toMatchObject({ + runId: 'r1-sub-0', + verb: 'banner', + index: 0, + status: 'success', + result: { url: 'img' }, + text: 'Hello', + }) + + // A new root run resets the sub-run state. + onChunk({ type: 'RUN_STARTED', runId: 'r2', threadId: 't1' }) + expect(client.getSubRuns('blogPost')).toHaveLength(0) + }) + + it('marks a sub-run errored on the error event', () => { + const txn = defineTransaction({ + blogPost: verb({ execute: async () => ({}) }), + }) + const client = new TransactionClient({ + transaction: txn, + connection: fetchServerSentEvents('/api/transaction'), + }) + const onChunk = (chunk: any) => + (client as any).handleSubRunChunk('blogPost', chunk, undefined) + + onChunk({ type: 'RUN_STARTED', runId: 'r1', threadId: 't1' }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r1-sub-0', parentRunId: 'r1', verb: 'banner', index: 0 }, + }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:error', + value: { runId: 'r1-sub-0', verb: 'banner', index: 0, message: 'nope' }, + }) + + expect(client.getSubRuns('blogPost')[0]).toMatchObject({ + status: 'error', + error: 'nope', + }) + }) +}) + +describe('TransactionSystem typing', () => { + it('exposes only declared verbs, typed by kind', () => { + const txn = defineTransaction({ + primaryChat: emptyChatVerb(), + banner: verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ url: `img:${input.prompt}` }), + }), + }) + type Sys = TransactionSystem + expectTypeOf().toHaveProperty('primaryChat') + expectTypeOf().toHaveProperty('banner') + // @ts-expect-error speech was not declared + expectTypeOf().toHaveProperty('speech') + + // One-shot input comes from the verb's schema; result from execute. + expectTypeOf[0]>().toEqualTypeOf<{ + prompt: string + }>() + expectTypeOf().toEqualTypeOf<{ + url: string + } | null>() + expectTypeOf().toEqualTypeOf< + Array + >() + }) + + it('chat verb tools narrow that surface message tool-call parts', () => { + const weatherDef = toolDefinition({ + name: 'get_weather', + description: 'Get the weather for a city', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ tempC: z.number() }), + }) + + const txn = defineTransaction({ + primaryChat: chatVerb((req) => + chat({ adapter, messages: req.messages, tools: [weatherDef] }), + ), + }) + + type Sys = TransactionSystem + type Part = Sys['primaryChat']['messages'][number]['parts'][number] + type WeatherCall = Extract + + expectTypeOf().toEqualTypeOf<'get_weather'>() + expectTypeOf().toEqualTypeOf< + { city: string } | undefined + >() + expectTypeOf().toEqualTypeOf< + { tempC: number } | undefined + >() + }) + + it('outputSchema adds typed partial/final to that chat surface only', () => { + const outputSchema = z.object({ answer: z.string(), score: z.number() }) + + const txn = defineTransaction({ + drafting: chatVerb((req) => + chat({ adapter, messages: req.messages, outputSchema, stream: true }), + ), + support: chatVerb((req) => chat({ adapter, messages: req.messages })), + }) + + type Drafting = TransactionSystem['drafting'] + type Support = TransactionSystem['support'] + + expectTypeOf().toEqualTypeOf<{ + answer: string + score: number + } | null>() + expectTypeOf().toEqualTypeOf<{ + answer?: string + score?: number + }>() + + // @ts-expect-error no outputSchema → no `partial` on this chat surface + expectTypeOf().toHaveProperty('partial') + // @ts-expect-error no outputSchema → no `final` on this chat surface + expectTypeOf().toHaveProperty('final') + }) +}) diff --git a/packages/ai-client/tests/assistant-structured.test.ts b/packages/ai-client/tests/transaction-structured.test.ts similarity index 97% rename from packages/ai-client/tests/assistant-structured.test.ts rename to packages/ai-client/tests/transaction-structured.test.ts index 438afdad3..84c5eb189 100644 --- a/packages/ai-client/tests/assistant-structured.test.ts +++ b/packages/ai-client/tests/transaction-structured.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { computeStructuredParts } from '../src/assistant-structured.js' +import { computeStructuredParts } from '../src/transaction-structured.js' import type { UIMessage } from '../src/types.js' function userMessage(id: string): UIMessage { diff --git a/packages/ai-client/vite.config.ts b/packages/ai-client/vite.config.ts index b60ca079b..0b9b5153a 100644 --- a/packages/ai-client/vite.config.ts +++ b/packages/ai-client/vite.config.ts @@ -40,7 +40,7 @@ export default mergeConfig( // implementations; declare it as its own entry so the build emits // it independently and the main entry can stay free of the bridge // classes (they're imported only via `import type` from clients). - entry: ['./src/index.ts', './src/devtools.ts', './src/assistant.ts'], + entry: ['./src/index.ts', './src/devtools.ts', './src/transaction.ts'], srcDir: './src', cjs: false, }), diff --git a/packages/ai-react/package.json b/packages/ai-react/package.json index eb82e4bca..6ff056126 100644 --- a/packages/ai-react/package.json +++ b/packages/ai-react/package.json @@ -29,9 +29,9 @@ "types": "./dist/esm/mcp-apps.d.ts", "import": "./dist/esm/mcp-apps.js" }, - "./assistant": { - "types": "./dist/esm/assistant.d.ts", - "import": "./dist/esm/assistant.js" + "./transaction": { + "types": "./dist/esm/transaction.d.ts", + "import": "./dist/esm/transaction.js" } }, "files": [ @@ -85,6 +85,7 @@ "@vitest/coverage-v8": "4.0.14", "jsdom": "^27.2.0", "react": "^19.2.3", - "vite": "^7.3.3" + "vite": "^7.3.3", + "zod": "^4.2.0" } } diff --git a/packages/ai-react/src/assistant.ts b/packages/ai-react/src/assistant.ts deleted file mode 100644 index 6a8666b6e..000000000 --- a/packages/ai-react/src/assistant.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAssistant } from './use-assistant' diff --git a/packages/ai-react/src/transaction.ts b/packages/ai-react/src/transaction.ts new file mode 100644 index 000000000..643dca5bf --- /dev/null +++ b/packages/ai-react/src/transaction.ts @@ -0,0 +1 @@ +export { useTransaction } from './use-transaction' diff --git a/packages/ai-react/src/use-assistant.ts b/packages/ai-react/src/use-assistant.ts deleted file mode 100644 index 5d5af2539..000000000 --- a/packages/ai-react/src/use-assistant.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - AssistantClient, - computeStructuredParts, -} from '@tanstack/ai-client/assistant' -import { useEffect, useId, useMemo, useRef, useState } from 'react' -import type { AssistantDefinition } from '@tanstack/ai/assistant' -import type { - AssistantClientOptions, - AssistantSystem, - OneShotCapabilityName, -} from '@tanstack/ai-client/assistant' -import type { - ChatClientState, - ConnectionStatus, - GenerationClientState, -} from '@tanstack/ai-client' - -/** Reactive chat-capability state mirrored from the underlying ChatClient. */ -interface ChatState { - messages: Array - isLoading: boolean - error: Error | undefined - status: ChatClientState - isSubscribed: boolean - connectionStatus: ConnectionStatus - sessionGenerating: boolean -} - -/** Reactive one-shot-capability state mirrored from a GenerationClient. */ -interface OneShotState { - result: any - isLoading: boolean - error: Error | undefined - status: GenerationClientState -} - -const initialChatState: ChatState = { - messages: [], - isLoading: false, - error: undefined, - status: 'ready', - isSubscribed: false, - connectionStatus: 'disconnected', - sessionGenerating: false, -} - -const initialOneShotState: OneShotState = { - result: null, - isLoading: false, - error: undefined, - status: 'idle', -} - -/** - * React hook wrapping `AssistantClient`, composing the existing chat + - * generation clients behind one endpoint into a single typed system keyed by - * the assistant's declared capabilities. - * - * @example - * ```tsx - * const system = useAssistant(assistant, { - * connection: fetchServerSentEvents('/api/assistant'), - * }) - * - * await system.chat.sendMessage('hi') - * await system.image.generate({ prompt: 'a fox' }) - * ``` - */ -export function useAssistant< - TDef extends AssistantDefinition, - // Capture the options object so per-capability `onResult` transforms flow - // into each one-shot capability's `result` type. `any` tools keep the - // client-executed `chat.tools` option unconstrained (tool typing on the chat - // surface comes from the assistant definition, not this generic). - TOptions extends Omit< - AssistantClientOptions, - 'assistant' | 'callbacks' - >, ->(assistant: TDef, options: TOptions): AssistantSystem { - const hookId = useId() - const clientId = options.id ?? hookId - - const optionsRef = useRef(options) - optionsRef.current = options - const activeClientRef = useRef | null>(null) - - const [chatState, setChatState] = useState(initialChatState) - const [oneShotState, setOneShotState] = useState< - Record - >({}) - - const client = useMemo(() => { - const initial = optionsRef.current - - const instance = new AssistantClient({ - ...initial, - assistant, - id: clientId, - callbacks: { - chat: { - onMessagesChange: (m) => { - if (activeClientRef.current !== instance) return - setChatState((s) => ({ ...s, messages: m })) - }, - onLoadingChange: (v) => { - if (activeClientRef.current !== instance) return - setChatState((s) => ({ ...s, isLoading: v })) - }, - onErrorChange: (v) => { - if (activeClientRef.current !== instance) return - setChatState((s) => ({ ...s, error: v })) - }, - onStatusChange: (v) => { - if (activeClientRef.current !== instance) return - setChatState((s) => ({ ...s, status: v })) - }, - onSubscriptionChange: (v) => { - if (activeClientRef.current !== instance) return - setChatState((s) => ({ ...s, isSubscribed: v })) - }, - onConnectionStatusChange: (v) => { - if (activeClientRef.current !== instance) return - setChatState((s) => ({ ...s, connectionStatus: v })) - }, - onSessionGeneratingChange: (v) => { - if (activeClientRef.current !== instance) return - setChatState((s) => ({ ...s, sessionGenerating: v })) - }, - }, - oneShot: (cap) => ({ - onResultChange: (r) => { - if (activeClientRef.current !== instance) return - setOneShotState((s) => ({ - ...s, - [cap]: { ...(s[cap] ?? initialOneShotState), result: r }, - })) - }, - onLoadingChange: (v) => { - if (activeClientRef.current !== instance) return - setOneShotState((s) => ({ - ...s, - [cap]: { ...(s[cap] ?? initialOneShotState), isLoading: v }, - })) - }, - onErrorChange: (v) => { - if (activeClientRef.current !== instance) return - setOneShotState((s) => ({ - ...s, - [cap]: { ...(s[cap] ?? initialOneShotState), error: v }, - })) - }, - onStatusChange: (v) => { - if (activeClientRef.current !== instance) return - setOneShotState((s) => ({ - ...s, - [cap]: { ...(s[cap] ?? initialOneShotState), status: v }, - })) - }, - }), - }, - }) - activeClientRef.current = instance - return instance - // Client is intentionally keyed only on clientId, mirroring useChat. - }, [clientId]) - - useEffect(() => { - activeClientRef.current = client - return () => { - if (activeClientRef.current === client) { - activeClientRef.current = null - } - client.dispose() - } - }, [client]) - - const { partial, final } = useMemo( - () => computeStructuredParts(chatState.messages), - [chatState.messages], - ) - - const system = useMemo(() => { - const out: Record = {} - - for (const cap of client.capabilities) { - if (cap === 'chat') { - const c = client.chat - if (!c) continue - out.chat = { - messages: chatState.messages, - sendMessage: async (content: any) => { - await c.sendMessage(content) - const msgs = c.getMessages() - const hasStructured = - msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? - false - // Cast: TS can't structurally narrow the conditional return of - // AssistantChatSurface['sendMessage'] against this runtime branch, - // mirroring the assembled-`system` cast below. - return ( - hasStructured ? computeStructuredParts(msgs).final : msgs - ) as any - }, - append: (message: any) => c.append(message), - reload: () => c.reload(), - stop: () => c.stop(), - clear: () => c.clear(), - setMessages: (messages: any) => c.setMessagesManually(messages), - addToolResult: (result: any) => c.addToolResult(result), - addToolApprovalResponse: (response: any) => - c.addToolApprovalResponse(response), - isLoading: chatState.isLoading, - error: chatState.error, - status: chatState.status, - isSubscribed: chatState.isSubscribed, - connectionStatus: chatState.connectionStatus, - sessionGenerating: chatState.sessionGenerating, - // Runtime shape unconditionally exposes partial/final; the public - // AssistantSystem type hides them when the chat capability's - // outputSchema is absent, matching useChat's behavior. - partial, - final, - } - continue - } - - const g = client.get(cap as OneShotCapabilityName) - if (!g) continue - const slice = oneShotState[cap] ?? initialOneShotState - out[cap] = { - generate: async (input: any) => { - await g.generate(input) - return g.getResult() - }, - result: slice.result, - isLoading: slice.isLoading, - error: slice.error, - status: slice.status, - stop: () => g.stop(), - reset: () => g.reset(), - } - } - - return out as AssistantSystem - }, [client, chatState, oneShotState, partial, final]) - - return system -} diff --git a/packages/ai-react/src/use-transaction.ts b/packages/ai-react/src/use-transaction.ts new file mode 100644 index 000000000..5443ef12f --- /dev/null +++ b/packages/ai-react/src/use-transaction.ts @@ -0,0 +1,227 @@ +import { + TransactionClient, + computeStructuredParts, +} from '@tanstack/ai-client/transaction' +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import type { TransactionDefinition } from '@tanstack/ai/transaction' +import type { + TransactionClientOptions, + TransactionSubRun, + TransactionSystem, +} from '@tanstack/ai-client/transaction' +import type { + ChatClientState, + ConnectionStatus, + GenerationClientState, +} from '@tanstack/ai-client' + +/** Reactive chat-verb state mirrored from the underlying ChatClient. */ +interface ChatState { + messages: Array + isLoading: boolean + error: Error | undefined + status: ChatClientState + isSubscribed: boolean + connectionStatus: ConnectionStatus + sessionGenerating: boolean +} + +/** Reactive one-shot-verb state mirrored from a GenerationClient. */ +interface OneShotState { + result: any + isLoading: boolean + error: Error | undefined + status: GenerationClientState + subRuns: Array +} + +const initialChatState: ChatState = { + messages: [], + isLoading: false, + error: undefined, + status: 'ready', + isSubscribed: false, + connectionStatus: 'disconnected', + sessionGenerating: false, +} + +const initialOneShotState: OneShotState = { + result: null, + isLoading: false, + error: undefined, + status: 'idle', + subRuns: [], +} + +/** + * React hook wrapping `TransactionClient`, composing the existing chat + + * generation clients behind one endpoint into a single typed system keyed by + * the transaction's declared verbs. + * + * @example + * ```tsx + * const txn = useTransaction(blogTransaction, { + * connection: fetchServerSentEvents('/api/blog'), + * }) + * + * await txn.primaryChat.sendMessage('hi') + * await txn.banner.run({ prompt: 'a fox' }) + * txn.blogPost.subRuns // live sub-run state during a transaction run + * ``` + */ +export function useTransaction< + TDef extends TransactionDefinition, + // Capture the options object so per-verb `onResult` transforms flow into + // each one-shot verb's `result` type. + TOptions extends Omit, 'transaction' | 'callbacks'>, +>(transaction: TDef, options: TOptions): TransactionSystem { + const hookId = useId() + const clientId = options.id ?? hookId + + const optionsRef = useRef(options) + optionsRef.current = options + const activeClientRef = useRef | null>(null) + + const [chatState, setChatState] = useState>({}) + const [oneShotState, setOneShotState] = useState< + Record + >({}) + + const client = useMemo(() => { + const initial = optionsRef.current + + const setChatSlice = ( + instance: TransactionClient, + verb: string, + patch: Partial, + ) => { + if (activeClientRef.current !== instance) return + setChatState((s) => ({ + ...s, + [verb]: { ...(s[verb] ?? initialChatState), ...patch }, + })) + } + const setOneShotSlice = ( + instance: TransactionClient, + verb: string, + patch: Partial, + ) => { + if (activeClientRef.current !== instance) return + setOneShotState((s) => ({ + ...s, + [verb]: { ...(s[verb] ?? initialOneShotState), ...patch }, + })) + } + + const instance: TransactionClient = new TransactionClient({ + ...initial, + transaction, + id: clientId, + callbacks: { + chat: (verb) => ({ + onMessagesChange: (m) => setChatSlice(instance, verb, { messages: m }), + onLoadingChange: (v) => setChatSlice(instance, verb, { isLoading: v }), + onErrorChange: (v) => setChatSlice(instance, verb, { error: v }), + onStatusChange: (v) => setChatSlice(instance, verb, { status: v }), + onSubscriptionChange: (v) => + setChatSlice(instance, verb, { isSubscribed: v }), + onConnectionStatusChange: (v) => + setChatSlice(instance, verb, { connectionStatus: v }), + onSessionGeneratingChange: (v) => + setChatSlice(instance, verb, { sessionGenerating: v }), + }), + oneShot: (verb) => ({ + onResultChange: (r) => setOneShotSlice(instance, verb, { result: r }), + onLoadingChange: (v) => + setOneShotSlice(instance, verb, { isLoading: v }), + onErrorChange: (v) => setOneShotSlice(instance, verb, { error: v }), + onStatusChange: (v) => setOneShotSlice(instance, verb, { status: v }), + onSubRunsChange: (subRuns) => + setOneShotSlice(instance, verb, { subRuns }), + }), + }, + }) + activeClientRef.current = instance + return instance + // Client is intentionally keyed only on clientId, mirroring useChat. + }, [clientId]) + + useEffect(() => { + activeClientRef.current = client + return () => { + if (activeClientRef.current === client) { + activeClientRef.current = null + } + client.dispose() + } + }, [client]) + + const system = useMemo(() => { + const out: Record = {} + + for (const verb of client.verbs) { + const c = client.chat(verb) + if (c) { + const slice = chatState[verb] ?? initialChatState + const { partial, final } = computeStructuredParts(slice.messages) + out[verb] = { + messages: slice.messages, + sendMessage: async (content: any) => { + await c.sendMessage(content) + const msgs = c.getMessages() + const hasStructured = + msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? + false + // Cast: TS can't structurally narrow the conditional return of + // TransactionChatSurface['sendMessage'] against this runtime + // branch, mirroring the assembled-`system` cast below. + return ( + hasStructured ? computeStructuredParts(msgs).final : msgs + ) as any + }, + append: (message: any) => c.append(message), + reload: () => c.reload(), + stop: () => c.stop(), + clear: () => c.clear(), + setMessages: (messages: any) => c.setMessagesManually(messages), + addToolResult: (result: any) => c.addToolResult(result), + addToolApprovalResponse: (response: any) => + c.addToolApprovalResponse(response), + isLoading: slice.isLoading, + error: slice.error, + status: slice.status, + isSubscribed: slice.isSubscribed, + connectionStatus: slice.connectionStatus, + sessionGenerating: slice.sessionGenerating, + // Runtime shape unconditionally exposes partial/final; the public + // TransactionSystem type hides them when the chat verb's + // outputSchema is absent, matching useChat's behavior. + partial, + final, + } + continue + } + + const g = client.oneShot(verb) + if (!g) continue + const slice = oneShotState[verb] ?? initialOneShotState + out[verb] = { + run: async (input: any) => { + await g.generate(input) + return g.getResult() + }, + result: slice.result, + isLoading: slice.isLoading, + error: slice.error, + status: slice.status, + stop: () => g.stop(), + reset: () => g.reset(), + subRuns: slice.subRuns, + } + } + + return out as TransactionSystem + }, [client, chatState, oneShotState]) + + return system +} diff --git a/packages/ai-react/tests/use-assistant.test.tsx b/packages/ai-react/tests/use-assistant.test.tsx deleted file mode 100644 index 49c22fa6c..000000000 --- a/packages/ai-react/tests/use-assistant.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest' -import { act, renderHook } from '@testing-library/react' -import { defineAssistant } from '@tanstack/ai/assistant' -import { stream } from '@tanstack/ai-client' -import type { ImageGenerationResult } from '@tanstack/ai' -import { useAssistant } from '../src/use-assistant.js' - -// A connection adapter that replays canned chunks for both capabilities, -// branching on the `capability` discriminator forwarded by AssistantClient. -function fakeConnection() { - return stream(async function* (_messages, data) { - const capability = (data as Record | undefined)?.capability - - if (capability === 'chat') { - yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any - yield { - type: 'TEXT_MESSAGE_START', - messageId: 'm1', - role: 'assistant', - } as any - yield { - type: 'TEXT_MESSAGE_CONTENT', - messageId: 'm1', - delta: 'hello', - } as any - yield { type: 'TEXT_MESSAGE_END', messageId: 'm1' } as any - yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any - } else { - yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any - yield { - type: 'CUSTOM', - name: 'generation:result', - value: { id: 'i', model: 'gpt-image-1', images: [{ url: 'u' }] }, - } as any - yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any - } - }) -} - -describe('useAssistant', () => { - it('exposes only the declared capabilities', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - const { result } = renderHook(() => - useAssistant(assistant, { connection: fakeConnection() }), - ) - expect(result.current.chat).toBeDefined() - expect(result.current.image).toBeDefined() - expect((result.current as any).speech).toBeUndefined() - }) - - it('generate() on a one-shot capability populates its result', async () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - const { result } = renderHook(() => - useAssistant(assistant, { connection: fakeConnection() }), - ) - - // Holder object (not a bare `let`) so TS keeps the awaited return type at - // the read site — a `let` assigned only inside the async `act` callback - // gets narrowed away. The assignment still type-checks generate()'s return. - const captured: { value: typeof result.current.image.result } = { - value: null, - } - await act(async () => { - captured.value = await result.current.image.generate({ prompt: 'a fox' }) - }) - - // generate() resolves to the fresh result... - expect(captured.value?.images[0]?.url).toBe('u') - // ...and the reactive result state is still populated. - expect(result.current.image.result?.images[0]?.url).toBe('u') - }) - - it('sendMessage() on the chat capability populates messages', async () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - const { result } = renderHook(() => - useAssistant(assistant, { connection: fakeConnection() }), - ) - - // Holder object (see note in the generate() test) keeps the awaited - // sendMessage() return type at the read site. - const captured: { value: typeof result.current.chat.messages } = { - value: [], - } - await act(async () => { - captured.value = await result.current.chat.sendMessage('hi') - }) - - // With no outputSchema, sendMessage() resolves to the messages array... - expect(captured.value.length).toBeGreaterThan(0) - // ...and the reactive messages state is still populated. - expect(result.current.chat.messages.length).toBeGreaterThan(0) - }) - - it('exposes chat.partial/final with cleared defaults on first render', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - const { result } = renderHook(() => - useAssistant(assistant, { connection: fakeConnection() }), - ) - - expect((result.current.chat as any).partial).toEqual({}) - expect((result.current.chat as any).final).toBeNull() - }) - - it('applies a one-shot onResult transform and infers its type', async () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - const { result } = renderHook(() => - useAssistant(assistant, { - connection: fakeConnection(), - image: { - onResult: (raw) => { - // The transform's input is the raw backend result, fully typed - // (`toEqualTypeOf` fails if `raw` were `any`). - expectTypeOf(raw).toEqualTypeOf() - return raw.images[0]?.url ?? null - }, - }, - }), - ) - - // The surface `result` is the transform's (non-nullish) return type — not - // the raw result, and not `any`. - expectTypeOf(result.current.image.result).toEqualTypeOf() - - const captured: { value: typeof result.current.image.result } = { - value: null, - } - await act(async () => { - captured.value = await result.current.image.generate({ prompt: 'a fox' }) - }) - - // The transformed value flows to both the resolved return and the - // reactive result state. - expect(captured.value).toBe('u') - expect(result.current.image.result).toBe('u') - }) -}) diff --git a/packages/ai-react/tests/use-transaction.test.tsx b/packages/ai-react/tests/use-transaction.test.tsx new file mode 100644 index 000000000..31e86d259 --- /dev/null +++ b/packages/ai-react/tests/use-transaction.test.tsx @@ -0,0 +1,204 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { stream } from '@tanstack/ai-client' +import { z } from 'zod' +import { useTransaction } from '../src/use-transaction.js' + +// A connection adapter that replays canned chunks per verb, branching on the +// `verb` discriminator forwarded by TransactionClient. +function fakeConnection() { + return stream(async function* (_messages, data) { + const verbName = (data as Record | undefined)?.verb + + if (verbName === 'primaryChat') { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'm1', + role: 'assistant', + } as any + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + delta: 'hello', + } as any + yield { type: 'TEXT_MESSAGE_END', messageId: 'm1' } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else if (verbName === 'blogPost') { + // A transaction run: one banner sub-run, then the final result. + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r-sub-0', parentRunId: 'r', verb: 'banner', index: 0 }, + } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:result', + value: { + runId: 'r-sub-0', + verb: 'banner', + index: 0, + result: { url: 'hero.png' }, + }, + } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { title: 'Foxes', heroUrl: 'hero.png' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { url: 'u' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } + }) +} + +function makeTransaction() { + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ url: `img:${input.prompt}` }), + }) + return defineTransaction({ + primaryChat: chatVerb(async function* (r: any) { + yield { type: 'RUN_STARTED', threadId: r.threadId, runId: r.runId } as any + } as any), + banner, + blogPost: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const hero = await ctx.call(banner, { prompt: input.topic }) + return { title: input.topic, heroUrl: hero.url } + }, + }), + }) +} + +describe('useTransaction', () => { + it('exposes only the declared verbs, by kind', () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + expect(result.current.primaryChat).toBeDefined() + expect(result.current.primaryChat.sendMessage).toBeDefined() + expect(result.current.banner).toBeDefined() + expect(result.current.banner.run).toBeDefined() + expect(result.current.blogPost).toBeDefined() + expect((result.current as any).speech).toBeUndefined() + }) + + it('run() on a one-shot verb populates its result, typed by the schema', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + // The input type comes from the verb's schema. + expectTypeOf(result.current.banner.run) + .parameter(0) + .toEqualTypeOf<{ prompt: string }>() + + // Holder object (not a bare `let`) so TS keeps the awaited return type + // at the read site. + const captured: { value: typeof result.current.banner.result } = { + value: null, + } + await act(async () => { + captured.value = await result.current.banner.run({ prompt: 'a fox' }) + }) + + expect(captured.value?.url).toBe('u') + expect(result.current.banner.result?.url).toBe('u') + }) + + it('sendMessage() on a chat verb populates its messages', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + const captured: { value: typeof result.current.primaryChat.messages } = { + value: [], + } + await act(async () => { + captured.value = await result.current.primaryChat.sendMessage('hi') + }) + + expect(captured.value.length).toBeGreaterThan(0) + expect(result.current.primaryChat.messages.length).toBeGreaterThan(0) + }) + + it('surfaces live sub-run state on a transaction run', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + await act(async () => { + await result.current.blogPost.run({ topic: 'foxes' }) + }) + + expect(result.current.blogPost.result).toEqual({ + title: 'Foxes', + heroUrl: 'hero.png', + }) + expect(result.current.blogPost.subRuns).toHaveLength(1) + expect(result.current.blogPost.subRuns[0]).toMatchObject({ + verb: 'banner', + status: 'success', + result: { url: 'hero.png' }, + }) + // The sibling banner surface is untouched by the transaction's sub-run. + expect(result.current.banner.result).toBeNull() + }) + + it('exposes chat partial/final with cleared defaults on first render', () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + expect((result.current.primaryChat as any).partial).toEqual({}) + expect((result.current.primaryChat as any).final).toBeNull() + }) + + it('applies a one-shot onResult transform and infers its type', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { + connection: fakeConnection(), + verbs: { + banner: { + onResult: (raw) => { + // The transform's input is the verb's typed result + // (`toEqualTypeOf` fails if `raw` were `any`). + expectTypeOf(raw).toEqualTypeOf<{ url: string }>() + return raw.url + }, + }, + }, + }), + ) + + // The surface `result` is the transform's (non-nullish) return type. + expectTypeOf(result.current.banner.result).toEqualTypeOf() + + const captured: { value: typeof result.current.banner.result } = { + value: null, + } + await act(async () => { + captured.value = await result.current.banner.run({ prompt: 'a fox' }) + }) + + expect(captured.value).toBe('u') + expect(result.current.banner.result).toBe('u') + }) +}) diff --git a/packages/ai-react/vite.config.ts b/packages/ai-react/vite.config.ts index 891058a8d..8e9b04cb2 100644 --- a/packages/ai-react/vite.config.ts +++ b/packages/ai-react/vite.config.ts @@ -29,7 +29,7 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts', './src/mcp-apps.ts', './src/assistant.ts'], + entry: ['./src/index.ts', './src/mcp-apps.ts', './src/transaction.ts'], srcDir: './src', cjs: false, }), diff --git a/packages/ai-solid/package.json b/packages/ai-solid/package.json index 554b8a403..14b46d87b 100644 --- a/packages/ai-solid/package.json +++ b/packages/ai-solid/package.json @@ -25,9 +25,9 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./assistant": { - "types": "./dist/assistant.d.ts", - "import": "./dist/assistant.js" + "./transaction": { + "types": "./dist/transaction.d.ts", + "import": "./dist/transaction.js" } }, "files": [ @@ -73,6 +73,7 @@ "solid-js": "^1.9.10", "tsdown": "^0.17.0-beta.6", "typescript": "5.9.3", - "vitest": "^4.0.14" + "vitest": "^4.0.14", + "zod": "^4.2.0" } } diff --git a/packages/ai-solid/src/assistant.ts b/packages/ai-solid/src/assistant.ts deleted file mode 100644 index 6a8666b6e..000000000 --- a/packages/ai-solid/src/assistant.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAssistant } from './use-assistant' diff --git a/packages/ai-solid/src/transaction.ts b/packages/ai-solid/src/transaction.ts new file mode 100644 index 000000000..643dca5bf --- /dev/null +++ b/packages/ai-solid/src/transaction.ts @@ -0,0 +1 @@ +export { useTransaction } from './use-transaction' diff --git a/packages/ai-solid/src/use-assistant.ts b/packages/ai-solid/src/use-assistant.ts deleted file mode 100644 index e749add1e..000000000 --- a/packages/ai-solid/src/use-assistant.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { - createMemo, - createSignal, - createUniqueId, - onCleanup, - onMount, -} from 'solid-js' - -import { - AssistantClient, - computeStructuredParts, -} from '@tanstack/ai-client/assistant' -import type { - AnyClientTool, - ChatClientState, - ConnectionStatus, - GenerationClientState, - MultimodalContent, - UIMessage, -} from '@tanstack/ai-client' -import type { - AssistantClientOptions, - AssistantSystem, - OneShotCapabilityName, -} from '@tanstack/ai-client/assistant' -import type { ModelMessage } from '@tanstack/ai' -import type { AssistantDefinition } from '@tanstack/ai/assistant' - -/** Reactive chat sub-state, mirrored from the `ChatClient` callbacks. */ -interface ChatState> { - messages: Array> - isLoading: boolean - error: Error | undefined - status: ChatClientState - isSubscribed: boolean - connectionStatus: ConnectionStatus - sessionGenerating: boolean -} - -/** Reactive one-shot sub-state, mirrored from a `GenerationClient`'s callbacks. */ -interface OneShotState { - result: unknown - isLoading: boolean - error: Error | undefined - status: GenerationClientState -} - -const defaultOneShotState: OneShotState = { - result: null, - isLoading: false, - error: undefined, - status: 'idle', -} - -/** - * Solid hook for a multi-capability `AssistantDefinition` — composes one - * reactive surface per declared capability (`chat`, and/or one or more - * one-shot generation capabilities) backed by a single `AssistantClient`. - * - * Mirrors the `useChat` idiom: state lives in `createSignal`s, the client is - * built once in a `createMemo` (`clientId` is a stable per-hook id), and every reactive - * callback is threaded into the `AssistantClient` constructor via its - * `callbacks` option — `ChatClient` and `GenerationClient` only accept these - * callbacks at construction time, not through `updateOptions`. - * - * @example - * ```tsx - * const assistant = useAssistant(myAssistant, { - * connection: fetchServerSentEvents('/api/assistant'), - * }) - * - * await assistant.chat.sendMessage('Hello') - * await assistant.image.generate({ prompt: 'A sunset' }) - * ``` - */ -export function useAssistant< - TDef extends AssistantDefinition, - TChatTools extends ReadonlyArray = [], - // Capture the options so per-capability `onResult` transforms flow into each - // one-shot capability's `result` type (`any` tools keep `chat.tools` - // unconstrained; chat tool typing comes from the definition). - TOptions extends Omit< - AssistantClientOptions, - 'assistant' | 'callbacks' - > = Omit, 'assistant' | 'callbacks'>, ->( - assistant: TDef, - options: TOptions, -): AssistantSystem { - const hookId = createUniqueId() - const clientId = options.id || hookId - - const [chatState, setChatState] = createSignal>({ - messages: [], - isLoading: false, - error: undefined, - status: 'ready', - isSubscribed: false, - connectionStatus: 'disconnected', - sessionGenerating: false, - }) - - const initialOneShotState: Record = {} - for (const capability of assistant.capabilities) { - if (capability !== 'chat') { - initialOneShotState[capability] = { ...defaultOneShotState } - } - } - const [oneShotState, setOneShotState] = - createSignal>(initialOneShotState) - - // Build the AssistantClient with every reactive callback wired through the - // constructor's `callbacks` option (VP2: ChatClient/GenerationClient only - // accept these at construction time). - const client = createMemo(() => { - return new AssistantClient({ - ...options, - assistant, - id: clientId, - callbacks: { - chat: { - onMessagesChange: (messages) => - setChatState((s) => ({ ...s, messages })), - onLoadingChange: (isLoading) => - setChatState((s) => ({ ...s, isLoading })), - onErrorChange: (error) => setChatState((s) => ({ ...s, error })), - onStatusChange: (status) => setChatState((s) => ({ ...s, status })), - onSubscriptionChange: (isSubscribed) => - setChatState((s) => ({ ...s, isSubscribed })), - onConnectionStatusChange: (connectionStatus) => - setChatState((s) => ({ ...s, connectionStatus })), - onSessionGeneratingChange: (sessionGenerating) => - setChatState((s) => ({ ...s, sessionGenerating })), - }, - oneShot: (capability) => ({ - onResultChange: (result) => - setOneShotState((s) => ({ - ...s, - [capability]: { - ...(s[capability] ?? defaultOneShotState), - result, - }, - })), - onLoadingChange: (isLoading) => - setOneShotState((s) => ({ - ...s, - [capability]: { - ...(s[capability] ?? defaultOneShotState), - isLoading, - }, - })), - onErrorChange: (error) => - setOneShotState((s) => ({ - ...s, - [capability]: { - ...(s[capability] ?? defaultOneShotState), - error, - }, - })), - onStatusChange: (status) => - setOneShotState((s) => ({ - ...s, - [capability]: { - ...(s[capability] ?? defaultOneShotState), - status, - }, - })), - }), - }, - }) - }) - - // Sync initial chat state now that the client (and its `chat` sub-client, - // if declared) exists — mirrors useChat's `setMessages(client().getMessages())`. - const initialChatClient = client().chat - if (initialChatClient) { - setChatState({ - messages: initialChatClient.getMessages(), - isLoading: initialChatClient.getIsLoading(), - error: initialChatClient.getError(), - status: initialChatClient.getStatus(), - isSubscribed: initialChatClient.getIsSubscribed(), - connectionStatus: initialChatClient.getConnectionStatus(), - sessionGenerating: initialChatClient.getSessionGenerating(), - }) - } - - onMount(() => { - client().chat?.mountDevtools() - }) - - // Cleanup on unmount: tear down the chat client and every one-shot client. - onCleanup(() => { - client().dispose() - }) - - // Narrowing helpers: capability presence is guaranteed by construction (a - // surface for `capability` is only built below when the assistant actually - // declares it), but the sub-client getters are typed as `T | undefined`, so - // reads go through an explicit check rather than a non-null assertion. - const requireChatClient = () => { - const chatClient = client().chat - if (chatClient === undefined) { - throw new Error( - 'useAssistant: "chat" capability was not declared on this assistant', - ) - } - return chatClient - } - - const requireOneShotClient = (capability: OneShotCapabilityName) => { - const oneShotClient = client().get(capability) - if (oneShotClient === undefined) { - throw new Error( - `useAssistant: "${capability}" capability was not declared on this assistant`, - ) - } - return oneShotClient - } - - // Built dynamically per declared capability below; the object shape can't - // be statically checked against the mapped `AssistantSystem` type, so it's - // assembled as `Record` and cast once at the end. - const system: Record = {} - - for (const capability of assistant.capabilities) { - if (capability === 'chat') { - system.chat = { - get messages() { - return chatState().messages - }, - get isLoading() { - return chatState().isLoading - }, - get error() { - return chatState().error - }, - get status() { - return chatState().status - }, - get isSubscribed() { - return chatState().isSubscribed - }, - get connectionStatus() { - return chatState().connectionStatus - }, - get sessionGenerating() { - return chatState().sessionGenerating - }, - // Runtime shape unconditionally exposes partial/final; the public - // AssistantSystem type hides them when the chat capability's - // outputSchema is absent, matching useChat's behavior. - get partial() { - return computeStructuredParts(chatState().messages).partial - }, - get final() { - return computeStructuredParts(chatState().messages).final - }, - sendMessage: async (content: string | MultimodalContent) => { - const chatClient = requireChatClient() - await chatClient.sendMessage(content) - const msgs = chatClient.getMessages() - const hasStructured = - msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? - false - // Cast: TS can't structurally narrow the conditional return of - // AssistantChatSurface['sendMessage'] against this runtime branch, - // mirroring the assembled-`system` cast at the end of this hook. - return ( - hasStructured ? computeStructuredParts(msgs).final : msgs - ) as any - }, - append: async (message: ModelMessage | UIMessage) => { - await requireChatClient().append(message) - }, - reload: async () => { - await requireChatClient().reload() - }, - stop: () => { - requireChatClient().stop() - }, - clear: () => { - requireChatClient().clear() - }, - setMessages: (messages: Array>) => { - requireChatClient().setMessagesManually(messages) - }, - addToolResult: async (result: { - toolCallId: string - tool: string - output: any - state?: 'output-available' | 'output-error' - errorText?: string - }) => { - await requireChatClient().addToolResult(result) - }, - addToolApprovalResponse: async (response: { - id: string - approved: boolean - }) => { - await requireChatClient().addToolApprovalResponse(response) - }, - } - continue - } - - const oneShotCapability = capability as OneShotCapabilityName - system[capability] = { - get result() { - return oneShotState()[oneShotCapability]?.result ?? null - }, - get isLoading() { - return oneShotState()[oneShotCapability]?.isLoading ?? false - }, - get error() { - return oneShotState()[oneShotCapability]?.error - }, - get status() { - return oneShotState()[oneShotCapability]?.status ?? 'idle' - }, - generate: async (input: Record) => { - const oneShotClient = requireOneShotClient(oneShotCapability) - await oneShotClient.generate(input) - return oneShotClient.getResult() - }, - stop: () => { - requireOneShotClient(oneShotCapability).stop() - }, - reset: () => { - requireOneShotClient(oneShotCapability).reset() - }, - } - } - - // eslint-disable-next-line no-restricted-syntax -- built dynamically per declared capability; TS can't structurally verify the assembled object against the mapped AssistantSystem type - return system as unknown as AssistantSystem -} diff --git a/packages/ai-solid/src/use-transaction.ts b/packages/ai-solid/src/use-transaction.ts new file mode 100644 index 000000000..b41a7525d --- /dev/null +++ b/packages/ai-solid/src/use-transaction.ts @@ -0,0 +1,323 @@ +import { + createMemo, + createSignal, + createUniqueId, + onCleanup, + onMount, +} from 'solid-js' + +import { + TransactionClient, + computeStructuredParts, +} from '@tanstack/ai-client/transaction' +import type { + ChatClientState, + ConnectionStatus, + GenerationClientState, + MultimodalContent, + UIMessage, +} from '@tanstack/ai-client' +import type { + TransactionClientOptions, + TransactionSubRun, + TransactionSystem, +} from '@tanstack/ai-client/transaction' +import type { ModelMessage } from '@tanstack/ai' +import type { TransactionDefinition } from '@tanstack/ai/transaction' + +/** Reactive chat-verb sub-state, mirrored from that verb's `ChatClient` callbacks. */ +interface ChatState { + messages: Array> + isLoading: boolean + error: Error | undefined + status: ChatClientState + isSubscribed: boolean + connectionStatus: ConnectionStatus + sessionGenerating: boolean +} + +/** Reactive one-shot-verb sub-state, mirrored from a `GenerationClient`'s callbacks. */ +interface OneShotState { + result: unknown + isLoading: boolean + error: Error | undefined + status: GenerationClientState + subRuns: Array +} + +const defaultChatState: ChatState = { + messages: [], + isLoading: false, + error: undefined, + status: 'ready', + isSubscribed: false, + connectionStatus: 'disconnected', + sessionGenerating: false, +} + +const defaultOneShotState: OneShotState = { + result: null, + isLoading: false, + error: undefined, + status: 'idle', + subRuns: [], +} + +/** + * Solid hook wrapping `TransactionClient`, composing the existing chat + + * generation clients behind one endpoint into a single typed system keyed by + * the transaction's declared verbs. + * + * Mirrors the `useChat` idiom: state lives in `createSignal`s, the client is + * built once in a `createMemo` (`clientId` is a stable per-hook id), and every + * reactive callback is threaded into the `TransactionClient` constructor via + * its `callbacks` option — `ChatClient` and `GenerationClient` only accept + * these callbacks at construction time, not through `updateOptions`. + * + * @example + * ```tsx + * const txn = useTransaction(blogTransaction, { + * connection: fetchServerSentEvents('/api/blog'), + * }) + * + * await txn.primaryChat.sendMessage('hi') + * await txn.banner.run({ prompt: 'a fox' }) + * txn.blogPost.subRuns // live sub-run state during a transaction run + * ``` + */ +export function useTransaction< + TDef extends TransactionDefinition, + // Capture the options object so per-verb `onResult` transforms flow into + // each one-shot verb's `result` type. + TOptions extends Omit< + TransactionClientOptions, + 'transaction' | 'callbacks' + >, +>(transaction: TDef, options: TOptions): TransactionSystem { + const hookId = createUniqueId() + const clientId = options.id || hookId + + const [chatState, setChatState] = createSignal>({}) + const [oneShotState, setOneShotState] = createSignal< + Record + >({}) + + const patchChatState = (verb: string, patch: Partial) => { + setChatState((s) => ({ + ...s, + [verb]: { ...(s[verb] ?? defaultChatState), ...patch }, + })) + } + const patchOneShotState = (verb: string, patch: Partial) => { + setOneShotState((s) => ({ + ...s, + [verb]: { ...(s[verb] ?? defaultOneShotState), ...patch }, + })) + } + + // Build the TransactionClient with every reactive callback wired through the + // constructor's `callbacks` option (ChatClient/GenerationClient only accept + // these at construction time). + const client = createMemo(() => { + return new TransactionClient({ + ...options, + transaction, + id: clientId, + callbacks: { + chat: (verb) => ({ + onMessagesChange: (messages) => patchChatState(verb, { messages }), + onLoadingChange: (isLoading) => patchChatState(verb, { isLoading }), + onErrorChange: (error) => patchChatState(verb, { error }), + onStatusChange: (status) => patchChatState(verb, { status }), + onSubscriptionChange: (isSubscribed) => + patchChatState(verb, { isSubscribed }), + onConnectionStatusChange: (connectionStatus) => + patchChatState(verb, { connectionStatus }), + onSessionGeneratingChange: (sessionGenerating) => + patchChatState(verb, { sessionGenerating }), + }), + oneShot: (verb) => ({ + onResultChange: (result) => patchOneShotState(verb, { result }), + onLoadingChange: (isLoading) => + patchOneShotState(verb, { isLoading }), + onErrorChange: (error) => patchOneShotState(verb, { error }), + onStatusChange: (status) => patchOneShotState(verb, { status }), + onSubRunsChange: (subRuns) => patchOneShotState(verb, { subRuns }), + }), + }, + }) + }) + + // Sync initial chat state now that the client (and its per-verb chat + // sub-clients) exists — mirrors useChat's `setMessages(client().getMessages())`. + for (const verb of client().verbs) { + const chatClient = client().chat(verb) + if (chatClient) { + patchChatState(verb, { + messages: chatClient.getMessages(), + isLoading: chatClient.getIsLoading(), + error: chatClient.getError(), + status: chatClient.getStatus(), + isSubscribed: chatClient.getIsSubscribed(), + connectionStatus: chatClient.getConnectionStatus(), + sessionGenerating: chatClient.getSessionGenerating(), + }) + } + } + + onMount(() => { + for (const verb of client().verbs) { + client().chat(verb)?.mountDevtools() + } + }) + + // Cleanup on unmount: tear down every chat and one-shot sub-client. + onCleanup(() => { + client().dispose() + }) + + // Narrowing helpers: verb presence is guaranteed by construction (a surface + // for `verb` is only built below when the transaction actually declares it), + // but the sub-client getters are typed as `T | undefined`, so reads go + // through an explicit check rather than a non-null assertion. + const requireChatClient = (verb: string) => { + const chatClient = client().chat(verb) + if (chatClient === undefined) { + throw new Error( + `useTransaction: "${verb}" is not a chat verb on this transaction`, + ) + } + return chatClient + } + + const requireOneShotClient = (verb: string) => { + const oneShotClient = client().oneShot(verb) + if (oneShotClient === undefined) { + throw new Error( + `useTransaction: "${verb}" is not a one-shot verb on this transaction`, + ) + } + return oneShotClient + } + + // Built dynamically per declared verb below; the object shape can't be + // statically checked against the mapped `TransactionSystem` type, so it's + // assembled as `Record` and cast once at the end. + const system: Record = {} + + for (const verb of client().verbs) { + if (client().chat(verb)) { + system[verb] = { + get messages() { + return chatState()[verb]?.messages ?? defaultChatState.messages + }, + get isLoading() { + return chatState()[verb]?.isLoading ?? false + }, + get error() { + return chatState()[verb]?.error + }, + get status() { + return chatState()[verb]?.status ?? 'ready' + }, + get isSubscribed() { + return chatState()[verb]?.isSubscribed ?? false + }, + get connectionStatus() { + return chatState()[verb]?.connectionStatus ?? 'disconnected' + }, + get sessionGenerating() { + return chatState()[verb]?.sessionGenerating ?? false + }, + // Runtime shape unconditionally exposes partial/final; the public + // TransactionSystem type hides them when the chat verb's outputSchema + // is absent, matching useChat's behavior. + get partial() { + return computeStructuredParts(chatState()[verb]?.messages ?? []) + .partial + }, + get final() { + return computeStructuredParts(chatState()[verb]?.messages ?? []).final + }, + sendMessage: async (content: string | MultimodalContent) => { + const chatClient = requireChatClient(verb) + await chatClient.sendMessage(content) + const msgs = chatClient.getMessages() + const hasStructured = + msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? + false + // Cast: TS can't structurally narrow the conditional return of + // TransactionChatSurface['sendMessage'] against this runtime branch, + // mirroring the assembled-`system` cast at the end of this hook. + return ( + hasStructured ? computeStructuredParts(msgs).final : msgs + ) as any + }, + append: async (message: ModelMessage | UIMessage) => { + await requireChatClient(verb).append(message) + }, + reload: async () => { + await requireChatClient(verb).reload() + }, + stop: () => { + requireChatClient(verb).stop() + }, + clear: () => { + requireChatClient(verb).clear() + }, + setMessages: (messages: Array>) => { + requireChatClient(verb).setMessagesManually(messages) + }, + addToolResult: async (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => { + await requireChatClient(verb).addToolResult(result) + }, + addToolApprovalResponse: async (response: { + id: string + approved: boolean + }) => { + await requireChatClient(verb).addToolApprovalResponse(response) + }, + } + continue + } + + if (!client().oneShot(verb)) continue + system[verb] = { + get result() { + return oneShotState()[verb]?.result ?? null + }, + get isLoading() { + return oneShotState()[verb]?.isLoading ?? false + }, + get error() { + return oneShotState()[verb]?.error + }, + get status() { + return oneShotState()[verb]?.status ?? 'idle' + }, + get subRuns() { + return oneShotState()[verb]?.subRuns ?? defaultOneShotState.subRuns + }, + run: async (input: Record) => { + const oneShotClient = requireOneShotClient(verb) + await oneShotClient.generate(input) + return oneShotClient.getResult() + }, + stop: () => { + requireOneShotClient(verb).stop() + }, + reset: () => { + requireOneShotClient(verb).reset() + }, + } + } + + // eslint-disable-next-line no-restricted-syntax -- built dynamically per declared verb; TS can't structurally verify the assembled object against the mapped TransactionSystem type + return system as unknown as TransactionSystem +} diff --git a/packages/ai-solid/tests/use-assistant.test.tsx b/packages/ai-solid/tests/use-assistant.test.tsx deleted file mode 100644 index bc0c97cb8..000000000 --- a/packages/ai-solid/tests/use-assistant.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { renderHook, waitFor } from '@solidjs/testing-library' -import { describe, expect, it } from 'vitest' -import { defineAssistant } from '@tanstack/ai/assistant' -import { stream } from '@tanstack/ai-client' -import type { StreamChunk } from '@tanstack/ai' -import { useAssistant } from '../src/use-assistant.js' -import { createTextChunks } from './test-utils' - -/** - * A canned connection shared by chat and one-shot sub-clients (mirroring how - * `AssistantClient` composes them). Routes on `data.capability`, which - * `AssistantClient` tags onto every request: `forwardedProps.capability` for - * chat, `body.capability` for one-shot generation. - */ -function createAssistantConnection() { - return stream(async function* (_messages, data): AsyncGenerator { - const capability = (data as Record | undefined)?.capability - - if (capability === 'chat') { - yield* createTextChunks('Hello from chat') - return - } - - yield { - type: 'RUN_STARTED', - runId: 'run-1', - threadId: 'thread-1', - timestamp: Date.now(), - } as StreamChunk - yield { - type: 'CUSTOM', - name: 'generation:result', - value: { id: 'img-1', model: 'test-model', images: [] }, - timestamp: Date.now(), - } as StreamChunk - yield { - type: 'RUN_FINISHED', - runId: 'run-1', - threadId: 'thread-1', - timestamp: Date.now(), - } as StreamChunk - }) -} - -describe('useAssistant', () => { - it('exposes only the declared capabilities', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - - const { result } = renderHook(() => - useAssistant(assistant, { connection: createAssistantConnection() }), - ) - - expect(result.chat).toBeDefined() - expect(result.image).toBeDefined() - expect((result as any).speech).toBeUndefined() - }) - - it('populates the one-shot result after generate()', async () => { - const assistant = defineAssistant({ - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - - const { result } = renderHook(() => - useAssistant(assistant, { connection: createAssistantConnection() }), - ) - - expect(result.image.result).toBeNull() - expect(result.image.isLoading).toBe(false) - expect(result.image.status).toBe('idle') - - // generate() resolves to the fresh result... - const generated = await result.image.generate({ prompt: 'A sunset' }) - expect(generated).toEqual({ - id: 'img-1', - model: 'test-model', - images: [], - }) - - // ...and the reactive result state is still populated. - expect(result.image.result).toEqual({ - id: 'img-1', - model: 'test-model', - images: [], - }) - expect(result.image.status).toBe('success') - expect(result.image.isLoading).toBe(false) - }) - - it('populates chat messages after sendMessage()', async () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - }) - - const { result } = renderHook(() => - useAssistant(assistant, { connection: createAssistantConnection() }), - ) - - expect(result.chat.messages).toEqual([]) - - // With no outputSchema, sendMessage() resolves to the messages array. - const returned = await result.chat.sendMessage('Hi there') - expect(returned.length).toBeGreaterThanOrEqual(2) - - await waitFor(() => { - expect(result.chat.messages.length).toBeGreaterThanOrEqual(2) - }) - - const userMessage = result.chat.messages.find((m) => m.role === 'user') - expect(userMessage).toBeDefined() - if (userMessage) { - expect(userMessage.parts[0]).toEqual({ - type: 'text', - content: 'Hi there', - }) - } - - const assistantMessage = result.chat.messages.find( - (m) => m.role === 'assistant', - ) - expect(assistantMessage).toBeDefined() - const textPart = assistantMessage?.parts.find((p) => p.type === 'text') - expect(textPart).toBeDefined() - if (textPart && textPart.type === 'text') { - expect(textPart.content).toBe('Hello from chat') - } - }) - - it('supports both chat and one-shot capabilities on the same assistant', async () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - - const { result } = renderHook(() => - useAssistant(assistant, { connection: createAssistantConnection() }), - ) - - await result.chat.sendMessage('Hi there') - await waitFor(() => { - expect(result.chat.messages.length).toBeGreaterThanOrEqual(2) - }) - - await result.image.generate({ prompt: 'A sunset' }) - expect(result.image.result).toEqual({ - id: 'img-1', - model: 'test-model', - images: [], - }) - }) - - it('exposes structured partial/final on the chat surface with no structured part present', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - }) - - const { result } = renderHook(() => - useAssistant(assistant, { connection: createAssistantConnection() }), - ) - - expect((result.chat as any).partial).toEqual({}) - expect((result.chat as any).final).toBeNull() - }) - - it('disposes both the chat and one-shot sub-clients on cleanup', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - - const { result, cleanup } = renderHook(() => - useAssistant(assistant, { connection: createAssistantConnection() }), - ) - - expect(result.chat).toBeDefined() - expect(() => cleanup()).not.toThrow() - }) -}) diff --git a/packages/ai-solid/tests/use-transaction.test.tsx b/packages/ai-solid/tests/use-transaction.test.tsx new file mode 100644 index 000000000..ded1faaaa --- /dev/null +++ b/packages/ai-solid/tests/use-transaction.test.tsx @@ -0,0 +1,224 @@ +import { renderHook, waitFor } from '@solidjs/testing-library' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { stream } from '@tanstack/ai-client' +import { z } from 'zod' +import { useTransaction } from '../src/use-transaction.js' + +// A connection adapter that replays canned chunks per verb, branching on the +// `verb` discriminator forwarded by TransactionClient. +function fakeConnection() { + return stream(async function* (_messages, data) { + const verbName = (data as Record | undefined)?.verb + + if (verbName === 'primaryChat') { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'm1', + role: 'assistant', + } as any + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + delta: 'hello', + } as any + yield { type: 'TEXT_MESSAGE_END', messageId: 'm1' } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else if (verbName === 'blogPost') { + // A transaction run: one banner sub-run, then the final result. + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r-sub-0', parentRunId: 'r', verb: 'banner', index: 0 }, + } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:result', + value: { + runId: 'r-sub-0', + verb: 'banner', + index: 0, + result: { url: 'hero.png' }, + }, + } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { title: 'Foxes', heroUrl: 'hero.png' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { url: 'u' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } + }) +} + +function makeTransaction() { + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ url: `img:${input.prompt}` }), + }) + return defineTransaction({ + primaryChat: chatVerb(async function* (r: any) { + yield { type: 'RUN_STARTED', threadId: r.threadId, runId: r.runId } as any + } as any), + banner, + blogPost: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const hero = await ctx.call(banner, { prompt: input.topic }) + return { title: input.topic, heroUrl: hero.url } + }, + }), + }) +} + +describe('useTransaction', () => { + it('exposes only the declared verbs, by kind', () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + expect(result.primaryChat).toBeDefined() + expect(result.primaryChat.sendMessage).toBeDefined() + expect(result.banner).toBeDefined() + expect(result.banner.run).toBeDefined() + expect(result.blogPost).toBeDefined() + expect((result as any).speech).toBeUndefined() + }) + + it('run() on a one-shot verb populates its result, typed by the schema', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + // The input type comes from the verb's schema. + expectTypeOf(result.banner.run) + .parameter(0) + .toEqualTypeOf<{ prompt: string }>() + + expect(result.banner.result).toBeNull() + expect(result.banner.isLoading).toBe(false) + expect(result.banner.status).toBe('idle') + + // run() resolves to the fresh result... + const generated = await result.banner.run({ prompt: 'a fox' }) + expect(generated?.url).toBe('u') + + // ...and the reactive result state is still populated. + expect(result.banner.result?.url).toBe('u') + expect(result.banner.status).toBe('success') + expect(result.banner.isLoading).toBe(false) + }) + + it('sendMessage() on a chat verb populates its messages', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + expect(result.primaryChat.messages).toEqual([]) + + // With no outputSchema, sendMessage() resolves to the messages array. + const returned = await result.primaryChat.sendMessage('hi') + expect(returned.length).toBeGreaterThan(0) + + await waitFor(() => { + expect(result.primaryChat.messages.length).toBeGreaterThan(0) + }) + + const userMessage = result.primaryChat.messages.find( + (m) => m.role === 'user', + ) + expect(userMessage).toBeDefined() + if (userMessage) { + expect(userMessage.parts[0]).toEqual({ type: 'text', content: 'hi' }) + } + + const assistantMessage = result.primaryChat.messages.find( + (m) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + const textPart = assistantMessage?.parts.find((p) => p.type === 'text') + expect(textPart).toBeDefined() + expect(textPart?.content).toBe('hello') + }) + + it('surfaces live sub-run state on a transaction run', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + await result.blogPost.run({ topic: 'foxes' }) + + expect(result.blogPost.result).toEqual({ + title: 'Foxes', + heroUrl: 'hero.png', + }) + expect(result.blogPost.subRuns).toHaveLength(1) + expect(result.blogPost.subRuns[0]).toMatchObject({ + verb: 'banner', + status: 'success', + result: { url: 'hero.png' }, + }) + // The sibling banner surface is untouched by the transaction's sub-run. + expect(result.banner.result).toBeNull() + }) + + it('exposes chat partial/final with cleared defaults on first render', () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + expect((result.primaryChat as any).partial).toEqual({}) + expect((result.primaryChat as any).final).toBeNull() + }) + + it('applies a one-shot onResult transform and infers its type', async () => { + const txn = makeTransaction() + const { result } = renderHook(() => + useTransaction(txn, { + connection: fakeConnection(), + verbs: { + banner: { + onResult: (raw) => { + // The transform's input is the verb's typed result + // (`toEqualTypeOf` fails if `raw` were `any`). + expectTypeOf(raw).toEqualTypeOf<{ url: string }>() + return raw.url + }, + }, + }, + }), + ) + + // The surface `result` is the transform's (non-nullish) return type. + expectTypeOf(result.banner.result).toEqualTypeOf() + + const returned = await result.banner.run({ prompt: 'a fox' }) + expect(returned).toBe('u') + expect(result.banner.result).toBe('u') + }) + + it('disposes every chat and one-shot sub-client on cleanup', () => { + const txn = makeTransaction() + const { result, cleanup } = renderHook(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + expect(result.primaryChat).toBeDefined() + expect(() => cleanup()).not.toThrow() + }) +}) diff --git a/packages/ai-solid/tsdown.config.ts b/packages/ai-solid/tsdown.config.ts index de631c408..e2591dbd6 100644 --- a/packages/ai-solid/tsdown.config.ts +++ b/packages/ai-solid/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ - entry: ['./src/index.ts', './src/assistant.ts'], + entry: ['./src/index.ts', './src/transaction.ts'], format: ['esm'], unbundle: true, dts: true, diff --git a/packages/ai-svelte/package.json b/packages/ai-svelte/package.json index fa06b7a56..86e1471ee 100644 --- a/packages/ai-svelte/package.json +++ b/packages/ai-svelte/package.json @@ -27,10 +27,10 @@ "svelte": "./dist/index.js", "import": "./dist/index.js" }, - "./assistant": { - "types": "./dist/assistant.d.ts", - "svelte": "./dist/assistant.js", - "import": "./dist/assistant.js" + "./transaction": { + "types": "./dist/transaction.d.ts", + "svelte": "./dist/transaction.js", + "import": "./dist/transaction.js" } }, "files": [ @@ -78,6 +78,7 @@ "svelte": "^5.20.0", "svelte-check": "^4.2.0", "typescript": "5.9.3", - "vite": "^7.3.3" + "vite": "^7.3.3", + "zod": "^4.2.0" } } diff --git a/packages/ai-svelte/src/assistant.ts b/packages/ai-svelte/src/assistant.ts deleted file mode 100644 index 7d2764959..000000000 --- a/packages/ai-svelte/src/assistant.ts +++ /dev/null @@ -1 +0,0 @@ -export { createAssistant } from './create-assistant.svelte' diff --git a/packages/ai-svelte/src/create-assistant.svelte.ts b/packages/ai-svelte/src/create-assistant.svelte.ts deleted file mode 100644 index 0e011a1e1..000000000 --- a/packages/ai-svelte/src/create-assistant.svelte.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { - AssistantClient, - computeStructuredParts, -} from '@tanstack/ai-client/assistant' -import type { - AssistantClientOptions, - AssistantSystem, - OneShotCapabilityName, -} from '@tanstack/ai-client/assistant' -import type { - AnyClientTool, - ChatClientState, - ConnectionStatus, - GenerationClientState, -} from '@tanstack/ai-client' -import type { AssistantDefinition } from '@tanstack/ai/assistant' -import type { ModelMessage } from '@tanstack/ai' -import type { MultimodalContent, UIMessage } from './types' - -/** Reactive state for a single one-shot (non-chat) capability. */ -interface OneShotState { - result: unknown - isLoading: boolean - error: Error | undefined - status: GenerationClientState -} - -/** - * Creates a reactive assistant instance for Svelte 5. - * - * This function wraps the `AssistantClient` from `@tanstack/ai-client` and - * exposes reactive state using Svelte 5 runes, one surface per declared - * capability (`chat` plus any one-shot generation capabilities). The - * returned object exposes reactive getters that automatically update when - * state changes. - * - * @example - * ```svelte - * - * - *
- * {#each assistant.chat.messages as message} - *
{message.role}: {message.parts[0].content}
- * {/each} - * - * - * - *
- * ``` - */ -export function createAssistant< - TDef extends AssistantDefinition, - TChatTools extends ReadonlyArray = [], - // Capture the options so per-capability `onResult` transforms flow into each - // one-shot capability's `result` type (`any` tools keep `chat.tools` - // unconstrained; chat tool typing comes from the definition). - TOptions extends Omit< - AssistantClientOptions, - 'assistant' | 'callbacks' - > = Omit, 'assistant' | 'callbacks'>, ->( - assistant: TDef, - options: TOptions, -): AssistantSystem & { dispose: () => void } { - // Reactive state for the chat capability, if declared. - let chatMessages = $state>>([]) - let chatIsLoading = $state(false) - let chatError = $state(undefined) - let chatStatus = $state('ready') - let chatIsSubscribed = $state(false) - let chatConnectionStatus = $state('disconnected') - let chatSessionGenerating = $state(false) - - // Derived structured-output `partial`/`final`, recomputed whenever - // `chatMessages` changes (mirrors `useChat`/`useAssistant`'s `useMemo`). - const structuredParts = $derived(computeStructuredParts(chatMessages)) - - // Reactive state per one-shot capability, keyed by capability name. - // Initialized eagerly for every declared one-shot capability, since - // `assistant.capabilities` is fixed at creation time (mirrors - // `AssistantClient`'s own constructor, which iterates the same array). - const oneShotStates = $state>({}) - for (const capability of assistant.capabilities) { - if (capability === 'chat') continue - oneShotStates[capability] = { - result: null, - isLoading: false, - error: undefined, - status: 'idle', - } - } - - // Returns (and lazily creates) the reactive state slot for a one-shot - // capability, avoiding a non-null assertion at each call site below. - const ensureOneShotState = (capability: string): OneShotState => { - let state = oneShotStates[capability] - if (!state) { - state = { - result: null, - isLoading: false, - error: undefined, - status: 'idle', - } - oneShotStates[capability] = state - } - return state - } - - // Create the AssistantClient eagerly, once. Reactive callbacks are passed - // into the constructor (the only place ChatClient/GenerationClient accept - // them) rather than via `updateOptions`, mirroring `createChat`. - const client = new AssistantClient({ - ...options, - assistant, - callbacks: { - chat: { - onMessagesChange: (newMessages) => { - chatMessages = newMessages - }, - onLoadingChange: (newIsLoading) => { - chatIsLoading = newIsLoading - }, - onErrorChange: (newError) => { - chatError = newError - }, - onStatusChange: (newStatus) => { - chatStatus = newStatus - }, - onSubscriptionChange: (nextIsSubscribed) => { - chatIsSubscribed = nextIsSubscribed - }, - onConnectionStatusChange: (nextStatus) => { - chatConnectionStatus = nextStatus - }, - onSessionGeneratingChange: (isGenerating) => { - chatSessionGenerating = isGenerating - }, - }, - oneShot: (capability) => ({ - onResultChange: (result) => { - ensureOneShotState(capability).result = result - }, - onLoadingChange: (isLoading) => { - ensureOneShotState(capability).isLoading = isLoading - }, - onErrorChange: (error) => { - ensureOneShotState(capability).error = error - }, - onStatusChange: (status) => { - ensureOneShotState(capability).status = status - }, - }), - }, - }) - - chatMessages = client.chat?.getMessages() ?? [] - - // Note: No auto-cleanup in Svelte — call `dispose()` in your component's - // cleanup if needed. Unlike React/Vue/Solid, Svelte 5 runes like $effect - // can only be used during component initialization. - const dispose = () => { - client.dispose() - } - - // Chat capability methods. - const sendMessage = async (content: string | MultimodalContent) => { - await client.chat?.sendMessage(content) - const msgs = client.chat?.getMessages() ?? [] - const hasStructured = - msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? false - // Cast: TS can't structurally narrow the conditional return of - // AssistantChatSurface['sendMessage'] against this runtime branch, - // mirroring the assembled-`system` cast at the end of this function. - return (hasStructured ? computeStructuredParts(msgs).final : msgs) as any - } - - const append = async (message: ModelMessage | UIMessage) => { - await client.chat?.append(message) - } - - const reload = async () => { - await client.chat?.reload() - } - - const chatStop = () => { - client.chat?.stop() - } - - const clear = () => { - client.chat?.clear() - } - - const setMessages = (newMessages: Array>) => { - client.chat?.setMessagesManually(newMessages) - } - - const addToolResult = async (result: { - toolCallId: string - tool: string - output: any - state?: 'output-available' | 'output-error' - errorText?: string - }) => { - await client.chat?.addToolResult(result) - } - - const addToolApprovalResponse = async (response: { - id: string - approved: boolean - }) => { - await client.chat?.addToolApprovalResponse(response) - } - - // Build the returned system: one entry per declared capability, plus a - // top-level `dispose`. Uses getters so Svelte tracks the underlying - // `$state` without a `$` prefix. - const system: Record = {} - - for (const capability of assistant.capabilities) { - if (capability === 'chat') { - system.chat = { - get messages() { - return chatMessages - }, - get isLoading() { - return chatIsLoading - }, - get error() { - return chatError - }, - get status() { - return chatStatus - }, - get isSubscribed() { - return chatIsSubscribed - }, - get connectionStatus() { - return chatConnectionStatus - }, - get sessionGenerating() { - return chatSessionGenerating - }, - // Runtime shape unconditionally exposes partial/final; the public - // AssistantSystem type hides them when the chat capability's - // outputSchema is absent, matching useChat's behavior. - get partial() { - return structuredParts.partial - }, - get final() { - return structuredParts.final - }, - sendMessage, - append, - reload, - stop: chatStop, - clear, - setMessages, - addToolResult, - addToolApprovalResponse, - } - continue - } - - const capabilityName = capability as OneShotCapabilityName - system[capabilityName] = { - get result() { - return oneShotStates[capabilityName]?.result ?? null - }, - get isLoading() { - return oneShotStates[capabilityName]?.isLoading ?? false - }, - get error() { - return oneShotStates[capabilityName]?.error - }, - get status() { - return oneShotStates[capabilityName]?.status ?? 'idle' - }, - generate: async (input: any) => { - const oneShotClient = client.get(capabilityName) - await oneShotClient?.generate(input) - return oneShotClient?.getResult() ?? null - }, - stop: () => { - client.get(capabilityName)?.stop() - }, - reset: () => { - client.get(capabilityName)?.reset() - }, - } - } - - system.dispose = dispose - - // eslint-disable-next-line no-restricted-syntax -- built dynamically from a runtime `assistant.capabilities` array; the static AssistantSystem shape can't be verified structurally here, plus the added `dispose` field. - return system as unknown as AssistantSystem & { - dispose: () => void - } -} diff --git a/packages/ai-svelte/src/create-transaction.svelte.ts b/packages/ai-svelte/src/create-transaction.svelte.ts new file mode 100644 index 000000000..33bc527d2 --- /dev/null +++ b/packages/ai-svelte/src/create-transaction.svelte.ts @@ -0,0 +1,311 @@ +import { + TransactionClient, + computeStructuredParts, +} from '@tanstack/ai-client/transaction' +import type { + TransactionClientOptions, + TransactionSubRun, + TransactionSystem, +} from '@tanstack/ai-client/transaction' +import type { + ChatClientState, + ConnectionStatus, + GenerationClientState, +} from '@tanstack/ai-client' +import type { TransactionDefinition } from '@tanstack/ai/transaction' +import type { ModelMessage } from '@tanstack/ai' +import type { MultimodalContent, UIMessage } from './types' + +/** Reactive chat-verb state mirrored from the underlying ChatClient. */ +interface ChatState { + messages: Array> + isLoading: boolean + error: Error | undefined + status: ChatClientState + isSubscribed: boolean + connectionStatus: ConnectionStatus + sessionGenerating: boolean +} + +/** Reactive one-shot-verb state mirrored from a GenerationClient. */ +interface OneShotState { + result: unknown + isLoading: boolean + error: Error | undefined + status: GenerationClientState + subRuns: Array +} + +const makeInitialChatState = (): ChatState => ({ + messages: [], + isLoading: false, + error: undefined, + status: 'ready', + isSubscribed: false, + connectionStatus: 'disconnected', + sessionGenerating: false, +}) + +const makeInitialOneShotState = (): OneShotState => ({ + result: null, + isLoading: false, + error: undefined, + status: 'idle', + subRuns: [], +}) + +/** + * Creates a reactive transaction instance for Svelte 5. + * + * This function wraps the `TransactionClient` from `@tanstack/ai-client` and + * exposes reactive state using Svelte 5 runes, one surface per declared verb + * (any number of chat verbs plus any one-shot verbs). The returned object + * exposes reactive getters that automatically update when state changes. + * + * @example + * ```svelte + * + * + *
+ * {#each txn.primaryChat.messages as message} + *
{message.role}: {message.parts[0].content}
+ * {/each} + * + * + * + *
+ * ``` + */ +export function createTransaction< + TDef extends TransactionDefinition, + // Capture the options object so per-verb `onResult` transforms flow into + // each one-shot verb's `result` type. + TOptions extends Omit< + TransactionClientOptions, + 'transaction' | 'callbacks' + > = Omit, 'transaction' | 'callbacks'>, +>( + transaction: TDef, + options: TOptions, +): TransactionSystem & { dispose: () => void } { + // Reactive state per chat verb, keyed by verb name. Initialized eagerly + // for every declared verb, since `transaction.verbs` is fixed at creation + // time (mirrors `TransactionClient`'s own constructor, which iterates the + // same array). + const chatStates = $state>({}) + // Reactive state per one-shot verb, keyed by verb name. + const oneShotStates = $state>({}) + for (const verbName of transaction.verbs) { + if (transaction.verbKinds[verbName] === 'chat') { + chatStates[verbName] = makeInitialChatState() + } else { + oneShotStates[verbName] = makeInitialOneShotState() + } + } + + // Return (and lazily create) the reactive state slot for a verb, avoiding + // a non-null assertion at each call site below. + const ensureChatState = (verbName: string): ChatState => { + let state = chatStates[verbName] + if (!state) { + state = makeInitialChatState() + chatStates[verbName] = state + } + return state + } + const ensureOneShotState = (verbName: string): OneShotState => { + let state = oneShotStates[verbName] + if (!state) { + state = makeInitialOneShotState() + oneShotStates[verbName] = state + } + return state + } + + // Create the TransactionClient eagerly, once. Reactive callbacks are + // passed into the constructor (the only place ChatClient/GenerationClient + // accept them) rather than via `updateOptions`, mirroring `createChat`. + const client = new TransactionClient({ + ...options, + transaction, + callbacks: { + chat: (verbName) => ({ + onMessagesChange: (newMessages) => { + ensureChatState(verbName).messages = newMessages + }, + onLoadingChange: (newIsLoading) => { + ensureChatState(verbName).isLoading = newIsLoading + }, + onErrorChange: (newError) => { + ensureChatState(verbName).error = newError + }, + onStatusChange: (newStatus) => { + ensureChatState(verbName).status = newStatus + }, + onSubscriptionChange: (nextIsSubscribed) => { + ensureChatState(verbName).isSubscribed = nextIsSubscribed + }, + onConnectionStatusChange: (nextStatus) => { + ensureChatState(verbName).connectionStatus = nextStatus + }, + onSessionGeneratingChange: (isGenerating) => { + ensureChatState(verbName).sessionGenerating = isGenerating + }, + }), + oneShot: (verbName) => ({ + onResultChange: (result) => { + ensureOneShotState(verbName).result = result + }, + onLoadingChange: (isLoading) => { + ensureOneShotState(verbName).isLoading = isLoading + }, + onErrorChange: (error) => { + ensureOneShotState(verbName).error = error + }, + onStatusChange: (status) => { + ensureOneShotState(verbName).status = status + }, + onSubRunsChange: (subRuns) => { + ensureOneShotState(verbName).subRuns = subRuns + }, + }), + }, + }) + + // Note: No auto-cleanup in Svelte — call `dispose()` in your component's + // cleanup if needed. Unlike React/Vue/Solid, Svelte 5 runes like $effect + // can only be used during component initialization. + const dispose = () => { + client.dispose() + } + + // Build the returned system: one entry per declared verb, plus a + // top-level `dispose`. Uses getters so Svelte tracks the underlying + // `$state` without a `$` prefix. + const system: Record = {} + + for (const verbName of client.verbs) { + const chatClient = client.chat(verbName) + if (chatClient) { + ensureChatState(verbName).messages = chatClient.getMessages() + + // Derived structured-output `partial`/`final`, recomputed whenever + // this verb's messages change (mirrors `useTransaction`'s `useMemo`). + const structuredParts = $derived( + computeStructuredParts(chatStates[verbName]?.messages ?? []), + ) + + system[verbName] = { + get messages() { + return chatStates[verbName]?.messages ?? [] + }, + get isLoading() { + return chatStates[verbName]?.isLoading ?? false + }, + get error() { + return chatStates[verbName]?.error + }, + get status() { + return chatStates[verbName]?.status ?? 'ready' + }, + get isSubscribed() { + return chatStates[verbName]?.isSubscribed ?? false + }, + get connectionStatus() { + return chatStates[verbName]?.connectionStatus ?? 'disconnected' + }, + get sessionGenerating() { + return chatStates[verbName]?.sessionGenerating ?? false + }, + // Runtime shape unconditionally exposes partial/final; the public + // TransactionSystem type hides them when the chat verb's + // outputSchema is absent, matching useChat's behavior. + get partial() { + return structuredParts.partial + }, + get final() { + return structuredParts.final + }, + sendMessage: async (content: string | MultimodalContent) => { + await chatClient.sendMessage(content) + const msgs = chatClient.getMessages() + const hasStructured = + msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? + false + // Cast: TS can't structurally narrow the conditional return of + // TransactionChatSurface['sendMessage'] against this runtime + // branch, mirroring the assembled-`system` cast at the end of + // this function. + return ( + hasStructured ? computeStructuredParts(msgs).final : msgs + ) as any + }, + append: (message: ModelMessage | UIMessage) => + chatClient.append(message), + reload: () => chatClient.reload(), + stop: () => chatClient.stop(), + clear: () => chatClient.clear(), + setMessages: (newMessages: Array>) => + chatClient.setMessagesManually(newMessages), + addToolResult: (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => chatClient.addToolResult(result), + addToolApprovalResponse: (response: { + id: string + approved: boolean + }) => chatClient.addToolApprovalResponse(response), + } + continue + } + + const oneShotClient = client.oneShot(verbName) + if (!oneShotClient) continue + system[verbName] = { + run: async (input: any) => { + await oneShotClient.generate(input) + return oneShotClient.getResult() + }, + get result() { + return oneShotStates[verbName]?.result ?? null + }, + get isLoading() { + return oneShotStates[verbName]?.isLoading ?? false + }, + get error() { + return oneShotStates[verbName]?.error + }, + get status() { + return oneShotStates[verbName]?.status ?? 'idle' + }, + stop: () => { + oneShotClient.stop() + }, + reset: () => { + oneShotClient.reset() + }, + get subRuns() { + return oneShotStates[verbName]?.subRuns ?? [] + }, + } + } + + system.dispose = dispose + + // eslint-disable-next-line no-restricted-syntax -- built dynamically from a runtime `transaction.verbs` array; the static TransactionSystem shape can't be verified structurally here, plus the added `dispose` field. + return system as unknown as TransactionSystem & { + dispose: () => void + } +} diff --git a/packages/ai-svelte/src/transaction.ts b/packages/ai-svelte/src/transaction.ts new file mode 100644 index 000000000..7c4809d1e --- /dev/null +++ b/packages/ai-svelte/src/transaction.ts @@ -0,0 +1 @@ +export { createTransaction } from './create-transaction.svelte' diff --git a/packages/ai-svelte/tests/create-assistant.svelte.test.ts b/packages/ai-svelte/tests/create-assistant.svelte.test.ts deleted file mode 100644 index 423dcf398..000000000 --- a/packages/ai-svelte/tests/create-assistant.svelte.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { defineAssistant } from '@tanstack/ai/assistant' -import { stream } from '@tanstack/ai-client' -import { createAssistant } from '../src/create-assistant.svelte.js' - -// A connection adapter that replays canned chunks for both capabilities, -// branching on the `capability` discriminator forwarded by AssistantClient. -function fakeConnection() { - return stream(async function* (_messages, data) { - const capability = (data as Record | undefined)?.capability - - if (capability === 'chat') { - yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any - yield { - type: 'TEXT_MESSAGE_START', - messageId: 'm1', - role: 'assistant', - } as any - yield { - type: 'TEXT_MESSAGE_CONTENT', - messageId: 'm1', - delta: 'hello', - } as any - yield { type: 'TEXT_MESSAGE_END', messageId: 'm1' } as any - yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any - } else { - yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any - yield { - type: 'CUSTOM', - name: 'generation:result', - value: { id: 'i', model: 'gpt-image-1', images: [{ url: 'u' }] }, - } as any - yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any - } - }) -} - -describe('createAssistant', () => { - it('exposes only the declared capabilities', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - - const system = createAssistant(assistant, { connection: fakeConnection() }) - - expect(system.chat).toBeDefined() - expect(system.image).toBeDefined() - expect((system as any).speech).toBeUndefined() - - system.dispose() - }) - - it('generate() on a one-shot capability populates its result', async () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - - const system = createAssistant(assistant, { connection: fakeConnection() }) - - // generate() resolves to the fresh result... - const generated = await system.image.generate({ prompt: 'a fox' }) - expect(generated?.images[0]?.url).toBe('u') - - // ...and the reactive result state is still populated. - expect(system.image.result?.images[0]?.url).toBe('u') - - system.dispose() - }) - - it('sendMessage() on the chat capability populates messages', async () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - - const system = createAssistant(assistant, { connection: fakeConnection() }) - - // With no outputSchema, sendMessage() resolves to the messages array. - const returned = await system.chat.sendMessage('hi') - expect(returned.length).toBeGreaterThan(0) - - expect(system.chat.messages.length).toBeGreaterThan(0) - - system.dispose() - }) - - it('exposes structured partial/final on the chat surface with no structured part', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({}) as any, - }) - - const system = createAssistant(assistant, { connection: fakeConnection() }) - - expect((system.chat as any).partial).toEqual({}) - expect((system.chat as any).final).toBeNull() - - system.dispose() - }) -}) diff --git a/packages/ai-svelte/tests/create-transaction.svelte.test.ts b/packages/ai-svelte/tests/create-transaction.svelte.test.ts new file mode 100644 index 000000000..96f9ad973 --- /dev/null +++ b/packages/ai-svelte/tests/create-transaction.svelte.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { stream } from '@tanstack/ai-client' +import { z } from 'zod' +import { createTransaction } from '../src/create-transaction.svelte.js' + +// A connection adapter that replays canned chunks per verb, branching on the +// `verb` discriminator forwarded by TransactionClient. +function fakeConnection() { + return stream(async function* (_messages, data) { + const verbName = (data as Record | undefined)?.verb + + if (verbName === 'primaryChat') { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'm1', + role: 'assistant', + } as any + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + delta: 'hello', + } as any + yield { type: 'TEXT_MESSAGE_END', messageId: 'm1' } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else if (verbName === 'blogPost') { + // A transaction run: one banner sub-run, then the final result. + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r-sub-0', parentRunId: 'r', verb: 'banner', index: 0 }, + } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:result', + value: { + runId: 'r-sub-0', + verb: 'banner', + index: 0, + result: { url: 'hero.png' }, + }, + } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { title: 'Foxes', heroUrl: 'hero.png' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { url: 'u' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } + }) +} + +function makeTransaction() { + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ url: `img:${input.prompt}` }), + }) + return defineTransaction({ + primaryChat: chatVerb(async function* (r: any) { + yield { type: 'RUN_STARTED', threadId: r.threadId, runId: r.runId } as any + } as any), + banner, + blogPost: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const hero = await ctx.call(banner, { prompt: input.topic }) + return { title: input.topic, heroUrl: hero.url } + }, + }), + }) +} + +describe('createTransaction', () => { + it('exposes only the declared verbs, by kind', () => { + const txn = makeTransaction() + const system = createTransaction(txn, { connection: fakeConnection() }) + + expect(system.primaryChat).toBeDefined() + expect(system.primaryChat.sendMessage).toBeDefined() + expect(system.banner).toBeDefined() + expect(system.banner.run).toBeDefined() + expect(system.blogPost).toBeDefined() + expect((system as any).speech).toBeUndefined() + + system.dispose() + }) + + it('run() on a one-shot verb populates its result, typed by the schema', async () => { + const txn = makeTransaction() + const system = createTransaction(txn, { connection: fakeConnection() }) + + // The input type comes from the verb's schema. + expectTypeOf(system.banner.run) + .parameter(0) + .toEqualTypeOf<{ prompt: string }>() + + // run() resolves to the fresh result... + const generated = await system.banner.run({ prompt: 'a fox' }) + expect(generated?.url).toBe('u') + + // ...and the reactive result state is still populated. + expect(system.banner.result?.url).toBe('u') + + system.dispose() + }) + + it('sendMessage() on a chat verb populates its messages', async () => { + const txn = makeTransaction() + const system = createTransaction(txn, { connection: fakeConnection() }) + + // With no outputSchema, sendMessage() resolves to the messages array. + const returned = await system.primaryChat.sendMessage('hi') + expect(returned.length).toBeGreaterThan(0) + + expect(system.primaryChat.messages.length).toBeGreaterThan(0) + + system.dispose() + }) + + it('surfaces live sub-run state on a transaction run', async () => { + const txn = makeTransaction() + const system = createTransaction(txn, { connection: fakeConnection() }) + + await system.blogPost.run({ topic: 'foxes' }) + + expect(system.blogPost.result).toEqual({ + title: 'Foxes', + heroUrl: 'hero.png', + }) + expect(system.blogPost.subRuns).toHaveLength(1) + expect(system.blogPost.subRuns[0]).toMatchObject({ + verb: 'banner', + status: 'success', + result: { url: 'hero.png' }, + }) + // The sibling banner surface is untouched by the transaction's sub-run. + expect(system.banner.result).toBeNull() + + system.dispose() + }) + + it('exposes chat partial/final with cleared defaults on creation', () => { + const txn = makeTransaction() + const system = createTransaction(txn, { connection: fakeConnection() }) + + expect((system.primaryChat as any).partial).toEqual({}) + expect((system.primaryChat as any).final).toBeNull() + + system.dispose() + }) + + it('applies a one-shot onResult transform and infers its type', async () => { + const txn = makeTransaction() + const system = createTransaction(txn, { + connection: fakeConnection(), + verbs: { + banner: { + onResult: (raw) => { + // The transform's input is the verb's typed result + // (`toEqualTypeOf` fails if `raw` were `any`). + expectTypeOf(raw).toEqualTypeOf<{ url: string }>() + return raw.url + }, + }, + }, + }) + + // The surface `result` is the transform's (non-nullish) return type. + expectTypeOf(system.banner.result).toEqualTypeOf() + + const generated = await system.banner.run({ prompt: 'a fox' }) + expect(generated).toBe('u') + expect(system.banner.result).toBe('u') + + system.dispose() + }) +}) diff --git a/packages/ai-vue/package.json b/packages/ai-vue/package.json index 357a6717c..6d2ee7678 100644 --- a/packages/ai-vue/package.json +++ b/packages/ai-vue/package.json @@ -25,9 +25,9 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./assistant": { - "types": "./dist/assistant.d.ts", - "import": "./dist/assistant.js" + "./transaction": { + "types": "./dist/transaction.d.ts", + "import": "./dist/transaction.js" } }, "files": [ @@ -73,6 +73,7 @@ "tsdown": "^0.17.0-beta.6", "typescript": "5.9.3", "vitest": "^4.0.14", - "vue": "^3.5.25" + "vue": "^3.5.25", + "zod": "^4.2.0" } } diff --git a/packages/ai-vue/src/assistant.ts b/packages/ai-vue/src/assistant.ts deleted file mode 100644 index 6a8666b6e..000000000 --- a/packages/ai-vue/src/assistant.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAssistant } from './use-assistant' diff --git a/packages/ai-vue/src/transaction.ts b/packages/ai-vue/src/transaction.ts new file mode 100644 index 000000000..643dca5bf --- /dev/null +++ b/packages/ai-vue/src/transaction.ts @@ -0,0 +1 @@ +export { useTransaction } from './use-transaction' diff --git a/packages/ai-vue/src/use-assistant.ts b/packages/ai-vue/src/use-assistant.ts deleted file mode 100644 index c82b4068c..000000000 --- a/packages/ai-vue/src/use-assistant.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { - AssistantClient, - computeStructuredParts, -} from '@tanstack/ai-client/assistant' -import { computed, onMounted, onScopeDispose, readonly, shallowRef } from 'vue' -import type { ModelMessage } from '@tanstack/ai' -import type { AssistantDefinition } from '@tanstack/ai/assistant' -import type { - AssistantClientOptions, - AssistantSystem, - OneShotCapabilityName, -} from '@tanstack/ai-client/assistant' -import type { - AnyClientTool, - ChatClientState, - ConnectionStatus, - GenerationClientState, - MultimodalContent, - UIMessage, -} from '@tanstack/ai-client' - -/** Per-capability one-shot generation state, tracked in a single record ref. */ -interface OneShotState { - result: unknown - isLoading: boolean - error: Error | undefined - status: GenerationClientState -} - -const DEFAULT_ONE_SHOT_STATE: OneShotState = { - result: null, - isLoading: false, - error: undefined, - status: 'idle', -} - -/** - * Vue composable for `AssistantDefinition`-based assistants: one composed - * system exposing a typed surface per declared capability (`chat` plus any - * one-shot capabilities like `image`, `speech`, `transcription`, etc.), - * sharing a single connection. - * - * Mirrors the `useChat` idiom: the `AssistantClient` is built eagerly, once, - * with reactive state callbacks wired into its constructor — `ChatClient` - * and `GenerationClient` (the sub-clients `AssistantClient` composes) only - * accept these callbacks via construction, not `updateOptions`, so they must - * be threaded through up front rather than attached after the fact. - * - * @example - * ```vue - * - * - * - * ``` - */ -export function useAssistant< - TDef extends AssistantDefinition, - TChatTools extends ReadonlyArray = [], - // Capture the options so per-capability `onResult` transforms flow into each - // one-shot capability's `result` type (`any` tools keep `chat.tools` - // unconstrained; chat tool typing comes from the definition). - TOptions extends Omit< - AssistantClientOptions, - 'assistant' | 'callbacks' - > = Omit, 'assistant' | 'callbacks'>, ->( - assistant: TDef, - options: TOptions, -): AssistantSystem { - // Chat sub-client state. - const chatMessages = shallowRef>>([]) - const chatIsLoading = shallowRef(false) - const chatError = shallowRef(undefined) - const chatStatus = shallowRef('ready') - const chatIsSubscribed = shallowRef(false) - const chatConnectionStatus = shallowRef('disconnected') - const chatSessionGenerating = shallowRef(false) - - // One-shot capability state, keyed by capability name. A single record ref - // (rather than one ref per capability) keeps the update path uniform - // regardless of how many one-shot capabilities the assistant declares. - const oneShotState = shallowRef>({}) - - const updateOneShot = (capability: string, patch: Partial) => { - const previous = oneShotState.value[capability] ?? DEFAULT_ONE_SHOT_STATE - oneShotState.value = { - ...oneShotState.value, - [capability]: { ...previous, ...patch }, - } - } - - // Build the AssistantClient eagerly, once (no memo). Reactive callbacks are - // passed into the constructor's `callbacks` option — not wired up via - // `updateOptions` afterwards — because the sub-clients (`ChatClient`, - // `GenerationClient`) only accept these callbacks at construction time. - const client = new AssistantClient({ - ...options, - assistant, - callbacks: { - chat: { - onMessagesChange: (messages) => { - chatMessages.value = messages - }, - onLoadingChange: (isLoading) => { - chatIsLoading.value = isLoading - }, - onErrorChange: (error) => { - chatError.value = error - }, - onStatusChange: (status) => { - chatStatus.value = status - }, - onSubscriptionChange: (isSubscribed) => { - chatIsSubscribed.value = isSubscribed - }, - onConnectionStatusChange: (connectionStatus) => { - chatConnectionStatus.value = connectionStatus - }, - onSessionGeneratingChange: (sessionGenerating) => { - chatSessionGenerating.value = sessionGenerating - }, - }, - oneShot: (capability) => ({ - onResultChange: (result) => updateOneShot(capability, { result }), - onLoadingChange: (isLoading) => - updateOneShot(capability, { isLoading }), - onErrorChange: (error) => updateOneShot(capability, { error }), - onStatusChange: (status) => updateOneShot(capability, { status }), - }), - }, - }) - - onMounted(() => { - client.chat?.mountDevtools() - }) - - // Cleanup on unmount: tears down the chat sub-client and every one-shot - // sub-client (stops in-flight requests, unregisters devtools). - onScopeDispose(() => { - client.dispose() - }) - - const structuredParts = computed(() => - computeStructuredParts(chatMessages.value), - ) - - const system: Record = {} - - for (const capability of client.capabilities) { - if (capability === 'chat') { - const chatClient = client.chat - // `client.capabilities` only contains 'chat' when the AssistantClient - // constructor actually built the chat sub-client, so this is always - // defined here — but the field type stays optional, so guard rather - // than assert. - if (!chatClient) continue - - system.chat = { - messages: readonly(chatMessages), - sendMessage: async (content: string | MultimodalContent) => { - await chatClient.sendMessage(content) - const msgs = chatClient.getMessages() - const hasStructured = - msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? - false - // Cast: TS can't structurally narrow the conditional return of - // AssistantChatSurface['sendMessage'] against this runtime branch, - // mirroring the assembled-`system` cast at the end of this composable. - return ( - hasStructured ? computeStructuredParts(msgs).final : msgs - ) as any - }, - append: async (message: ModelMessage | UIMessage) => { - await chatClient.append(message) - }, - reload: async () => { - await chatClient.reload() - }, - stop: () => { - chatClient.stop() - }, - clear: () => { - chatClient.clear() - }, - setMessages: (messages: Array>) => { - chatClient.setMessagesManually(messages) - }, - addToolResult: async (result: { - toolCallId: string - tool: string - output: any - state?: 'output-available' | 'output-error' - errorText?: string - }) => { - await chatClient.addToolResult(result) - }, - addToolApprovalResponse: async (response: { - id: string - approved: boolean - }) => { - await chatClient.addToolApprovalResponse(response) - }, - isLoading: readonly(chatIsLoading), - error: readonly(chatError), - status: readonly(chatStatus), - isSubscribed: readonly(chatIsSubscribed), - connectionStatus: readonly(chatConnectionStatus), - sessionGenerating: readonly(chatSessionGenerating), - // Runtime shape unconditionally exposes partial/final; the public - // AssistantSystem type hides them when the chat capability's - // outputSchema is absent, matching useChat's behavior. - partial: readonly(computed(() => structuredParts.value.partial)), - final: readonly(computed(() => structuredParts.value.final)), - } - continue - } - - const oneShotCapability = capability as OneShotCapabilityName - const oneShotClient = client.get(oneShotCapability) - // Same reasoning as `chatClient` above: declared in `capabilities` - // implies the sub-client was constructed, but the map lookup is - // typed as possibly `undefined`. - if (!oneShotClient) continue - - system[capability] = { - generate: async (input: Record) => { - await oneShotClient.generate(input) - return oneShotClient.getResult() - }, - result: computed(() => oneShotState.value[capability]?.result ?? null), - isLoading: computed( - () => oneShotState.value[capability]?.isLoading ?? false, - ), - error: computed(() => oneShotState.value[capability]?.error), - status: computed(() => oneShotState.value[capability]?.status ?? 'idle'), - stop: () => { - oneShotClient.stop() - }, - reset: () => { - oneShotClient.reset() - }, - } - } - - // The runtime shape (refs nested per capability) diverges from the - // declared `AssistantSystem` type (plain values) — the same divergence - // `useChat` accepts for its return, since consumers unwrap refs via - // `.value` (script) or Vue's template auto-unwrapping. - // eslint-disable-next-line no-restricted-syntax -- composable return shape (nested refs per capability) diverges from the framework-agnostic AssistantSystem type (plain values); TS can't structurally relate the two - return system as unknown as AssistantSystem -} diff --git a/packages/ai-vue/src/use-transaction.ts b/packages/ai-vue/src/use-transaction.ts new file mode 100644 index 000000000..90fc2f1cf --- /dev/null +++ b/packages/ai-vue/src/use-transaction.ts @@ -0,0 +1,296 @@ +import { + TransactionClient, + computeStructuredParts, +} from '@tanstack/ai-client/transaction' +import { computed, onMounted, onScopeDispose, readonly, shallowRef } from 'vue' +import type { ShallowRef } from 'vue' +import type { ModelMessage } from '@tanstack/ai' +import type { TransactionDefinition } from '@tanstack/ai/transaction' +import type { + TransactionClientOptions, + TransactionSubRun, + TransactionSystem, +} from '@tanstack/ai-client/transaction' +import type { + ChatClientState, + ConnectionStatus, + GenerationClientState, + MultimodalContent, + UIMessage, +} from '@tanstack/ai-client' + +/** Per-chat-verb reactive state mirrored from the underlying ChatClient. */ +interface ChatRefs { + messages: ShallowRef>> + isLoading: ShallowRef + error: ShallowRef + status: ShallowRef + isSubscribed: ShallowRef + connectionStatus: ShallowRef + sessionGenerating: ShallowRef +} + +/** Per-one-shot-verb reactive state mirrored from a GenerationClient. */ +interface OneShotRefs { + result: ShallowRef + isLoading: ShallowRef + error: ShallowRef + status: ShallowRef + subRuns: ShallowRef> +} + +/** + * Vue composable wrapping `TransactionClient`, composing the existing chat + + * generation clients behind one endpoint into a single typed system keyed by + * the transaction's declared verbs. + * + * Mirrors the `useChat` idiom: the `TransactionClient` is built eagerly, + * once, with reactive state callbacks wired into its constructor — + * `ChatClient` and `GenerationClient` (the sub-clients `TransactionClient` + * composes) only accept these callbacks via construction, not + * `updateOptions`, so they must be threaded through up front rather than + * attached after the fact. + * + * @example + * ```vue + * + * + * + * ``` + */ +export function useTransaction< + TDef extends TransactionDefinition, + // Capture the options so per-verb `onResult` transforms flow into each + // one-shot verb's `result` type. + TOptions extends Omit< + TransactionClientOptions, + 'transaction' | 'callbacks' + >, +>(transaction: TDef, options: TOptions): TransactionSystem { + // Per-verb reactive state, keyed by verb name and created lazily the first + // time the TransactionClient constructor asks for a verb's callbacks — so + // the callbacks and the assembled surfaces share the same refs. + const chatRefs = new Map() + const oneShotRefs = new Map() + + const getChatRefs = (verb: string): ChatRefs => { + let refs = chatRefs.get(verb) + if (!refs) { + refs = { + messages: shallowRef>>([]), + isLoading: shallowRef(false), + error: shallowRef(undefined), + status: shallowRef('ready'), + isSubscribed: shallowRef(false), + connectionStatus: shallowRef('disconnected'), + sessionGenerating: shallowRef(false), + } + chatRefs.set(verb, refs) + } + return refs + } + + const getOneShotRefs = (verb: string): OneShotRefs => { + let refs = oneShotRefs.get(verb) + if (!refs) { + refs = { + result: shallowRef(null), + isLoading: shallowRef(false), + error: shallowRef(undefined), + status: shallowRef('idle'), + subRuns: shallowRef>([]), + } + oneShotRefs.set(verb, refs) + } + return refs + } + + // Build the TransactionClient eagerly, once (no memo). Reactive callbacks + // are passed into the constructor's `callbacks` option — not wired up via + // `updateOptions` afterwards — because the sub-clients (`ChatClient`, + // `GenerationClient`) only accept these callbacks at construction time. + const client = new TransactionClient({ + ...options, + transaction, + callbacks: { + chat: (verb) => { + const refs = getChatRefs(verb) + return { + onMessagesChange: (messages) => { + refs.messages.value = messages + }, + onLoadingChange: (isLoading) => { + refs.isLoading.value = isLoading + }, + onErrorChange: (error) => { + refs.error.value = error + }, + onStatusChange: (status) => { + refs.status.value = status + }, + onSubscriptionChange: (isSubscribed) => { + refs.isSubscribed.value = isSubscribed + }, + onConnectionStatusChange: (connectionStatus) => { + refs.connectionStatus.value = connectionStatus + }, + onSessionGeneratingChange: (sessionGenerating) => { + refs.sessionGenerating.value = sessionGenerating + }, + } + }, + oneShot: (verb) => { + const refs = getOneShotRefs(verb) + return { + onResultChange: (result) => { + refs.result.value = result + }, + onLoadingChange: (isLoading) => { + refs.isLoading.value = isLoading + }, + onErrorChange: (error) => { + refs.error.value = error + }, + onStatusChange: (status) => { + refs.status.value = status + }, + onSubRunsChange: (subRuns) => { + refs.subRuns.value = subRuns + }, + } + }, + }, + }) + + onMounted(() => { + for (const verbName of client.verbs) { + client.chat(verbName)?.mountDevtools() + } + }) + + // Cleanup on unmount: tears down every chat and one-shot sub-client (stops + // in-flight requests, unregisters devtools). + onScopeDispose(() => { + client.dispose() + }) + + const system: Record = {} + + for (const verbName of client.verbs) { + const chatClient = client.chat(verbName) + if (chatClient) { + const refs = getChatRefs(verbName) + const structuredParts = computed(() => + computeStructuredParts(refs.messages.value), + ) + + system[verbName] = { + messages: readonly(refs.messages), + sendMessage: async (content: string | MultimodalContent) => { + await chatClient.sendMessage(content) + const msgs = chatClient.getMessages() + const hasStructured = + msgs.at(-1)?.parts.some((p) => p.type === 'structured-output') ?? + false + // Cast: TS can't structurally narrow the conditional return of + // TransactionChatSurface['sendMessage'] against this runtime + // branch, mirroring the assembled-`system` cast at the end of this + // composable. + return ( + hasStructured ? computeStructuredParts(msgs).final : msgs + ) as any + }, + append: async (message: ModelMessage | UIMessage) => { + await chatClient.append(message) + }, + reload: async () => { + await chatClient.reload() + }, + stop: () => { + chatClient.stop() + }, + clear: () => { + chatClient.clear() + }, + setMessages: (messages: Array>) => { + chatClient.setMessagesManually(messages) + }, + addToolResult: async (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => { + await chatClient.addToolResult(result) + }, + addToolApprovalResponse: async (response: { + id: string + approved: boolean + }) => { + await chatClient.addToolApprovalResponse(response) + }, + isLoading: readonly(refs.isLoading), + error: readonly(refs.error), + status: readonly(refs.status), + isSubscribed: readonly(refs.isSubscribed), + connectionStatus: readonly(refs.connectionStatus), + sessionGenerating: readonly(refs.sessionGenerating), + // Runtime shape unconditionally exposes partial/final; the public + // TransactionSystem type hides them when the chat verb's + // outputSchema is absent, matching useChat's behavior. + partial: readonly(computed(() => structuredParts.value.partial)), + final: readonly(computed(() => structuredParts.value.final)), + } + continue + } + + const oneShotClient = client.oneShot(verbName) + // `client.verbs` only contains verbs the constructor built a sub-client + // for, so this is always defined here — but the map lookup is typed as + // possibly `undefined`, so guard rather than assert. + if (!oneShotClient) continue + + const refs = getOneShotRefs(verbName) + system[verbName] = { + run: async (input: Record) => { + await oneShotClient.generate(input) + return oneShotClient.getResult() + }, + result: readonly(refs.result), + isLoading: readonly(refs.isLoading), + error: readonly(refs.error), + status: readonly(refs.status), + stop: () => { + oneShotClient.stop() + }, + reset: () => { + oneShotClient.reset() + }, + subRuns: readonly(refs.subRuns), + } + } + + // The runtime shape (refs nested per verb) diverges from the declared + // `TransactionSystem` type (plain values) — the same divergence `useChat` + // accepts for its return, since consumers unwrap refs via `.value` + // (script) or Vue's template auto-unwrapping. + // eslint-disable-next-line no-restricted-syntax -- composable return shape (nested refs per verb) diverges from the framework-agnostic TransactionSystem type (plain values); TS can't structurally relate the two + return system as unknown as TransactionSystem +} diff --git a/packages/ai-vue/tests/use-assistant.test.ts b/packages/ai-vue/tests/use-assistant.test.ts deleted file mode 100644 index 8b1f40f60..000000000 --- a/packages/ai-vue/tests/use-assistant.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { flushPromises, mount } from '@vue/test-utils' -import { defineComponent } from 'vue' -import { describe, expect, it } from 'vitest' -import { defineAssistant } from '@tanstack/ai/assistant' -import { stream } from '@tanstack/ai-client' -import { useAssistant } from '../src/use-assistant.js' -import { createTextChunks } from './test-utils' -import type { StreamChunk } from '@tanstack/ai' -import type { AssistantDefinition } from '@tanstack/ai/assistant' -import type { ConnectConnectionAdapter } from '@tanstack/ai-client' - -// Helper mirroring the `generation:result` CUSTOM chunk used across the -// ai-vue generation tests (see use-generation.test.ts). -function createGenerationChunks(result: unknown): Array { - return [ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { - type: 'CUSTOM', - name: 'generation:result', - value: result, - timestamp: Date.now(), - }, - { - type: 'RUN_FINISHED', - runId: 'run-1', - finishReason: 'stop', - timestamp: Date.now(), - }, - ] as unknown as Array -} - -/** - * A single canned connection shared by chat and one-shot sub-clients. - * `AssistantClient` tags each sub-client's requests with `capability` (via - * `forwardedProps` for chat, `body` for one-shot), so a single `stream()` - * adapter can route on `data.capability` the same way a real server handler - * (`defineAssistant(...).handler`) would. - */ -function createAssistantConnection() { - return stream(async function* (_messages, data) { - if (data?.capability === 'chat') { - yield* createTextChunks('Hello from assistant') - return - } - if (data?.capability === 'image') { - yield* createGenerationChunks({ - id: 'img-1', - model: 'test', - images: ['a.png'], - }) - return - } - }) -} - -function renderUseAssistant( - assistant: AssistantDefinition, - options: { connection: ConnectConnectionAdapter }, -) { - const TestComponent = defineComponent({ - setup() { - return { system: useAssistant(assistant, options) } - }, - template: '
', - }) - - const wrapper = mount(TestComponent) - return { wrapper, system: wrapper.vm.system as any } -} - -describe('useAssistant', () => { - const assistant = defineAssistant({ - chat: async function* () {} as any, - image: async () => ({ id: '', model: '', images: [] }) as any, - }) - - it('exposes only the declared capabilities', () => { - const { system } = renderUseAssistant(assistant, { - connection: createAssistantConnection(), - }) - - expect(system.chat).toBeDefined() - expect(system.image).toBeDefined() - expect(system.speech).toBeUndefined() - }) - - it('exposes structured partial/final with no structured part', () => { - const { system } = renderUseAssistant(assistant, { - connection: createAssistantConnection(), - }) - - expect(system.chat.partial.value).toEqual({}) - expect(system.chat.final.value).toBeNull() - }) - - it('sendMessage populates chat messages', async () => { - const { system } = renderUseAssistant(assistant, { - connection: createAssistantConnection(), - }) - - // With no outputSchema, sendMessage() resolves to the messages array. - const returned = await system.chat.sendMessage('Hi') - expect(returned.length).toBeGreaterThan(0) - await flushPromises() - - expect(system.chat.messages.value.length).toBeGreaterThan(0) - const assistantMessage = system.chat.messages.value.find( - (m: any) => m.role === 'assistant', - ) - expect(assistantMessage).toBeDefined() - const textPart = assistantMessage?.parts.find((p: any) => p.type === 'text') - expect(textPart?.content).toBe('Hello from assistant') - }) - - it('generate populates the one-shot result', async () => { - const { system } = renderUseAssistant(assistant, { - connection: createAssistantConnection(), - }) - - expect(system.image.result.value).toBeNull() - expect(system.image.isLoading.value).toBe(false) - - // generate() resolves to the fresh result... - const generated = await system.image.generate({ prompt: 'a cat' }) - expect(generated).toEqual({ - id: 'img-1', - model: 'test', - images: ['a.png'], - }) - await flushPromises() - - // ...and the reactive result ref is still populated. - expect(system.image.result.value).toEqual({ - id: 'img-1', - model: 'test', - images: ['a.png'], - }) - expect(system.image.isLoading.value).toBe(false) - expect(system.image.status.value).toBe('success') - }) - - it('disposes the underlying client on unmount without throwing', async () => { - const { wrapper, system } = renderUseAssistant(assistant, { - connection: createAssistantConnection(), - }) - - await system.chat.sendMessage('Hi') - await flushPromises() - - expect(() => wrapper.unmount()).not.toThrow() - }) -}) diff --git a/packages/ai-vue/tests/use-transaction.test.ts b/packages/ai-vue/tests/use-transaction.test.ts new file mode 100644 index 000000000..7e7ce1634 --- /dev/null +++ b/packages/ai-vue/tests/use-transaction.test.ts @@ -0,0 +1,236 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { stream } from '@tanstack/ai-client' +import { z } from 'zod' +import { useTransaction } from '../src/use-transaction.js' + +// A connection adapter that replays canned chunks per verb, branching on the +// `verb` discriminator forwarded by TransactionClient — the same way a real +// server handler (`defineTransaction(...).handler`) would route. +function fakeConnection() { + return stream(async function* (_messages, data) { + const verbName = (data as Record | undefined)?.verb + + if (verbName === 'primaryChat') { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'TEXT_MESSAGE_START', + messageId: 'm1', + role: 'assistant', + } as any + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + delta: 'hello', + } as any + yield { type: 'TEXT_MESSAGE_END', messageId: 'm1' } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else if (verbName === 'blogPost') { + // A transaction run: one banner sub-run, then the final result. + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r-sub-0', parentRunId: 'r', verb: 'banner', index: 0 }, + } as any + yield { + type: 'CUSTOM', + name: 'transaction:sub-run:result', + value: { + runId: 'r-sub-0', + verb: 'banner', + index: 0, + result: { url: 'hero.png' }, + }, + } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { title: 'Foxes', heroUrl: 'hero.png' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } else { + yield { type: 'RUN_STARTED', threadId: 't', runId: 'r' } as any + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { url: 'u' }, + } as any + yield { type: 'RUN_FINISHED', threadId: 't', runId: 'r' } as any + } + }) +} + +function makeTransaction() { + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ url: `img:${input.prompt}` }), + }) + return defineTransaction({ + primaryChat: chatVerb(async function* (r: any) { + yield { type: 'RUN_STARTED', threadId: r.threadId, runId: r.runId } as any + } as any), + banner, + blogPost: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const hero = await ctx.call(banner, { prompt: input.topic }) + return { title: input.topic, heroUrl: hero.url } + }, + }), + }) +} + +/** + * Mount the composable inside a component (the harness style shared by the + * other ai-vue tests), capturing its return via a closure so the declared + * `TransactionSystem` typing survives for the type-level assertions. Runtime + * assertions on reactive fields unwrap the nested refs via `vm` (`as any`), + * matching the runtime shape the composable actually returns. + */ +function renderUseTransaction(setup: () => T) { + let system!: T + const TestComponent = defineComponent({ + setup() { + system = setup() + return {} + }, + template: '
', + }) + + const wrapper = mount(TestComponent) + return { wrapper, system, vm: system as any } +} + +describe('useTransaction', () => { + it('exposes only the declared verbs, by kind', () => { + const txn = makeTransaction() + const { system, vm } = renderUseTransaction(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + expect(system.primaryChat).toBeDefined() + expect(system.primaryChat.sendMessage).toBeDefined() + expect(system.banner).toBeDefined() + expect(system.banner.run).toBeDefined() + expect(system.blogPost).toBeDefined() + expect(vm.speech).toBeUndefined() + }) + + it('run() on a one-shot verb populates its result, typed by the schema', async () => { + const txn = makeTransaction() + const { system, vm } = renderUseTransaction(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + // The input type comes from the verb's schema. + expectTypeOf(system.banner.run) + .parameter(0) + .toEqualTypeOf<{ prompt: string }>() + + // run() resolves to the fresh result... + const returned = await system.banner.run({ prompt: 'a fox' }) + expect(returned?.url).toBe('u') + await flushPromises() + + // ...and the reactive result ref is still populated. + expect(vm.banner.result.value).toEqual({ url: 'u' }) + expect(vm.banner.isLoading.value).toBe(false) + expect(vm.banner.status.value).toBe('success') + }) + + it('sendMessage() on a chat verb populates its messages', async () => { + const txn = makeTransaction() + const { system, vm } = renderUseTransaction(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + // With no outputSchema, sendMessage() resolves to the messages array. + const returned = await system.primaryChat.sendMessage('hi') + expect(returned.length).toBeGreaterThan(0) + await flushPromises() + + expect(vm.primaryChat.messages.value.length).toBeGreaterThan(0) + const assistantMessage = vm.primaryChat.messages.value.find( + (m: any) => m.role === 'assistant', + ) + expect(assistantMessage).toBeDefined() + const textPart = assistantMessage?.parts.find((p: any) => p.type === 'text') + expect(textPart?.content).toBe('hello') + }) + + it('surfaces live sub-run state on a transaction run', async () => { + const txn = makeTransaction() + const { system, vm } = renderUseTransaction(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + await system.blogPost.run({ topic: 'foxes' }) + await flushPromises() + + expect(vm.blogPost.result.value).toEqual({ + title: 'Foxes', + heroUrl: 'hero.png', + }) + expect(vm.blogPost.subRuns.value).toHaveLength(1) + expect(vm.blogPost.subRuns.value[0]).toMatchObject({ + verb: 'banner', + status: 'success', + result: { url: 'hero.png' }, + }) + // The sibling banner surface is untouched by the transaction's sub-run. + expect(vm.banner.result.value).toBeNull() + }) + + it('exposes chat partial/final with cleared defaults on first render', () => { + const txn = makeTransaction() + const { vm } = renderUseTransaction(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + expect(vm.primaryChat.partial.value).toEqual({}) + expect(vm.primaryChat.final.value).toBeNull() + }) + + it('applies a one-shot onResult transform and infers its type', async () => { + const txn = makeTransaction() + const { system, vm } = renderUseTransaction(() => + useTransaction(txn, { + connection: fakeConnection(), + verbs: { + banner: { + onResult: (raw) => { + // The transform's input is the verb's typed result + // (`toEqualTypeOf` fails if `raw` were `any`). + expectTypeOf(raw).toEqualTypeOf<{ url: string }>() + return raw.url + }, + }, + }, + }), + ) + + // The surface `result` is the transform's (non-nullish) return type. + expectTypeOf(system.banner.result).toEqualTypeOf() + + const returned = await system.banner.run({ prompt: 'a fox' }) + await flushPromises() + + expect(returned).toBe('u') + expect(vm.banner.result.value).toBe('u') + }) + + it('disposes the underlying client on unmount without throwing', async () => { + const txn = makeTransaction() + const { wrapper, system } = renderUseTransaction(() => + useTransaction(txn, { connection: fakeConnection() }), + ) + + await system.primaryChat.sendMessage('hi') + await flushPromises() + + expect(() => wrapper.unmount()).not.toThrow() + }) +}) diff --git a/packages/ai-vue/tsdown.config.ts b/packages/ai-vue/tsdown.config.ts index de631c408..e2591dbd6 100644 --- a/packages/ai-vue/tsdown.config.ts +++ b/packages/ai-vue/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ - entry: ['./src/index.ts', './src/assistant.ts'], + entry: ['./src/index.ts', './src/transaction.ts'], format: ['esm'], unbundle: true, dts: true, diff --git a/packages/ai/package.json b/packages/ai/package.json index 07b04624a..92e80a1e0 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -25,9 +25,9 @@ "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, - "./assistant": { - "types": "./dist/esm/assistant.d.ts", - "import": "./dist/esm/assistant.js" + "./transaction": { + "types": "./dist/esm/transaction.d.ts", + "import": "./dist/esm/transaction.js" }, "./client": { "types": "./dist/esm/client.d.ts", diff --git a/packages/ai/skills/ai-core/SKILL.md b/packages/ai/skills/ai-core/SKILL.md index 4bf9b64b8..4b234b9a2 100644 --- a/packages/ai/skills/ai-core/SKILL.md +++ b/packages/ai/skills/ai-core/SKILL.md @@ -30,6 +30,7 @@ Always import from the framework package on the client — never from | Choose and configure a provider adapter | ai-core/adapter-configuration/SKILL.md | | Implement AG-UI streaming protocol server-side | ai-core/ag-ui-protocol/SKILL.md | | Add analytics, logging, or lifecycle hooks | ai-core/middleware/SKILL.md | +| Compose verbs / server-side pipelines behind one endpoint | ai-core/transaction/SKILL.md | | Connect to a non-TanStack-AI backend | ai-core/custom-backend-integration/SKILL.md | | Turn on/off debug logging, pipe into pino/winston | ai-core/debug-logging/SKILL.md | | Set up Code Mode (LLM code execution) | See `@tanstack/ai-code-mode` package skills | @@ -43,6 +44,7 @@ Always import from the framework package on the client — never from - Choosing/configuring a provider? → ai-core/adapter-configuration - Building a server-only AG-UI backend? → ai-core/ag-ui-protocol - Adding analytics or post-stream events? → ai-core/middleware +- Bundling several AI verbs (chat + media + pipelines) behind one endpoint? → ai-core/transaction - Connecting to a custom backend? → ai-core/custom-backend-integration - Turning on debug logging to trace chunks/tools/middleware? → ai-core/debug-logging - Debugging mistakes? → Check Common Mistakes in the relevant sub-skill diff --git a/packages/ai/skills/ai-core/assistant/SKILL.md b/packages/ai/skills/ai-core/assistant/SKILL.md deleted file mode 100644 index 96b348194..000000000 --- a/packages/ai/skills/ai-core/assistant/SKILL.md +++ /dev/null @@ -1,389 +0,0 @@ ---- -name: ai-core/assistant -description: > - Multi-capability assistant composition: defineAssistant() registers - per-capability callbacks (chat/image/audio/speech/video/transcription/ - summarize) on the server behind one handler; useAssistant() consumes all - declared capabilities from a single client hook with no generics, types - inferred from the server definition. Each capability entry mirrors the - matching primitive hook (useChat, useGenerateImage, etc.) exactly. -type: sub-skill -library: tanstack-ai -library_version: '0.10.0' -sources: - - 'TanStack/ai:packages/ai/src/activities/assistant/index.ts' - - 'TanStack/ai:packages/ai/src/activities/assistant/types.ts' - - 'TanStack/ai:packages/ai-client/src/assistant-client.ts' - - 'TanStack/ai:packages/ai-client/src/assistant-types.ts' - - 'TanStack/ai:packages/ai-react/src/use-assistant.ts' ---- - -# Assistant - -This skill builds on ai-core, ai-core/chat-experience, and -ai-core/media-generation. Read them first. - -`defineAssistant` + `useAssistant` are a **composition layer**, not a new -activity or wire format. They wire together activities you already know -(`chat()`, `generateImage()`, `generateSpeech()`, …) behind one server -endpoint and one client hook. There is no new client state machine: each -declared capability on the client is exactly the same surface as the -matching primitive hook (`useChat`, `useGenerateImage`, `useGenerateAudio`, -`useGenerateSpeech`, `useGenerateVideo`, `useTranscription`, `useSummarize`). - -## Setup — Chat + Image Assistant End-to-End - -### Server: `defineAssistant` + a single `handler` - -Each capability key maps to a callback `(req) => `. The -definition is **inert** — `defineAssistant` only stores the callbacks and a -static list of declared capability names. It constructs nothing (no -adapters, no connections) until a request actually reaches `handler`, so -it's safe to import into an isomorphic module shared with the client. - -```typescript -// src/lib/assistant.ts — shared/isomorphic module -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { - openaiText, - openaiImage, - openaiSpeech, -} from '@tanstack/ai-openai/adapters' -import { getWeather } from './tools' - -export const blogAssistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - threadId: req.threadId, - runId: req.runId, - tools: [getWeather], - }), - - image: (req) => - generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt, - size: req.size, - numberOfImages: req.numberOfImages, - }), - - speech: (req) => - generateSpeech({ - adapter: openaiSpeech('tts-1'), - text: req.text, - voice: req.voice ?? 'alloy', - }), -}) -``` - -```typescript -// src/routes/api.assistant.ts — server route, single handler -import { createFileRoute } from '@tanstack/react-router' -import { assistant } from '../lib/assistant' - -export const Route = createFileRoute('/api/assistant')({ - server: { - handlers: { - POST: (request) => blogAssistant.handler(request), - }, - }, -}) -``` - -`handler` is the **only** thing the route needs to call. Internally it -routes by a `capability` discriminator carried on the request body: `'chat'` -dispatches to the AG-UI `RunAgentInput` parsing path (same as a standalone -chat route), and every other declared key is a one-shot generation request -parsed into that capability's input shape. Unknown or undeclared -capabilities get a `400` before any callback runs. - -### Client: `useAssistant` - -```tsx -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { assistant } from '../lib/assistant' - -function AssistantPanel() { - const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - }) - - return ( -
-
- {assistant.chat.messages.map((message) => ( -
{message.role}
- ))} -
- - - - {assistant.image.result?.images.map((img, i) => ( - - ))} -
- ) -} -``` - -`assistant` is typed from the `assistant` value passed in — **no generics** at -the call site. Only capabilities declared in `defineAssistant` appear on -`assistant`; referencing an undeclared key is a compile error. `assistant.chat` -is the full `useChat` return (`messages`, `sendMessage`, `isLoading`, -`error`, `status`, `stop`, `clear`, `addToolResult`, …); `assistant.image` / -`assistant.audio` / `assistant.speech` / `assistant.video` / -`assistant.transcription` / `assistant.summarize` are each the full -`useGeneration`-style return (`generate`, `result`, `isLoading`, `error`, -`status`, `stop`, `reset`). - -Vue/Solid/Svelte have identical patterns with different hook imports -(e.g. `import { useAssistant } from '@tanstack/ai-solid/assistant'`). - -## Core Patterns - -### 1. Chat tools auto-type from the server callback; `chat: { tools }` is only for client-executed runtime - -Tools passed to `chat({ tools: [...] })` inside the server callback -automatically type `assistant.chat.messages`' tool-call/result parts — -narrowed by tool name, input, and output — with **no** client-side -re-declaration needed for typing: - -```typescript -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { blogAssistant } from '../lib/assistant' // chat callback passed tools: [getWeather] - -const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), -}) - -// assistant.chat.messages parts are already narrowed by tool name — inferred -// from the server callback's `tools: [getWeather]`, not from anything passed -// here. -``` - -`chat: { tools }` on `useAssistant` still exists, but only for one reason: -a client-**executed** tool's `.client()` implementation runs in the browser, -so its code can't cross the wire — the server callback only ever sees the -tool's _definition_ (for the model and for typing). Pass the client -implementation there to register its runtime: - -```typescript -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { blogAssistant } from '../lib/assistant' // chat callback passed tools: [showToastDef] -import { showToast } from '../lib/tools' // showToastDef.client((input) => ...) - -const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - chat: { tools: [showToast] }, // runtime only — types already came from the callback -}) -``` - -`chat.forwardedProps` is also available on the same option, merged into -every chat request alongside the reserved `capability` field. - -Each **one-shot** capability accepts an optional transform too, keyed by the -capability name (`image`, `speech`, `audio`, `video`, `transcription`, -`summarize`): `image: { onResult, forwardedProps }`. `onResult` runs on the -raw backend result and its return type becomes `assistant..result` -— mirroring the standalone generation hooks. Return nothing to keep the raw -result. - -```typescript -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { blogAssistant } from '../lib/assistant' - -const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), - image: { onResult: (result) => result.images[0]?.url ?? null }, -}) - -assistant.image.result // string | null — the transform's return type -``` - -### 2. Structured output via `outputSchema` in the chat callback - -If the `chat` callback passes `outputSchema` to `chat()`, `assistant.chat` -picks up typed `partial` (progressive `DeepPartial`) and `final` (validated -terminal object) fields — the same conditional shape `useChat({ -outputSchema })` returns. Omit `outputSchema` and neither field is present on -the type. - -```typescript -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { blogAssistant } from '../lib/assistant' // chat callback passed outputSchema: BlogOutlineSchema - -const assistant = useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), -}) - -assistant.chat.partial.title // string | undefined — fills in as JSON streams -assistant.chat.final // full schema type | null — set once the run completes -``` - -### 3. Manual chaining across capabilities - -There is no auto-chaining or shared "artifact workspace" — `useAssistant` -is a pure composition layer. To use one capability's result as input to -another, read the result value and pass it explicitly: - -```typescript -await assistant.image.generate({ prompt: 'a lighthouse at dusk' }) - -// after assistant.image.result is populated: -if (assistant.image.result) { - assistant.chat.sendMessage({ - content: [ - { - type: 'image', - source: { type: 'url', value: assistant.image.result.images[0].url }, - }, - { type: 'text', content: 'Write a short caption for this image.' }, - ], - }) -} -``` - -### 4. Only declared capabilities are constructed - -`defineAssistant({ chat, image })` produces a client `assistant` with exactly -`assistant.chat` and `assistant.image` — no `assistant.audio`, `assistant.video`, etc. -Add or remove capability keys on the server definition to change what the -client can call; there's nothing else to keep in sync. - -### 5. Sharing one connection across capabilities - -All capabilities declared in one `defineAssistant` call share a single -`connection` passed to `useAssistant` — one endpoint, one adapter. Each -underlying sub-client (a `ChatClient` for `chat`, a `GenerationClient` per -one-shot capability) tags its own requests with the capability name, so the -single `handler` on the server can route correctly. This mirrors the shared -`connection` pattern already used by `useChat` / `useGenerateImage` -individually — see ai-core/chat-experience and ai-core/media-generation. - -## Common Mistakes - -### a. HIGH: Constructing adapters outside the capability callback - -```typescript -// WRONG — adapter constructed eagerly at module load, defeats "inert" guarantee -const textAdapter = openaiText('gpt-5.5') -const blogAssistant = defineAssistant({ - chat: (req) => chat({ adapter: textAdapter, messages: req.messages }), -}) - -// CORRECT — construct inside the callback, per request -const blogAssistant = defineAssistant({ - chat: (req) => - chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), -}) -``` - -Adapter construction is cheap (it wraps config; the provider HTTP client is -created lazily), but constructing outside the callback breaks the -"`defineAssistant` is inert, safe to import into the client bundle" -guarantee — it now runs provider setup at import time. - -### b. HIGH: Writing a custom handler that branches on capability manually - -```typescript -// WRONG — reimplementing what `handler` already does -export const POST = async (request: Request) => { - const body = await request.json() - if (body.capability === 'chat') { - return toServerSentEventsResponse( - chat({ adapter, messages: body.messages }), - ) - } - // ...manual branching for every capability -} - -// CORRECT — defineAssistant's handler already parses, routes, and serializes -export const POST = (request: Request) => blogAssistant.handler(request) -``` - -### c. HIGH: Passing `model` or top-level generation options to `useAssistant` - -There is no `model`/`prompt` option on `useAssistant` itself. Model choice and -generation parameters belong inside the server callback -(`openaiImage('gpt-image-2')`, `openaiSpeech('tts-1')`, …). The client-side -options `useAssistant` accepts besides `connection` are `threadId`/`id`, -`chat: { tools, forwardedProps }` (`tools` here registers **client-executed** -tools' runtime implementations only — typing already comes from the server -callback), and a per-one-shot-capability `{ onResult, forwardedProps }` (a -result transform + extra request-body fields — not model/prompt). - -```typescript -// WRONG — no model/prompt options on useAssistant -useAssistant(blogAssistant, { connection, model: 'gpt-5.5', prompt: '...' }) - -// CORRECT — model/prompt live in the server callback; useAssistant takes -// connection, threadId/id, chat.{tools,forwardedProps}, and per-capability -// { onResult, forwardedProps } -useAssistant(blogAssistant, { - connection: fetchServerSentEvents('/api/assistant'), -}) -``` - -### d. MEDIUM: Expecting `defineAssistant` to auto-chain results - -```typescript -// WRONG — assuming the assistant remembers assistant.image.result automatically -assistant.chat.sendMessage('use the image I just generated') - -// CORRECT — thread the result value through explicitly (see Core Pattern 2) -assistant.chat.sendMessage({ - content: [ - { - type: 'image', - source: { type: 'url', value: assistant.image.result.images[0].url }, - }, - { type: 'text', content: 'Use this image.' }, - ], -}) -``` - -Chaining is manual by design (v1 non-goal: no shared artifact workspace or -auto-resolution of prior outputs). - -### e. MEDIUM: Declaring a capability on the server but never checking it client-side - -Every capability declared in `defineAssistant` is unconditionally present on -`assistant` — there's no need to guard with `assistant.image?.generate`. If a -capability is optional per-deployment, omit the key from the -`defineAssistant` config entirely rather than declaring it and ignoring it -on the client. - -## Cross-References - -- See also: **ai-core/chat-experience/SKILL.md** -- `assistant.chat` is the - same `useChat` surface; streaming, tool rendering, and multimodal - messages all apply unchanged. -- See also: **ai-core/media-generation/SKILL.md** -- `assistant.image` / - `assistant.audio` / `assistant.speech` / `assistant.video` / - `assistant.transcription` / `assistant.summarize` are each the same - `useGeneration`-style surface documented there. -- See also: **ai-core/tool-calling/SKILL.md** -- Tools passed to the `chat` - capability callback follow the same server/client tool patterns as a - standalone `chat()` route. -- See also: **ai-core/custom-backend-integration/SKILL.md** -- The shared - `connection` passed to `useAssistant` is the same `ConnectConnectionAdapter` - used by `useChat` / `useGenerate*`. diff --git a/packages/ai/skills/ai-core/chat-experience/SKILL.md b/packages/ai/skills/ai-core/chat-experience/SKILL.md index 2aa0606a5..e3a7466ea 100644 --- a/packages/ai/skills/ai-core/chat-experience/SKILL.md +++ b/packages/ai/skills/ai-core/chat-experience/SKILL.md @@ -620,7 +620,7 @@ If not handled, the UI appears to hang with no feedback. - See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools - See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features - See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events -- See also: **ai-core/assistant/SKILL.md** -- To compose chat with other - capabilities (image/audio/speech/video/transcription/summarize) behind a - single `defineAssistant` handler + `useAssistant` hook instead of wiring up - `chat()` and `useChat` alone +- See also: **ai-core/transaction/SKILL.md** -- To compose chat with other + app-defined verbs (image generation, narration, server-composed pipelines) + behind a single `defineTransaction` handler + `useTransaction` hook instead + of wiring up `chat()` and `useChat` alone diff --git a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md index 3cb3fb1a3..974b87605 100644 --- a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md +++ b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md @@ -461,7 +461,8 @@ Source: `docs/protocol/http-stream-protocol.md` - See also: **ai-core/ag-ui-protocol/SKILL.md** -- Understanding the AG-UI protocol helps build compatible custom servers - See also: **ai-core/chat-experience/SKILL.md** -- Full chat setup patterns including server-side `chat()` and `toServerSentEventsResponse()` - See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events on the server side -- See also: **ai-core/assistant/SKILL.md** -- If the backend needs to compose - chat with other capabilities (image/audio/speech/video/transcription/ - summarize) behind one endpoint, `defineAssistant` + `useAssistant` may fit - better than a custom connection adapter wrapping `useChat` alone +- See also: **ai-core/transaction/SKILL.md** -- If the backend needs to + compose chat with other app-defined verbs (media generation, + server-composed pipelines) behind one endpoint, `defineTransaction` + + `useTransaction` may fit better than a custom connection adapter wrapping + `useChat` alone diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index b0e6aaefc..de04c8f34 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -876,7 +876,8 @@ and piping into a custom logger. returns unexpected output or fails mid-stream, toggle `debug: true` on any `generate*()` call to see request metadata, raw provider chunks, and errors. Covers per-category toggling and piping into pino/winston. -- See also: **ai-core/assistant/SKILL.md** -- To combine multiple media - activities (image/audio/speech/video/transcription) with chat behind one - `defineAssistant` handler + `useAssistant` hook instead of wiring up each - `generate*()` activity and its own hook separately. +- See also: **ai-core/transaction/SKILL.md** -- To combine multiple media + activities (image/audio/speech/video/transcription) with chat as app-named + verbs behind one `defineTransaction` handler + `useTransaction` hook — + including server-composed transactions via `ctx.call` — instead of wiring + up each `generate*()` activity and its own hook separately. diff --git a/packages/ai/skills/ai-core/transaction/SKILL.md b/packages/ai/skills/ai-core/transaction/SKILL.md new file mode 100644 index 000000000..a24158a51 --- /dev/null +++ b/packages/ai/skills/ai-core/transaction/SKILL.md @@ -0,0 +1,389 @@ +--- +name: ai-core/transaction +description: > + App-defined verb composition and server-composed transactions: + defineTransaction() registers app-named verbs — chatVerb(callback) for + conversational surfaces, verb({ input, execute }) for one-shot + schema-validated work — behind one handler; useTransaction() consumes all + declared verbs from a single client hook with no generics, types inferred + from the server definition. A one-shot verb's execute can compose sibling + verbs via ctx.call as live-streamed sub-runs of the same single request, + with one abort scope. +type: sub-skill +library: tanstack-ai +library_version: '0.10.0' +sources: + - 'TanStack/ai:packages/ai/src/activities/transaction/index.ts' + - 'TanStack/ai:packages/ai/src/activities/transaction/types.ts' + - 'TanStack/ai:packages/ai-client/src/transaction-client.ts' + - 'TanStack/ai:packages/ai-client/src/transaction-types.ts' + - 'TanStack/ai:packages/ai-react/src/use-transaction.ts' +--- + +# Transaction + +This skill builds on ai-core, ai-core/chat-experience, and +ai-core/media-generation. Read them first. + +`defineTransaction` + `useTransaction` are a **composition layer**, not a new +activity or wire format. They wire together activities you already know +(`chat()`, `generateImage()`, `generateSpeech()`, …) behind one server +endpoint and one client hook, keyed by **app-named verbs** — `drafting`, +`heroImage`, `narration`, `blogPost` — not a fixed set of library nouns. +There are two verb kinds: + +- `chatVerb(callback)` — conversational: message history in, streamed chat + out. Client surface = the full `useChat` return. A definition may declare + several chat verbs (e.g. `primaryChat` + `summaryChat`). +- `verb({ input, execute })` — one-shot: Standard-Schema-validated input in, + typed result out. Client surface = `run(input)` / `result` / + `isLoading` / `error` / `status` / `stop` / `reset` / `subRuns`. + +A one-shot verb's `execute` receives `(req, ctx)`; `ctx.call(sibling, input)` +runs a sibling verb **inside the same request** as a live-streamed sub-run — +a server-composed **transaction** with one abort scope. + +## Setup — Chat + Image + Composing Verb End-to-End + +### Server: `defineTransaction` + a single `handler` + +The definition is **inert** — `defineTransaction` only stores the verbs and +their names/kinds. It constructs nothing (no adapters, no connections) until +a request actually reaches `handler`, so it's safe to import into an +isomorphic module shared with the client. + +```typescript +// src/lib/blog-transaction.ts — shared/isomorphic module +import { chat, generateImage } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { openaiText, openaiImage } from '@tanstack/ai-openai/adapters' +import { z } from 'zod' +import { BlogPostSchema } from './schemas' + +const drafting = chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + outputSchema: BlogPostSchema, // → typed structured output + stream: true, + threadId: req.threadId, + runId: req.runId, + }), +) + +const heroImage = verb({ + input: z.object({ prompt: z.string() }), // validated at runtime, 400 + issues on mismatch + execute: ({ input }) => + generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt }), +}) + +// A transaction: composes the siblings above server-side via ctx.call. +const blogPost = verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + // ctx.call on a chat verb → { text, structured }; re-validate structured. + const draft = await ctx.call(drafting, [ + { role: 'user', content: `Write a blog post about: ${input.topic}` }, + ]) + const parsed = BlogPostSchema.safeParse(draft.structured) + if (!parsed.success) throw new Error('invalid draft') + // ctx.call on a one-shot verb → its typed result. + const hero = await ctx.call(heroImage, { + prompt: `Hero image for "${parsed.data.title}"`, + }) + return { post: parsed.data, hero } // → txn.blogPost.result on the client + }, +}) + +export const blogTransaction = defineTransaction({ + drafting, + heroImage, + blogPost, +}) +``` + +```typescript +// src/routes/api.blog-studio.ts — server route, single handler +import { createFileRoute } from '@tanstack/react-router' +import { blogTransaction } from '../lib/blog-transaction' + +export const Route = createFileRoute('/api/blog-studio')({ + server: { + handlers: { + POST: ({ request }) => blogTransaction.handler(request), + }, + }, +}) +``` + +`handler` is the **only** thing the route needs to call. Internally it +routes by a `verb` discriminator carried in the request's `forwardedProps`: +chat verbs dispatch to the AG-UI `RunAgentInput` parsing path (same as a +standalone chat route); one-shot verbs get their forwarded props validated +against the verb's `input` schema (`400` with the validation issues on +failure) before `execute` runs. Unknown or undeclared verbs get a `400` +before any callback runs. + +### Client: `useTransaction` + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { blogTransaction } from '../lib/blog-transaction' + +function BlogStudio() { + const txn = useTransaction(blogTransaction, { + connection: fetchServerSentEvents('/api/blog-studio'), + }) + + return ( +
+
+ {txn.drafting.messages.map((message) => ( +
{message.role}
+ ))} +
+ + + {/* ONE request runs the whole pipeline; sub-runs stream back live. */} + + {txn.blogPost.isLoading && ( + + )} +
    + {txn.blogPost.subRuns.map((run) => ( +
  1. + {run.verb}: {run.status} +
  2. + ))} +
+ {txn.blogPost.result &&

{txn.blogPost.result.post.title}

} +
+ ) +} +``` + +`txn` is typed from the definition value passed in — **no generics** at the +call site (inference rides the definition's `'~verbs'` phantom type). Only +declared verbs appear on `txn`; referencing an undeclared key is a compile +error. A chat verb's surface is the full `useChat` return (`messages`, +`sendMessage`, `isLoading`, `error`, `status`, `stop`, `clear`, +`addToolResult`, `addToolApprovalResponse`, …, plus `partial`/`final` when +the callback declared `outputSchema`); a one-shot verb's surface is +`run(input)` (input typed by the verb's schema; resolves with the result or +`null`), `result`, `isLoading`, `error`, `status`, `stop`, `reset`, and +`subRuns`. + +Vue/Solid have identical patterns with different hook imports +(e.g. `import { useTransaction } from '@tanstack/ai-solid/transaction'`); +Svelte exports `createTransaction` from `@tanstack/ai-svelte/transaction`. + +## Core Patterns + +### 1. Server-composed transactions: `ctx.call` + live `subRuns` + +Inside a one-shot verb's `execute`, `ctx.call(sibling, input)` runs a sibling +verb as a tagged sub-run of the **same request**: + +- `ctx.call(oneShotVerb, input)` → validates input against the sibling's + schema, resolves with its typed result. +- `ctx.call(chatVerbObj, messagesArray)` → runs the chat callback to + completion server-side, resolves with `{ text, structured }` (`structured` + is `unknown` — re-validate with `safeParse`). + +Every sub-run streams to the client live as `transaction:sub-run:*` CUSTOM +events (names on `TRANSACTION_EVENTS` from `@tanstack/ai/transaction`: +`SUB_RUN_STARTED` / `SUB_RUN_CHUNK` / `SUB_RUN_RESULT` / `SUB_RUN_ERROR`). +The client demultiplexes them into `txn..subRuns`: +`{ runId, verb, index, status: 'running' | 'success' | 'error', result, +text, error? }` in server start order, reset on each new run. For chat +sub-runs `text` accumulates the streamed text — note that with an +`outputSchema` it's partial JSON, so show progress by length, don't render +it. `Promise.all` over multiple `ctx.call`s runs sub-work in parallel. + +### 2. One request, one abort scope + +`txn..stop()` aborts the single fetch. Server-side, `req.signal` +(=== `ctx.signal`, the request's `AbortSignal`) trips: `ctx.call` refuses +new sub-runs and chat sub-runs stop draining. Chain the signal into +activities so provider work is cancelled too — `chat()` takes an +`abortController`, so hang one off the request signal: + +```typescript +const narrate = verb({ + input: z.object({ text: z.string() }), + execute: (req) => { + const abortController = new AbortController() + req.signal.addEventListener('abort', () => abortController.abort(), { + once: true, + }) + return chat({ + adapter: openaiText('gpt-5.5'), + messages: [{ role: 'user', content: req.input.text }], + stream: false, + abortController, // stop() / disconnect cancels the provider call + }) + }, +}) +``` + +Error semantics: a throwing sub-run emits `transaction:sub-run:error` and +rejects that `ctx.call`; uncaught, the run terminates with `RUN_ERROR` +(client: `txn..error` set, `result` stays `null`, failed step marked +`status: 'error'` in `subRuns`, completed steps keep `'success'`). Catch a +`ctx.call` rejection inside `execute` to make a step best-effort. + +### 3. Chat tools auto-type from the server callback; per-verb `tools` is only for client-executed runtime + +Tools passed to `chat({ tools: [...] })` inside a chat verb's callback +automatically type that verb's `messages` tool-call/result parts — with +**no** client-side re-declaration needed for typing. Per-verb client options +are nested under `verbs` (so verb names can't collide with +`connection`/`id`/`threadId`): + +```typescript +const txn = useTransaction(blogTransaction, { + connection: fetchServerSentEvents('/api/blog-studio'), + verbs: { + // runtime only for client-executed (.client()) tools — types already + // came from the server callback + drafting: { tools: [showToast] }, + }, +}) +``` + +Each **one-shot** verb's entry accepts `{ onResult, forwardedProps }`: +`onResult` runs on the raw backend result and its return type becomes +`txn..result`'s type (return nothing to keep the raw result); chat +verb entries accept `{ tools, forwardedProps }`. + +```typescript +const txn = useTransaction(blogTransaction, { + connection: fetchServerSentEvents('/api/blog-studio'), + verbs: { + heroImage: { onResult: (result) => result.images[0]?.url ?? null }, + }, +}) + +txn.heroImage.result // string | null — the transform's return type +``` + +### 4. Structured output via `outputSchema` in a chat verb's callback + +If a chat verb's callback passes `outputSchema` to `chat()`, that verb's +surface picks up typed `partial` (progressive `DeepPartial`) and `final` +(validated terminal object), and `sendMessage` resolves to the validated +final (or `null`) instead of the messages array. Omit `outputSchema` and +neither field is present on the type — same conditional shape as +`useChat({ outputSchema })`. + +### 5. Client needs the definition's TYPE — mirror it when the server builds it per-request + +`useTransaction` reads verb names/kinds at runtime and everything else at +the type level. If the route constructs the definition inside the handler +(to keep provider SDKs out of the client bundle), export an **inert mirror** +from a client-safe module: same verb names, kinds, input schemas, and +`outputSchema`s; the mirrored `execute`/callback bodies never run in the +browser — they only carry result types. Share the Zod schemas and prompt +helpers from one module so the two sides can't drift (see the +`examples/ts-react-chat` blog-studio routes for the full pattern). + +### 6. Only declared verbs are constructed; one shared connection + +`defineTransaction({ drafting, heroImage })` produces a client with exactly +`txn.drafting` and `txn.heroImage`. All verbs share the single `connection` +passed to `useTransaction` — one endpoint, one adapter. Each underlying +sub-client (a `ChatClient` per chat verb, a `GenerationClient` per one-shot +verb) tags its own requests with the verb name so the single `handler` can +route. + +## Common Mistakes + +### a. HIGH: Writing a custom handler that branches on verb manually + +```typescript +// WRONG — reimplementing what `handler` already does +export const POST = async (request: Request) => { + const body = await request.json() + if (body.forwardedProps.verb === 'drafting') { + return toServerSentEventsResponse(chat({ adapter, messages: body.messages })) + } + // ...manual branching for every verb +} + +// CORRECT — defineTransaction's handler already parses, validates, routes, +// and serializes +export const POST = (request: Request) => blogTransaction.handler(request) +``` + +### b. HIGH: Passing `model` or generation options to `useTransaction` + +There is no `model`/`prompt` option on `useTransaction`. Model choice and +generation parameters belong inside the server verbs +(`openaiImage('gpt-image-2')`, …). Besides `connection`, the client options +are `id`/`threadId` and the nested `verbs` map: chat verbs take +`{ tools, forwardedProps }`, one-shot verbs `{ onResult, forwardedProps }`. + +```typescript +// WRONG — no model/prompt options, and per-verb options are not top-level +useTransaction(blogTransaction, { connection, model: 'gpt-5.5' }) + +// CORRECT +useTransaction(blogTransaction, { + connection: fetchServerSentEvents('/api/blog-studio'), + verbs: { heroImage: { onResult: (r) => r.images[0]?.url ?? null } }, +}) +``` + +### c. HIGH: Trusting `ctx.call(chatVerb, ...)`'s `structured` without re-validating + +`structured` is `unknown` (the callback's schema validated the stream, but a +stopped/failed generation can leave it `null` or partial). Always +`safeParse` it and throw on failure so the transaction fails with a clear +`RUN_ERROR` instead of returning a half-built result. + +### d. MEDIUM: Client-side orchestration when the pipeline should be one request + +```typescript +// WORKS, but: N requests, N abort scopes, intermediate values round-trip +// through the browser +const draft = await txn.drafting.sendMessage(topic) +await txn.heroImage.run({ prompt: draft.title }) + +// TRANSACTIONAL — declare a composing verb; ONE request, live subRuns, one +// stop() for the whole pipeline +await txn.blogPost.run({ topic }) +``` + +Client-side chaining is right when the *user* drives each step (regenerate +buttons, editable intermediate state). Server-side `ctx.call` is right when +one gesture should produce the finished artifact or fail as a unit. + +### e. MEDIUM: Forgetting to propagate `req.signal` into provider calls + +`stop()` aborts the fetch and halts orchestration, but a provider call not +chained to `req.signal` (e.g. via `chat()`'s `abortController`) keeps +burning tokens server-side until it completes. Thread the signal into every +activity a verb runs. + +## Cross-References + +- See also: **ai-core/chat-experience/SKILL.md** -- a chat verb's surface is + the same `useChat` surface; streaming, tool rendering, and multimodal + messages all apply unchanged. +- See also: **ai-core/media-generation/SKILL.md** -- one-shot verbs + typically wrap the generation activities documented there + (`generateImage`, `generateSpeech`, …). +- See also: **ai-core/tool-calling/SKILL.md** -- Tools passed to a chat + verb's callback follow the same server/client tool patterns as a + standalone `chat()` route. +- See also: **ai-core/custom-backend-integration/SKILL.md** -- The shared + `connection` passed to `useTransaction` is the same + `ConnectConnectionAdapter` used by `useChat` / `useGenerate*`. diff --git a/packages/ai/src/activities/assistant/index.ts b/packages/ai/src/activities/assistant/index.ts deleted file mode 100644 index bb2422ac9..000000000 --- a/packages/ai/src/activities/assistant/index.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { chatParamsFromRequestBody } from '../../utilities/chat-params.js' -import { toServerSentEventsResponse } from '../../stream-to-response.js' -import { streamGenerationResult } from '../stream-generation-result.js' -import type { - AssistantChatRequest, - AssistantConfig, - AssistantDefinition, -} from './types.js' - -export type * from './types.js' - -function isAsyncIterable(value: unknown): value is AsyncIterable { - return ( - value != null && typeof value === 'object' && Symbol.asyncIterator in value - ) -} - -/** - * Define a server-side assistant: a registry of per-capability callbacks - * exposing a single request `handler`. Inert — no adapters, connections, - * or other resources are constructed until a request actually arrives. - */ -export function defineAssistant( - config: T, -): AssistantDefinition { - const capabilities = Object.keys(config) as Array - - const handler = async (request: Request): Promise => { - let body: unknown - try { - body = await request.json() - } catch { - return new Response('Invalid JSON body', { status: 400 }) - } - - const bodyRecord = body as Record - const forwardedProps: Record = - (bodyRecord.forwardedProps as Record | undefined) ?? - (bodyRecord.data as Record | undefined) ?? - {} - const capability = forwardedProps.capability - // Gate on OWN, DECLARED capabilities only — `capabilities` is - // `Object.keys(config)`, so this excludes inherited `Object.prototype` - // members (`toString`, `valueOf`, `hasOwnProperty`, `constructor`, …) - // that `capability in config` / `Reflect.get(config, capability)` would - // otherwise treat as valid callbacks. - const isDeclaredCapability = - typeof capability === 'string' && - (capabilities as Array).includes(capability) - - // `Reflect.get` returns `any`, so this is a single narrowing cast (not - // `as unknown as`) — a per-key cast from `AssistantConfig` directly - // fails structurally because callback params are contravariant per key. - const callback = isDeclaredCapability - ? (Reflect.get(config, capability) as ((req: any) => unknown) | undefined) - : undefined - - if (!isDeclaredCapability || !callback) { - return new Response( - `Unknown assistant capability: ${String(capability)}`, - { status: 400 }, - ) - } - - if (capability === 'chat') { - // chatParamsFromRequestBody rejects with an AGUIError (not a thrown - // Response) when the body doesn't conform to AG-UI's RunAgentInput - // shape. Surface that as a 400, mirroring the e2e `api.chat.ts` route. - let params - try { - params = await chatParamsFromRequestBody(body) - } catch (error) { - if (error instanceof Response) return error - return new Response( - error instanceof Error ? error.message : 'Invalid request body', - { status: 400 }, - ) - } - const chatReq: AssistantChatRequest = { ...params, request } - const result = callback(chatReq) - if (!isAsyncIterable(result)) { - return new Response( - 'assistant chat capability must return a streaming ChatStream', - { status: 400 }, - ) - } - return toServerSentEventsResponse(result as AsyncIterable) - } - - // One-shot capabilities: validate the AG-UI envelope (same as `chat`), - // then pull the generation input out of forwardedProps. - let params - try { - params = await chatParamsFromRequestBody(body) - } catch (error) { - if (error instanceof Response) return error - return new Response( - error instanceof Error ? error.message : 'Invalid request body', - { status: 400 }, - ) - } - const { capability: _omit, ...input } = params.forwardedProps - const oneShotReq = { - ...input, - threadId: params.threadId, - runId: params.runId, - ...(params.parentRunId !== undefined && { - parentRunId: params.parentRunId, - }), - state: params.state, - aguiContext: params.aguiContext, - forwardedProps: params.forwardedProps, - request, - } - const result = callback(oneShotReq) - - if (isAsyncIterable(result)) { - // User opted into stream:true; the activity already emits generation:result. - return toServerSentEventsResponse(result as AsyncIterable) - } - // Non-streaming activity promise → wrap so the client's GenerationClient sees it. - return toServerSentEventsResponse( - streamGenerationResult(() => Promise.resolve(result), { - threadId: params.threadId, - runId: params.runId, - }), - ) - } - - return { capabilities, handler, '~caps': config } -} diff --git a/packages/ai/src/activities/assistant/types.ts b/packages/ai/src/activities/assistant/types.ts deleted file mode 100644 index 3f3efb05a..000000000 --- a/packages/ai/src/activities/assistant/types.ts +++ /dev/null @@ -1,118 +0,0 @@ -// packages/ai/src/activities/assistant/types.ts -import type { Context as AGUIContext } from '@ag-ui/core' -import type { - AudioGenerationResult, - ChatStream, - ImageGenerationResult, - JSONSchema, - ModelMessage, - StreamChunk, - StructuredOutputStream, - SummarizationResult, - TTSResult, - TranscriptionResult, - UIMessage, - VideoJobResult, -} from '../../types.js' - -/** The parsed request handed to the `chat` capability callback. */ -export interface AssistantChatRequest { - messages: Array - threadId: string - runId: string - parentRunId?: string - /** Client-declared tools (name/description/JSON-schema) from the AG-UI body. */ - tools: Array<{ name: string; description: string; parameters: JSONSchema }> - forwardedProps: Record - state: unknown - aguiContext: Array - /** Raw request, for escape hatches (headers, auth). */ - request: Request -} - -/** Base for one-shot capability requests: parsed generation input + raw request. */ -interface AssistantOneShotBase { - threadId: string - runId: string - parentRunId?: string - state: unknown - aguiContext: Array - forwardedProps: Record - request: Request -} - -export interface AssistantImageRequest extends AssistantOneShotBase { - prompt: unknown - numberOfImages?: number - size?: unknown - modelOptions?: Record -} -export interface AssistantAudioRequest extends AssistantOneShotBase { - prompt: unknown - duration?: number - modelOptions?: Record -} -export interface AssistantSpeechRequest extends AssistantOneShotBase { - text: string - voice?: string - format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm' - speed?: number - modelOptions?: Record -} -export interface AssistantVideoRequest extends AssistantOneShotBase { - prompt: unknown - size?: unknown - duration?: unknown - modelOptions?: Record -} -export interface AssistantTranscriptionRequest extends AssistantOneShotBase { - audio: string | File | Blob | ArrayBuffer - language?: string - prompt?: string - responseFormat?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt' - modelOptions?: Record -} -export interface AssistantSummarizeRequest extends AssistantOneShotBase { - text: string - maxLength?: number - style?: 'bullet-points' | 'paragraph' | 'concise' - focus?: Array - modelOptions?: Record -} - -/** A capability callback returns either a stream or a promise of the activity result. */ -type MaybeStream = AsyncIterable | Promise - -/** The shape a user passes to `defineAssistant`. Every key optional. */ -export interface AssistantConfig { - chat?: ( - req: AssistantChatRequest, - ) => - | ChatStream - | StructuredOutputStream - | Promise - | Promise - image?: (req: AssistantImageRequest) => MaybeStream - audio?: (req: AssistantAudioRequest) => MaybeStream - speech?: (req: AssistantSpeechRequest) => MaybeStream - video?: (req: AssistantVideoRequest) => MaybeStream - transcription?: ( - req: AssistantTranscriptionRequest, - ) => MaybeStream - summarize?: ( - req: AssistantSummarizeRequest, - ) => MaybeStream -} - -export type AssistantCapabilityName = keyof AssistantConfig - -export interface AssistantDefinition< - T extends AssistantConfig = AssistantConfig, -> { - /** The declared capability names (for the client to enumerate). */ - readonly capabilities: ReadonlyArray - /** Single request handler; routes by the `capability` discriminator. */ - handler: (request: Request) => Promise - /** Type-only carrier of the config for client inference. Never read at runtime. */ - readonly '~caps': T -} diff --git a/packages/ai/src/activities/transaction/index.ts b/packages/ai/src/activities/transaction/index.ts new file mode 100644 index 000000000..fd5b2732a --- /dev/null +++ b/packages/ai/src/activities/transaction/index.ts @@ -0,0 +1,431 @@ +import { EventType } from '@ag-ui/core' +import { chatParamsFromRequestBody } from '../../utilities/chat-params.js' +import { toServerSentEventsResponse } from '../../stream-to-response.js' +import { toRunErrorPayload } from '../error-payload.js' +import { + StandardSchemaValidationError, + validateWithStandardSchema, +} from '../chat/tools/schema-converter.js' +import { TRANSACTION_EVENTS } from './types.js' +import type { + AnyVerb, + ChatVerb, + ChatVerbReturn, + CollectedChatResult, + OneShotVerb, + TransactionChatRequest, + TransactionConfig, + TransactionDefinition, + TransactionRunContext, + VerbOptions, + VerbRequest, +} from './types.js' +import type { InferSchemaType, SchemaInput, StreamChunk } from '../../types.js' + +export type * from './types.js' +export { TRANSACTION_EVENTS } from './types.js' + +/** Declare a conversational verb (full chat surface on the client). */ +export function chatVerb( + callback: (req: TransactionChatRequest) => TRet, +): ChatVerb<(req: TransactionChatRequest) => TRet> { + return { kind: 'chat', callback } +} + +/** Declare a one-shot verb: (optionally schema-validated) input in, result out. */ +export function verb< + TResult, + const TSchema extends SchemaInput | undefined = undefined, +>( + options: VerbOptions, +): OneShotVerb< + TSchema extends SchemaInput ? InferSchemaType : unknown, + TResult +> { + return { + kind: 'one-shot', + ...(options.input !== undefined && { input: options.input }), + execute: options.execute, + } +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + value != null && typeof value === 'object' && Symbol.asyncIterator in value + ) +} + +/** + * An unbounded push channel bridging concurrently-running sub-verb streams + * into the single response iterable. `push` after `close` is a no-op. + */ +function createChunkChannel(): { + push: (chunk: StreamChunk) => void + close: () => void + [Symbol.asyncIterator]: () => AsyncIterator +} { + const queue: Array = [] + let notify: (() => void) | null = null + let closed = false + + return { + push(chunk: StreamChunk) { + if (closed) return + queue.push(chunk) + notify?.() + }, + close() { + closed = true + notify?.() + }, + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + for (;;) { + const chunk = queue.shift() + if (chunk !== undefined) return { value: chunk, done: false } + if (closed) return { value: undefined, done: true } + await new Promise((resolve) => { + notify = resolve + }) + notify = null + } + }, + } + }, + } +} + +function customEvent(name: string, value: unknown): StreamChunk { + return { + type: EventType.CUSTOM, + name, + value, + timestamp: Date.now(), + } +} + +interface RunEnvelope { + threadId: string + runId: string + parentRunId?: string + state: unknown + aguiContext: TransactionChatRequest['aguiContext'] + forwardedProps: Record +} + +/** + * Define a server-side transaction endpoint: a registry of app-named verbs + * exposing a single request `handler`. Inert — no adapters, connections, or + * other resources are constructed until a request actually arrives. + * + * Routing: the client tags each request with a `verb` discriminator in + * `forwardedProps`. Chat verbs speak the conversational AG-UI protocol; + * one-shot verbs receive validated input and may compose sibling verbs via + * `ctx.call`, which streams each sub-run back to the client tagged with + * {@link TRANSACTION_EVENTS} custom events — one request, one abort scope. + */ +export function defineTransaction( + config: T, +): TransactionDefinition { + const verbs = Object.keys(config) as Array + const verbKinds = Object.fromEntries( + Object.entries(config).map(([name, v]) => [name, v.kind]), + ) as Record + + const verbNameOf = (v: AnyVerb): string => + verbs.find((name) => config[name] === v) ?? 'verb' + + const handler = async (request: Request): Promise => { + let body: unknown + try { + body = await request.json() + } catch { + return new Response('Invalid JSON body', { status: 400 }) + } + + // Both verb kinds ride the AG-UI envelope; reject malformed bodies with + // a 400 (chatParamsFromRequestBody rejects with an AGUIError). + let params: Awaited> + try { + params = await chatParamsFromRequestBody(body) + } catch (error) { + if (error instanceof Response) return error + return new Response( + error instanceof Error ? error.message : 'Invalid request body', + { status: 400 }, + ) + } + + const verbName = params.forwardedProps.verb + // Route on OWN, DECLARED verb names only — `verbs` comes from + // `Object.keys(config)`, which excludes inherited `Object.prototype` + // members (`toString`, `constructor`, ...) that a bare `in` check would + // otherwise treat as callable verbs. + const declared = + typeof verbName === 'string' && (verbs as Array).includes(verbName) + // `Reflect.get` returns `any`; this is a single narrowing cast. + const target = + declared && typeof verbName === 'string' + ? (Reflect.get(config, verbName) as AnyVerb | undefined) + : undefined + if (!target) { + return new Response(`Unknown transaction verb: ${String(verbName)}`, { + status: 400, + }) + } + + const envelope: RunEnvelope = { + threadId: params.threadId, + runId: params.runId, + ...(params.parentRunId !== undefined && { + parentRunId: params.parentRunId, + }), + state: params.state, + aguiContext: params.aguiContext, + forwardedProps: params.forwardedProps, + } + + if (target.kind === 'chat') { + const chatReq: TransactionChatRequest = { + messages: params.messages, + tools: params.tools, + ...envelope, + request, + } + const result = target.callback(chatReq) + if (!isAsyncIterable(result)) { + return new Response( + 'transaction chat verb must return a streaming ChatStream', + { status: 400 }, + ) + } + return toServerSentEventsResponse(result as AsyncIterable) + } + + // One-shot verb: input = the forwarded props minus the routing + // discriminator, validated against the verb's schema when declared. + const { verb: _omit, ...rawInput } = params.forwardedProps + const validation = await validateWithStandardSchema(target.input, rawInput) + if (!validation.success) { + return new Response( + JSON.stringify({ + error: 'Invalid verb input', + verb: verbName, + issues: validation.issues, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ) + } + + return toServerSentEventsResponse( + runVerbStream(target, validation.data, envelope, request, verbNameOf), + ) + } + + return { verbs, verbKinds, handler, '~verbs': config } +} + +/** + * Run a one-shot verb as the root of a (possibly multi-sub-run) transaction + * stream: RUN_STARTED, live sub-run events from `ctx.call`, the verb's own + * `generation:result`, RUN_FINISHED — or RUN_ERROR on failure. + */ +async function* runVerbStream( + target: OneShotVerb, + input: unknown, + envelope: RunEnvelope, + request: Request, + verbNameOf: (v: AnyVerb) => string, +): AsyncIterable { + const { threadId, runId } = envelope + const channel = createChunkChannel() + let subIndex = 0 + + const call = async (subVerb: AnyVerb, subInput: unknown): Promise => { + request.signal.throwIfAborted() + const index = subIndex++ + const subRunId = `${runId}-sub-${index}` + const verbName = verbNameOf(subVerb) + const tag = { runId: subRunId, parentRunId: runId, verb: verbName, index } + channel.push(customEvent(TRANSACTION_EVENTS.SUB_RUN_STARTED, tag)) + try { + let result: unknown + if (subVerb.kind === 'chat') { + result = await collectChatSubRun( + subVerb, + subInput as TransactionChatRequest['messages'], + { ...envelope, runId: subRunId, parentRunId: runId }, + request, + (chunk) => + channel.push( + customEvent(TRANSACTION_EVENTS.SUB_RUN_CHUNK, { ...tag, chunk }), + ), + ) + } else { + const validation = await validateWithStandardSchema( + subVerb.input, + subInput, + ) + if (!validation.success) { + throw new StandardSchemaValidationError( + validation.issues.map((issue) => ({ message: issue.message })), + ) + } + const subReq: VerbRequest = { + input: validation.data, + threadId, + runId: subRunId, + parentRunId: runId, + state: envelope.state, + aguiContext: envelope.aguiContext, + forwardedProps: envelope.forwardedProps, + request, + signal: request.signal, + } + const out = subVerb.execute(subReq, ctx) + result = isAsyncIterable(out) + ? await forwardAndExtractResult(out, (chunk) => + channel.push( + customEvent(TRANSACTION_EVENTS.SUB_RUN_CHUNK, { + ...tag, + chunk, + }), + ), + ) + : await out + } + channel.push( + customEvent(TRANSACTION_EVENTS.SUB_RUN_RESULT, { ...tag, result }), + ) + return result + } catch (error) { + channel.push( + customEvent(TRANSACTION_EVENTS.SUB_RUN_ERROR, { + ...tag, + message: error instanceof Error ? error.message : String(error), + }), + ) + throw error + } + } + + const ctx: TransactionRunContext = { + call: call as TransactionRunContext['call'], + signal: request.signal, + } + + const req: VerbRequest = { + input, + ...envelope, + request, + signal: request.signal, + } + + const out = target.execute(req, ctx) + + // Escape hatch: an execute returning a raw StreamChunk iterable (e.g. a + // `stream: true` activity) is forwarded as-is. `ctx.call` is unavailable + // on this path — nothing drains the channel. + if (isAsyncIterable(out)) { + channel.close() + yield* out + return + } + + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + } + + const execution = out.then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }), + ) + void execution.finally(() => channel.close()) + + for await (const chunk of channel) { + yield chunk + } + + const outcome = await execution + if (outcome.ok) { + yield customEvent('generation:result', outcome.result) + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + finishReason: 'stop', + timestamp: Date.now(), + } + } else { + const payload = toRunErrorPayload(outcome.error, 'Transaction failed') + const codeFields = + payload.code !== undefined ? { code: payload.code } : undefined + yield { + type: EventType.RUN_ERROR, + message: payload.message, + ...codeFields, + error: { message: payload.message, ...codeFields }, + timestamp: Date.now(), + } + } +} + +/** + * Run a chat verb's callback to completion server-side, forwarding every + * chunk to the transaction stream and collecting the accumulated text plus + * the structured output (from the `structured-output.complete` event). + */ +async function collectChatSubRun( + target: ChatVerb, + messages: TransactionChatRequest['messages'], + envelope: RunEnvelope, + request: Request, + forward: (chunk: unknown) => void, +): Promise { + const stream = target.callback({ + messages, + tools: [], + ...envelope, + request, + }) + if (!isAsyncIterable(stream)) { + throw new Error('transaction chat verb must return a streaming ChatStream') + } + let text = '' + let structured: unknown = null + for await (const chunk of stream as AsyncIterable) { + request.signal.throwIfAborted() + forward(chunk) + if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) { + text += typeof chunk.delta === 'string' ? chunk.delta : '' + } else if ( + chunk.type === EventType.CUSTOM && + chunk.name === 'structured-output.complete' + ) { + structured = chunk.value?.object ?? null + } + } + return { text, structured } +} + +/** + * Forward a streaming sub-verb's chunks and recover its result from the + * terminal `generation:result` CUSTOM event. + */ +async function forwardAndExtractResult( + stream: AsyncIterable, + forward: (chunk: unknown) => void, +): Promise { + let result: unknown = null + for await (const chunk of stream as AsyncIterable) { + forward(chunk) + if (chunk.type === EventType.CUSTOM && chunk.name === 'generation:result') { + result = chunk.value + } + } + return result +} diff --git a/packages/ai/src/activities/transaction/types.ts b/packages/ai/src/activities/transaction/types.ts new file mode 100644 index 000000000..02849d174 --- /dev/null +++ b/packages/ai/src/activities/transaction/types.ts @@ -0,0 +1,195 @@ +// packages/ai/src/activities/transaction/types.ts +import type { Context as AGUIContext } from '@ag-ui/core' +import type { + ChatStream, + InferSchemaType, + JSONSchema, + ModelMessage, + SchemaInput, + StreamChunk, + StructuredOutputStream, + UIMessage, +} from '../../types.js' + +/** The parsed request handed to a chat verb's callback. */ +export interface TransactionChatRequest { + messages: Array + threadId: string + runId: string + parentRunId?: string + /** Client-declared tools (name/description/JSON-schema) from the AG-UI body. */ + tools: Array<{ name: string; description: string; parameters: JSONSchema }> + forwardedProps: Record + state: unknown + aguiContext: Array + /** Raw request, for escape hatches (headers, auth). */ + request: Request +} + +/** + * The return types a chat verb callback may produce. The promise forms are + * accepted for assignability with `chat()`'s overloads but rejected at + * request time — a chat verb must stream. + */ +export type ChatVerbReturn = + | ChatStream + | StructuredOutputStream + | Promise + | Promise + +/** + * The callback shape a chat verb wraps: full conversational turns — message + * history in, a streaming chat (or structured-output) stream out. + */ +export type ChatVerbCallback = (req: TransactionChatRequest) => ChatVerbReturn + +/** + * A conversational verb. Produces the full chat surface on the client + * (`sendMessage` / `messages` / tools / approvals). Create with + * {@link chatVerb}; the callback generic is preserved so the client can + * infer tool and output-schema types from its return type. + */ +export interface ChatVerb< + TCallback extends ChatVerbCallback = ChatVerbCallback, +> { + readonly kind: 'chat' + readonly callback: TCallback +} + +/** The parsed request handed to a one-shot verb's `execute`. */ +export interface VerbRequest { + /** + * The client-sent input. Validated against the verb's `input` schema + * (when one is declared) before `execute` runs. + */ + input: TInput + threadId: string + runId: string + parentRunId?: string + state: unknown + aguiContext: Array + /** The raw forwarded props, including fields outside the input schema. */ + forwardedProps: Record + /** Raw request, for escape hatches (headers, auth). */ + request: Request + /** + * Aborted when the client disconnects or stops the run. Pass into + * activities (e.g. `chat({ abortSignal })`) to cancel provider work. + */ + signal: AbortSignal +} + +/** + * The result of running a chat verb to completion inside a transaction via + * `ctx.call`: the accumulated assistant text plus the schema-validated + * structured output (when the callback declared an `outputSchema`). + */ +export interface CollectedChatResult { + text: string + structured: TStructured | null +} + +/** + * Composition context handed to every one-shot verb's `execute`. `ctx.call` + * invokes another verb as a tagged sub-run of the current transaction: its + * stream is forwarded into the parent response (so the client can observe it + * live) and the call resolves with the sub-verb's result. + */ +export interface TransactionRunContext { + call: TransactionCall + /** Aborted when the client disconnects or stops the run. */ + signal: AbortSignal +} + +export interface TransactionCall { + /** Run a one-shot verb as a sub-run; resolves with its result. */ + (verb: OneShotVerb, input: TIn): Promise + /** + * Run a chat verb's callback to completion as a sub-run; resolves with the + * accumulated text and (if the callback declared an `outputSchema`) the + * validated structured output. + */ + ( + verb: ChatVerb, + messages: Array, + ): Promise +} + +/** The `execute` shape of a one-shot verb. */ +export type VerbExecute = ( + req: VerbRequest, + ctx: TransactionRunContext, +) => Promise | AsyncIterable + +/** + * A one-shot verb: validated input in, result out. Create with {@link verb}; + * the input/result generics are preserved so the client can type + * `txn..run(input)` end to end. + */ +export interface OneShotVerb { + readonly kind: 'one-shot' + /** Optional Standard Schema for the input; validated before `execute`. */ + readonly input?: SchemaInput + readonly execute: VerbExecute +} + +/** The options accepted by {@link verb}. */ +export interface VerbOptions< + TSchema extends SchemaInput | undefined, + TResult, +> { + /** + * Standard Schema (Zod, ArkType, Valibot, ...) describing the input. + * Validated at runtime before `execute`; drives the client-side input type. + */ + input?: TSchema + execute: VerbExecute< + TSchema extends SchemaInput ? InferSchemaType : unknown, + TResult + > +} + +export type AnyVerb = ChatVerb | OneShotVerb + +/** The shape a user passes to `defineTransaction`: app-named verbs. */ +export type TransactionConfig = Record + +export type VerbKind = AnyVerb['kind'] + +export interface TransactionDefinition< + T extends TransactionConfig = TransactionConfig, +> { + /** The declared verb names (for the client to enumerate). */ + readonly verbs: ReadonlyArray + /** Each verb's kind, so the client composer picks the right sub-client. */ + readonly verbKinds: Readonly> + /** Single request handler; routes by the `verb` discriminator. */ + handler: (request: Request) => Promise + /** Type-only carrier of the config for client inference. Never read at runtime. */ + readonly '~verbs': T +} + +/** Payloads of the CUSTOM events a transaction run emits for its sub-runs. */ +export interface SubRunStartedPayload { + runId: string + parentRunId: string + verb: string + index: number +} +export interface SubRunChunkPayload extends SubRunStartedPayload { + chunk: unknown +} +export interface SubRunResultPayload extends SubRunStartedPayload { + result: unknown +} +export interface SubRunErrorPayload extends SubRunStartedPayload { + message: string +} + +/** CUSTOM event names used by the transaction sub-run protocol. */ +export const TRANSACTION_EVENTS = { + SUB_RUN_STARTED: 'transaction:sub-run:started', + SUB_RUN_CHUNK: 'transaction:sub-run:chunk', + SUB_RUN_RESULT: 'transaction:sub-run:result', + SUB_RUN_ERROR: 'transaction:sub-run:error', +} as const diff --git a/packages/ai/src/assistant.ts b/packages/ai/src/assistant.ts deleted file mode 100644 index 377478178..000000000 --- a/packages/ai/src/assistant.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { defineAssistant } from './activities/assistant/index' -export type { - AssistantConfig, - AssistantDefinition, - AssistantCapabilityName, - AssistantChatRequest, - AssistantImageRequest, - AssistantAudioRequest, - AssistantSpeechRequest, - AssistantVideoRequest, - AssistantTranscriptionRequest, - AssistantSummarizeRequest, -} from './activities/assistant/index' diff --git a/packages/ai/src/transaction.ts b/packages/ai/src/transaction.ts new file mode 100644 index 000000000..72dd35d7e --- /dev/null +++ b/packages/ai/src/transaction.ts @@ -0,0 +1,26 @@ +export { + chatVerb, + defineTransaction, + verb, + TRANSACTION_EVENTS, +} from './activities/transaction/index' +export type { + AnyVerb, + ChatVerb, + ChatVerbCallback, + CollectedChatResult, + OneShotVerb, + SubRunChunkPayload, + SubRunErrorPayload, + SubRunResultPayload, + SubRunStartedPayload, + TransactionCall, + TransactionChatRequest, + TransactionConfig, + TransactionDefinition, + TransactionRunContext, + VerbExecute, + VerbKind, + VerbOptions, + VerbRequest, +} from './activities/transaction/index' diff --git a/packages/ai/tests/activities/assistant/index.test.ts b/packages/ai/tests/activities/assistant/index.test.ts deleted file mode 100644 index 2b1bf4539..000000000 --- a/packages/ai/tests/activities/assistant/index.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { defineAssistant } from '../../../src/activities/assistant/index.js' -import type { AssistantConfig } from '../../../src/activities/assistant/types.js' -import type { ChatStream } from '../../../src/types.js' - -// Compile-only: a streaming chat callback (the shape `chat()` returns by -// default) must be assignable to `AssistantConfig['chat']` without a cast. -// Regression test for the `any`-collapse bug where `TextActivityResult` silently dropped the default `ChatStream` branch. -const _chatCbAssignable: AssistantConfig['chat'] = (_req) => - undefined as unknown as ChatStream -void _chatCbAssignable - -describe('defineAssistant', () => { - it('is inert: does not invoke any capability callback at define time', () => { - const chatCb = vi.fn() - const imageCb = vi.fn() - const assistant = defineAssistant({ chat: chatCb, image: imageCb }) - expect(chatCb).not.toHaveBeenCalled() - expect(imageCb).not.toHaveBeenCalled() - expect(assistant.capabilities.slice().sort()).toEqual(['chat', 'image']) - expect(typeof assistant.handler).toBe('function') - }) - - it('is exported from the assistant subpath entry', async () => { - const mod = await import('../../../src/assistant.js') - expect(typeof mod.defineAssistant).toBe('function') - }, 30000) -}) - -function runAgentBody(capability: string, extra: Record = {}) { - return { - threadId: 't1', - runId: 'r1', - state: {}, - messages: [], - tools: [], - context: [], - forwardedProps: { capability, ...extra }, - data: { capability, ...extra }, - } -} - -function req(body: unknown) { - return new Request('http://x/api/assistant', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -async function readSse(res: Response): Promise> { - const text = await res.text() - return text - .split('\n\n') - .map((l) => l.replace(/^data: /, '').trim()) - .filter(Boolean) - .map((l) => JSON.parse(l)) -} - -describe('assistant.handler', () => { - it('400s on unknown capability', async () => { - const assistant = defineAssistant({ chat: async function* () {} as any }) - const res = await assistant.handler(req(runAgentBody('image'))) - expect(res.status).toBe(400) - }) - - it('400s on an inherited Object.prototype key used as capability', async () => { - const assistant = defineAssistant({ chat: async function* () {} as any }) - const res = await assistant.handler(req(runAgentBody('toString'))) - expect(res.status).toBe(400) - }) - - it('routes chat and streams the callback iterable', async () => { - const assistant = defineAssistant({ - chat: async function* (r: any) { - yield { - type: 'RUN_STARTED', - threadId: r.threadId, - runId: r.runId, - } as any - yield { - type: 'RUN_FINISHED', - threadId: r.threadId, - runId: r.runId, - } as any - }, - }) - const res = await assistant.handler(req(runAgentBody('chat'))) - expect(res.headers.get('content-type')).toContain('text/event-stream') - const chunks = await readSse(res) - expect(chunks[0].type).toBe('RUN_STARTED') - }) - - it('wraps a one-shot promise as a generation:result CUSTOM event', async () => { - const assistant = defineAssistant({ - image: async (r: any) => ({ - id: 'img1', - model: 'gpt-image-1', - images: [{ url: `generated:${String(r.prompt)}` }], - }), - }) - const res = await assistant.handler( - req(runAgentBody('image', { prompt: 'a fox' })), - ) - const chunks = await readSse(res) - const custom = chunks.find((c) => c.type === 'CUSTOM') - expect(custom.name).toBe('generation:result') - expect(custom.value.images[0].url).toBe('generated:a fox') - }) - - it('gives a one-shot capability callback the validated AG-UI envelope', async () => { - const assistant = defineAssistant({ - image: async (r: any) => ({ - id: 'img1', - model: 'gpt-image-1', - // Echo the envelope fields into the result so we can assert on - // them via the streamed generation:result CUSTOM event. - images: [ - { - url: `${String(r.prompt)}|${r.threadId}|${r.runId}|${JSON.stringify(r.state)}|${JSON.stringify(r.aguiContext)}|${r.forwardedProps.capability}`, - }, - ], - }), - }) - const body = runAgentBody('image', { prompt: 'a fox' }) - const res = await assistant.handler(req(body)) - const chunks = await readSse(res) - const custom = chunks.find((c) => c.type === 'CUSTOM') - expect(custom.name).toBe('generation:result') - const [prompt, threadId, runId, state, aguiContext, capability] = - custom.value.images[0].url.split('|') - expect(prompt).toBe('a fox') - expect(threadId).toBe(body.threadId) - expect(runId).toBe(body.runId) - expect(state).toBe(JSON.stringify(body.state)) - expect(aguiContext).toBe(JSON.stringify(body.context)) - expect(capability).toBe('image') - }) -}) diff --git a/packages/ai/tests/activities/chat/chat-result-meta.test.ts b/packages/ai/tests/activities/chat/chat-result-meta.test.ts index bc8e96ff7..7d8cab14f 100644 --- a/packages/ai/tests/activities/chat/chat-result-meta.test.ts +++ b/packages/ai/tests/activities/chat/chat-result-meta.test.ts @@ -9,7 +9,7 @@ * * The phantom is optional (`'~chatMeta'?:`), so it must not change * assignability of `chat()`'s return to its pre-existing consumers (see - * `chat-result-types.test.ts` and `activities/assistant/index.test.ts` for + * `chat-result-types.test.ts` and `activities/transaction/index.test.ts` for * the pinned pre-phantom shapes). */ import { describe, expectTypeOf, it } from 'vitest' diff --git a/packages/ai/tests/activities/transaction/index.test.ts b/packages/ai/tests/activities/transaction/index.test.ts new file mode 100644 index 000000000..0d208346a --- /dev/null +++ b/packages/ai/tests/activities/transaction/index.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { + TRANSACTION_EVENTS, + chatVerb, + defineTransaction, + verb, +} from '../../../src/activities/transaction/index.js' +import type { ChatVerbCallback } from '../../../src/activities/transaction/types.js' +import type { ChatStream } from '../../../src/types.js' + +// Compile-only: a streaming chat callback (the shape `chat()` returns by +// default) must be assignable to `ChatVerbCallback` without a cast. +const _chatCbAssignable: ChatVerbCallback = (_req) => + undefined as unknown as ChatStream +void _chatCbAssignable + +function runAgentBody(verbName: string, extra: Record = {}) { + return { + threadId: 't1', + runId: 'r1', + state: {}, + messages: [], + tools: [], + context: [], + forwardedProps: { verb: verbName, ...extra }, + } +} + +function req(body: unknown) { + return new Request('http://x/api/transaction', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +async function readSse(res: Response): Promise> { + const text = await res.text() + return text + .split('\n\n') + .map((l) => l.replace(/^data: /, '').trim()) + .filter(Boolean) + .map((l) => JSON.parse(l)) +} + +const emptyChat = () => + chatVerb(async function* (r: any) { + yield { + type: 'RUN_STARTED', + threadId: r.threadId, + runId: r.runId, + } as any + yield { + type: 'RUN_FINISHED', + threadId: r.threadId, + runId: r.runId, + } as any + } as any) + +describe('defineTransaction', () => { + it('is inert: does not invoke any verb at define time', () => { + const execute = vi.fn() + const callback = vi.fn() + const txn = defineTransaction({ + primaryChat: chatVerb(callback as any), + banner: verb({ execute }), + }) + expect(execute).not.toHaveBeenCalled() + expect(callback).not.toHaveBeenCalled() + expect(txn.verbs.slice().sort()).toEqual(['banner', 'primaryChat']) + expect(txn.verbKinds).toEqual({ primaryChat: 'chat', banner: 'one-shot' }) + expect(typeof txn.handler).toBe('function') + }) + + it('is exported from the transaction subpath entry', async () => { + const mod = await import('../../../src/transaction.js') + expect(typeof mod.defineTransaction).toBe('function') + expect(typeof mod.verb).toBe('function') + expect(typeof mod.chatVerb).toBe('function') + }, 30000) +}) + +describe('transaction.handler routing', () => { + it('400s on an unknown verb', async () => { + const txn = defineTransaction({ primaryChat: emptyChat() }) + const res = await txn.handler(req(runAgentBody('banner'))) + expect(res.status).toBe(400) + }) + + it('400s on an inherited Object.prototype key used as verb', async () => { + const txn = defineTransaction({ primaryChat: emptyChat() }) + const res = await txn.handler(req(runAgentBody('toString'))) + expect(res.status).toBe(400) + }) + + it('routes a chat verb and streams the callback iterable', async () => { + const txn = defineTransaction({ primaryChat: emptyChat() }) + const res = await txn.handler(req(runAgentBody('primaryChat'))) + expect(res.headers.get('content-type')).toContain('text/event-stream') + const chunks = await readSse(res) + expect(chunks[0].type).toBe('RUN_STARTED') + }) + + it('supports multiple independently-routed chat verbs', async () => { + const seen: Array = [] + const mkChat = (label: string) => + chatVerb(async function* (r: any) { + seen.push(label) + yield { + type: 'RUN_STARTED', + threadId: r.threadId, + runId: r.runId, + } as any + } as any) + const txn = defineTransaction({ + primaryChat: mkChat('primary'), + summaryChat: mkChat('summary'), + }) + await (await txn.handler(req(runAgentBody('summaryChat')))).text() + expect(seen).toEqual(['summary']) + await (await txn.handler(req(runAgentBody('primaryChat')))).text() + expect(seen).toEqual(['summary', 'primary']) + }) +}) + +describe('one-shot verbs', () => { + it('wraps the execute result as a generation:result CUSTOM event', async () => { + const txn = defineTransaction({ + banner: verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ url: `generated:${input.prompt}` }), + }), + }) + const res = await txn.handler( + req(runAgentBody('banner', { prompt: 'a fox' })), + ) + const chunks = await readSse(res) + expect(chunks[0].type).toBe('RUN_STARTED') + const custom = chunks.find((c) => c.type === 'CUSTOM') + expect(custom.name).toBe('generation:result') + expect(custom.value.url).toBe('generated:a fox') + expect(chunks.at(-1).type).toBe('RUN_FINISHED') + }) + + it('validates input against the verb schema and 400s with issues', async () => { + const execute = vi.fn() + const txn = defineTransaction({ + banner: verb({ + input: z.object({ prompt: z.string() }), + execute, + }), + }) + const res = await txn.handler( + req(runAgentBody('banner', { prompt: 42 })), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toBe('Invalid verb input') + expect(body.verb).toBe('banner') + expect(body.issues.length).toBeGreaterThan(0) + expect(execute).not.toHaveBeenCalled() + }) + + it('gives execute the validated envelope and an abort signal', async () => { + const txn = defineTransaction({ + echo: verb({ + execute: async (r) => ({ + threadId: r.threadId, + runId: r.runId, + state: r.state, + hasSignal: r.signal instanceof AbortSignal, + verb: r.forwardedProps.verb, + }), + }), + }) + const body = runAgentBody('echo', { anything: true }) + const chunks = await readSse(await txn.handler(req(body))) + const custom = chunks.find((c) => c.type === 'CUSTOM') + expect(custom.value).toEqual({ + threadId: 't1', + runId: 'r1', + state: {}, + hasSignal: true, + verb: 'echo', + }) + }) + + it('emits RUN_ERROR when execute rejects', async () => { + const txn = defineTransaction({ + boom: verb({ + execute: async () => { + throw new Error('kaboom') + }, + }), + }) + const chunks = await readSse(await txn.handler(req(runAgentBody('boom')))) + const error = chunks.find((c) => c.type === 'RUN_ERROR') + expect(error.message).toContain('kaboom') + expect(chunks.find((c) => c.type === 'RUN_FINISHED')).toBeUndefined() + }) +}) + +describe('transactions (ctx.call)', () => { + it('runs sibling one-shot verbs as tagged sub-runs inside one response', async () => { + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ url: `img:${input.prompt}` }), + }) + const txn = defineTransaction({ + banner, + blogPost: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const [hero, thumb] = await Promise.all([ + ctx.call(banner, { prompt: `hero ${input.topic}` }), + ctx.call(banner, { prompt: `thumb ${input.topic}` }), + ]) + return { hero, thumb } + }, + }), + }) + + const chunks = await readSse( + await txn.handler(req(runAgentBody('blogPost', { topic: 'foxes' }))), + ) + + const started = chunks.filter( + (c) => c.name === TRANSACTION_EVENTS.SUB_RUN_STARTED, + ) + expect(started).toHaveLength(2) + expect(started[0].value.verb).toBe('banner') + expect(started[0].value.parentRunId).toBe('r1') + expect(started[0].value.runId).not.toBe(started[1].value.runId) + + const results = chunks.filter( + (c) => c.name === TRANSACTION_EVENTS.SUB_RUN_RESULT, + ) + expect(results.map((r) => r.value.result.url).sort()).toEqual([ + 'img:hero foxes', + 'img:thumb foxes', + ]) + + const final = chunks.find((c) => c.name === 'generation:result') + expect(final.value).toEqual({ + hero: { url: 'img:hero foxes' }, + thumb: { url: 'img:thumb foxes' }, + }) + expect(chunks.at(-1).type).toBe('RUN_FINISHED') + }) + + it('validates sub-verb input and surfaces a sub-run error + RUN_ERROR', async () => { + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async () => ({ url: 'never' }), + }) + const txn = defineTransaction({ + banner, + bad: verb({ + execute: async (_r, ctx) => ctx.call(banner, { prompt: 42 as any }), + }), + }) + const chunks = await readSse(await txn.handler(req(runAgentBody('bad')))) + const subError = chunks.find( + (c) => c.name === TRANSACTION_EVENTS.SUB_RUN_ERROR, + ) + expect(subError.value.verb).toBe('banner') + expect(chunks.find((c) => c.type === 'RUN_ERROR')).toBeDefined() + }) + + it('collects text and structured output from a chat verb sub-run', async () => { + const drafting = chatVerb(async function* (r: any) { + yield { type: 'RUN_STARTED', threadId: r.threadId, runId: r.runId } as any + yield { type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'Hel' } as any + yield { type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'lo' } as any + yield { + type: 'CUSTOM', + name: 'structured-output.complete', + value: { object: { title: 'Foxes' }, raw: '{"title":"Foxes"}' }, + } as any + yield { type: 'RUN_FINISHED', threadId: r.threadId, runId: r.runId } as any + } as any) + const txn = defineTransaction({ + drafting, + blogPost: verb({ + execute: async (_r, ctx) => { + const draft = await ctx.call(drafting, [ + { role: 'user', content: 'write about foxes' }, + ]) + return draft + }, + }), + }) + const chunks = await readSse( + await txn.handler(req(runAgentBody('blogPost'))), + ) + const forwarded = chunks.filter( + (c) => c.name === TRANSACTION_EVENTS.SUB_RUN_CHUNK, + ) + expect(forwarded.length).toBeGreaterThan(0) + expect(forwarded[0].value.verb).toBe('drafting') + const final = chunks.find((c) => c.name === 'generation:result') + expect(final.value).toEqual({ + text: 'Hello', + structured: { title: 'Foxes' }, + }) + }) +}) diff --git a/packages/ai/vite.config.ts b/packages/ai/vite.config.ts index 066420b9d..234c159e4 100644 --- a/packages/ai/vite.config.ts +++ b/packages/ai/vite.config.ts @@ -31,7 +31,7 @@ export default mergeConfig( tanstackViteConfig({ entry: [ './src/index.ts', - './src/assistant.ts', + './src/transaction.ts', './src/client.ts', './src/activities/index.ts', './src/middlewares/index.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e006a80cf..e31386bcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2032,6 +2032,9 @@ importers: vite: specifier: ^7.3.3 version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^4.2.0 + version: 4.3.6 packages/ai-react-ui: dependencies: @@ -2216,6 +2219,9 @@ importers: vitest: specifier: ^4.0.14 version: 4.0.15(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.15))(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^4.2.0 + version: 4.3.6 packages/ai-solid-ui: dependencies: @@ -2290,6 +2296,9 @@ importers: vite: specifier: ^7.3.3 version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + zod: + specifier: ^4.2.0 + version: 4.3.6 packages/ai-utils: devDependencies: @@ -2339,6 +2348,9 @@ importers: vue: specifier: ^3.5.25 version: 3.5.25(typescript@5.9.3) + zod: + specifier: ^4.2.0 + version: 4.3.6 packages/ai-vue-ui: dependencies: diff --git a/testing/e2e/fixtures/assistant/basic.json b/testing/e2e/fixtures/assistant/basic.json deleted file mode 100644 index 81e0fe911..000000000 --- a/testing/e2e/fixtures/assistant/basic.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "fixtures": [ - { - "match": { "userMessage": "[assistant] recommend a guitar" }, - "response": { - "content": "I'd recommend the Fender Stratocaster — versatile and comfortable." - } - } - ] -} diff --git a/testing/e2e/fixtures/transaction/basic.json b/testing/e2e/fixtures/transaction/basic.json new file mode 100644 index 000000000..cf4c974b9 --- /dev/null +++ b/testing/e2e/fixtures/transaction/basic.json @@ -0,0 +1,28 @@ +{ + "fixtures": [ + { + "match": { "userMessage": "[transaction] recommend a guitar" }, + "response": { + "content": "I'd recommend the Fender Stratocaster — versatile and comfortable." + } + }, + { + "match": { "userMessage": "[transaction] banner: solo banner" }, + "response": { + "content": "BANNER: solo banner" + } + }, + { + "match": { "userMessage": "[transaction] banner: hero guitars" }, + "response": { + "content": "BANNER: hero guitars" + } + }, + { + "match": { "userMessage": "[transaction] banner: thumb guitars" }, + "response": { + "content": "BANNER: thumb guitars" + } + } + ] +} diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 38862e26a..2f6b1c135 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -254,7 +254,7 @@ export const matrix: Record> = { // Only Gemini currently surfaces a first-class stateful conversation API via // the adapter (geminiTextInteractions, behind @tanstack/ai-gemini/experimental). 'stateful-interactions': new Set(['gemini']), - assistant: new Set(['openai']), + transaction: new Set(['openai']), } export function isSupported(provider: Provider, feature: Feature): boolean { diff --git a/testing/e2e/src/lib/features.ts b/testing/e2e/src/lib/features.ts index 947e0f72c..660923ff6 100644 --- a/testing/e2e/src/lib/features.ts +++ b/testing/e2e/src/lib/features.ts @@ -144,7 +144,7 @@ export const featureConfigs: Record = { tools: [], modelOptions: {}, }, - assistant: { + transaction: { tools: [], modelOptions: {}, }, diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index f148d3b74..691f632eb 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -44,7 +44,7 @@ export type Feature = | 'image-to-video' | 'interactions-video' | 'stateful-interactions' - | 'assistant' + | 'transaction' export const ALL_PROVIDERS: Provider[] = [ 'openai', @@ -91,5 +91,5 @@ export const ALL_FEATURES: Feature[] = [ 'image-to-video', 'interactions-video', 'stateful-interactions', - 'assistant', + 'transaction', ] diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 15d13759e..1cff5ca1d 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TransactionRouteImport } from './routes/transaction' import { Route as ToolsTestRouteImport } from './routes/tools-test' import { Route as MiddlewareTestRouteImport } from './routes/middleware-test' import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk' @@ -19,12 +20,12 @@ import { Route as DevtoolsRouteARouteImport } from './routes/devtools-route-a' import { Route as DevtoolsGenerationHooksRouteImport } from './routes/devtools-generation-hooks' import { Route as DevtoolsChatRouteImport } from './routes/devtools-chat' import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge' -import { Route as AssistantRouteImport } from './routes/assistant' import { Route as IndexRouteImport } from './routes/index' import { Route as ProviderIndexRouteImport } from './routes/$provider/index' import { Route as ApiVideoRouteImport } from './routes/api.video' import { Route as ApiTtsRouteImport } from './routes/api.tts' import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription' +import { Route as ApiTransactionRouteImport } from './routes/api.transaction' import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test' import { Route as ApiToolCallLifecycleWireRouteImport } from './routes/api.tool-call-lifecycle-wire' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' @@ -48,7 +49,6 @@ import { Route as ApiLazyToolsWireRouteImport } from './routes/api.lazy-tools-wi import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' -import { Route as ApiAssistantRouteImport } from './routes/api.assistant' import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire' import { Route as ApiAnthropicStructuredUsageRouteImport } from './routes/api.anthropic-structured-usage' import { Route as ApiAnthropicSkillsWireRouteImport } from './routes/api.anthropic-skills-wire' @@ -60,6 +60,11 @@ import { Route as ApiTranscriptionStreamRouteImport } from './routes/api.transcr import { Route as ApiImageStreamRouteImport } from './routes/api.image.stream' import { Route as ApiAudioStreamRouteImport } from './routes/api.audio.stream' +const TransactionRoute = TransactionRouteImport.update({ + id: '/transaction', + path: '/transaction', + getParentRoute: () => rootRouteImport, +} as any) const ToolsTestRoute = ToolsTestRouteImport.update({ id: '/tools-test', path: '/tools-test', @@ -110,11 +115,6 @@ const ChatClientDefaultBridgeRoute = ChatClientDefaultBridgeRouteImport.update({ path: '/chat-client-default-bridge', getParentRoute: () => rootRouteImport, } as any) -const AssistantRoute = AssistantRouteImport.update({ - id: '/assistant', - path: '/assistant', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -140,6 +140,11 @@ const ApiTranscriptionRoute = ApiTranscriptionRouteImport.update({ path: '/api/transcription', getParentRoute: () => rootRouteImport, } as any) +const ApiTransactionRoute = ApiTransactionRouteImport.update({ + id: '/api/transaction', + path: '/api/transaction', + getParentRoute: () => rootRouteImport, +} as any) const ApiToolsTestRoute = ApiToolsTestRouteImport.update({ id: '/api/tools-test', path: '/api/tools-test', @@ -259,11 +264,6 @@ const ApiAudioRoute = ApiAudioRouteImport.update({ path: '/api/audio', getParentRoute: () => rootRouteImport, } as any) -const ApiAssistantRoute = ApiAssistantRouteImport.update({ - id: '/api/assistant', - path: '/api/assistant', - getParentRoute: () => rootRouteImport, -} as any) const ApiArktypeToolWireRoute = ApiArktypeToolWireRouteImport.update({ id: '/api/arktype-tool-wire', path: '/api/arktype-tool-wire', @@ -318,7 +318,6 @@ const ApiAudioStreamRoute = ApiAudioStreamRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/assistant': typeof AssistantRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute @@ -329,12 +328,12 @@ export interface FileRoutesByFullPath { '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute '/tools-test': typeof ToolsTestRoute + '/transaction': typeof TransactionRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute - '/api/assistant': typeof ApiAssistantRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren @@ -358,6 +357,7 @@ export interface FileRoutesByFullPath { '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute + '/api/transaction': typeof ApiTransactionRoute '/api/transcription': typeof ApiTranscriptionRouteWithChildren '/api/tts': typeof ApiTtsRouteWithChildren '/api/video': typeof ApiVideoRouteWithChildren @@ -370,7 +370,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/assistant': typeof AssistantRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute @@ -381,12 +380,12 @@ export interface FileRoutesByTo { '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute '/tools-test': typeof ToolsTestRoute + '/transaction': typeof TransactionRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute - '/api/assistant': typeof ApiAssistantRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren @@ -410,6 +409,7 @@ export interface FileRoutesByTo { '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute + '/api/transaction': typeof ApiTransactionRoute '/api/transcription': typeof ApiTranscriptionRouteWithChildren '/api/tts': typeof ApiTtsRouteWithChildren '/api/video': typeof ApiVideoRouteWithChildren @@ -423,7 +423,6 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/assistant': typeof AssistantRoute '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute @@ -434,12 +433,12 @@ export interface FileRoutesById { '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute '/tools-test': typeof ToolsTestRoute + '/transaction': typeof TransactionRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute - '/api/assistant': typeof ApiAssistantRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute '/api/image': typeof ApiImageRouteWithChildren @@ -463,6 +462,7 @@ export interface FileRoutesById { '/api/summarize': typeof ApiSummarizeRoute '/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute '/api/tools-test': typeof ApiToolsTestRoute + '/api/transaction': typeof ApiTransactionRoute '/api/transcription': typeof ApiTranscriptionRouteWithChildren '/api/tts': typeof ApiTtsRouteWithChildren '/api/video': typeof ApiVideoRouteWithChildren @@ -477,7 +477,6 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/assistant' | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' @@ -488,12 +487,12 @@ export interface FileRouteTypes { | '/markdown-cjk' | '/middleware-test' | '/tools-test' + | '/transaction' | '/$provider/$feature' | '/api/anthropic-bug-test' | '/api/anthropic-skills-wire' | '/api/anthropic-structured-usage' | '/api/arktype-tool-wire' - | '/api/assistant' | '/api/audio' | '/api/chat' | '/api/image' @@ -517,6 +516,7 @@ export interface FileRouteTypes { | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' + | '/api/transaction' | '/api/transcription' | '/api/tts' | '/api/video' @@ -529,7 +529,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' - | '/assistant' | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' @@ -540,12 +539,12 @@ export interface FileRouteTypes { | '/markdown-cjk' | '/middleware-test' | '/tools-test' + | '/transaction' | '/$provider/$feature' | '/api/anthropic-bug-test' | '/api/anthropic-skills-wire' | '/api/anthropic-structured-usage' | '/api/arktype-tool-wire' - | '/api/assistant' | '/api/audio' | '/api/chat' | '/api/image' @@ -569,6 +568,7 @@ export interface FileRouteTypes { | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' + | '/api/transaction' | '/api/transcription' | '/api/tts' | '/api/video' @@ -581,7 +581,6 @@ export interface FileRouteTypes { id: | '__root__' | '/' - | '/assistant' | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' @@ -592,12 +591,12 @@ export interface FileRouteTypes { | '/markdown-cjk' | '/middleware-test' | '/tools-test' + | '/transaction' | '/$provider/$feature' | '/api/anthropic-bug-test' | '/api/anthropic-skills-wire' | '/api/anthropic-structured-usage' | '/api/arktype-tool-wire' - | '/api/assistant' | '/api/audio' | '/api/chat' | '/api/image' @@ -621,6 +620,7 @@ export interface FileRouteTypes { | '/api/summarize' | '/api/tool-call-lifecycle-wire' | '/api/tools-test' + | '/api/transaction' | '/api/transcription' | '/api/tts' | '/api/video' @@ -634,7 +634,6 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - AssistantRoute: typeof AssistantRoute ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute DevtoolsChatRoute: typeof DevtoolsChatRoute DevtoolsGenerationHooksRoute: typeof DevtoolsGenerationHooksRoute @@ -645,12 +644,12 @@ export interface RootRouteChildren { MarkdownCjkRoute: typeof MarkdownCjkRoute MiddlewareTestRoute: typeof MiddlewareTestRoute ToolsTestRoute: typeof ToolsTestRoute + TransactionRoute: typeof TransactionRoute ProviderFeatureRoute: typeof ProviderFeatureRoute ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute ApiAnthropicSkillsWireRoute: typeof ApiAnthropicSkillsWireRoute ApiAnthropicStructuredUsageRoute: typeof ApiAnthropicStructuredUsageRoute ApiArktypeToolWireRoute: typeof ApiArktypeToolWireRoute - ApiAssistantRoute: typeof ApiAssistantRoute ApiAudioRoute: typeof ApiAudioRouteWithChildren ApiChatRoute: typeof ApiChatRoute ApiImageRoute: typeof ApiImageRouteWithChildren @@ -674,6 +673,7 @@ export interface RootRouteChildren { ApiSummarizeRoute: typeof ApiSummarizeRoute ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute ApiToolsTestRoute: typeof ApiToolsTestRoute + ApiTransactionRoute: typeof ApiTransactionRoute ApiTranscriptionRoute: typeof ApiTranscriptionRouteWithChildren ApiTtsRoute: typeof ApiTtsRouteWithChildren ApiVideoRoute: typeof ApiVideoRouteWithChildren @@ -682,6 +682,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/transaction': { + id: '/transaction' + path: '/transaction' + fullPath: '/transaction' + preLoaderRoute: typeof TransactionRouteImport + parentRoute: typeof rootRouteImport + } '/tools-test': { id: '/tools-test' path: '/tools-test' @@ -752,13 +759,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatClientDefaultBridgeRouteImport parentRoute: typeof rootRouteImport } - '/assistant': { - id: '/assistant' - path: '/assistant' - fullPath: '/assistant' - preLoaderRoute: typeof AssistantRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -794,6 +794,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTranscriptionRouteImport parentRoute: typeof rootRouteImport } + '/api/transaction': { + id: '/api/transaction' + path: '/api/transaction' + fullPath: '/api/transaction' + preLoaderRoute: typeof ApiTransactionRouteImport + parentRoute: typeof rootRouteImport + } '/api/tools-test': { id: '/api/tools-test' path: '/api/tools-test' @@ -955,13 +962,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAudioRouteImport parentRoute: typeof rootRouteImport } - '/api/assistant': { - id: '/api/assistant' - path: '/api/assistant' - fullPath: '/api/assistant' - preLoaderRoute: typeof ApiAssistantRouteImport - parentRoute: typeof rootRouteImport - } '/api/arktype-tool-wire': { id: '/api/arktype-tool-wire' path: '/api/arktype-tool-wire' @@ -1095,7 +1095,6 @@ const ApiVideoRouteWithChildren = ApiVideoRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - AssistantRoute: AssistantRoute, ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute, DevtoolsChatRoute: DevtoolsChatRoute, DevtoolsGenerationHooksRoute: DevtoolsGenerationHooksRoute, @@ -1106,12 +1105,12 @@ const rootRouteChildren: RootRouteChildren = { MarkdownCjkRoute: MarkdownCjkRoute, MiddlewareTestRoute: MiddlewareTestRoute, ToolsTestRoute: ToolsTestRoute, + TransactionRoute: TransactionRoute, ProviderFeatureRoute: ProviderFeatureRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, ApiAnthropicSkillsWireRoute: ApiAnthropicSkillsWireRoute, ApiAnthropicStructuredUsageRoute: ApiAnthropicStructuredUsageRoute, ApiArktypeToolWireRoute: ApiArktypeToolWireRoute, - ApiAssistantRoute: ApiAssistantRoute, ApiAudioRoute: ApiAudioRouteWithChildren, ApiChatRoute: ApiChatRoute, ApiImageRoute: ApiImageRouteWithChildren, @@ -1135,6 +1134,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSummarizeRoute: ApiSummarizeRoute, ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute, ApiToolsTestRoute: ApiToolsTestRoute, + ApiTransactionRoute: ApiTransactionRoute, ApiTranscriptionRoute: ApiTranscriptionRouteWithChildren, ApiTtsRoute: ApiTtsRouteWithChildren, ApiVideoRoute: ApiVideoRouteWithChildren, diff --git a/testing/e2e/src/routes/api.assistant.ts b/testing/e2e/src/routes/api.assistant.ts deleted file mode 100644 index 02bf054cd..000000000 --- a/testing/e2e/src/routes/api.assistant.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { chat, generateImage, maxIterations } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import type { Provider } from '@/lib/types' -import { createTextAdapter } from '@/lib/providers' -import { createImageAdapter } from '@/lib/media-providers' - -export const Route = createFileRoute('/api/assistant')({ - server: { - handlers: { - POST: async ({ request }) => { - await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) - - // `assistant.handler(request)` below consumes the body itself via - // `request.json()`, so peek at a clone here to pull the - // provider/testId/aimockPort needed to construct adapters, and pass - // the original (unconsumed) request through to the handler. - const peekBody = await request.clone().json() - const peekData = peekBody.forwardedProps ?? peekBody.data ?? peekBody - const { provider, testId, aimockPort } = peekData as { - provider: Provider - testId?: string - aimockPort?: number - } - - const assistant = defineAssistant({ - chat: (req) => - chat({ - ...createTextAdapter( - provider, - undefined, - aimockPort, - testId, - 'assistant', - ), - messages: req.messages, - agentLoopStrategy: maxIterations(5), - threadId: req.threadId, - runId: req.runId, - }), - image: (req) => - generateImage({ - adapter: createImageAdapter(provider, aimockPort, testId), - prompt: req.prompt as string, - }), - }) - - return assistant.handler(request) - }, - }, - }, -}) diff --git a/testing/e2e/src/routes/api.transaction.ts b/testing/e2e/src/routes/api.transaction.ts new file mode 100644 index 000000000..3dc95a219 --- /dev/null +++ b/testing/e2e/src/routes/api.transaction.ts @@ -0,0 +1,116 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, maxIterations } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { z } from 'zod' +import type { Provider } from '@/lib/types' +import { createTextAdapter } from '@/lib/providers' + +/** + * Drain a ChatStream and accumulate the assistant text. Used by the one-shot + * `banner` verb below to turn a (mocked, deterministic) chat completion into + * a plain result value. + */ +async function collectText(stream: AsyncIterable): Promise { + let text = '' + for await (const chunk of stream) { + if ( + chunk != null && + typeof chunk === 'object' && + 'type' in chunk && + chunk.type === 'TEXT_MESSAGE_CONTENT' && + 'delta' in chunk && + typeof chunk.delta === 'string' + ) { + text += chunk.delta + } + } + return text +} + +export const Route = createFileRoute('/api/transaction')({ + server: { + handlers: { + POST: async ({ request }) => { + await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) + + // `transaction.handler(request)` below consumes the body itself via + // `request.json()`, so peek at a clone here to pull the + // provider/testId/aimockPort needed to construct adapters, and pass + // the original (unconsumed) request through to the handler. + const peekBody = await request.clone().json() + const peekData = peekBody.forwardedProps ?? peekBody.data ?? peekBody + const { provider, testId, aimockPort } = peekData as { + provider: Provider + testId?: string + aimockPort?: number + } + + const textOptions = () => + createTextAdapter( + provider, + undefined, + aimockPort, + testId, + 'transaction', + ) + + // One-shot verb: schema-validated input in, result out. Runs a + // single deterministic chat completion against aimock (the fixture + // keys on the exact `[transaction] banner: …` user message). + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async (req) => { + const text = await collectText( + chat({ + ...textOptions(), + messages: [ + { + role: 'user', + content: `[transaction] banner: ${req.input.prompt}`, + }, + ], + agentLoopStrategy: maxIterations(1), + threadId: req.threadId, + runId: req.runId, + }), + ) + return { prompt: req.input.prompt, text } + }, + }) + + const transaction = defineTransaction({ + // Conversational verb: full chat surface on the client. + primaryChat: chatVerb((req) => + chat({ + ...textOptions(), + messages: req.messages, + agentLoopStrategy: maxIterations(5), + threadId: req.threadId, + runId: req.runId, + }), + ), + banner, + // Composing verb: runs the sibling `banner` verb twice via + // `ctx.call`, so the client observes two tagged sub-runs + // (transaction:sub-run:* CUSTOM events) inside one SSE response. + bannerPair: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + // Sequential so sub-run start order (hero=0, thumb=1) is + // deterministic for the spec's per-index assertions. + const hero = await ctx.call(banner, { + prompt: `hero ${input.topic}`, + }) + const thumb = await ctx.call(banner, { + prompt: `thumb ${input.topic}`, + }) + return { hero, thumb } + }, + }), + }) + + return transaction.handler(request) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/assistant.tsx b/testing/e2e/src/routes/assistant.tsx deleted file mode 100644 index 963b0a5e9..000000000 --- a/testing/e2e/src/routes/assistant.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { chat, generateImage, maxIterations } from '@tanstack/ai' -import { defineAssistant } from '@tanstack/ai/assistant' -import { fetchServerSentEvents } from '@tanstack/ai-react' -import { useAssistant } from '@tanstack/ai-react/assistant' -import { openaiImage, openaiText } from '@tanstack/ai-openai' -import { ChatUI } from '@/components/ChatUI' -import type { Provider } from '@/lib/types' - -export interface AssistantRouteSearch { - provider: Provider - testId?: string - aimockPort?: number -} - -const DEFAULT_PROVIDER: Provider = 'openai' - -function parseAssistantRouteSearch( - search: Record, -): AssistantRouteSearch { - const aimockPort = - typeof search.aimockPort === 'string' - ? Number.parseInt(search.aimockPort, 10) - : undefined - const provider = - typeof search.provider === 'string' - ? (search.provider as Provider) - : DEFAULT_PROVIDER - - return { - provider, - ...(typeof search.testId === 'string' ? { testId: search.testId } : {}), - ...(aimockPort !== undefined && !Number.isNaN(aimockPort) - ? { aimockPort } - : {}), - } -} - -export const Route = createFileRoute('/assistant')({ - component: AssistantRoute, - validateSearch: parseAssistantRouteSearch, -}) - -// Client-side assistant definition. `defineAssistant` is inert — none of -// these callbacks ever run in the browser. `useAssistant` only reads the -// declared capability names (and their types) off this value to build the -// typed client system; the actual chat/image requests are always sent to -// the server route `/api/assistant`, which defines its own (real) callbacks. -// Mirroring the server route's two capabilities (chat + image) here just -// keeps the client types in sync with what the server actually supports. -// -// Uses the single-provider openai adapters directly (not the multi-provider -// `@/lib/providers` / `@/lib/media-providers` factories) so the browser -// bundle doesn't pull in every provider SDK (e.g. ollama's `node:fs`) — the -// callbacks are inert on the client, so only their return TYPES matter. -const assistant = defineAssistant({ - chat: (req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - agentLoopStrategy: maxIterations(5), - threadId: req.threadId, - runId: req.runId, - }), - image: (req) => - generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: req.prompt as string, - }), -}) - -function AssistantRoute() { - const { provider, testId, aimockPort } = Route.useSearch() - - const system = useAssistant(assistant, { - connection: fetchServerSentEvents('/api/assistant'), - chat: { forwardedProps: { provider, testId, aimockPort } }, - }) - - // The image one-shot's `GenerationClient` (unlike the chat sub-client) has - // no `forwardedProps` option on `useAssistant` — its request body is fixed - // to `{ capability: 'image' }` by `AssistantClient`. So the routing - // metadata (provider/testId/aimockPort) the server needs rides along on - // the `generate()` call's input instead: `GenerationClient.generate` merges - // its input directly into the wire `forwardedProps`, and the server route - // reads `provider`/`testId`/`aimockPort` back out of `forwardedProps`. - // Declared as a variable (not an inline literal) so these extra fields - // don't trip `ImageGenerateInput`'s excess-property check. - const imageGenerateInput = { - prompt: '[assistant] a fox', - provider, - testId, - aimockPort, - } - - return ( -
-
- - - {system.image.result?.images[0]?.url ?? ''} - -
-
- { - system.chat.sendMessage(text) - }} - onStop={system.chat.stop} - /> -
-
- ) -} diff --git a/testing/e2e/src/routes/transaction.tsx b/testing/e2e/src/routes/transaction.tsx new file mode 100644 index 000000000..55bee158c --- /dev/null +++ b/testing/e2e/src/routes/transaction.tsx @@ -0,0 +1,179 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, maxIterations } from '@tanstack/ai' +import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { fetchServerSentEvents } from '@tanstack/ai-react' +import { useTransaction } from '@tanstack/ai-react/transaction' +import { openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' +import { ChatUI } from '@/components/ChatUI' +import type { Provider } from '@/lib/types' + +export interface TransactionRouteSearch { + provider: Provider + testId?: string + aimockPort?: number +} + +const DEFAULT_PROVIDER: Provider = 'openai' + +function parseTransactionRouteSearch( + search: Record, +): TransactionRouteSearch { + const aimockPort = + typeof search.aimockPort === 'string' + ? Number.parseInt(search.aimockPort, 10) + : undefined + const provider = + typeof search.provider === 'string' + ? (search.provider as Provider) + : DEFAULT_PROVIDER + + return { + provider, + ...(typeof search.testId === 'string' ? { testId: search.testId } : {}), + ...(aimockPort !== undefined && !Number.isNaN(aimockPort) + ? { aimockPort } + : {}), + } +} + +export const Route = createFileRoute('/transaction')({ + component: TransactionRoute, + validateSearch: parseTransactionRouteSearch, +}) + +// Client-side transaction definition. `defineTransaction` is inert — none of +// these callbacks ever run in the browser. `useTransaction` only reads the +// declared verb names (and their kinds/types) off this value to build the +// typed client system; the actual requests are always sent to the server +// route `/api/transaction`, which defines its own (real) verbs. Mirroring +// the server route's three verbs (primaryChat + banner + bannerPair) here +// just keeps the client types in sync with what the server actually runs. +// +// Uses the single-provider openai adapter directly (not the multi-provider +// `@/lib/providers` factory) so the browser bundle doesn't pull in every +// provider SDK (e.g. ollama's `node:fs`) — the callbacks are inert on the +// client, so only their input/result TYPES matter. +const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ prompt: input.prompt, text: '' }), +}) + +const transaction = defineTransaction({ + primaryChat: chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + agentLoopStrategy: maxIterations(5), + threadId: req.threadId, + runId: req.runId, + }), + ), + banner, + bannerPair: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const hero = await ctx.call(banner, { prompt: `hero ${input.topic}` }) + const thumb = await ctx.call(banner, { prompt: `thumb ${input.topic}` }) + return { hero, thumb } + }, + }), +}) + +function TransactionRoute() { + const { provider, testId, aimockPort } = Route.useSearch() + + // Routing metadata (provider/testId/aimockPort) rides along in every + // verb's forwardedProps; the server route peeks it to build adapters and + // the one-shot verbs' zod schemas simply strip the extra fields. + const routing = { provider, testId, aimockPort } + + const txn = useTransaction(transaction, { + connection: fetchServerSentEvents('/api/transaction'), + verbs: { + primaryChat: { forwardedProps: routing }, + banner: { forwardedProps: routing }, + bannerPair: { forwardedProps: routing }, + }, + }) + + return ( +
+
+
+ + + {txn.banner.status} + + + {txn.banner.result?.text ?? ''} + +
+
+ + + {txn.bannerPair.status} + + + {txn.bannerPair.result + ? `${txn.bannerPair.result.hero.text} + ${txn.bannerPair.result.thumb.text}` + : ''} + +
+
    + {txn.bannerPair.subRuns.map((sub) => ( +
  • + {sub.verb}:{sub.status} +
  • + ))} +
+
+
+ { + void txn.primaryChat.sendMessage(text) + }} + onStop={txn.primaryChat.stop} + /> +
+
+ ) +} diff --git a/testing/e2e/tests/assistant.spec.ts b/testing/e2e/tests/assistant.spec.ts deleted file mode 100644 index 46797116c..000000000 --- a/testing/e2e/tests/assistant.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { test, expect } from './fixtures' -import { - getLastAssistantMessage, - sendMessage, - waitForResponse, -} from './helpers' -import { providersFor } from './test-matrix' - -// The assistant feature has its own dedicated page (`/assistant`) that drives -// `useAssistant`, which posts to the `/api/assistant` handler — NOT the generic -// matrix page (`/$provider/$feature` → `/api/chat`). So we navigate directly -// rather than via `featureUrl`, exercising the real defineAssistant/useAssistant -// path end to end. -function assistantUrl(provider: string, testId: string, aimockPort: number) { - const params = new URLSearchParams({ - provider, - testId, - aimockPort: String(aimockPort), - }) - return `/assistant?${params.toString()}` -} - -for (const provider of providersFor('assistant')) { - test.describe(`${provider} — assistant`, () => { - test('chat capability streams through useAssistant + the assistant handler', async ({ - page, - testId, - aimockPort, - }) => { - await page.goto(assistantUrl(provider, testId, aimockPort)) - - await sendMessage(page, '[assistant] recommend a guitar') - await waitForResponse(page) - - const response = await getLastAssistantMessage(page) - expect(response).toContain('Fender Stratocaster') - }) - - // TODO(assistant image e2e): the click + one-shot dispatch through - // `/api/assistant` (capability=image) fires correctly, but aimock returns - // no image body — image generation is mocked programmatically via - // `registerMediaFixtures` (`match.endpoint`), not the userMessage-keyed - // fixtures used for chat, so the assistant image request has no registered - // response. The one-shot path is already covered by unit tests - // (`@tanstack/ai`: handler emits a `generation:result` CUSTOM event; - // `@tanstack/ai-react`: `system.image.generate` populates `result`). - // Un-skip once an assistant image media fixture is registered in global-setup. - test.skip('image one-shot leg runs through the assistant handler', async ({ - page, - testId, - aimockPort, - }) => { - await page.goto(assistantUrl(provider, testId, aimockPort)) - - await page.getByTestId('assistant-generate-image').click() - - await expect(page.getByTestId('assistant-image-result')).not.toBeEmpty({ - timeout: 15_000, - }) - }) - }) -} diff --git a/testing/e2e/tests/transaction.spec.ts b/testing/e2e/tests/transaction.spec.ts new file mode 100644 index 000000000..bd0646960 --- /dev/null +++ b/testing/e2e/tests/transaction.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from './fixtures' +import type { Page } from '@playwright/test' +import { + getLastAssistantMessage, + sendMessage, + waitForResponse, +} from './helpers' +import { providersFor } from './test-matrix' + +// The transaction feature has its own dedicated page (`/transaction`) that +// drives `useTransaction`, which posts to the `/api/transaction` handler — +// NOT the generic matrix page (`/$provider/$feature` → `/api/chat`). So we +// navigate directly rather than via `featureUrl`, exercising the real +// defineTransaction/useTransaction path end to end. +function transactionUrl(provider: string, testId: string, aimockPort: number) { + const params = new URLSearchParams({ + provider, + testId, + aimockPort: String(aimockPort), + }) + return `/transaction?${params.toString()}` +} + +/** + * Click a run button once the page is hydrated. A click that lands before + * React hydration is a silent no-op, so verify the verb's status left + * 'idle' and retry once if it didn't (mirrors `clickGenerate` in helpers). + */ +async function clickRun( + page: Page, + buttonTestId: string, + statusTestId: string, +) { + await page.waitForLoadState('networkidle') + const btn = page.getByTestId(buttonTestId) + await btn.click() + try { + await expect(page.getByTestId(statusTestId)).not.toHaveText('idle', { + timeout: 3_000, + }) + } catch { + // Retry click — hydration likely wasn't complete on first attempt + await btn.click() + } +} + +for (const provider of providersFor('transaction')) { + test.describe(`${provider} — transaction`, () => { + test('chat verb streams through useTransaction + the transaction handler', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto(transactionUrl(provider, testId, aimockPort)) + + await sendMessage(page, '[transaction] recommend a guitar') + await waitForResponse(page) + + const response = await getLastAssistantMessage(page) + expect(response).toContain('Fender Stratocaster') + }) + + test('one-shot verb run() renders its result', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto(transactionUrl(provider, testId, aimockPort)) + + await clickRun( + page, + 'transaction-run-banner', + 'transaction-banner-status', + ) + + await expect(page.getByTestId('transaction-banner-result')).toHaveText( + 'BANNER: solo banner', + { timeout: 15_000 }, + ) + }) + + test('composing verb streams two sub-runs and a final result', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto(transactionUrl(provider, testId, aimockPort)) + + await clickRun( + page, + 'transaction-run-banner-pair', + 'transaction-banner-pair-status', + ) + + // Two `ctx.call(banner, …)` sub-runs, tagged in start order, both + // reaching success. + const subRuns = page.locator('[data-testid^="transaction-sub-run-"]') + await expect(subRuns).toHaveCount(2, { timeout: 15_000 }) + for (const index of [0, 1]) { + const subRun = page.getByTestId(`transaction-sub-run-${index}`) + await expect(subRun).toHaveAttribute('data-verb', 'banner') + await expect(subRun).toHaveAttribute('data-status', 'success', { + timeout: 15_000, + }) + } + + // The composing verb's own return value arrives as the final + // generation:result and lands on `txn.bannerPair.result`. + await expect( + page.getByTestId('transaction-banner-pair-result'), + ).toHaveText('BANNER: hero guitars + BANNER: thumb guitars', { + timeout: 15_000, + }) + await expect( + page.getByTestId('transaction-banner-pair-status'), + ).toHaveText('success') + }) + }) +} From eaec27e1e4d6fb36f6c014d8077d8d539cb5fe88 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 14 Jul 2026 19:55:55 -0700 Subject: [PATCH 2/9] fix(transaction): stream structured sub-run output as live parsed partials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blog-studio drafting step blocked until completion instead of streaming. When orchestration moved server-side into a transaction, the drafting chat became a sub-run whose demux accumulated raw text but never parsed the structured-output JSON — so the client only had partial JSON (unrenderable) and the final result, nothing in between. Regression vs. the original, where drafting went through ChatClient and got live partial parsing. - TransactionClient demux now runs parsePartialJSON on each streamed chat sub-run chunk, exposing a live `partial` on TransactionSubRun and snapping it to the validated object on structured-output.complete. Prose yields undefined, so plain chat verbs are unaffected. New `partial?: unknown` field. - Coalesce the reactive notify for streamed text deltas to one per 12 chunks (mirroring the core StreamProcessor's structured-output batch size), so consumers rendering live Markdown re-render at a bounded rate instead of once per token. Internal sub-run state still updates every chunk, so getSubRuns() stays exact; status changes and completion flush immediately. - blog-studio renders the drafting body as live-building Markdown again (matching the original), sourced from the sub-run's parsed `partial`. - Add demux unit tests: structured sub-run → live parsed partial + terminal snap; prose sub-run leaves partial unset. Verified live: the article body streams in as formatted Markdown (headings and paragraphs growing mid-stream, no raw ##, no main-thread stall) while the hero image and voice-over finish in parallel. Co-Authored-By: Claude Fable 5 --- .../ts-react-chat/src/routes/blog-studio.tsx | 73 +++++++++++++---- packages/ai-client/src/transaction-client.ts | 58 ++++++++++++- packages/ai-client/src/transaction-types.ts | 9 ++ .../tests/transaction-client.test.ts | 82 +++++++++++++++++++ 4 files changed, 205 insertions(+), 17 deletions(-) diff --git a/examples/ts-react-chat/src/routes/blog-studio.tsx b/examples/ts-react-chat/src/routes/blog-studio.tsx index 1fd0147e2..3ef1abe71 100644 --- a/examples/ts-react-chat/src/routes/blog-studio.tsx +++ b/examples/ts-react-chat/src/routes/blog-studio.tsx @@ -1,4 +1,3 @@ -import type { FormEvent, ReactNode } from 'react' import { createFileRoute } from '@tanstack/react-router' import { chat, generateImage, generateSpeech } from '@tanstack/ai' import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' @@ -20,8 +19,9 @@ import { Volume2, Wand2, } from 'lucide-react' -import type { ImageGenerationResult, TTSResult } from '@tanstack/ai' import { BlogPostSchema, forNarration, heroPromptFor } from './api.blog-studio' +import type { ImageGenerationResult, TTSResult } from '@tanstack/ai' +import type { FormEvent, ReactNode } from 'react' // Client-side transaction definition. `defineTransaction` is INERT in the // browser — these callbacks never run; `useTransaction` only reads the @@ -119,6 +119,28 @@ function audioSrcOf(result: TTSResult | null): string | null { return `data:${result.contentType ?? 'audio/mpeg'};base64,${result.audio}` } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +// The drafting sub-run's live `partial` is typed `unknown` (progressively +// parsed structured output). Narrow it to the blog-post fields without an +// `as` cast — each field may be absent or half-written while streaming. +function asBlogPostDraft(partial: unknown): { + title?: string + subtitle?: string + body?: string +} { + if (!isRecord(partial)) return {} + const str = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined + return { + title: str(partial.title), + subtitle: str(partial.subtitle), + body: str(partial.body), + } +} + function BlogStudio() { const txn = useTransaction(blogTransaction, { connection: fetchServerSentEvents('/api/blog-studio'), @@ -150,11 +172,20 @@ function BlogStudio() { const isRunning = blogPost.isLoading const hasRun = blogPost.status !== 'idle' - // While drafting streams, its `text` is partial JSON (the structured - // output in flight) — don't render it; show progress by length instead. - const draftedChars = draftingRun?.text.length ?? 0 + // While the `drafting` chat sub-run streams, the demux progressively parses + // its structured output into `partial` — a live `{ title?, subtitle?, body? }` + // that fills in as the JSON arrives. Render it directly for a streaming + // preview before the transaction finishes and `blogPost.result` lands. + const liveDraft = asBlogPostDraft(draftingRun?.partial) + const draftedChars = liveDraft.body?.length ?? 0 const writingStep = subRunToStep(draftingRun?.status) + // Prefer the finished post; fall back to the live streaming draft. + const shownTitle = post?.title ?? liveDraft.title + const shownSubtitle = post?.subtitle ?? liveDraft.subtitle + const shownBody = post?.body ?? liveDraft.body + const showArticle = Boolean(shownTitle || shownBody) + const heroBusy = heroRerun.isLoading || heroRun?.status === 'running' const heroFailed = heroRerun.status === 'error' || heroRun?.status === 'error' const narrationBusy = @@ -315,16 +346,18 @@ function BlogStudio() { )} - {/* Right: the post */} + {/* Right: the post. Appears as soon as the drafting sub-run streams a + title/body — the body fills in live — then the hero image and + voice-over slot in as their sub-runs finish. */}
- {post ? ( + {showArticle ? (
{/* Hero image */}
{imageUrl && !heroBusy ? ( {post.title} ) : ( @@ -349,10 +382,16 @@ function BlogStudio() {
-

- {post.title} -

-

{post.subtitle}

+ {shownTitle ? ( +

+ {shownTitle} +

+ ) : ( +
+ )} + {shownSubtitle && ( +

{shownSubtitle}

+ )} {/* Byline + voice-over */}
@@ -378,11 +417,17 @@ function BlogStudio() { ) : null}
- {/* Body */} + {/* Body — rendered as Markdown live while the draft streams in. + The client batches the streamed structured-output updates + (see TransactionClient), so this re-parses at a bounded rate + rather than once per token. */}
- {post.body} + {shownBody ?? ''} + {writingStep === 'active' && ( + + )}
diff --git a/packages/ai-client/src/transaction-client.ts b/packages/ai-client/src/transaction-client.ts index 47175cb47..e57bd32fa 100644 --- a/packages/ai-client/src/transaction-client.ts +++ b/packages/ai-client/src/transaction-client.ts @@ -1,3 +1,4 @@ +import { parsePartialJSON } from '@tanstack/ai' import { ChatClient } from './chat-client.js' import { GenerationClient } from './generation-client.js' import type { StreamChunk } from '@tanstack/ai' @@ -17,6 +18,17 @@ const SUB_RUN_EVENTS = { ERROR: 'transaction:sub-run:error', } as const +/** + * Coalesce reactive `notify`s for streamed text deltas to one per this many + * chunks, so consumers rendering the live `partial`/`text` (e.g. Markdown) + * re-render at a bounded rate rather than once per token. The internal + * sub-run state is still updated on every chunk; only the reactive + * notification is batched. Status transitions and completion always flush + * immediately. Mirrors the core StreamProcessor's structured-output batch + * size so the transaction path streams as smoothly as the chat path. + */ +const SUB_RUN_TEXT_NOTIFY_BATCH = 12 + /** * Composes one `ChatClient` per chat verb and one `GenerationClient` per * one-shot verb declared on a `TransactionDefinition`, sharing the single @@ -40,6 +52,8 @@ export class TransactionClient< GenerationClient >() private readonly subRuns = new Map>() + /** Per-sub-run streamed-text-chunk counter, for batching reactive notifies. */ + private readonly subRunChunkCounts = new Map() readonly verbs: ReadonlyArray constructor(options: TransactionClientOptions) { @@ -125,6 +139,7 @@ export class TransactionClient< const type = (chunk as { type?: string }).type if (type === 'RUN_STARTED') { this.subRuns.set(verbName, []) + this.subRunChunkCounts.clear() notify?.([]) return } @@ -137,7 +152,12 @@ export class TransactionClient< index?: number result?: unknown message?: string - chunk?: { type?: string; delta?: unknown } + chunk?: { + type?: string + delta?: unknown + name?: string + value?: { object?: unknown } + } } } if (!name || !name.startsWith('transaction:sub-run:') || !value) return @@ -147,6 +167,12 @@ export class TransactionClient< const at = next.findIndex((s) => s.runId === value.runId) const existing = at === -1 ? undefined : next[at] + // Streamed text deltas fire per token; coalesce their reactive notifies so + // consumers re-render at a bounded rate. Every other event (status change, + // completion) flushes immediately. Internal state updates on every chunk + // regardless, so `getSubRuns()` is always current. + let shouldNotify = true + if (name === SUB_RUN_EVENTS.STARTED) { next.push({ runId: value.runId ?? '', @@ -164,7 +190,33 @@ export class TransactionClient< inner?.type === 'TEXT_MESSAGE_CONTENT' && typeof inner.delta === 'string' ) { - next[at] = { ...existing, text: existing.text + inner.delta } + // Accumulate the streamed text. For a structured-output chat verb + // these deltas are the output's JSON; attempt a partial parse so a + // live object streams into `partial`. Plain prose yields `undefined` + // (parsePartialJSON only produces object-shaped values), so `partial` + // stays unset for non-structured verbs. + const text = existing.text + inner.delta + const parsed = parsePartialJSON(text) + next[at] = { + ...existing, + text, + ...(parsed != null && typeof parsed === 'object' + ? { partial: parsed } + : {}), + } + // Notify only on batch boundaries; the internal map is still updated. + const runId = value.runId ?? '' + const count = (this.subRunChunkCounts.get(runId) ?? 0) + 1 + this.subRunChunkCounts.set(runId, count) + shouldNotify = count % SUB_RUN_TEXT_NOTIFY_BATCH === 0 + } else if ( + inner?.type === 'CUSTOM' && + inner.name === 'structured-output.complete' + ) { + // Snap `partial` to the fully-parsed, schema-validated object. + const object = inner.value?.object + next[at] = + object !== undefined ? { ...existing, partial: object } : existing } else { return } @@ -181,7 +233,7 @@ export class TransactionClient< } this.subRuns.set(verbName, next) - notify?.(next) + if (shouldNotify) notify?.(next) } /** Tears down every chat and one-shot sub-client. */ diff --git a/packages/ai-client/src/transaction-types.ts b/packages/ai-client/src/transaction-types.ts index 4c8366e18..114ada01d 100644 --- a/packages/ai-client/src/transaction-types.ts +++ b/packages/ai-client/src/transaction-types.ts @@ -41,6 +41,15 @@ export interface TransactionSubRun { result: unknown /** Accumulated streamed text, for chat-verb sub-runs. */ text: string + /** + * Live, progressively-parsed structured output for a chat-verb sub-run + * whose callback declared an `outputSchema` — the partial object built from + * the streamed JSON as it arrives, snapped to the fully-validated object on + * completion. `undefined` for plain-text chat verbs and one-shot verbs + * (whose streamed `text`, if any, is prose, not JSON). Typed `unknown`; + * narrow it before use. + */ + partial?: unknown error?: string } diff --git a/packages/ai-client/tests/transaction-client.test.ts b/packages/ai-client/tests/transaction-client.test.ts index 5ad0373de..f4fa79dec 100644 --- a/packages/ai-client/tests/transaction-client.test.ts +++ b/packages/ai-client/tests/transaction-client.test.ts @@ -190,6 +190,88 @@ describe('TransactionClient', () => { error: 'nope', }) }) + + it('parses a structured chat sub-run into a live `partial`', () => { + const txn = defineTransaction({ + blogPost: verb({ execute: async () => ({}) }), + }) + const client = new TransactionClient({ + transaction: txn, + connection: fetchServerSentEvents('/api/transaction'), + }) + const onChunk = (chunk: any) => + (client as any).handleSubRunChunk('blogPost', chunk, undefined) + const chunkEvent = (inner: any) => + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:chunk', + value: { runId: 'r1-sub-0', verb: 'drafting', index: 0, chunk: inner }, + }) + + onChunk({ type: 'RUN_STARTED', runId: 'r1', threadId: 't1' }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r1-sub-0', parentRunId: 'r1', verb: 'drafting', index: 0 }, + }) + + // The structured output streams as partial JSON via TEXT_MESSAGE_CONTENT. + chunkEvent({ type: 'TEXT_MESSAGE_CONTENT', delta: '{"title":"Sour' }) + expect(client.getSubRuns('blogPost')[0]?.partial).toEqual({ title: 'Sour' }) + + chunkEvent({ + type: 'TEXT_MESSAGE_CONTENT', + delta: 'dough","body":"Once upon', + }) + expect(client.getSubRuns('blogPost')[0]?.partial).toEqual({ + title: 'Sourdough', + body: 'Once upon', + }) + + // The terminal event snaps `partial` to the fully-validated object. + chunkEvent({ + type: 'CUSTOM', + name: 'structured-output.complete', + value: { object: { title: 'Sourdough', body: 'Once upon a loaf.' } }, + }) + expect(client.getSubRuns('blogPost')[0]?.partial).toEqual({ + title: 'Sourdough', + body: 'Once upon a loaf.', + }) + }) + + it('leaves `partial` unset for a plain-text (prose) chat sub-run', () => { + const txn = defineTransaction({ + blogPost: verb({ execute: async () => ({}) }), + }) + const client = new TransactionClient({ + transaction: txn, + connection: fetchServerSentEvents('/api/transaction'), + }) + const onChunk = (chunk: any) => + (client as any).handleSubRunChunk('blogPost', chunk, undefined) + + onChunk({ type: 'RUN_STARTED', runId: 'r1', threadId: 't1' }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:started', + value: { runId: 'r1-sub-0', parentRunId: 'r1', verb: 'support', index: 0 }, + }) + onChunk({ + type: 'CUSTOM', + name: 'transaction:sub-run:chunk', + value: { + runId: 'r1-sub-0', + verb: 'support', + index: 0, + chunk: { type: 'TEXT_MESSAGE_CONTENT', delta: 'Hello, how can I help?' }, + }, + }) + + const sub = client.getSubRuns('blogPost')[0] + expect(sub?.text).toBe('Hello, how can I help?') + expect(sub?.partial).toBeUndefined() + }) }) describe('TransactionSystem typing', () => { From ae5b0aa85bee82aa5c43a77a2f1498c8196e3784 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 14 Jul 2026 20:15:43 -0700 Subject: [PATCH 3/9] feat(transaction): add clientTransaction for typed clients without verb mirrors clientTransaction binds useTransaction to a server defineTransaction via import type, so the browser only declares verb names/kinds instead of duplicating inert callbacks. Consolidates the blog-studio example into a shared lib module and updates docs, skill, and e2e coverage. Co-authored-by: Cursor --- .changeset/transaction-verbs.md | 2 +- docs/config.json | 6 +- docs/transaction/overview.md | 56 +++--- docs/transaction/transactions.md | 61 ++----- examples/ts-react-chat/src/lib/blog-studio.ts | 163 ++++++++++++++++++ .../src/routes/api.blog-studio.ts | 161 +---------------- .../ts-react-chat/src/routes/blog-studio.tsx | 68 +------- .../ai/skills/ai-core/transaction/SKILL.md | 30 +++- .../ai/src/activities/transaction/index.ts | 35 ++++ .../ai/src/activities/transaction/types.ts | 9 + packages/ai/src/transaction.ts | 2 + .../activities/transaction/index.test.ts | 92 +++++++++- testing/e2e/src/routes/api.transaction.ts | 118 +++++++------ testing/e2e/src/routes/transaction.tsx | 50 +----- 14 files changed, 443 insertions(+), 410 deletions(-) create mode 100644 examples/ts-react-chat/src/lib/blog-studio.ts diff --git a/.changeset/transaction-verbs.md b/.changeset/transaction-verbs.md index 5c211ca91..18e39df9a 100644 --- a/.changeset/transaction-verbs.md +++ b/.changeset/transaction-verbs.md @@ -7,4 +7,4 @@ '@tanstack/ai-svelte': minor --- -Add defineTransaction (server) and useTransaction/createTransaction (client): declare app-defined verbs — chatVerb for conversational surfaces, verb for one-shot work with schema-validated inputs — behind one endpoint and drive them from one fully typed client hook. A verb's execute can compose sibling verbs via ctx.call into a server-composed transaction: one request runs the whole pipeline, every sub-run streams back live, and a single stop()/disconnect aborts everything. +Add defineTransaction (server) and useTransaction/createTransaction (client): declare app-defined verbs — chatVerb for conversational surfaces, verb for one-shot work with schema-validated inputs — behind one endpoint and drive them from one fully typed client hook. A verb's execute can compose sibling verbs via ctx.call into a server-composed transaction: one request runs the whole pipeline, every sub-run streams back live, and a single stop()/disconnect aborts everything. clientTransaction binds a type-only client stub to a server definition without mirroring inert verb callbacks in the browser. diff --git a/docs/config.json b/docs/config.json index 1c716b5a3..58fb5a3f5 100644 --- a/docs/config.json +++ b/docs/config.json @@ -314,12 +314,14 @@ { "label": "Overview", "to": "transaction/overview", - "addedAt": "2026-07-14" + "addedAt": "2026-07-14", + "updatedAt": "2026-07-14" }, { "label": "Transactions", "to": "transaction/transactions", - "addedAt": "2026-07-14" + "addedAt": "2026-07-14", + "updatedAt": "2026-07-14" }, { "label": "Scenarios", diff --git a/docs/transaction/overview.md b/docs/transaction/overview.md index c34bc2b90..86464a618 100644 --- a/docs/transaction/overview.md +++ b/docs/transaction/overview.md @@ -7,6 +7,7 @@ keywords: - tanstack ai - transaction - defineTransaction + - clientTransaction - useTransaction - verbs - chat @@ -67,35 +68,20 @@ That one route now serves conversational drafting, image generation, and narrati // components/BlogStudio.tsx import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -// The same definition your server route exports — share it from one module in -// a real app; repeated here so this snippet type-checks on its own. See -// "Sharing the definition with the client" below. -const blogTransaction = defineTransaction({ - drafting: chatVerb((req) => - chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), - ), - heroImage: verb({ - input: z.object({ prompt: z.string() }), - execute: ({ input }) => - generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: input.prompt, - }), - }), - narration: verb({ - input: z.object({ text: z.string() }), - execute: ({ input }) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }), - }), +import { clientTransaction } from '@tanstack/ai/transaction' +import type { blogTransaction } from './api/blog-studio' + +// Type-only binding to the server definition — no provider imports in the +// browser bundle. See "Sharing the definition with the client" below. +const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + narration: 'one-shot', + blogPost: 'one-shot', }) function BlogStudio() { - const txn = useTransaction(blogTransaction, { + const txn = useTransaction(blogTxnDef, { connection: fetchServerSentEvents('/api/blog-studio'), }) @@ -173,7 +159,23 @@ On the client, `txn.primaryChat` and `txn.summaryChat` are two fully independent ## Sharing the definition with the client -`useTransaction` needs the definition's **type** to build the typed client — verb names and kinds at runtime, input/result/tool/schema types at compile time. Because the definition is inert, the cleanest way is often a shared isomorphic module. When your server route builds the definition inside the handler (for example, to keep provider SDKs out of the browser bundle), mirror the definition client-side instead: declare the same verb names, kinds, input schemas, and `outputSchema`s, and let the mirrored `execute`/callback bodies carry the result types — they **never run in the browser**. The [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/routes/blog-studio.tsx) shows this pattern end to end, sharing the Zod schema and prompt helpers from the API route module so the two sides can't drift. +`useTransaction` needs the definition's **type** to build the typed client — verb names and kinds at runtime, input/result/tool/schema types at compile time. + +The recommended pattern is **`clientTransaction({ kinds })`**: define the transaction once on the server (export it, or export a `ReturnType` type when the definition is built per-request), then bind it on the client with a verb-name → kind map. The generic is supplied via `import type` — erased from the bundle, so provider SDKs never ship to the browser. The kinds map is checked exhaustively against the server definition, so drift fails at compile time. + +```tsx +import type { blogTransaction } from './api/blog-studio' +import { clientTransaction } from '@tanstack/ai/transaction' + +const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + narration: 'one-shot', + blogPost: 'one-shot', +}) +``` + +When the definition truly has no provider imports — a shared isomorphic module works fine — you can also export one `defineTransaction(...)` value and pass it to both `handler` and `useTransaction` directly. The [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/routes/blog-studio.tsx) uses `clientTransaction` end to end. ## When should you reach for a transaction endpoint? diff --git a/docs/transaction/transactions.md b/docs/transaction/transactions.md index 1273f43ec..dd9dabd49 100644 --- a/docs/transaction/transactions.md +++ b/docs/transaction/transactions.md @@ -9,6 +9,7 @@ keywords: - ctx.call - sub-runs - defineTransaction + - clientTransaction - useTransaction - abort - cancellation @@ -126,62 +127,20 @@ export const POST = (request: Request) => blogTransaction.handler(request) ```tsx // components/BlogStudio.tsx -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -// The same definition the server route above exports — share it from one -// module in a real app; mirrored here so this snippet type-checks on its -// own. It is inert in the browser: no callback ever runs client-side. -const BlogPostSchema = z.object({ - title: z.string(), - subtitle: z.string(), - body: z.string(), -}) -const drafting = chatVerb((req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - outputSchema: BlogPostSchema, - stream: true, - }), -) -const heroImage = verb({ - input: z.object({ prompt: z.string() }), - execute: ({ input }) => - generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt }), -}) -const narration = verb({ - input: z.object({ text: z.string() }), - execute: ({ input }) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }), -}) -const blogPostVerb = verb({ - input: z.object({ topic: z.string() }), - execute: async ({ input }, ctx) => { - const draft = await ctx.call(drafting, [ - { role: 'user', content: `Write a blog post about: ${input.topic}` }, - ]) - const post = BlogPostSchema.parse(draft.structured) - const [hero, audio] = await Promise.all([ - ctx.call(heroImage, { prompt: post.title }), - ctx.call(narration, { text: post.body }), - ]) - return { post, hero, audio } - }, -}) -const blogTransaction = defineTransaction({ - drafting, - heroImage, - narration, - blogPost: blogPostVerb, +import { clientTransaction } from '@tanstack/ai/transaction' +import type { blogTransaction } from './api/blog-studio' + +const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + narration: 'one-shot', + blogPost: 'one-shot', }) function BlogStudio() { - const txn = useTransaction(blogTransaction, { + const txn = useTransaction(blogTxnDef, { connection: fetchServerSentEvents('/api/blog-studio'), }) diff --git a/examples/ts-react-chat/src/lib/blog-studio.ts b/examples/ts-react-chat/src/lib/blog-studio.ts new file mode 100644 index 000000000..3f9ad3afe --- /dev/null +++ b/examples/ts-react-chat/src/lib/blog-studio.ts @@ -0,0 +1,163 @@ +import { chat, generateImage, generateSpeech } from '@tanstack/ai' +import { + chatVerb, + clientTransaction, + defineTransaction, + verb, +} from '@tanstack/ai/transaction' +import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' +import { z } from 'zod' + +/** + * The shape a blog post is drafted into. The `drafting` chat verb declares + * this as its `outputSchema`, so its structured output is schema-validated — + * both when the client talks to it directly and when the `blogPost` + * transaction runs it server-side via `ctx.call`. + */ +export const BlogPostSchema = z.object({ + title: z.string().describe('A punchy, editorial blog post title'), + subtitle: z.string().describe('A one-sentence standfirst / subtitle'), + body: z + .string() + .describe( + 'The full blog post body as GitHub-flavored Markdown: use ## / ### ' + + 'headings, short paragraphs, and the occasional list. ~400-600 words.', + ), +}) + +export type BlogPost = z.infer + +export const BLOG_STUDIO_SYSTEM_PROMPT = + 'You are a seasoned staff writer. Given a topic, write one engaging, ' + + 'well-structured blog post. Return a title, a short subtitle, and the ' + + 'body as GitHub-flavored Markdown with section headings and tight ' + + 'paragraphs. Be vivid and concrete; avoid filler and clichés.' + +/** + * Build the hero-image prompt from a drafted post. Used by the server-side + * `blogPost` transaction and by the client's "Regenerate hero image" button, + * so both produce the same style of illustration. + */ +export function heroPromptFor(post: BlogPost): string { + return ( + `A striking editorial hero image for a blog post titled ` + + `"${post.title}". ${post.subtitle}. Modern, clean, cinematic, ` + + `high quality, no text.` + ) +} + +/** + * Prepare the post body for narration: strip Markdown so TTS doesn't read the + * syntax aloud, and cap the length at a sentence boundary (OpenAI TTS rejects + * input over 4096 characters, and long posts easily exceed it). Used by the + * server-side `blogPost` transaction and by the client's "Re-narrate" button. + */ +export function forNarration(markdown: string, max = 4000): string { + const plain = markdown + .replace(/^#{1,6}\s+/gm, '') // headings + .replace(/^\s*[-*+]\s+/gm, '') // list bullets + .replace(/^\s*>\s?/gm, '') // blockquotes + .replace(/\*\*(.*?)\*\*/g, '$1') // bold + .replace(/\*(.*?)\*/g, '$1') // italic + .replace(/`([^`]+)`/g, '$1') // inline code + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → link text + .replace(/\n{3,}/g, '\n\n') + .trim() + if (plain.length <= max) return plain + const clipped = plain.slice(0, max) + const boundary = Math.max( + clipped.lastIndexOf('. '), + clipped.lastIndexOf('\n'), + ) + return (boundary > max / 2 ? clipped.slice(0, boundary + 1) : clipped).trim() +} + +// Conversational verb: writes the post as a typed object +// (structured output + streaming). +const drafting = chatVerb((req) => + chat({ + adapter: openaiText('gpt-5.5'), + messages: req.messages, + systemPrompts: [BLOG_STUDIO_SYSTEM_PROMPT], + outputSchema: BlogPostSchema, + stream: true, + threadId: req.threadId, + runId: req.runId, + }), +) + +// One-shot verb: a landscape hero / OG image from a prompt. +const heroImage = verb({ + input: z.object({ prompt: z.string() }), + execute: ({ input }) => + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + size: '1536x1024', + }), +}) + +// One-shot verb: narrate a piece of text. +const narration = verb({ + input: z.object({ text: z.string() }), + execute: ({ input }) => + generateSpeech({ + adapter: openaiSpeech('tts-1'), + text: input.text, + voice: 'alloy', + }), +}) + +// The transaction: one client call composes the three verbs above, +// entirely server-side. Each `ctx.call` streams back to the client as +// a live, tagged sub-run of this single request — and the whole +// pipeline shares one abort scope (client disconnect / stop() cancels +// everything). +const blogPost = verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + // 1. Draft the post. `ctx.call` on a chat verb resolves with the + // accumulated text and the structured output; re-validate it so + // a half-finished draft fails the run with a clear error. + const draft = await ctx.call(drafting, [ + { + role: 'user', + content: `Write a blog post about: ${input.topic}`, + }, + ]) + const parsed = BlogPostSchema.safeParse(draft.structured) + if (!parsed.success) { + throw new Error( + `Drafting did not produce a valid blog post: ${parsed.error.message}`, + ) + } + const post = parsed.data + + // 2. Illustrate and narrate in parallel, both derived from the + // validated draft. + const [hero, audio] = await Promise.all([ + ctx.call(heroImage, { prompt: heroPromptFor(post) }), + ctx.call(narration, { text: forNarration(post.body) }), + ]) + + // 3. The return value becomes the run's final result on the + // client (`txn.blogPost.result`). + return { post, hero, audio } + }, +}) + +/** Server-side transaction definition — wire to `blogTransaction.handler`. */ +export const blogTransaction = defineTransaction({ + drafting, + heroImage, + narration, + blogPost, +}) + +/** Client-side type binding — pass to `useTransaction`. */ +export const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + narration: 'one-shot', + blogPost: 'one-shot', +}) diff --git a/examples/ts-react-chat/src/routes/api.blog-studio.ts b/examples/ts-react-chat/src/routes/api.blog-studio.ts index 73757b1cc..3b5481cbc 100644 --- a/examples/ts-react-chat/src/routes/api.blog-studio.ts +++ b/examples/ts-react-chat/src/routes/api.blog-studio.ts @@ -1,79 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' - -/** - * The shape a blog post is drafted into. The `drafting` chat verb declares - * this as its `outputSchema`, so its structured output is schema-validated — - * both when the client talks to it directly and when the `blogPost` - * transaction runs it server-side via `ctx.call`. - * - * Exported so the client page (`blog-studio.tsx`) can import the same schema - * and stay in type-sync. Only client-safe values (this schema and the two - * pure string helpers below) live at module scope; the actual - * `defineTransaction` (with its server adapters) is built inside the POST - * handler so importing them never pulls provider SDKs into the browser - * bundle. - */ -export const BlogPostSchema = z.object({ - title: z.string().describe('A punchy, editorial blog post title'), - subtitle: z.string().describe('A one-sentence standfirst / subtitle'), - body: z - .string() - .describe( - 'The full blog post body as GitHub-flavored Markdown: use ## / ### ' + - 'headings, short paragraphs, and the occasional list. ~400-600 words.', - ), -}) - -export type BlogPost = z.infer - -const SYSTEM_PROMPT = - 'You are a seasoned staff writer. Given a topic, write one engaging, ' + - 'well-structured blog post. Return a title, a short subtitle, and the ' + - 'body as GitHub-flavored Markdown with section headings and tight ' + - 'paragraphs. Be vivid and concrete; avoid filler and clichés.' - -/** - * Build the hero-image prompt from a drafted post. Used by the server-side - * `blogPost` transaction and by the client's "Regenerate hero image" button, - * so both produce the same style of illustration. - */ -export function heroPromptFor(post: BlogPost): string { - return ( - `A striking editorial hero image for a blog post titled ` + - `"${post.title}". ${post.subtitle}. Modern, clean, cinematic, ` + - `high quality, no text.` - ) -} - -/** - * Prepare the post body for narration: strip Markdown so TTS doesn't read the - * syntax aloud, and cap the length at a sentence boundary (OpenAI TTS rejects - * input over 4096 characters, and long posts easily exceed it). Used by the - * server-side `blogPost` transaction and by the client's "Re-narrate" button. - */ -export function forNarration(markdown: string, max = 4000): string { - const plain = markdown - .replace(/^#{1,6}\s+/gm, '') // headings - .replace(/^\s*[-*+]\s+/gm, '') // list bullets - .replace(/^\s*>\s?/gm, '') // blockquotes - .replace(/\*\*(.*?)\*\*/g, '$1') // bold - .replace(/\*(.*?)\*/g, '$1') // italic - .replace(/`([^`]+)`/g, '$1') // inline code - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → link text - .replace(/\n{3,}/g, '\n\n') - .trim() - if (plain.length <= max) return plain - const clipped = plain.slice(0, max) - const boundary = Math.max( - clipped.lastIndexOf('. '), - clipped.lastIndexOf('\n'), - ) - return (boundary > max / 2 ? clipped.slice(0, boundary + 1) : clipped).trim() -} +import { blogTransaction } from '../lib/blog-studio' export const Route = createFileRoute('/api/blog-studio')({ server: { @@ -82,90 +8,7 @@ export const Route = createFileRoute('/api/blog-studio')({ // `handler(request)` runs, at which point it parses the AG-UI request, // routes by the `verb` discriminator the client sends, and streams the // result back over SSE. - POST: async ({ request }) => { - // Conversational verb: writes the post as a typed object - // (structured output + streaming). - const drafting = chatVerb((req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - systemPrompts: [SYSTEM_PROMPT], - outputSchema: BlogPostSchema, - stream: true, - threadId: req.threadId, - runId: req.runId, - }), - ) - - // One-shot verb: a landscape hero / OG image from a prompt. - const heroImage = verb({ - input: z.object({ prompt: z.string() }), - execute: ({ input }) => - generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: input.prompt, - size: '1536x1024', - }), - }) - - // One-shot verb: narrate a piece of text. - const narration = verb({ - input: z.object({ text: z.string() }), - execute: ({ input }) => - generateSpeech({ - adapter: openaiSpeech('tts-1'), - text: input.text, - voice: 'alloy', - }), - }) - - // The transaction: one client call composes the three verbs above, - // entirely server-side. Each `ctx.call` streams back to the client as - // a live, tagged sub-run of this single request — and the whole - // pipeline shares one abort scope (client disconnect / stop() cancels - // everything). - const blogPost = verb({ - input: z.object({ topic: z.string() }), - execute: async ({ input }, ctx) => { - // 1. Draft the post. `ctx.call` on a chat verb resolves with the - // accumulated text and the structured output; re-validate it so - // a half-finished draft fails the run with a clear error. - const draft = await ctx.call(drafting, [ - { - role: 'user', - content: `Write a blog post about: ${input.topic}`, - }, - ]) - const parsed = BlogPostSchema.safeParse(draft.structured) - if (!parsed.success) { - throw new Error( - `Drafting did not produce a valid blog post: ${parsed.error.message}`, - ) - } - const post = parsed.data - - // 2. Illustrate and narrate in parallel, both derived from the - // validated draft. - const [hero, audio] = await Promise.all([ - ctx.call(heroImage, { prompt: heroPromptFor(post) }), - ctx.call(narration, { text: forNarration(post.body) }), - ]) - - // 3. The return value becomes the run's final result on the - // client (`txn.blogPost.result`). - return { post, hero, audio } - }, - }) - - const transaction = defineTransaction({ - drafting, - heroImage, - narration, - blogPost, - }) - - return transaction.handler(request) - }, + POST: ({ request }) => blogTransaction.handler(request), }, }, }) diff --git a/examples/ts-react-chat/src/routes/blog-studio.tsx b/examples/ts-react-chat/src/routes/blog-studio.tsx index 3ef1abe71..f9c4b7942 100644 --- a/examples/ts-react-chat/src/routes/blog-studio.tsx +++ b/examples/ts-react-chat/src/routes/blog-studio.tsx @@ -1,10 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' -import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' -import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { @@ -19,70 +15,10 @@ import { Volume2, Wand2, } from 'lucide-react' -import { BlogPostSchema, forNarration, heroPromptFor } from './api.blog-studio' +import { blogTxnDef, forNarration, heroPromptFor } from '../lib/blog-studio' import type { ImageGenerationResult, TTSResult } from '@tanstack/ai' import type { FormEvent, ReactNode } from 'react' -// Client-side transaction definition. `defineTransaction` is INERT in the -// browser — these callbacks never run; `useTransaction` only reads the -// declared verb names/kinds and their input/result types off this value to -// build the fully typed client. Every request is sent to the -// `/api/blog-studio` route, which owns the real callbacks. Mirroring the four -// verbs (and the drafting `outputSchema`) here keeps the client types in sync -// with the server. -// -// Single-provider openai adapters are used directly (not the multi-provider -// factories) so the browser bundle doesn't pull in every provider SDK. -const drafting = chatVerb((req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - outputSchema: BlogPostSchema, - stream: true, - }), -) - -const heroImage = verb({ - input: z.object({ prompt: z.string() }), - execute: ({ input }) => - generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: input.prompt, - }), -}) - -const narration = verb({ - input: z.object({ text: z.string() }), - execute: ({ input }) => - generateSpeech({ adapter: openaiSpeech('tts-1'), text: input.text }), -}) - -const blogTransaction = defineTransaction({ - drafting, - heroImage, - narration, - // Mirrors the server's composition so `txn.blogPost.result` is typed as - // `{ post, hero, audio }` — the body never executes in the browser. - blogPost: verb({ - input: z.object({ topic: z.string() }), - execute: async ({ input }, ctx) => { - const draft = await ctx.call(drafting, [ - { role: 'user', content: `Write a blog post about: ${input.topic}` }, - ]) - const parsed = BlogPostSchema.safeParse(draft.structured) - if (!parsed.success) { - throw new Error('Drafting did not produce a valid blog post') - } - const post = parsed.data - const [hero, audio] = await Promise.all([ - ctx.call(heroImage, { prompt: heroPromptFor(post) }), - ctx.call(narration, { text: forNarration(post.body) }), - ]) - return { post, hero, audio } - }, - }), -}) - export const Route = createFileRoute('/blog-studio')({ component: BlogStudio, }) @@ -142,7 +78,7 @@ function asBlogPostDraft(partial: unknown): { } function BlogStudio() { - const txn = useTransaction(blogTransaction, { + const txn = useTransaction(blogTxnDef, { connection: fetchServerSentEvents('/api/blog-studio'), }) diff --git a/packages/ai/skills/ai-core/transaction/SKILL.md b/packages/ai/skills/ai-core/transaction/SKILL.md index a24158a51..c08a079a6 100644 --- a/packages/ai/skills/ai-core/transaction/SKILL.md +++ b/packages/ai/skills/ai-core/transaction/SKILL.md @@ -284,16 +284,30 @@ final (or `null`) instead of the messages array. Omit `outputSchema` and neither field is present on the type — same conditional shape as `useChat({ outputSchema })`. -### 5. Client needs the definition's TYPE — mirror it when the server builds it per-request +### 5. Client needs the definition's TYPE — use `clientTransaction` `useTransaction` reads verb names/kinds at runtime and everything else at -the type level. If the route constructs the definition inside the handler -(to keep provider SDKs out of the client bundle), export an **inert mirror** -from a client-safe module: same verb names, kinds, input schemas, and -`outputSchema`s; the mirrored `execute`/callback bodies never run in the -browser — they only carry result types. Share the Zod schemas and prompt -helpers from one module so the two sides can't drift (see the -`examples/ts-react-chat` blog-studio routes for the full pattern). +the type level. Define the transaction on the server, export it (or export +a `ReturnType` type when the definition is built +per-request), then bind it on the client with `clientTransaction`: + +```tsx +import type { blogTransaction } from './api/blog-studio' +import { clientTransaction } from '@tanstack/ai/transaction' + +const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + narration: 'one-shot', + blogPost: 'one-shot', +}) +``` + +The generic is supplied via `import type` — erased from the bundle — so +provider SDKs never ship to the browser. The kinds map is checked +exhaustively against the server definition. When the definition truly has +no provider imports, a shared isomorphic `defineTransaction` module also +works (see `examples/ts-react-chat` blog-studio routes). ### 6. Only declared verbs are constructed; one shared connection diff --git a/packages/ai/src/activities/transaction/index.ts b/packages/ai/src/activities/transaction/index.ts index fd5b2732a..f9d84c4e3 100644 --- a/packages/ai/src/activities/transaction/index.ts +++ b/packages/ai/src/activities/transaction/index.ts @@ -11,6 +11,7 @@ import type { AnyVerb, ChatVerb, ChatVerbReturn, + ClientTransactionKinds, CollectedChatResult, OneShotVerb, TransactionChatRequest, @@ -226,6 +227,40 @@ export function defineTransaction( return { verbs, verbKinds, handler, '~verbs': config } } +/** + * Build a type-only client stub for a server-side {@link defineTransaction} + * definition. The browser only needs verb names and kinds at runtime; input, + * result, tool, and schema types flow from `TDef` via `import type`. + * + * @example + * ```ts + * import type { blogTransaction } from './api.blog-studio' + * import { clientTransaction } from '@tanstack/ai/transaction' + * + * const txnDef = clientTransaction({ + * drafting: 'chat', + * heroImage: 'one-shot', + * narration: 'one-shot', + * blogPost: 'one-shot', + * }) + * ``` + */ +export function clientTransaction>( + kinds: ClientTransactionKinds, +): TDef { + const verbs = Object.keys(kinds) as Array + return { + verbs, + verbKinds: kinds, + handler: async () => { + throw new Error( + 'clientTransaction definitions are type-only stubs and must not handle requests in the browser. Route requests to the server transaction handler.', + ) + }, + '~verbs': {} as TDef['~verbs'], + } as unknown as TDef +} + /** * Run a one-shot verb as the root of a (possibly multi-sub-run) transaction * stream: RUN_STARTED, live sub-run events from `ctx.call`, the verb's own diff --git a/packages/ai/src/activities/transaction/types.ts b/packages/ai/src/activities/transaction/types.ts index 02849d174..7d0ecefe3 100644 --- a/packages/ai/src/activities/transaction/types.ts +++ b/packages/ai/src/activities/transaction/types.ts @@ -169,6 +169,15 @@ export interface TransactionDefinition< readonly '~verbs': T } +/** + * Verb-name → kind map required by {@link clientTransaction}. Every declared + * verb must appear exactly once with its correct kind so client/server drift + * fails at compile time. + */ +export type ClientTransactionKinds> = { + [K in keyof TDef['~verbs'] & string]: TDef['~verbs'][K]['kind'] +} + /** Payloads of the CUSTOM events a transaction run emits for its sub-runs. */ export interface SubRunStartedPayload { runId: string diff --git a/packages/ai/src/transaction.ts b/packages/ai/src/transaction.ts index 72dd35d7e..4409b58cb 100644 --- a/packages/ai/src/transaction.ts +++ b/packages/ai/src/transaction.ts @@ -1,5 +1,6 @@ export { chatVerb, + clientTransaction, defineTransaction, verb, TRANSACTION_EVENTS, @@ -8,6 +9,7 @@ export type { AnyVerb, ChatVerb, ChatVerbCallback, + ClientTransactionKinds, CollectedChatResult, OneShotVerb, SubRunChunkPayload, diff --git a/packages/ai/tests/activities/transaction/index.test.ts b/packages/ai/tests/activities/transaction/index.test.ts index 0d208346a..06016b039 100644 --- a/packages/ai/tests/activities/transaction/index.test.ts +++ b/packages/ai/tests/activities/transaction/index.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' import { TRANSACTION_EVENTS, chatVerb, + clientTransaction, defineTransaction, verb, } from '../../../src/activities/transaction/index.js' @@ -306,3 +307,92 @@ describe('transactions (ctx.call)', () => { }) }) }) + +describe('clientTransaction', () => { + const serverTxn = defineTransaction({ + primaryChat: chatVerb(async function* () {} as any), + banner: verb({ + input: z.object({ prompt: z.string() }), + execute: async ({ input }) => ({ prompt: input.prompt, url: 'x' }), + }), + bannerPair: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + const hero = await ctx.call( + verb({ + input: z.object({ prompt: z.string() }), + execute: async () => ({ url: 'hero' }), + }), + { prompt: input.topic }, + ) + return { hero } + }, + }), + }) + + it('builds verbs and verbKinds from the kinds map', () => { + const clientTxn = clientTransaction({ + primaryChat: 'chat', + banner: 'one-shot', + bannerPair: 'one-shot', + }) + expect(clientTxn.verbs.slice().sort()).toEqual([ + 'banner', + 'bannerPair', + 'primaryChat', + ]) + expect(clientTxn.verbKinds).toEqual({ + primaryChat: 'chat', + banner: 'one-shot', + bannerPair: 'one-shot', + }) + }) + + it('handler throws — client stubs must not handle requests', async () => { + const clientTxn = clientTransaction({ + primaryChat: 'chat', + banner: 'one-shot', + bannerPair: 'one-shot', + }) + await expect(clientTxn.handler(new Request('http://x'))).rejects.toThrow( + /type-only stubs/, + ) + }) + + it('is exported from the transaction subpath entry', async () => { + const mod = await import('../../../src/transaction.js') + expect(typeof mod.clientTransaction).toBe('function') + }) + + it('is assignable to the server definition type', () => { + const clientTxn = clientTransaction({ + primaryChat: 'chat', + banner: 'one-shot', + bannerPair: 'one-shot', + }) + expectTypeOf(clientTxn).toMatchTypeOf() + }) + + // Compile-only: missing or wrong kinds must fail type-check. + void (() => + // @ts-expect-error — bannerPair is required + clientTransaction({ + primaryChat: 'chat', + banner: 'one-shot', + })) + void (() => + clientTransaction({ + primaryChat: 'chat', + banner: 'one-shot', + bannerPair: 'one-shot', + // @ts-expect-error — extra verbs are rejected + extra: 'one-shot', + })) + void (() => + clientTransaction({ + // @ts-expect-error — wrong kind for primaryChat + primaryChat: 'one-shot', + banner: 'one-shot', + bannerPair: 'one-shot', + })) +}) diff --git a/testing/e2e/src/routes/api.transaction.ts b/testing/e2e/src/routes/api.transaction.ts index 3dc95a219..6b7a931e1 100644 --- a/testing/e2e/src/routes/api.transaction.ts +++ b/testing/e2e/src/routes/api.transaction.ts @@ -27,6 +27,69 @@ async function collectText(stream: AsyncIterable): Promise { return text } +type TextOptions = ReturnType + +/** Build the e2e transaction for a resolved provider/test routing context. */ +export function createE2eTransaction(textOptions: () => TextOptions) { + // One-shot verb: schema-validated input in, result out. Runs a + // single deterministic chat completion against aimock (the fixture + // keys on the exact `[transaction] banner: …` user message). + const banner = verb({ + input: z.object({ prompt: z.string() }), + execute: async (req) => { + const text = await collectText( + chat({ + ...textOptions(), + messages: [ + { + role: 'user', + content: `[transaction] banner: ${req.input.prompt}`, + }, + ], + agentLoopStrategy: maxIterations(1), + threadId: req.threadId, + runId: req.runId, + }), + ) + return { prompt: req.input.prompt, text } + }, + }) + + return defineTransaction({ + // Conversational verb: full chat surface on the client. + primaryChat: chatVerb((req) => + chat({ + ...textOptions(), + messages: req.messages, + agentLoopStrategy: maxIterations(5), + threadId: req.threadId, + runId: req.runId, + }), + ), + banner, + // Composing verb: runs the sibling `banner` verb twice via + // `ctx.call`, so the client observes two tagged sub-runs + // (transaction:sub-run:* CUSTOM events) inside one SSE response. + bannerPair: verb({ + input: z.object({ topic: z.string() }), + execute: async ({ input }, ctx) => { + // Sequential so sub-run start order (hero=0, thumb=1) is + // deterministic for the spec's per-index assertions. + const hero = await ctx.call(banner, { + prompt: `hero ${input.topic}`, + }) + const thumb = await ctx.call(banner, { + prompt: `thumb ${input.topic}`, + }) + return { hero, thumb } + }, + }), + }) +} + +/** Server-side transaction shape — import with `import type` on the client. */ +export type E2eTransaction = ReturnType + export const Route = createFileRoute('/api/transaction')({ server: { handlers: { @@ -54,60 +117,7 @@ export const Route = createFileRoute('/api/transaction')({ 'transaction', ) - // One-shot verb: schema-validated input in, result out. Runs a - // single deterministic chat completion against aimock (the fixture - // keys on the exact `[transaction] banner: …` user message). - const banner = verb({ - input: z.object({ prompt: z.string() }), - execute: async (req) => { - const text = await collectText( - chat({ - ...textOptions(), - messages: [ - { - role: 'user', - content: `[transaction] banner: ${req.input.prompt}`, - }, - ], - agentLoopStrategy: maxIterations(1), - threadId: req.threadId, - runId: req.runId, - }), - ) - return { prompt: req.input.prompt, text } - }, - }) - - const transaction = defineTransaction({ - // Conversational verb: full chat surface on the client. - primaryChat: chatVerb((req) => - chat({ - ...textOptions(), - messages: req.messages, - agentLoopStrategy: maxIterations(5), - threadId: req.threadId, - runId: req.runId, - }), - ), - banner, - // Composing verb: runs the sibling `banner` verb twice via - // `ctx.call`, so the client observes two tagged sub-runs - // (transaction:sub-run:* CUSTOM events) inside one SSE response. - bannerPair: verb({ - input: z.object({ topic: z.string() }), - execute: async ({ input }, ctx) => { - // Sequential so sub-run start order (hero=0, thumb=1) is - // deterministic for the spec's per-index assertions. - const hero = await ctx.call(banner, { - prompt: `hero ${input.topic}`, - }) - const thumb = await ctx.call(banner, { - prompt: `thumb ${input.topic}`, - }) - return { hero, thumb } - }, - }), - }) + const transaction = createE2eTransaction(textOptions) return transaction.handler(request) }, diff --git a/testing/e2e/src/routes/transaction.tsx b/testing/e2e/src/routes/transaction.tsx index 55bee158c..7520f7852 100644 --- a/testing/e2e/src/routes/transaction.tsx +++ b/testing/e2e/src/routes/transaction.tsx @@ -1,11 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' -import { chat, maxIterations } from '@tanstack/ai' -import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { clientTransaction } from '@tanstack/ai/transaction' import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' -import { openaiText } from '@tanstack/ai-openai' -import { z } from 'zod' import { ChatUI } from '@/components/ChatUI' +import type { E2eTransaction } from './api.transaction' import type { Provider } from '@/lib/types' export interface TransactionRouteSearch { @@ -42,42 +40,12 @@ export const Route = createFileRoute('/transaction')({ validateSearch: parseTransactionRouteSearch, }) -// Client-side transaction definition. `defineTransaction` is inert — none of -// these callbacks ever run in the browser. `useTransaction` only reads the -// declared verb names (and their kinds/types) off this value to build the -// typed client system; the actual requests are always sent to the server -// route `/api/transaction`, which defines its own (real) verbs. Mirroring -// the server route's three verbs (primaryChat + banner + bannerPair) here -// just keeps the client types in sync with what the server actually runs. -// -// Uses the single-provider openai adapter directly (not the multi-provider -// `@/lib/providers` factory) so the browser bundle doesn't pull in every -// provider SDK (e.g. ollama's `node:fs`) — the callbacks are inert on the -// client, so only their input/result TYPES matter. -const banner = verb({ - input: z.object({ prompt: z.string() }), - execute: async ({ input }) => ({ prompt: input.prompt, text: '' }), -}) - -const transaction = defineTransaction({ - primaryChat: chatVerb((req) => - chat({ - adapter: openaiText('gpt-5.5'), - messages: req.messages, - agentLoopStrategy: maxIterations(5), - threadId: req.threadId, - runId: req.runId, - }), - ), - banner, - bannerPair: verb({ - input: z.object({ topic: z.string() }), - execute: async ({ input }, ctx) => { - const hero = await ctx.call(banner, { prompt: `hero ${input.topic}` }) - const thumb = await ctx.call(banner, { prompt: `thumb ${input.topic}` }) - return { hero, thumb } - }, - }), +// Type-only binding to the server definition — no inert verb mirrors or +// provider imports in the browser bundle. +const e2eTxnDef = clientTransaction({ + primaryChat: 'chat', + banner: 'one-shot', + bannerPair: 'one-shot', }) function TransactionRoute() { @@ -88,7 +56,7 @@ function TransactionRoute() { // the one-shot verbs' zod schemas simply strip the extra fields. const routing = { provider, testId, aimockPort } - const txn = useTransaction(transaction, { + const txn = useTransaction(e2eTxnDef, { connection: fetchServerSentEvents('/api/transaction'), verbs: { primaryChat: { forwardedProps: routing }, From c8da87c8b2008bfa6d6882a96477ed578d8c7219 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:18:12 +0000 Subject: [PATCH 4/9] ci: apply automated fixes --- .../ts-react-chat/src/routes/blog-studio.tsx | 6 ++--- packages/ai-client/src/transaction-client.ts | 5 +--- packages/ai-client/src/transaction-types.ts | 4 ++- .../tests/transaction-client.test.ts | 19 +++++++++++--- packages/ai-react/src/use-transaction.ts | 11 +++++--- packages/ai/skills/ai-core/SKILL.md | 26 +++++++++---------- .../ai/skills/ai-core/transaction/SKILL.md | 11 +++++--- .../ai/src/activities/transaction/index.ts | 3 ++- .../ai/src/activities/transaction/types.ts | 5 +--- .../activities/transaction/index.test.ts | 22 +++++++++++----- 10 files changed, 71 insertions(+), 41 deletions(-) diff --git a/examples/ts-react-chat/src/routes/blog-studio.tsx b/examples/ts-react-chat/src/routes/blog-studio.tsx index f9c4b7942..aa3e9fac6 100644 --- a/examples/ts-react-chat/src/routes/blog-studio.tsx +++ b/examples/ts-react-chat/src/routes/blog-studio.tsx @@ -164,9 +164,9 @@ function BlogStudio() { Turn a topic into a finished post

- One transaction writes the article, then illustrates it and records - a voice-over in parallel — composed on the server from a single - request, with every step streamed back live. + One transaction writes the article, then illustrates it and records a + voice-over in parallel — composed on the server from a single request, + with every step streamed back live.

diff --git a/packages/ai-client/src/transaction-client.ts b/packages/ai-client/src/transaction-client.ts index e57bd32fa..093ef804c 100644 --- a/packages/ai-client/src/transaction-client.ts +++ b/packages/ai-client/src/transaction-client.ts @@ -47,10 +47,7 @@ export class TransactionClient< TDef extends TransactionDefinition = TransactionDefinition, > { private readonly chats = new Map>() - private readonly oneShots = new Map< - string, - GenerationClient - >() + private readonly oneShots = new Map>() private readonly subRuns = new Map>() /** Per-sub-run streamed-text-chunk counter, for batching reactive notifies. */ private readonly subRunChunkCounts = new Map() diff --git a/packages/ai-client/src/transaction-types.ts b/packages/ai-client/src/transaction-types.ts index 114ada01d..b7e7a56e6 100644 --- a/packages/ai-client/src/transaction-types.ts +++ b/packages/ai-client/src/transaction-types.ts @@ -94,7 +94,9 @@ export type VerbOptionsMap> = { */ export interface TransactionClientCallbacks { /** Reactive state callbacks for each chat verb's `ChatClient`. */ - chat?: (verb: string) => Pick< + chat?: ( + verb: string, + ) => Pick< ChatClientOptions, | 'onMessagesChange' | 'onLoadingChange' diff --git a/packages/ai-client/tests/transaction-client.test.ts b/packages/ai-client/tests/transaction-client.test.ts index f4fa79dec..07d7ed090 100644 --- a/packages/ai-client/tests/transaction-client.test.ts +++ b/packages/ai-client/tests/transaction-client.test.ts @@ -212,7 +212,12 @@ describe('TransactionClient', () => { onChunk({ type: 'CUSTOM', name: 'transaction:sub-run:started', - value: { runId: 'r1-sub-0', parentRunId: 'r1', verb: 'drafting', index: 0 }, + value: { + runId: 'r1-sub-0', + parentRunId: 'r1', + verb: 'drafting', + index: 0, + }, }) // The structured output streams as partial JSON via TEXT_MESSAGE_CONTENT. @@ -255,7 +260,12 @@ describe('TransactionClient', () => { onChunk({ type: 'CUSTOM', name: 'transaction:sub-run:started', - value: { runId: 'r1-sub-0', parentRunId: 'r1', verb: 'support', index: 0 }, + value: { + runId: 'r1-sub-0', + parentRunId: 'r1', + verb: 'support', + index: 0, + }, }) onChunk({ type: 'CUSTOM', @@ -264,7 +274,10 @@ describe('TransactionClient', () => { runId: 'r1-sub-0', verb: 'support', index: 0, - chunk: { type: 'TEXT_MESSAGE_CONTENT', delta: 'Hello, how can I help?' }, + chunk: { + type: 'TEXT_MESSAGE_CONTENT', + delta: 'Hello, how can I help?', + }, }, }) diff --git a/packages/ai-react/src/use-transaction.ts b/packages/ai-react/src/use-transaction.ts index 5443ef12f..ec3bc0c29 100644 --- a/packages/ai-react/src/use-transaction.ts +++ b/packages/ai-react/src/use-transaction.ts @@ -73,7 +73,10 @@ export function useTransaction< TDef extends TransactionDefinition, // Capture the options object so per-verb `onResult` transforms flow into // each one-shot verb's `result` type. - TOptions extends Omit, 'transaction' | 'callbacks'>, + TOptions extends Omit< + TransactionClientOptions, + 'transaction' | 'callbacks' + >, >(transaction: TDef, options: TOptions): TransactionSystem { const hookId = useId() const clientId = options.id ?? hookId @@ -119,8 +122,10 @@ export function useTransaction< id: clientId, callbacks: { chat: (verb) => ({ - onMessagesChange: (m) => setChatSlice(instance, verb, { messages: m }), - onLoadingChange: (v) => setChatSlice(instance, verb, { isLoading: v }), + onMessagesChange: (m) => + setChatSlice(instance, verb, { messages: m }), + onLoadingChange: (v) => + setChatSlice(instance, verb, { isLoading: v }), onErrorChange: (v) => setChatSlice(instance, verb, { error: v }), onStatusChange: (v) => setChatSlice(instance, verb, { status: v }), onSubscriptionChange: (v) => diff --git a/packages/ai/skills/ai-core/SKILL.md b/packages/ai/skills/ai-core/SKILL.md index 4b234b9a2..50612ba7a 100644 --- a/packages/ai/skills/ai-core/SKILL.md +++ b/packages/ai/skills/ai-core/SKILL.md @@ -21,19 +21,19 @@ Always import from the framework package on the client — never from ## Sub-Skills -| Need to... | Read | -| ------------------------------------------------- | ------------------------------------------- | -| Build a chat UI with streaming | ai-core/chat-experience/SKILL.md | -| Add tool calling (server, client, or both) | ai-core/tool-calling/SKILL.md | -| Generate images, video, speech, or transcriptions | ai-core/media-generation/SKILL.md | -| Get typed JSON responses from the LLM | ai-core/structured-outputs/SKILL.md | -| Choose and configure a provider adapter | ai-core/adapter-configuration/SKILL.md | -| Implement AG-UI streaming protocol server-side | ai-core/ag-ui-protocol/SKILL.md | -| Add analytics, logging, or lifecycle hooks | ai-core/middleware/SKILL.md | -| Compose verbs / server-side pipelines behind one endpoint | ai-core/transaction/SKILL.md | -| Connect to a non-TanStack-AI backend | ai-core/custom-backend-integration/SKILL.md | -| Turn on/off debug logging, pipe into pino/winston | ai-core/debug-logging/SKILL.md | -| Set up Code Mode (LLM code execution) | See `@tanstack/ai-code-mode` package skills | +| Need to... | Read | +| --------------------------------------------------------- | ------------------------------------------- | +| Build a chat UI with streaming | ai-core/chat-experience/SKILL.md | +| Add tool calling (server, client, or both) | ai-core/tool-calling/SKILL.md | +| Generate images, video, speech, or transcriptions | ai-core/media-generation/SKILL.md | +| Get typed JSON responses from the LLM | ai-core/structured-outputs/SKILL.md | +| Choose and configure a provider adapter | ai-core/adapter-configuration/SKILL.md | +| Implement AG-UI streaming protocol server-side | ai-core/ag-ui-protocol/SKILL.md | +| Add analytics, logging, or lifecycle hooks | ai-core/middleware/SKILL.md | +| Compose verbs / server-side pipelines behind one endpoint | ai-core/transaction/SKILL.md | +| Connect to a non-TanStack-AI backend | ai-core/custom-backend-integration/SKILL.md | +| Turn on/off debug logging, pipe into pino/winston | ai-core/debug-logging/SKILL.md | +| Set up Code Mode (LLM code execution) | See `@tanstack/ai-code-mode` package skills | ## Quick Decision Tree diff --git a/packages/ai/skills/ai-core/transaction/SKILL.md b/packages/ai/skills/ai-core/transaction/SKILL.md index c08a079a6..8c7d94833 100644 --- a/packages/ai/skills/ai-core/transaction/SKILL.md +++ b/packages/ai/skills/ai-core/transaction/SKILL.md @@ -74,7 +74,10 @@ const drafting = chatVerb((req) => const heroImage = verb({ input: z.object({ prompt: z.string() }), // validated at runtime, 400 + issues on mismatch execute: ({ input }) => - generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt }), + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + }), }) // A transaction: composes the siblings above server-side via ctx.call. @@ -327,7 +330,9 @@ route. export const POST = async (request: Request) => { const body = await request.json() if (body.forwardedProps.verb === 'drafting') { - return toServerSentEventsResponse(chat({ adapter, messages: body.messages })) + return toServerSentEventsResponse( + chat({ adapter, messages: body.messages }), + ) } // ...manual branching for every verb } @@ -376,7 +381,7 @@ await txn.heroImage.run({ prompt: draft.title }) await txn.blogPost.run({ topic }) ``` -Client-side chaining is right when the *user* drives each step (regenerate +Client-side chaining is right when the _user_ drives each step (regenerate buttons, editable intermediate state). Server-side `ctx.call` is right when one gesture should produce the finished artifact or fail as a unit. diff --git a/packages/ai/src/activities/transaction/index.ts b/packages/ai/src/activities/transaction/index.ts index f9d84c4e3..b3b5548e0 100644 --- a/packages/ai/src/activities/transaction/index.ts +++ b/packages/ai/src/activities/transaction/index.ts @@ -164,7 +164,8 @@ export function defineTransaction( // members (`toString`, `constructor`, ...) that a bare `in` check would // otherwise treat as callable verbs. const declared = - typeof verbName === 'string' && (verbs as Array).includes(verbName) + typeof verbName === 'string' && + (verbs as Array).includes(verbName) // `Reflect.get` returns `any`; this is a single narrowing cast. const target = declared && typeof verbName === 'string' diff --git a/packages/ai/src/activities/transaction/types.ts b/packages/ai/src/activities/transaction/types.ts index 7d0ecefe3..d66e98185 100644 --- a/packages/ai/src/activities/transaction/types.ts +++ b/packages/ai/src/activities/transaction/types.ts @@ -134,10 +134,7 @@ export interface OneShotVerb { } /** The options accepted by {@link verb}. */ -export interface VerbOptions< - TSchema extends SchemaInput | undefined, - TResult, -> { +export interface VerbOptions { /** * Standard Schema (Zod, ArkType, Valibot, ...) describing the input. * Validated at runtime before `execute`; drives the client-side input type. diff --git a/packages/ai/tests/activities/transaction/index.test.ts b/packages/ai/tests/activities/transaction/index.test.ts index 06016b039..cbeff77de 100644 --- a/packages/ai/tests/activities/transaction/index.test.ts +++ b/packages/ai/tests/activities/transaction/index.test.ts @@ -152,9 +152,7 @@ describe('one-shot verbs', () => { execute, }), }) - const res = await txn.handler( - req(runAgentBody('banner', { prompt: 42 })), - ) + const res = await txn.handler(req(runAgentBody('banner', { prompt: 42 }))) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('Invalid verb input') @@ -272,14 +270,26 @@ describe('transactions (ctx.call)', () => { it('collects text and structured output from a chat verb sub-run', async () => { const drafting = chatVerb(async function* (r: any) { yield { type: 'RUN_STARTED', threadId: r.threadId, runId: r.runId } as any - yield { type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'Hel' } as any - yield { type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'lo' } as any + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + delta: 'Hel', + } as any + yield { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + delta: 'lo', + } as any yield { type: 'CUSTOM', name: 'structured-output.complete', value: { object: { title: 'Foxes' }, raw: '{"title":"Foxes"}' }, } as any - yield { type: 'RUN_FINISHED', threadId: r.threadId, runId: r.runId } as any + yield { + type: 'RUN_FINISHED', + threadId: r.threadId, + runId: r.runId, + } as any } as any) const txn = defineTransaction({ drafting, From e8d661be8f7fa36922a6e4a8981ae08f610bf2a3 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 14 Jul 2026 20:18:33 -0700 Subject: [PATCH 5/9] docs(transaction): align examples with lib/blog-studio module layout Update overview, transactions, scenarios, and the transaction skill to document blogTxnDef exported from lib/blog-studio.ts instead of inert client mirrors or import-type-from-api-route snippets. Co-authored-by: Cursor --- docs/config.json | 3 +- docs/transaction/overview.md | 46 ++++++++++++------- docs/transaction/scenarios.md | 5 +- docs/transaction/transactions.md | 19 +++----- .../ai/skills/ai-core/transaction/SKILL.md | 46 +++++++++++++------ 5 files changed, 72 insertions(+), 47 deletions(-) diff --git a/docs/config.json b/docs/config.json index 58fb5a3f5..79022d30f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -326,7 +326,8 @@ { "label": "Scenarios", "to": "transaction/scenarios", - "addedAt": "2026-07-14" + "addedAt": "2026-07-14", + "updatedAt": "2026-07-14" } ] }, diff --git a/docs/transaction/overview.md b/docs/transaction/overview.md index 86464a618..248f5fd00 100644 --- a/docs/transaction/overview.md +++ b/docs/transaction/overview.md @@ -22,9 +22,9 @@ Verb names are *your* domain language, not a fixed set of library nouns. A blog `defineTransaction()` lives on the `/transaction` subpath of `@tanstack/ai`, and `useTransaction()` on the `/transaction` subpath of `@tanstack/ai-react` (Solid and Vue follow the same pattern; Svelte exports `createTransaction` from `@tanstack/ai-svelte/transaction`) — not the package root. ```ts -// api/blog-studio.ts +// lib/blog-studio.ts — define once; the API route only calls `.handler` import { chat, generateImage, generateSpeech } from '@tanstack/ai' -import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { chatVerb, clientTransaction, defineTransaction, verb } from '@tanstack/ai/transaction' import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' import { z } from 'zod' @@ -59,26 +59,26 @@ export const blogTransaction = defineTransaction({ }), }) +export const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + narration: 'one-shot', + blogPost: 'one-shot', +}) +``` + +```ts +// routes/api.blog-studio.ts — thin server route export const POST = (request: Request) => blogTransaction.handler(request) ``` That one route now serves conversational drafting, image generation, and narration. On the client, one hook drives all three: ```tsx -// components/BlogStudio.tsx +// routes/blog-studio.tsx import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' -import { clientTransaction } from '@tanstack/ai/transaction' -import type { blogTransaction } from './api/blog-studio' - -// Type-only binding to the server definition — no provider imports in the -// browser bundle. See "Sharing the definition with the client" below. -const blogTxnDef = clientTransaction({ - drafting: 'chat', - heroImage: 'one-shot', - narration: 'one-shot', - blogPost: 'one-shot', -}) +import { blogTxnDef } from '../lib/blog-studio' function BlogStudio() { const txn = useTransaction(blogTxnDef, { @@ -161,7 +161,21 @@ On the client, `txn.primaryChat` and `txn.summaryChat` are two fully independent `useTransaction` needs the definition's **type** to build the typed client — verb names and kinds at runtime, input/result/tool/schema types at compile time. -The recommended pattern is **`clientTransaction({ kinds })`**: define the transaction once on the server (export it, or export a `ReturnType` type when the definition is built per-request), then bind it on the client with a verb-name → kind map. The generic is supplied via `import type` — erased from the bundle, so provider SDKs never ship to the browser. The kinds map is checked exhaustively against the server definition, so drift fails at compile time. +The recommended pattern is to **`defineTransaction` once in a shared module** and export a **`blogTxnDef`** client stub beside it via `clientTransaction({ kinds })`. The page imports `blogTxnDef`; the API route imports `blogTransaction` and calls `.handler`. The kinds map is checked exhaustively against the server definition, so drift fails at compile time. + +```ts +// lib/blog-studio.ts +export const blogTransaction = defineTransaction({ /* … */ }) + +export const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + narration: 'one-shot', + blogPost: 'one-shot', +}) +``` + +When the definition is built per-request inside a handler (for example, to pick adapters from routing props), export a `ReturnType` type and bind on the client with `import type` — the generic is erased from the bundle: ```tsx import type { blogTransaction } from './api/blog-studio' @@ -175,7 +189,7 @@ const blogTxnDef = clientTransaction({ }) ``` -When the definition truly has no provider imports — a shared isomorphic module works fine — you can also export one `defineTransaction(...)` value and pass it to both `handler` and `useTransaction` directly. The [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/routes/blog-studio.tsx) uses `clientTransaction` end to end. +When the definition truly has no provider imports, you can also pass one shared `defineTransaction(...)` value to both `handler` and `useTransaction` directly. The [`blog-studio` example](https://github.com/TanStack/ai/blob/main/examples/ts-react-chat/src/lib/blog-studio.ts) defines both exports in one lib module. ## When should you reach for a transaction endpoint? diff --git a/docs/transaction/scenarios.md b/docs/transaction/scenarios.md index d3b7265e1..f3cb6fda0 100644 --- a/docs/transaction/scenarios.md +++ b/docs/transaction/scenarios.md @@ -96,8 +96,7 @@ import { useTransaction } from '@tanstack/ai-react/transaction' import { openaiText } from '@tanstack/ai-openai' // The same definition your server route exports — share it from one module -// in a real app; mirrored here so this snippet type-checks on its own. It -// is inert in the browser: the callback never runs client-side. +// in a real app; repeated here so this snippet type-checks on its own. const supportTransaction = defineTransaction({ supportChat: chatVerb((req) => chat({ adapter: openaiText('gpt-5.5'), messages: req.messages }), @@ -157,7 +156,7 @@ function StudioPage() { } ``` -Each page's `txn` object is typed to exactly its definition's verbs — `SupportPage` has no `txn.heroImage` to misuse, and there's no way to accidentally call the support route with an image request. (When a route builds its definition inside the handler, mirror it client-side as described in [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client).) +Each page's `txn` object is typed to exactly its definition's verbs — `SupportPage` has no `txn.heroImage` to misuse, and there's no way to accidentally call the support route with an image request. (When a route builds its definition inside the handler, bind the client with `clientTransaction` as described in [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client).) ## Split, or one broad definition? diff --git a/docs/transaction/transactions.md b/docs/transaction/transactions.md index dd9dabd49..443c26b71 100644 --- a/docs/transaction/transactions.md +++ b/docs/transaction/transactions.md @@ -26,7 +26,7 @@ One click turns a topic into a finished post: draft the article as a typed objec ### Server: a `blogPost` verb that composes its siblings ```ts -// api/blog-studio.ts +// lib/blog-studio.ts import { chat, generateImage, generateSpeech } from '@tanstack/ai' import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' import { openaiImage, openaiSpeech, openaiText } from '@tanstack/ai-openai' @@ -114,7 +114,10 @@ export const blogTransaction = defineTransaction({ narration, blogPost, }) +``` +```ts +// routes/api.blog-studio.ts export const POST = (request: Request) => blogTransaction.handler(request) ``` @@ -126,18 +129,10 @@ export const POST = (request: Request) => blogTransaction.handler(request) ### Client: one call, live progress, typed result ```tsx -// components/BlogStudio.tsx +// routes/blog-studio.tsx import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' -import { clientTransaction } from '@tanstack/ai/transaction' -import type { blogTransaction } from './api/blog-studio' - -const blogTxnDef = clientTransaction({ - drafting: 'chat', - heroImage: 'one-shot', - narration: 'one-shot', - blogPost: 'one-shot', -}) +import { blogTxnDef } from '../lib/blog-studio' function BlogStudio() { const txn = useTransaction(blogTxnDef, { @@ -185,7 +180,7 @@ function BlogStudio() { } ``` -`blogPost.result` is typed as `{ post, hero, audio } | null` — inferred from `execute`'s return type on the server definition, nothing re-declared on the client. (If your route builds the definition inside the handler, mirror it client-side as described in [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client) — the mirrored bodies never execute in the browser.) +`blogPost.result` is typed as `{ post, hero, audio } | null` — inferred from `execute`'s return type on the server definition, nothing re-declared on the client. The lib module exports a `blogTxnDef` stub via `clientTransaction` so the page never duplicates verb callbacks (see [Sharing the definition with the client](./overview#sharing-the-definition-with-the-client)). And because `heroImage` and `narration` are ordinary declared verbs, the *same* client can also drive them directly — `txn.heroImage.run({ prompt })` for a "regenerate hero image" button — without another endpoint or another definition. Server-composed and user-driven are two ways to call the same verbs. diff --git a/packages/ai/skills/ai-core/transaction/SKILL.md b/packages/ai/skills/ai-core/transaction/SKILL.md index 8c7d94833..7e51bfc97 100644 --- a/packages/ai/skills/ai-core/transaction/SKILL.md +++ b/packages/ai/skills/ai-core/transaction/SKILL.md @@ -53,9 +53,9 @@ a request actually reaches `handler`, so it's safe to import into an isomorphic module shared with the client. ```typescript -// src/lib/blog-transaction.ts — shared/isomorphic module +// src/lib/blog-studio.ts — shared module: server definition + client stub import { chat, generateImage } from '@tanstack/ai' -import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' +import { chatVerb, clientTransaction, defineTransaction, verb } from '@tanstack/ai/transaction' import { openaiText, openaiImage } from '@tanstack/ai-openai/adapters' import { z } from 'zod' import { BlogPostSchema } from './schemas' @@ -103,12 +103,18 @@ export const blogTransaction = defineTransaction({ heroImage, blogPost, }) + +export const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + blogPost: 'one-shot', +}) ``` ```typescript // src/routes/api.blog-studio.ts — server route, single handler import { createFileRoute } from '@tanstack/react-router' -import { blogTransaction } from '../lib/blog-transaction' +import { blogTransaction } from '../lib/blog-studio' export const Route = createFileRoute('/api/blog-studio')({ server: { @@ -132,10 +138,10 @@ before any callback runs. ```tsx import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' -import { blogTransaction } from '../lib/blog-transaction' +import { blogTxnDef } from '../lib/blog-studio' function BlogStudio() { - const txn = useTransaction(blogTransaction, { + const txn = useTransaction(blogTxnDef, { connection: fetchServerSentEvents('/api/blog-studio'), }) @@ -290,9 +296,22 @@ neither field is present on the type — same conditional shape as ### 5. Client needs the definition's TYPE — use `clientTransaction` `useTransaction` reads verb names/kinds at runtime and everything else at -the type level. Define the transaction on the server, export it (or export -a `ReturnType` type when the definition is built -per-request), then bind it on the client with `clientTransaction`: +the type level. Define the transaction in a shared lib module, export +`blogTransaction` for the API route and `blogTxnDef` beside it: + +```tsx +// lib/blog-studio.ts +export const blogTransaction = defineTransaction({ /* … */ }) + +export const blogTxnDef = clientTransaction({ + drafting: 'chat', + heroImage: 'one-shot', + blogPost: 'one-shot', +}) +``` + +When the definition is built per-request inside a handler, export a +`ReturnType` type and bind with `import type`: ```tsx import type { blogTransaction } from './api/blog-studio' @@ -301,16 +320,13 @@ import { clientTransaction } from '@tanstack/ai/transaction' const blogTxnDef = clientTransaction({ drafting: 'chat', heroImage: 'one-shot', - narration: 'one-shot', blogPost: 'one-shot', }) ``` -The generic is supplied via `import type` — erased from the bundle — so -provider SDKs never ship to the browser. The kinds map is checked -exhaustively against the server definition. When the definition truly has -no provider imports, a shared isomorphic `defineTransaction` module also -works (see `examples/ts-react-chat` blog-studio routes). +The generic is supplied via `import type` — erased from the bundle. The +kinds map is checked exhaustively against the server definition. See +`examples/ts-react-chat/src/lib/blog-studio.ts` for the full pattern. ### 6. Only declared verbs are constructed; one shared connection @@ -355,7 +371,7 @@ are `id`/`threadId` and the nested `verbs` map: chat verbs take useTransaction(blogTransaction, { connection, model: 'gpt-5.5' }) // CORRECT -useTransaction(blogTransaction, { +useTransaction(blogTxnDef, { connection: fetchServerSentEvents('/api/blog-studio'), verbs: { heroImage: { onResult: (r) => r.images[0]?.url ?? null } }, }) From 95259fa9317093124c20084436e66fbcfc18c671 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 14 Jul 2026 20:18:45 -0700 Subject: [PATCH 6/9] docs(transaction): fix intro snippet verb kinds to match defineTransaction Co-authored-by: Cursor --- docs/transaction/overview.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/transaction/overview.md b/docs/transaction/overview.md index 248f5fd00..565d6035a 100644 --- a/docs/transaction/overview.md +++ b/docs/transaction/overview.md @@ -63,7 +63,6 @@ export const blogTxnDef = clientTransaction({ drafting: 'chat', heroImage: 'one-shot', narration: 'one-shot', - blogPost: 'one-shot', }) ``` From 20398086c921f223a54873f271a9837f92585a61 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:20:31 +0000 Subject: [PATCH 7/9] ci: apply automated fixes --- packages/ai/skills/ai-core/transaction/SKILL.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ai/skills/ai-core/transaction/SKILL.md b/packages/ai/skills/ai-core/transaction/SKILL.md index 7e51bfc97..19ab563e2 100644 --- a/packages/ai/skills/ai-core/transaction/SKILL.md +++ b/packages/ai/skills/ai-core/transaction/SKILL.md @@ -55,7 +55,12 @@ isomorphic module shared with the client. ```typescript // src/lib/blog-studio.ts — shared module: server definition + client stub import { chat, generateImage } from '@tanstack/ai' -import { chatVerb, clientTransaction, defineTransaction, verb } from '@tanstack/ai/transaction' +import { + chatVerb, + clientTransaction, + defineTransaction, + verb, +} from '@tanstack/ai/transaction' import { openaiText, openaiImage } from '@tanstack/ai-openai/adapters' import { z } from 'zod' import { BlogPostSchema } from './schemas' @@ -301,7 +306,9 @@ the type level. Define the transaction in a shared lib module, export ```tsx // lib/blog-studio.ts -export const blogTransaction = defineTransaction({ /* … */ }) +export const blogTransaction = defineTransaction({ + /* … */ +}) export const blogTxnDef = clientTransaction({ drafting: 'chat', From d4627b46f18baeab726b3bc46e2781643cbf7d39 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 14 Jul 2026 21:04:56 -0700 Subject: [PATCH 8/9] fix(transaction): satisfy kiira doc checks and eslint on clientTransaction cast Add kiira group tags and explicit snippet types in transaction docs, and document the phantom-only double cast in clientTransaction with an eslint opt-out matching the chat activity pattern. Co-authored-by: Cursor --- docs/transaction/overview.md | 15 +++++++++------ docs/transaction/transactions.md | 19 +++++++++++++------ .../ai/src/activities/transaction/index.ts | 8 ++++++-- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/transaction/overview.md b/docs/transaction/overview.md index 565d6035a..cca573a69 100644 --- a/docs/transaction/overview.md +++ b/docs/transaction/overview.md @@ -21,7 +21,7 @@ Verb names are *your* domain language, not a fixed set of library nouns. A blog `defineTransaction()` lives on the `/transaction` subpath of `@tanstack/ai`, and `useTransaction()` on the `/transaction` subpath of `@tanstack/ai-react` (Solid and Vue follow the same pattern; Svelte exports `createTransaction` from `@tanstack/ai-svelte/transaction`) — not the package root. -```ts +```ts group=overview-1 // lib/blog-studio.ts — define once; the API route only calls `.handler` import { chat, generateImage, generateSpeech } from '@tanstack/ai' import { chatVerb, clientTransaction, defineTransaction, verb } from '@tanstack/ai/transaction' @@ -66,17 +66,18 @@ export const blogTxnDef = clientTransaction({ }) ``` -```ts +```ts group=overview-1 // routes/api.blog-studio.ts — thin server route export const POST = (request: Request) => blogTransaction.handler(request) ``` That one route now serves conversational drafting, image generation, and narration. On the client, one hook drives all three: -```tsx +```tsx group=overview-client // routes/blog-studio.tsx import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' +import type { UIMessage } from '@tanstack/ai-react' import { blogTxnDef } from '../lib/blog-studio' function BlogStudio() { @@ -87,7 +88,7 @@ function BlogStudio() { return (
- {txn.drafting.messages.map((message) => ( + {txn.drafting.messages.map((message: UIMessage) => (

{message.parts.find((part) => part.type === 'text')?.content}

@@ -120,7 +121,7 @@ Both `txn..run(input)` and `txn..sendMessage(...)` **resolve You can declare **several verbs of the same kind** — including several chat verbs. Each gets its own conversation state, system prompt, and even model: -```ts +```ts group=overview-2 // api/support.ts import { chat } from '@tanstack/ai' import { chatVerb, defineTransaction } from '@tanstack/ai/transaction' @@ -162,7 +163,9 @@ On the client, `txn.primaryChat` and `txn.summaryChat` are two fully independent The recommended pattern is to **`defineTransaction` once in a shared module** and export a **`blogTxnDef`** client stub beside it via `clientTransaction({ kinds })`. The page imports `blogTxnDef`; the API route imports `blogTransaction` and calls `.handler`. The kinds map is checked exhaustively against the server definition, so drift fails at compile time. -```ts +```ts group=overview-sharing +import { clientTransaction, defineTransaction } from '@tanstack/ai/transaction' + // lib/blog-studio.ts export const blogTransaction = defineTransaction({ /* … */ }) diff --git a/docs/transaction/transactions.md b/docs/transaction/transactions.md index 443c26b71..6e2d0cdcc 100644 --- a/docs/transaction/transactions.md +++ b/docs/transaction/transactions.md @@ -25,7 +25,7 @@ One click turns a topic into a finished post: draft the article as a typed objec ### Server: a `blogPost` verb that composes its siblings -```ts +```ts group=transactions // lib/blog-studio.ts import { chat, generateImage, generateSpeech } from '@tanstack/ai' import { chatVerb, defineTransaction, verb } from '@tanstack/ai/transaction' @@ -116,7 +116,7 @@ export const blogTransaction = defineTransaction({ }) ``` -```ts +```ts group=transactions // routes/api.blog-studio.ts export const POST = (request: Request) => blogTransaction.handler(request) ``` @@ -128,10 +128,11 @@ export const POST = (request: Request) => blogTransaction.handler(request) ### Client: one call, live progress, typed result -```tsx +```tsx group=transactions // routes/blog-studio.tsx import { fetchServerSentEvents } from '@tanstack/ai-react' import { useTransaction } from '@tanstack/ai-react/transaction' +import type { TransactionSubRun } from '@tanstack/ai-client/transaction' import { blogTxnDef } from '../lib/blog-studio' function BlogStudio() { @@ -141,9 +142,15 @@ function BlogStudio() { const { blogPost } = txn // One entry per `ctx.call` the server made, keyed by verb name. - const draftingRun = blogPost.subRuns.find((run) => run.verb === 'drafting') - const heroRun = blogPost.subRuns.find((run) => run.verb === 'heroImage') - const narrationRun = blogPost.subRuns.find((run) => run.verb === 'narration') + const draftingRun = blogPost.subRuns.find( + (run: TransactionSubRun) => run.verb === 'drafting', + ) + const heroRun = blogPost.subRuns.find( + (run: TransactionSubRun) => run.verb === 'heroImage', + ) + const narrationRun = blogPost.subRuns.find( + (run: TransactionSubRun) => run.verb === 'narration', + ) return (
diff --git a/packages/ai/src/activities/transaction/index.ts b/packages/ai/src/activities/transaction/index.ts index b3b5548e0..a3322c9b9 100644 --- a/packages/ai/src/activities/transaction/index.ts +++ b/packages/ai/src/activities/transaction/index.ts @@ -250,7 +250,7 @@ export function clientTransaction>( kinds: ClientTransactionKinds, ): TDef { const verbs = Object.keys(kinds) as Array - return { + const stub = { verbs, verbKinds: kinds, handler: async () => { @@ -259,7 +259,11 @@ export function clientTransaction>( ) }, '~verbs': {} as TDef['~verbs'], - } as unknown as TDef + } + // Type-only stub: runtime value carries verb names/kinds; `~verbs` phantom + // comes from the generic `TDef` supplied by the caller. + // eslint-disable-next-line no-restricted-syntax -- phantom-only cast, see comment above + return stub as unknown as TDef } /** From 20fe8c2632793ec9285b84c2240251d2f3b62c6c Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 14 Jul 2026 21:20:34 -0700 Subject: [PATCH 9/9] fix(transaction): remove relative import from clientTransaction JSDoc example The emitted .d.ts picked up `./api.blog-studio` from the @example block and failed scan-dangling-dts in CI. Co-authored-by: Cursor --- packages/ai/src/activities/transaction/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/activities/transaction/index.ts b/packages/ai/src/activities/transaction/index.ts index a3322c9b9..6e1cb056e 100644 --- a/packages/ai/src/activities/transaction/index.ts +++ b/packages/ai/src/activities/transaction/index.ts @@ -235,9 +235,9 @@ export function defineTransaction( * * @example * ```ts - * import type { blogTransaction } from './api.blog-studio' * import { clientTransaction } from '@tanstack/ai/transaction' * + * // `blogTransaction` is your server-side defineTransaction() export. * const txnDef = clientTransaction({ * drafting: 'chat', * heroImage: 'one-shot',