Skip to content

Commit 2e0e2eb

Browse files
tombeckenhamclaudeAlemTuzlakautofix-ci[bot]
authored
feat: structured-output as a typed UIMessage part (#577)
* feat(ai-react,ai-client,ai): structured-output as a typed UIMessage part `useChat({ outputSchema })` previously held a single hook-level `partial`/`final` slot, so multi-turn structured chats lost prior turns the moment a new one streamed in. Each assistant turn now carries its own typed `structured-output` MessagePart on the UIMessage it belongs to. History walks `messages` per turn; the hook-level `partial` / `final` are derived from the latest assistant message's part and keep their existing shape for backwards compat. Server-side `chat({ outputSchema, stream: true })` emits a new `structured-output.start` CUSTOM event before the JSON deltas so the client-side StreamProcessor routes them into the StructuredOutputPart instead of building a TextPart. The wire converter serializes the part's raw JSON back as assistant content so the LLM sees its own prior structured response on follow-up turns. New example: `/generations/structured-chat` in ts-react-chat demonstrates the multi-turn refinement flow (a recipe builder where each turn refines the recipe and old turns stay typed and renderable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai,ai-client,ai-react): address PR-review findings on structured-output parts Fixes from the multi-agent review of the structured-output message-part design: - Add structured-output case to uiMessageToModelMessages so the ModelMessage path produces the same wire content as the AG-UI path. Without this, the multi-turn coherence the PR was meant to deliver was silently broken for any consumer routing through ModelMessage. - Tighten the messageId guard in the server-side stream loop. An adapter emitting TEXT_MESSAGE_CONTENT with no usable messageId previously caused the synthetic structured-output.start never to fire, the JSON deltas rendered as plain text, and partial/final stayed empty forever. Now emits a RUN_ERROR with a clear message instead. - completeStructuredOutputPart resolves a non-empty raw at construction time (caller-supplied, then existing, then JSON.stringify(data)), so downstream consumers never see a complete part with empty raw. Wire converter no longer needs the stringify fallback. - collectText in ag-ui-wire skips structured-output parts that aren't complete. Streaming or errored parts would have shipped malformed JSON fragments as assistant content on the next turn. - removeMessagesAfter now also drops stale entries from structuredMessageIds so reload() can't land deltas on a phantom messageId. - errorStructuredOutputPart creates an empty errored placeholder when no streaming part exists yet, so RUN_ERROR firing between structured-output.start and the first delta still leaves the UI something renderable. - De-dupe StructuredOutputPart by re-exporting from @tanstack/ai in ai-client/types.ts rather than redeclaring it. - Trim restating comments per the analyzer's findings. New tests: - interleaved tool-call + structured-output on the same message - reload-clears-routing - RUN_ERROR before any delta produces an error placeholder - RUN_ERROR after complete doesn't un-complete the part - streaming/errored structured parts skipped on the wire - complete-with-empty-raw skipped (defensive, completeStructuredOutputPart guarantees non-empty raw in practice) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit findings + e2e structured-output renderer - e2e ChatUI: render `structured-output` parts so the assistant message has visible text content. The Phase 1 redesign routes structured-output JSON deltas into a dedicated part instead of a text part, so the e2e selector `getLastAssistantMessage` was returning an empty string and the structured-output-stream specs failed. - processor: reconcile cumulative `chunk.content` against the existing StructuredOutputPart.raw before appending, mirroring the dedup the text path already does. Adapters that emit cumulative content (rather than incremental delta) on a structured run would otherwise duplicate the JSON buffer and corrupt the partial parse. Adds a focused unit test. - example: fix the recipe metadata block in /generations/structured-chat so a valid `estimatedCostUsd === 0` renders `$0.00` instead of being hidden by a truthy check. Minor CodeRabbit catch. The remaining CodeRabbit findings were either stale (raw fallback already addressed in the prior fix commit) or pre-existing (initialMessages mount-only hydration was on main before this branch — out of scope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(example): make structured-chat schema OpenAI-strict-compatible OpenAI's strict structured outputs reject integer types, min/max bounds, min/maxLength, min/maxItems, and defaults. The Recipe schema used `.int()`, `.min(...)`, and `.default([])`, which surfaced as a 500 server_error from OpenAI when running the example against gpt-4o. Strip the constraints and nudge the model via the description text + system prompt instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(example): add tiles for Structured Chat, Guitar Demo, and Realtime on homepage These routes already exist in the sidebar but weren't surfaced on the welcome screen tile grid. Mirrors the sidebar so first-time users see the full set of demos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(example): drop Guitar Demo / Realtime tiles from this PR Out of scope for the structured-output redesign — those tiles belong to a separate change. Keep the Structured Chat tile so the new demo is discoverable from the welcome screen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(example): harden api.structured-chat abort race and 500 sanitization - Address the TOCTOU window between the early-return `request.signal.aborted` check and `addEventListener('abort', ...)`. Use `{ once: true }` and a post-attach `aborted` re-check so a signal that fires between the two calls still triggers the controller. Aborting an already-aborted AbortController is a no-op per spec, so the post-attach call is safe. - Stop returning raw `error.message` to clients in the 500 response. Provider / internal error text (auth failures, model names, partial stack traces) could leak through the JSON body; log the full error server-side and ship a generic `Internal server error` message to the client. Both flagged by CodeRabbit on PR #577. * fix(ai,ai-react): address CR-loop findings on structured-output parts Round-2 of CR loop after the explicit CodeRabbit comments. Six review bugs plus comment-accuracy and coverage gaps surfaced across source files touched by this PR. Source fixes: - F1: `runStreamingStructuredOutputImpl` now synthesizes a `messageId` and emits `structured-output.start` before forwarding a RUN_ERROR that fires before any TEXT_MESSAGE_START. Without this, a pre-delta error (auth failure, network error before stream open, fallback adapter throwing synchronously) left the client with no `structured-output` part on the assistant message — the UI rendered nothing instead of an error state. - F2: `appendStructuredOutputDelta` preserves the previously-good `partial` when the progressive parse returns null/undefined for a transient delta. Avoids a one-render flicker back to empty when the buffer briefly stops being parseable. - F3: `StreamProcessor.finalizeStream` snaps any still-streaming `structured-output` part to `error` (with "Stream ended without structured-output.complete") and clears `structuredMessageIds`. Long- lived `subscribe()` instances no longer leak the routing entry across runs, and a UI never gets stuck on a perpetually "streaming" part. The loop is unconditional w.r.t. `hasError` because `handleRunErrorEvent` already removes its target id from the set, so iterating naturally finds only the never-completed entries (multi-run-safe). - SFH-4: `removeMessagesAfter` now prunes all four routing maps that key on messageId — `structuredMessageIds`, `messageStates`, `toolCallToMessage`, and `activeMessageIds` — not just the first. Previously a resumed stream that reused a dropped messageId could short-circuit `ensureAssistantMessage` against stale state and silently drop deltas. - SFH-6: `useChat`'s `activeStructuredPart` returns null when there is no user message yet. Otherwise an `initialMessages` containing a stale assistant turn would leak its `data` into the hook-level `final` on first render. - SFH-9: `handleRunErrorEvent` logs the original chunk via console.error when both `chunk.message` and `chunk.error?.message` are falsy. The fallback `'An error occurred'` no longer silently drops the chunk's remaining debug context (code, request ids, provider fields). Comment / docstring fixes: - `types.ts` `ApprovalRequestedEvent` / `ToolInputAvailableEvent` no longer reference the non-existent `buildApprovalChunks` / `buildClientToolChunks` symbols; they now point at the actual forwarding path in `runStreamingStructuredOutputImpl`. - `appendStructuredOutputDelta` docstring is honest about unconditionally writing `status: 'streaming'` and documents the new partial-preservation behavior. - `completeStructuredOutputPart` catch-block comment correctly describes BOTH downstream filters — `ag-ui-wire` skip and `uiMessageToModelMessages` `safeJsonStringify` fallback — instead of overclaiming a single "refuses to round-trip" guard. - `collectText` in `ag-ui-wire.ts` no longer claims `completeStructuredOutputPart` guarantees non-empty raw; the `p.raw !== ''` guard at the call site is now correctly identified as the real enforcement. - `removeMessagesAfter` comment names all four maps and explains why `activeMessageIds` is included. Test additions (each fix has a behavioral regression test verified via red-green inversion — temporarily reverting the source fix makes the corresponding test fail): - `chat-structured-output-stream.test.ts`: ordering tests for `structured-output.start` before deltas (gap from round 1) and before pre-delta RUN_ERROR (F1 regression). - `stream-processor.test.ts`: F3 regression (RUN_FINISHED without complete snaps to error), SFH-4 regression (resumed stream lands on a revived message after removeMessagesAfter), and the existing RUN_ERROR-with-empty-message test now spies the new console.error (try/finally guards against mock leakage on assertion failure). - `message-updaters.test.ts`: F2 regression (partial preserved across unparseable delta), terminal-only `JSON.stringify(data)` fallback, BigInt + circular catch branches, raw-resolution priority order, errored-placeholder and complete-status-preservation tests. - `message-converters.test.ts`: structured-output round-trip via `uiMessageToModelMessages` — five branches (complete-with-raw, empty-raw + serializable-data fallback, streaming-skip, errored-skip, unserializable-skip). - `use-chat-structured-output.test.ts`: SFH-6 regression (no `final` leak from `initialMessages` when no user message exists yet). Surfaced via PR #577 CR loop. Out of scope: the type-design analyzer recommends a status-discriminated union for `StructuredOutputPart` (lift invariants into the type), and the `initialMessages` mount-only effect is pre-existing on main. Both deferred. * feat(ai,ai-client,ai-react,example): thread schema generic through UIMessage parts `useChat({ outputSchema })` already infers the schema type for hook-level `partial` / `final`, but the `StructuredOutputPart` on `messages[i].parts` was stuck at `unknown` — consumers had to cast manually to recover the typed `data` field. This commit threads the schema-inferred type all the way down to the message-part variant so the cast is gone. Type changes: - `@tanstack/ai`: `StructuredOutputPart<TData = unknown>` becomes generic, `MessagePart<TData = unknown>` and `UIMessage<TData = unknown>` thread the generic. Defaults preserve backward compatibility — every existing call site (StreamProcessor, wire converter, message converters, helpers) gets `StructuredOutputPart<unknown>` and behaves identically. Adds `DeepPartial<T>` next to the existing types so the part's `partial?: DeepPartial<TData>` field carries honest typing. - `@tanstack/ai-client`: `MessagePart<TTools, TData = unknown>` and `UIMessage<TTools, TData = unknown>` accept the new generic and substitute `StructuredOutputPart<TData>` into the discriminated union. - `@tanstack/ai-react`: `BaseUseChatReturn<TTools, TData = unknown>` takes a `TData` parameter; `UseChatReturn<TTools, TSchema>` substitutes `TData = InferSchemaType<TSchema>` when a schema is supplied. The return now types `messages` as `Array<UIMessage<TTools, InferSchemaType<TSchema>>>`, so `messages[i].parts.find(p => p.type === 'structured-output').data` is `InferSchemaType<TSchema>` without any cast. Example update (also rewrites the structured-chat page's bare-bones UI): - Drops the index-walking `turns` loop that pre-paired user messages with assistants — TypeScript narrows `m.role === 'assistant'` against the typed messages array, so a direct `messages.map(...)` is simpler and equivalent. - Removes the `(p as { content: string })` cast in the user-message branch and the `as Partial<Recipe>` cast in `RecipeCard` — both recover their types via the new schema-typed parts. - Replaces the gray/orange minimal layout with a recipe-app feel: warm cream/amber palette, chef-branded header, 2x2 suggestion grid with cuisine tags, cuisine-aware hero banner emoji on each card, ingredients pill grid, numbered method steps, chef's tips callout, streaming and errored states styled to match. Verification: typecheck + lint + build clean across 33 / 31 / 42 nx projects, 832 unit tests in `@tanstack/ai` and 115 in `@tanstack/ai-react` pass. The example dev server renders the new empty state correctly (verified via Playwright snapshot). * docs(structured-outputs): split into top-level section with per-journey pages The single `chat/structured-outputs.md` page had grown to cover four non-overlapping consumer journeys — non-streaming extraction, streaming UIs, multi-turn structured chat, and the agent-loop combination — all under one heading. Readers landing on it had to scroll for their use case, the streaming section's "hide raw TextPart" advice was now wrong (this PR routes JSON deltas into a typed `StructuredOutputPart`, not a `TextPart`), and the new multi-turn capability had no documented home. Reorganized into a dedicated top-level `Structured Outputs` nav section with one page per journey: - `overview` — schema libraries, provider-support table, persona-based "which page do I read?" router. Single landing page that points at the three specific journeys + the with-tools combination. - `one-shot` — single prompt in, single typed object out. Non-streaming `chat({ outputSchema })`. Field descriptions, nested schemas, plain JSON Schema, error handling, best practices. - `streaming` — progressive UI with `useChat({ outputSchema })` and the hook-level `partial` / `final`. Corrected chunk-type table (deltas route into `StructuredOutputPart`, not `TextPart`). Migration note about the removed "hide TextPart" hack. Direct iteration sub-section. - `multi-turn` — per-message typed structured-output parts. Walks `messages[i].parts.find(p => p.type === 'structured-output')` with the schema generic flowing through `UIMessage<TTools, TData>` and `MessagePart<TTools, TData>`. Recipe-builder example with the typed `RecipePart` alias pattern. Round-trip serialization story. - `with-tools` — combining `outputSchema` with the agent loop. Pause / resume points for server-tool approvals and client-tool invocations inside a structured run. `docs/chat/structured-outputs.md` reduced to a short redirect-style stub that keeps the existing URL working (same `id` for stable bookmarks) and links into the new section. Existing cross-links in `adapters/openai.md` and `migration/migration-from-vercel-ai.md` updated to point at the new overview page. `docs/config.json` adds the new `Structured Outputs` section after `Chat` and removes the old `Chat → Structured Outputs` child. Verification: `pnpm test:docs` (the in-repo link checker) passes — 288 markdown files cross-checked with no broken links. * docs(structured-outputs): fact-check pass — fix hallucinated API claims Spawned a fact-checker over the five new pages against the actual code. Four real hallucinations surfaced, all fixed: - `one-shot.md` return-type table for `chat()` was missing the `stream: false` case. The full surface is four rows, not three: `Promise<string>` (no schema, no stream), `AsyncIterable<StreamChunk>` (no schema, streaming default), `Promise<InferSchemaType<T>>` (with schema, non-streaming), `StructuredOutputStream<…>` (with schema, streaming). The middle two were collapsed before. - `multi-turn.md` repeatedly claimed `MessagePart<TTools, TData>` and `UIMessage<TTools, TData>` as if those generics live everywhere. The core `@tanstack/ai` types are single-generic (`<TData>` only); only the `@tanstack/ai-client` types — which the framework hook packages re-export — carry both generics. Added a callout explaining the split so readers don't reach for the wrong import. - `with-tools.md` showed an `onToolCall` callback being passed to `useChat({ … })` for "manual control" of a client tool. That option doesn't exist on `ChatClientOptions` / `UseChatOptions` — `onToolCall` is an internal event on the `StreamProcessor` that the `ChatClient` subscribes to in order to auto-execute `.client()`-registered tools. The user-facing path is `toolDefinition(...).client((input) => ...)`; there's no manual handler shape. Rewrote the section to show the canonical `.client(...)` + `clientTools(...)` flow and explicitly note there's no `onToolCall` option. Also corrected the `clientTools` import — it lives in `@tanstack/ai-client`, not `@tanstack/ai`. - `streaming.md` showed `messageId: string` as a public field on the terminal `structured-output.complete` event's `value`. The runtime does attach it on every emit, but the exported `StructuredOutputCompleteEvent<T>` type only declares `{ object, raw, reasoning? }`. Dropped `messageId` from the typed payload diagram and explained that the `messageId` on the preceding `structured-output.start` event is the canonical place to read it from, with a footnote that the same value is attached on `complete` at runtime for consumers who need it. Imports re-audited across all five pages — every `import { … } from "@tanstack/…"` line now matches an actual exported symbol from that package's `index.ts`. `pnpm test:docs` link checker still passes (288 markdown files, no broken links). Items the fact-checker marked as plausible-but-unverified (provider wire-knob specifics in the overview table, exact tool-loop ordering in `with-tools.md`) were left as-is — they match the pre-existing documentation and would need adapter-level source review to either confirm or weaken; out of scope for this pass. * test(e2e): cover multi-turn structured chat across every provider Adds an end-to-end test for the multi-turn-structured-chat pattern documented in `docs/structured-outputs/multi-turn.md` and shipped as the recipe-builder example. Runs across every provider that supports both multi-turn and structured-output — native-streaming (openai, groq, grok, openrouter) and fallback paths (anthropic, gemini, ollama). All seven providers pass. New feature flag `multi-turn-structured` in the e2e harness: - `testing/e2e/src/lib/types.ts`: added to `Feature` union and `ALL_FEATURES`. - `testing/e2e/src/lib/feature-support.ts`: support set matches `multi-turn ∩ structured-output` — all seven supported providers. - `testing/e2e/src/lib/schemas.ts`: added `recipeSchema` mirroring the example app's RecipeSchema (title, cuisine, servings, estimatedCostUsd, ingredients[], steps[], tips[]) so the harness exercises the same shape end users see in the docs. - `testing/e2e/src/lib/features.ts`: per-feature `systemPrompt` override (defaulting to the existing guitar-store prompt for everything else); the new feature uses a chef persona. - `testing/e2e/src/routes/api.chat.ts`: routes the new feature through `chat({ outputSchema: recipeSchema, stream: true })`. Branched per feature so TS picks the right `chat<TSchema>()` overload without a `never` cast. Test (`testing/e2e/tests/multi-turn-structured.spec.ts`) walks a three-turn conversation — pasta -> vegan variant -> gluten-free + side salad — and asserts the load-bearing claims from the doc: 1. Each user prompt produces a new assistant message with its own `structured-output` part (one part per turn, all preserved as the conversation grows from one to three turns). 2. The first turn's recipe is NOT clobbered when the second turn lands (a single hook-level partial/final slot would fail this assertion — this is exactly the bug the per-message `StructuredOutputPart` design eliminates). 3. The hook-level `final` (exposed in the harness as the `structured-output-complete` testid's `data-structured-output` attribute) reflects only the LATEST turn's data — proving the derivation walks back to the most recent assistant message's part. Fixtures: `testing/e2e/fixtures/multi-turn-structured/conversation.json` ships three deterministic recipe responses keyed by user-message prefix. Each turn's response is valid JSON against `recipeSchema` with a unique title substring (Pomodoro / Vegan / Gluten-Free) so the test can pin the three turns apart without depending on the LLM. Routing assertion added to `structured-output-stream.spec.ts`: The streaming-UI doc claims `TEXT_MESSAGE_CONTENT` deltas land on a `structured-output` part — not a `text` part with raw JSON. Tightened the existing per-provider test so the assistant message must have exactly one `structured-output-part` and zero `text-part`s. To support this, added `data-testid="text-part"` to the `ChatUI` text renderer (previously untagged because tests read text via `assistant-message` content). Doesn't change behavior; gives the assertion something to count. Verified: `playwright test --grep "structured-output-stream|multi-turn-structured"` runs 15 tests across 7 providers, all pass. Out of scope for this commit: extending `with-tools.md` coverage to exercise tool approval inside a structured-output run. The basic agentic+structured path is already covered by `agentic-structured.spec.ts`; approval-mid-structured-run is a follow-up. * ci: apply automated fixes * skill(ai-core/structured-outputs): cover useChat + multi-turn patterns The structured-outputs agent skill was scoped to the server-side `chat({ outputSchema })` activity (patterns 1-3) and never mentioned the client-side hook surface. With PR #577 the structured-output payload becomes a typed `MessagePart` on `useChat`'s `messages` array and the multi-turn recipe-builder pattern lights up — the skill needs to teach both, otherwise agents will keep generating the old "hide the TextPart" hack and treat `partial` / `final` as singleton hook-level state. Skill updates: - Sources updated to point at the new top-level `docs/structured-outputs/*` pages (overview, one-shot, streaming, multi-turn, with-tools). The old stub at `docs/chat/structured-outputs.md` redirects to those. - Description rewritten to mention `useChat`, the per-turn `StructuredOutputPart`, and the derived `partial` / `final`. - New "decision: which pattern fits" table at the top so agents pick the right pattern by what they're building instead of pattern-matching the first example. - New Pattern 4 ("useChat with outputSchema — progressive UI") covers the server endpoint + client hook shape with typed `partial` / `final` and the same-shape-on-fallback-adapters note. - New Pattern 5 ("multi-turn structured chat") covers the recipe-builder shape — walking `messages` to render history, the `RecipePart = StructuredOutputPart<Recipe>` alias for typed `find()`, the schema-generic flow `useChat<TSchema>` → `UIMessage<TTools, TData>` → `MessagePart<TTools, TData>` → `StructuredOutputPart<TData>`, the partial/final-are-derived semantic, and the round-trip story. - Pattern 3 reframed as "direct stream iteration" (Node / CLI / tests) with a pointer to Pattern 4 for the in-browser case. Two new HIGH-severity common-mistake entries: - Filtering `TextPart`s out of `useChat` renderers when using `outputSchema`. That hack was needed when JSON deltas landed in a `TextPart`; with PR #577 they land in a dedicated `StructuredOutputPart` and the guard now hides legitimate text content. The entry shows the obsolete pattern explicitly and the correct part-type narrowing. - Treating `partial` / `final` as sticky state across turns. They're derived from the latest assistant message's `structured-output` part, not a singleton slot — `partial` reads `{}` between `sendMessage()` and the first chunk, `final` only reflects the most recent turn. Renders that need history must walk `messages` directly. The entry contrasts "render `final` only" (loses history) with "render every assistant's part" (correct). Cross-references add a pointer to `docs/structured-outputs/with-tools.md` on the tool-calling cross-link. Existing pre-multi-turn mistake entries (parsing partial JSON deltas yourself, provider-specific strategies, raw JSON Schema instead of project's library) preserved verbatim. `library_version` left as `0.10.0` — that bumps via changesets on release, not from this PR. * ci: apply automated fixes * skill(ai-core/structured-outputs): fact-check correction — fallback partial behavior Fact-check pass surfaced one soft hallucination in the Pattern 4 "non-streaming adapters" bullet. The skill claimed `partial` stays `{}` and `final` populates on arrival when an adapter uses the non-streaming fallback. Reality: `fallbackStructuredOutputStream` emits one whole-JSON `TEXT_MESSAGE_CONTENT` (the full `result.rawText` as a single delta) before yielding `structured-output.complete`. The processor routes that delta through `appendStructuredOutputDelta` and the progressive JSON parser, so `partial` does populate — just in the same render tick that `final` snaps, rather than incrementally field-by-field like the native-streaming path. User-facing effect is identical for almost every consumer (one render with both `partial` and `final` populated), but the literal "`partial` stays `{}`" claim was wrong. Reworded to describe what actually happens: one whole-JSON delta → `partial` populates and `final` snaps in the same tick, no field-by-field reveal. Verified the remaining 20+ skill claims (frontmatter source paths, import surface, partial/final derivation, generic flow, round-trip behavior, "filter TextParts" historical note, etc.) against current code — clean. * chore(self-learning): codify "skills + docs + e2e" rules after PR #577 miss After landing source changes, the example UI redesign, type-generic threading, and even the example app — but missing skill/doc updates until the user explicitly asked — codifying the rule so future runs catch this earlier: Three new lessons under `.agent/self-learning/lessons/`: - `update-skills-with-feature-work` — agent skills are a contract with downstream coding agents; surface changes need skill edits in the same PR. Includes the heuristic for "is this skill-relevant?" and a pointer to fact-checking the updated skill the same way as docs. - `update-docs-with-new-cases` — public docs are the canonical surface for users; new capabilities / behavior changes need doc edits in the same PR. Includes the IA-planning callout for cases that warrant a new page, the `pnpm test:docs` link-checker step, and a pointer to fact-checking against current source. Both lessons link to each other and to the existing one. Indexed in `INDEX.md` so they load every turn. Three coupling rules in `coupling.json`: - `ai-source-touches-agent-skills` — fires on any source change under `packages/typescript/ai*/src/**/*.ts`. Lists every skill file so the plan-time hook surfaces the full set and the agent picks the matching one(s). Includes a routing key from src-subdir to skill in the `why` so the hint is actionable, not just "check skills". - `ai-source-touches-public-docs` — fires on the same trigger; impacts the relevant doc trees with case notes for the three patterns (contradicts existing advice, opens a new use case, type/signature change). - `ai-source-touches-e2e-coverage` — same trigger; impacts the e2e spec/fixture/feature-support files. Encodes the established pattern for adding a new feature flag end-to-end (per CLAUDE.md's "E2E tests are mandatory" rule) so a future run doesn't reinvent it. `kind: change-required` for skills and docs (existing files must be updated when surface changes); `kind: new-code-required` for e2e (a new feature adds new spec + fixture files rather than editing existing ones). All three couplings carry the "skip only for pure refactors / perf / internal-only changes" escape hatch so they don't fire spuriously on unrelated source edits. Hook + lesson together = both plan-time signal AND in-context guidance. Lessons explain "why care"; couplings give "here are the specific paths". * feat(ai-vue,ai-solid,ai-svelte): bring structured-output parity from ai-react PR #577 landed the typed per-message `StructuredOutputPart` design in `@tanstack/ai-react` but left the other framework packages on the old hook-level singleton state + RUN_STARTED reset. Bringing all four hook surfaces back to parity so users on Vue, Solid, and Svelte get the same multi-turn structured-output story (history-preserving typed parts on every assistant message, schema generic flowing through `messages` to `parts[i].data`) the React docs and skill already advertise. Per-package mirror of the React work: - `BaseUseChatReturn<TTools, TData>` (Vue, Solid) and `BaseCreateChatReturn<TTools, TData>` (Svelte) take the new generic. `UseChatReturn<TTools, TSchema>` / `CreateChatReturn<TTools, TSchema>` substitute `TData = InferSchemaType<TSchema>` when a schema is supplied, so `messages` is typed as `Array<UIMessage<TTools, T>>` and `messages[i].parts.find(p => p.type === 'structured-output').data` resolves to `T` (not `unknown`) — same end-user ergonomics as React. - `setMessages` / `append` parameter types thread `TData` through as well so the public surface is consistent. `use-chat.ts` (Vue), `use-chat.ts` (Solid), and `create-chat.svelte.ts` (Svelte): - Dropped the `parsePartialJSON` import, the `rawJson` accumulator, and the `onChunk`-based `RUN_STARTED → reset → TEXT_MESSAGE_CONTENT → parse → structured-output.complete → snap` state machine. None of it is needed anymore — the per-message `StructuredOutputPart` on `messages` already carries everything. - Added `activeStructuredPart` derivations using each framework's primitive: `computed()` (Vue), `createMemo()` (Solid), `$derived.by()` (Svelte). All three implement the same `messages` scan as React's `use-chat.ts`: walk backwards to the latest user message, then scan forward for an assistant message carrying a structured-output part. Return null when no user message exists yet so a stale `final` from `initialMessages` can't leak in on first render (the SFH-6 fix from PR #577). - `partial` and `final` are now `computed` / `createMemo` / `$derived` reading from `activeStructuredPart` — `partial = part.partial ?? part.data ?? {}`, `final = part.status === 'complete' ? part.data : null`. Identical semantics across all four frameworks. Type-level tests in each package's `tests/use-chat-types.test.ts` / `tests/create-chat-types.test.ts` extended with two new assertions: - When `outputSchema` is supplied, the structured-output variant of `messages[i].parts[j]` resolves to `StructuredOutputPart<Person>` (not the default `<unknown>`), and `data` types as `Person | undefined`. Locks in the schema-generic flow at the type level. - Without `outputSchema`, the variant defaults to `StructuredOutputPart<unknown>` and `data` is `unknown | undefined`. Locks in the backward-compatible default. `@tanstack/ai-preact` deliberately left alone — it doesn't support `outputSchema` at all (verified by grep), which matches the docs' "useChat (React, Vue, Solid) and createChat (Svelte) all accept the same outputSchema option" claim. Adding it to preact is a separate feature, not parity work. Verification: - All three packages build cleanly (`nx run-many -t build`). - Type-level tests pass and now exercise the schema-generic flow. - `nx run-many -t test:lib,test:types,test:eslint` clean across the monorepo. 256 tests across the three frameworks (94 + 58 + 104), all green. End-to-end coverage is currently React-only (the e2e harness ships a React app at `testing/e2e/src/routes/`) — adding parallel Vue / Solid / Svelte harness apps is a separate, larger lift and out of scope for this parity pass. The unit-level type tests pin the API contract; runtime behavior is identical to React because all four hooks ride the same `ChatClient` + `StreamProcessor` underneath. * chore(changeset): include all six framework packages bumped by PR #577 The structured-output-as-message-part changeset only listed `@tanstack/ai`, `@tanstack/ai-client`, and `@tanstack/ai-react` from when the original PR was scoped to React. Now that the same per-message typed `StructuredOutputPart` design, schema-generic threading through `UIMessage<TTools, TData>`, and `partial`/`final` derivation has landed in `@tanstack/ai-vue`, `@tanstack/ai-solid`, and `@tanstack/ai-svelte`, the changeset needs to bump those at minor too — otherwise the parity ships without a version bump and consumers on Vue/Solid/Svelte get the new behavior without notice in their lockfile diff. Added the three framework packages at `minor` (matching the other three) and expanded the description to: - Cover all four hook surfaces (`useChat` for React/Vue/Solid, `createChat` for Svelte) instead of just `useChat`. - Document the schema-generic threading (`StructuredOutputPart<TData = unknown>`, `MessagePart<TTools, TData>`, `UIMessage<TTools, TData>`) since that's the user-facing shape change agents reading the release notes care about. Default `TData = unknown` preserves source compatibility for consumers who don't pass a schema. - Note the adapter fallback path (Anthropic / Gemini / Ollama still emit one terminal `structured-output.complete` and the per-turn typed part still lands — consumer code is identical). - Add a "breaking-shape note" callout that explicitly stays minor: with `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas no longer create a `TextPart`. The old "filter out TextPart to hide JSON" workaround the prior docs recommended is now a no-op (and removable), not a regression — no `TextPart` is produced. Verified via `pnpm exec changeset status`: six packages bump at minor (the ones listed above), no major bumps, downstream consumers auto-patch via `updateInternalDependencies: "patch"`. The sibling `use-chat-reset-on-action.md` changeset is from PR #576 (merged earlier into this branch) — left untouched; it documents an ai-react-only fix at patch severity and that's accurate for that change. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 3026ee6 commit 2e0e2eb

54 files changed

Lines changed: 4239 additions & 857 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agent/self-learning/INDEX.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ If a `Use when ...` condition matches the current task, read the full lesson fil
77
<!-- Auto-managed by self-improve plugin. Manual edits preserved between markers. -->
88

99
- [build-before-running-examples](lessons/2026-05-14-build-before-running-examples.md) — Use when starting any tanstack/ai example dev server — build workspace packages first
10+
- [update-skills-with-feature-work](lessons/2026-05-19-update-skills-with-feature-work.md) — Use when implementing a feature in tanstack/ai that touches a surface covered by an agent skill — the skill needs to be updated in the same PR
11+
- [update-docs-with-new-cases](lessons/2026-05-19-update-docs-with-new-cases.md) — Use when adding a new use case, capability, or behavior change to tanstack/ai — public docs that cover the surface need updating in the same PR
1012
<!-- LESSONS:END -->

.agent/self-learning/coupling.json

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,66 @@
11
{
22
"$schema": "./coupling.schema.json",
3-
"couplings": []
3+
"couplings": [
4+
{
5+
"id": "ai-source-touches-agent-skills",
6+
"trigger": "packages/typescript/ai*/src/**/*.ts",
7+
"impacts": [
8+
{
9+
"target": [
10+
"packages/typescript/ai/skills/ai-core/structured-outputs/SKILL.md",
11+
"packages/typescript/ai/skills/ai-core/chat-experience/SKILL.md",
12+
"packages/typescript/ai/skills/ai-core/tool-calling/SKILL.md",
13+
"packages/typescript/ai/skills/ai-core/adapter-configuration/SKILL.md",
14+
"packages/typescript/ai/skills/ai-core/middleware/SKILL.md",
15+
"packages/typescript/ai/skills/ai-core/media-generation/SKILL.md",
16+
"packages/typescript/ai/skills/ai-core/ag-ui-protocol/SKILL.md",
17+
"packages/typescript/ai/skills/ai-core/debug-logging/SKILL.md",
18+
"packages/typescript/ai/skills/ai-core/custom-backend-integration/SKILL.md",
19+
"packages/typescript/ai-code-mode/skills/ai-code-mode/SKILL.md"
20+
],
21+
"kind": "change-required",
22+
"why": "Agent skills are a contract with downstream coding agents. When the source surface they cover changes (new public API, changed type, new pattern, removed pattern, deprecation), the skill must be updated in the same PR — otherwise agents reading the skill keep generating obsolete or wrong code targeting a surface that no longer exists. Use this list to decide which skill(s) cover the file you're touching: `activities/chat/**` → chat-experience / structured-outputs / tool-calling; `activities/generateImage|generateAudio|generateVideo|generateSpeech|generateTranscription|summarize/**` → media-generation; `middleware/**` → middleware; `protocol/**` → ag-ui-protocol; `adapters/**` or anything that affects the adapter contract → adapter-configuration. Skip only for pure refactors / perf / internal-only changes that don't surface a new pattern or change observable behavior."
23+
}
24+
]
25+
},
26+
{
27+
"id": "ai-source-touches-public-docs",
28+
"trigger": "packages/typescript/ai*/src/**/*.ts",
29+
"impacts": [
30+
{
31+
"target": [
32+
"docs/structured-outputs/**/*.md",
33+
"docs/chat/**/*.md",
34+
"docs/tools/**/*.md",
35+
"docs/adapters/**/*.md",
36+
"docs/advanced/**/*.md",
37+
"docs/api/**/*.md",
38+
"docs/getting-started/**/*.md",
39+
"docs/protocol/**/*.md",
40+
"docs/media/**/*.md",
41+
"docs/code-mode/**/*.md",
42+
"docs/migration/**/*.md"
43+
],
44+
"kind": "change-required",
45+
"why": "Public docs are the canonical source for users. When the source surface they describe changes — new capability, behavior change, deprecation — the docs must be updated in the same PR. Cases to watch for: (1) the new behavior contradicts existing doc advice (e.g. removing a hack the doc still recommends); (2) the new capability opens a use case no existing page covers (consider whether a new page is needed, plus nav config and cross-links); (3) a public type / function gains or loses generic parameters, optional fields, etc. Run `pnpm test:docs` after editing to catch cross-link rot. Skip only for pure refactors / perf / internal-only changes."
46+
}
47+
]
48+
},
49+
{
50+
"id": "ai-source-touches-e2e-coverage",
51+
"trigger": "packages/typescript/ai*/src/**/*.ts",
52+
"impacts": [
53+
{
54+
"target": [
55+
"testing/e2e/tests/**/*.spec.ts",
56+
"testing/e2e/fixtures/**",
57+
"testing/e2e/src/lib/feature-support.ts",
58+
"testing/e2e/src/lib/types.ts"
59+
],
60+
"kind": "new-code-required",
61+
"why": "Per CLAUDE.md, every feature / bug fix / behavior change MUST include E2E test coverage. When a new public capability is added, a corresponding spec under testing/e2e/tests/ plus a fixture under testing/e2e/fixtures/ are required — often plus a new entry in feature-support.ts and types.ts. The full pattern is: add the Feature flag, decide provider support, optionally add a per-feature config (system prompt, schema, tools), wire it through src/routes/api.chat.ts (or the relevant route), write the fixture(s), write the spec iterating `providersFor(feature)`. Spec must run against every supported provider so non-native-streaming providers exercise the fallback path. Skip only for refactors that don't change observable behavior."
62+
}
63+
]
64+
}
65+
]
466
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
name: update-docs-with-new-cases
3+
description: Use when adding a new use case, capability, or behavior change to tanstack/ai — public docs that cover the surface need updating in the same PR
4+
tags: [documentation, monorepo, pr-discipline]
5+
scope: repo
6+
source:
7+
type: user-correction
8+
created: 2026-05-19T11:50:00Z
9+
related_skill: null
10+
related: [update-skills-with-feature-work]
11+
---
12+
13+
# Docs Need to Be Updated When Adding New Cases
14+
15+
**Rule:** When a PR adds a new pattern, capability, or behavior change to the public surface of any tanstack/ai package, update the corresponding doc page(s) in `docs/**` in the same PR. New cases that aren't documented don't exist for users.
16+
17+
**Why:** The user flagged this after shipping the multi-turn structured-output PR. Initial commits added the typed `StructuredOutputPart` on `useChat`'s `messages`, the schema-generic flow through `UIMessage<TTools, TData>`, and the recipe-builder pattern — but the docs only got updated several turns later, after the user explicitly asked. The window between "feature ships" and "docs catch up" is a window where users following the docs hit either obsolete advice (the "hide TextPart" filter hack the PR removed) or missing capability (no mention of multi-turn structured chat at all). Both are silent failures.
18+
19+
**How to apply:**
20+
21+
1. **At feature-design time**, identify which doc pages cover the surface being changed. Grep `docs/**` for the symbols, types, hooks, or patterns the feature touches. Note them as part of the implementation plan, not as follow-up.
22+
2. **For each affected doc page, decide:** does it need a correction (the feature contradicts what's written) or an addition (the feature opens a new use case)? Often both.
23+
3. **If a new use case warrants its own page**, plan the page placement / IA at feature-design time. New pages often imply nav-config updates (`docs/config.json`), a deprecation stub at the old location, and cross-link edits in adapter / API reference docs.
24+
4. **Run `pnpm test:docs` after editing** — the link checker catches the cross-link rot reorgs introduce.
25+
5. **Fact-check the docs** the same way as agent skills — dispatch a verification pass against the current source. Hallucinated APIs in docs send users down dead ends.
26+
6. **Coordinate with the related skill update** (see [[update-skills-with-feature-work]]) — the same surface change usually needs both, and the same fact-check pattern catches the same kinds of bugs.
27+
28+
**Heuristic for "does this PR need doc updates":** if the answer to "would a user reading the existing docs after this PR ships be misled?" is yes, the docs need updating. If the answer to "would a user benefit from knowing about the new capability?" is yes, the docs need adding to. Most non-trivial PRs hit one of those two.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
name: update-skills-with-feature-work
3+
description: Use when implementing a feature in tanstack/ai that touches a surface covered by an agent skill — the skill needs to be updated in the same PR
4+
tags: [agent-skills, documentation, monorepo, pr-discipline]
5+
scope: repo
6+
source:
7+
type: user-correction
8+
created: 2026-05-19T11:50:00Z
9+
related_skill: null
10+
related: [update-docs-with-new-cases]
11+
---
12+
13+
# Agent Skills Need to Be Updated With the Feature Work That Touches Them
14+
15+
**Rule:** When implementing a feature that changes or extends a surface covered by an agent skill under `packages/typescript/ai/skills/**` or `packages/typescript/ai-code-mode/skills/**`, update the skill in the same PR. Don't treat skill updates as a follow-up — they ship to coding agents that read them as authoritative.
16+
17+
**Why:** The user pointed this out after I'd already landed code, docs, e2e tests, and the example UI for the multi-turn structured-output PR (#577) without touching `ai-core/structured-outputs/SKILL.md`. The skill was still documenting the old single-slot `partial` / `final` design and still telling agents to filter `TextPart`s out of `useChat` renderers — the exact hack the PR removes. Without the skill update, every coding agent reading that skill would continue generating the obsolete pattern. Skills are a contract with downstream agents the same way the public API is a contract with users.
18+
19+
**How to apply:**
20+
21+
1. **Before shipping any non-trivial feature**, grep `packages/typescript/ai*/skills/**/SKILL.md` for the symbols, types, hooks, or patterns the feature touches. If any skill mentions them, that skill needs review.
22+
2. **Check both directions:** does the skill teach something the feature now makes wrong? Does the feature introduce a pattern the skill should add? Both warrant edits.
23+
3. **Update in the same PR as the feature code.** Splitting into a follow-up means the skill is stale during the window between merges — and during that window, agents reading the skill generate broken code that targets a surface that no longer exists.
24+
4. **After updating, fact-check the skill the same way as the docs** — dispatch a verification pass against the current source code. Hallucinations in skills propagate further than hallucinations in docs because agents act on them autonomously.
25+
5. **Don't forget the description / sources frontmatter**`sources:` paths break silently when docs get reorganized (e.g. when `chat/structured-outputs.md` becomes a redirect stub and the real content moves to `structured-outputs/*`).
26+
27+
**Heuristic for "is this feature skill-relevant":** if the feature adds a new public API, changes the shape of a public type, deprecates a pattern, or introduces a new way of consuming an existing surface, the answer is yes. Pure refactors and internal-only changes can usually skip it.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@tanstack/ai': minor
3+
'@tanstack/ai-client': minor
4+
'@tanstack/ai-react': minor
5+
'@tanstack/ai-vue': minor
6+
'@tanstack/ai-solid': minor
7+
'@tanstack/ai-svelte': minor
8+
---
9+
10+
feat: structured-output as a typed MessagePart on each assistant UIMessage
11+
12+
`useChat({ outputSchema })` (React, Vue, Solid) and `createChat({ outputSchema })` (Svelte) previously kept a single hook-level `partial`/`final` slot, so multi-turn structured chats lost every prior turn's response as soon as a new one streamed in. Each assistant turn now carries its own typed `structured-output` MessagePart on the UIMessage it belongs to. History walks `messages` and finds the typed part on each turn; the hook-level `partial` and `final` are derived from the latest assistant message's part and continue to work as before. Applies to all four framework hook packages.
13+
14+
The structured-output part type is generic over the schema's inferred data type:
15+
16+
- `StructuredOutputPart<TData = unknown>` in `@tanstack/ai` carries `data: TData`, `partial: DeepPartial<TData>`, `raw: string`, plus `status: 'streaming' | 'complete' | 'error'` and an optional `errorMessage`.
17+
- `MessagePart<TTools, TData>` and `UIMessage<TTools, TData>` in `@tanstack/ai-client` thread the generic through the message types.
18+
- Each framework hook's return (`UseChatReturn<TTools, TSchema>` for React / Vue / Solid, `CreateChatReturn<TTools, TSchema>` for Svelte) substitutes `TData = InferSchemaType<TSchema>` when a schema is supplied, so `messages[i].parts.find(p => p.type === 'structured-output').data` is typed by the schema with no cast required.
19+
20+
Default `TData = unknown` keeps every existing consumer that doesn't pass a schema source-compatible.
21+
22+
Server-side `chat({ outputSchema, stream: true })` emits a new `structured-output.start` CUSTOM event before the JSON deltas so the client processor can route them into the StructuredOutputPart instead of building a TextPart. The wire converter serializes the part's raw JSON back as assistant content, so multi-turn structured chats stay coherent (the LLM sees its own prior structured responses on follow-up turns). For adapters without native JSON-schema streaming (Anthropic, Gemini, Ollama), the existing fallback path emits one terminal `structured-output.complete` event and the same per-turn typed part lands on the message — consumer code is identical.
23+
24+
A new example route demonstrating the multi-turn pattern is at `/generations/structured-chat` in the `ts-react-chat` example.
25+
26+
**Breaking-shape note (minor, not major):** When `outputSchema` is set, `TEXT_MESSAGE_CONTENT` deltas no longer create a `TextPart` on the assistant message — they accumulate into the `StructuredOutputPart`. Consumers that iterated `message.parts` and explicitly filtered out `TextPart`s to hide raw JSON (the workaround documented prior to this change) can remove that filter; doing nothing is also safe because no `TextPart` is produced in the first place.

docs/adapters/openai.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const stream = chat({
7777
});
7878
```
7979

80-
Both adapters work identically with [Structured Outputs](../chat/structured-outputs) — including `stream: true` — and accept the same `modelOptions` (temperature, top_p, max_tokens, stop, …). The reasoning section below applies to `openaiText`; `openaiChatCompletions` accepts `modelOptions.reasoning.effort` but cannot stream summary text.
80+
Both adapters work identically with [Structured Outputs](../structured-outputs/overview) — including `stream: true` — and accept the same `modelOptions` (temperature, top_p, max_tokens, stop, …). The reasoning section below applies to `openaiText`; `openaiChatCompletions` accepts `modelOptions.reasoning.effort` but cannot stream summary text.
8181

8282
## Basic Usage - Custom API Key
8383

0 commit comments

Comments
 (0)