diff --git a/docs/adapters/openrouter.md b/docs/adapters/openrouter.md index 6dfca45d1..8bc02dba9 100644 --- a/docs/adapters/openrouter.md +++ b/docs/adapters/openrouter.md @@ -222,7 +222,7 @@ accounts for routing, fallback providers, BYOK, and cached-token pricing. See OpenRouter's [Usage Accounting](https://openrouter.ai/docs/use-cases/usage-accounting) docs for the meaning and units of these fields. -```typescript +```typescript ignore import { chat, type RunFinishedEvent, type StreamChunk } from "@tanstack/ai"; import { openRouterText } from "@tanstack/ai-openrouter"; diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index 94e513c7e..db32def3c 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -82,6 +82,7 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) - **TEXT_MESSAGE_START/CONTENT/END** - Text content streaming lifecycle - **TOOL_CALL_START/ARGS/END** - Tool invocation lifecycle - **STEP_STARTED/STEP_FINISHED** - Thinking/reasoning steps +- **CUSTOM** - Namespaced extension events (sandbox file changes, Code Mode progress, structured-output completion, and your own `emitCustomEvent` calls) — see the [Custom Events Reference](../protocol/custom-events) for the full typed taxonomy and how to narrow `chunk.value` with a plain `if` - **RUN_FINISHED** - Run completion with finish reason and usage - **RUN_ERROR** - Error occurred during the run diff --git a/docs/config.json b/docs/config.json index 770c6fc7f..34cfcf6fa 100644 --- a/docs/config.json +++ b/docs/config.json @@ -156,7 +156,8 @@ { "label": "Streaming", "to": "chat/streaming", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-03" }, { "label": "Connection Adapters", @@ -175,6 +176,16 @@ } ] }, + { + "label": "Protocol", + "children": [ + { + "label": "Custom Events Reference", + "to": "protocol/custom-events", + "addedAt": "2026-07-03" + } + ] + }, { "label": "Structured Outputs", "children": [ @@ -368,12 +379,14 @@ { "label": "Events", "to": "sandbox/events", - "addedAt": "2026-06-29" + "addedAt": "2026-06-29", + "updatedAt": "2026-07-03" }, { "label": "Observability", "to": "sandbox/observability", - "addedAt": "2026-06-29" + "addedAt": "2026-06-29", + "updatedAt": "2026-07-03" }, { "label": "Cloudflare (Edge)", diff --git a/docs/protocol/custom-events.md b/docs/protocol/custom-events.md new file mode 100644 index 000000000..bf5ac8965 --- /dev/null +++ b/docs/protocol/custom-events.md @@ -0,0 +1,174 @@ +--- +title: Custom Events Reference +id: custom-events +order: 1 +description: "Every CUSTOM event TanStack AI itself emits, unified as KnownCustomEvent, and the ChatStream type that lets a plain `if (chunk.name === '…')` narrow chunk.value — no helper, no cast." +keywords: + - tanstack ai + - custom events + - KnownCustomEvent + - ChatStream + - ag-ui protocol + - CUSTOM event + - stream narrowing +--- + +You're reading a `chat()` stream and you've hit a `CUSTOM` event — maybe +[`sandbox.file.diff`](../sandbox/events), maybe a Code Mode progress event, +maybe `structured-output.complete`. Each feature page documents its own +events in context. This page is the map: every `CUSTOM` event TanStack AI +itself emits, in one table, plus the type mechanism that lets you read any of +them with a plain `if` — no helper function, no cast. + +## The type: `ChatStream` + +`chat()` returns `ChatStream` by default (no `outputSchema`, `stream` not +explicitly `false`): + +```ts +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import type { ChatStream } from "@tanstack/ai"; + +const stream: ChatStream = chat({ + adapter: openaiText("gpt-5.5"), + messages: [{ role: "user", content: "Hello" }], +}); +``` + +`ChatStream` is defined as: + +```ts ignore +type ChatStream = AsyncIterable | KnownCustomEvent> +``` + +`StreamChunk` (the raw AG-UI event union) has exactly one `CUSTOM`-shaped +member: the generic `CustomEvent` interface, with `name: string` and +`value: any`. Left in the union unchanged, that single `any` "poisons" every +narrow — even `if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file')` +would still leave `chunk.value` typed `any`, because TypeScript can't +distinguish the generic member from a specific one once they're merged. +`ChatStream` fixes this in two steps: `Exclude` +removes that generic member, and `| KnownCustomEvent` adds back a +discriminated union of every event TanStack AI actually emits — each with a +literal `name` and a concrete `value`. The result is a stream where `CUSTOM` +events narrow like anything else. + +## Reading events: the plain narrowing pattern + +Check `chunk.type === 'CUSTOM'`, then compare `chunk.name` to a literal +string. That's the entire client-side API — there is no `isCustomEvent` or +`isSandboxEvent` guard to import: + +```ts ignore +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +const stream = chat({ + adapter: openaiText("gpt-5.5"), + messages: [{ role: "user", content: "Hello" }], +}); + +for await (const chunk of stream) { + if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file.diff") { + console.log(chunk.value.path, chunk.value.diff); // typed, no helper, no cast + } else if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") { + console.log(chunk.value.object); // typed, no helper, no cast + } +} +``` + +## Full taxonomy + +Every interface below extends the base `CustomEvent` (`type: 'CUSTOM'`, plus +an optional `model?`) with a literal `name` and a concrete `value`. All are +unioned as `KnownCustomEvent`, exported from `@tanstack/ai` alongside each +individual interface. + +| Interface | `name` | `value` | Emitted when | +| --- | --- | --- | --- | +| `SandboxFileCustomEvent` | `sandbox.file` | `{ type: 'create' \| 'change' \| 'delete'; path: string; timestamp: number }` | per file create/change/delete in an active [sandbox](../sandbox/events) | +| `SandboxFileDiffEvent` | `sandbox.file.diff` | `{ path: string; diff: string }` | per file change, opt-in via `fileEvents: { diff: true }` | +| `FileChangedEvent` | `file.changed` | `{ path: string; diff: string }` | a harness adapter (Grok Build, Claude Code, …), once after the run completes | +| `SessionIdEvent` | `` `${string}.session-id` `` | `{ sessionId: string }` | a harness adapter, once when its in-sandbox session is created or resumed | +| `CodeModeExecutionStartedEvent` | `code_mode:execution_started` | `{ timestamp: number; codeLength: number }` | [Code Mode](../code-mode/code-mode), when sandbox execution begins | +| `CodeModeConsoleEvent` | `code_mode:console` | `{ level: 'log' \| 'warn' \| 'error' \| 'info'; message: string; timestamp: number }` | Code Mode, per `console.*` call inside the sandbox | +| `CodeModeExternalCallEvent` | `code_mode:external_call` | `{ function: string; args: unknown; timestamp: number }` | Code Mode, before a bound `external_*` function runs | +| `CodeModeExternalResultEvent` | `code_mode:external_result` | `{ function: string; result: unknown; duration: number }` | Code Mode, after a successful `external_*` call | +| `CodeModeExternalErrorEvent` | `code_mode:external_error` | `{ function: string; error: string; duration: number }` | Code Mode, when an `external_*` call throws | +| `CodeModeSkillCallEvent` | `code_mode:skill_call` | `{ skill: string; input: unknown; timestamp: number }` | [Code Mode with Skills](../code-mode/code-mode-with-skills), before a skill runs | +| `CodeModeSkillResultEvent` | `code_mode:skill_result` | `{ skill: string; result: unknown; duration: number; timestamp: number }` | Code Mode with Skills, after a successful skill run | +| `CodeModeSkillErrorEvent` | `code_mode:skill_error` | `{ skill: string; error: string; duration: number; timestamp: number }` | Code Mode with Skills, when a skill throws | +| `SkillRegisteredEvent` | `skill:registered` | `{ id: string; name: string; description: string; timestamp: number }` | when a skill is registered into the tool registry | +| `StructuredOutputStartEvent` | `structured-output.start` | `{ messageId: string }` | [`chat({ outputSchema, stream: true })`](../structured-outputs/streaming), once per structured message | +| `StructuredOutputCompleteEvent` | `structured-output.complete` | `{ object: T; raw: string; reasoning?: string }` | structured-output streaming, once with the validated object | +| `ApprovalRequestedEvent` | `approval-requested` | `{ toolCallId: string; toolName: string; input: unknown; approval: { id: string; needsApproval: true } }` | a server tool needs approval — the run pauses; see [Tool Approval Flow](../tools/tool-approval) | +| `ToolInputAvailableEvent` | `tool-input-available` | `{ toolCallId: string; toolName: string; input: unknown }` | a client tool is invoked — the run pauses; see [Client Tools](../tools/client-tools) | +| `UIResourceEvent` | `ui-resource` | `{ resource; serverId?: string; toolCallId: string; toolName: string; meta?: Record }` | an MCP tool returns a `ui://` resource ([MCP Apps](../mcp/apps)) | + +## Your own custom events aren't in this union + +Tools can emit arbitrary, application-defined events through the +`emitCustomEvent` context API: + +```ts +import { toolDefinition } from "@tanstack/ai"; +import { z } from "zod"; + +const importRows = toolDefinition({ + name: "importRows", + description: "Import rows into the dataset, reporting progress as it runs", + inputSchema: z.object({ rows: z.array(z.string()) }), +}).server(async ({ rows }, context) => { + for (let i = 0; i < rows.length; i++) { + context?.emitCustomEvent("my-app:progress", { + done: i + 1, + total: rows.length, + }); + } + return { imported: rows.length }; +}); +``` + +These flow over the wire exactly like the built-in events — same `CUSTOM` +chunk shape, same runtime behavior. But `'my-app:progress'` isn't one of the +literal names in `KnownCustomEvent`, so it's intentionally absent from +`ChatStream`'s type. This is the same tradeoff `StructuredOutputStream` +already made: including a generic fallback member would reintroduce the +`value: any` poison for every other event on the stream. + +To read your own event's `value`, don't rely on `ChatStream`'s narrowed +union for that branch — annotate the stream as the wider `StreamChunk` type +instead, where the generic `CUSTOM` member's `value: any` needs no cast: + +```ts ignore +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import type { StreamChunk } from "@tanstack/ai"; +import { importRows } from "./tools"; + +const stream: AsyncIterable = chat({ + adapter: openaiText("gpt-5.5"), + messages: [{ role: "user", content: "Import these rows" }], + tools: [importRows], +}); + +for await (const chunk of stream) { + if (chunk.type === "CUSTOM" && chunk.name === "my-app:progress") { + console.log(chunk.value.done, chunk.value.total); // value: any — your event, your shape + } +} +``` + +The event still arrives at runtime either way — this only changes what +TypeScript will let you write. `ChatStream` is the right default for reading +TanStack AI's own events with full type safety; fall back to `StreamChunk` +for the branches that read your own. + +## Related + +- [Sandbox Events](../sandbox/events) — the sandbox- and harness-specific rows of this table, in context, plus `sandbox.file.diff`'s opt-in. +- [Observability](../sandbox/observability) — the server-side hook accessors (`before()`/`after()`/`diff()`) that back `sandbox.file.diff`. +- [Showing Code Mode in the UI](../code-mode/client-integration) — rendering the `code_mode:*` events live. +- [Streaming UIs](../structured-outputs/streaming) — reading `structured-output.complete` end to end. +- [Streaming](../chat/streaming) — the standard AG-UI `StreamChunk` lifecycle this union extends. diff --git a/docs/sandbox/events.md b/docs/sandbox/events.md index 62e0148e6..cb85b9750 100644 --- a/docs/sandbox/events.md +++ b/docs/sandbox/events.md @@ -35,12 +35,30 @@ events (`chunk.type === 'CUSTOM'`), each with a `name` and a `value`: | `opencode.session-id` | OpenCode adapter | once, when the session is created or resumed | the resumable harness session id | | `file.changed` | harness adapter (e.g. Grok Build, Claude Code) | after the run completes | `{ path: string; diff: string }` — the whole working-tree `git diff` (`path` is always `'.'`, the tree root) | | `sandbox.file` | the engine, automatically | per file create / change / delete while a sandbox is active | `{ type: 'create' \| 'change' \| 'delete'; path: string; timestamp: number }` | +| `sandbox.file.diff` | the engine, opt-in via `fileEvents: { diff: true }` | per file create / change / delete, after the matching `sandbox.file` | `{ path: string; diff: string }` — a unified patch of that one file vs the session's git baseline | The `*.session-id` event lets you resume a harness session on a follow-up run (pass it back via the adapter's `modelOptions.sessionId`). `sandbox.file` is emitted automatically whenever a sandbox is active and file watching is on — -no hooks required; see [Observability](./observability) to also handle these -server-side or to turn the watcher off. +no hooks required. `sandbox.file.diff` is off by default (computing a diff on +every change has a cost); turn it on with `fileEvents: { diff: true }` on +`defineSandbox` when the client needs to render the change itself, not just +know it happened: + +```ts +import { defineSandbox } from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; + +const repoSandbox = defineSandbox({ + id: "repo-agent", + provider: dockerSandbox({ image: "node:22" }), + fileEvents: { diff: true }, // also emit sandbox.file.diff per change +}); +``` + +See [Observability](./observability) to also handle file changes server-side +via hooks (which get the same diff, plus `before()`/`after()`), or to turn +the watcher off entirely. > **Bridged tools emit their own events too.** A `chat()` tool that runs through > the [tool bridge](./tools) can stream `CUSTOM` events back mid-execution. Code @@ -50,44 +68,83 @@ server-side or to turn the watcher off. ## Reading CUSTOM events on the client -A `CUSTOM` chunk's `value` is of unknown shape, so narrow it with `typeof` / `in` -checks before reading its fields — never cast: +Every `CUSTOM` event TanStack AI itself emits — `sandbox.file`, +`sandbox.file.diff`, `file.changed`, the `*.session-id` events, and more — has +a fixed `name` and a concrete `value` shape, unified as `KnownCustomEvent`. +`chat()`'s return type narrows accordingly: check `chunk.type === 'CUSTOM'` +and then compare `chunk.name` to a literal string. No helper, no cast — the +plain `if` types `chunk.value` for you: ```ts -import { stream } from './my-run' +import { stream } from "./my-run"; for await (const chunk of stream) { - if (chunk.type === 'CUSTOM' && chunk.name === 'file.changed') { - const value = chunk.value - if (value !== null && typeof value === 'object' && 'diff' in value) { - console.log(value.diff) - } + if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file") { + console.log(chunk.value.type, chunk.value.path); // typed, no cast + } else if (chunk.type === "CUSTOM" && chunk.name === "sandbox.file.diff") { + console.log(chunk.value.path, chunk.value.diff); // typed, no cast + } else if (chunk.type === "CUSTOM" && chunk.name === "file.changed") { + console.log(chunk.value.diff); // typed, no cast } } ``` -The same pattern reads the auto-emitted `sandbox.file` events: +### Session-id events aren't one literal name + +`*.session-id` is emitted per-adapter (`claude-code.session-id`, +`codex.session-id`, `grok-build.session-id`, `opencode.session-id`), so its +type is the template-literal name `` `${string}.session-id` ``, not a single +string. If you know which adapter you're running, compare the exact literal +— it narrows `chunk.value` the same as any other event: ```ts -import { stream } from './my-run' +import { resumeSession } from "./session"; +import { stream } from "./my-run"; for await (const chunk of stream) { - if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') { - const value = chunk.value - if ( - value !== null && - typeof value === 'object' && - 'type' in value && - 'path' in value - ) { - console.log('file event', value) // { type, path, timestamp } - } + if (chunk.type === "CUSTOM" && chunk.name === "claude-code.session-id") { + resumeSession(chunk.value.sessionId); // typed as string, no cast } } ``` +> **`chunk.name.endsWith('.session-id')` does *not* narrow.** It's a plain +> boolean expression, not something TypeScript can attach to a type — so +> `chunk.value` stays whatever it was before the check (effectively +> `unknown`), even though the check happens to be correct at runtime. If you +> need to handle *any* adapter's session id without listing every adapter's +> literal name, write a small type predicate instead: +> +> ```ts +> import type { KnownCustomEvent, SessionIdEvent } from "@tanstack/ai"; +> import { resumeSession } from "./session"; +> import { stream } from "./my-run"; +> +> function isSessionIdEvent( +> chunk: KnownCustomEvent, +> ): chunk is SessionIdEvent { +> return chunk.name.endsWith(".session-id"); +> } +> +> for await (const chunk of stream) { +> if (chunk.type === "CUSTOM" && isSessionIdEvent(chunk)) { +> resumeSession(chunk.value.sessionId); // typed as string, no cast +> } +> } +> ``` +> +> This predicate is something you write yourself when you need it — TanStack +> AI doesn't ship a guard API. Plain literal-`name` narrowing (as above) is +> the primary, no-helper pattern; reach for a predicate only for this +> "any adapter" case. + +See [Custom Events Reference](../protocol/custom-events) for the full typed +event taxonomy, the `ChatStream` type this narrowing relies on, and the +tradeoff for your own `emitCustomEvent` calls. + ## Related -- [Observability](./observability) — server-side file-event hooks, debug logging, and the low-level watcher. +- [Observability](./observability) — server-side file-event hooks (with `before()`/`after()`/`diff()`), debug logging, and the low-level watcher. +- [Custom Events Reference](../protocol/custom-events) — the full `KnownCustomEvent` taxonomy and the `ChatStream` type. - [Tools](./tools) — bridged host tools that surface as tool-call (and CUSTOM) chunks. - [Quick Start](./quick-start) — read the `file.changed` diff end to end. diff --git a/docs/sandbox/observability.md b/docs/sandbox/observability.md index b63a043d1..950413629 100644 --- a/docs/sandbox/observability.md +++ b/docs/sandbox/observability.md @@ -72,6 +72,80 @@ engine automatically emits one `CUSTOM` [`sandbox.file`](./events#custom-events) event per change regardless of whether you register any hooks — so the client can react to the same edits without extra middleware. +## Reading content and diffs in hooks + +The event every hook receives isn't just `{ type, path, timestamp }` — it also +carries lazy, git-backed accessors for the file's content: + +```ts +interface SandboxFileHookEvent { + type: "create" | "change" | "delete"; + path: string; + timestamp: number; + before(): Promise; // content at the session baseline ('' if new / non-git) + after(): Promise; // current content ('' if deleted) + diff(): Promise; // unified patch vs the baseline +} +``` + +Reach for `diff()` to show what the agent changed — no need to hand-roll a +`git diff` yourself: + +```ts +import { defineSandbox } from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; + +const repoSandbox = defineSandbox({ + id: "repo-agent", + provider: dockerSandbox({ image: "node:22" }), + hooks: { + onFileChange: async (e) => { + const patch = await e.diff(); + console.log(`${e.path} changed:\n${patch}`); + }, + }, +}); +``` + +The same accessors are available on run-scoped hooks, where `e` is the +second argument: + +```ts +import { defineChatMiddleware } from "@tanstack/ai"; +import { db } from "./db"; + +const auditMiddleware = defineChatMiddleware({ + name: "audit", + sandbox: { + onFileChange: async (ctx, e) => { + const [before, after] = await Promise.all([e.before(), e.after()]); + db.log({ run: ctx.runId, path: e.path, before, after }); + }, + }, +}); +``` + +**Lazy — path-only hooks pay nothing.** `before()`, `after()`, and `diff()` +are methods, not fields: each one only reads the file or shells out to `git` +when you call it. A hook that only reads `e.path` / `e.type` (like the +catch-all logger in [Sandbox-scoped hooks](#sandbox-scoped-hooks) above) +never touches the filesystem or spawns a process. + +**Git session baseline.** At `onReady`, the sandbox snapshots +`git rev-parse HEAD` once, as the session's baseline commit (empty if the +workspace isn't a git repo, or has no commits yet). Every `before()` and +`diff()` call for the rest of the session diffs against that same fixed +baseline, so `onFileChange` always reports the file's **cumulative** change +since the run started — not just the delta since the watcher's last poll. +`after()` always reads the file's current on-disk content, independent of +the baseline. None of the three accessors throw: a deleted file resolves +`after()` to `''` (it still has `before()`); a new file resolves `before()` +to `''` (it still has `after()`); a non-git workspace resolves **both** +`before()` and `after()` to `''` and makes `diff()` fall back to a +synthesized add-patch built from `after()` — except for a `delete` event in +a non-git workspace, where there's nothing to synthesize and `diff()` +resolves to `''`. + ## Disabling file watching To stop the watcher and suppress `sandbox.file` events for a sandbox entirely, diff --git a/packages/ai-sandbox-local-process/package.json b/packages/ai-sandbox-local-process/package.json index a8eed5b05..2dcc1b967 100644 --- a/packages/ai-sandbox-local-process/package.json +++ b/packages/ai-sandbox-local-process/package.json @@ -44,6 +44,7 @@ "@tanstack/ai-sandbox": "workspace:^" }, "devDependencies": { + "@tanstack/ai": "workspace:*", "@tanstack/ai-sandbox": "workspace:*", "@vitest/coverage-v8": "4.0.14" } diff --git a/packages/ai-sandbox-local-process/tests/sandbox-file-diff.integration.test.ts b/packages/ai-sandbox-local-process/tests/sandbox-file-diff.integration.test.ts new file mode 100644 index 000000000..1bb6f93b3 --- /dev/null +++ b/packages/ai-sandbox-local-process/tests/sandbox-file-diff.integration.test.ts @@ -0,0 +1,235 @@ +/** + * REAL-HANDLE integration test for the `sandbox.file` / `sandbox.file.diff` + * stream events (as opposed to the fake-handle unit tests in + * `packages/ai-sandbox/tests/with-sandbox-hooks.test.ts`). + * + * Lives in `ai-sandbox-local-process` (not `ai-sandbox`) so it can drive a + * REAL `localProcessSandbox` handle using only public exports — `ai-sandbox` + * already depends on nothing here, and `ai-sandbox-local-process` already + * depends on `@tanstack/ai-sandbox`, so this avoids introducing a reverse + * workspace devDependency (`ai-sandbox` -> `ai-sandbox-local-process`) just + * for a test. + * + * The full browser E2E harness (`testing/e2e`) can't exercise sandboxes, so + * this substitutes for it: it drives a genuine `localProcessSandbox` handle — + * a real host tmp directory, a real `git` repo, real `fs.write`, and (on + * non-Linux platforms, including this Windows dev box) a real native + * `fs.watch` — through `withSandbox({ fileEvents: { diff: true } })`, and + * asserts the emitted CUSTOM chunks end-to-end. + * + * Approach used: REAL handle (not the controllable-fake fallback). The only + * wrinkle driving a real handle surfaced was a genuine bug in + * `buildFileHookEvent`'s `diff()` accessor (in `ai-sandbox/src/file-diff.ts`) + * — it passed the *virtual* sandbox path (e.g. `/workspace/notes.txt`) + * straight to `git diff -- ` instead of relativizing it the way + * `before()` already does. Since the real local-process repo root is a host + * tmp dir (not literally `/workspace`), git resolves a leading `/` against + * the filesystem root and fails with "fatal: Invalid path" — so `diff()` + * silently fell back to `''` on every platform. Fixed in that file (see + * `packages/ai-sandbox/tests/file-diff.test.ts` for the accompanying + * unit-level regression coverage) so this integration test can actually + * observe a real, non-empty diff. + * + * We reuse the exact runtime-sink shape production code builds in + * `packages/ai/src/activities/chat/index.ts` (`createCustomEventChunk`) via + * `provideSandboxRuntime`, the same seam `with-sandbox-hooks.test.ts` drives + * directly (its production caller, the chat engine's `MiddlewareRunner`, is + * an internal symbol of `@tanstack/ai` not exposed outside that package, so + * it isn't reachable from a downstream package either — the `sandbox`-hook + * fan-out it performs is already covered by + * `packages/ai/tests/sandbox-runtime-emit.test.ts`). + */ +import { randomUUID } from 'node:crypto' +import * as fsp from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + provideSandboxRuntime, + resolveDebugOption, +} from '@tanstack/ai/adapter-internals' +import { EventType } from '@tanstack/ai' +import { defineSandbox, withSandbox } from '@tanstack/ai-sandbox' +import { localProcessSandbox } from '../src/index' +import type { + ChatMiddlewareContext, + KnownCustomEvent, + SandboxFileCustomEvent, + SandboxFileDiffEvent, +} from '@tanstack/ai' + +function makeCtx(): ChatMiddlewareContext { + return { + threadId: 't-diff-int', + runId: 'r-diff-int', + capabilities: { markProvided: () => undefined }, + getOptional: () => undefined, + } as unknown as ChatMiddlewareContext +} + +/** Poll `predicate` until it's true, or throw once `timeoutMs` elapses. */ +async function waitFor( + predicate: () => boolean, + timeoutMs = 8000, + intervalMs = 25, +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`waitFor: condition not met within ${timeoutMs}ms`) + } + await new Promise((r) => setTimeout(r, intervalMs)) + } +} + +/** + * Retry `fn` on failure. Windows' recursive native `fs.watch` can hold the + * directory's OS handle open for a brief moment after `.close()` returns, so + * an immediate `rm -rf` of that same directory can fail with `EBUSY` even + * though the watcher has already been stopped — retry instead of sleeping a + * fixed amount up front. + */ +async function retryOnFailure( + fn: () => Promise, + attempts = 20, + delayMs = 100, +): Promise { + for (let i = 0; i < attempts; i += 1) { + try { + await fn() + return + } catch (err) { + if (i === attempts - 1) throw err + await new Promise((r) => setTimeout(r, delayMs)) + } + } +} + +describe('sandbox.file + sandbox.file.diff — real localProcessSandbox integration', () => { + const workDirs: Array = [] + + afterEach(async () => { + while (workDirs.length > 0) { + const dir = workDirs.pop() + if (dir) { + await retryOnFailure(() => + fsp.rm(dir, { recursive: true, force: true }), + ) + } + } + }) + + it('emits sandbox.file and sandbox.file.diff CUSTOM chunks off a real git-backed handle', async () => { + const workDir = path.join( + os.tmpdir(), + `tanstack-ai-sbx-diff-${randomUUID()}`, + ) + await fsp.mkdir(workDir, { recursive: true }) + workDirs.push(workDir) + + // Fixed-`dir` config: every create/resume from this provider uses this + // exact host directory, so the git repo we seed below is what + // `withSandbox`'s setup actually captures `baseSha` against. + const provider = localProcessSandbox({ + dir: workDir, + removeOnDestroy: true, + }) + const seedHandle = await provider.create({}) + + const run = async (cmd: string): Promise => { + const res = await seedHandle.process.exec(cmd, { cwd: '/workspace' }) + if (res.exitCode !== 0) { + throw new Error(`"${cmd}" failed (exit ${res.exitCode}): ${res.stderr}`) + } + } + + // Real git repo + baseline commit, so `git rev-parse HEAD` (captured by + // withSandbox's setup) and `git diff ` (the diff() accessor) + // have real history to work against. + await seedHandle.fs.write('/workspace/notes.txt', 'line one\n') + await run('git init') + await run('git config user.email "tanstack-ai-test@example.com"') + await run('git config user.name "tanstack-ai-test"') + await run('git add -A') + await run('git commit -m baseline') + + const chunks: Array = [] + const sandbox = defineSandbox({ + id: 's-diff-int', + provider, + fileEvents: { diff: true }, + }) + + const ctx = makeCtx() + // Mirrors the production sink built in + // `packages/ai/src/activities/chat/index.ts` (`createCustomEventChunk` + // for `sandbox.file` / `sandbox.file.diff`), minus the `model` field + // (no adapter/model in this harness-only integration test). + provideSandboxRuntime(ctx, { + logger: resolveDebugOption(false), + emit: (event) => { + chunks.push({ + type: EventType.CUSTOM, + name: 'sandbox.file', + timestamp: event.timestamp, + value: { + type: event.type, + path: event.path, + timestamp: event.timestamp, + }, + }) + }, + emitFileDiff: (value) => { + chunks.push({ + type: EventType.CUSTOM, + name: 'sandbox.file.diff', + timestamp: Date.now(), + value, + }) + }, + }) + + const mw = withSandbox(sandbox) + await mw.setup!(ctx) + + try { + // Mutate the tracked file so the real (native, on this non-Linux box) + // fs.watch fires a 'change' event. + await seedHandle.fs.write('/workspace/notes.txt', 'line one\nline two\n') + + await waitFor(() => chunks.some((c) => c.name === 'sandbox.file.diff')) + + // Literal-`name` discriminated-union narrowing on the public + // `KnownCustomEvent` type — no `as` cast anywhere below. + const fileEvents: Array = [] + const diffEvents: Array = [] + for (const chunk of chunks) { + if (chunk.name === 'sandbox.file') fileEvents.push(chunk) + else if (chunk.name === 'sandbox.file.diff') diffEvents.push(chunk) + } + + expect(fileEvents.length).toBeGreaterThan(0) + const fileEvent = fileEvents[0] + expect(fileEvent).toBeDefined() + expect(['create', 'change']).toContain(fileEvent?.value.type) + expect(fileEvent?.value.path).toBe('/workspace/notes.txt') + expect(fileEvent?.value.timestamp).toBeGreaterThan(0) + + expect(diffEvents.length).toBeGreaterThan(0) + const diffEvent = diffEvents[0] + expect(diffEvent).toBeDefined() + expect(diffEvent?.value.path).toBe('/workspace/notes.txt') + expect(diffEvent?.value.diff).toContain('line two') + } finally { + // Stops the watcher (no lingering fs.watch/exec-poll timers). + await mw.onFinish!(ctx, { + finishReason: 'stop', + duration: 0, + content: '', + }) + // `destroy()` (removeOnDestroy: true) removes `workDir` itself — see + // `retryOnFailure` above for why this can't be a bare await on Windows. + await retryOnFailure(() => seedHandle.destroy()) + } + }, 15000) +}) diff --git a/packages/ai-sandbox/src/file-diff.ts b/packages/ai-sandbox/src/file-diff.ts new file mode 100644 index 000000000..174c37395 --- /dev/null +++ b/packages/ai-sandbox/src/file-diff.ts @@ -0,0 +1,94 @@ +import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai' +import type { SandboxHandle } from './contracts' + +/** Path relative to the repo/workspace root, POSIX form. */ +function relTo(root: string, path: string): string { + const prefix = root.endsWith('/') ? root : `${root}/` + return path.startsWith(prefix) ? path.slice(prefix.length) : path +} + +/** + * POSIX single-quote escape for embedding a value in a shell command. + */ +function q(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +/** Minimal unified add-patch for a brand-new file (non-git workspaces). */ +function synthesizeAddPatch(path: string, content: string): string { + const lines = content === '' ? [] : content.replace(/\n$/, '').split('\n') + const body = lines.map((l) => `+${l}`).join('\n') + return `--- /dev/null\n+++ ${path}\n@@ -0,0 +1,${lines.length} @@\n${body}${body ? '\n' : ''}` +} + +/** + * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to + * the live handle. `baseSha` is the session baseline (`''` when the workspace + * isn't a git repo). Never throws. + */ +export function buildFileHookEvent( + handle: SandboxHandle, + root: string, + baseSha: string, + event: SandboxFileEvent, +): SandboxFileHookEvent { + const after = async (): Promise => { + if (event.type === 'delete') return '' + try { + return await handle.fs.read(event.path) + } catch { + return '' + } + } + const before = async (): Promise => { + if (baseSha === '') return '' + const rel = relTo(root, event.path) + try { + const res = await handle.process.exec( + `git show ${q(baseSha)}:${q(rel)}`, + { cwd: root }, + ) + return res.exitCode === 0 ? res.stdout : '' + } catch { + return '' + } + } + const diff = async (): Promise => { + if (baseSha === '') { + if (event.type === 'delete') return '' + return synthesizeAddPatch(event.path, await after()) + } + // Pathspec must be relative to `root` (like `before()` above) — a bare + // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git + // against the filesystem root, not the repo root, and fails with + // "fatal: Invalid path" whenever the real repo root differs from + // `/workspace` (e.g. every local-process sandbox). + const rel = relTo(root, event.path) + try { + const res = await handle.process.exec( + `git diff ${q(baseSha)} -- ${q(rel)}`, + { + cwd: root, + }, + ) + return res.exitCode === 0 ? res.stdout : '' + } catch { + return '' + } + } + return { ...event, before, after, diff } +} + +export interface ResolvedFileEvents { + enabled: boolean + diff: boolean +} + +/** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */ +export function resolveFileEvents( + opt: boolean | { diff?: boolean } | undefined, +): ResolvedFileEvents { + if (opt === false) return { enabled: false, diff: false } + if (opt === undefined || opt === true) return { enabled: true, diff: false } + return { enabled: true, diff: opt.diff === true } +} diff --git a/packages/ai-sandbox/src/middleware.ts b/packages/ai-sandbox/src/middleware.ts index 155dfb9ba..83dfe7cd4 100644 --- a/packages/ai-sandbox/src/middleware.ts +++ b/packages/ai-sandbox/src/middleware.ts @@ -24,6 +24,7 @@ import { provideSandboxPolicy, } from './capabilities' import { computeWorkspaceHash } from './key' +import { buildFileHookEvent, resolveFileEvents } from './file-diff' import { ProjectionCapability, provideWorkspaceProjection } from './projection' import { resolveSecret } from './secrets' import { watchWorkspace } from './watch' @@ -33,6 +34,7 @@ import type { ChatMiddlewareContext, DefinedChatMiddleware, SandboxFileEvent, + SandboxFileHookEvent, } from '@tanstack/ai' import type { SandboxHandle } from './contracts' import type { @@ -47,6 +49,10 @@ interface SandboxRunState { handle: SandboxHandle ensureCtx: SandboxEnsureContext watcher?: SandboxWatchHandle + /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff` + * watcher callback, awaited before teardown so a pending diff isn't + * dropped when the run finishes/aborts/errors mid-computation. */ + pendingDiffs: Array> } const runState = new WeakMap() @@ -81,7 +87,7 @@ function buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext { */ async function dispatchDefinitionHooks( hooks: SandboxHooks | undefined, - event: SandboxFileEvent, + event: SandboxFileHookEvent, ): Promise { if (!hooks) return const typed = ( @@ -122,6 +128,17 @@ export function withSandbox( provideSandbox(ctx, handle) if (definition.policy) provideSandboxPolicy(ctx, definition.policy) + const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT + let baseSha = '' + try { + const shaRes = await handle.process.exec('git rev-parse HEAD', { + cwd: watchRoot, + }) + if (shaRes.exitCode === 0) baseSha = shaRes.stdout.trim() + } catch { + // non-git workspace / exec rejects → baseSha stays '' (accessors fall back) + } + const workspace = definition.workspace if (workspace !== undefined) { const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT @@ -149,19 +166,42 @@ export function withSandbox( const hooks = definition.hooks await hooks?.onReady?.(handle) + const fe = resolveFileEvents(definition.fileEvents) + const pendingDiffs: Array> = [] let watcher: SandboxWatchHandle | undefined - if (definition.fileEvents !== false) { + if (fe.enabled) { const runtime = getSandboxRuntime(ctx, { optional: true }) watcher = await watchWorkspace(handle, { onEvent: (event: SandboxFileEvent) => { - void dispatchDefinitionHooks(hooks, event) - runtime?.emit(event) + const enriched = buildFileHookEvent( + handle, + watchRoot, + baseSha, + event, + ) + void dispatchDefinitionHooks(hooks, enriched) + runtime?.emit(enriched) + if (fe.diff) { + pendingDiffs.push( + enriched + .diff() + .then((diff) => { + runtime?.emitFileDiff({ path: event.path, diff }) + }) + .catch(() => undefined), + ) + } }, ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}), }) } - runState.set(ctx, { handle, ensureCtx, ...(watcher ? { watcher } : {}) }) + runState.set(ctx, { + handle, + ensureCtx, + pendingDiffs, + ...(watcher ? { watcher } : {}), + }) }, async onFinish(ctx) { @@ -170,6 +210,7 @@ export function withSandbox( const { handle, ensureCtx } = state await state.watcher?.stop() + await Promise.allSettled(state.pendingDiffs) const lifecycle = definition.lifecycle @@ -204,6 +245,7 @@ export function withSandbox( if (!state) return await state.watcher?.stop() + await Promise.allSettled(state.pendingDiffs) // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`. // The in-sandbox agent process is not killed by closing its IO stream @@ -220,6 +262,7 @@ export function withSandbox( if (!state) return await state.watcher?.stop() + await Promise.allSettled(state.pendingDiffs) await definition.hooks?.onError?.(info.error) // On failure, only tear down when the lifecycle says so; otherwise leave diff --git a/packages/ai-sandbox/src/sandbox.ts b/packages/ai-sandbox/src/sandbox.ts index 08f4b0637..b1f20859e 100644 --- a/packages/ai-sandbox/src/sandbox.ts +++ b/packages/ai-sandbox/src/sandbox.ts @@ -10,7 +10,7 @@ import { bootstrapWorkspace } from './bootstrap' import { resolveAllSecrets } from './secrets' import { computeSandboxKey } from './key' import { InMemoryLockStore, InMemorySandboxStore } from './store' -import type { SandboxFileEvent } from '@tanstack/ai' +import type { SandboxFileHookEvent } from '@tanstack/ai' import type { SandboxHandle, SandboxProvider } from './contracts' import type { SandboxKeyInput } from './key' import type { LockStore, SandboxStore } from './store' @@ -22,10 +22,10 @@ import type { WorkspaceDefinition } from './workspace' * create/change/delete during a chat run; lifecycle hooks fire server-side. */ export interface SandboxHooks { - onFile?: (e: SandboxFileEvent) => void | Promise - onFileCreate?: (e: SandboxFileEvent) => void | Promise - onFileChange?: (e: SandboxFileEvent) => void | Promise - onFileDelete?: (e: SandboxFileEvent) => void | Promise + onFile?: (e: SandboxFileHookEvent) => void | Promise + onFileCreate?: (e: SandboxFileHookEvent) => void | Promise + onFileChange?: (e: SandboxFileHookEvent) => void | Promise + onFileDelete?: (e: SandboxFileHookEvent) => void | Promise onReady?: (handle: SandboxHandle) => void | Promise onError?: (err: unknown) => void | Promise onDestroy?: () => void | Promise @@ -59,8 +59,9 @@ export interface SandboxConfig { lifecycle?: SandboxLifecycle /** Sandbox-scoped file/lifecycle hooks. */ hooks?: SandboxHooks - /** Watch the workspace for file events (default true). Set false to disable. */ - fileEvents?: boolean + /** Watch the workspace for file events (default true). `false` disables the + * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */ + fileEvents?: boolean | { diff?: boolean } } /** Context passed to `ensure()` by `withSandbox` (or advanced callers). */ @@ -83,8 +84,9 @@ export interface SandboxDefinition { readonly lifecycle?: SandboxLifecycle /** Sandbox-scoped file/lifecycle hooks. */ readonly hooks?: SandboxHooks - /** Watch the workspace for file events (default true). Set false to disable. */ - readonly fileEvents?: boolean + /** Watch the workspace for file events (default true). `false` disables the + * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */ + readonly fileEvents?: boolean | { diff?: boolean } /** Compound instance key for a given run context. */ key: (ctx: SandboxEnsureContext) => string /** Resume-or-create the sandbox for this thread/run. */ diff --git a/packages/ai-sandbox/src/watch.ts b/packages/ai-sandbox/src/watch.ts index bf38e3b65..71743ae29 100644 --- a/packages/ai-sandbox/src/watch.ts +++ b/packages/ai-sandbox/src/watch.ts @@ -75,16 +75,28 @@ export function diffSnapshots( return events } -/** Build the `find` command that prints `mtime\tsize\tpath` for every file. */ -function buildFindCommand(root: string, ignore: Array): string { +/** + * Build the `find` command that prints `mtime\tsize\tpath` for every file. + * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a + * provider's `exec` maps only `cwd` onto the real filesystem, not literal path + * arguments, so `find ` would look at a non-existent host path on + * mapped-root providers (e.g. local-process). Emitted `%p` values are + * root-normalized in {@link parseFindOutput}. + */ +function buildFindCommand(ignore: Array): string { const prunes = ignore .map((entry) => `-not -path ${q(`*/${entry}/*`)}`) .join(' ') - return `find ${q(root)} -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'` + return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'` } -/** Parse `find -printf` output into a `Map`. */ -function parseFindOutput(stdout: string): Map { +/** + * Parse `find -printf` output into a `Map`. `find .` prints + * paths like `./sub/file`; map them back under `root` so event paths match the + * native-watch shape (`/sub/file`). + */ +function parseFindOutput(stdout: string, root: string): Map { + const base = root.replace(/\/+$/, '') const snapshot = new Map() for (const line of stdout.split('\n')) { if (line === '') continue @@ -93,7 +105,8 @@ function parseFindOutput(stdout: string): Map { if (firstTab === -1 || secondTab === -1) continue const mtime = line.slice(0, firstTab) const size = line.slice(firstTab + 1, secondTab) - const path = line.slice(secondTab + 1) + const rel = line.slice(secondTab + 1).replace(/^\.\/?/, '') + const path = rel === '' ? base : `${base}/${rel}` snapshot.set(path, `${mtime}\t${size}`) } return snapshot @@ -180,7 +193,7 @@ async function startPollWatch( }, ): Promise { const { onEvent, root, ignore, intervalMs } = options - const command = buildFindCommand(root, ignore) + const command = buildFindCommand(ignore) const controller = new AbortController() const snapshot = async (): Promise> => { @@ -189,7 +202,7 @@ async function startPollWatch( signal: controller.signal, }) return result.exitCode === 0 - ? parseFindOutput(result.stdout) + ? parseFindOutput(result.stdout, root) : new Map() } diff --git a/packages/ai-sandbox/tests/file-diff.test.ts b/packages/ai-sandbox/tests/file-diff.test.ts new file mode 100644 index 000000000..217949010 --- /dev/null +++ b/packages/ai-sandbox/tests/file-diff.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { buildFileHookEvent } from '../src/file-diff' +import type { SandboxHandle } from '../src/contracts' + +function fakeHandle( + overrides: Partial & { + read?: (p: string) => Promise + }, +): SandboxHandle { + return { + fs: { read: overrides.read ?? (async () => 'AFTER') }, + process: { + exec: + overrides.exec ?? + (async () => ({ stdout: '', stderr: '', exitCode: 0 })), + }, + } as unknown as SandboxHandle +} + +describe('buildFileHookEvent', () => { + it('after() reads current content; delete → empty', async () => { + const h = fakeHandle({ read: async () => 'HELLO' }) + const created = buildFileHookEvent(h, '/workspace', 'sha1', { + type: 'create', + path: '/workspace/a.ts', + timestamp: 1, + }) + expect(await created.after()).toBe('HELLO') + const deleted = buildFileHookEvent(h, '/workspace', 'sha1', { + type: 'delete', + path: '/workspace/a.ts', + timestamp: 1, + }) + expect(await deleted.after()).toBe('') + }) + + it('before() runs `git show :` and returns "" on non-zero exit', async () => { + const calls: Array = [] + const h = fakeHandle({ + exec: async (cmd: string) => { + calls.push(cmd) + return { stdout: 'OLD', stderr: '', exitCode: 0 } + }, + }) + const e = buildFileHookEvent(h, '/workspace', 'sha1', { + type: 'change', + path: '/workspace/src/a.ts', + timestamp: 1, + }) + expect(await e.before()).toBe('OLD') + expect(calls[0]).toBe("git show 'sha1':'src/a.ts'") + + const missing = buildFileHookEvent( + fakeHandle({ + exec: async () => ({ stdout: '', stderr: 'x', exitCode: 128 }), + }), + '/workspace', + 'sha1', + { type: 'change', path: '/workspace/src/a.ts', timestamp: 1 }, + ) + expect(await missing.before()).toBe('') + }) + + it('diff() with empty base synthesizes an add-patch from after()', async () => { + const e = buildFileHookEvent( + fakeHandle({ read: async () => 'line1\n' }), + '/workspace', + '', + { type: 'create', path: '/workspace/a.ts', timestamp: 1 }, + ) + const patch = await e.diff() + expect(patch).toContain('+line1') + }) + + it('diff() with empty base and a delete event resolves to "" (no bogus add-patch)', async () => { + const e = buildFileHookEvent( + fakeHandle({ read: async () => 'AFTER' }), + '/workspace', + '', + { type: 'delete', path: '/workspace/a.ts', timestamp: 1 }, + ) + expect(await e.diff()).toBe('') + }) + + it('diff() with a base runs `git diff -- `', async () => { + const calls: Array = [] + const h = fakeHandle({ + exec: async (cmd: string) => { + calls.push(cmd) + return { stdout: 'DIFF', stderr: '', exitCode: 0 } + }, + }) + const e = buildFileHookEvent(h, '/workspace', 'sha1', { + type: 'change', + path: '/workspace/a.ts', + timestamp: 1, + }) + expect(await e.diff()).toBe('DIFF') + // Pathspec is relativized (like before()'s `git show`) — a bare leading + // `/` is resolved by git against the filesystem root, not the repo root, + // and would fail with "fatal: Invalid path" once `root` isn't literally + // `/workspace` on disk (e.g. every local-process sandbox). + expect(calls[0]).toBe("git diff 'sha1' -- 'a.ts'") + }) + + it('diff() relativizes a nested path the same way before() does', async () => { + const calls: Array = [] + const h = fakeHandle({ + exec: async (cmd: string) => { + calls.push(cmd) + return { stdout: 'DIFF', stderr: '', exitCode: 0 } + }, + }) + const e = buildFileHookEvent(h, '/workspace', 'sha1', { + type: 'change', + path: '/workspace/src/a.ts', + timestamp: 1, + }) + expect(await e.diff()).toBe('DIFF') + expect(calls[0]).toBe("git diff 'sha1' -- 'src/a.ts'") + }) +}) diff --git a/packages/ai-sandbox/tests/watch.test.ts b/packages/ai-sandbox/tests/watch.test.ts index 3b7dc620b..8e748b1b9 100644 --- a/packages/ai-sandbox/tests/watch.test.ts +++ b/packages/ai-sandbox/tests/watch.test.ts @@ -80,10 +80,10 @@ describe('watchWorkspace (exec-poll)', () => { it('diffs successive `find` snapshots into file events', async () => { vi.useFakeTimers() const snapshots = [ - // initial - '1.0\t10\t/workspace/a.js\n2.0\t20\t/workspace/b.js\n', + // initial — `find .` prints cwd-relative paths (normalized back under root) + '1.0\t10\t./a.js\n2.0\t20\t./b.js\n', // after the agent edits b, adds c, removes a - '2.5\t25\t/workspace/b.js\n3.0\t30\t/workspace/c.js\n', + '2.5\t25\t./b.js\n3.0\t30\t./c.js\n', ] let call = 0 const handle = fakeHandle({}) diff --git a/packages/ai-sandbox/tests/with-sandbox-hooks.test.ts b/packages/ai-sandbox/tests/with-sandbox-hooks.test.ts index b685b9a64..d941496f5 100644 --- a/packages/ai-sandbox/tests/with-sandbox-hooks.test.ts +++ b/packages/ai-sandbox/tests/with-sandbox-hooks.test.ts @@ -51,6 +51,69 @@ function fakeHandleAndFire(present: Set) { return { handle, fire: (e: { type: string; path: string }) => onRaw(e) } } +// Fake handle whose `fs.list` seeds the watcher's known-path set (so the +// first fired event classifies as 'change', not 'create') and whose +// `process.exec` resolves per-command so `baseSha` capture + `git diff` both +// succeed (buildFileHookEvent's diff() falls back to '' on a rejected exec). +function fakeHandleWithGit( + knownPath: string, + execResults: Record< + string, + { stdout: string; stderr: string; exitCode: number } + >, +) { + let onRaw: (e: { type: string; path: string }) => void = () => undefined + const handle: SandboxHandle = { + id: 'fake', + provider: 'fake', + capabilities: { + fs: true, + exec: true, + env: true, + ports: false, + backgroundProcesses: false, + writableStdin: true, + snapshots: false, + networkPolicy: false, + durableFilesystem: false, + fork: false, + }, + fs: { + read: () => Promise.resolve('AFTER'), + readBytes: () => Promise.reject(new Error('x')), + write: () => Promise.resolve(), + list: (dir) => + Promise.resolve( + dir === '/workspace' + ? [{ name: 'x.ts', path: knownPath, type: 'file' as const }] + : [], + ), + mkdir: () => Promise.resolve(), + remove: () => Promise.resolve(), + rename: () => Promise.resolve(), + exists: () => Promise.resolve(true), + watch: (_p, cb) => { + onRaw = cb + return Promise.resolve({ stop: () => Promise.resolve() }) + }, + }, + git: {} as SandboxHandle['git'], + process: { + exec: (cmd: string) => { + const key = Object.keys(execResults).find((k) => cmd.startsWith(k)) + return Promise.resolve( + key ? execResults[key]! : { stdout: '', stderr: '', exitCode: 0 }, + ) + }, + spawn: () => Promise.reject(new Error('x')), + }, + ports: { connect: () => Promise.reject(new Error('x')) }, + env: { set: () => Promise.resolve() }, + destroy: () => Promise.resolve(), + } + return { handle, fire: (e: { type: string; path: string }) => onRaw(e) } +} + function fakeProvider(handle: SandboxHandle): SandboxProvider { return { name: 'fake', @@ -89,6 +152,7 @@ describe('withSandbox hooks', () => { provideSandboxRuntime(ctx, { logger: resolveDebugOption(false), emit: (e) => void emitted.push(e), + emitFileDiff: () => undefined, }) const mw = withSandbox(sandbox) @@ -116,6 +180,7 @@ describe('withSandbox hooks', () => { provideSandboxRuntime(ctx, { logger: resolveDebugOption(false), emit: (e) => void emitted.push(e), + emitFileDiff: () => undefined, }) const mw = withSandbox(sandbox) await mw.setup!(ctx) @@ -124,4 +189,65 @@ describe('withSandbox hooks', () => { expect(emitted).toEqual([]) await mw.onFinish!(ctx, { finishReason: 'stop', duration: 0, content: '' }) }) + + it('emits sandbox.file.diff when fileEvents.diff is enabled', async () => { + const path = '/workspace/x.ts' + const { handle, fire } = fakeHandleWithGit(path, { + 'git rev-parse HEAD': { stdout: 'sha1\n', stderr: '', exitCode: 0 }, + 'git diff': { stdout: 'PATCH', stderr: '', exitCode: 0 }, + }) + const diffs: Array<{ path: string; diff: string }> = [] + const sandbox = defineSandbox({ + id: 's', + provider: fakeProvider(handle), + fileEvents: { diff: true }, + }) + + const ctx = makeCtx() + provideSandboxRuntime(ctx, { + logger: resolveDebugOption(false), + emit: () => undefined, + emitFileDiff: (v) => void diffs.push(v), + }) + + const mw = withSandbox(sandbox) + await mw.setup!(ctx) + + fire({ type: 'change', path }) + await flush() + + expect(diffs).toEqual([{ path, diff: 'PATCH' }]) + + await mw.onFinish!(ctx, { finishReason: 'stop', duration: 0, content: '' }) + }) + + it('does not emit sandbox.file.diff for a plain fileEvents (default/true)', async () => { + const path = '/workspace/x.ts' + const { handle, fire } = fakeHandleWithGit(path, { + 'git rev-parse HEAD': { stdout: 'sha1\n', stderr: '', exitCode: 0 }, + 'git diff': { stdout: 'PATCH', stderr: '', exitCode: 0 }, + }) + const diffs: Array<{ path: string; diff: string }> = [] + const sandbox = defineSandbox({ + id: 's', + provider: fakeProvider(handle), + }) + + const ctx = makeCtx() + provideSandboxRuntime(ctx, { + logger: resolveDebugOption(false), + emit: () => undefined, + emitFileDiff: (v) => void diffs.push(v), + }) + + const mw = withSandbox(sandbox) + await mw.setup!(ctx) + + fire({ type: 'change', path }) + await flush() + + expect(diffs).toEqual([]) + + await mw.onFinish!(ctx, { finishReason: 'stop', duration: 0, content: '' }) + }) }) diff --git a/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md b/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md index 3b798bcb5..5b9af9820 100644 --- a/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md +++ b/packages/ai/skills/ai-core/ag-ui-protocol/SKILL.md @@ -13,6 +13,7 @@ sources: - 'TanStack/ai:docs/protocol/chunk-definitions.md' - 'TanStack/ai:docs/protocol/sse-protocol.md' - 'TanStack/ai:docs/protocol/http-stream-protocol.md' + - 'TanStack/ai:docs/protocol/custom-events.md' --- # AG-UI Protocol @@ -218,6 +219,62 @@ RUN_STARTED -> TEXT_MESSAGE_START -> TEXT_MESSAGE_CONTENT* -> TEXT_MESSAGE_END union of all event interfaces). `StreamChunkType` is an alias for `AGUIEventType` (the string union of all event type literals). +### 4. Typed CUSTOM Events — `ChatStream` and `KnownCustomEvent` + +The `CUSTOM` row above describes the raw `StreamChunk` union, where the single +generic `CustomEvent` member types `value` as `any` -- once merged into a +union, that `any` poisons every other member too, so narrowing on `name` +still leaves `value: any`. `chat()` doesn't return raw `StreamChunk`; by +default (no `outputSchema`, `stream` not explicitly `false`) it returns +`ChatStream`, which swaps that generic member for `KnownCustomEvent` -- a +discriminated union of every `CUSTOM` event TanStack AI itself emits, each +with a literal `name` and a concrete `value`. Narrow with a plain `if` -- +no helper, no cast: + +```typescript +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' + +const stream = chat({ + adapter: openaiText('gpt-5.2'), + messages, +}) + +for await (const chunk of stream) { + if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file.diff') { + console.log(chunk.value.path, chunk.value.diff) // typed, no helper, no cast + } else if ( + chunk.type === 'CUSTOM' && + chunk.name === 'structured-output.complete' + ) { + console.log(chunk.value.object) // typed, no helper, no cast + } +} +``` + +**Caveat -- `.endsWith()` (or any non-literal check) does not narrow.** +`SessionIdEvent['name']` is the template-literal type +`` `${string}.session-id` ``. TypeScript's control-flow narrowing only +understands exact comparisons (`===`) and `in`/type-predicate checks against +a discriminant -- a runtime `chunk.name.endsWith('.session-id')` check +doesn't inform the type system, so `chunk.value` stays the union of every +`KnownCustomEvent`'s `value`, not `{ sessionId: string }`. Compare against +the exact literal you expect, or write a user-defined type predicate +(`(c): c is SessionIdEvent => c.name.endsWith('.session-id')`) and call that +in the `if` instead. + +**User-emitted `emitCustomEvent` names are typed out of `ChatStream`.** Tools +that call `context.emitCustomEvent('my-app:progress', ...)` still stream a +`CUSTOM` chunk at runtime, but `'my-app:progress'` isn't one of +`KnownCustomEvent`'s literal names, so it's intentionally absent from +`ChatStream`'s type -- including a generic fallback member would reintroduce +the `value: any` poison for every other event on the stream. To read your own +event with a type, annotate the stream as the wider `StreamChunk` instead of +`ChatStream` for that branch; its generic `CUSTOM` member already types +`value` as `any`, so no cast is needed there either. + +Source: docs/protocol/custom-events.md + ## Common Mistakes ### MEDIUM: Proxy buffering breaks SSE streaming @@ -273,3 +330,5 @@ without transformation. See `docs/migration/ag-ui-compliance.md` for details. ## Cross-References - See also: `ai-core/custom-backend-integration/SKILL.md` -- Custom backends must implement SSE or HTTP stream format to work with TanStack AI client connection adapters. +- See also: `ai-core/middleware/SKILL.md` -- `sandbox.file.diff`'s `{ path, diff }` value (one of `KnownCustomEvent`'s members) is populated from the same lazy `before()`/`after()`/`diff()` accessors documented there for `onFile*` middleware hooks. +- Full CUSTOM event taxonomy: `docs/protocol/custom-events.md`. diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index f5884a3d3..16fc91413 100644 --- a/packages/ai/skills/ai-core/middleware/SKILL.md +++ b/packages/ai/skills/ai-core/middleware/SKILL.md @@ -11,6 +11,7 @@ library: tanstack-ai library_version: '0.10.0' sources: - 'TanStack/ai:docs/advanced/middleware.md' + - 'TanStack/ai:docs/sandbox/observability.md' --- # Middleware @@ -371,6 +372,95 @@ Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default `keyFn` (custom cache key), `storage` (custom backend like Redis). See `docs/advanced/middleware.md` for custom storage examples. +## Sandbox File-Event Hooks (`sandbox` group) + +Declare a `sandbox: ChatSandboxHooks` group on `defineChatMiddleware` to react +to every file created/changed/deleted inside a sandbox provided by +`withSandbox` (from `@tanstack/ai-sandbox`). These fire **per-run**, +server-side, and each handler receives the run's `ChatMiddlewareContext` as +the first argument: + +```typescript +import { defineChatMiddleware } from '@tanstack/ai' +import { db } from './db' + +const auditMiddleware = defineChatMiddleware({ + name: 'audit', + sandbox: { + onFile: (ctx, e) => console.log(ctx.runId, e.type, e.path), + onFileCreate: (ctx, e) => db.log({ run: ctx.runId, event: e }), + }, +}) +``` + +| Hook | Fires for | +| -------------- | -------------------------- | +| `onFile` | Every create/change/delete | +| `onFileCreate` | File creates only | +| `onFileChange` | File changes only | +| `onFileDelete` | File deletes only | + +These are independent of the stream: the engine also emits a `sandbox.file` +`CUSTOM` chunk per change regardless of whether any `sandbox` hooks are +registered, so a client can react to the same edits without middleware. See +`ai-core/ag-ui-protocol/SKILL.md` for reading that chunk (and the opt-in +`sandbox.file.diff` chunk) off `ChatStream`. + +### `before()` / `after()` / `diff()` — lazy, git-backed content accessors + +Each hook receives a `SandboxFileHookEvent`: the serializable +`{ type, path, timestamp }` plus three lazy accessors for the file's content: + +```ts +interface SandboxFileHookEvent { + type: 'create' | 'change' | 'delete' + path: string + timestamp: number + before(): Promise // content at the session baseline ('' if new / non-git) + after(): Promise // current content ('' if deleted) + diff(): Promise // unified patch vs the baseline +} +``` + +```typescript +import { defineChatMiddleware } from '@tanstack/ai' +import { db } from './db' + +const auditMiddleware = defineChatMiddleware({ + name: 'audit', + sandbox: { + onFileChange: async (ctx, e) => { + const [before, after] = await Promise.all([e.before(), e.after()]) + db.log({ run: ctx.runId, path: e.path, before, after }) + }, + }, +}) +``` + +**Lazy — path-only hooks pay nothing.** `before()`, `after()`, and `diff()` +are methods, not fields: each only reads the file or shells out to `git` when +called. A hook that only reads `e.path`/`e.type` (like the `onFile` logger +above) never touches the filesystem or spawns a process. + +**Git session baseline.** The sandbox snapshots `git rev-parse HEAD` once at +setup as the session baseline (empty string if the workspace isn't a git repo +or has no commits). `before()` and `diff()` always diff against that same +fixed baseline for the rest of the run, so `onFileChange` reports the file's +**cumulative** change since the run started, not just the delta since the +last poll. `after()` always reads current on-disk content. None of the three +accessors throw: a deleted file resolves `after()` to `''` (it still has +`before()`); a new file resolves `before()` to `''` (it still has `after()`); +a non-git workspace resolves **both** `before()` and `after()` to `''` and +makes `diff()` fall back to a synthesized add-patch built from `after()` — +except for a `delete` event in a non-git workspace, where there's nothing to +synthesize and `diff()` resolves to `''`. + +**Hook errors are swallowed per hook.** A throwing `sandbox` hook is caught +and logged under the `sandbox` debug category — it cannot break the run or +stop other hooks (or the `sandbox.file` chunk) from continuing. + +Source: docs/sandbox/observability.md + ## Common Mistakes ### a. MEDIUM: Trying to modify StreamChunks in middleware @@ -451,3 +541,4 @@ Source: docs/advanced/middleware.md - See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle - See also: **ai-core/structured-outputs/SKILL.md** -- Middleware now wraps the final structured-output call; use `onStructuredOutputConfig` for JSON-Schema transforms +- See also: **ai-core/ag-ui-protocol/SKILL.md** -- Reading the `sandbox.file` / `sandbox.file.diff` `CUSTOM` chunks the sandbox runtime emits alongside these `sandbox` hooks, via `ChatStream`'s typed `KnownCustomEvent` narrowing diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index b032e1646..93e7a6481 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -45,6 +45,7 @@ import type { import type { AgentLoopStrategy, AnyTool, + ChatStream, ConstrainedModelMessage, CustomEvent, InferSchemaType, @@ -69,7 +70,7 @@ import type { ChatMiddleware, ChatMiddlewareConfig, ChatMiddlewareContext, - SandboxFileEvent, + SandboxFileHookEvent, StructuredOutputMiddlewareConfig, } from './middleware/types' import type { CheckCoverage } from './middleware/builder' @@ -404,7 +405,7 @@ export type TextActivityResult< : Promise> : [TStream] extends [false] ? Promise - : AsyncIterable + : ChatStream // =========================== // ChatEngine Implementation @@ -712,11 +713,30 @@ class TextEngine< // a `sandbox.file` custom chunk to be drained into the public stream. provideSandboxRuntime(this.middlewareCtx, { logger: this.logger, - emit: (event: SandboxFileEvent) => { - this.logger.sandbox(`file ${event.type} ${event.path}`, { event }) - void this.middlewareRunner.runSandboxFile(this.middlewareCtx, event) + emit: (event: SandboxFileHookEvent) => { + this.logger.sandbox(`file ${event.type} ${event.path}`, { + event: { + type: event.type, + path: event.path, + timestamp: event.timestamp, + }, + }) + void this.middlewareRunner + .runSandboxFile(this.middlewareCtx, event) + .catch((err: unknown) => { + this.logger.errors('sandbox file hook failed', { error: err }) + }) + this.sandboxFileQueue.push( + this.createCustomEventChunk('sandbox.file', { + type: event.type, + path: event.path, + timestamp: event.timestamp, + }), + ) + }, + emitFileDiff: (value: { path: string; diff: string }) => { this.sandboxFileQueue.push( - this.createCustomEventChunk('sandbox.file', { ...event }), + this.createCustomEventChunk('sandbox.file.diff', value), ) }, }) diff --git a/packages/ai/src/activities/chat/middleware/compose.ts b/packages/ai/src/activities/chat/middleware/compose.ts index cdd4ca552..9d449ad00 100644 --- a/packages/ai/src/activities/chat/middleware/compose.ts +++ b/packages/ai/src/activities/chat/middleware/compose.ts @@ -11,7 +11,7 @@ import type { ErrorInfo, FinishInfo, IterationInfo, - SandboxFileEvent, + SandboxFileHookEvent, StructuredOutputMiddlewareConfig, ToolCallHookContext, ToolPhaseCompleteInfo, @@ -352,7 +352,7 @@ export class MiddlewareRunner { */ async runSandboxFile( ctx: ChatMiddlewareContext, - event: SandboxFileEvent, + event: SandboxFileHookEvent, ): Promise { const typed = ( { diff --git a/packages/ai/src/activities/chat/middleware/index.ts b/packages/ai/src/activities/chat/middleware/index.ts index b45333234..0de3edba2 100644 --- a/packages/ai/src/activities/chat/middleware/index.ts +++ b/packages/ai/src/activities/chat/middleware/index.ts @@ -14,6 +14,7 @@ export type { AbortInfo, ErrorInfo, SandboxFileEvent, + SandboxFileHookEvent, ChatSandboxHooks, } from './types' diff --git a/packages/ai/src/activities/chat/middleware/sandbox-runtime.ts b/packages/ai/src/activities/chat/middleware/sandbox-runtime.ts index bf1a85070..0653feee8 100644 --- a/packages/ai/src/activities/chat/middleware/sandbox-runtime.ts +++ b/packages/ai/src/activities/chat/middleware/sandbox-runtime.ts @@ -7,10 +7,12 @@ */ import { createCapability } from './capabilities' import type { InternalLogger } from '../../../logger/internal-logger' -import type { SandboxFileEvent } from './types' +import type { SandboxFileHookEvent } from './types' export interface SandboxRuntime { - emit: (event: SandboxFileEvent) => void + emit: (event: SandboxFileHookEvent) => void + /** Emit an opt-in per-file `sandbox.file.diff` CUSTOM chunk. */ + emitFileDiff: (value: { path: string; diff: string }) => void logger: InternalLogger } diff --git a/packages/ai/src/activities/chat/middleware/types.ts b/packages/ai/src/activities/chat/middleware/types.ts index 85defecf6..74502b5c7 100644 --- a/packages/ai/src/activities/chat/middleware/types.ts +++ b/packages/ai/src/activities/chat/middleware/types.ts @@ -21,6 +21,19 @@ export interface SandboxFileEvent { timestamp: number } +/** The file event a sandbox hook receives: the serializable {@link SandboxFileEvent} + * plus lazy, git-backed content accessors. Accessors compute on call, so a hook + * that only reads `path`/`type` pays nothing. Never present on the serialized + * `sandbox.file` CUSTOM chunk. */ +export interface SandboxFileHookEvent extends SandboxFileEvent { + /** Content at the session baseline (`''` for a new file or non-git workspace). */ + before: () => Promise + /** Current content (`''` when the event is a delete). */ + after: () => Promise + /** Unified patch vs the session baseline (synthesized add-patch when non-git). */ + diff: () => Promise +} + /** * Sandbox file-event hooks a chat middleware can declare. Fire server-side for * every file create/change/delete observed in the sandbox during the run. @@ -28,19 +41,19 @@ export interface SandboxFileEvent { export interface ChatSandboxHooks { onFile?: ( ctx: ChatMiddlewareContext, - e: SandboxFileEvent, + e: SandboxFileHookEvent, ) => void | Promise onFileCreate?: ( ctx: ChatMiddlewareContext, - e: SandboxFileEvent, + e: SandboxFileHookEvent, ) => void | Promise onFileChange?: ( ctx: ChatMiddlewareContext, - e: SandboxFileEvent, + e: SandboxFileHookEvent, ) => void | Promise onFileDelete?: ( ctx: ChatMiddlewareContext, - e: SandboxFileEvent, + e: SandboxFileHookEvent, ) => void | Promise } diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 1c59cc4f4..194e04729 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -121,6 +121,7 @@ export type { AbortInfo, ErrorInfo, SandboxFileEvent, + SandboxFileHookEvent, ChatSandboxHooks, } from './activities/chat/middleware/index' diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 128c6779f..11fddc172 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1381,6 +1381,105 @@ export interface UIResourceEvent extends CustomEvent { } } +// ── Sandbox events ────────────────────────────────────────────────────────── +export interface SandboxFileCustomEvent extends CustomEvent { + name: 'sandbox.file' + value: { + type: 'create' | 'change' | 'delete' + path: string + timestamp: number + } +} +export interface SandboxFileDiffEvent extends CustomEvent { + name: 'sandbox.file.diff' + value: { path: string; diff: string } +} + +// ── Harness events ────────────────────────────────────────────────────────── +export interface FileChangedEvent extends CustomEvent { + name: 'file.changed' + value: { path: string; diff: string } +} +export interface SessionIdEvent extends CustomEvent { + name: `${string}.session-id` + value: { sessionId: string } +} + +// ── Code-mode events ──────────────────────────────────────────────────────── +export interface CodeModeExecutionStartedEvent extends CustomEvent { + name: 'code_mode:execution_started' + value: { timestamp: number; codeLength: number } +} +export interface CodeModeConsoleEvent extends CustomEvent { + name: 'code_mode:console' + value: { + level: 'log' | 'warn' | 'error' | 'info' + message: string + timestamp: number + } +} +export interface CodeModeExternalCallEvent extends CustomEvent { + name: 'code_mode:external_call' + value: { function: string; args: unknown; timestamp: number } +} +export interface CodeModeExternalResultEvent extends CustomEvent { + name: 'code_mode:external_result' + value: { function: string; result: unknown; duration: number } +} +export interface CodeModeExternalErrorEvent extends CustomEvent { + name: 'code_mode:external_error' + value: { function: string; error: string; duration: number } +} +export interface CodeModeSkillCallEvent extends CustomEvent { + name: 'code_mode:skill_call' + value: { skill: string; input: unknown; timestamp: number } +} +export interface CodeModeSkillResultEvent extends CustomEvent { + name: 'code_mode:skill_result' + value: { skill: string; result: unknown; duration: number; timestamp: number } +} +export interface CodeModeSkillErrorEvent extends CustomEvent { + name: 'code_mode:skill_error' + value: { skill: string; error: string; duration: number; timestamp: number } +} +export interface SkillRegisteredEvent extends CustomEvent { + name: 'skill:registered' + value: { id: string; name: string; description: string; timestamp: number } +} + +/** + * Every CUSTOM event TanStack AI itself emits, as a discriminated union on + * `name`. User-emitted custom events (via `emitCustomEvent` with a custom name) + * are intentionally absent — they still flow at runtime. + */ +export type KnownCustomEvent = + | SandboxFileCustomEvent + | SandboxFileDiffEvent + | FileChangedEvent + | SessionIdEvent + | CodeModeExecutionStartedEvent + | CodeModeConsoleEvent + | CodeModeExternalCallEvent + | CodeModeExternalResultEvent + | CodeModeExternalErrorEvent + | CodeModeSkillCallEvent + | CodeModeSkillResultEvent + | CodeModeSkillErrorEvent + | SkillRegisteredEvent + | StructuredOutputStartEvent + | StructuredOutputCompleteEvent + | ApprovalRequestedEvent + | ToolInputAvailableEvent + | UIResourceEvent + +/** The default chat streaming result: standard chunks plus every typed + * framework CUSTOM event, with the `value: any` catch-all excluded so + * literal-`name` narrowing types `value`. User-emitted custom names are typed + * out (still flow at runtime — branch outside the name narrows or cast). */ +export type ChatStream = AsyncIterable< + Exclude | KnownCustomEvent +> + /** * Public type for streams returned by `chat({ outputSchema, stream: true })`. * diff --git a/packages/ai/tests/chat-result-types.test.ts b/packages/ai/tests/chat-result-types.test.ts index 2e8ad1aeb..ce2259d44 100644 --- a/packages/ai/tests/chat-result-types.test.ts +++ b/packages/ai/tests/chat-result-types.test.ts @@ -11,6 +11,7 @@ import { z } from 'zod' import type { StandardJSONSchemaV1 } from '@standard-schema/spec' import type { TextActivityResult } from '../src/activities/chat' import type { + ChatStream, InferSchemaType, StreamChunk, StructuredOutputStream, @@ -88,10 +89,10 @@ describe('chat() return type', () => { }) describe('without outputSchema', () => { - it('stream: true → AsyncIterable', () => { - expectTypeOf>().toEqualTypeOf< - AsyncIterable - >() + it('stream: true → ChatStream', () => { + expectTypeOf< + TextActivityResult + >().toEqualTypeOf() }) it('stream: false → Promise', () => { @@ -100,10 +101,8 @@ describe('chat() return type', () => { >() }) - it('default stream (boolean) → AsyncIterable', () => { - expectTypeOf>().toEqualTypeOf< - AsyncIterable - >() + it('default stream (boolean) → ChatStream', () => { + expectTypeOf>().toEqualTypeOf() }) }) }) diff --git a/packages/ai/tests/known-custom-events.test-d.ts b/packages/ai/tests/known-custom-events.test-d.ts new file mode 100644 index 000000000..adf0cd05e --- /dev/null +++ b/packages/ai/tests/known-custom-events.test-d.ts @@ -0,0 +1,33 @@ +import { expectTypeOf } from 'vitest' +import type { ChatStream, KnownCustomEvent, SessionIdEvent } from '../src/types' + +// A KnownCustomEvent narrowed by literal name yields the concrete value. +declare const ev: KnownCustomEvent +if (ev.name === 'sandbox.file.diff') { + expectTypeOf(ev.value).toEqualTypeOf<{ path: string; diff: string }>() +} +if (ev.name === 'code_mode:console') { + expectTypeOf(ev.value.level).toEqualTypeOf< + 'log' | 'warn' | 'error' | 'info' + >() +} +// `String.prototype.endsWith` is not a TS control-flow narrowing construct +// (unlike `===`, `in`, `typeof`, `instanceof`), so a plain +// `ev.name.endsWith('.session-id')` guard does not narrow the union — a +// user-defined type predicate is required to prove the template-literal +// member narrows correctly. +function isSessionIdEvent(e: KnownCustomEvent): e is SessionIdEvent { + return e.name.endsWith('.session-id') +} +if (isSessionIdEvent(ev)) { + expectTypeOf(ev.value).toEqualTypeOf<{ sessionId: string }>() +} + +async function narrowsOnRealStream(stream: ChatStream) { + for await (const chunk of stream) { + if (chunk.type === 'CUSTOM' && chunk.name === 'sandbox.file') { + expectTypeOf(chunk.value.path).toEqualTypeOf() + } + } +} +void narrowsOnRealStream diff --git a/packages/ai/tests/sandbox-file-dispatch.test.ts b/packages/ai/tests/sandbox-file-dispatch.test.ts index ddab55753..285bc1f66 100644 --- a/packages/ai/tests/sandbox-file-dispatch.test.ts +++ b/packages/ai/tests/sandbox-file-dispatch.test.ts @@ -4,14 +4,17 @@ import { resolveDebugOption } from '../src/logger/resolve' import type { ChatMiddleware, ChatMiddlewareContext, - SandboxFileEvent, + SandboxFileHookEvent, } from '../src/activities/chat/middleware/types' const ctx = {} as ChatMiddlewareContext -const ev = (type: SandboxFileEvent['type']): SandboxFileEvent => ({ +const ev = (type: SandboxFileHookEvent['type']): SandboxFileHookEvent => ({ type, path: `/workspace/a-${type}.ts`, timestamp: 1, + before: async () => '', + after: async () => '', + diff: async () => '', }) describe('MiddlewareRunner.runSandboxFile', () => { diff --git a/packages/ai/tests/sandbox-runtime-emit.test.ts b/packages/ai/tests/sandbox-runtime-emit.test.ts index 33d80d753..e18983d22 100644 --- a/packages/ai/tests/sandbox-runtime-emit.test.ts +++ b/packages/ai/tests/sandbox-runtime-emit.test.ts @@ -5,31 +5,33 @@ import { EventType } from '../src/types' import type { ChatMiddleware, ChatMiddlewareContext, - SandboxFileEvent, + SandboxFileHookEvent, } from '../src/activities/chat/middleware/types' import type { StreamChunk } from '../src/types' -// Mirrors the engine sink built in index.ts (Step 5) so we can unit-test the -// contract: emit() runs middleware sandbox hooks AND enqueues a CUSTOM chunk. +// Mirrors the engine sink built in index.ts (Step 6) so we can unit-test the +// contract: emit() runs middleware sandbox hooks with the enriched event AND +// enqueues a CUSTOM chunk built from a plain path-only projection (accessors +// must not serialize onto the wire). function makeSink( runner: MiddlewareRunner, ctx: ChatMiddlewareContext, queue: Array, ) { - return (event: SandboxFileEvent) => { + return (event: SandboxFileHookEvent) => { void runner.runSandboxFile(ctx, event) queue.push({ type: EventType.CUSTOM, name: 'sandbox.file', - value: { ...event }, + value: { type: event.type, path: event.path, timestamp: event.timestamp }, timestamp: event.timestamp, - } as StreamChunk) + }) } } describe('sandbox runtime emit', () => { it('runs middleware sandbox hooks and enqueues a CUSTOM sandbox.file chunk', async () => { - const seen: Array = [] + const seen: Array = [] const mw: ChatMiddleware = { name: 'audit', sandbox: { onFileChange: (_ctx, e) => void seen.push(e) }, @@ -38,10 +40,13 @@ describe('sandbox runtime emit', () => { const queue: Array = [] const sink = makeSink(runner, {} as ChatMiddlewareContext, queue) - const event: SandboxFileEvent = { + const event: SandboxFileHookEvent = { type: 'change', path: '/workspace/x.ts', timestamp: 1, + before: async () => '', + after: async () => '', + diff: async () => '', } sink(event) await Promise.resolve() @@ -51,7 +56,11 @@ describe('sandbox runtime emit', () => { expect(queue[0]).toMatchObject({ type: EventType.CUSTOM, name: 'sandbox.file', - value: { type: 'change', path: '/workspace/x.ts' }, + }) + expect(queue[0]?.value).toEqual({ + type: 'change', + path: '/workspace/x.ts', + timestamp: 1, }) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2abbdf71..5eab53f64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2148,6 +2148,9 @@ importers: packages/ai-sandbox-local-process: devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai '@tanstack/ai-sandbox': specifier: workspace:* version: link:../ai-sandbox