From 420dd13ddeac70f93767d7b3b49fb35714aa84e1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 14 Aug 2026 18:42:12 +0200 Subject: [PATCH 1/5] feat: honor outputSchema on dedicated harness adapters Harness adapters honor chat({ outputSchema }) on the same turn. Claude Code and Codex pass a native schema flag. OpenCode and Grok Build parse JSON from the last assistant text. The engine reads structured-output.complete so harness prose is not parsed as JSON. Add a repo-report page in ts-react-chat and a Harness Agents guide. --- .changeset/harness-output-schema.md | 11 + docs/adapters/acp-compatible.md | 2 + docs/adapters/claude-code.md | 29 +- docs/adapters/codex.md | 29 +- docs/adapters/grok-build.md | 31 +++ docs/adapters/opencode.md | 29 +- docs/chat/structured-outputs.md | 1 + docs/config.json | 30 ++- docs/sandbox/harnesses.md | 4 + docs/sandbox/overview.md | 6 +- docs/structured-outputs/harnesses.md | 199 ++++++++++++++ docs/structured-outputs/one-shot.md | 2 +- docs/structured-outputs/overview.md | 7 +- docs/structured-outputs/streaming.md | 2 +- docs/structured-outputs/with-tools.md | 2 + .../ts-react-chat/src/components/Header.tsx | 13 + .../ts-react-chat/src/repo-report-options.ts | 51 ++++ .../src/repo-report-prompt.test.ts | 12 + .../ts-react-chat/src/repo-report-schema.ts | 16 ++ examples/ts-react-chat/src/routeTree.gen.ts | 62 ++++- .../src/routes/api.sandbox-repo-report.ts | 122 +++++++++ examples/ts-react-chat/src/routes/index.tsx | 7 + .../src/routes/sandboxes.repo-report.tsx | 206 ++++++++++++++ packages/ai-claude-code/src/adapters/text.ts | 26 +- .../ai-claude-code/src/stream/translate.ts | 28 ++ .../ai-claude-code/tests/text-adapter.test.ts | 53 ++++ .../ai-claude-code/tests/translate.test.ts | 75 +++++- packages/ai-codex/src/adapters/text.ts | 27 +- packages/ai-codex/src/stream/translate.ts | 106 ++++++-- packages/ai-codex/tests/text-adapter.test.ts | 52 ++++ packages/ai-codex/tests/translate.test.ts | 72 +++++ packages/ai-grok-build/src/adapters/text.ts | 92 ++++++- .../ai-grok-build/src/stream/translate.ts | 41 +++ .../ai-grok-build/tests/translate.test.ts | 45 +++- packages/ai-opencode/src/adapters/text.ts | 65 ++++- .../ai-opencode/tests/text-adapter.test.ts | 6 + .../ai-core/structured-outputs/SKILL.md | 71 ++++- packages/ai/src/activities/chat/adapter.ts | 14 + packages/ai/src/activities/chat/index.ts | 129 ++++++--- packages/ai/src/adapter-internals.ts | 8 + packages/ai/src/types.ts | 11 +- .../src/utilities/structured-output-events.ts | 44 +++ .../src/utilities/structured-output-text.ts | 21 ++ ...t-combined-event-structured-output.test.ts | 252 ++++++++++++++++++ .../ai/tests/structured-output-text.test.ts | 33 +++ packages/ai/tests/test-utils.ts | 7 + 46 files changed, 2047 insertions(+), 104 deletions(-) create mode 100644 .changeset/harness-output-schema.md create mode 100644 docs/structured-outputs/harnesses.md create mode 100644 examples/ts-react-chat/src/repo-report-options.ts create mode 100644 examples/ts-react-chat/src/repo-report-prompt.test.ts create mode 100644 examples/ts-react-chat/src/repo-report-schema.ts create mode 100644 examples/ts-react-chat/src/routes/api.sandbox-repo-report.ts create mode 100644 examples/ts-react-chat/src/routes/sandboxes.repo-report.tsx create mode 100644 packages/ai/src/utilities/structured-output-events.ts create mode 100644 packages/ai/src/utilities/structured-output-text.ts create mode 100644 packages/ai/tests/chat-combined-event-structured-output.test.ts create mode 100644 packages/ai/tests/structured-output-text.test.ts diff --git a/.changeset/harness-output-schema.md b/.changeset/harness-output-schema.md new file mode 100644 index 0000000000..3e98a72526 --- /dev/null +++ b/.changeset/harness-output-schema.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-claude-code': minor +'@tanstack/ai-codex': minor +'@tanstack/ai-opencode': minor +'@tanstack/ai-grok-build': minor +--- + +Harness adapters honor `chat({ outputSchema })` on the same turn. + +Claude Code and Codex pass a native schema flag. OpenCode and Grok Build parse JSON from the final assistant text. The engine reads a `structured-output.complete` event so harness prose is not parsed as JSON. diff --git a/docs/adapters/acp-compatible.md b/docs/adapters/acp-compatible.md index 54c03fd4e5..a309ebacb3 100644 --- a/docs/adapters/acp-compatible.md +++ b/docs/adapters/acp-compatible.md @@ -16,6 +16,8 @@ Coding-agent CLIs that speak the [Agent Client Protocol](https://agentclientprot It is the harness equivalent of the [OpenAI-Compatible adapter](./openai-compatible). Use it when your agent speaks ACP but has no `@tanstack/ai-*` package. If a dedicated harness adapter exists ([Grok Build](./grok-build), and others), prefer it — those carry curated per-model metadata and vendor-specific behavior. +`acpCompatible` does not accept `outputSchema`. If you need a typed object from a coding agent, use a dedicated harness adapter. See [Harness Agents](../structured-outputs/harnesses). + ## Installation `acpCompatible` ships in `@tanstack/ai-acp`. You drive it inside a sandbox, so install the sandbox package and a provider too: diff --git a/docs/adapters/claude-code.md b/docs/adapters/claude-code.md index 01bfaeb300..a486ee9a6f 100644 --- a/docs/adapters/claude-code.md +++ b/docs/adapters/claude-code.md @@ -170,7 +170,34 @@ const stream = chat({ ## Structured Output -`structuredOutput()` uses the harness's native JSON-schema output format in a one-shot run (single turn, no tools). It works for finalization after a chat, but a plain provider adapter (e.g. `@tanstack/ai-anthropic`) is the better choice when structured extraction is the primary job — it's faster and doesn't spawn a subprocess. +Pass `outputSchema` on `chat()`. Claude Code runs one harness turn, uses its native tools, and returns a typed object. The schema is sent with `--json-schema`. Tool activity and prose stream as usual. The object arrives as `structured-output.complete`. + +```ts +import { chat } from "@tanstack/ai" +import { claudeCodeText } from "@tanstack/ai-claude-code" +import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { z } from "zod" + +const Report = z.object({ + summary: z.string(), + filesChanged: z.array(z.string()), +}) + +const report = await chat({ + adapter: claudeCodeText("claude-opus-4-8"), + messages: [{ role: "user", content: "Review this repo." }], + outputSchema: Report, + middleware: [withSandbox(defineSandbox({ /* provider */ }))], +}) + +report.summary +``` + +On the client, `useChat({ outputSchema }).final` works the same as HTTP adapters. `partial` stays empty until the end. + +If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-anthropic`. That path is faster. + +Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). ## Limitations diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index eb000dd053..64607d9488 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -170,7 +170,34 @@ const stream = chat({ ## Structured Output -`structuredOutput()` uses Codex's native `outputSchema` support in a fresh, read-only, one-shot thread whose final message is a JSON string conforming to your schema. It works for finalization after a chat, but a plain provider adapter (e.g. `@tanstack/ai-openai`) is the better choice when structured extraction is the primary job — it's faster and doesn't spawn a subprocess. +Pass `outputSchema` on `chat()`. Codex runs one harness turn and constrains the last message with `--output-schema`. Tool activity still streams. The object arrives as `structured-output.complete`. + +```ts +import { chat } from "@tanstack/ai" +import { codexText } from "@tanstack/ai-codex" +import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { z } from "zod" + +const Report = z.object({ + summary: z.string(), + filesChanged: z.array(z.string()), +}) + +const report = await chat({ + adapter: codexText("gpt-5.3-codex"), + messages: [{ role: "user", content: "Review this repo." }], + outputSchema: Report, + middleware: [withSandbox(defineSandbox({ /* provider */ }))], +}) + +report.summary +``` + +On the client, `useChat({ outputSchema }).final` works the same as HTTP adapters. `partial` stays empty until the end. + +If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-openai`. That path is faster. + +Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). ## Limitations diff --git a/docs/adapters/grok-build.md b/docs/adapters/grok-build.md index 7d1e6600cc..2fa25cd1df 100644 --- a/docs/adapters/grok-build.md +++ b/docs/adapters/grok-build.md @@ -185,6 +185,37 @@ On the `'streaming-json'` path with auto-approve, the adapter adds `--always-approve --no-plan --no-auto-update`. Those flags keep Plan Mode and the CLI update check from blocking a headless run. +## Structured Output + +Pass `outputSchema` on `chat()`. Grok Build has no native schema flag. The adapter adds the JSON Schema to the prompt (ACP and `streaming-json`) and parses the last assistant text. Tool activity still streams. The object arrives as `structured-output.complete`. + +```ts +import { chat } from "@tanstack/ai" +import { grokBuildText } from "@tanstack/ai-grok-build" +import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { z } from "zod" + +const Report = z.object({ + summary: z.string(), + filesChanged: z.array(z.string()), +}) + +const report = await chat({ + adapter: grokBuildText("grok-build"), + messages: [{ role: "user", content: "Review this repo." }], + outputSchema: Report, + middleware: [withSandbox(defineSandbox({ /* provider */ }))], +}) + +report.summary +``` + +This path parses JSON from the last assistant message. If extract-only is the job, use `@tanstack/ai-grok`. + +On the client, `useChat({ outputSchema }).final` works the same as HTTP adapters. `partial` stays empty until the end. + +Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). + ## Limitations - **Requires a sandbox.** Always run it under `withSandbox(...)`; see the diff --git a/docs/adapters/opencode.md b/docs/adapters/opencode.md index 23ccbb453c..48790e2a0a 100644 --- a/docs/adapters/opencode.md +++ b/docs/adapters/opencode.md @@ -175,7 +175,34 @@ const stream = chat({ ## Structured Output -`structuredOutput()` is best-effort: OpenCode's prompt API has no native JSON-schema channel, so the schema is embedded as a prompt instruction in a fresh, one-shot session and the final text is parsed (markdown fences are stripped when present). It works for finalization after a chat, but a plain provider adapter (e.g. `@tanstack/ai-openai`) is the better choice when structured extraction is the primary job — it's faster, deterministic, and doesn't spawn a harness. +Pass `outputSchema` on `chat()`. OpenCode has no native schema flag. The adapter adds the JSON Schema to the prompt and parses the last assistant text (markdown fences are stripped). Tool activity still streams. The object arrives as `structured-output.complete`. + +```ts +import { chat } from "@tanstack/ai" +import { opencodeText } from "@tanstack/ai-opencode" +import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { z } from "zod" + +const Report = z.object({ + summary: z.string(), + filesChanged: z.array(z.string()), +}) + +const report = await chat({ + adapter: opencodeText("anthropic/claude-opus-4-5"), + messages: [{ role: "user", content: "Review this repo." }], + outputSchema: Report, + middleware: [withSandbox(defineSandbox({ /* provider */ }))], +}) + +report.summary +``` + +This path parses JSON from the last assistant message. If extract-only is the job, use a model adapter such as `@tanstack/ai-openai`. + +On the client, `useChat({ outputSchema }).final` works the same as HTTP adapters. `partial` stays empty until the end. + +Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). ## Limitations diff --git a/docs/chat/structured-outputs.md b/docs/chat/structured-outputs.md index 425dc56abe..4793bed491 100644 --- a/docs/chat/structured-outputs.md +++ b/docs/chat/structured-outputs.md @@ -17,5 +17,6 @@ The structured-outputs guide has moved to its own top-level section, split by wh - **[Streaming UIs](../structured-outputs/streaming)** — `useChat({ outputSchema })` with `partial` and `final` populating a UI field by field. - **[Multi-Turn Chat](../structured-outputs/multi-turn)** — every assistant turn carries its own typed `StructuredOutputPart`, history stays renderable, and `messages[i].parts.find(p => p.type === "structured-output").data` is typed by your schema. - **[With Tools](../structured-outputs/with-tools)** — combining `outputSchema` with the agent loop, including pause/resume for server-tool approvals and client-tool invocations. +- **[Harness Agents](../structured-outputs/harnesses)** — a coding agent in a sandbox inspects files, then returns a typed object. > **Note:** This URL is kept for backward compatibility. New content lives under `/structured-outputs/*` — update existing bookmarks when you can. diff --git a/docs/config.json b/docs/config.json index e1a5aa44b6..7c8240ec7c 100644 --- a/docs/config.json +++ b/docs/config.json @@ -335,17 +335,19 @@ "label": "Overview", "to": "structured-outputs/overview", "addedAt": "2026-05-19", - "updatedAt": "2026-06-10" + "updatedAt": "2026-08-14" }, { "label": "One-Shot Extraction", "to": "structured-outputs/one-shot", - "addedAt": "2026-05-19" + "addedAt": "2026-05-19", + "updatedAt": "2026-08-14" }, { "label": "Streaming UIs", "to": "structured-outputs/streaming", - "addedAt": "2026-05-19" + "addedAt": "2026-05-19", + "updatedAt": "2026-08-14" }, { "label": "Multi-Turn Chat", @@ -356,7 +358,12 @@ "label": "With Tools", "to": "structured-outputs/with-tools", "addedAt": "2026-05-19", - "updatedAt": "2026-07-08" + "updatedAt": "2026-08-14" + }, + { + "label": "Harness Agents", + "to": "structured-outputs/harnesses", + "addedAt": "2026-08-14" } ] }, @@ -509,7 +516,7 @@ "label": "Overview", "to": "sandbox/overview", "addedAt": "2026-06-16", - "updatedAt": "2026-08-12" + "updatedAt": "2026-08-14" }, { "label": "Quick Start", @@ -527,7 +534,7 @@ "label": "Harnesses", "to": "sandbox/harnesses", "addedAt": "2026-06-30", - "updatedAt": "2026-08-04" + "updatedAt": "2026-08-14" }, { "label": "Workspace", @@ -864,30 +871,31 @@ "label": "Claude Code", "to": "adapters/claude-code", "addedAt": "2026-06-12", - "updatedAt": "2026-06-30" + "updatedAt": "2026-08-14" }, { "label": "Codex", "to": "adapters/codex", "addedAt": "2026-06-12", - "updatedAt": "2026-08-12" + "updatedAt": "2026-08-14" }, { "label": "OpenCode", "to": "adapters/opencode", "addedAt": "2026-06-12", - "updatedAt": "2026-06-30" + "updatedAt": "2026-08-14" }, { "label": "Grok Build", "to": "adapters/grok-build", "addedAt": "2026-06-29", - "updatedAt": "2026-08-12" + "updatedAt": "2026-08-14" }, { "label": "ACP-Compatible", "to": "adapters/acp-compatible", - "addedAt": "2026-06-30" + "addedAt": "2026-06-30", + "updatedAt": "2026-08-14" }, { "label": "Amazon Bedrock", diff --git a/docs/sandbox/harnesses.md b/docs/sandbox/harnesses.md index 21da0c4b4a..8e9f290700 100644 --- a/docs/sandbox/harnesses.md +++ b/docs/sandbox/harnesses.md @@ -39,6 +39,10 @@ const stream = chat({ }) ``` +## Typed report from a harness + +If you want a typed object after the agent inspects the repo, pass `outputSchema` on the same `chat()` call. See [Harness Agents](../structured-outputs/harnesses). + ## Harness output can go to a journal `grokBuildText`, `claudeCodeText`, and `codexText` can stop holding the agent's diff --git a/docs/sandbox/overview.md b/docs/sandbox/overview.md index 79259a6b84..76bdcdcb6e 100644 --- a/docs/sandbox/overview.md +++ b/docs/sandbox/overview.md @@ -127,7 +127,7 @@ build an adapter. ## Try it -Two runnable demos: +Three runnable demos: - [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web): a "build me an app" agent on Docker with durable runs wired. It scaffolds an app, @@ -135,3 +135,7 @@ Two runnable demos: and a closed tab, and Stop is a real cancel. - [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): the same idea at the edge, with the harness picked per run from the UI. +- [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat) + at `/sandboxes/repo-report`: clone `TanStack/ai`, pick Claude Code, Grok Build, + or Codex, and read a typed report from `useChat().final`. See + [Harness Agents](../structured-outputs/harnesses). diff --git a/docs/structured-outputs/harnesses.md b/docs/structured-outputs/harnesses.md new file mode 100644 index 0000000000..a675addf03 --- /dev/null +++ b/docs/structured-outputs/harnesses.md @@ -0,0 +1,199 @@ +--- +title: Harness Structured Output +id: structured-outputs-harnesses +order: 6 +description: "Ask a coding agent in a sandbox to inspect a repo, then read a typed object from chat({ outputSchema }). Works with Claude Code, Codex, OpenCode, and Grok Build." +keywords: + - tanstack ai + - structured outputs + - harness + - claude code + - codex + - opencode + - grok build + - outputSchema + - sandbox +--- + +You asked a coding agent to inspect a repository. The agent streams tool calls and prose. You need a typed object you can store or render, not a wall of text to parse. + +Pass `outputSchema` on the same `chat()` call. The harness runs its native tools. Then you get a validated object from `await chat()` or from `useChat().final`. + +This page is for dedicated harness adapters: + +- [Claude Code](../adapters/claude-code) +- [Codex](../adapters/codex) +- [OpenCode](../adapters/opencode) +- [Grok Build](../adapters/grok-build) + +If you only extract JSON from a prompt and you do not need a sandbox, use [One-Shot Extraction](./one-shot) with an HTTP adapter. + +## Define the schema + +```typescript +import { z } from "zod"; + +export const ReportSchema = z.object({ + name: z.string(), + oneLiner: z.string(), + audience: z.string(), + mainPackages: z.array( + z.object({ + name: z.string(), + role: z.string(), + }), + ), + howToRun: z.string(), +}); +``` + +The return type follows from the schema. You do not need a cast. + +## Server: sandbox plus outputSchema + +The harness needs a sandbox. Pass `withSandbox(...)`. If the client reads the stream, pass `stream: true`. Without `stream: true`, `chat()` returns a `Promise`, not SSE. + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { claudeCodeText } from "@tanstack/ai-claude-code"; +import { + defineSandbox, + defineWorkspace, + githubRepo, + withSandbox, +} from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; +import { ReportSchema } from "./report-schema"; + +const sandbox = defineSandbox({ + id: "repo-report", + provider: dockerSandbox({ image: "node:22" }), + workspace: defineWorkspace({ + source: githubRepo({ repo: "TanStack/ai" }), + }), +}); + +export async function POST(request: Request) { + const body: unknown = await request.json(); + const messages = + typeof body === "object" && + body !== null && + "messages" in body && + Array.isArray(body.messages) + ? body.messages + : []; + + const stream = chat({ + adapter: claudeCodeText("claude-opus-4-8"), + messages, + outputSchema: ReportSchema, + stream: true, + middleware: [withSandbox(sandbox)], + }); + + return toServerSentEventsResponse(stream); +} +``` + +Swap the adapter to change the agent: + +- `codexText("gpt-5.3-codex")` +- `opencodeText("anthropic/claude-opus-4-5")` +- `grokBuildText("grok-build")` + +The typed object arrives as a `structured-output.complete` event. Tool activity streams first. + +## Client: read `final` + +Pass the same schema to `useChat`. Read the object from `final`. + +```tsx +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { ReportSchema } from "./report-schema"; + +function RepoReport() { + const { sendMessage, isLoading, final } = useChat({ + connection: fetchServerSentEvents("/api/repo-report"), + outputSchema: ReportSchema, + }); + + return ( + <> + + {isLoading &&

The agent is inspecting the repo.

} + {final && ( + <> +

{final.name}

+

{final.oneLiner}

+

{final.audience}

+ + )} + + ); +} +``` + +`final` is typed as the schema. It stays `null` until `structured-output.complete` arrives. + +`partial` stays empty on harness adapters. The object is not streamed field by field. Render tool calls from `messages` while you wait. See [Streaming UIs](./streaming) for the `partial` / `final` shape. + +## How each harness applies the schema + +| Adapter | How the schema is applied | +|---|---| +| Claude Code | Native `--json-schema` flag on the same turn | +| Codex | Native `--output-schema` flag on the same turn | +| OpenCode | Schema is added to the prompt. The adapter parses the last assistant text. | +| Grok Build | Schema is added to the prompt. The adapter parses the last assistant text. | + +OpenCode and Grok Build parse JSON from the last assistant message. That parse fails if the message is not JSON. If the job is extract-only and you do not need a sandbox, use `@tanstack/ai-openai` or `@tanstack/ai-grok`. + +The generic ACP adapter (`acpCompatible`) does not accept `outputSchema`. Use a dedicated harness adapter. + +## Approval gates and client tools + +Harness adapters run tools inside the sandbox. They do not pause for a browser round-trip. + +- A tool without a server `execute()` fails fast. +- A tool with `needsApproval` fails fast. + +If you need approval gates or client tools, use [With Tools](./with-tools) with an HTTP adapter. + +## Script without a UI + +If you do not stream to a browser, omit `stream: true`. The promise resolves with the typed object. + +```typescript +import { chat } from "@tanstack/ai"; +import { claudeCodeText } from "@tanstack/ai-claude-code"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { ReportSchema } from "./report-schema"; +import { sandbox } from "./sandbox"; + +const report = await chat({ + adapter: claudeCodeText("claude-opus-4-8"), + messages: [{ role: "user", content: "What is this repository about?" }], + outputSchema: ReportSchema, + middleware: [withSandbox(sandbox)], +}); + +report.name; +report.oneLiner; +``` + +## Try it + +The React chat example includes a repo-report page. + +1. Open [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat). +2. Set the harness API key in `.env`. +3. Open `/sandboxes/repo-report`. +4. Pick Claude Code, Grok Build, or Codex. +5. Run the report. The page reads the typed object from `useChat().final`. + +The page clones `TanStack/ai` into a sandbox, asks the agent to inspect it, and shows the validated report. diff --git a/docs/structured-outputs/one-shot.md b/docs/structured-outputs/one-shot.md index 183181b88f..2e43cb4684 100644 --- a/docs/structured-outputs/one-shot.md +++ b/docs/structured-outputs/one-shot.md @@ -17,7 +17,7 @@ You have unstructured input — a paragraph of text, a freeform user prompt, the By the end of this guide you'll have a working `chat({ outputSchema })` call returning a fully-typed result, know how to describe fields so the model fills them correctly, and have a pattern for handling validation errors. -> **Note:** If you want to stream the result field-by-field into a UI, you want [Streaming UIs](./streaming) instead. If you want users to iterate on the object across multiple turns, you want [Multi-Turn Chat](./multi-turn). This page is for the single-extraction case. +> **Note:** If you want to stream the result field-by-field into a UI, you want [Streaming UIs](./streaming) instead. If you want users to iterate on the object across multiple turns, you want [Multi-Turn Chat](./multi-turn). If the model must inspect files in a sandbox first, you want [Harness Agents](./harnesses). This page is for the single-extraction case. ## Basic Usage diff --git a/docs/structured-outputs/overview.md b/docs/structured-outputs/overview.md index 18edaac6e5..e9cb3fc4e4 100644 --- a/docs/structured-outputs/overview.md +++ b/docs/structured-outputs/overview.md @@ -57,8 +57,10 @@ Every adapter handles structured output through its provider's native API: | Google Gemini | `responseSchema` | | Ollama | JSON mode with schema | | OpenRouter / Grok / Groq | `response_format` with `json_schema` | +| Claude Code / Codex | Native schema flag on the same harness turn (`--json-schema` / `--output-schema`) | +| OpenCode / Grok Build | Same-turn prompt-and-parse | -The provider-specific details are handled for you — the same `chat({ outputSchema })` call works across all of them. +The provider-specific details are handled for you. The same `chat({ outputSchema })` call works across all of them. For a coding agent in a sandbox, see [Harness Agents](./harnesses). ### Anthropic schema complexity limits @@ -75,7 +77,7 @@ Anthropic's exact limits change over time and aren't all published, so we delibe ## Which page do I read? -Pick the journey that matches what you're building. The four guides under "Structured Outputs" cover non-overlapping use cases — read the one that fits, not all of them. +Pick the journey that matches what you're building. The guides under Structured Outputs cover separate use cases. Read the one that fits. | You want to… | Read | |---|---| @@ -83,6 +85,7 @@ Pick the journey that matches what you're building. The four guides under "Struc | Build a UI that fills in field-by-field as the model streams (progressive form, live card, typewriter preview) | [Streaming UIs](./streaming) | | Let users iterate on a structured object across multiple turns — each turn produces a new typed object and history stays renderable | [Multi-Turn Chat](./multi-turn) | | Combine structured output with tool calls (agent loop that runs tools first, then returns a typed object) | [With Tools](./with-tools) | +| Ask a coding agent in a sandbox to inspect files, then return a typed object | [Harness Agents](./harnesses) | The streaming and multi-turn paths both build on `useChat({ outputSchema })`. The "with tools" path layers on top of either. Pick the one that describes your shipping shape — start there, follow the cross-links when you need a piece of another story. diff --git a/docs/structured-outputs/streaming.md b/docs/structured-outputs/streaming.md index cd37c21c7a..54ad8a132c 100644 --- a/docs/structured-outputs/streaming.md +++ b/docs/structured-outputs/streaming.md @@ -93,7 +93,7 @@ What the hook does for you: - **`partial`** is `DeepPartial>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas via the runtime's partial-JSON parser. The hook derives it from the latest assistant message's `structured-output` part (see [Multi-Turn Chat](./multi-turn) for why that distinction matters), so it reads `{}` between `sendMessage()` and the first chunk without any extra reset state. - **`final`** is `z.infer | null` — the validated terminal payload from the `structured-output.complete` event. `null` until the run completes successfully. - **`outputSchema`** is used purely for client-side TypeScript inference. Validation still runs on the server against the schema you pass to `chat({ outputSchema })` on the server route — the client doesn't re-validate. -- The same shape works for **non-streaming adapters too**. If an adapter (Anthropic, Gemini, Ollama) returns a single `structured-output.complete` event with no incremental deltas, `partial` stays `{}` and `final` populates when the event arrives. Same consumer code. +- The same shape works for **non-streaming adapters too**. If an adapter (Anthropic, Gemini, Ollama) returns a single `structured-output.complete` event with no incremental deltas, `partial` stays `{}` and `final` populates when the event arrives. Same consumer code. Harness adapters work this way too: tool activity streams first, then `final` snaps. See [Harness Agents](./harnesses). `outputSchema` is optional: omit it and `useChat` returns its standard shape without `partial` / `final`. diff --git a/docs/structured-outputs/with-tools.md b/docs/structured-outputs/with-tools.md index 69e43d7be4..4a11c5e47e 100644 --- a/docs/structured-outputs/with-tools.md +++ b/docs/structured-outputs/with-tools.md @@ -56,6 +56,8 @@ recommendation.reason; // string The agent decides when to call `get_product_price`, executes the tool, integrates the result into its reasoning, and only then produces the final structured response. You see the validated object; the tool calls happen behind the scenes. +> **Note:** Coding agents in a sandbox (Claude Code, Codex, OpenCode, Grok Build) run native tools and the schema on the same turn. They do not pause for TanStack tool approval or client tools. See [Harness Agents](./harnesses). + ## Streaming: lifecycle events before the structured payload Pass `stream: true` and the wire format changes — the client now sees tool-call events as they happen, _then_ the structured-output stream emits its terminal event. The lifecycle ordering is: diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 9b4d744f98..159f8593ce 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -204,6 +204,19 @@ export default function Header() { Structured Output + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + Repo report + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/repo-report-options.ts b/examples/ts-react-chat/src/repo-report-options.ts new file mode 100644 index 0000000000..9694ceadab --- /dev/null +++ b/examples/ts-react-chat/src/repo-report-options.ts @@ -0,0 +1,51 @@ +/** + * Client-safe picker types for /sandboxes/repo-report. + * Do not import harness packages here. + */ + +export type ReportHarness = 'claude-code' | 'codex' | 'grok' +export type ReportProvider = 'docker' | 'local' +export type ReportAgent = 'explainer' | 'package-map' | 'first-hour' + +export const REPORT_HARNESSES: Record = { + 'claude-code': { label: 'Claude Code' }, + grok: { label: 'Grok Build' }, + codex: { label: 'Codex' }, +} + +export const REPORT_PROVIDERS: Record = { + docker: { label: 'Docker' }, + local: { label: 'Local process' }, +} + +export const REPORT_AGENTS: Record< + ReportAgent, + { label: string; hint: string } +> = { + explainer: { + label: 'Explainer', + hint: 'What this repo is, and who it is for', + }, + 'package-map': { + label: 'Package map', + hint: 'Main packages and what each one does', + }, + 'first-hour': { + label: 'First hour', + hint: 'Clone, install, and run the first command', + }, +} + +export function isReportHarness(value: unknown): value is ReportHarness { + return typeof value === 'string' && value in REPORT_HARNESSES +} + +export function isReportProvider(value: unknown): value is ReportProvider { + return typeof value === 'string' && value in REPORT_PROVIDERS +} + +export function isReportAgent(value: unknown): value is ReportAgent { + return typeof value === 'string' && value in REPORT_AGENTS +} + +export const REPORT_REPO = 'TanStack/ai' diff --git a/examples/ts-react-chat/src/repo-report-prompt.test.ts b/examples/ts-react-chat/src/repo-report-prompt.test.ts new file mode 100644 index 0000000000..5eedc39fe2 --- /dev/null +++ b/examples/ts-react-chat/src/repo-report-prompt.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { buildRepoReportPrompt } from './routes/api.sandbox-repo-report' +import { REPORT_REPO } from './repo-report-options' + +describe('buildRepoReportPrompt', () => { + it('names the repo and the explainer focus', () => { + const prompt = buildRepoReportPrompt('explainer') + expect(prompt).toContain(REPORT_REPO) + expect(prompt).toContain('Do not change files.') + expect(prompt).toContain('What this repo is') + }) +}) diff --git a/examples/ts-react-chat/src/repo-report-schema.ts b/examples/ts-react-chat/src/repo-report-schema.ts new file mode 100644 index 0000000000..510ba8aece --- /dev/null +++ b/examples/ts-react-chat/src/repo-report-schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const RepoReportSchema = z.object({ + name: z.string(), + oneLiner: z.string(), + audience: z.string(), + mainPackages: z.array( + z.object({ + name: z.string(), + role: z.string(), + }), + ), + howToRun: z.string(), +}) + +export type RepoReport = z.infer diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 7dfa0251b7..d752600492 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' import { Route as IndexRouteImport } from './routes/index' +import { Route as SandboxesRepoReportRouteImport } from './routes/sandboxes.repo-report' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription' import { Route as GenerationsSummarizeRouteImport } from './routes/generations.summarize' @@ -44,6 +45,7 @@ import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured import { Route as ApiStructuredChatRouteImport } from './routes/api.structured-chat' import { Route as ApiSandboxTriageDurableRouteImport } from './routes/api.sandbox-triage-durable' import { Route as ApiSandboxTriageRouteImport } from './routes/api.sandbox-triage' +import { Route as ApiSandboxRepoReportRouteImport } from './routes/api.sandbox-repo-report' import { Route as ApiResumableRouteImport } from './routes/api.resumable' import { Route as ApiPersistentChatRouteImport } from './routes/api.persistent-chat' import { Route as ApiMcpStatusRouteImport } from './routes/api.mcp-status' @@ -157,6 +159,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const SandboxesRepoReportRoute = SandboxesRepoReportRouteImport.update({ + id: '/repo-report', + path: '/repo-report', + getParentRoute: () => SandboxesRoute, +} as any) const GenerationsVideoRoute = GenerationsVideoRouteImport.update({ id: '/generations/video', path: '/generations/video', @@ -246,6 +253,11 @@ const ApiSandboxTriageRoute = ApiSandboxTriageRouteImport.update({ path: '/api/sandbox-triage', getParentRoute: () => rootRouteImport, } as any) +const ApiSandboxRepoReportRoute = ApiSandboxRepoReportRouteImport.update({ + id: '/api/sandbox-repo-report', + path: '/api/sandbox-repo-report', + getParentRoute: () => rootRouteImport, +} as any) const ApiResumableRoute = ApiResumableRouteImport.update({ id: '/api/resumable', path: '/api/resumable', @@ -372,7 +384,7 @@ export interface FileRoutesByFullPath { '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute '/resumable': typeof ResumableRoute - '/sandboxes': typeof SandboxesRoute + '/sandboxes': typeof SandboxesRouteWithChildren '/sandboxes-durable': typeof SandboxesDurableRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute @@ -392,6 +404,7 @@ export interface FileRoutesByFullPath { '/api/mcp-status': typeof ApiMcpStatusRoute '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute + '/api/sandbox-repo-report': typeof ApiSandboxRepoReportRoute '/api/sandbox-triage': typeof ApiSandboxTriageRoute '/api/sandbox-triage-durable': typeof ApiSandboxTriageDurableRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -409,6 +422,7 @@ export interface FileRoutesByFullPath { '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute + '/sandboxes/repo-report': typeof SandboxesRepoReportRoute '/api/generate/audio': typeof ApiGenerateAudioRoute '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute @@ -431,7 +445,7 @@ export interface FileRoutesByTo { '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute '/resumable': typeof ResumableRoute - '/sandboxes': typeof SandboxesRoute + '/sandboxes': typeof SandboxesRouteWithChildren '/sandboxes-durable': typeof SandboxesDurableRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute @@ -451,6 +465,7 @@ export interface FileRoutesByTo { '/api/mcp-status': typeof ApiMcpStatusRoute '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute + '/api/sandbox-repo-report': typeof ApiSandboxRepoReportRoute '/api/sandbox-triage': typeof ApiSandboxTriageRoute '/api/sandbox-triage-durable': typeof ApiSandboxTriageDurableRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -468,6 +483,7 @@ export interface FileRoutesByTo { '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute + '/sandboxes/repo-report': typeof SandboxesRepoReportRoute '/api/generate/audio': typeof ApiGenerateAudioRoute '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute @@ -491,7 +507,7 @@ export interface FileRoutesById { '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute '/resumable': typeof ResumableRoute - '/sandboxes': typeof SandboxesRoute + '/sandboxes': typeof SandboxesRouteWithChildren '/sandboxes-durable': typeof SandboxesDurableRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute @@ -511,6 +527,7 @@ export interface FileRoutesById { '/api/mcp-status': typeof ApiMcpStatusRoute '/api/persistent-chat': typeof ApiPersistentChatRoute '/api/resumable': typeof ApiResumableRoute + '/api/sandbox-repo-report': typeof ApiSandboxRepoReportRoute '/api/sandbox-triage': typeof ApiSandboxTriageRoute '/api/sandbox-triage-durable': typeof ApiSandboxTriageDurableRoute '/api/structured-chat': typeof ApiStructuredChatRoute @@ -528,6 +545,7 @@ export interface FileRoutesById { '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute + '/sandboxes/repo-report': typeof SandboxesRepoReportRoute '/api/generate/audio': typeof ApiGenerateAudioRoute '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute @@ -572,6 +590,7 @@ export interface FileRouteTypes { | '/api/mcp-status' | '/api/persistent-chat' | '/api/resumable' + | '/api/sandbox-repo-report' | '/api/sandbox-triage' | '/api/sandbox-triage-durable' | '/api/structured-chat' @@ -589,6 +608,7 @@ export interface FileRouteTypes { | '/generations/summarize' | '/generations/transcription' | '/generations/video' + | '/sandboxes/repo-report' | '/api/generate/audio' | '/api/generate/image' | '/api/generate/speech' @@ -631,6 +651,7 @@ export interface FileRouteTypes { | '/api/mcp-status' | '/api/persistent-chat' | '/api/resumable' + | '/api/sandbox-repo-report' | '/api/sandbox-triage' | '/api/sandbox-triage-durable' | '/api/structured-chat' @@ -648,6 +669,7 @@ export interface FileRouteTypes { | '/generations/summarize' | '/generations/transcription' | '/generations/video' + | '/sandboxes/repo-report' | '/api/generate/audio' | '/api/generate/image' | '/api/generate/speech' @@ -690,6 +712,7 @@ export interface FileRouteTypes { | '/api/mcp-status' | '/api/persistent-chat' | '/api/resumable' + | '/api/sandbox-repo-report' | '/api/sandbox-triage' | '/api/sandbox-triage-durable' | '/api/structured-chat' @@ -707,6 +730,7 @@ export interface FileRouteTypes { | '/generations/summarize' | '/generations/transcription' | '/generations/video' + | '/sandboxes/repo-report' | '/api/generate/audio' | '/api/generate/image' | '/api/generate/speech' @@ -730,7 +754,7 @@ export interface RootRouteChildren { QueueingRoute: typeof QueueingRoute RealtimeRoute: typeof RealtimeRoute ResumableRoute: typeof ResumableRoute - SandboxesRoute: typeof SandboxesRoute + SandboxesRoute: typeof SandboxesRouteWithChildren SandboxesDurableRoute: typeof SandboxesDurableRoute ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute @@ -750,6 +774,7 @@ export interface RootRouteChildren { ApiMcpStatusRoute: typeof ApiMcpStatusRoute ApiPersistentChatRoute: typeof ApiPersistentChatRoute ApiResumableRoute: typeof ApiResumableRoute + ApiSandboxRepoReportRoute: typeof ApiSandboxRepoReportRoute ApiSandboxTriageRoute: typeof ApiSandboxTriageRoute ApiSandboxTriageDurableRoute: typeof ApiSandboxTriageDurableRoute ApiStructuredChatRoute: typeof ApiStructuredChatRoute @@ -903,6 +928,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/sandboxes/repo-report': { + id: '/sandboxes/repo-report' + path: '/repo-report' + fullPath: '/sandboxes/repo-report' + preLoaderRoute: typeof SandboxesRepoReportRouteImport + parentRoute: typeof SandboxesRoute + } '/generations/video': { id: '/generations/video' path: '/generations/video' @@ -1022,6 +1054,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSandboxTriageRouteImport parentRoute: typeof rootRouteImport } + '/api/sandbox-repo-report': { + id: '/api/sandbox-repo-report' + path: '/api/sandbox-repo-report' + fullPath: '/api/sandbox-repo-report' + preLoaderRoute: typeof ApiSandboxRepoReportRouteImport + parentRoute: typeof rootRouteImport + } '/api/resumable': { id: '/api/resumable' path: '/api/resumable' @@ -1179,6 +1218,18 @@ declare module '@tanstack/react-router' { } } +interface SandboxesRouteChildren { + SandboxesRepoReportRoute: typeof SandboxesRepoReportRoute +} + +const SandboxesRouteChildren: SandboxesRouteChildren = { + SandboxesRepoReportRoute: SandboxesRepoReportRoute, +} + +const SandboxesRouteWithChildren = SandboxesRoute._addFileChildren( + SandboxesRouteChildren, +) + interface ApiGenerateImageRouteChildren { ApiGenerateImageArtifactRoute: typeof ApiGenerateImageArtifactRoute } @@ -1204,7 +1255,7 @@ const rootRouteChildren: RootRouteChildren = { QueueingRoute: QueueingRoute, RealtimeRoute: RealtimeRoute, ResumableRoute: ResumableRoute, - SandboxesRoute: SandboxesRoute, + SandboxesRoute: SandboxesRouteWithChildren, SandboxesDurableRoute: SandboxesDurableRoute, ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, @@ -1224,6 +1275,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMcpStatusRoute: ApiMcpStatusRoute, ApiPersistentChatRoute: ApiPersistentChatRoute, ApiResumableRoute: ApiResumableRoute, + ApiSandboxRepoReportRoute: ApiSandboxRepoReportRoute, ApiSandboxTriageRoute: ApiSandboxTriageRoute, ApiSandboxTriageDurableRoute: ApiSandboxTriageDurableRoute, ApiStructuredChatRoute: ApiStructuredChatRoute, diff --git a/examples/ts-react-chat/src/routes/api.sandbox-repo-report.ts b/examples/ts-react-chat/src/routes/api.sandbox-repo-report.ts new file mode 100644 index 0000000000..8c146bb20e --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.sandbox-repo-report.ts @@ -0,0 +1,122 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + isReportAgent, + isReportHarness, + isReportProvider, + REPORT_AGENTS, + REPORT_REPO, +} from '../repo-report-options' +import { RepoReportSchema } from '../repo-report-schema' +import type { ReportAgent } from '../repo-report-options' + +interface ReportBody { + harness: unknown + provider: unknown + agent: unknown + threadId: unknown +} + +function json(status: number, error: string): Response { + return new Response(JSON.stringify({ error }), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +export function buildRepoReportPrompt(agent: ReportAgent): string { + const focus = REPORT_AGENTS[agent].hint + return [ + `The ${REPORT_REPO} repository is checked out in the working directory.`, + 'Read README, package.json, and packages/* enough to answer.', + 'Do not change files.', + `Focus: ${focus}.`, + 'Return only the structured report.', + ].join('\n') +} + +export async function repoReportPost(request: Request): Promise { + if (request.signal.aborted) return new Response(null, { status: 499 }) + + const [{ chat, toServerSentEventsStream }, { withSandbox }, triage] = + await Promise.all([ + import('@tanstack/ai'), + import('@tanstack/ai-sandbox'), + import('../sandbox-triage'), + ]) + const { buildHarnessAdapter, buildSandbox, isProvider, missingEnv } = triage + + let data: ReportBody + try { + const body = (await request.json()) as { + data?: ReportBody + forwardedProps?: ReportBody + } + const layer = body.data ?? body.forwardedProps + if (layer == null || typeof layer !== 'object') { + throw new Error('body.data (or forwardedProps) is required') + } + data = layer + } catch (error) { + return json(400, error instanceof Error ? error.message : 'invalid body') + } + + if (!isReportHarness(data.harness) || !isProvider(data.provider)) { + return json(400, 'Unknown harness or provider.') + } + if (!isReportProvider(data.provider)) { + return json(400, 'This page only supports docker or local.') + } + if (!isReportAgent(data.agent)) { + return json(400, 'Unknown agent.') + } + + const threadId = + typeof data.threadId === 'string' && data.threadId !== '' + ? data.threadId + : crypto.randomUUID() + const missing = missingEnv(data.harness, data.provider) + if (missing.length > 0) { + return json( + 500, + `Missing required env: ${missing.join(', ')}. Set it and restart the dev server.`, + ) + } + + const abortController = new AbortController() + request.signal.addEventListener('abort', () => abortController.abort()) + + try { + const sandbox = buildSandbox({ + harness: data.harness, + provider: data.provider, + repo: REPORT_REPO, + threadId, + }) + const stream = chat({ + threadId, + adapter: buildHarnessAdapter(data.harness, data.provider), + messages: [{ role: 'user', content: buildRepoReportPrompt(data.agent) }], + outputSchema: RepoReportSchema, + stream: true, + middleware: [withSandbox(sandbox)], + abortController, + }) + return new Response(toServerSentEventsStream(stream, abortController), { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + } catch (error) { + return json(500, error instanceof Error ? error.message : 'report failed') + } +} + +export const Route = createFileRoute('/api/sandbox-repo-report')({ + server: { + handlers: { + POST: ({ request }) => repoReportPost(request), + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index be30c2f919..c7accdc509 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -223,6 +223,13 @@ function Messages({ Sandboxes + + + Repo report + diff --git a/examples/ts-react-chat/src/routes/sandboxes.repo-report.tsx b/examples/ts-react-chat/src/routes/sandboxes.repo-report.tsx new file mode 100644 index 0000000000..3884522173 --- /dev/null +++ b/examples/ts-react-chat/src/routes/sandboxes.repo-report.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { BookOpen, Play, Square } from 'lucide-react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { + isReportAgent, + isReportHarness, + isReportProvider, + REPORT_AGENTS, + REPORT_HARNESSES, + REPORT_PROVIDERS, + REPORT_REPO, +} from '../repo-report-options' +import { RepoReportSchema } from '../repo-report-schema' +import type { + ReportAgent, + ReportHarness, + ReportProvider, +} from '../repo-report-options' + +export const Route = createFileRoute('/sandboxes/repo-report')({ + component: RepoReportPage, +}) + +function RepoReportPage() { + const [threadId] = useState(() => crypto.randomUUID()) + const [harness, setHarness] = useState('claude-code') + const [provider, setProvider] = useState('docker') + const [agent, setAgent] = useState('explainer') + + const chat = useChat({ + threadId, + outputSchema: RepoReportSchema, + connection: fetchServerSentEvents('/api/sandbox-repo-report'), + forwardedProps: { harness, provider, agent, threadId }, + }) + + const canRun = !chat.isLoading + + function run() { + if (!canRun) return + chat.clear() + void chat.sendMessage(`Report on ${REPORT_REPO}`) + } + + const report = chat.final + + return ( +
+
+ +
+

Repo report

+

+ Clone {REPORT_REPO}, pick a harness, and grab a typed report from{' '} + outputSchema. +

+
+
+ +
+ + + + + {REPORT_AGENTS[agent].hint} + + {chat.isLoading ? ( + + ) : ( + + )} +
+ +
+
+ {chat.error ? ( +

{chat.error.message}

+ ) : null} + + {chat.messages.map((message) => ( +
+ {message.parts.map((part, index) => { + if (part.type === 'text' && part.content) { + return ( +

+ {part.content} +

+ ) + } + if (part.type === 'tool-call') { + return ( +

+ tool {part.name} +

+ ) + } + return null + })} +
+ ))} + + {report ? ( +
+

{report.name}

+

{report.oneLiner}

+

+ Audience: + {report.audience} +

+
    + {report.mainPackages.map((pkg) => ( +
  • + + {pkg.name} + + {': '} + {pkg.role} +
  • + ))} +
+

{report.howToRun}

+
+ ) : null} + + {!chat.isLoading && chat.messages.length === 0 ? ( +

+ Pick Claude Code, Grok Build, or Codex, pick an agent, then Run. + The sandbox clones {REPORT_REPO} and the typed report lands here. +

+ ) : null} +
+
+
+ ) +} diff --git a/packages/ai-claude-code/src/adapters/text.ts b/packages/ai-claude-code/src/adapters/text.ts index 0725b46f67..2fd3625080 100644 --- a/packages/ai-claude-code/src/adapters/text.ts +++ b/packages/ai-claude-code/src/adapters/text.ts @@ -154,12 +154,21 @@ export class ClaudeCodeTextAdapter< } /** Build the `claude` command line (prompt goes via stdin, not argv). */ + supportsCombinedToolsAndSchema(): boolean { + return true + } + + combinedStructuredOutputSource(): 'event' { + return 'event' + } + private buildCommand( options: TextOptions, resume: string | undefined, policyFlags: ClaudePolicyFlags, mcpConfigPath: string | undefined, permissionPromptTool: string | undefined, + jsonSchemaPath: string | undefined, ): string { const config = this.adapterConfig const modelOptions = options.modelOptions @@ -218,6 +227,9 @@ export class ClaudeCodeTextAdapter< } if (mcpConfigPath !== undefined) args.push('--mcp-config', q(mcpConfigPath)) + if (jsonSchemaPath !== undefined) { + args.push('--json-schema', q(jsonSchemaPath)) + } if (permissionPromptTool !== undefined) { args.push('--permission-prompt-tool', q(permissionPromptTool)) } @@ -421,6 +433,14 @@ export class ClaudeCodeTextAdapter< tempFiles.push(mcpConfigPath) mcpConfigArg = mcpConfigFile } + let jsonSchemaArg: string | undefined + if (options.outputSchema) { + const schemaFile = `.tanstack-output-schema-${runIdSegment}.json` + const schemaPath = `${cwd}/${schemaFile}` + await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema)) + tempFiles.push(schemaPath) + jsonSchemaArg = schemaFile + } const command = this.buildCommand( options, resume, @@ -429,6 +449,7 @@ export class ClaudeCodeTextAdapter< bridge && permission ? `mcp__${bridge.name}__${permission.toolName}` : undefined, + jsonSchemaArg, ) // Deliver the prompt. The default feeds it over stdin (keeps it out of @@ -509,6 +530,7 @@ export class ClaudeCodeTextAdapter< // which mixes in `Date.now()` / `Math.random()`). See // `createRunScopedIdGen` in `@tanstack/ai-sandbox`. genId: createRunScopedIdGen(runId), + ...(options.outputSchema ? { expectStructuredOutput: true } : {}), onSdkMessage: (message) => logger.provider(`provider=claude-code type=${message.type}`, { chunk: message, @@ -585,8 +607,8 @@ export class ClaudeCodeTextAdapter< ): Promise> { return Promise.reject( new Error( - 'Structured output is not yet supported by the in-sandbox Claude Code adapter. ' + - 'Use a model adapter (e.g. anthropic) for structured output, or omit outputSchema.', + 'This harness honors outputSchema on chat() in the same turn. ' + + 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.', ), ) } diff --git a/packages/ai-claude-code/src/stream/translate.ts b/packages/ai-claude-code/src/stream/translate.ts index 67271d63c7..d4313f0c98 100644 --- a/packages/ai-claude-code/src/stream/translate.ts +++ b/packages/ai-claude-code/src/stream/translate.ts @@ -1,4 +1,8 @@ import { EventType, buildBaseUsage } from '@tanstack/ai' +import { + structuredOutputCompleteChunk, + structuredOutputStartChunk, +} from '@tanstack/ai/adapter-internals' import type { StreamChunk, TokenUsage } from '@tanstack/ai' import type { AgentSdkMessage, @@ -34,6 +38,8 @@ export interface TranslateContext { onSessionId?: (sessionId: string) => void /** Called for each raw SDK message, for logging. */ onSdkMessage?: (message: AgentSdkMessage) => void + /** Emit structured-output events from `result.structured_output`. */ + expectStructuredOutput?: boolean } /** @@ -320,6 +326,28 @@ export async function* translateSdkStream( yield* synthesizeUnresolvedResults() const usage = buildUsage(message.usage, message.total_cost_usd) + if ( + ctx.expectStructuredOutput === true && + message.structured_output !== undefined + ) { + const object = message.structured_output + const raw = JSON.stringify(object) + const messageId = genId() + yield structuredOutputStartChunk({ + messageId, + model, + threadId, + runId, + }) + yield structuredOutputCompleteChunk({ + messageId, + model, + threadId, + runId, + object, + raw, + }) + } if (message.subtype === 'success') { yield { type: EventType.RUN_FINISHED, diff --git a/packages/ai-claude-code/tests/text-adapter.test.ts b/packages/ai-claude-code/tests/text-adapter.test.ts index fe468ccc43..404cce480f 100644 --- a/packages/ai-claude-code/tests/text-adapter.test.ts +++ b/packages/ai-claude-code/tests/text-adapter.test.ts @@ -161,4 +161,57 @@ describe('claude-code in-sandbox adapter', () => { expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true) await sbx.destroy() }) + + it('passes --json-schema and emits structured-output.complete', async () => { + const fake = [ + `import { writeFileSync } from 'node:fs'`, + `writeFileSync('argv.txt', process.argv.slice(2).join(' '))`, + `let input = ''`, + `process.stdin.on('data', (d) => { input += d })`, + `process.stdin.on('end', () => {`, + ` const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n')`, + ` w({ type: 'system', subtype: 'init', session_id: 'sess-so', model: 'haiku', tools: [] })`, + ` w({ type: 'assistant', message: { id: 'msg-1', content: [{ type: 'text', text: 'looking' }] }, parent_tool_use_id: null })`, + ` w({ type: 'result', subtype: 'success', result: 'done', structured_output: { summary: 'ok' }, usage: { input_tokens: 1, output_tokens: 1 } })`, + `})`, + ].join('\n') + + const sbx = await provider.create({}) + await sbx.fs.write('/workspace/fake-claude.mjs', fake) + + const adapter = claudeCodeText('haiku', { + claudeExecutable: 'node fake-claude.mjs', + streamPartials: false, + emitDiff: false, + }) + + const chunks = await collect( + adapter.chatStream({ + model: 'haiku', + messages: [{ role: 'user', content: 'summarize' }], + logger: noopLogger, + capabilities: capabilityContextWith(sbx), + outputSchema: { + type: 'object', + properties: { summary: { type: 'string' } }, + required: ['summary'], + }, + }), + ) + + const argv = await sbx.fs.read('/workspace/argv.txt') + expect(argv).toContain('--json-schema') + + const complete = chunks.find( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ) + expect(complete).toBeDefined() + if (complete?.type === 'CUSTOM') { + expect(complete.value).toEqual( + expect.objectContaining({ object: { summary: 'ok' } }), + ) + } + + await sbx.destroy() + }) }) diff --git a/packages/ai-claude-code/tests/translate.test.ts b/packages/ai-claude-code/tests/translate.test.ts index 607d2c4573..ed85706254 100644 --- a/packages/ai-claude-code/tests/translate.test.ts +++ b/packages/ai-claude-code/tests/translate.test.ts @@ -23,12 +23,12 @@ async function* fromArray( async function collect( messages: Array, + context: ReturnType & { + expectStructuredOutput?: boolean + } = makeContext(), ): Promise> { const chunks: Array = [] - for await (const chunk of translateSdkStream( - fromArray(messages), - makeContext(), - )) { + for await (const chunk of translateSdkStream(fromArray(messages), context)) { chunks.push(chunk) } return chunks @@ -482,4 +482,71 @@ describe('translateSdkStream', () => { 'RUN_FINISHED', ]) }) + + it('emits structured-output events from result.structured_output when expected', async () => { + const resultWithObject: AgentSdkMessage = { + type: 'result', + subtype: 'success', + result: 'done', + usage, + structured_output: { summary: 'ok' }, + } + const chunks = await collect( + [init, assistantText('Looking around.'), resultWithObject], + { ...makeContext(), expectStructuredOutput: true }, + ) + const start = chunks.find( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.start', + ) + const complete = chunks.find( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ) + expect(start).toBeDefined() + expect(complete).toBeDefined() + if (complete?.type === 'CUSTOM') { + expect(complete.value).toEqual( + expect.objectContaining({ + object: { summary: 'ok' }, + raw: JSON.stringify({ summary: 'ok' }), + }), + ) + } + expect(chunks.some((c) => c.type === 'TEXT_MESSAGE_CONTENT')).toBe(true) + expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true) + }) + + it('does not emit structured-output events when the flag is off', async () => { + const resultWithObject: AgentSdkMessage = { + type: 'result', + subtype: 'success', + result: 'done', + usage, + structured_output: { summary: 'ok' }, + } + const chunks = await collect([ + init, + assistantText('Looking around.'), + resultWithObject, + ]) + expect( + chunks.some( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ), + ).toBe(false) + }) + + it('maps error_max_structured_output_retries to RUN_ERROR', async () => { + const failed: AgentSdkMessage = { + type: 'result', + subtype: 'error_max_structured_output_retries', + errors: ['schema retries exhausted'], + usage, + } + const chunks = await collect([init, failed]) + const err = chunks.find((c) => c.type === 'RUN_ERROR') + expect(err).toBeDefined() + if (err?.type === 'RUN_ERROR') { + expect(err.message).toContain('schema retries exhausted') + } + }) }) diff --git a/packages/ai-codex/src/adapters/text.ts b/packages/ai-codex/src/adapters/text.ts index 85583df937..fc8c3367b8 100644 --- a/packages/ai-codex/src/adapters/text.ts +++ b/packages/ai-codex/src/adapters/text.ts @@ -140,12 +140,21 @@ export class CodexTextAdapter< } /** Mirror @openai/codex-sdk's `codex exec --experimental-json` invocation. */ + supportsCombinedToolsAndSchema(): boolean { + return true + } + + combinedStructuredOutputSource(): 'event' { + return 'event' + } + private buildCommand( options: TextOptions, resume: string | undefined, bridge: HostToolBridge | undefined, policyFlags: CodexPolicyFlags, provider: string, + outputSchemaPath: string | undefined, ): string { const config = this.adapterConfig const modelOptions = options.modelOptions @@ -214,6 +223,10 @@ export class CodexTextAdapter< args.push('--config', q(`${key}=${value}`)) } + if (outputSchemaPath !== undefined) { + args.push('--output-schema', q(outputSchemaPath)) + } + // Resume an existing thread (mirrors the SDK's `resume `). if (resume !== undefined) args.push('resume', q(resume)) @@ -304,12 +317,21 @@ export class CodexTextAdapter< const policy = options.capabilities ? getSandboxPolicy(options.capabilities, { optional: true }) : undefined + let outputSchemaArg: string | undefined + if (options.outputSchema) { + const schemaFile = `.tanstack-output-schema-${encodeRunId(runId)}.json` + const schemaPath = `${cwd}/${schemaFile}` + await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema)) + tempFiles.push(schemaPath) + outputSchemaArg = schemaFile + } const command = this.buildCommand( options, resume, bridge, mapPolicyToCodexFlags(policy), sandbox.provider, + outputSchemaArg, ) logger.request( @@ -409,6 +431,7 @@ export class CodexTextAdapter< parentRunId: options.parentRunId, }), genId, + ...(options.outputSchema ? { expectStructuredOutput: true } : {}), onThreadEvent: (event) => logger.provider(`provider=codex type=${event.type}`, { chunk: event, @@ -458,8 +481,8 @@ export class CodexTextAdapter< ): Promise> { return Promise.reject( new Error( - 'Structured output is not yet supported by the in-sandbox Codex adapter. ' + - 'Use a model adapter for structured output, or omit outputSchema.', + 'This harness honors outputSchema on chat() in the same turn. ' + + 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.', ), ) } diff --git a/packages/ai-codex/src/stream/translate.ts b/packages/ai-codex/src/stream/translate.ts index 082e26ba3e..ca3244405b 100644 --- a/packages/ai-codex/src/stream/translate.ts +++ b/packages/ai-codex/src/stream/translate.ts @@ -1,4 +1,8 @@ import { EventType, buildBaseUsage } from '@tanstack/ai' +import { + structuredOutputCompleteChunk, + structuredOutputStartChunk, +} from '@tanstack/ai/adapter-internals' import type { StreamChunk, TokenUsage } from '@tanstack/ai' import type { CodexThreadEvent, CodexThreadItem, CodexUsage } from './sdk-types' @@ -18,6 +22,8 @@ export interface TranslateContext { onSessionId?: (sessionId: string) => void /** Called for each raw SDK thread event, for logging. */ onThreadEvent?: (event: CodexThreadEvent) => void + /** Treat the last agent_message as schema JSON. */ + expectStructuredOutput?: boolean } /** @@ -237,30 +243,86 @@ export async function* translateThreadEvents( unresolvedToolCalls.add(item.id) } + const pendingAgentMessages: Array<{ id: string; text: string }> = [] + + function* emitAgentText(item: { + id: string + text: string + }): Generator { + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: item.id, + model, + timestamp: now(), + role: 'assistant', + } + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: item.id, + model, + timestamp: now(), + delta: item.text, + content: item.text, + } + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: item.id, + model, + timestamp: now(), + } + } + + function* flushAgentMessages( + lastIsStructured: boolean, + ): Generator { + for (let index = 0; index < pendingAgentMessages.length; index++) { + const item = pendingAgentMessages[index] + if (item === undefined) continue + const isLast = index === pendingAgentMessages.length - 1 + if (lastIsStructured && isLast) { + try { + const object: unknown = JSON.parse(item.text) + yield structuredOutputStartChunk({ + messageId: item.id, + model, + threadId, + runId, + }) + yield structuredOutputCompleteChunk({ + messageId: item.id, + model, + threadId, + runId, + object, + raw: item.text, + }) + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : 'Invalid structured output JSON' + yield { + type: EventType.RUN_ERROR, + model, + timestamp: now(), + message, + error: { message }, + } + } + } else { + yield* emitAgentText(item) + } + } + pendingAgentMessages.length = 0 + } + function* handleItemCompleted(item: CodexThreadItem): Generator { if (item.type === 'agent_message') { - const messageId = item.id - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - model, - timestamp: now(), - role: 'assistant', - } - yield { - type: EventType.TEXT_MESSAGE_CONTENT, - messageId, - model, - timestamp: now(), - delta: item.text, - content: item.text, - } - yield { - type: EventType.TEXT_MESSAGE_END, - messageId, - model, - timestamp: now(), + if (ctx.expectStructuredOutput === true) { + pendingAgentMessages.push({ id: item.id, text: item.text }) + return } + yield* emitAgentText(item) } else if (item.type === 'reasoning') { const reasoningId = item.id yield { @@ -341,6 +403,7 @@ export async function* translateThreadEvents( } else if (event.type === 'item.completed') { yield* handleItemCompleted(event.item) } else if (event.type === 'turn.completed') { + yield* flushAgentMessages(ctx.expectStructuredOutput === true) yield* synthesizeUnresolvedResults() const usage = buildUsage(event.usage) yield { @@ -353,6 +416,7 @@ export async function* translateThreadEvents( ...(usage !== undefined && { usage }), } } else if (event.type === 'turn.failed' || event.type === 'error') { + yield* flushAgentMessages(false) yield* synthesizeUnresolvedResults() const message = event.type === 'turn.failed' diff --git a/packages/ai-codex/tests/text-adapter.test.ts b/packages/ai-codex/tests/text-adapter.test.ts index 57b84b3d70..681635c370 100644 --- a/packages/ai-codex/tests/text-adapter.test.ts +++ b/packages/ai-codex/tests/text-adapter.test.ts @@ -201,4 +201,56 @@ describe('codex in-sandbox adapter', () => { const err = chunks.find((c) => c.type === 'RUN_ERROR') expect((err as { message?: string }).message).toMatch(/requires a sandbox/i) }) + + it('passes --output-schema and emits structured-output.complete', async () => { + const fake = [ + `import { writeFileSync } from 'node:fs'`, + `writeFileSync('codex-argv.txt', process.argv.join(' '))`, + `let input = ''`, + `process.stdin.on('data', (d) => { input += d })`, + `process.stdin.on('end', () => {`, + ` const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n')`, + ` w({ type: 'thread.started', thread_id: 'th-so' })`, + ` w({ type: 'turn.started' })`, + ` w({ type: 'item.completed', item: { id: 'i1', type: 'agent_message', text: '{"ok":true}' } })`, + ` w({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } })`, + `})`, + ].join('\n') + + const sbx = await provider.create({}) + await sbx.fs.write('/workspace/fake-codex.mjs', fake) + + const adapter = codexText('gpt-5.5-codex', { + codexExecutable: 'node fake-codex.mjs', + }) + + const chunks = await collect( + adapter.chatStream({ + model: 'gpt-5.5-codex', + messages: [{ role: 'user', content: 'summarize' }], + logger: noopLogger, + capabilities: capabilityContextWith(sbx), + outputSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'], + }, + }), + ) + + const argv = await sbx.fs.read('/workspace/codex-argv.txt') + expect(argv).toContain('--output-schema') + + const complete = chunks.find( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ) + expect(complete).toBeDefined() + if (complete?.type === 'CUSTOM') { + expect(complete.value).toEqual( + expect.objectContaining({ object: { ok: true } }), + ) + } + + await sbx.destroy() + }) }) diff --git a/packages/ai-codex/tests/translate.test.ts b/packages/ai-codex/tests/translate.test.ts index b76e1d48fc..9b20810a32 100644 --- a/packages/ai-codex/tests/translate.test.ts +++ b/packages/ai-codex/tests/translate.test.ts @@ -451,4 +451,76 @@ describe('translateThreadEvents', () => { 'RUN_FINISHED', ]) }) + + it('emits structured-output events from the last agent_message when expected', async () => { + const chunks = await collect( + [ + started, + { + type: 'item.completed', + item: { id: 'item-1', type: 'agent_message', text: '{"ok":true}' }, + }, + completedTurn, + ], + makeCtx({ expectStructuredOutput: true }), + ) + expect( + chunks.some( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.start', + ), + ).toBe(true) + const complete = chunks.find( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ) + expect(complete).toBeDefined() + if (complete?.type === 'CUSTOM') { + expect(complete.value).toEqual( + expect.objectContaining({ object: { ok: true }, raw: '{"ok":true}' }), + ) + } + expect(chunks.some((c) => c.type === 'TEXT_MESSAGE_CONTENT')).toBe(false) + }) + + it('keeps earlier agent_message text when only the last item is structured', async () => { + const chunks = await collect( + [ + started, + { + type: 'item.completed', + item: { id: 'item-1', type: 'agent_message', text: 'working' }, + }, + { + type: 'item.completed', + item: { id: 'item-2', type: 'agent_message', text: '{"ok":true}' }, + }, + completedTurn, + ], + makeCtx({ expectStructuredOutput: true }), + ) + const text = chunks + .filter((c) => c.type === 'TEXT_MESSAGE_CONTENT') + .map((c) => ('delta' in c ? c.delta : '')) + .join('') + expect(text).toBe('working') + expect( + chunks.some( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ), + ).toBe(true) + }) + + it('emits RUN_ERROR when the last agent_message is not JSON', async () => { + const chunks = await collect( + [ + started, + { + type: 'item.completed', + item: { id: 'item-1', type: 'agent_message', text: 'not json' }, + }, + completedTurn, + ], + makeCtx({ expectStructuredOutput: true }), + ) + expect(chunks.some((c) => c.type === 'RUN_ERROR')).toBe(true) + }) }) diff --git a/packages/ai-grok-build/src/adapters/text.ts b/packages/ai-grok-build/src/adapters/text.ts index f8658aebe3..d05178744d 100644 --- a/packages/ai-grok-build/src/adapters/text.ts +++ b/packages/ai-grok-build/src/adapters/text.ts @@ -1,5 +1,11 @@ import { EventType, normalizeSystemPrompts } from '@tanstack/ai' -import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals' +import { + appendOutputSchemaInstruction, + parseJsonFromAssistantText, + structuredOutputCompleteChunk, + structuredOutputStartChunk, + toRunErrorRawEvent, +} from '@tanstack/ai/adapter-internals' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { AsyncQueue, @@ -197,6 +203,14 @@ export class GrokBuildTextAdapter< return `${exe} ${args.join(' ')}` } + supportsCombinedToolsAndSchema(): boolean { + return true + } + + combinedStructuredOutputSource(): 'event' { + return 'event' + } + private protocol( options: TextOptions, ): GrokBuildProtocol { @@ -407,12 +421,18 @@ export class GrokBuildTextAdapter< const systemPrompts = normalizeSystemPrompts(options.systemPrompts) .map((p) => p.content) .filter((c) => c.trim() !== '') - const promptText = this.applySystemPrompts( + let promptText = this.applySystemPrompts( systemPrompts, session.resumed || sessionId === undefined ? resumePrompt : buildPrompt(options.messages, undefined).prompt, ) + if (options.outputSchema) { + promptText = appendOutputSchemaInstruction( + promptText, + options.outputSchema, + ) + } session .prompt(promptText) @@ -426,7 +446,8 @@ export class GrokBuildTextAdapter< }) .catch((error: unknown) => queue.fail(error)) - yield* mergeChunkStreams( + let lastAssistantText = '' + for await (const chunk of mergeChunkStreams( translateAcpStream(queue, { model: this.model, runId, @@ -446,7 +467,20 @@ export class GrokBuildTextAdapter< }), }), channel.stream, - ) + )) { + if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) { + lastAssistantText += chunk.delta + } + yield chunk + } + + if (options.outputSchema) { + yield* this.emitParsedStructuredOutput( + lastAssistantText, + threadId, + runId, + ) + } if (this.adapterConfig.emitDiff !== false) { yield* this.emitDiffChunks(sandbox, cwd, threadId, runId) @@ -487,6 +521,43 @@ export class GrokBuildTextAdapter< return `${systemPrompts.join('\n\n')}\n\n${prompt}` } + private *emitParsedStructuredOutput( + raw: string, + threadId: string, + runId: string, + ): Generator { + try { + const object = parseJsonFromAssistantText(raw) + const messageId = this.generateId() + yield structuredOutputStartChunk({ + messageId, + model: this.model, + threadId, + runId, + }) + yield structuredOutputCompleteChunk({ + messageId, + model: this.model, + threadId, + runId, + object, + raw, + }) + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : 'Failed to parse structured output' + yield { + type: EventType.RUN_ERROR, + model: this.model, + timestamp: Date.now(), + message, + error: { message }, + } + } + } + private async *emitDiffChunks( sandbox: SandboxHandle, cwd: string, @@ -579,10 +650,16 @@ export class GrokBuildTextAdapter< const systemPrompts = normalizeSystemPrompts(options.systemPrompts) .map((p) => p.content) .filter((c) => c.trim() !== '') - const fullPrompt = + let fullPrompt = systemPrompts.length > 0 ? `${systemPrompts.join('\n\n')}\n\n${prompt}` : prompt + if (options.outputSchema) { + fullPrompt = appendOutputSchemaInstruction( + fullPrompt, + options.outputSchema, + ) + } const exe = await resolveGrokExecutable( sandbox, @@ -664,6 +741,7 @@ export class GrokBuildTextAdapter< parentRunId: options.parentRunId, }), genId, + ...(options.outputSchema ? { expectStructuredOutput: true } : {}), onThreadEvent: (event) => logger.provider(`provider=grok-build type=${event.type}`, { chunk: event, @@ -715,8 +793,8 @@ export class GrokBuildTextAdapter< ): Promise> { return Promise.reject( new Error( - 'Structured output is not yet supported by the in-sandbox Grok Build adapter. ' + - 'Use a model adapter (e.g. grok) for structured output, or omit outputSchema.', + 'This harness honors outputSchema on chat() in the same turn. ' + + 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.', ), ) } diff --git a/packages/ai-grok-build/src/stream/translate.ts b/packages/ai-grok-build/src/stream/translate.ts index b54116fa4e..885565287e 100644 --- a/packages/ai-grok-build/src/stream/translate.ts +++ b/packages/ai-grok-build/src/stream/translate.ts @@ -1,4 +1,9 @@ import { EventType, buildBaseUsage } from '@tanstack/ai' +import { + parseJsonFromAssistantText, + structuredOutputCompleteChunk, + structuredOutputStartChunk, +} from '@tanstack/ai/adapter-internals' import { GrokThoughtRouter } from './thought-router' import type { StreamChunk, TokenUsage } from '@tanstack/ai' import type { @@ -23,6 +28,8 @@ export interface TranslateContext { onSessionId?: (sessionId: string) => void /** Called for each raw harness event, for logging. */ onThreadEvent?: (event: GrokBuildStreamEvent) => void + /** Parse accumulated assistant text as schema JSON at run end. */ + expectStructuredOutput?: boolean } /** @@ -153,6 +160,7 @@ export async function* translateThreadEvents( let assistantMessageId: string | null = null let reasoningMessageId: string | null = null let thoughtRouter: GrokThoughtRouter | null = null + let assistantText = '' const unresolvedToolCalls = new Set() const openedToolItems = new Set() @@ -255,12 +263,45 @@ export async function* translateThreadEvents( model, timestamp: now(), } + assistantText += event.data break } case 'end': { yield* getThoughtRouter().finalize() yield* closeReasoning() yield* closeAssistant() + if (ctx.expectStructuredOutput === true) { + try { + const object = parseJsonFromAssistantText(assistantText) + const messageId = genId() + yield structuredOutputStartChunk({ + messageId, + model, + threadId, + runId, + }) + yield structuredOutputCompleteChunk({ + messageId, + model, + threadId, + runId, + object, + raw: assistantText, + }) + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : 'Failed to parse structured output' + yield { + type: EventType.RUN_ERROR, + model, + timestamp: now(), + message, + error: { message }, + } + } + } if (event.sessionId) { onSessionId?.(event.sessionId) yield { diff --git a/packages/ai-grok-build/tests/translate.test.ts b/packages/ai-grok-build/tests/translate.test.ts index 5bb4619a7b..04dd0b614c 100644 --- a/packages/ai-grok-build/tests/translate.test.ts +++ b/packages/ai-grok-build/tests/translate.test.ts @@ -5,16 +5,19 @@ import type { StreamChunk } from '@tanstack/ai' async function collect( events: Array, + expectStructuredOutput = false, ): Promise> { async function* source() { for (const event of events) yield event } + let n = 0 const out: Array = [] for await (const chunk of translateThreadEvents(source(), { model: 'grok-build', runId: 'run-1', threadId: 'thread-1', - genId: () => 'gen-id', + genId: () => `gen-${++n}`, + ...(expectStructuredOutput ? { expectStructuredOutput: true } : {}), })) { out.push(chunk) } @@ -57,6 +60,46 @@ describe('translateThreadEvents (native grok streaming-json)', () => { ).toBe(true) }) + it('emits structured-output events from accumulated text when expected', async () => { + const chunks = await collect( + [ + { type: 'text', data: '{"ok":true}' }, + { + type: 'end', + stopReason: 'EndTurn', + sessionId: 'sess-so', + requestId: 'req-1', + }, + ], + true, + ) + const complete = chunks.find( + (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', + ) + expect(complete).toBeDefined() + if (complete?.type === 'CUSTOM') { + expect(complete.value).toEqual( + expect.objectContaining({ object: { ok: true } }), + ) + } + }) + + it('emits RUN_ERROR when expected structured text is not JSON', async () => { + const chunks = await collect( + [ + { type: 'text', data: 'not json' }, + { + type: 'end', + stopReason: 'EndTurn', + sessionId: 'sess-so', + requestId: 'req-1', + }, + ], + true, + ) + expect(chunks.some((c) => c.type === 'RUN_ERROR')).toBe(true) + }) + it('surfaces native error events as RUN_ERROR', async () => { const chunks = await collect([{ type: 'error', message: 'bad model' }]) expect(chunks.some((c) => c.type === 'RUN_ERROR')).toBe(true) diff --git a/packages/ai-opencode/src/adapters/text.ts b/packages/ai-opencode/src/adapters/text.ts index de3650103c..fd9837a3c1 100644 --- a/packages/ai-opencode/src/adapters/text.ts +++ b/packages/ai-opencode/src/adapters/text.ts @@ -1,5 +1,11 @@ import { EventType, normalizeSystemPrompts } from '@tanstack/ai' -import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals' +import { + appendOutputSchemaInstruction, + parseJsonFromAssistantText, + structuredOutputCompleteChunk, + structuredOutputStartChunk, + toRunErrorRawEvent, +} from '@tanstack/ai/adapter-internals' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { DurableAttachNotSupportedError, @@ -110,6 +116,14 @@ export class OpencodeTextAdapter< return getSandbox(ctx) } + supportsCombinedToolsAndSchema(): boolean { + return true + } + + combinedStructuredOutputSource(): 'event' { + return 'event' + } + private applySystemPrompts( options: TextOptions, prompt: string, @@ -321,16 +335,24 @@ export class OpencodeTextAdapter< queue.push({ kind: 'session', sessionId: session.sessionId }) - const promptText = this.applySystemPrompts( + let promptText = this.applySystemPrompts( options, session.resumed || sessionId === undefined ? resumePrompt : buildPrompt(options.messages, undefined).prompt, ) + if (options.outputSchema) { + promptText = appendOutputSchemaInstruction( + promptText, + options.outputSchema, + ) + } + let lastAssistantText = '' session .prompt(promptText) - .then(({ message }) => { + .then(({ message, text }) => { + lastAssistantText = text queue.push({ kind: 'done', message }) queue.end() }) @@ -354,6 +376,39 @@ export class OpencodeTextAdapter< channel.stream, ) + if (options.outputSchema) { + try { + const object = parseJsonFromAssistantText(lastAssistantText) + const messageId = this.generateId() + yield structuredOutputStartChunk({ + messageId, + model: this.model, + threadId, + runId, + }) + yield structuredOutputCompleteChunk({ + messageId, + model: this.model, + threadId, + runId, + object, + raw: lastAssistantText, + }) + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : 'Failed to parse structured output' + yield { + type: EventType.RUN_ERROR, + model: this.model, + timestamp: Date.now(), + message, + error: { message }, + } + } + } + // Surface pending approval requests (ask-policy actions awaiting a client // decision); the client approves and re-runs to continue. for (const event of approvalRequests) yield event @@ -392,8 +447,8 @@ export class OpencodeTextAdapter< ): Promise> { return Promise.reject( new Error( - 'Structured output is not yet supported by the in-sandbox OpenCode adapter. ' + - 'Use a model adapter for structured output, or omit outputSchema.', + 'This harness honors outputSchema on chat() in the same turn. ' + + 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.', ), ) } diff --git a/packages/ai-opencode/tests/text-adapter.test.ts b/packages/ai-opencode/tests/text-adapter.test.ts index 0ab5f0fce6..00d9a0a172 100644 --- a/packages/ai-opencode/tests/text-adapter.test.ts +++ b/packages/ai-opencode/tests/text-adapter.test.ts @@ -94,6 +94,12 @@ describe('startOpencodeServerInSandbox', () => { }) describe('opencode adapter', () => { + it('opts into combined event-source structured output', () => { + const adapter = opencodeText('anthropic/claude-sonnet-4-5') + expect(adapter.supportsCombinedToolsAndSchema()).toBe(true) + expect(adapter.combinedStructuredOutputSource()).toBe('event') + }) + it('requires a sandbox capability', async () => { const adapter = opencodeText('anthropic/claude-sonnet-4-5') const result = await collect( diff --git a/packages/ai/skills/ai-core/structured-outputs/SKILL.md b/packages/ai/skills/ai-core/structured-outputs/SKILL.md index 6126921fcc..a8616061ca 100644 --- a/packages/ai/skills/ai-core/structured-outputs/SKILL.md +++ b/packages/ai/skills/ai-core/structured-outputs/SKILL.md @@ -20,6 +20,7 @@ sources: - 'TanStack/ai:docs/structured-outputs/streaming.md' - 'TanStack/ai:docs/structured-outputs/multi-turn.md' - 'TanStack/ai:docs/structured-outputs/with-tools.md' + - 'TanStack/ai:docs/structured-outputs/harnesses.md' --- # Structured Outputs @@ -48,7 +49,7 @@ person.age // number When `outputSchema` is provided, `chat()` returns `Promise>` instead of `AsyncIterable`. The result is fully typed. -Adding `stream: true` switches the return to `StructuredOutputStream>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, and **Pattern 5** for multi-turn structured chats. +Adding `stream: true` switches the return to `StructuredOutputStream>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, **Pattern 5** for multi-turn structured chats, and **Pattern 6** for harness adapters. ## Decision: which pattern fits @@ -59,6 +60,7 @@ Adding `stream: true` switches the return to `StructuredOutputStream }` so the model sees its own prior structured response. Streaming / errored parts are dropped from the round-trip. +### Pattern 6: Harness adapters (Claude Code, Codex, OpenCode, Grok Build) + +Dedicated harness adapters honor `chat({ outputSchema })` on the same turn. Native harness tools still run. Read the object from `await chat()` or from `useChat().final`. Do not parse assistant prose. + +A UI endpoint must pass `stream: true`. Without it, `chat()` returns a `Promise`, not SSE. + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { claudeCodeText } from '@tanstack/ai-claude-code' +import { withSandbox } from '@tanstack/ai-sandbox' +import { z } from 'zod' +import { sandbox } from './sandbox' + +const ReportSchema = z.object({ + name: z.string(), + oneLiner: z.string(), +}) + +export async function POST(request: Request) { + const body: unknown = await request.json() + const messages = + typeof body === 'object' && + body !== null && + 'messages' in body && + Array.isArray(body.messages) + ? body.messages + : [] + + const stream = chat({ + adapter: claudeCodeText('claude-opus-4-8'), + messages, + outputSchema: ReportSchema, + stream: true, + middleware: [withSandbox(sandbox)], + }) + return toServerSentEventsResponse(stream) +} +``` + +```tsx +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { z } from 'zod' + +const ReportSchema = z.object({ + name: z.string(), + oneLiner: z.string(), +}) + +const { final } = useChat({ + connection: fetchServerSentEvents('/api/repo-report'), + outputSchema: ReportSchema, +}) + +final?.name +``` + +- Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode and Grok Build: prompt-and-parse. +- `partial` stays empty until `structured-output.complete`. +- Client tools and `needsApproval` fail fast. The harness cannot pause for a browser round-trip. +- `acpCompatible` does not accept `outputSchema`. +- See [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md). + ## Common Mistakes ### HIGH: Filtering `TextPart`s out of `useChat` renderers when using `outputSchema` @@ -509,4 +577,5 @@ provider call, stripping system prompts), use the dedicated - See also: **ai-core/chat-experience/SKILL.md** — Base `useChat` surface; the structured-output additions documented here layer on top. - See also: **ai-core/adapter-configuration/SKILL.md** — Adapter handles structured-output strategy transparently. - See also: **ai-core/tool-calling/SKILL.md** — Combine `tools` with `outputSchema` for an agent loop that runs tools first and returns a typed object. Tool-approval and client-tool flows compose with structured runs without extra wiring; see [docs/structured-outputs/with-tools.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/with-tools.md). +- See also: [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md) — dedicated harness adapters and `useChat().final`. - See also: **ai-core/middleware/SKILL.md** — `onStructuredOutputConfig` hook and the `structuredOutput` phase for observing/transforming the final structured-output call. diff --git a/packages/ai/src/activities/chat/adapter.ts b/packages/ai/src/activities/chat/adapter.ts index cb3bcae6df..d461afb669 100644 --- a/packages/ai/src/activities/chat/adapter.ts +++ b/packages/ai/src/activities/chat/adapter.ts @@ -159,6 +159,20 @@ export interface TextAdapter< supportsCombinedToolsAndSchema?: ( modelOptions?: TProviderOptions | undefined, ) => boolean + + /** + * Where native-combined structured output is taken from. + * + * - `'text'` (default when omitted): the agent loop's accumulated + * assistant text is schema JSON. The engine parses it after the loop. + * HTTP adapters use this. + * - `'event'`: the adapter emits `structured-output.complete` during + * `chatStream`. The engine must not parse accumulated prose. Harness + * adapters use this. + */ + combinedStructuredOutputSource?: ( + modelOptions?: TProviderOptions | undefined, + ) => 'text' | 'event' } /** diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 15fb215ba0..1a25d55675 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -655,11 +655,12 @@ interface TextEngineConfig< * - nativeCombined: when true, the adapter declared * `supportsCombinedToolsAndSchema()` and the engine wires `jsonSchema` * into the regular `chatStream` call instead of running a separate - * finalization round-trip. The agent loop's final-turn text is the - * schema-constrained JSON; the engine parses it from accumulated - * content. The `'structuredOutput'` middleware phase does NOT fire on - * this path — middleware sees the run through `beforeModel` / - * `modelStream` as usual. + * finalization round-trip. The `'structuredOutput'` middleware phase + * does NOT fire on this path — middleware sees the run through + * `beforeModel` / `modelStream` as usual. + * - source: how to take the combined object. `'text'` (default) parses + * accumulated assistant text. `'event'` reads an adapter-emitted + * `structured-output.complete` and does not parse prose. */ finalStructuredOutput?: { jsonSchema: JSONSchema @@ -667,6 +668,7 @@ interface TextEngineConfig< normalize?: (data: unknown) => unknown validate?: (data: unknown) => unknown nativeCombined?: boolean + source?: 'text' | 'event' } } @@ -801,12 +803,14 @@ class TextEngine< code?: string cause?: unknown } | null = null + private combinedCompleteEmitted = false private readonly finalStructuredOutput?: { jsonSchema: JSONSchema yieldChunks: boolean normalize?: (data: unknown) => unknown validate?: (data: unknown) => unknown nativeCombined?: boolean + source?: 'text' | 'event' } constructor( @@ -1110,7 +1114,8 @@ class TextEngine< this.finalStructuredOutput && this.toolPhase !== 'wait' && !this.isCancelled() && - !this.finalizationError + !this.finalizationError && + !this.earlyTermination ) { if (this.finalStructuredOutput.nativeCombined === true) { yield* this.harvestCombinedStructuredOutput() @@ -1374,9 +1379,46 @@ class TextEngine< // text starting — intermediate tool-call iterations don't need it, // and emitting at run-start would wrap tool-call commentary into a // structured-output part too. + if ( + chunk.type === EventType.CUSTOM && + chunk.name === 'structured-output.start' + ) { + this.combinedStartEmitted = true + const startValue = chunk.value + if ( + startValue && + typeof startValue === 'object' && + 'messageId' in startValue && + typeof startValue.messageId === 'string' + ) { + this.combinedStructuredMessageId = startValue.messageId + } + } + + let outboundChunk: StreamChunk = chunk + if ( + this.finalStructuredOutput?.source === 'event' && + chunk.type === EventType.CUSTOM && + chunk.name === 'structured-output.complete' + ) { + const parsed = readStructuredOutputCompleteValue(chunk.value) + if (parsed) { + const object = this.finalStructuredOutput.normalize + ? this.finalStructuredOutput.normalize(parsed.object) + : parsed.object + this.structuredOutputResult = { data: object, rawText: parsed.raw } + this.combinedCompleteEmitted = true + const value = chunk.value + if (object !== parsed.object && value && typeof value === 'object') { + outboundChunk = { ...chunk, value: { ...value, object } } + } + } + } + if ( this.finalStructuredOutput?.nativeCombined === true && this.finalStructuredOutput.yieldChunks && + this.finalStructuredOutput.source !== 'event' && !this.combinedStartEmitted && chunk.type === EventType.TEXT_MESSAGE_START ) { @@ -1408,7 +1450,7 @@ class TextEngine< // Pipe chunk through middleware (devtools middleware observes; strip-to-spec cleans) const outputChunks = await this.middlewareRunner.runOnChunk( this.middlewareCtx, - chunk, + outboundChunk, ) // When a streaming structured-output finalization step will run after // the agent loop, suppress the agent-loop's RUN_STARTED/RUN_FINISHED @@ -3139,35 +3181,46 @@ class TextEngine< } const yieldChunks = this.finalStructuredOutput.yieldChunks - const rawText = this.accumulatedContent + const source = this.finalStructuredOutput.source ?? 'text' - // Empty final-turn text means the agent loop terminated without the - // model emitting any assistant content (e.g. early termination after - // tool calls). Mirror the fallback path's "missing structured result" - // error rather than silently returning undefined. - if (rawText.length === 0) { - this.finalizationError = { - message: 'missing structured result', - code: 'structured-output-missing-result', + if (source === 'event') { + if (!this.structuredOutputResult) { + this.finalizationError = { + message: 'missing structured result', + code: 'structured-output-missing-result', + } } } else { - try { - const parsed: unknown = JSON.parse(rawText) - // Normalize (un-widen) before storing so the synthesized - // structured-output.complete chunk and the Promise result both - // carry the cleaned payload. JSON.parse preserves provider nulls, so - // this is where native-combined output gets its widening undone. - const data = this.finalStructuredOutput.normalize - ? this.finalStructuredOutput.normalize(parsed) - : parsed - this.structuredOutputResult = { data, rawText } - } catch (err: unknown) { - const detail = - rawText.slice(0, 200) + (rawText.length > 200 ? '...' : '') + const rawText = this.accumulatedContent + + // Empty final-turn text means the agent loop terminated without the + // model emitting any assistant content (e.g. early termination after + // tool calls). Mirror the fallback path's "missing structured result" + // error rather than silently returning undefined. + if (rawText.length === 0) { this.finalizationError = { - message: `Failed to parse structured output as JSON. Content: ${detail}`, - code: 'structured-output-parse-failed', - cause: err, + message: 'missing structured result', + code: 'structured-output-missing-result', + } + } else { + try { + const parsed: unknown = JSON.parse(rawText) + // Normalize (un-widen) before storing so the synthesized + // structured-output.complete chunk and the Promise result both + // carry the cleaned payload. JSON.parse preserves provider nulls, so + // this is where native-combined output gets its widening undone. + const data = this.finalStructuredOutput.normalize + ? this.finalStructuredOutput.normalize(parsed) + : parsed + this.structuredOutputResult = { data, rawText } + } catch (err: unknown) { + const detail = + rawText.slice(0, 200) + (rawText.length > 200 ? '...' : '') + this.finalizationError = { + message: `Failed to parse structured output as JSON. Content: ${detail}`, + code: 'structured-output-parse-failed', + cause: err, + } } } } @@ -3237,7 +3290,11 @@ class TextEngine< // complete event yields AFTER the loop ends, by which point // `getActiveAssistantMessageId()` returns null and would otherwise drop // the event silently). - if (this.structuredOutputResult && !this.finalizationError) { + if ( + this.structuredOutputResult && + !this.finalizationError && + !this.combinedCompleteEmitted + ) { const completeChunk: StreamChunk = { type: EventType.CUSTOM, name: 'structured-output.complete', @@ -3890,6 +3947,8 @@ async function runAgenticStructuredOutput< // agent loop's accumulated final-turn text. const nativeCombined = adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true + const source = + adapter.combinedStructuredOutputSource?.(options.modelOptions) ?? 'text' const mcpManager = MCPManager.from(mcp) const mcpTools = await mcpManager.discover() @@ -3913,6 +3972,7 @@ async function runAgenticStructuredOutput< normalize, ...(validate ? { validate } : {}), ...(nativeCombined ? { nativeCombined: true } : {}), + source, }, }, logger, @@ -4205,6 +4265,8 @@ async function* runStreamingStructuredOutputImpl< // does not fire. const nativeCombined = adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true + const source = + adapter.combinedStructuredOutputSource?.(options.modelOptions) ?? 'text' const mcpManager = MCPManager.from(mcp) const mcpTools = await mcpManager.discover() @@ -4229,6 +4291,7 @@ async function* runStreamingStructuredOutputImpl< yieldChunks: true, normalize, ...(nativeCombined ? { nativeCombined: true } : {}), + source, }, }, logger, diff --git a/packages/ai/src/adapter-internals.ts b/packages/ai/src/adapter-internals.ts index b0c2612bb6..ddcb6724f2 100644 --- a/packages/ai/src/adapter-internals.ts +++ b/packages/ai/src/adapter-internals.ts @@ -25,3 +25,11 @@ export { PendingTurnCapability, providePendingTurn, } from './activities/chat/middleware/pending-turn' +export { + appendOutputSchemaInstruction, + parseJsonFromAssistantText, +} from './utilities/structured-output-text' +export { + structuredOutputCompleteChunk, + structuredOutputStartChunk, +} from './utilities/structured-output-events' diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e80d02175a..5ed75a547f 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -958,10 +958,13 @@ export interface TextOptions< * `supportsCombinedToolsAndSchema(modelOptions) === true`. The adapter * should then wire the schema into the upstream request (e.g. * `response_format: { type: 'json_schema', ... }`, `text.format`, - * `output_format`) alongside any `tools`. The model's natural final - * turn carries the schema-constrained JSON text and the engine - * harvests it from the agent loop without a separate finalization - * round-trip. + * `output_format`, `--json-schema`) alongside any `tools`. + * + * How the engine then takes the object depends on + * `combinedStructuredOutputSource()`: + * - `'text'` (default): the final-turn assistant text is the JSON. + * - `'event'`: the adapter emits `structured-output.complete` during + * `chatStream`. Accumulated prose is not parsed. * * Adapters that did NOT declare the capability never see this field * populated — the engine instead invokes `structuredOutput` / diff --git a/packages/ai/src/utilities/structured-output-events.ts b/packages/ai/src/utilities/structured-output-events.ts new file mode 100644 index 0000000000..6f7daa64ac --- /dev/null +++ b/packages/ai/src/utilities/structured-output-events.ts @@ -0,0 +1,44 @@ +import { EventType } from '../types' +import type { StreamChunk } from '../types' + +export function structuredOutputStartChunk(args: { + messageId: string + model: string + threadId: string + runId: string + timestamp?: number +}): StreamChunk { + return { + type: EventType.CUSTOM, + name: 'structured-output.start', + value: { messageId: args.messageId }, + model: args.model, + timestamp: args.timestamp ?? Date.now(), + threadId: args.threadId, + runId: args.runId, + } +} + +export function structuredOutputCompleteChunk(args: { + messageId: string + model: string + threadId: string + runId: string + object: unknown + raw: string + timestamp?: number +}): StreamChunk { + return { + type: EventType.CUSTOM, + name: 'structured-output.complete', + value: { + object: args.object, + raw: args.raw, + messageId: args.messageId, + }, + model: args.model, + timestamp: args.timestamp ?? Date.now(), + threadId: args.threadId, + runId: args.runId, + } +} diff --git a/packages/ai/src/utilities/structured-output-text.ts b/packages/ai/src/utilities/structured-output-text.ts new file mode 100644 index 0000000000..4d96e51363 --- /dev/null +++ b/packages/ai/src/utilities/structured-output-text.ts @@ -0,0 +1,21 @@ +/** + * Parse JSON from a model/harness assistant string. + * Strips a wrapping markdown fence when the whole payload is fenced. + */ +export function parseJsonFromAssistantText(raw: string): unknown { + const trimmed = raw.trim() + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/) + const payload = (fenced?.[1] ?? trimmed).trim() + return JSON.parse(payload) +} + +export function appendOutputSchemaInstruction( + prompt: string, + schema: unknown, +): string { + return `${prompt} + +Respond with a single JSON object that matches this JSON Schema. Do not wrap the object in markdown unless you must. + +${JSON.stringify(schema)}` +} diff --git a/packages/ai/tests/chat-combined-event-structured-output.test.ts b/packages/ai/tests/chat-combined-event-structured-output.test.ts new file mode 100644 index 0000000000..f98e06c862 --- /dev/null +++ b/packages/ai/tests/chat-combined-event-structured-output.test.ts @@ -0,0 +1,252 @@ +/** + * Native combined mode with `combinedStructuredOutputSource() === 'event'`. + * + * Harness adapters emit `structured-output.complete` during chatStream. + * The engine must harvest that event and must not JSON.parse assistant prose. + */ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { chat } from '../src/activities/chat/index' +import { EventType } from '../src/types' +import { collectChunks, createMockAdapter } from './test-utils' +import type { StreamChunk } from '../src/types' + +const PersonSchema = z.object({ + name: z.string(), + age: z.number(), +}) +type Person = z.infer + +const validPerson: Person = { name: 'Jane Roe', age: 31 } + +function isNamedCustom(chunk: StreamChunk, name: string): boolean { + return chunk.type === EventType.CUSTOM && chunk.name === name +} + +function eventSourcedTurn(args: { + prose: string + complete?: Person + runError?: string +}): Array { + const ts = 1 + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: ts, + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'msg-1', + role: 'assistant', + timestamp: ts, + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-1', + delta: args.prose, + timestamp: ts, + }, + { + type: EventType.TEXT_MESSAGE_END, + messageId: 'msg-1', + timestamp: ts, + }, + ] + if (args.complete) { + const raw = JSON.stringify(args.complete) + chunks.push( + { + type: EventType.CUSTOM, + name: 'structured-output.start', + value: { messageId: 'msg-so' }, + timestamp: ts, + }, + { + type: EventType.CUSTOM, + name: 'structured-output.complete', + value: { object: args.complete, raw, messageId: 'msg-so' }, + timestamp: ts, + }, + ) + } + if (args.runError) { + chunks.push({ + type: EventType.RUN_ERROR, + runId: 'run-1', + threadId: 'thread-1', + timestamp: ts, + message: args.runError, + error: { message: args.runError }, + }) + } else { + chunks.push({ + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop', + timestamp: ts, + }) + } + return chunks +} + +describe('chat({ outputSchema }) — combined event source', () => { + it('forwards outputSchema to chatStream and skips the finalization adapter call', async () => { + let structuredCalled = false + let structuredStreamCalled = false + + const { adapter, calls } = createMockAdapter({ + iterations: [ + eventSourcedTurn({ prose: 'looking around', complete: validPerson }), + ], + structuredOutput: async () => { + structuredCalled = true + return { data: {}, rawText: '{}' } + }, + structuredOutputStream: () => { + structuredStreamCalled = true + return (async function* () {})() + }, + supportsCombinedToolsAndSchema: true, + combinedStructuredOutputSource: 'event', + }) + + await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }), + ) + + expect(calls.length).toBe(1) + expect(calls[0]?.outputSchema).toBeDefined() + expect(structuredCalled).toBe(false) + expect(structuredStreamCalled).toBe(false) + }) + + it('returns the adapter complete object on the Promise path', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + eventSourcedTurn({ + prose: 'I will look around.', + complete: validPerson, + }), + ], + supportsCombinedToolsAndSchema: true, + combinedStructuredOutputSource: 'event', + }) + + const person = await chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + }) + + expect(person).toEqual(validPerson) + }) + + it('does not synthesize start before prose text on the stream path', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + eventSourcedTurn({ + prose: 'I will look around.', + complete: validPerson, + }), + ], + supportsCombinedToolsAndSchema: true, + combinedStructuredOutputSource: 'event', + }) + + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }), + ) + + const startIdx = chunks.findIndex((c) => + isNamedCustom(c, 'structured-output.start'), + ) + const textStartIdx = chunks.findIndex( + (c) => c.type === EventType.TEXT_MESSAGE_START, + ) + const completeIdx = chunks.findIndex((c) => + isNamedCustom(c, 'structured-output.complete'), + ) + + expect(textStartIdx).toBeGreaterThanOrEqual(0) + expect(startIdx).toBeGreaterThan(textStartIdx) + expect(completeIdx).toBeGreaterThan(startIdx) + + const completes = chunks.filter((c) => + isNamedCustom(c, 'structured-output.complete'), + ) + expect(completes).toHaveLength(1) + const complete = completes[0] + expect(complete?.type).toBe(EventType.CUSTOM) + if (complete?.type === EventType.CUSTOM) { + expect(complete.value).toEqual( + expect.objectContaining({ object: validPerson }), + ) + } + }) + + it('errors with missing-result when no complete event arrives', async () => { + const { adapter } = createMockAdapter({ + iterations: [eventSourcedTurn({ prose: 'I will look around.' })], + supportsCombinedToolsAndSchema: true, + combinedStructuredOutputSource: 'event', + }) + + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }), + ) + + const runError = chunks.find((c) => c.type === EventType.RUN_ERROR) + expect(runError?.type).toBe(EventType.RUN_ERROR) + if (runError?.type === EventType.RUN_ERROR) { + expect(runError.code).toBe('structured-output-missing-result') + } + }) + + it('does not harvest a second error after an adapter RUN_ERROR', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + eventSourcedTurn({ + prose: 'I will look around.', + runError: 'harness failed', + }), + ], + supportsCombinedToolsAndSchema: true, + combinedStructuredOutputSource: 'event', + }) + + const chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'extract' }], + outputSchema: PersonSchema, + stream: true, + }), + ) + + const errors = chunks.filter((c) => c.type === EventType.RUN_ERROR) + expect(errors).toHaveLength(1) + if (errors[0]?.type === EventType.RUN_ERROR) { + expect(errors[0].message).toBe('harness failed') + expect(errors[0].code).not.toBe('structured-output-missing-result') + expect(errors[0].code).not.toBe('structured-output-parse-failed') + } + }) +}) diff --git a/packages/ai/tests/structured-output-text.test.ts b/packages/ai/tests/structured-output-text.test.ts new file mode 100644 index 0000000000..c028489f8c --- /dev/null +++ b/packages/ai/tests/structured-output-text.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + appendOutputSchemaInstruction, + parseJsonFromAssistantText, +} from '../src/utilities/structured-output-text' + +describe('parseJsonFromAssistantText', () => { + it('parses a bare object', () => { + expect(parseJsonFromAssistantText('{"a":1}')).toEqual({ a: 1 }) + }) + + it('strips a json fence', () => { + expect(parseJsonFromAssistantText('```json\n{"a":1}\n```')).toEqual({ + a: 1, + }) + }) + + it('strips a bare fence', () => { + expect(parseJsonFromAssistantText('```\n{"a":1}\n```')).toEqual({ a: 1 }) + }) + + it('throws on prose', () => { + expect(() => parseJsonFromAssistantText('not json')).toThrow() + }) + + it('appends the schema instruction', () => { + const next = appendOutputSchemaInstruction('Look around.', { + type: 'object', + }) + expect(next).toContain('Look around.') + expect(next).toContain('"type":"object"') + }) +}) diff --git a/packages/ai/tests/test-utils.ts b/packages/ai/tests/test-utils.ts index 2c6b713037..ffd0c28036 100644 --- a/packages/ai/tests/test-utils.ts +++ b/packages/ai/tests/test-utils.ts @@ -162,6 +162,9 @@ export function createMockAdapter(options: { * The engine then forwards `outputSchema` into `chatStream` and skips * the separate finalization round-trip. */ supportsCombinedToolsAndSchema?: boolean + /** When `'event'`, the engine harvests `structured-output.complete` + * from `chatStream` instead of parsing accumulated assistant text. */ + combinedStructuredOutputSource?: 'text' | 'event' }) { const calls: Array> = [] let callIndex = 0 @@ -213,6 +216,10 @@ export function createMockAdapter(options: { adapter.supportsCombinedToolsAndSchema = () => true } + if (options.combinedStructuredOutputSource === 'event') { + adapter.combinedStructuredOutputSource = () => 'event' + } + return { adapter, calls } } From 9b7898048cefdee2d5faab194cea477c22cf1798 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 14 Aug 2026 19:18:03 +0200 Subject: [PATCH 2/5] fix(docs): give adapter outputSchema snippets a real sandbox Kiira type-checks doc fences. defineSandbox requires id and provider. --- docs/adapters/claude-code.md | 8 +++++++- docs/adapters/codex.md | 8 +++++++- docs/adapters/grok-build.md | 8 +++++++- docs/adapters/opencode.md | 8 +++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/adapters/claude-code.md b/docs/adapters/claude-code.md index a486ee9a6f..c6bd77fd51 100644 --- a/docs/adapters/claude-code.md +++ b/docs/adapters/claude-code.md @@ -176,6 +176,7 @@ Pass `outputSchema` on `chat()`. Claude Code runs one harness turn, uses its nat import { chat } from "@tanstack/ai" import { claudeCodeText } from "@tanstack/ai-claude-code" import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { dockerSandbox } from "@tanstack/ai-sandbox-docker" import { z } from "zod" const Report = z.object({ @@ -183,11 +184,16 @@ const Report = z.object({ filesChanged: z.array(z.string()), }) +const sandbox = defineSandbox({ + id: "repo-report", + provider: dockerSandbox({ image: "node:22" }), +}) + const report = await chat({ adapter: claudeCodeText("claude-opus-4-8"), messages: [{ role: "user", content: "Review this repo." }], outputSchema: Report, - middleware: [withSandbox(defineSandbox({ /* provider */ }))], + middleware: [withSandbox(sandbox)], }) report.summary diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 64607d9488..da15db97eb 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -176,6 +176,7 @@ Pass `outputSchema` on `chat()`. Codex runs one harness turn and constrains the import { chat } from "@tanstack/ai" import { codexText } from "@tanstack/ai-codex" import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { dockerSandbox } from "@tanstack/ai-sandbox-docker" import { z } from "zod" const Report = z.object({ @@ -183,11 +184,16 @@ const Report = z.object({ filesChanged: z.array(z.string()), }) +const sandbox = defineSandbox({ + id: "repo-report", + provider: dockerSandbox({ image: "node:22" }), +}) + const report = await chat({ adapter: codexText("gpt-5.3-codex"), messages: [{ role: "user", content: "Review this repo." }], outputSchema: Report, - middleware: [withSandbox(defineSandbox({ /* provider */ }))], + middleware: [withSandbox(sandbox)], }) report.summary diff --git a/docs/adapters/grok-build.md b/docs/adapters/grok-build.md index 2fa25cd1df..fbbae01fff 100644 --- a/docs/adapters/grok-build.md +++ b/docs/adapters/grok-build.md @@ -193,6 +193,7 @@ Pass `outputSchema` on `chat()`. Grok Build has no native schema flag. The adapt import { chat } from "@tanstack/ai" import { grokBuildText } from "@tanstack/ai-grok-build" import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { dockerSandbox } from "@tanstack/ai-sandbox-docker" import { z } from "zod" const Report = z.object({ @@ -200,11 +201,16 @@ const Report = z.object({ filesChanged: z.array(z.string()), }) +const sandbox = defineSandbox({ + id: "repo-report", + provider: dockerSandbox({ image: "node:22" }), +}) + const report = await chat({ adapter: grokBuildText("grok-build"), messages: [{ role: "user", content: "Review this repo." }], outputSchema: Report, - middleware: [withSandbox(defineSandbox({ /* provider */ }))], + middleware: [withSandbox(sandbox)], }) report.summary diff --git a/docs/adapters/opencode.md b/docs/adapters/opencode.md index 48790e2a0a..f4744d7a60 100644 --- a/docs/adapters/opencode.md +++ b/docs/adapters/opencode.md @@ -181,6 +181,7 @@ Pass `outputSchema` on `chat()`. OpenCode has no native schema flag. The adapter import { chat } from "@tanstack/ai" import { opencodeText } from "@tanstack/ai-opencode" import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" +import { dockerSandbox } from "@tanstack/ai-sandbox-docker" import { z } from "zod" const Report = z.object({ @@ -188,11 +189,16 @@ const Report = z.object({ filesChanged: z.array(z.string()), }) +const sandbox = defineSandbox({ + id: "repo-report", + provider: dockerSandbox({ image: "node:22" }), +}) + const report = await chat({ adapter: opencodeText("anthropic/claude-opus-4-5"), messages: [{ role: "user", content: "Review this repo." }], outputSchema: Report, - middleware: [withSandbox(defineSandbox({ /* provider */ }))], + middleware: [withSandbox(sandbox)], }) report.summary From 1a909c16840482921da6c6965c3e05f1281ca686 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 17 Aug 2026 14:55:54 +0200 Subject: [PATCH 3/5] fix(example): render repo report on its own route /sandboxes/repo-report was a child of the triage page. The parent has no Outlet, so the URL showed issue triage. Move the page to /repo-report. --- docs/sandbox/overview.md | 2 +- docs/structured-outputs/harnesses.md | 2 +- .../ts-react-chat/src/components/Header.tsx | 2 +- .../ts-react-chat/src/repo-report-options.ts | 2 +- examples/ts-react-chat/src/routeTree.gen.ts | 62 ++++++++----------- examples/ts-react-chat/src/routes/index.tsx | 2 +- ...dboxes.repo-report.tsx => repo-report.tsx} | 2 +- 7 files changed, 32 insertions(+), 42 deletions(-) rename examples/ts-react-chat/src/routes/{sandboxes.repo-report.tsx => repo-report.tsx} (99%) diff --git a/docs/sandbox/overview.md b/docs/sandbox/overview.md index 76bdcdcb6e..1c52969520 100644 --- a/docs/sandbox/overview.md +++ b/docs/sandbox/overview.md @@ -136,6 +136,6 @@ Three runnable demos: - [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): the same idea at the edge, with the harness picked per run from the UI. - [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat) - at `/sandboxes/repo-report`: clone `TanStack/ai`, pick Claude Code, Grok Build, + at `/repo-report`: clone `TanStack/ai`, pick Claude Code, Grok Build, or Codex, and read a typed report from `useChat().final`. See [Harness Agents](../structured-outputs/harnesses). diff --git a/docs/structured-outputs/harnesses.md b/docs/structured-outputs/harnesses.md index a675addf03..b57d241637 100644 --- a/docs/structured-outputs/harnesses.md +++ b/docs/structured-outputs/harnesses.md @@ -192,7 +192,7 @@ The React chat example includes a repo-report page. 1. Open [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat). 2. Set the harness API key in `.env`. -3. Open `/sandboxes/repo-report`. +3. Open `/repo-report`. 4. Pick Claude Code, Grok Build, or Codex. 5. Run the report. The page reads the typed object from `useChat().final`. diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 159f8593ce..8660905a3c 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -205,7 +205,7 @@ export default function Header() { setIsOpen(false)} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" activeProps={{ diff --git a/examples/ts-react-chat/src/repo-report-options.ts b/examples/ts-react-chat/src/repo-report-options.ts index 9694ceadab..aba8f93da7 100644 --- a/examples/ts-react-chat/src/repo-report-options.ts +++ b/examples/ts-react-chat/src/repo-report-options.ts @@ -1,5 +1,5 @@ /** - * Client-safe picker types for /sandboxes/repo-report. + * Client-safe picker types for /repo-report. * Do not import harness packages here. */ diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index d752600492..e42124b47d 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as ServerFnChatRouteImport } from './routes/server-fn-chat' import { Route as SandboxesDurableRouteImport } from './routes/sandboxes-durable' import { Route as SandboxesRouteImport } from './routes/sandboxes' import { Route as ResumableRouteImport } from './routes/resumable' +import { Route as RepoReportRouteImport } from './routes/repo-report' import { Route as RealtimeRouteImport } from './routes/realtime' import { Route as QueueingRouteImport } from './routes/queueing' import { Route as PersistentChatRouteImport } from './routes/persistent-chat' @@ -27,7 +28,6 @@ import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' import { Route as IndexRouteImport } from './routes/index' -import { Route as SandboxesRepoReportRouteImport } from './routes/sandboxes.repo-report' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription' import { Route as GenerationsSummarizeRouteImport } from './routes/generations.summarize' @@ -99,6 +99,11 @@ const ResumableRoute = ResumableRouteImport.update({ path: '/resumable', getParentRoute: () => rootRouteImport, } as any) +const RepoReportRoute = RepoReportRouteImport.update({ + id: '/repo-report', + path: '/repo-report', + getParentRoute: () => rootRouteImport, +} as any) const RealtimeRoute = RealtimeRouteImport.update({ id: '/realtime', path: '/realtime', @@ -159,11 +164,6 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) -const SandboxesRepoReportRoute = SandboxesRepoReportRouteImport.update({ - id: '/repo-report', - path: '/repo-report', - getParentRoute: () => SandboxesRoute, -} as any) const GenerationsVideoRoute = GenerationsVideoRouteImport.update({ id: '/generations/video', path: '/generations/video', @@ -383,8 +383,9 @@ export interface FileRoutesByFullPath { '/persistent-chat': typeof PersistentChatRoute '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute + '/repo-report': typeof RepoReportRoute '/resumable': typeof ResumableRoute - '/sandboxes': typeof SandboxesRouteWithChildren + '/sandboxes': typeof SandboxesRoute '/sandboxes-durable': typeof SandboxesDurableRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute @@ -422,7 +423,6 @@ export interface FileRoutesByFullPath { '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute - '/sandboxes/repo-report': typeof SandboxesRepoReportRoute '/api/generate/audio': typeof ApiGenerateAudioRoute '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute @@ -444,8 +444,9 @@ export interface FileRoutesByTo { '/persistent-chat': typeof PersistentChatRoute '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute + '/repo-report': typeof RepoReportRoute '/resumable': typeof ResumableRoute - '/sandboxes': typeof SandboxesRouteWithChildren + '/sandboxes': typeof SandboxesRoute '/sandboxes-durable': typeof SandboxesDurableRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute @@ -483,7 +484,6 @@ export interface FileRoutesByTo { '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute - '/sandboxes/repo-report': typeof SandboxesRepoReportRoute '/api/generate/audio': typeof ApiGenerateAudioRoute '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute @@ -506,8 +506,9 @@ export interface FileRoutesById { '/persistent-chat': typeof PersistentChatRoute '/queueing': typeof QueueingRoute '/realtime': typeof RealtimeRoute + '/repo-report': typeof RepoReportRoute '/resumable': typeof ResumableRoute - '/sandboxes': typeof SandboxesRouteWithChildren + '/sandboxes': typeof SandboxesRoute '/sandboxes-durable': typeof SandboxesDurableRoute '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute @@ -545,7 +546,6 @@ export interface FileRoutesById { '/generations/summarize': typeof GenerationsSummarizeRoute '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute - '/sandboxes/repo-report': typeof SandboxesRepoReportRoute '/api/generate/audio': typeof ApiGenerateAudioRoute '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute @@ -569,6 +569,7 @@ export interface FileRouteTypes { | '/persistent-chat' | '/queueing' | '/realtime' + | '/repo-report' | '/resumable' | '/sandboxes' | '/sandboxes-durable' @@ -608,7 +609,6 @@ export interface FileRouteTypes { | '/generations/summarize' | '/generations/transcription' | '/generations/video' - | '/sandboxes/repo-report' | '/api/generate/audio' | '/api/generate/image' | '/api/generate/speech' @@ -630,6 +630,7 @@ export interface FileRouteTypes { | '/persistent-chat' | '/queueing' | '/realtime' + | '/repo-report' | '/resumable' | '/sandboxes' | '/sandboxes-durable' @@ -669,7 +670,6 @@ export interface FileRouteTypes { | '/generations/summarize' | '/generations/transcription' | '/generations/video' - | '/sandboxes/repo-report' | '/api/generate/audio' | '/api/generate/image' | '/api/generate/speech' @@ -691,6 +691,7 @@ export interface FileRouteTypes { | '/persistent-chat' | '/queueing' | '/realtime' + | '/repo-report' | '/resumable' | '/sandboxes' | '/sandboxes-durable' @@ -730,7 +731,6 @@ export interface FileRouteTypes { | '/generations/summarize' | '/generations/transcription' | '/generations/video' - | '/sandboxes/repo-report' | '/api/generate/audio' | '/api/generate/image' | '/api/generate/speech' @@ -753,8 +753,9 @@ export interface RootRouteChildren { PersistentChatRoute: typeof PersistentChatRoute QueueingRoute: typeof QueueingRoute RealtimeRoute: typeof RealtimeRoute + RepoReportRoute: typeof RepoReportRoute ResumableRoute: typeof ResumableRoute - SandboxesRoute: typeof SandboxesRouteWithChildren + SandboxesRoute: typeof SandboxesRoute SandboxesDurableRoute: typeof SandboxesDurableRoute ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute @@ -844,6 +845,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResumableRouteImport parentRoute: typeof rootRouteImport } + '/repo-report': { + id: '/repo-report' + path: '/repo-report' + fullPath: '/repo-report' + preLoaderRoute: typeof RepoReportRouteImport + parentRoute: typeof rootRouteImport + } '/realtime': { id: '/realtime' path: '/realtime' @@ -928,13 +936,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/sandboxes/repo-report': { - id: '/sandboxes/repo-report' - path: '/repo-report' - fullPath: '/sandboxes/repo-report' - preLoaderRoute: typeof SandboxesRepoReportRouteImport - parentRoute: typeof SandboxesRoute - } '/generations/video': { id: '/generations/video' path: '/generations/video' @@ -1218,18 +1219,6 @@ declare module '@tanstack/react-router' { } } -interface SandboxesRouteChildren { - SandboxesRepoReportRoute: typeof SandboxesRepoReportRoute -} - -const SandboxesRouteChildren: SandboxesRouteChildren = { - SandboxesRepoReportRoute: SandboxesRepoReportRoute, -} - -const SandboxesRouteWithChildren = SandboxesRoute._addFileChildren( - SandboxesRouteChildren, -) - interface ApiGenerateImageRouteChildren { ApiGenerateImageArtifactRoute: typeof ApiGenerateImageArtifactRoute } @@ -1254,8 +1243,9 @@ const rootRouteChildren: RootRouteChildren = { PersistentChatRoute: PersistentChatRoute, QueueingRoute: QueueingRoute, RealtimeRoute: RealtimeRoute, + RepoReportRoute: RepoReportRoute, ResumableRoute: ResumableRoute, - SandboxesRoute: SandboxesRouteWithChildren, + SandboxesRoute: SandboxesRoute, SandboxesDurableRoute: SandboxesDurableRoute, ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index c7accdc509..fac7e8aa3d 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -224,7 +224,7 @@ function Messages({ Sandboxes diff --git a/examples/ts-react-chat/src/routes/sandboxes.repo-report.tsx b/examples/ts-react-chat/src/routes/repo-report.tsx similarity index 99% rename from examples/ts-react-chat/src/routes/sandboxes.repo-report.tsx rename to examples/ts-react-chat/src/routes/repo-report.tsx index 3884522173..54b2b1c385 100644 --- a/examples/ts-react-chat/src/routes/sandboxes.repo-report.tsx +++ b/examples/ts-react-chat/src/routes/repo-report.tsx @@ -18,7 +18,7 @@ import type { ReportProvider, } from '../repo-report-options' -export const Route = createFileRoute('/sandboxes/repo-report')({ +export const Route = createFileRoute('/repo-report')({ component: RepoReportPage, }) From 17bd21ef17515c3e7d475ac93adf94a75219fdbe Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 17 Aug 2026 15:02:04 +0200 Subject: [PATCH 4/5] fix: parse harness JSON after prose and render the report card Grok writes tool notes then the object. Parsing the whole assistant text failed on I'll. Take the last JSON object. The repo-report page hides the raw JSON and fills the card as the object streams. Claude Code sets CLAUDE_CODE_SANDBOXED so a cloned repo is not blocked as untrusted. --- .../ts-react-chat/src/routes/repo-report.tsx | 95 ++++++++++++++----- examples/ts-react-chat/src/sandbox-triage.ts | 6 +- .../src/utilities/structured-output-text.ts | 53 ++++++++++- .../ai/tests/structured-output-text.test.ts | 12 +++ 4 files changed, 137 insertions(+), 29 deletions(-) diff --git a/examples/ts-react-chat/src/routes/repo-report.tsx b/examples/ts-react-chat/src/routes/repo-report.tsx index 54b2b1c385..3d4714ea7b 100644 --- a/examples/ts-react-chat/src/routes/repo-report.tsx +++ b/examples/ts-react-chat/src/routes/repo-report.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { BookOpen, Play, Square } from 'lucide-react' +import { parsePartialJSON } from '@tanstack/ai' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' import { isReportAgent, @@ -12,16 +13,79 @@ import { REPORT_REPO, } from '../repo-report-options' import { RepoReportSchema } from '../repo-report-schema' +import type { RepoReport } from '../repo-report-schema' import type { ReportAgent, ReportHarness, ReportProvider, } from '../repo-report-options' +import type { UIMessage } from '@tanstack/ai-react' export const Route = createFileRoute('/repo-report')({ component: RepoReportPage, }) +function assistantText(messages: Array): string { + return messages + .filter((message) => message.role === 'assistant') + .flatMap((message) => message.parts) + .flatMap((part) => + part.type === 'text' && typeof part.content === 'string' + ? [part.content] + : [], + ) + .join('\n') +} + +function liveReportFromMessages( + messages: Array, +): Partial | undefined { + const text = assistantText(messages) + const start = text.lastIndexOf('{') + if (start < 0) return undefined + const parsed: unknown = parsePartialJSON(text.slice(start)) + if (parsed === null || parsed === undefined || typeof parsed !== 'object') { + return undefined + } + return parsed as Partial +} + +function proseWithoutJson(content: string): string | null { + const start = content.indexOf('{') + if (start < 0) return content + const before = content.slice(0, start).trim() + return before === '' ? null : before +} + +function ReportCard({ + report, +}: { + report: Partial +}) { + return ( +
+

{report.name ?? '…'}

+

{report.oneLiner ?? '…'}

+

+ Audience: + {report.audience ?? '…'} +

+
    + {(report.mainPackages ?? []).map((pkg, index) => ( +
  • + {pkg.name ?? '…'} + {': '} + {pkg.role ?? '…'} +
  • + ))} +
+ {report.howToRun ? ( +

{report.howToRun}

+ ) : null} +
+ ) +} + function RepoReportPage() { const [threadId] = useState(() => crypto.randomUUID()) const [harness, setHarness] = useState('claude-code') @@ -43,7 +107,7 @@ function RepoReportPage() { void chat.sendMessage(`Report on ${REPORT_REPO}`) } - const report = chat.final + const report = chat.final ?? liveReportFromMessages(chat.messages) return (
@@ -134,7 +198,7 @@ function RepoReportPage() {
- {chat.error ? ( + {chat.error && !report ? (

{chat.error.message}

) : null} @@ -149,9 +213,11 @@ function RepoReportPage() { > {message.parts.map((part, index) => { if (part.type === 'text' && part.content) { + const prose = proseWithoutJson(part.content) + if (prose === null) return null return (

- {part.content} + {prose}

) } @@ -170,28 +236,7 @@ function RepoReportPage() {
))} - {report ? ( -
-

{report.name}

-

{report.oneLiner}

-

- Audience: - {report.audience} -

-
    - {report.mainPackages.map((pkg) => ( -
  • - - {pkg.name} - - {': '} - {pkg.role} -
  • - ))} -
-

{report.howToRun}

-
- ) : null} + {report ? : null} {!chat.isLoading && chat.messages.length === 0 ? (

diff --git a/examples/ts-react-chat/src/sandbox-triage.ts b/examples/ts-react-chat/src/sandbox-triage.ts index 5bf1259ab1..7bea441901 100644 --- a/examples/ts-react-chat/src/sandbox-triage.ts +++ b/examples/ts-react-chat/src/sandbox-triage.ts @@ -111,7 +111,11 @@ function npmGlobalCli(spec: string, verify: string): string { export const HARNESSES: Record = { 'claude-code': { label: 'Claude Code', - makeAdapter: () => claudeCodeText('sonnet'), + makeAdapter: () => + claudeCodeText('sonnet', { + permissionMode: 'bypassPermissions', + env: { CLAUDE_CODE_SANDBOXED: '1' }, + }), installCommand: npmGlobalCli( '@anthropic-ai/claude-code', 'claude --version', diff --git a/packages/ai/src/utilities/structured-output-text.ts b/packages/ai/src/utilities/structured-output-text.ts index 4d96e51363..5019e018f7 100644 --- a/packages/ai/src/utilities/structured-output-text.ts +++ b/packages/ai/src/utilities/structured-output-text.ts @@ -1,12 +1,59 @@ /** * Parse JSON from a model/harness assistant string. * Strips a wrapping markdown fence when the whole payload is fenced. + * If the model wrote prose first, take the last JSON object or array. */ export function parseJsonFromAssistantText(raw: string): unknown { const trimmed = raw.trim() - const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/) - const payload = (fenced?.[1] ?? trimmed).trim() - return JSON.parse(payload) + if (trimmed === '') { + throw new SyntaxError('Assistant text is empty') + } + + const candidates: Array = [] + const wholeFence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/) + if (wholeFence?.[1]) candidates.push(wholeFence[1].trim()) + candidates.push(trimmed) + const lastFence = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/g)].at( + -1, + ) + if (lastFence?.[1]) candidates.push(lastFence[1].trim()) + const extracted = extractLastJsonSlice(trimmed) + if (extracted !== undefined) candidates.push(extracted) + + let lastError: unknown + for (const candidate of candidates) { + try { + return JSON.parse(candidate) + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error + ? lastError + : new SyntaxError('No JSON object found in assistant text') +} + +function extractLastJsonSlice(text: string): string | undefined { + let end = -1 + for (let i = text.length - 1; i >= 0; i--) { + if (text[i] === '}' || text[i] === ']') { + end = i + break + } + } + if (end < 0) return undefined + for (let start = end; start >= 0; start--) { + const opener = text[start] + if (opener !== '{' && opener !== '[') continue + const slice = text.slice(start, end + 1) + try { + JSON.parse(slice) + return slice + } catch { + // Try an earlier opener. Nested braces often fail until the real start. + } + } + return undefined } export function appendOutputSchemaInstruction( diff --git a/packages/ai/tests/structured-output-text.test.ts b/packages/ai/tests/structured-output-text.test.ts index c028489f8c..46ec5df893 100644 --- a/packages/ai/tests/structured-output-text.test.ts +++ b/packages/ai/tests/structured-output-text.test.ts @@ -23,6 +23,18 @@ describe('parseJsonFromAssistantText', () => { expect(() => parseJsonFromAssistantText('not json')).toThrow() }) + it('takes the last object after assistant prose', () => { + expect( + parseJsonFromAssistantText('I\'ll read the README.\n{"a":1}'), + ).toEqual({ a: 1 }) + }) + + it('takes the last fenced object after prose', () => { + expect( + parseJsonFromAssistantText('Looking around.\n```json\n{"a":2}\n```'), + ).toEqual({ a: 2 }) + }) + it('appends the schema instruction', () => { const next = appendOutputSchemaInstruction('Look around.', { type: 'object', From 1103b2bb4b41671d5aa7ff257851415069d6ccb8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 17 Aug 2026 15:19:05 +0200 Subject: [PATCH 5/5] fix: pass Claude --json-schema inline and stop guessing the report card --json-schema expects JSON, not a .json filename. Mark the local sandbox cwd trusted so cloned .claude/settings.json is used. Render the card from structured-output parts or a text part that starts with the root object. Show thinking, tools, and text while the run is still going. --- docs/adapters/claude-code.md | 2 +- .../ts-react-chat/src/routes/repo-report.tsx | 172 ++++++++++++------ packages/ai-claude-code/src/adapters/text.ts | 23 ++- packages/ai-claude-code/src/adapters/trust.ts | 57 ++++++ .../ai-claude-code/tests/text-adapter.test.ts | 2 + packages/ai-claude-code/tests/trust.test.ts | 24 +++ 6 files changed, 216 insertions(+), 64 deletions(-) create mode 100644 packages/ai-claude-code/src/adapters/trust.ts create mode 100644 packages/ai-claude-code/tests/trust.test.ts diff --git a/docs/adapters/claude-code.md b/docs/adapters/claude-code.md index c6bd77fd51..eeea051e20 100644 --- a/docs/adapters/claude-code.md +++ b/docs/adapters/claude-code.md @@ -170,7 +170,7 @@ const stream = chat({ ## Structured Output -Pass `outputSchema` on `chat()`. Claude Code runs one harness turn, uses its native tools, and returns a typed object. The schema is sent with `--json-schema`. Tool activity and prose stream as usual. The object arrives as `structured-output.complete`. +Pass `outputSchema` on `chat()`. Claude Code runs one harness turn, uses its native tools, and returns a typed object. The schema JSON is passed to `--json-schema` (inline, not a file path). Tool activity and prose stream as usual. The object arrives as `structured-output.complete`. ```ts import { chat } from "@tanstack/ai" diff --git a/examples/ts-react-chat/src/routes/repo-report.tsx b/examples/ts-react-chat/src/routes/repo-report.tsx index 3d4714ea7b..88c6efeca1 100644 --- a/examples/ts-react-chat/src/routes/repo-report.tsx +++ b/examples/ts-react-chat/src/routes/repo-report.tsx @@ -25,60 +25,83 @@ export const Route = createFileRoute('/repo-report')({ component: RepoReportPage, }) -function assistantText(messages: Array): string { - return messages - .filter((message) => message.role === 'assistant') - .flatMap((message) => message.parts) - .flatMap((part) => - part.type === 'text' && typeof part.content === 'string' - ? [part.content] - : [], - ) - .join('\n') +function looksLikeReport(value: unknown): value is Partial { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const record = value as Record + return ( + 'name' in record || + 'oneLiner' in record || + 'audience' in record || + 'mainPackages' in record || + 'howToRun' in record + ) } -function liveReportFromMessages( +function reportFromMessages( messages: Array, ): Partial | undefined { - const text = assistantText(messages) - const start = text.lastIndexOf('{') - if (start < 0) return undefined - const parsed: unknown = parsePartialJSON(text.slice(start)) - if (parsed === null || parsed === undefined || typeof parsed !== 'object') { - return undefined + for (const message of [...messages].reverse()) { + for (const part of [...message.parts].reverse()) { + if (part.type === 'structured-output') { + if (part.data !== undefined && looksLikeReport(part.data)) { + return part.data + } + if (part.partial !== undefined && looksLikeReport(part.partial)) { + return part.partial + } + } + } } - return parsed as Partial + for (const message of [...messages].reverse()) { + for (const part of [...message.parts].reverse()) { + if (part.type !== 'text' || typeof part.content !== 'string') continue + const trimmed = part.content.trim() + if (!trimmed.startsWith('{')) continue + const parsed: unknown = parsePartialJSON(trimmed) + if (looksLikeReport(parsed)) return parsed + } + } + return undefined } -function proseWithoutJson(content: string): string | null { - const start = content.indexOf('{') - if (start < 0) return content - const before = content.slice(0, start).trim() - return before === '' ? null : before +function isCompleteReportJson(content: string): boolean { + const trimmed = content.trim() + if (!trimmed.startsWith('{')) return false + try { + return looksLikeReport(JSON.parse(trimmed)) + } catch { + return false + } } -function ReportCard({ - report, -}: { - report: Partial -}) { +function ReportCard({ report }: { report: Partial }) { + const packages = report.mainPackages ?? [] return (

-

{report.name ?? '…'}

-

{report.oneLiner ?? '…'}

-

- Audience: - {report.audience ?? '…'} -

-
    - {(report.mainPackages ?? []).map((pkg, index) => ( -
  • - {pkg.name ?? '…'} - {': '} - {pkg.role ?? '…'} -
  • - ))} -
+ {report.name ? ( +

{report.name}

+ ) : null} + {report.oneLiner ?

{report.oneLiner}

: null} + {report.audience ? ( +

+ Audience: + {report.audience} +

+ ) : null} + {packages.length > 0 ? ( +
    + {packages.map((pkg, index) => ( +
  • + + {pkg.name ?? '…'} + + {pkg.role ? `: ${pkg.role}` : ''} +
  • + ))} +
+ ) : null} {report.howToRun ? (

{report.howToRun}

) : null} @@ -107,7 +130,19 @@ function RepoReportPage() { void chat.sendMessage(`Report on ${REPORT_REPO}`) } - const report = chat.final ?? liveReportFromMessages(chat.messages) + const report = chat.final ?? reportFromMessages(chat.messages) + const last = chat.messages.at(-1) + const waiting = + chat.isLoading && + (last === undefined || + last.role === 'user' || + last.parts.every( + (part) => + part.type !== 'text' && + part.type !== 'tool-call' && + part.type !== 'thinking' && + part.type !== 'structured-output', + )) return (
@@ -198,8 +233,18 @@ function RepoReportPage() {
- {chat.error && !report ? ( -

{chat.error.message}

+ {chat.error ? ( +

+ {chat.error.message} +

+ ) : null} + + {waiting ? ( +

+ Starting the sandbox and the agent. Tool calls and text show up + here as they arrive. Codex can take a few minutes before the first + line. +

) : null} {chat.messages.map((message) => ( @@ -207,17 +252,26 @@ function RepoReportPage() { key={message.id} className={ message.role === 'assistant' - ? 'rounded-lg bg-orange-500/5 p-4' + ? 'rounded-lg bg-orange-500/5 p-4 space-y-2' : 'text-gray-400 text-sm' } > {message.parts.map((part, index) => { + if (part.type === 'thinking' && part.content) { + return ( +

+ {part.content} +

+ ) + } if (part.type === 'text' && part.content) { - const prose = proseWithoutJson(part.content) - if (prose === null) return null + if (report && isCompleteReportJson(part.content)) return null return (

- {prose} + {part.content}

) } @@ -228,15 +282,31 @@ function RepoReportPage() { className="font-mono text-xs text-orange-200/80" > tool {part.name} + {part.state ? ` (${part.state})` : ''}

) } + if (part.type === 'structured-output') { + const data = part.data ?? part.partial + if (!looksLikeReport(data)) return null + return ( + + ) + } return null })}
))} - {report ? : null} + {report && + !chat.messages.some((message) => + message.parts.some((part) => part.type === 'structured-output'), + ) ? ( + + ) : null} {!chat.isLoading && chat.messages.length === 0 ? (

diff --git a/packages/ai-claude-code/src/adapters/text.ts b/packages/ai-claude-code/src/adapters/text.ts index 2fd3625080..6a762db90c 100644 --- a/packages/ai-claude-code/src/adapters/text.ts +++ b/packages/ai-claude-code/src/adapters/text.ts @@ -20,12 +20,14 @@ import { resolveApproval, resolveDurableRunId, resolveDurableThreadId, + resolveHarnessCwd, spawnNdjson, } from '@tanstack/ai-sandbox' import { buildPrompt } from '../messages/prompt' import { translateSdkStream } from '../stream/translate' import { mapPolicyToClaudeFlags } from './policy-map' import { projectClaudeWorkspace } from './projection' +import { acceptClaudeTrustDialog } from './trust' import type { ClaudePolicyFlags } from './policy-map' import type { BridgeEventChannel, @@ -168,7 +170,7 @@ export class ClaudeCodeTextAdapter< policyFlags: ClaudePolicyFlags, mcpConfigPath: string | undefined, permissionPromptTool: string | undefined, - jsonSchemaPath: string | undefined, + jsonSchemaJson: string | undefined, ): string { const config = this.adapterConfig const modelOptions = options.modelOptions @@ -227,8 +229,8 @@ export class ClaudeCodeTextAdapter< } if (mcpConfigPath !== undefined) args.push('--mcp-config', q(mcpConfigPath)) - if (jsonSchemaPath !== undefined) { - args.push('--json-schema', q(jsonSchemaPath)) + if (jsonSchemaJson !== undefined) { + args.push('--json-schema', q(jsonSchemaJson)) } if (permissionPromptTool !== undefined) { args.push('--permission-prompt-tool', q(permissionPromptTool)) @@ -320,6 +322,7 @@ export class ClaudeCodeTextAdapter< const sandbox = this.sandboxFrom(options) cleanupSandbox = sandbox const cwd = this.workdir(options) + await acceptClaudeTrustDialog(resolveHarnessCwd(sandbox, cwd)) // Durability comes from `withSandbox(sandbox, { runs, durability })`, read // back off the capability bus. Absent it, everything below resolves to // exactly today's behavior (no journal option, no alignment, and a @@ -433,14 +436,10 @@ export class ClaudeCodeTextAdapter< tempFiles.push(mcpConfigPath) mcpConfigArg = mcpConfigFile } - let jsonSchemaArg: string | undefined - if (options.outputSchema) { - const schemaFile = `.tanstack-output-schema-${runIdSegment}.json` - const schemaPath = `${cwd}/${schemaFile}` - await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema)) - tempFiles.push(schemaPath) - jsonSchemaArg = schemaFile - } + const jsonSchemaJson = + options.outputSchema !== undefined + ? JSON.stringify(options.outputSchema) + : undefined const command = this.buildCommand( options, resume, @@ -449,7 +448,7 @@ export class ClaudeCodeTextAdapter< bridge && permission ? `mcp__${bridge.name}__${permission.toolName}` : undefined, - jsonSchemaArg, + jsonSchemaJson, ) // Deliver the prompt. The default feeds it over stdin (keeps it out of diff --git a/packages/ai-claude-code/src/adapters/trust.ts b/packages/ai-claude-code/src/adapters/trust.ts new file mode 100644 index 0000000000..f688e90685 --- /dev/null +++ b/packages/ai-claude-code/src/adapters/trust.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +/** Claude stores project keys with forward slashes, even on Windows. */ +export function claudeProjectKey(cwd: string): string { + return path.resolve(cwd).replace(/\\/g, '/') +} + +export function withTrustDialogAccepted( + config: Record, + cwd: string, +): Record { + const key = claudeProjectKey(cwd) + const projectsRaw = config.projects + const projects = + projectsRaw !== null && + typeof projectsRaw === 'object' && + !Array.isArray(projectsRaw) + ? { ...(projectsRaw as Record) } + : {} + const existingRaw = projects[key] + const existing = + existingRaw !== null && + typeof existingRaw === 'object' && + !Array.isArray(existingRaw) + ? { ...(existingRaw as Record) } + : {} + projects[key] = { ...existing, hasTrustDialogAccepted: true } + return { ...config, projects } +} + +/** + * Mark a host cwd as trusted in `~/.claude.json` so headless `-p` can use + * the repo's `.claude/settings.json`. Skip virtual sandbox roots. + */ +export async function acceptClaudeTrustDialog(cwd: string): Promise { + if (cwd === '' || cwd === '/workspace' || cwd.startsWith('/workspace/')) { + return + } + const file = path.join(os.homedir(), '.claude.json') + let current: Record = {} + try { + const raw = await fs.readFile(file, 'utf8') + const parsed: unknown = JSON.parse(raw) + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + current = parsed as Record + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return + } + await fs.writeFile( + file, + `${JSON.stringify(withTrustDialogAccepted(current, cwd), null, 2)}\n`, + 'utf8', + ) +} diff --git a/packages/ai-claude-code/tests/text-adapter.test.ts b/packages/ai-claude-code/tests/text-adapter.test.ts index 404cce480f..d5f7b7feb6 100644 --- a/packages/ai-claude-code/tests/text-adapter.test.ts +++ b/packages/ai-claude-code/tests/text-adapter.test.ts @@ -201,6 +201,8 @@ describe('claude-code in-sandbox adapter', () => { const argv = await sbx.fs.read('/workspace/argv.txt') expect(argv).toContain('--json-schema') + expect(argv).toContain('"type":"object"') + expect(argv).toContain('"summary"') const complete = chunks.find( (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete', diff --git a/packages/ai-claude-code/tests/trust.test.ts b/packages/ai-claude-code/tests/trust.test.ts new file mode 100644 index 0000000000..c13250895e --- /dev/null +++ b/packages/ai-claude-code/tests/trust.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { claudeProjectKey, withTrustDialogAccepted } from '../src/adapters/trust' + +describe('withTrustDialogAccepted', () => { + it('sets hasTrustDialogAccepted on the resolved cwd', () => { + const next = withTrustDialogAccepted({}, 'C:\\tmp\\repo') + const key = claudeProjectKey('C:\\tmp\\repo') + expect(key.includes('\\')).toBe(false) + const projects = next.projects as Record + expect(projects[key]?.hasTrustDialogAccepted).toBe(true) + }) + + it('keeps other project entries', () => { + const next = withTrustDialogAccepted( + { projects: { '/other': { hasTrustDialogAccepted: false } } }, + '/tmp/repo', + ) + const projects = next.projects as Record + expect(projects['/other']?.hasTrustDialogAccepted).toBe(false) + expect(projects[claudeProjectKey('/tmp/repo')]?.hasTrustDialogAccepted).toBe( + true, + ) + }) +})