From cd413c4a96bcb24d2d0c4e51ee7caf9decfaeb17 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 24 Aug 2026 15:53:41 -0700 Subject: [PATCH 01/13] feat(ai-compaction): add context-window compaction middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @tanstack/ai-compaction — withCompaction() rewrites messages via the chat() onConfig hook before each model call: keeps the recent tail verbatim, replaces the older head with a summary (when a summarize callback is given) or an eviction marker, and preserves tool-call/result pairing. Includes a panel demo (/compaction) and an e2e wire test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/ai-compaction.md | 10 + packages/ai-compaction/README.md | 66 +++++ packages/ai-compaction/package.json | 50 ++++ packages/ai-compaction/src/index.test.ts | 125 ++++++++ packages/ai-compaction/src/index.ts | 131 +++++++++ packages/ai-compaction/tsconfig.json | 8 + packages/ai-compaction/vite.config.ts | 35 +++ pnpm-lock.yaml | 16 ++ testing/e2e/package.json | 1 + testing/e2e/src/routeTree.gen.ts | 47 ++- testing/e2e/src/routes/api.compaction-wire.ts | 121 ++++++++ testing/e2e/tests/compaction-wire.spec.ts | 30 ++ testing/panel/package.json | 1 + testing/panel/src/components/Header.tsx | 19 ++ testing/panel/src/lib/compaction-store.ts | 27 ++ testing/panel/src/routeTree.gen.ts | 63 ++++ .../panel/src/routes/api.compaction-chat.ts | 119 ++++++++ .../src/routes/api.compaction-inspect.ts | 22 ++ testing/panel/src/routes/compaction.tsx | 272 ++++++++++++++++++ 19 files changed, 1150 insertions(+), 13 deletions(-) create mode 100644 .changeset/ai-compaction.md create mode 100644 packages/ai-compaction/README.md create mode 100644 packages/ai-compaction/package.json create mode 100644 packages/ai-compaction/src/index.test.ts create mode 100644 packages/ai-compaction/src/index.ts create mode 100644 packages/ai-compaction/tsconfig.json create mode 100644 packages/ai-compaction/vite.config.ts create mode 100644 testing/e2e/src/routes/api.compaction-wire.ts create mode 100644 testing/e2e/tests/compaction-wire.spec.ts create mode 100644 testing/panel/src/lib/compaction-store.ts create mode 100644 testing/panel/src/routes/api.compaction-chat.ts create mode 100644 testing/panel/src/routes/api.compaction-inspect.ts create mode 100644 testing/panel/src/routes/compaction.tsx diff --git a/.changeset/ai-compaction.md b/.changeset/ai-compaction.md new file mode 100644 index 0000000000..a54bd71f74 --- /dev/null +++ b/.changeset/ai-compaction.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai-compaction': minor +--- + +Add `@tanstack/ai-compaction` — context-window compaction as a `chat()` +middleware. `withCompaction({ maxTokens })` keeps the recent tail verbatim and +replaces the older head with a single note (a summary when a `summarize` +callback is supplied, otherwise an eviction marker). It runs before every model +call via `onConfig`, so compaction is incremental and rolling, and it preserves +tool-call/result pairing so it never sends an orphaned tool result. diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md new file mode 100644 index 0000000000..d63e20f443 --- /dev/null +++ b/packages/ai-compaction/README.md @@ -0,0 +1,66 @@ +# @tanstack/ai-compaction + +Context-window compaction as a `chat()` middleware. When the working message set +grows past `maxTokens`, it keeps the recent tail verbatim and replaces the older +head with a single note — a **summary** (if you pass `summarize`) or an +**eviction marker**. It runs before every model call, so compaction is +incremental and rolling: a later compaction re-folds the previous summary into +the next one. The system prompt is untouched (`chat()` keeps it separate from +`messages`). + +```bash +npm install @tanstack/ai-compaction +``` + +## Evict (cheapest — no extra model call) + +```ts +import { chat } from '@tanstack/ai' +import { withCompaction } from '@tanstack/ai-compaction' + +chat({ + adapter, + messages, + middleware: [withCompaction({ maxTokens: 100_000 })], +}) +``` + +## Summarize the dropped head + +Pass a `summarize` callback — wire it to a cheap model. + +```ts +import { chat, generate } from '@tanstack/ai' +import { withCompaction } from '@tanstack/ai-compaction' + +const summarize = async (msgs) => { + const { text } = await generate({ + adapter, + messages: [ + ...msgs, + { role: 'user', content: 'Summarize the conversation above in a few sentences.' }, + ], + }) + return text +} + +chat({ + adapter, + messages, + middleware: [withCompaction({ maxTokens: 100_000, summarize })], +}) +``` + +## Options + +| Option | Default | What it does | +|---|---|---| +| `maxTokens` | — (required) | Compact when estimated tokens exceed this. | +| `keepRecentTokens` | `floor(maxTokens / 2)` | Recent tokens always kept verbatim. Must be `< maxTokens`. | +| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | +| `summarize` | — | Summarize the dropped head. Omit to evict with a marker. | +| `summaryRole` | `'user'` | Role of the injected note. | +| `onCompact` | — | Observe each compaction (`before`/`after`/`droppedMessages`/`summarized`). | + +The token estimate is a rough `chars / 4` heuristic — good enough to trigger on, +not exact. Pass `estimateTokens` if you need provider-accurate counts. diff --git a/packages/ai-compaction/package.json b/packages/ai-compaction/package.json new file mode 100644 index 0000000000..3c41c7b4fe --- /dev/null +++ b/packages/ai-compaction/package.json @@ -0,0 +1,50 @@ +{ + "name": "@tanstack/ai-compaction", + "version": "0.0.1", + "description": "Context-window compaction middleware for TanStack AI chat()", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-compaction" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "sideEffects": false, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "tanstack", + "compaction", + "context", + "middleware" + ], + "peerDependencies": { + "@tanstack/ai": "workspace:^" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.1.10" + } +} diff --git a/packages/ai-compaction/src/index.test.ts b/packages/ai-compaction/src/index.test.ts new file mode 100644 index 0000000000..77fbb43ab9 --- /dev/null +++ b/packages/ai-compaction/src/index.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + ChatMiddlewareConfig, + ChatMiddlewareContext, + ModelMessage, + ToolCall, +} from '@tanstack/ai' +import { estimateMessageTokens, withCompaction } from './index' + +// Minimal onConfig driver. The middleware ignores ctx, so a bare stub is fine. +// oxlint-disable-next-line eslint-js/no-restricted-syntax -- test stub; onConfig never reads ctx +const CTX = {} as unknown as ChatMiddlewareContext +function runOnConfig( + mw: ReturnType, + messages: Array, +) { + const config: ChatMiddlewareConfig = { + messages, + systemPrompts: [], + tools: [], + } + return mw.onConfig?.(CTX, config) +} + +const text = (role: ModelMessage['role'], content: string): ModelMessage => ({ + role, + content, +}) +// ~40 tokens each at chars/4. +const big = (role: ModelMessage['role']) => text(role, 'x'.repeat(160)) + +describe('withCompaction', () => { + it('passes through when under the token budget', async () => { + const mw = withCompaction({ maxTokens: 1000 }) + const result = await runOnConfig(mw, [ + text('user', 'hi'), + text('assistant', 'hello'), + ]) + expect(result).toBeUndefined() + }) + + it('evicts the head with a marker when no summarizer is given', async () => { + const mw = withCompaction({ maxTokens: 100, keepRecentTokens: 50 }) + const msgs = [big('user'), big('assistant'), big('user'), big('assistant')] + const result = await runOnConfig(mw, msgs) + expect(result).toBeTruthy() + const out = result?.messages ?? [] + expect(out[0]?.content).toContain('omitted') + // recent tail is preserved verbatim + expect(out[out.length - 1]).toBe(msgs[msgs.length - 1]) + }) + + it('summarizes the head when a summarizer is given', async () => { + const summarize = vi.fn(async () => 'the gist') + const mw = withCompaction({ + maxTokens: 100, + keepRecentTokens: 50, + summarize, + }) + const result = await runOnConfig(mw, [ + big('user'), + big('assistant'), + big('user'), + big('assistant'), + ]) + expect(summarize).toHaveBeenCalledOnce() + expect(result?.messages?.[0]?.content).toBe( + 'Summary of earlier conversation:\nthe gist', + ) + }) + + it('never lets the tail start with an orphaned tool result', async () => { + const call: ToolCall = { + id: 't1', + type: 'function', + function: { name: 'f', arguments: '{}' }, + } + const assistantCall: ModelMessage = { + role: 'assistant', + content: 'x'.repeat(160), + toolCalls: [call], + } + const toolResult: ModelMessage = { + role: 'tool', + content: 'x'.repeat(160), + toolCallId: 't1', + } + const msgs = [big('user'), assistantCall, toolResult, big('user')] + const mw = withCompaction({ maxTokens: 100, keepRecentTokens: 45 }) + const result = await runOnConfig(mw, msgs) + const out = result?.messages ?? [] + // The tool result was folded into the dropped head, so nothing after the + // note is an orphaned tool message. + expect(out.slice(1).some((m) => m.role === 'tool')).toBe(false) + }) + + it('reports before/after via onCompact', async () => { + const onCompact = vi.fn() + const mw = withCompaction({ + maxTokens: 100, + keepRecentTokens: 50, + onCompact, + }) + await runOnConfig(mw, [ + big('user'), + big('assistant'), + big('user'), + big('assistant'), + ]) + expect(onCompact).toHaveBeenCalledOnce() + const info = onCompact.mock.calls[0]?.[0] + expect(info.after).toBeLessThan(info.before) + expect(info.droppedMessages).toBeGreaterThan(0) + }) + + it('rejects keepRecentTokens >= maxTokens', () => { + expect(() => + withCompaction({ maxTokens: 100, keepRecentTokens: 100 }), + ).toThrow() + }) + + it('estimateMessageTokens counts content and tool calls', () => { + expect(estimateMessageTokens(text('user', 'x'.repeat(40)))).toBe(10) + }) +}) diff --git a/packages/ai-compaction/src/index.ts b/packages/ai-compaction/src/index.ts new file mode 100644 index 0000000000..e193039764 --- /dev/null +++ b/packages/ai-compaction/src/index.ts @@ -0,0 +1,131 @@ +/** + * `@tanstack/ai-compaction` — context-window compaction as a `chat()` + * middleware. When the working message set grows past `maxTokens`, it keeps the + * recent tail verbatim and replaces the older head with a single note — either a + * summary (if you pass `summarize`) or an eviction marker. Runs before every + * model call, so compaction is incremental and rolling: a later compaction + * re-folds the previous summary into the next one. + * + * The system prompt is never touched — `chat()` keeps it separate from + * `messages`. + */ +import type { ChatMiddleware, ModelMessage } from '@tanstack/ai' + +/** Rough token estimate for one message. Default: characters / 4. */ +export function estimateMessageTokens(message: ModelMessage): number { + let text = + typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content ?? '') + if (message.toolCalls?.length) text += JSON.stringify(message.toolCalls) + return Math.ceil(text.length / 4) +} + +/** Reported to `onCompact` after each compaction event. */ +export interface CompactionInfo { + /** Estimated tokens before compaction. */ + before: number + /** Estimated tokens after compaction. */ + after: number + /** How many head messages were folded into the note. */ + droppedMessages: number + /** True when a `summarize` callback produced the note. */ + summarized: boolean +} + +export interface CompactionOptions { + /** Compact when estimated tokens across `messages` exceed this. */ + maxTokens: number + /** + * Tokens of the most recent messages to always keep verbatim. + * Default: `floor(maxTokens / 2)`. Must be `< maxTokens`. + */ + keepRecentTokens?: number + /** Per-message token estimator. Default: {@link estimateMessageTokens}. */ + estimateTokens?: (message: ModelMessage) => number + /** + * Summarize the dropped head into prose. Omit to evict (drop) it with a short + * marker instead. Wire this to `summarize()` or any LLM call. + */ + summarize?: (messages: Array) => Promise + /** Role of the injected note. Default `'user'`. */ + summaryRole?: 'user' | 'assistant' + /** Observe each compaction (logging, metrics). */ + onCompact?: (info: CompactionInfo) => void +} + +/** + * Context-compaction middleware. Add to `chat({ middleware: [...] })`. + * + * @example + * ```ts + * chat({ + * adapter, + * messages, + * middleware: [ + * withCompaction({ + * maxTokens: 100_000, + * summarize: (msgs) => summarizeToString(adapter, msgs), + * }), + * ], + * }) + * ``` + */ +export function withCompaction(options: CompactionOptions): ChatMiddleware { + const keepRecentTokens = + options.keepRecentTokens ?? Math.floor(options.maxTokens / 2) + if (keepRecentTokens >= options.maxTokens) { + throw new Error( + `withCompaction: keepRecentTokens (${keepRecentTokens}) must be < maxTokens (${options.maxTokens})`, + ) + } + const estimate = options.estimateTokens ?? estimateMessageTokens + const summaryRole = options.summaryRole ?? 'user' + + return { + name: 'compaction', + async onConfig(_ctx, config) { + const { messages } = config + const sizes = messages.map(estimate) + const total = sizes.reduce((a, b) => a + b, 0) + if (total <= options.maxTokens) return + + // Walk back from the end, keeping recent messages up to keepRecentTokens. + let kept = 0 + let cut = messages.length + while (cut > 0) { + const size = sizes[cut - 1] ?? 0 + if (kept + size > keepRecentTokens) break + kept += size + cut-- + } + // Always keep at least the last message. + if (cut >= messages.length) cut = messages.length - 1 + // Integrity: the tail must not start with an orphaned tool result (its + // matching tool call would be in the dropped head). Fold leading tool + // results back into the head — which becomes prose, so no dangling call. + while (cut < messages.length && messages[cut]?.role === 'tool') cut++ + + const head = messages.slice(0, cut) + // ponytail: can't shrink past the recent window; raise keepRecentTokens + // or lower maxTokens if this fires every turn. + if (head.length === 0) return + const tail = messages.slice(cut) + + const note = options.summarize + ? `Summary of earlier conversation:\n${await options.summarize(head)}` + : `[${head.length} earlier message(s) omitted to save context.]` + const noteMessage: ModelMessage = { role: summaryRole, content: note } + const next = [noteMessage, ...tail] + + options.onCompact?.({ + before: total, + after: next.reduce((a, m) => a + estimate(m), 0), + droppedMessages: head.length, + summarized: Boolean(options.summarize), + }) + + return { messages: next } + }, + } +} diff --git a/packages/ai-compaction/tsconfig.json b/packages/ai-compaction/tsconfig.json new file mode 100644 index 0000000000..29112eff9f --- /dev/null +++ b/packages/ai-compaction/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["vite.config.ts", "./src", "./tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-compaction/vite.config.ts b/packages/ai-compaction/vite.config.ts new file mode 100644 index 0000000000..1f3542380f --- /dev/null +++ b/packages/ai-compaction/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 827b407241..60c9a0a7ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1795,6 +1795,15 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + packages/ai-compaction: + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + packages/ai-devtools: dependencies: '@tanstack/ai': @@ -2944,6 +2953,9 @@ importers: '@tanstack/ai-client': specifier: workspace:* version: link:../../packages/ai-client + '@tanstack/ai-compaction': + specifier: workspace:* + version: link:../../packages/ai-compaction '@tanstack/ai-elevenlabs': specifier: workspace:* version: link:../../packages/ai-elevenlabs @@ -3092,6 +3104,9 @@ importers: '@tanstack/ai-client': specifier: workspace:* version: link:../../packages/ai-client + '@tanstack/ai-compaction': + specifier: workspace:* + version: link:../../packages/ai-compaction '@tanstack/ai-event-client': specifier: workspace:* version: link:../../packages/ai-event-client @@ -11786,6 +11801,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 0d100c5160..7d8cb50e42 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -24,6 +24,7 @@ "@tanstack/ai-byteplus": "workspace:*", "@tanstack/ai-claude-code": "workspace:*", "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-compaction": "workspace:*", "@tanstack/ai-elevenlabs": "workspace:*", "@tanstack/ai-gemini": "workspace:*", "@tanstack/ai-grok": "workspace:*", diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 3acd134ba7..588c2a25b1 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -79,9 +79,10 @@ import { Route as ApiEmbeddingRouteImport } from './routes/api.embedding' import { Route as ApiDurableTakeoverRouteImport } from './routes/api.durable-takeover' import { Route as ApiDurableDeliveryRouteImport } from './routes/api.durable-delivery' import { Route as ApiDevtoolsMemoryRouteImport } from './routes/api.devtools-memory' +import { Route as ApiCompactionWireRouteImport } from './routes/api.compaction-wire' import { Route as ApiChatRouteImport } from './routes/api.chat' -import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat' import { Route as ApiByteplusSeedance1080pWireRouteImport } from './routes/api.byteplus-seedance-1080p-wire' +import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire' import { Route as ApiAnthropicStructuredUsageRouteImport } from './routes/api.anthropic-structured-usage' @@ -461,22 +462,27 @@ const ApiDevtoolsMemoryRoute = ApiDevtoolsMemoryRouteImport.update({ path: '/api/devtools-memory', getParentRoute: () => rootRouteImport, } as any) +const ApiCompactionWireRoute = ApiCompactionWireRouteImport.update({ + id: '/api/compaction-wire', + path: '/api/compaction-wire', + getParentRoute: () => rootRouteImport, +} as any) const ApiChatRoute = ApiChatRouteImport.update({ id: '/api/chat', path: '/api/chat', getParentRoute: () => rootRouteImport, } as any) -const ApiByokChatRoute = ApiByokChatRouteImport.update({ - id: '/api/byok-chat', - path: '/api/byok-chat', - getParentRoute: () => rootRouteImport, -} as any) const ApiByteplusSeedance1080pWireRoute = ApiByteplusSeedance1080pWireRouteImport.update({ id: '/api/byteplus-seedance-1080p-wire', path: '/api/byteplus-seedance-1080p-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiByokChatRoute = ApiByokChatRouteImport.update({ + id: '/api/byok-chat', + path: '/api/byok-chat', + getParentRoute: () => rootRouteImport, +} as any) const ApiAudioRoute = ApiAudioRouteImport.update({ id: '/api/audio', path: '/api/audio', @@ -564,6 +570,7 @@ export interface FileRoutesByFullPath { '/api/byok-chat': typeof ApiByokChatRoute '/api/byteplus-seedance-1080p-wire': typeof ApiByteplusSeedance1080pWireRoute '/api/chat': typeof ApiChatRoute + '/api/compaction-wire': typeof ApiCompactionWireRoute '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/durable-takeover': typeof ApiDurableTakeoverRoute @@ -650,6 +657,7 @@ export interface FileRoutesByTo { '/api/byok-chat': typeof ApiByokChatRoute '/api/byteplus-seedance-1080p-wire': typeof ApiByteplusSeedance1080pWireRoute '/api/chat': typeof ApiChatRoute + '/api/compaction-wire': typeof ApiCompactionWireRoute '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/durable-takeover': typeof ApiDurableTakeoverRoute @@ -737,6 +745,7 @@ export interface FileRoutesById { '/api/byok-chat': typeof ApiByokChatRoute '/api/byteplus-seedance-1080p-wire': typeof ApiByteplusSeedance1080pWireRoute '/api/chat': typeof ApiChatRoute + '/api/compaction-wire': typeof ApiCompactionWireRoute '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/durable-takeover': typeof ApiDurableTakeoverRoute @@ -825,6 +834,7 @@ export interface FileRouteTypes { | '/api/byok-chat' | '/api/byteplus-seedance-1080p-wire' | '/api/chat' + | '/api/compaction-wire' | '/api/devtools-memory' | '/api/durable-delivery' | '/api/durable-takeover' @@ -911,6 +921,7 @@ export interface FileRouteTypes { | '/api/byok-chat' | '/api/byteplus-seedance-1080p-wire' | '/api/chat' + | '/api/compaction-wire' | '/api/devtools-memory' | '/api/durable-delivery' | '/api/durable-takeover' @@ -997,6 +1008,7 @@ export interface FileRouteTypes { | '/api/byok-chat' | '/api/byteplus-seedance-1080p-wire' | '/api/chat' + | '/api/compaction-wire' | '/api/devtools-memory' | '/api/durable-delivery' | '/api/durable-takeover' @@ -1084,6 +1096,7 @@ export interface RootRouteChildren { ApiByokChatRoute: typeof ApiByokChatRoute ApiByteplusSeedance1080pWireRoute: typeof ApiByteplusSeedance1080pWireRoute ApiChatRoute: typeof ApiChatRoute + ApiCompactionWireRoute: typeof ApiCompactionWireRoute ApiDevtoolsMemoryRoute: typeof ApiDevtoolsMemoryRoute ApiDurableDeliveryRoute: typeof ApiDurableDeliveryRoute ApiDurableTakeoverRoute: typeof ApiDurableTakeoverRoute @@ -1628,6 +1641,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiDevtoolsMemoryRouteImport parentRoute: typeof rootRouteImport } + '/api/compaction-wire': { + id: '/api/compaction-wire' + path: '/api/compaction-wire' + fullPath: '/api/compaction-wire' + preLoaderRoute: typeof ApiCompactionWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/chat': { id: '/api/chat' path: '/api/chat' @@ -1635,13 +1655,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiChatRouteImport parentRoute: typeof rootRouteImport } - '/api/byok-chat': { - id: '/api/byok-chat' - path: '/api/byok-chat' - fullPath: '/api/byok-chat' - preLoaderRoute: typeof ApiByokChatRouteImport - parentRoute: typeof rootRouteImport - } '/api/byteplus-seedance-1080p-wire': { id: '/api/byteplus-seedance-1080p-wire' path: '/api/byteplus-seedance-1080p-wire' @@ -1649,6 +1662,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiByteplusSeedance1080pWireRouteImport parentRoute: typeof rootRouteImport } + '/api/byok-chat': { + id: '/api/byok-chat' + path: '/api/byok-chat' + fullPath: '/api/byok-chat' + preLoaderRoute: typeof ApiByokChatRouteImport + parentRoute: typeof rootRouteImport + } '/api/audio': { id: '/api/audio' path: '/api/audio' @@ -1817,6 +1837,7 @@ const rootRouteChildren: RootRouteChildren = { ApiByokChatRoute: ApiByokChatRoute, ApiByteplusSeedance1080pWireRoute: ApiByteplusSeedance1080pWireRoute, ApiChatRoute: ApiChatRoute, + ApiCompactionWireRoute: ApiCompactionWireRoute, ApiDevtoolsMemoryRoute: ApiDevtoolsMemoryRoute, ApiDurableDeliveryRoute: ApiDurableDeliveryRoute, ApiDurableTakeoverRoute: ApiDurableTakeoverRoute, diff --git a/testing/e2e/src/routes/api.compaction-wire.ts b/testing/e2e/src/routes/api.compaction-wire.ts new file mode 100644 index 0000000000..576ee59882 --- /dev/null +++ b/testing/e2e/src/routes/api.compaction-wire.ts @@ -0,0 +1,121 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, createChatOptions, maxIterations } from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { withCompaction } from '@tanstack/ai-compaction' +import type { ModelMessage } from '@tanstack/ai' + +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +function makeTextStream(): ReadableStream { + const encoder = new TextEncoder() + const responseId = 'resp_compaction' + const itemId = 'msg_compaction' + const events = [ + { + type: 'response.created', + response: { + id: responseId, + object: 'response', + status: 'in_progress', + output: [], + }, + }, + { + type: 'response.output_text.delta', + response_id: responseId, + item_id: itemId, + output_index: 0, + content_index: 0, + delta: 'ok', + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + output: [ + { + id: itemId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'ok' }], + }, + ], + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }, + }, + ] + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) +} + +const FILLER = 'x'.repeat(160) + +/** + * Wire-format verification for `withCompaction`. A capturing `fetch` records the + * outgoing request body. We send a long history whose oldest message carries a + * marker (`SECRET_ALPHA_ONE`) and whose newest carries another (`KEEP_ME_LAST`), + * with a small `maxTokens`. The captured body must show the old marker evicted, + * the compaction note present, and the recent marker preserved. + */ +export const Route = createFileRoute('/api/compaction-wire')({ + server: { + handlers: { + POST: async () => { + let firstRequestBody: unknown + + const mockFetch: typeof fetch = async (input, init) => { + const request = + input instanceof Request ? input : new Request(input, init) + if (firstRequestBody === undefined) { + firstRequestBody = JSON.parse(await request.text()) + } + return new Response(makeTextStream(), { + headers: { 'Content-Type': 'text/event-stream' }, + }) + } + + const messages: Array = [ + { role: 'user', content: `SECRET_ALPHA_ONE ${FILLER}` }, + { role: 'assistant', content: FILLER }, + { role: 'user', content: FILLER }, + { role: 'assistant', content: FILLER }, + { role: 'user', content: `KEEP_ME_LAST ${FILLER}` }, + ] + + const adapter = createOpenaiChat('gpt-5.2', DUMMY_KEY, { + fetch: mockFetch, + }) + + try { + for await (const _ of chat({ + ...createChatOptions({ adapter }), + messages, + middleware: [ + withCompaction({ maxTokens: 60, keepRecentTokens: 45 }), + ], + agentLoopStrategy: maxIterations(1), + })) { + // Drain the stream. + } + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + + return Response.json({ ok: true, firstRequestBody }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/compaction-wire.spec.ts b/testing/e2e/tests/compaction-wire.spec.ts new file mode 100644 index 0000000000..32fe1047af --- /dev/null +++ b/testing/e2e/tests/compaction-wire.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from './fixtures' + +/** + * Wire-format verification for `withCompaction`. Drives `/api/compaction-wire`, + * which sends a long history through `chat()` with a small `maxTokens` and + * captures the outgoing SDK request. The captured body must show the oldest + * message evicted, the compaction note injected, and the recent tail preserved. + */ +test.describe('withCompaction — wire format', () => { + test('evicts the old head, keeps the recent tail, injects a note', async ({ + request, + }) => { + const response = await request.post('/api/compaction-wire') + expect(response.ok()).toBe(true) + const result = (await response.json()) as { + ok: boolean + error?: string + firstRequestBody: unknown + } + if (!result.ok) throw new Error(`Route failed: ${result.error}`) + + const wire = JSON.stringify(result.firstRequestBody) + // Recent tail is preserved verbatim. + expect(wire).toContain('KEEP_ME_LAST') + // The dropped head was replaced by the eviction note. + expect(wire).toContain('omitted to save context') + // The oldest message is gone. + expect(wire).not.toContain('SECRET_ALPHA_ONE') + }) +}) diff --git a/testing/panel/package.json b/testing/panel/package.json index 7b1e3d63be..ebbaf4e2fa 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -15,6 +15,7 @@ "@tanstack/ai": "workspace:*", "@tanstack/ai-anthropic": "workspace:*", "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-compaction": "workspace:*", "@tanstack/ai-event-client": "workspace:*", "@tanstack/ai-gemini": "workspace:*", "@tanstack/ai-grok": "workspace:*", diff --git a/testing/panel/src/components/Header.tsx b/testing/panel/src/components/Header.tsx index b7711d91af..b0849b28b5 100644 --- a/testing/panel/src/components/Header.tsx +++ b/testing/panel/src/components/Header.tsx @@ -12,6 +12,7 @@ import { Menu, Mic, Package, + Scissors, Video, Volume2, X, @@ -139,6 +140,24 @@ export default function Header() { + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + +
+ Compaction + + context + +
+ +

Activities diff --git a/testing/panel/src/lib/compaction-store.ts b/testing/panel/src/lib/compaction-store.ts new file mode 100644 index 0000000000..fa445843b9 --- /dev/null +++ b/testing/panel/src/lib/compaction-store.ts @@ -0,0 +1,27 @@ +import type { CompactionInfo } from '@tanstack/ai-compaction' + +/** + * Process-local record of compaction events for the `/compaction` demo. The + * chat route writes here from `withCompaction`'s `onCompact` callback; the + * inspect route reads it. Same singleton or the reader sees nothing. + */ +export interface CompactionEvent extends CompactionInfo { + /** Wall-clock time the compaction fired. */ + at: number +} + +const eventsByThread = new Map>() + +export function recordCompaction(threadId: string, info: CompactionInfo): void { + const list = eventsByThread.get(threadId) ?? [] + list.push({ ...info, at: Date.now() }) + eventsByThread.set(threadId, list) +} + +export function getCompactions(threadId: string): Array { + return eventsByThread.get(threadId) ?? [] +} + +export function clearCompactions(threadId: string): void { + eventsByThread.delete(threadId) +} diff --git a/testing/panel/src/routeTree.gen.ts b/testing/panel/src/routeTree.gen.ts index c9ce6fdaf3..8f23c3235d 100644 --- a/testing/panel/src/routeTree.gen.ts +++ b/testing/panel/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as StreamDebuggerRouteImport } from './routes/stream-debugger' import { Route as SimulatorRouteImport } from './routes/simulator' import { Route as MemoryRouteImport } from './routes/memory' import { Route as ImageRouteImport } from './routes/image' +import { Route as CompactionRouteImport } from './routes/compaction' import { Route as AddonManagerRouteImport } from './routes/addon-manager' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiVideoRouteImport } from './routes/api.video' @@ -31,6 +32,8 @@ import { Route as ApiMemoryChatRouteImport } from './routes/api.memory-chat' import { Route as ApiLoadTraceRouteImport } from './routes/api.load-trace' import { Route as ApiListTracesRouteImport } from './routes/api.list-traces' import { Route as ApiImageRouteImport } from './routes/api.image' +import { Route as ApiCompactionInspectRouteImport } from './routes/api.compaction-inspect' +import { Route as ApiCompactionChatRouteImport } from './routes/api.compaction-chat' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAddonChatRouteImport } from './routes/api.addon-chat' @@ -79,6 +82,11 @@ const ImageRoute = ImageRouteImport.update({ path: '/image', getParentRoute: () => rootRouteImport, } as any) +const CompactionRoute = CompactionRouteImport.update({ + id: '/compaction', + path: '/compaction', + getParentRoute: () => rootRouteImport, +} as any) const AddonManagerRoute = AddonManagerRouteImport.update({ id: '/addon-manager', path: '/addon-manager', @@ -144,6 +152,16 @@ const ApiImageRoute = ApiImageRouteImport.update({ path: '/api/image', getParentRoute: () => rootRouteImport, } as any) +const ApiCompactionInspectRoute = ApiCompactionInspectRouteImport.update({ + id: '/api/compaction-inspect', + path: '/api/compaction-inspect', + getParentRoute: () => rootRouteImport, +} as any) +const ApiCompactionChatRoute = ApiCompactionChatRouteImport.update({ + id: '/api/compaction-chat', + path: '/api/compaction-chat', + getParentRoute: () => rootRouteImport, +} as any) const ApiChatRoute = ApiChatRouteImport.update({ id: '/api/chat', path: '/api/chat', @@ -158,6 +176,7 @@ const ApiAddonChatRoute = ApiAddonChatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/addon-manager': typeof AddonManagerRoute + '/compaction': typeof CompactionRoute '/image': typeof ImageRoute '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute @@ -169,6 +188,8 @@ export interface FileRoutesByFullPath { '/video': typeof VideoRoute '/api/addon-chat': typeof ApiAddonChatRoute '/api/chat': typeof ApiChatRoute + '/api/compaction-chat': typeof ApiCompactionChatRoute + '/api/compaction-inspect': typeof ApiCompactionInspectRoute '/api/image': typeof ApiImageRoute '/api/list-traces': typeof ApiListTracesRoute '/api/load-trace': typeof ApiLoadTraceRoute @@ -184,6 +205,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/addon-manager': typeof AddonManagerRoute + '/compaction': typeof CompactionRoute '/image': typeof ImageRoute '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute @@ -195,6 +217,8 @@ export interface FileRoutesByTo { '/video': typeof VideoRoute '/api/addon-chat': typeof ApiAddonChatRoute '/api/chat': typeof ApiChatRoute + '/api/compaction-chat': typeof ApiCompactionChatRoute + '/api/compaction-inspect': typeof ApiCompactionInspectRoute '/api/image': typeof ApiImageRoute '/api/list-traces': typeof ApiListTracesRoute '/api/load-trace': typeof ApiLoadTraceRoute @@ -211,6 +235,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/addon-manager': typeof AddonManagerRoute + '/compaction': typeof CompactionRoute '/image': typeof ImageRoute '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute @@ -222,6 +247,8 @@ export interface FileRoutesById { '/video': typeof VideoRoute '/api/addon-chat': typeof ApiAddonChatRoute '/api/chat': typeof ApiChatRoute + '/api/compaction-chat': typeof ApiCompactionChatRoute + '/api/compaction-inspect': typeof ApiCompactionInspectRoute '/api/image': typeof ApiImageRoute '/api/list-traces': typeof ApiListTracesRoute '/api/load-trace': typeof ApiLoadTraceRoute @@ -239,6 +266,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/addon-manager' + | '/compaction' | '/image' | '/memory' | '/simulator' @@ -250,6 +278,8 @@ export interface FileRouteTypes { | '/video' | '/api/addon-chat' | '/api/chat' + | '/api/compaction-chat' + | '/api/compaction-inspect' | '/api/image' | '/api/list-traces' | '/api/load-trace' @@ -265,6 +295,7 @@ export interface FileRouteTypes { to: | '/' | '/addon-manager' + | '/compaction' | '/image' | '/memory' | '/simulator' @@ -276,6 +307,8 @@ export interface FileRouteTypes { | '/video' | '/api/addon-chat' | '/api/chat' + | '/api/compaction-chat' + | '/api/compaction-inspect' | '/api/image' | '/api/list-traces' | '/api/load-trace' @@ -291,6 +324,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/addon-manager' + | '/compaction' | '/image' | '/memory' | '/simulator' @@ -302,6 +336,8 @@ export interface FileRouteTypes { | '/video' | '/api/addon-chat' | '/api/chat' + | '/api/compaction-chat' + | '/api/compaction-inspect' | '/api/image' | '/api/list-traces' | '/api/load-trace' @@ -318,6 +354,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AddonManagerRoute: typeof AddonManagerRoute + CompactionRoute: typeof CompactionRoute ImageRoute: typeof ImageRoute MemoryRoute: typeof MemoryRoute SimulatorRoute: typeof SimulatorRoute @@ -329,6 +366,8 @@ export interface RootRouteChildren { VideoRoute: typeof VideoRoute ApiAddonChatRoute: typeof ApiAddonChatRoute ApiChatRoute: typeof ApiChatRoute + ApiCompactionChatRoute: typeof ApiCompactionChatRoute + ApiCompactionInspectRoute: typeof ApiCompactionInspectRoute ApiImageRoute: typeof ApiImageRoute ApiListTracesRoute: typeof ApiListTracesRoute ApiLoadTraceRoute: typeof ApiLoadTraceRoute @@ -407,6 +446,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ImageRouteImport parentRoute: typeof rootRouteImport } + '/compaction': { + id: '/compaction' + path: '/compaction' + fullPath: '/compaction' + preLoaderRoute: typeof CompactionRouteImport + parentRoute: typeof rootRouteImport + } '/addon-manager': { id: '/addon-manager' path: '/addon-manager' @@ -498,6 +544,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiImageRouteImport parentRoute: typeof rootRouteImport } + '/api/compaction-inspect': { + id: '/api/compaction-inspect' + path: '/api/compaction-inspect' + fullPath: '/api/compaction-inspect' + preLoaderRoute: typeof ApiCompactionInspectRouteImport + parentRoute: typeof rootRouteImport + } + '/api/compaction-chat': { + id: '/api/compaction-chat' + path: '/api/compaction-chat' + fullPath: '/api/compaction-chat' + preLoaderRoute: typeof ApiCompactionChatRouteImport + parentRoute: typeof rootRouteImport + } '/api/chat': { id: '/api/chat' path: '/api/chat' @@ -518,6 +578,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AddonManagerRoute: AddonManagerRoute, + CompactionRoute: CompactionRoute, ImageRoute: ImageRoute, MemoryRoute: MemoryRoute, SimulatorRoute: SimulatorRoute, @@ -529,6 +590,8 @@ const rootRouteChildren: RootRouteChildren = { VideoRoute: VideoRoute, ApiAddonChatRoute: ApiAddonChatRoute, ApiChatRoute: ApiChatRoute, + ApiCompactionChatRoute: ApiCompactionChatRoute, + ApiCompactionInspectRoute: ApiCompactionInspectRoute, ApiImageRoute: ApiImageRoute, ApiListTracesRoute: ApiListTracesRoute, ApiLoadTraceRoute: ApiLoadTraceRoute, diff --git a/testing/panel/src/routes/api.compaction-chat.ts b/testing/panel/src/routes/api.compaction-chat.ts new file mode 100644 index 0000000000..ebc45f4840 --- /dev/null +++ b/testing/panel/src/routes/api.compaction-chat.ts @@ -0,0 +1,119 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + createChatOptions, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { withCompaction } from '@tanstack/ai-compaction' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { grokText } from '@tanstack/ai-grok' +import { openaiText } from '@tanstack/ai-openai' +import { ollamaText } from '@tanstack/ai-ollama' +import { openRouterText } from '@tanstack/ai-openrouter' +import { recordCompaction } from '@/lib/compaction-store' +import type { Provider } from '@/lib/model-selection' + +const SYSTEM_PROMPT = `You are a helpful assistant. Keep answers reasonably long +(a paragraph or two) so this demo's context fills up quickly.` + +/** + * Chat endpoint for the `/compaction` demo. Wires `withCompaction` with a small + * `maxTokens` so the middleware fires after a couple of turns. Compaction here + * evicts the oldest messages (no `summarize` callback), keeping the recent tail + * verbatim; each event is recorded so the page can show before/after tokens. + * + * `threadId` scopes the recorded events; it is demo-only (never trust a + * client-supplied identity in production). + */ +export const Route = createFileRoute('/api/compaction-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const requestSignal = request.signal + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + const body = await request.json() + const messages = body.messages + const data = body.data || {} + + const provider: Provider = data.provider || 'openai' + const model: string | undefined = data.model + const threadId: string = + typeof data.threadId === 'string' && data.threadId.length > 0 + ? data.threadId + : 'panel-default-thread' + const maxTokens: number = + typeof data.maxTokens === 'number' && data.maxTokens > 0 + ? data.maxTokens + : 400 + + try { + const adapterConfig = { + anthropic: () => + createChatOptions({ + adapter: anthropicText((model || 'claude-sonnet-4-5') as any), + }), + gemini: () => + createChatOptions({ + adapter: geminiText((model || 'gemini-2.5-flash') as any), + }), + grok: () => + createChatOptions({ + adapter: grokText((model || 'grok-build-0.1') as any), + }), + ollama: () => + createChatOptions({ + adapter: ollamaText((model || 'mistral:7b') as any), + }), + openai: () => + createChatOptions({ + adapter: openaiText((model || 'gpt-4o') as any), + }), + openrouter: () => + createChatOptions({ + adapter: openRouterText((model || 'openai/gpt-4o') as any), + }), + } + + const options = adapterConfig[provider]() + const { adapter } = options + + const compaction = withCompaction({ + maxTokens, + onCompact: (info) => recordCompaction(threadId, info), + }) + + const stream = chat({ + ...options, + adapter, + tools: [], + systemPrompts: [SYSTEM_PROMPT], + middleware: [compaction], + agentLoopStrategy: maxIterations(5), + messages, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.compaction-chat] Error:', error?.message) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/testing/panel/src/routes/api.compaction-inspect.ts b/testing/panel/src/routes/api.compaction-inspect.ts new file mode 100644 index 0000000000..259473f03f --- /dev/null +++ b/testing/panel/src/routes/api.compaction-inspect.ts @@ -0,0 +1,22 @@ +import { createFileRoute } from '@tanstack/react-router' +import { clearCompactions, getCompactions } from '@/lib/compaction-store' + +/** + * Read side of the `/compaction` demo. GET returns the recorded compaction + * events for a thread; DELETE clears them (used by "New thread"). + */ +export const Route = createFileRoute('/api/compaction-inspect')({ + server: { + handlers: { + GET: async ({ request }) => { + const threadId = new URL(request.url).searchParams.get('threadId') ?? '' + return Response.json({ events: getCompactions(threadId) }) + }, + DELETE: async ({ request }) => { + const threadId = new URL(request.url).searchParams.get('threadId') ?? '' + clearCompactions(threadId) + return Response.json({ ok: true }) + }, + }, + }, +}) diff --git a/testing/panel/src/routes/compaction.tsx b/testing/panel/src/routes/compaction.tsx new file mode 100644 index 0000000000..6834593d0f --- /dev/null +++ b/testing/panel/src/routes/compaction.tsx @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { RefreshCw, RotateCcw, Send, Scissors } from 'lucide-react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import type { UIMessage } from '@tanstack/ai-react' +import { MODEL_OPTIONS, getDefaultModelOption } from '@/lib/model-selection' +import type { ModelOption } from '@/lib/model-selection' + +const THREAD_STORAGE_KEY = 'panel-compaction-thread' + +// Mirror of /api/compaction-inspect. Kept local so the page has no build-time +// dependency on server internals. +interface CompactionEvent { + before: number + after: number + droppedMessages: number + summarized: boolean + at: number +} +interface InspectResponse { + events: Array +} + +function getMessageText(parts: UIMessage['parts']): string { + return parts + .filter((part) => part.type === 'text' && 'content' in part && part.content) + .map((part) => (part as { type: 'text'; content: string }).content) + .join('') +} + +function CompactionPage() { + const [selectedModel, setSelectedModel] = useState( + getDefaultModelOption(), + ) + const [threadId, setThreadId] = useState('') + const [maxTokens, setMaxTokens] = useState(400) + const [inspect, setInspect] = useState(null) + const [input, setInput] = useState('') + + useEffect(() => { + let existing = localStorage.getItem(THREAD_STORAGE_KEY) + if (!existing) { + existing = crypto.randomUUID() + localStorage.setItem(THREAD_STORAGE_KEY, existing) + } + setThreadId(existing) + }, []) + + const body = useMemo( + () => ({ + provider: selectedModel.provider, + model: selectedModel.model, + threadId, + maxTokens, + }), + [selectedModel.provider, selectedModel.model, threadId, maxTokens], + ) + + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/compaction-chat'), + body, + devtools: { name: 'Compaction' }, + }) + + const refreshInspect = useCallback(async () => { + if (!threadId) return + try { + const res = await fetch( + `/api/compaction-inspect?threadId=${encodeURIComponent(threadId)}`, + ) + if (res.ok) setInspect(await res.json()) + } catch { + // Non-fatal: read-only view. + } + }, [threadId]) + + const wasLoading = useRef(false) + useEffect(() => { + if (wasLoading.current && !isLoading) refreshInspect() + wasLoading.current = isLoading + }, [isLoading, refreshInspect]) + useEffect(() => { + refreshInspect() + }, [refreshInspect]) + + const startNewThread = async () => { + if (threadId) { + await fetch( + `/api/compaction-inspect?threadId=${encodeURIComponent(threadId)}`, + { method: 'DELETE' }, + ).catch(() => {}) + } + const next = crypto.randomUUID() + localStorage.setItem(THREAD_STORAGE_KEY, next) + setThreadId(next) + setInspect(null) + } + + const submit = () => { + const text = input.trim() + if (!text || isLoading) return + sendMessage(text) + setInput('') + } + + const events = inspect?.events ?? [] + + return ( +

+ {/* Left: chat */} +
+
+
+ + +
+
+ + setMaxTokens(parseInt(e.target.value))} + className="w-full accent-cyan-500" + /> +
+
+ +
+ {messages.length === 0 ? ( +

+ Chat for a few turns. Once the running transcript passes{' '} + {maxTokens} estimated tokens, older messages get compacted away + and the events show up on the right. +

+ ) : ( + messages.map(({ id, role, parts }) => ( +
+
+ {getMessageText(parts)} +
+
+ )) + )} +
+ +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + submit() + } + }} + placeholder="Type a message…" + disabled={isLoading} + className="flex-1 rounded-lg border border-cyan-500/20 bg-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-cyan-500/50 disabled:opacity-50" + /> + +
+
+
+ + {/* Right: compaction events */} +
+
+
+

Compaction events

+

+ thread: {threadId ? threadId.slice(0, 8) : '…'} +

+
+
+ + +
+
+ +
+ {events.length === 0 ? ( +

+ No compaction yet. Lower maxTokens or keep chatting until the + transcript grows past the threshold. +

+ ) : ( + events + .slice() + .reverse() + .map((ev, i) => ( +
+
+ + {ev.summarized ? 'Summarized' : 'Evicted'}{' '} + {ev.droppedMessages} message + {ev.droppedMessages === 1 ? '' : 's'} +
+
+ {ev.before} → {ev.after} tokens (− + {ev.before - ev.after}) +
+
+ {new Date(ev.at).toLocaleTimeString()} +
+
+ )) + )} +
+
+
+ ) +} + +export const Route = createFileRoute('/compaction')({ + component: CompactionPage, +}) From 941d5d8ef8cc9c7967567362277d1123106e2ba5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:17:10 +0000 Subject: [PATCH 02/13] ci: apply automated fixes --- packages/ai-compaction/README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md index d63e20f443..c41dbd6d8c 100644 --- a/packages/ai-compaction/README.md +++ b/packages/ai-compaction/README.md @@ -38,7 +38,10 @@ const summarize = async (msgs) => { adapter, messages: [ ...msgs, - { role: 'user', content: 'Summarize the conversation above in a few sentences.' }, + { + role: 'user', + content: 'Summarize the conversation above in a few sentences.', + }, ], }) return text @@ -53,14 +56,14 @@ chat({ ## Options -| Option | Default | What it does | -|---|---|---| -| `maxTokens` | — (required) | Compact when estimated tokens exceed this. | -| `keepRecentTokens` | `floor(maxTokens / 2)` | Recent tokens always kept verbatim. Must be `< maxTokens`. | -| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | -| `summarize` | — | Summarize the dropped head. Omit to evict with a marker. | -| `summaryRole` | `'user'` | Role of the injected note. | -| `onCompact` | — | Observe each compaction (`before`/`after`/`droppedMessages`/`summarized`). | +| Option | Default | What it does | +| ------------------ | ---------------------- | -------------------------------------------------------------------------- | +| `maxTokens` | — (required) | Compact when estimated tokens exceed this. | +| `keepRecentTokens` | `floor(maxTokens / 2)` | Recent tokens always kept verbatim. Must be `< maxTokens`. | +| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | +| `summarize` | — | Summarize the dropped head. Omit to evict with a marker. | +| `summaryRole` | `'user'` | Role of the injected note. | +| `onCompact` | — | Observe each compaction (`before`/`after`/`droppedMessages`/`summarized`). | The token estimate is a rough `chars / 4` heuristic — good enough to trigger on, not exact. Pass `estimateTokens` if you need provider-accurate counts. From 948b23172b30d3bccff7721624643dbe7aafc097 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 24 Aug 2026 16:22:51 -0700 Subject: [PATCH 03/13] docs: add compaction guide Document @tanstack/ai-compaction under Advanced > Middleware: the problem it solves, evict vs summarize wiring, the options table, and what it keeps safe. Add the nav entry and cross-link from the Middleware guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/advanced/compaction.md | 106 ++++++++++++++++++++++++++++++++++++ docs/advanced/middleware.md | 1 + docs/config.json | 7 ++- 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 docs/advanced/compaction.md diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md new file mode 100644 index 0000000000..658c34b782 --- /dev/null +++ b/docs/advanced/compaction.md @@ -0,0 +1,106 @@ +--- +title: Compaction +id: compaction +order: 3 +description: "Keep long chats under the context limit with @tanstack/ai-compaction. withCompaction drops or summarizes old messages before each model call and keeps the recent tail." +keywords: + - tanstack ai + - compaction + - context window + - middleware + - token limit + - summarize history +--- + +A long chat or a multi-step agent loop keeps adding messages. At some point the transcript passes the model's context limit and the call fails. You want the conversation to keep working without hitting that wall. + +`withCompaction` shrinks the history before each model call. It keeps the recent messages as they are and replaces the older ones with a single note. The note is a summary, or a short marker when you drop them. It is an ordinary [`ChatMiddleware`](./middleware), so you add it to the `middleware` array of any `chat()` call. + +## Install + +```bash +pnpm add @tanstack/ai-compaction +``` + +## Drop old messages (no extra model call) + +This is the cheapest option. Once the transcript passes `maxTokens`, the oldest messages are dropped and replaced with a short marker. + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { withCompaction } from "@tanstack/ai-compaction"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: openaiText("gpt-5.5"), + messages, + middleware: [withCompaction({ maxTokens: 100_000 })], + }); + + return toServerSentEventsResponse(stream); +} +``` + +## Summarize old messages instead + +Dropping messages loses their content. Pass a `summarize` callback to keep a short version of the old history instead. The callback gets the messages that are about to be dropped and returns the summary text. + +```typescript +import { chat, summarize, toServerSentEventsResponse } from "@tanstack/ai"; +import { openaiText, openaiSummarize } from "@tanstack/ai-openai"; +import { withCompaction } from "@tanstack/ai-compaction"; +import type { ModelMessage } from "@tanstack/ai"; + +async function summarizeHistory(messages: Array): Promise { + const text = messages + .map((m) => `${m.role}: ${typeof m.content === "string" ? m.content : ""}`) + .join("\n"); + + const { summary } = await summarize({ + adapter: openaiSummarize("gpt-5.5"), + text, + }); + return summary; +} + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: openaiText("gpt-5.5"), + messages, + middleware: [ + withCompaction({ maxTokens: 100_000, summarize: summarizeHistory }), + ], + }); + + return toServerSentEventsResponse(stream); +} +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxTokens` | `number` | - | **Required.** Compact when the estimated tokens across `messages` pass this. | +| `keepRecentTokens` | `number` | `floor(maxTokens / 2)` | Tokens of recent messages to always keep as they are. Must be less than `maxTokens`. | +| `estimateTokens` | `(message: ModelMessage) => number` | characters / 4 | Per-message token estimate. Pass a real tokenizer if you need exact counts. | +| `summarize` | `(messages: ModelMessage[]) => Promise` | - | Summarize the dropped messages. Leave it out to drop them with a marker. | +| `summaryRole` | `'user' \| 'assistant'` | `'user'` | Role of the note that replaces the old messages. | +| `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. `info` is `{ before, after, droppedMessages, summarized }`. | + +The token count is a rough `characters / 4` estimate. It is good enough to trigger on, not exact. Pass `estimateTokens` for provider-accurate counts. + +## What it keeps safe + +- **The system prompt is never dropped.** `chat()` keeps it separate from `messages`, so compaction only touches the conversation. +- **Tool calls stay paired with their results.** The kept tail never starts with an orphaned tool result, so the request stays valid. +- **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again, and a later pass folds an earlier summary into the new one. + +## Next steps + +- [Middleware](./middleware): the full hook reference and how middleware composes +- [Built-in Middleware](./built-in-middleware): ready-made middleware that ships in `@tanstack/ai` diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index fd9d064436..838ef05738 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -1140,6 +1140,7 @@ import type { ## Next Steps - [Built-in Middleware](./built-in-middleware) — `toolCacheMiddleware`, `contentGuardMiddleware`, `otelMiddleware` +- [Compaction](./compaction): keep long chats under the context limit with `withCompaction` - [OpenTelemetry](./otel) — emit traces and metrics via `otelMiddleware`- [Tools](../tools/tools) — Learn about the isomorphic tool system - [Agentic Cycle](../chat/agentic-cycle) — Understand the multi-step agent loop - [Streaming](../chat/streaming) — How streaming works in TanStack AI diff --git a/docs/config.json b/docs/config.json index 18ee148510..d28c4accda 100644 --- a/docs/config.json +++ b/docs/config.json @@ -537,7 +537,7 @@ "label": "Middleware", "to": "advanced/middleware", "addedAt": "2026-04-15", - "updatedAt": "2026-08-19" + "updatedAt": "2026-08-24" }, { "label": "Built-in Middleware", @@ -545,6 +545,11 @@ "addedAt": "2026-06-03", "updatedAt": "2026-07-21" }, + { + "label": "Compaction", + "to": "advanced/compaction", + "addedAt": "2026-08-24" + }, { "label": "Locks", "to": "advanced/locks", From 3f77a40cf89af16631bbe4a03142a5c432f94a7b Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 24 Aug 2026 16:37:52 -0700 Subject: [PATCH 04/13] feat(ai-compaction): pluggable compaction strategies Refactor withCompaction around a pluggable CompactionStrategy (mirroring AgentLoopStrategy). Ship three built-in strategies: evictOldest (default), summarizeOldest, and clearToolResults (observation masking for agent loops). Update the docs guide, README, panel demo (strategy selector), and add an e2e case for clearToolResults. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/ai-compaction.md | 11 +- docs/advanced/compaction.md | 93 +++++-- packages/ai-compaction/README.md | 98 +++++--- packages/ai-compaction/src/index.test.ts | 133 ++++++---- packages/ai-compaction/src/index.ts | 232 ++++++++++++------ testing/e2e/src/routes/api.compaction-wire.ts | 71 ++++-- testing/e2e/tests/compaction-wire.spec.ts | 21 ++ .../panel/src/routes/api.compaction-chat.ts | 39 ++- testing/panel/src/routes/compaction.tsx | 38 ++- 9 files changed, 545 insertions(+), 191 deletions(-) diff --git a/.changeset/ai-compaction.md b/.changeset/ai-compaction.md index a54bd71f74..076a49985a 100644 --- a/.changeset/ai-compaction.md +++ b/.changeset/ai-compaction.md @@ -3,8 +3,9 @@ --- Add `@tanstack/ai-compaction` — context-window compaction as a `chat()` -middleware. `withCompaction({ maxTokens })` keeps the recent tail verbatim and -replaces the older head with a single note (a summary when a `summarize` -callback is supplied, otherwise an eviction marker). It runs before every model -call via `onConfig`, so compaction is incremental and rolling, and it preserves -tool-call/result pairing so it never sends an orphaned tool result. +middleware. `withCompaction({ maxTokens, strategy })` runs a pluggable +`CompactionStrategy` before each model call, so compaction is incremental and +rolling. Three strategies ship built in: `evictOldest` (drop old messages, the +default), `summarizeOldest` (replace them with an LLM summary), and +`clearToolResults` (stub old tool output, keep the messages). Strategies +preserve tool-call/result pairing and never touch the system prompt. diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md index 658c34b782..217c34dc3c 100644 --- a/docs/advanced/compaction.md +++ b/docs/advanced/compaction.md @@ -2,7 +2,7 @@ title: Compaction id: compaction order: 3 -description: "Keep long chats under the context limit with @tanstack/ai-compaction. withCompaction drops or summarizes old messages before each model call and keeps the recent tail." +description: "Keep long chats under the context limit with @tanstack/ai-compaction. withCompaction runs a pluggable strategy before each model call: evict, summarize, or clear old tool output." keywords: - tanstack ai - compaction @@ -14,7 +14,7 @@ keywords: A long chat or a multi-step agent loop keeps adding messages. At some point the transcript passes the model's context limit and the call fails. You want the conversation to keep working without hitting that wall. -`withCompaction` shrinks the history before each model call. It keeps the recent messages as they are and replaces the older ones with a single note. The note is a summary, or a short marker when you drop them. It is an ordinary [`ChatMiddleware`](./middleware), so you add it to the `middleware` array of any `chat()` call. +`withCompaction` shrinks the history before each model call. When the transcript passes `maxTokens`, it runs a **strategy** that rewrites the messages. It is an ordinary [`ChatMiddleware`](./middleware), so you add it to the `middleware` array of any `chat()` call. ## Install @@ -22,9 +22,9 @@ A long chat or a multi-step agent loop keeps adding messages. At some point the pnpm add @tanstack/ai-compaction ``` -## Drop old messages (no extra model call) +## Quick start -This is the cheapest option. Once the transcript passes `maxTokens`, the oldest messages are dropped and replaced with a short marker. +The default strategy drops the oldest messages once the transcript passes `maxTokens` and keeps the recent ones. ```typescript import { chat, toServerSentEventsResponse } from "@tanstack/ai"; @@ -44,14 +44,37 @@ export async function POST(request: Request) { } ``` -## Summarize old messages instead +## Pick a strategy -Dropping messages loses their content. Pass a `summarize` callback to keep a short version of the old history instead. The callback gets the messages that are about to be dropped and returns the summary text. +Pass `strategy` to change how the history shrinks. Three are built in. + +| Strategy | What it does | Cost | +|----------|--------------|------| +| `evictOldest` (default) | Drop the oldest messages, leave a marker | No extra model call | +| `summarizeOldest` | Replace the oldest messages with an LLM summary | One summarize call | +| `clearToolResults` | Stub the content of old tool results, keep the messages | No extra model call | + +### evictOldest + +Cheapest. Keeps the recent tail, drops the older head, and leaves a short marker in its place. This is the default, so you only name it to tune `keepRecentTokens`. + +```typescript +import { withCompaction, evictOldest } from "@tanstack/ai-compaction"; + +withCompaction({ + maxTokens: 100_000, + strategy: evictOldest({ keepRecentTokens: 40_000 }), +}); +``` + +### summarizeOldest + +Keeps the gist of old turns instead of dropping them, at the cost of one summarization call. Pass a `summarize` callback. It gets the messages about to be dropped and returns the summary text. Wire it to `summarize()` or any model call. ```typescript import { chat, summarize, toServerSentEventsResponse } from "@tanstack/ai"; import { openaiText, openaiSummarize } from "@tanstack/ai-openai"; -import { withCompaction } from "@tanstack/ai-compaction"; +import { withCompaction, summarizeOldest } from "@tanstack/ai-compaction"; import type { ModelMessage } from "@tanstack/ai"; async function summarizeHistory(messages: Array): Promise { @@ -73,7 +96,10 @@ export async function POST(request: Request) { adapter: openaiText("gpt-5.5"), messages, middleware: [ - withCompaction({ maxTokens: 100_000, summarize: summarizeHistory }), + withCompaction({ + maxTokens: 100_000, + strategy: summarizeOldest({ summarize: summarizeHistory }), + }), ], }); @@ -81,24 +107,63 @@ export async function POST(request: Request) { } ``` +### clearToolResults + +Best for agent loops. Tool output (file reads, command output) is usually most of the tokens. This strategy replaces the content of old tool results with a stub and keeps every message and its tool-call pairing in place. The conversation shape does not change. + +```typescript +import { withCompaction, clearToolResults } from "@tanstack/ai-compaction"; + +withCompaction({ + maxTokens: 100_000, + // Keep the 5 most recent tool results in full, stub the older ones. + strategy: clearToolResults({ keepRecentToolResults: 5 }), +}); +``` + +### Write your own + +A strategy is a function. It gets the messages and the budget, and returns the rewritten messages, or `null` to change nothing. It runs only when the estimate is over `maxTokens`. + +```typescript +import { withCompaction } from "@tanstack/ai-compaction"; +import type { CompactionStrategy } from "@tanstack/ai-compaction"; + +// Keep only the last message. +const keepLastOnly: CompactionStrategy = (messages) => { + if (messages.length <= 1) return null; + return messages.slice(-1); +}; + +withCompaction({ maxTokens: 100_000, strategy: keepLastOnly }); +``` + ## Options +### withCompaction + | Option | Type | Default | Description | |--------|------|---------|-------------| | `maxTokens` | `number` | - | **Required.** Compact when the estimated tokens across `messages` pass this. | -| `keepRecentTokens` | `number` | `floor(maxTokens / 2)` | Tokens of recent messages to always keep as they are. Must be less than `maxTokens`. | +| `strategy` | `CompactionStrategy` | `evictOldest()` | How to shrink the messages. | | `estimateTokens` | `(message: ModelMessage) => number` | characters / 4 | Per-message token estimate. Pass a real tokenizer if you need exact counts. | -| `summarize` | `(messages: ModelMessage[]) => Promise` | - | Summarize the dropped messages. Leave it out to drop them with a marker. | -| `summaryRole` | `'user' \| 'assistant'` | `'user'` | Role of the note that replaces the old messages. | -| `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. `info` is `{ before, after, droppedMessages, summarized }`. | +| `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. `info` is `{ before, after, messagesBefore, messagesAfter }` (token and message counts). | + +### Strategy options + +| Strategy | Options | +|----------|---------| +| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | +| `summarizeOldest` | `summarize` (**required**), `keepRecentTokens`, `summaryRole` | +| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | The token count is a rough `characters / 4` estimate. It is good enough to trigger on, not exact. Pass `estimateTokens` for provider-accurate counts. ## What it keeps safe - **The system prompt is never dropped.** `chat()` keeps it separate from `messages`, so compaction only touches the conversation. -- **Tool calls stay paired with their results.** The kept tail never starts with an orphaned tool result, so the request stays valid. -- **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again, and a later pass folds an earlier summary into the new one. +- **Tool calls stay paired with their results.** The built-in strategies never leave an orphaned tool result, so the request stays valid. +- **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again, and a later `summarizeOldest` pass folds an earlier summary into the new one. ## Next steps diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md index c41dbd6d8c..20b0a81769 100644 --- a/packages/ai-compaction/README.md +++ b/packages/ai-compaction/README.md @@ -1,18 +1,19 @@ # @tanstack/ai-compaction Context-window compaction as a `chat()` middleware. When the working message set -grows past `maxTokens`, it keeps the recent tail verbatim and replaces the older -head with a single note — a **summary** (if you pass `summarize`) or an -**eviction marker**. It runs before every model call, so compaction is -incremental and rolling: a later compaction re-folds the previous summary into -the next one. The system prompt is untouched (`chat()` keeps it separate from -`messages`). +grows past `maxTokens`, `withCompaction` runs a pluggable **strategy** that +rewrites the messages. It runs before every model call, so compaction is +incremental and rolling. The system prompt is untouched (`chat()` keeps it +separate from `messages`). ```bash npm install @tanstack/ai-compaction ``` -## Evict (cheapest — no extra model call) +## Quick start + +The default strategy (`evictOldest`) drops the oldest messages and keeps the +recent ones. ```ts import { chat } from '@tanstack/ai' @@ -25,45 +26,68 @@ chat({ }) ``` -## Summarize the dropped head +## Strategies + +Pass `strategy` to change how the history shrinks. Three are built in. -Pass a `summarize` callback — wire it to a cheap model. +| Strategy | What it does | Cost | +| --- | --- | --- | +| `evictOldest` (default) | Drop the oldest messages, leave a marker | No extra model call | +| `summarizeOldest` | Replace the oldest messages with an LLM summary | One summarize call | +| `clearToolResults` | Stub the content of old tool results, keep the messages | No extra model call | ```ts -import { chat, generate } from '@tanstack/ai' -import { withCompaction } from '@tanstack/ai-compaction' +import { + withCompaction, + evictOldest, + summarizeOldest, + clearToolResults, +} from '@tanstack/ai-compaction' -const summarize = async (msgs) => { - const { text } = await generate({ - adapter, - messages: [ - ...msgs, - { - role: 'user', - content: 'Summarize the conversation above in a few sentences.', - }, - ], - }) - return text -} +// Tune how much recent history to keep. +withCompaction({ maxTokens: 100_000, strategy: evictOldest({ keepRecentTokens: 40_000 }) }) -chat({ - adapter, - messages, - middleware: [withCompaction({ maxTokens: 100_000, summarize })], +// Summarize instead of dropping. `summarize` gets the messages being removed. +withCompaction({ + maxTokens: 100_000, + strategy: summarizeOldest({ summarize: (msgs) => summarizeToText(msgs) }), }) + +// Best for agent loops: stub old tool output, keep the messages in place. +withCompaction({ maxTokens: 100_000, strategy: clearToolResults({ keepRecentToolResults: 5 }) }) +``` + +### Write your own + +A strategy gets the messages and the budget, and returns the rewritten messages +(or `null` to change nothing). It runs only when the estimate is over +`maxTokens`. + +```ts +import type { CompactionStrategy } from '@tanstack/ai-compaction' + +const keepLastOnly: CompactionStrategy = (messages) => + messages.length <= 1 ? null : messages.slice(-1) ``` ## Options -| Option | Default | What it does | -| ------------------ | ---------------------- | -------------------------------------------------------------------------- | -| `maxTokens` | — (required) | Compact when estimated tokens exceed this. | -| `keepRecentTokens` | `floor(maxTokens / 2)` | Recent tokens always kept verbatim. Must be `< maxTokens`. | -| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | -| `summarize` | — | Summarize the dropped head. Omit to evict with a marker. | -| `summaryRole` | `'user'` | Role of the injected note. | -| `onCompact` | — | Observe each compaction (`before`/`after`/`droppedMessages`/`summarized`). | +### `withCompaction` + +| Option | Default | What it does | +| --- | --- | --- | +| `maxTokens` | (required) | Compact when estimated tokens exceed this. | +| `strategy` | `evictOldest()` | How to shrink the messages. | +| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | +| `onCompact` | — | Observe each compaction (`before`/`after`/`messagesBefore`/`messagesAfter`). | + +### Strategy options + +| Strategy | Options | +| --- | --- | +| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | +| `summarizeOldest` | `summarize` (required), `keepRecentTokens`, `summaryRole` | +| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | -The token estimate is a rough `chars / 4` heuristic — good enough to trigger on, +The token estimate is a rough `chars / 4` heuristic, good enough to trigger on, not exact. Pass `estimateTokens` if you need provider-accurate counts. diff --git a/packages/ai-compaction/src/index.test.ts b/packages/ai-compaction/src/index.test.ts index 77fbb43ab9..48d79bfc73 100644 --- a/packages/ai-compaction/src/index.test.ts +++ b/packages/ai-compaction/src/index.test.ts @@ -5,7 +5,13 @@ import type { ModelMessage, ToolCall, } from '@tanstack/ai' -import { estimateMessageTokens, withCompaction } from './index' +import { + clearToolResults, + estimateMessageTokens, + evictOldest, + summarizeOldest, + withCompaction, +} from './index' // Minimal onConfig driver. The middleware ignores ctx, so a bare stub is fine. // oxlint-disable-next-line eslint-js/no-restricted-syntax -- test stub; onConfig never reads ctx @@ -29,6 +35,12 @@ const text = (role: ModelMessage['role'], content: string): ModelMessage => ({ // ~40 tokens each at chars/4. const big = (role: ModelMessage['role']) => text(role, 'x'.repeat(160)) +const call: ToolCall = { + id: 't1', + type: 'function', + function: { name: 'f', arguments: '{}' }, +} + describe('withCompaction', () => { it('passes through when under the token budget', async () => { const mw = withCompaction({ maxTokens: 1000 }) @@ -39,42 +51,44 @@ describe('withCompaction', () => { expect(result).toBeUndefined() }) - it('evicts the head with a marker when no summarizer is given', async () => { - const mw = withCompaction({ maxTokens: 100, keepRecentTokens: 50 }) + it('defaults to evictOldest', async () => { + const mw = withCompaction({ maxTokens: 100 }) const msgs = [big('user'), big('assistant'), big('user'), big('assistant')] const result = await runOnConfig(mw, msgs) - expect(result).toBeTruthy() const out = result?.messages ?? [] expect(out[0]?.content).toContain('omitted') - // recent tail is preserved verbatim expect(out[out.length - 1]).toBe(msgs[msgs.length - 1]) }) - it('summarizes the head when a summarizer is given', async () => { - const summarize = vi.fn(async () => 'the gist') - const mw = withCompaction({ - maxTokens: 100, - keepRecentTokens: 50, - summarize, - }) - const result = await runOnConfig(mw, [ + it('reports before/after token and message counts via onCompact', async () => { + const onCompact = vi.fn() + const mw = withCompaction({ maxTokens: 100, onCompact }) + await runOnConfig(mw, [ big('user'), big('assistant'), big('user'), big('assistant'), ]) - expect(summarize).toHaveBeenCalledOnce() - expect(result?.messages?.[0]?.content).toBe( - 'Summary of earlier conversation:\nthe gist', - ) + expect(onCompact).toHaveBeenCalledOnce() + const info = onCompact.mock.calls[0]?.[0] + expect(info.after).toBeLessThan(info.before) + expect(info.messagesAfter).toBeLessThan(info.messagesBefore) + }) +}) + +describe('evictOldest', () => { + it('keeps the recent tail and drops the head', async () => { + const mw = withCompaction({ + maxTokens: 100, + strategy: evictOldest({ keepRecentTokens: 50 }), + }) + const msgs = [big('user'), big('assistant'), big('user'), big('assistant')] + const out = (await runOnConfig(mw, msgs))?.messages ?? [] + expect(out[0]?.content).toContain('omitted') + expect(out[out.length - 1]).toBe(msgs[msgs.length - 1]) }) it('never lets the tail start with an orphaned tool result', async () => { - const call: ToolCall = { - id: 't1', - type: 'function', - function: { name: 'f', arguments: '{}' }, - } const assistantCall: ModelMessage = { role: 'assistant', content: 'x'.repeat(160), @@ -86,40 +100,77 @@ describe('withCompaction', () => { toolCallId: 't1', } const msgs = [big('user'), assistantCall, toolResult, big('user')] - const mw = withCompaction({ maxTokens: 100, keepRecentTokens: 45 }) - const result = await runOnConfig(mw, msgs) - const out = result?.messages ?? [] - // The tool result was folded into the dropped head, so nothing after the - // note is an orphaned tool message. + const mw = withCompaction({ + maxTokens: 100, + strategy: evictOldest({ keepRecentTokens: 45 }), + }) + const out = (await runOnConfig(mw, msgs))?.messages ?? [] expect(out.slice(1).some((m) => m.role === 'tool')).toBe(false) }) +}) - it('reports before/after via onCompact', async () => { - const onCompact = vi.fn() +describe('summarizeOldest', () => { + it('replaces the head with a summary', async () => { + const summarize = vi.fn(async () => 'the gist') const mw = withCompaction({ maxTokens: 100, - keepRecentTokens: 50, - onCompact, + strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }), }) - await runOnConfig(mw, [ + const result = await runOnConfig(mw, [ big('user'), big('assistant'), big('user'), big('assistant'), ]) - expect(onCompact).toHaveBeenCalledOnce() - const info = onCompact.mock.calls[0]?.[0] - expect(info.after).toBeLessThan(info.before) - expect(info.droppedMessages).toBeGreaterThan(0) + expect(summarize).toHaveBeenCalledOnce() + expect(result?.messages?.[0]?.content).toBe( + 'Summary of earlier conversation:\nthe gist', + ) }) +}) - it('rejects keepRecentTokens >= maxTokens', () => { - expect(() => - withCompaction({ maxTokens: 100, keepRecentTokens: 100 }), - ).toThrow() +describe('clearToolResults', () => { + const toolMsg = (id: string): ModelMessage => ({ + role: 'tool', + content: 'x'.repeat(400), + toolCallId: id, }) - it('estimateMessageTokens counts content and tool calls', () => { + it('stubs old tool results but keeps recent ones and message count', async () => { + const msgs: Array = [ + text('user', 'go'), + toolMsg('a'), + toolMsg('b'), + toolMsg('c'), + toolMsg('d'), + ] + const mw = withCompaction({ + maxTokens: 100, + strategy: clearToolResults({ keepRecentToolResults: 2 }), + }) + const out = (await runOnConfig(mw, msgs))?.messages ?? [] + // Same number of messages — structure is untouched. + expect(out.length).toBe(msgs.length) + // Oldest two tool results are stubbed. + expect(out[1]?.content).toBe('[tool output cleared to save context]') + expect(out[2]?.content).toBe('[tool output cleared to save context]') + // Two most recent tool results are untouched. + expect(out[3]?.content).toBe('x'.repeat(400)) + expect(out[4]?.content).toBe('x'.repeat(400)) + }) + + it('no-ops when there are not enough tool results to clear', async () => { + const msgs: Array = [big('user'), toolMsg('a'), big('user')] + const mw = withCompaction({ + maxTokens: 50, + strategy: clearToolResults({ keepRecentToolResults: 3 }), + }) + expect(await runOnConfig(mw, msgs)).toBeUndefined() + }) +}) + +describe('estimateMessageTokens', () => { + it('counts content and tool calls', () => { expect(estimateMessageTokens(text('user', 'x'.repeat(40)))).toBe(10) }) }) diff --git a/packages/ai-compaction/src/index.ts b/packages/ai-compaction/src/index.ts index e193039764..0e06dd57ad 100644 --- a/packages/ai-compaction/src/index.ts +++ b/packages/ai-compaction/src/index.ts @@ -1,10 +1,13 @@ /** * `@tanstack/ai-compaction` — context-window compaction as a `chat()` - * middleware. When the working message set grows past `maxTokens`, it keeps the - * recent tail verbatim and replaces the older head with a single note — either a - * summary (if you pass `summarize`) or an eviction marker. Runs before every - * model call, so compaction is incremental and rolling: a later compaction - * re-folds the previous summary into the next one. + * middleware. `withCompaction({ maxTokens, strategy })` runs before each model + * call: when the working message set grows past `maxTokens`, the chosen + * `CompactionStrategy` rewrites the messages. Because it runs every call, + * compaction is incremental and rolling. + * + * Strategies are pluggable, mirroring `AgentLoopStrategy`. Three are built in: + * {@link evictOldest}, {@link summarizeOldest}, and {@link clearToolResults}. + * Write your own by passing any {@link CompactionStrategy}. * * The system prompt is never touched — `chat()` keeps it separate from * `messages`. @@ -21,39 +24,165 @@ export function estimateMessageTokens(message: ModelMessage): number { return Math.ceil(text.length / 4) } +/** What a {@link CompactionStrategy} receives alongside the messages. */ +export interface CompactionContext { + /** The `maxTokens` budget from `withCompaction`. */ + maxTokens: number + /** The shared token estimator (default {@link estimateMessageTokens}). */ + estimate: (message: ModelMessage) => number +} + +/** + * Shrinks a message list. Called only when the estimate is over budget. + * Return the rewritten messages, or `null` to leave them unchanged. + */ +export type CompactionStrategy = ( + messages: ReadonlyArray, + ctx: CompactionContext, +) => Array | null | Promise | null> + /** Reported to `onCompact` after each compaction event. */ export interface CompactionInfo { /** Estimated tokens before compaction. */ before: number /** Estimated tokens after compaction. */ after: number - /** How many head messages were folded into the note. */ - droppedMessages: number - /** True when a `summarize` callback produced the note. */ - summarized: boolean + /** Message count before compaction. */ + messagesBefore: number + /** Message count after compaction (unchanged for {@link clearToolResults}). */ + messagesAfter: number } export interface CompactionOptions { /** Compact when estimated tokens across `messages` exceed this. */ maxTokens: number - /** - * Tokens of the most recent messages to always keep verbatim. - * Default: `floor(maxTokens / 2)`. Must be `< maxTokens`. - */ - keepRecentTokens?: number + /** How to shrink the messages. Default: {@link evictOldest}. */ + strategy?: CompactionStrategy /** Per-message token estimator. Default: {@link estimateMessageTokens}. */ estimateTokens?: (message: ModelMessage) => number - /** - * Summarize the dropped head into prose. Omit to evict (drop) it with a short - * marker instead. Wire this to `summarize()` or any LLM call. - */ - summarize?: (messages: Array) => Promise - /** Role of the injected note. Default `'user'`. */ - summaryRole?: 'user' | 'assistant' /** Observe each compaction (logging, metrics). */ onCompact?: (info: CompactionInfo) => void } +const sum = ( + messages: ReadonlyArray, + estimate: (m: ModelMessage) => number, +) => messages.reduce((total, m) => total + estimate(m), 0) + +/** + * Find the split point that keeps the most recent messages up to + * `keepRecentTokens`, then moves the cut forward past any leading tool result + * so the kept tail never starts with an orphan (its tool call would be dropped). + * Returns the index where the tail begins (head is `messages[0..cut)`). + */ +function splitAtRecent( + messages: ReadonlyArray, + estimate: (m: ModelMessage) => number, + keepRecentTokens: number, +): number { + let kept = 0 + let cut = messages.length + while (cut > 0) { + const prev = messages[cut - 1] + if (!prev) break + const size = estimate(prev) + if (kept + size > keepRecentTokens) break + kept += size + cut-- + } + // Always keep at least the last message. + if (cut >= messages.length) cut = messages.length - 1 + while (cut < messages.length && messages[cut]?.role === 'tool') cut++ + return cut +} + +/** + * Drop the oldest messages and replace them with a short marker. Cheapest + * strategy — no extra model call. This is the default. + */ +export function evictOldest( + options: { + /** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */ + keepRecentTokens?: number + /** Build the marker that replaces the dropped head. */ + marker?: (droppedCount: number) => string + } = {}, +): CompactionStrategy { + return (messages, ctx) => { + const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2) + const cut = splitAtRecent(messages, ctx.estimate, keep) + // ponytail: can't shrink past the recent window; raise keepRecentTokens or + // lower maxTokens if compaction never fires. + if (cut <= 0) return null + const marker = + options.marker?.(cut) ?? + `[${cut} earlier message(s) omitted to save context.]` + return [{ role: 'user', content: marker }, ...messages.slice(cut)] + } +} + +/** + * Drop the oldest messages and replace them with an LLM summary. Keeps the gist + * of old turns at the cost of one summarization call. Wire `summarize` to + * `summarize()` or any model call. + */ +export function summarizeOldest(options: { + summarize: (messages: Array) => Promise + /** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */ + keepRecentTokens?: number + /** Role of the injected summary message. Default `'user'`. */ + summaryRole?: 'user' | 'assistant' +}): CompactionStrategy { + return async (messages, ctx) => { + const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2) + const cut = splitAtRecent(messages, ctx.estimate, keep) + if (cut <= 0) return null + const summary = await options.summarize(messages.slice(0, cut)) + return [ + { + role: options.summaryRole ?? 'user', + content: `Summary of earlier conversation:\n${summary}`, + }, + ...messages.slice(cut), + ] + } +} + +/** + * Replace the content of old tool-result messages with a stub, keeping every + * message and its tool-call pairing in place. Best for agent loops where tool + * output (file reads, command output) dominates the token count — it clears the + * bulk without disturbing the conversation shape. No extra model call. + */ +export function clearToolResults( + options: { + /** Number of most-recent tool results to keep verbatim. Default `3`. */ + keepRecentToolResults?: number + /** Text that replaces a cleared tool result. */ + stub?: string + } = {}, +): CompactionStrategy { + const keepN = options.keepRecentToolResults ?? 3 + const stub = options.stub ?? '[tool output cleared to save context]' + return (messages) => { + const toolIndexes: Array = [] + messages.forEach((m, i) => { + if (m.role === 'tool') toolIndexes.push(i) + }) + if (toolIndexes.length <= keepN) return null + const clearBefore = toolIndexes[toolIndexes.length - keepN] ?? 0 + let changed = false + const next = messages.map((m, i) => { + if (m.role === 'tool' && i < clearBefore && m.content !== stub) { + changed = true + return { ...m, content: stub } + } + return m + }) + return changed ? next : null + } +} + /** * Context-compaction middleware. Add to `chat({ middleware: [...] })`. * @@ -62,67 +191,32 @@ export interface CompactionOptions { * chat({ * adapter, * messages, - * middleware: [ - * withCompaction({ - * maxTokens: 100_000, - * summarize: (msgs) => summarizeToString(adapter, msgs), - * }), - * ], + * middleware: [withCompaction({ maxTokens: 100_000 })], // evictOldest by default * }) * ``` */ export function withCompaction(options: CompactionOptions): ChatMiddleware { - const keepRecentTokens = - options.keepRecentTokens ?? Math.floor(options.maxTokens / 2) - if (keepRecentTokens >= options.maxTokens) { - throw new Error( - `withCompaction: keepRecentTokens (${keepRecentTokens}) must be < maxTokens (${options.maxTokens})`, - ) - } const estimate = options.estimateTokens ?? estimateMessageTokens - const summaryRole = options.summaryRole ?? 'user' + const strategy = options.strategy ?? evictOldest() return { name: 'compaction', async onConfig(_ctx, config) { const { messages } = config - const sizes = messages.map(estimate) - const total = sizes.reduce((a, b) => a + b, 0) - if (total <= options.maxTokens) return + const before = sum(messages, estimate) + if (before <= options.maxTokens) return - // Walk back from the end, keeping recent messages up to keepRecentTokens. - let kept = 0 - let cut = messages.length - while (cut > 0) { - const size = sizes[cut - 1] ?? 0 - if (kept + size > keepRecentTokens) break - kept += size - cut-- - } - // Always keep at least the last message. - if (cut >= messages.length) cut = messages.length - 1 - // Integrity: the tail must not start with an orphaned tool result (its - // matching tool call would be in the dropped head). Fold leading tool - // results back into the head — which becomes prose, so no dangling call. - while (cut < messages.length && messages[cut]?.role === 'tool') cut++ - - const head = messages.slice(0, cut) - // ponytail: can't shrink past the recent window; raise keepRecentTokens - // or lower maxTokens if this fires every turn. - if (head.length === 0) return - const tail = messages.slice(cut) - - const note = options.summarize - ? `Summary of earlier conversation:\n${await options.summarize(head)}` - : `[${head.length} earlier message(s) omitted to save context.]` - const noteMessage: ModelMessage = { role: summaryRole, content: note } - const next = [noteMessage, ...tail] + const next = await strategy(messages, { + maxTokens: options.maxTokens, + estimate, + }) + if (!next || next === messages) return options.onCompact?.({ - before: total, - after: next.reduce((a, m) => a + estimate(m), 0), - droppedMessages: head.length, - summarized: Boolean(options.summarize), + before, + after: sum(next, estimate), + messagesBefore: messages.length, + messagesAfter: next.length, }) return { messages: next } diff --git a/testing/e2e/src/routes/api.compaction-wire.ts b/testing/e2e/src/routes/api.compaction-wire.ts index 576ee59882..7161b169f8 100644 --- a/testing/e2e/src/routes/api.compaction-wire.ts +++ b/testing/e2e/src/routes/api.compaction-wire.ts @@ -1,7 +1,12 @@ import { createFileRoute } from '@tanstack/react-router' import { chat, createChatOptions, maxIterations } from '@tanstack/ai' import { createOpenaiChat } from '@tanstack/ai-openai' -import { withCompaction } from '@tanstack/ai-compaction' +import { + clearToolResults, + evictOldest, + withCompaction, +} from '@tanstack/ai-compaction' +import type { CompactionStrategy } from '@tanstack/ai-compaction' import type { ModelMessage } from '@tanstack/ai' const DUMMY_KEY = 'sk-e2e-test-dummy-key' @@ -60,37 +65,69 @@ function makeTextStream(): ReadableStream { const FILLER = 'x'.repeat(160) +// evict: oldest message carries SECRET_ALPHA_ONE, newest carries KEEP_ME_LAST. +const evictMessages: Array = [ + { role: 'user', content: `SECRET_ALPHA_ONE ${FILLER}` }, + { role: 'assistant', content: FILLER }, + { role: 'user', content: FILLER }, + { role: 'assistant', content: FILLER }, + { role: 'user', content: `KEEP_ME_LAST ${FILLER}` }, +] + +// clear: two tool results. Oldest carries SECRET_TOOL_ALPHA (should be stubbed), +// newest carries KEEP_TOOL_BETA (kept). All messages stay in place. +const clearMessages: Array = [ + { role: 'user', content: 'run the tools' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { id: 'a', type: 'function', function: { name: 'f', arguments: '{}' } }, + ], + }, + { role: 'tool', content: `SECRET_TOOL_ALPHA ${FILLER}`, toolCallId: 'a' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { id: 'b', type: 'function', function: { name: 'f', arguments: '{}' } }, + ], + }, + { role: 'tool', content: `KEEP_TOOL_BETA ${FILLER}`, toolCallId: 'b' }, + { role: 'user', content: 'done?' }, +] + /** * Wire-format verification for `withCompaction`. A capturing `fetch` records the - * outgoing request body. We send a long history whose oldest message carries a - * marker (`SECRET_ALPHA_ONE`) and whose newest carries another (`KEEP_ME_LAST`), - * with a small `maxTokens`. The captured body must show the old marker evicted, - * the compaction note present, and the recent marker preserved. + * outgoing request body so the spec can assert what each strategy sent. + * + * `?strategy=clear` uses `clearToolResults` on a tool-heavy history; anything + * else uses `evictOldest` on a plain chat history. */ export const Route = createFileRoute('/api/compaction-wire')({ server: { handlers: { - POST: async () => { + POST: async ({ request }) => { + const clear = + new URL(request.url).searchParams.get('strategy') === 'clear' + let firstRequestBody: unknown const mockFetch: typeof fetch = async (input, init) => { - const request = + const req = input instanceof Request ? input : new Request(input, init) if (firstRequestBody === undefined) { - firstRequestBody = JSON.parse(await request.text()) + firstRequestBody = JSON.parse(await req.text()) } return new Response(makeTextStream(), { headers: { 'Content-Type': 'text/event-stream' }, }) } - const messages: Array = [ - { role: 'user', content: `SECRET_ALPHA_ONE ${FILLER}` }, - { role: 'assistant', content: FILLER }, - { role: 'user', content: FILLER }, - { role: 'assistant', content: FILLER }, - { role: 'user', content: `KEEP_ME_LAST ${FILLER}` }, - ] + const messages = clear ? clearMessages : evictMessages + const strategy: CompactionStrategy = clear + ? clearToolResults({ keepRecentToolResults: 1 }) + : evictOldest({ keepRecentTokens: 45 }) const adapter = createOpenaiChat('gpt-5.2', DUMMY_KEY, { fetch: mockFetch, @@ -100,9 +137,7 @@ export const Route = createFileRoute('/api/compaction-wire')({ for await (const _ of chat({ ...createChatOptions({ adapter }), messages, - middleware: [ - withCompaction({ maxTokens: 60, keepRecentTokens: 45 }), - ], + middleware: [withCompaction({ maxTokens: 60, strategy })], agentLoopStrategy: maxIterations(1), })) { // Drain the stream. diff --git a/testing/e2e/tests/compaction-wire.spec.ts b/testing/e2e/tests/compaction-wire.spec.ts index 32fe1047af..5c9a63b1fc 100644 --- a/testing/e2e/tests/compaction-wire.spec.ts +++ b/testing/e2e/tests/compaction-wire.spec.ts @@ -27,4 +27,25 @@ test.describe('withCompaction — wire format', () => { // The oldest message is gone. expect(wire).not.toContain('SECRET_ALPHA_ONE') }) + + test('clearToolResults stubs old tool output and keeps the recent one', async ({ + request, + }) => { + const response = await request.post('/api/compaction-wire?strategy=clear') + expect(response.ok()).toBe(true) + const result = (await response.json()) as { + ok: boolean + error?: string + firstRequestBody: unknown + } + if (!result.ok) throw new Error(`Route failed: ${result.error}`) + + const wire = JSON.stringify(result.firstRequestBody) + // The most recent tool result is preserved verbatim. + expect(wire).toContain('KEEP_TOOL_BETA') + // The old tool result content is replaced by the stub. + expect(wire).toContain('tool output cleared') + // The old tool result content is gone. + expect(wire).not.toContain('SECRET_TOOL_ALPHA') + }) }) diff --git a/testing/panel/src/routes/api.compaction-chat.ts b/testing/panel/src/routes/api.compaction-chat.ts index ebc45f4840..80923eb474 100644 --- a/testing/panel/src/routes/api.compaction-chat.ts +++ b/testing/panel/src/routes/api.compaction-chat.ts @@ -5,7 +5,11 @@ import { maxIterations, toServerSentEventsResponse, } from '@tanstack/ai' -import { withCompaction } from '@tanstack/ai-compaction' +import { + evictOldest, + summarizeOldest, + withCompaction, +} from '@tanstack/ai-compaction' import { anthropicText } from '@tanstack/ai-anthropic' import { geminiText } from '@tanstack/ai-gemini' import { grokText } from '@tanstack/ai-grok' @@ -13,8 +17,31 @@ import { openaiText } from '@tanstack/ai-openai' import { ollamaText } from '@tanstack/ai-ollama' import { openRouterText } from '@tanstack/ai-openrouter' import { recordCompaction } from '@/lib/compaction-store' +import type { AnyTextAdapter, ModelMessage } from '@tanstack/ai' import type { Provider } from '@/lib/model-selection' +// Provider-agnostic summary: one throwaway chat() turn on the same adapter. +async function summarizeWith( + adapter: AnyTextAdapter, + messages: Array, +): Promise { + let text = '' + for await (const chunk of chat({ + adapter, + messages: [ + ...messages, + { + role: 'user', + content: 'Summarize the conversation above in 3-4 sentences.', + }, + ], + agentLoopStrategy: maxIterations(1), + })) { + if (chunk.type === 'TEXT_MESSAGE_CONTENT') text += chunk.delta + } + return text +} + const SYSTEM_PROMPT = `You are a helpful assistant. Keep answers reasonably long (a paragraph or two) so this demo's context fills up quickly.` @@ -51,6 +78,8 @@ export const Route = createFileRoute('/api/compaction-chat')({ typeof data.maxTokens === 'number' && data.maxTokens > 0 ? data.maxTokens : 400 + const strategyName: 'evict' | 'summarize' = + data.strategy === 'summarize' ? 'summarize' : 'evict' try { const adapterConfig = { @@ -83,8 +112,16 @@ export const Route = createFileRoute('/api/compaction-chat')({ const options = adapterConfig[provider]() const { adapter } = options + const strategy = + strategyName === 'summarize' + ? summarizeOldest({ + summarize: (msgs) => summarizeWith(adapter, msgs), + }) + : evictOldest() + const compaction = withCompaction({ maxTokens, + strategy, onCompact: (info) => recordCompaction(threadId, info), }) diff --git a/testing/panel/src/routes/compaction.tsx b/testing/panel/src/routes/compaction.tsx index 6834593d0f..7495ed0730 100644 --- a/testing/panel/src/routes/compaction.tsx +++ b/testing/panel/src/routes/compaction.tsx @@ -13,8 +13,8 @@ const THREAD_STORAGE_KEY = 'panel-compaction-thread' interface CompactionEvent { before: number after: number - droppedMessages: number - summarized: boolean + messagesBefore: number + messagesAfter: number at: number } interface InspectResponse { @@ -34,6 +34,7 @@ function CompactionPage() { ) const [threadId, setThreadId] = useState('') const [maxTokens, setMaxTokens] = useState(400) + const [strategy, setStrategy] = useState<'evict' | 'summarize'>('evict') const [inspect, setInspect] = useState(null) const [input, setInput] = useState('') @@ -52,8 +53,15 @@ function CompactionPage() { model: selectedModel.model, threadId, maxTokens, + strategy, }), - [selectedModel.provider, selectedModel.model, threadId, maxTokens], + [ + selectedModel.provider, + selectedModel.model, + threadId, + maxTokens, + strategy, + ], ) const { messages, sendMessage, isLoading } = useChat({ @@ -147,6 +155,26 @@ function CompactionPage() { className="w-full accent-cyan-500" />
+
+ + +
@@ -247,9 +275,7 @@ function CompactionPage() { >
- {ev.summarized ? 'Summarized' : 'Evicted'}{' '} - {ev.droppedMessages} message - {ev.droppedMessages === 1 ? '' : 's'} + Compacted {ev.messagesBefore} → {ev.messagesAfter} messages
{ev.before} → {ev.after} tokens (− From f9d6e1e66d4fb98673137c01fa1463e381f7a49c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:39:40 +0000 Subject: [PATCH 05/13] ci: apply automated fixes --- packages/ai-compaction/README.md | 42 ++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md index 20b0a81769..719b86bb65 100644 --- a/packages/ai-compaction/README.md +++ b/packages/ai-compaction/README.md @@ -30,11 +30,11 @@ chat({ Pass `strategy` to change how the history shrinks. Three are built in. -| Strategy | What it does | Cost | -| --- | --- | --- | -| `evictOldest` (default) | Drop the oldest messages, leave a marker | No extra model call | -| `summarizeOldest` | Replace the oldest messages with an LLM summary | One summarize call | -| `clearToolResults` | Stub the content of old tool results, keep the messages | No extra model call | +| Strategy | What it does | Cost | +| ----------------------- | ------------------------------------------------------- | ------------------- | +| `evictOldest` (default) | Drop the oldest messages, leave a marker | No extra model call | +| `summarizeOldest` | Replace the oldest messages with an LLM summary | One summarize call | +| `clearToolResults` | Stub the content of old tool results, keep the messages | No extra model call | ```ts import { @@ -45,7 +45,10 @@ import { } from '@tanstack/ai-compaction' // Tune how much recent history to keep. -withCompaction({ maxTokens: 100_000, strategy: evictOldest({ keepRecentTokens: 40_000 }) }) +withCompaction({ + maxTokens: 100_000, + strategy: evictOldest({ keepRecentTokens: 40_000 }), +}) // Summarize instead of dropping. `summarize` gets the messages being removed. withCompaction({ @@ -54,7 +57,10 @@ withCompaction({ }) // Best for agent loops: stub old tool output, keep the messages in place. -withCompaction({ maxTokens: 100_000, strategy: clearToolResults({ keepRecentToolResults: 5 }) }) +withCompaction({ + maxTokens: 100_000, + strategy: clearToolResults({ keepRecentToolResults: 5 }), +}) ``` ### Write your own @@ -74,20 +80,20 @@ const keepLastOnly: CompactionStrategy = (messages) => ### `withCompaction` -| Option | Default | What it does | -| --- | --- | --- | -| `maxTokens` | (required) | Compact when estimated tokens exceed this. | -| `strategy` | `evictOldest()` | How to shrink the messages. | -| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | -| `onCompact` | — | Observe each compaction (`before`/`after`/`messagesBefore`/`messagesAfter`). | +| Option | Default | What it does | +| ---------------- | --------------- | ---------------------------------------------------------------------------- | +| `maxTokens` | (required) | Compact when estimated tokens exceed this. | +| `strategy` | `evictOldest()` | How to shrink the messages. | +| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | +| `onCompact` | — | Observe each compaction (`before`/`after`/`messagesBefore`/`messagesAfter`). | ### Strategy options -| Strategy | Options | -| --- | --- | -| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | -| `summarizeOldest` | `summarize` (required), `keepRecentTokens`, `summaryRole` | -| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | +| Strategy | Options | +| ------------------ | --------------------------------------------------------- | +| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | +| `summarizeOldest` | `summarize` (required), `keepRecentTokens`, `summaryRole` | +| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | The token estimate is a rough `chars / 4` heuristic, good enough to trigger on, not exact. Pass `estimateTokens` if you need provider-accurate counts. From 4dea0fbf6b9de9ddb71a962f1a483e50d159a91b Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 24 Aug 2026 16:45:28 -0700 Subject: [PATCH 06/13] feat(ai-compaction): add composeStrategies combinator composeStrategies runs strategies in order and escalates: it stops once the transcript is back under maxTokens. Lets you clear old tool output first and fall back to evicting old messages only when that isn't enough. Docs, README, and unit tests included. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/ai-compaction.md | 6 ++- docs/advanced/compaction.md | 18 ++++++++ packages/ai-compaction/README.md | 20 +++++++++ packages/ai-compaction/src/index.test.ts | 56 ++++++++++++++++++++++++ packages/ai-compaction/src/index.ts | 33 ++++++++++++++ 5 files changed, 131 insertions(+), 2 deletions(-) diff --git a/.changeset/ai-compaction.md b/.changeset/ai-compaction.md index 076a49985a..80a443e750 100644 --- a/.changeset/ai-compaction.md +++ b/.changeset/ai-compaction.md @@ -7,5 +7,7 @@ middleware. `withCompaction({ maxTokens, strategy })` runs a pluggable `CompactionStrategy` before each model call, so compaction is incremental and rolling. Three strategies ship built in: `evictOldest` (drop old messages, the default), `summarizeOldest` (replace them with an LLM summary), and -`clearToolResults` (stub old tool output, keep the messages). Strategies -preserve tool-call/result pairing and never touch the system prompt. +`clearToolResults` (stub old tool output, keep the messages). Combine them with +`composeStrategies`, which escalates through strategies until the transcript is +back under budget. Strategies preserve tool-call/result pairing and never touch +the system prompt. diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md index 217c34dc3c..c7f2e100fe 100644 --- a/docs/advanced/compaction.md +++ b/docs/advanced/compaction.md @@ -138,6 +138,24 @@ const keepLastOnly: CompactionStrategy = (messages) => { withCompaction({ maxTokens: 100_000, strategy: keepLastOnly }); ``` +## Combine strategies + +`composeStrategies` runs several strategies in order and **escalates**: it stops as soon as the result is back under `maxTokens`. Put the cheap, targeted strategy first and a broad fallback last. Here it clears old tool output first, and only drops old messages if that was not enough. + +```typescript +import { + withCompaction, + composeStrategies, + clearToolResults, + evictOldest, +} from "@tanstack/ai-compaction"; + +withCompaction({ + maxTokens: 100_000, + strategy: composeStrategies(clearToolResults(), evictOldest()), +}); +``` + ## Options ### withCompaction diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md index 719b86bb65..3184a9a716 100644 --- a/packages/ai-compaction/README.md +++ b/packages/ai-compaction/README.md @@ -63,6 +63,26 @@ withCompaction({ }) ``` +### Combine them + +`composeStrategies` runs strategies in order and escalates: it stops once the +result is back under `maxTokens`. Put the cheap one first. + +```ts +import { + withCompaction, + composeStrategies, + clearToolResults, + evictOldest, +} from '@tanstack/ai-compaction' + +// Clear old tool output first; only drop old messages if that isn't enough. +withCompaction({ + maxTokens: 100_000, + strategy: composeStrategies(clearToolResults(), evictOldest()), +}) +``` + ### Write your own A strategy gets the messages and the budget, and returns the rewritten messages diff --git a/packages/ai-compaction/src/index.test.ts b/packages/ai-compaction/src/index.test.ts index 48d79bfc73..1053ce43e2 100644 --- a/packages/ai-compaction/src/index.test.ts +++ b/packages/ai-compaction/src/index.test.ts @@ -7,6 +7,7 @@ import type { } from '@tanstack/ai' import { clearToolResults, + composeStrategies, estimateMessageTokens, evictOldest, summarizeOldest, @@ -169,6 +170,61 @@ describe('clearToolResults', () => { }) }) +describe('composeStrategies', () => { + const assistantCall = (id: string): ModelMessage => ({ + role: 'assistant', + content: '', + toolCalls: [ + { id, type: 'function', function: { name: 'f', arguments: '{}' } }, + ], + }) + const toolMsg = (id: string): ModelMessage => ({ + role: 'tool', + content: 'x'.repeat(800), // ~200 tokens + toolCallId: id, + }) + const history = (): Array => [ + text('user', 'HEAD_MARKER'), + assistantCall('a'), + toolMsg('a'), + assistantCall('b'), + toolMsg('b'), + text('user', 'last'), + ] + + it('stops after the first strategy once back under budget', async () => { + const mw = withCompaction({ + maxTokens: 260, + strategy: composeStrategies( + clearToolResults({ keepRecentToolResults: 1 }), + evictOldest({ keepRecentTokens: 50 }), + ), + }) + const msgs = history() + const out = (await runOnConfig(mw, msgs))?.messages ?? [] + // Clearing one tool result was enough, so evict never ran: + // the head message and full message count survive. + expect(out.length).toBe(msgs.length) + expect(out.some((m) => m.content === 'HEAD_MARKER')).toBe(true) + expect(out[2]?.content).toBe('[tool output cleared to save context]') + }) + + it('escalates to the next strategy when the first is not enough', async () => { + const mw = withCompaction({ + maxTokens: 60, + strategy: composeStrategies( + clearToolResults({ keepRecentToolResults: 1 }), + evictOldest({ keepRecentTokens: 30 }), + ), + }) + const msgs = history() + const out = (await runOnConfig(mw, msgs))?.messages ?? [] + // Clearing was not enough, so evict ran too: the head is dropped. + expect(out.some((m) => m.content === 'HEAD_MARKER')).toBe(false) + expect(out[0]?.content).toContain('omitted') + }) +}) + describe('estimateMessageTokens', () => { it('counts content and tool calls', () => { expect(estimateMessageTokens(text('user', 'x'.repeat(40)))).toBe(10) diff --git a/packages/ai-compaction/src/index.ts b/packages/ai-compaction/src/index.ts index 0e06dd57ad..1fd3a169d5 100644 --- a/packages/ai-compaction/src/index.ts +++ b/packages/ai-compaction/src/index.ts @@ -183,6 +183,39 @@ export function clearToolResults( } } +/** + * Run several strategies in order, escalating: stop as soon as the running + * estimate is back under `maxTokens`. Put the cheap, targeted strategy first + * (for example {@link clearToolResults}) and a broad fallback last (for example + * {@link evictOldest}) — the fallback only runs when clearing was not enough. + * A strategy that returns `null` (no change) is skipped and the next one runs. + * + * @example + * ```ts + * withCompaction({ + * maxTokens: 100_000, + * strategy: composeStrategies(clearToolResults(), evictOldest()), + * }) + * ``` + */ +export function composeStrategies( + ...strategies: Array +): CompactionStrategy { + return async (messages, ctx) => { + let current: ReadonlyArray = messages + let result: Array | null = null + for (const strategy of strategies) { + if (sum(current, ctx.estimate) <= ctx.maxTokens) break + const out = await strategy(current, ctx) + if (out) { + current = out + result = out + } + } + return result + } +} + /** * Context-compaction middleware. Add to `chat({ middleware: [...] })`. * From 058921e523d728d72b8c11d4cb545717686c6b03 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 25 Aug 2026 11:06:17 -0700 Subject: [PATCH 07/13] docs: document compaction + persistence interaction and test the seam Server-side `withPersistence` and `withCompaction` share the run's message array. Compaction rewrites it in `onConfig`, and `withPersistence.onFinish` saves that array with a full-overwrite `saveThread`, so the stored thread becomes the compacted one. This was undocumented and untested. - Add a "Compaction and persistence" section to the compaction guide, plus a callout on the chat-persistence page, with the ways to keep a full transcript. - Add a with-persistence unit test that drops a message in `onConfig` and asserts the saved thread is the compacted set. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/advanced/compaction.md | 15 +++++++ docs/config.json | 5 ++- docs/persistence/chat-persistence.md | 9 ++++ .../tests/with-persistence.test.ts | 42 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md index c7f2e100fe..8ef68e4980 100644 --- a/docs/advanced/compaction.md +++ b/docs/advanced/compaction.md @@ -183,6 +183,21 @@ The token count is a rough `characters / 4` estimate. It is good enough to trigg - **Tool calls stay paired with their results.** The built-in strategies never leave an orphaned tool result, so the request stays valid. - **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again, and a later `summarizeOldest` pass folds an earlier summary into the new one. +## Compaction and persistence + +Compaction rewrites the messages the model sees. If you also save the thread on the server, know which copy you save. + +`withCompaction` and server-side [`withPersistence`](../persistence/chat-persistence) share one message array for the run. Compaction shrinks that array, and `withPersistence` saves it on finish with a full-overwrite `saveThread`. So the stored thread becomes the compacted one. Dropped, summarized, or stubbed messages are gone from the store. The middleware order does not change this. + +This is what you want when the compacted thread is the memory. It is data loss when you expected the store to keep every message. + +Two ways to keep a full transcript and still compact: + +- **Client-authoritative persistence.** The browser keeps the full transcript. The server compacts only for the model call. See [Client persistence](../persistence/client-persistence). +- **Save the transcript yourself first.** Persist the incoming `messages`, then call `chat()` with compaction. + +Do you use server-side [Chat persistence](../persistence/chat-persistence) and want the saved thread to stay readable? Prefer `clearToolResults` or `summarizeOldest` over `evictOldest`. They keep the shape of the conversation instead of dropping turns. + ## Next steps - [Middleware](./middleware): the full hook reference and how middleware composes diff --git a/docs/config.json b/docs/config.json index d28c4accda..565aa6a2a0 100644 --- a/docs/config.json +++ b/docs/config.json @@ -278,7 +278,7 @@ "label": "Chat Persistence", "to": "persistence/chat-persistence", "addedAt": "2026-08-04", - "updatedAt": "2026-08-19" + "updatedAt": "2026-08-25" }, { "label": "Client Persistence", @@ -548,7 +548,8 @@ { "label": "Compaction", "to": "advanced/compaction", - "addedAt": "2026-08-24" + "addedAt": "2026-08-24", + "updatedAt": "2026-08-25" }, { "label": "Locks", diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md index 4496155c20..741aec7426 100644 --- a/docs/persistence/chat-persistence.md +++ b/docs/persistence/chat-persistence.md @@ -91,6 +91,15 @@ generation hooks. [How persistence works](./internals) has the rest. middleware loads the stored transcript and the run picks up from there, so the client does not have to re-send history. +## Compaction rewrites what you save + +Do you add [`withCompaction`](../advanced/compaction) to the same `chat()`? The +saved thread is the compacted one. Compaction and `withPersistence` share the +message array of the run, and `saveThread` overwrites the thread in full. The +stored transcript then matches what the model saw, not the original messages. To +keep a full transcript, see +[Compaction and persistence](../advanced/compaction#compaction-and-persistence). + ## What gets persisted, and when `withPersistence` writes at **four** moments so a reload never loses a turn: diff --git a/packages/ai-persistence/tests/with-persistence.test.ts b/packages/ai-persistence/tests/with-persistence.test.ts index 2e0db2451a..fd16059036 100644 --- a/packages/ai-persistence/tests/with-persistence.test.ts +++ b/packages/ai-persistence/tests/with-persistence.test.ts @@ -3,6 +3,7 @@ import { EventType, chat } from '@tanstack/ai' import type { AdapterYieldChunk, AnyTextAdapter, + ChatMiddleware, ModelMessage, StreamChunk, Tool, @@ -140,6 +141,47 @@ describe('withPersistence (state-only)', () => { ]) }) + it('saves the compacted transcript when a middleware drops messages in onConfig', async () => { + // Reproduces the compaction + persistence seam: a middleware (here a stand-in + // for `withCompaction`) shrinks `config.messages` in `onConfig`. The engine + // makes that the run's live message array, so `onFinish` saves the shrunk set + // via a full-overwrite `saveThread`. The dropped message is gone from the store. + const persistence = memoryPersistence() + const { adapter } = mockAdapter([ + [ev.runStarted(), ev.text('hello'), ev.runFinished()], + ]) + + const dropOldest: ChatMiddleware = { + name: 'drop-oldest', + onConfig(_ctx, config) { + if (config.messages.length <= 1) return + return { messages: config.messages.slice(1) } + }, + } + + await collect( + chat({ + adapter, + messages: [ + { role: 'user', content: 'DROP_ME_FIRST' }, + { role: 'user', content: 'KEEP_ME_LAST' }, + ], + runId: 'r1', + threadId: 't1', + middleware: [dropOldest, withPersistence(persistence)], + }) as AsyncIterable, + ) + + // The saved thread is the compacted one: the dropped message is absent, and + // the kept message plus the assistant reply remain. + const thread = await persistence.stores.messages!.loadThread('t1') + expect(thread).toEqual([ + { role: 'user', content: 'KEEP_ME_LAST' }, + expect.objectContaining({ role: 'assistant', content: 'hello' }), + ]) + expect(JSON.stringify(thread)).not.toContain('DROP_ME_FIRST') + }) + it('persists cumulative usage across model calls', async () => { const persistence = memoryPersistence() const { adapter } = mockAdapter([ From eb89d094777a44c9a93685e7ba4710b58d69f366 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 25 Aug 2026 16:45:30 -0700 Subject: [PATCH 08/13] feat(ai-persistence): stamp stable ids on persisted messages The chat engine ids assistant messages but leaves incoming user messages, engine-created tool messages, and compaction-injected messages without one. `withPersistence` now fills in an id for any message that lacks one, in place, before each `saveThread`. The same message keeps its id across a run's saves and, when the server owns the thread, across the next turn's reload. This lets a row-keyed persistence adapter reconcile by id (SELECT id, version then delete/insert/update) instead of rewriting the whole transcript. Order and version (content hash) stay the adapter's to own; see the new "Storing messages per row" section in the store reference. - ensureMessageIds() at all four save points (start, streaming snapshot, interrupt boundary, finish) plus the pending-turn seam. - Tests: every persisted message has an id, and earlier ids stay stable across a continuation turn. - Existing verbatim-transcript assertions relaxed to tolerate the added id. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/persistence-stable-message-ids.md | 12 +++ docs/config.json | 2 +- docs/persistence/store-reference.md | 25 +++++ packages/ai-persistence/src/middleware.ts | 26 +++++ .../ai-persistence/tests/interrupts.test.ts | 2 +- .../tests/with-persistence.test.ts | 102 +++++++++++++++--- 6 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 .changeset/persistence-stable-message-ids.md diff --git a/.changeset/persistence-stable-message-ids.md b/.changeset/persistence-stable-message-ids.md new file mode 100644 index 0000000000..a818d28499 --- /dev/null +++ b/.changeset/persistence-stable-message-ids.md @@ -0,0 +1,12 @@ +--- +'@tanstack/ai-persistence': minor +--- + +`withPersistence` now stamps a stable `id` on every message it saves. The chat +engine already ids assistant messages; the middleware fills one in for incoming +user messages, engine-created tool messages, and compaction-injected messages +that would otherwise be saved without one. It mutates the shared message objects, +so the same message keeps its id across a run's saves and, when the server owns +the thread, across the next turn's reload. This lets a row-keyed store reconcile +by id (`SELECT id, version` then delete/insert/update) instead of rewriting the +whole transcript. See "Storing messages per row" in the store reference. diff --git a/docs/config.json b/docs/config.json index 565aa6a2a0..71cc9d3d49 100644 --- a/docs/config.json +++ b/docs/config.json @@ -348,7 +348,7 @@ "label": "Store Reference", "to": "persistence/store-reference", "addedAt": "2026-08-04", - "updatedAt": "2026-08-14" + "updatedAt": "2026-08-25" }, { "label": "How Persistence Works", diff --git a/docs/persistence/store-reference.md b/docs/persistence/store-reference.md index 31604ea730..6540b94301 100644 --- a/docs/persistence/store-reference.md +++ b/docs/persistence/store-reference.md @@ -40,6 +40,31 @@ interface MessageStore { `saveThread` receives the full authoritative model-message history, not a delta. `loadThread` returns `[]` (never `null`) for a thread that was never saved. +### Storing messages per row + +The simplest `saveThread` writes the whole transcript as one row: a JSON blob +keyed by `threadId`. That is what the shipped adapters do, and it stays cheap +until threads get very long. + +To store one row per message instead, reconcile against what you already have +rather than rewrite everything. Every persisted message carries a stable `id`. +The middleware fills one in for any message that lacks it, including messages +that [compaction](../advanced/compaction) rewrote. So you can key rows by the +`id`: + +1. `SELECT id, version FROM messages WHERE thread_id = ?` to read the light index. +2. Diff the incoming array against it: insert new ids, delete absent ids, update + rows whose `version` changed. +3. Set `version` to a content hash, so an in-place edit (a cleared tool result) + shows up as a change. + +Keep an order column, because compaction can insert a message at the front. +Assign a sortable value once (a gapped or fractional index) so an insert does not +renumber every row. Order the load by it. + +The stable `id` holds when the server owns the thread. A client-authoritative +caller that re-sends the transcript must keep the ids itself. + ## RunStore `RunStore` and `RunRecord` come from `@tanstack/ai`; `@tanstack/ai-persistence` diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 7e8eb184e2..1f56919658 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -1,6 +1,7 @@ import { defineChatMiddleware, fromSpecTokenUsage, + generateMessageId, getDetachableRun, InterruptResumeValidationError, readInterruptBinding, @@ -45,6 +46,7 @@ import type { GenerationMiddleware, GenerationMiddlewareContext, Interrupt, + ModelMessage, PendingInterruptResumeRecord, PersistedArtifactActivity, PersistedArtifactRef, @@ -1937,6 +1939,25 @@ export interface WithPersistenceOptions { snapshotIntervalMs?: number } +/** + * Stamp a stable `id` on any message that lacks one, in place, before the + * transcript is saved. The engine always ids assistant messages but leaves + * incoming user messages (when the client omits an id), engine-created tool + * messages, and compaction-injected messages without one. Mutating the shared + * `ctx.messages` objects means the SAME message keeps its id across this run's + * saves and, when the server owns the thread, across the next turn's reload. + * That is what lets a row-keyed store reconcile by id instead of rewriting the + * whole transcript. + * + * ponytail: server-authoritative only. A client that owns the transcript must + * send its own ids; upgrade path is stamping ids in the engine if that matters. + */ +function ensureMessageIds(messages: ReadonlyArray): void { + for (const message of messages) { + if (message.id === undefined) message.id = generateMessageId() + } +} + /** * @param persistence - Must satisfy {@link ChatTranscriptStores} (messages * required). Known-absent `messages` or `interrupts` without `runs` fail at @@ -2012,6 +2033,7 @@ export function withPersistence( // The SAME rule `onConfig` applies when it merges. Kept here, in the // owner, because `saveThread` REPLACES the thread: a caller that stored // only the newly-sent list would delete the history. + ensureMessageIds(ctx.messages) const list = ctx.messages.length > 0 ? [...ctx.messages] : stored await messageStore.saveThread(ctx.threadId, list) }, @@ -2090,6 +2112,7 @@ export function withPersistence( // it before the assistant reply exists. Best-effort: a failed eager // snapshot must not abort the run — the authoritative save is `onFinish`. try { + ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [...ctx.messages]) } catch { // Eager pre-save is best-effort; the run continues and onFinish saves. @@ -2142,6 +2165,7 @@ export function withPersistence( if (now - (snapshotState.lastSnapshotAt ?? 0) >= snapshotIntervalMs) { snapshotState.lastSnapshotAt = now try { + ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [ ...ctx.messages, { @@ -2197,6 +2221,7 @@ export function withPersistence( : (state.usage ?? chunkUsage) state.usage = usage await interruptRun(runs, ctx.runId, usage) + ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [...ctx.messages]) state.interrupted = true }, @@ -2215,6 +2240,7 @@ export function withPersistence( // or consuming approvals before the durable history lands leaves a // "finished" run whose transcript is missing the terminal turn. try { + ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [...ctx.messages]) await commitPendingResumes(state, persistence.stores.interrupts) await completeRun(runs, ctx.runId, state?.usage ?? info.usage) diff --git a/packages/ai-persistence/tests/interrupts.test.ts b/packages/ai-persistence/tests/interrupts.test.ts index 756e30a515..c46e8b1dca 100644 --- a/packages/ai-persistence/tests/interrupts.test.ts +++ b/packages/ai-persistence/tests/interrupts.test.ts @@ -269,7 +269,7 @@ describe('interrupt persistence', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'hi' }, + expect.objectContaining({ role: 'user', content: 'hi' }), ]) }) diff --git a/packages/ai-persistence/tests/with-persistence.test.ts b/packages/ai-persistence/tests/with-persistence.test.ts index fd16059036..2a9d207842 100644 --- a/packages/ai-persistence/tests/with-persistence.test.ts +++ b/packages/ai-persistence/tests/with-persistence.test.ts @@ -136,7 +136,7 @@ describe('withPersistence (state-only)', () => { // assistant's terminal text reply. expect((await persistence.stores.runs!.get('r1'))?.status).toBe('completed') expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'hi' }, + expect.objectContaining({ role: 'user', content: 'hi' }), expect.objectContaining({ role: 'assistant', content: 'hello' }), ]) }) @@ -176,12 +176,90 @@ describe('withPersistence (state-only)', () => { // the kept message plus the assistant reply remain. const thread = await persistence.stores.messages!.loadThread('t1') expect(thread).toEqual([ - { role: 'user', content: 'KEEP_ME_LAST' }, + expect.objectContaining({ role: 'user', content: 'KEEP_ME_LAST' }), expect.objectContaining({ role: 'assistant', content: 'hello' }), ]) expect(JSON.stringify(thread)).not.toContain('DROP_ME_FIRST') }) + it('stamps a stable id on every persisted message and keeps it across turns', async () => { + // The engine ids assistant messages but leaves incoming user messages and + // engine-created tool messages without one. Persistence must fill those so a + // row-keyed adapter can reconcile by id. Turn 1 runs a tool round-trip + // (user 'search' -> assistant tool call -> tool result -> assistant reply). + const persistence = memoryPersistence() + const toolThenText = mockAdapter([ + [ + ev.runStarted(), + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'agent-tool', + role: 'assistant', + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'call_1', + toolCallName: 'search', + toolName: 'search', + parentMessageId: 'agent-tool', + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'call_1', + delta: '{}', + timestamp: 1, + }, + { + type: EventType.RUN_FINISHED, + runId: 'r1', + threadId: 't1', + finishReason: 'tool_calls', + timestamp: 1, + }, + ], + [ev.runStarted(), ev.text('done'), ev.runFinished()], + ]) + + await collect( + chat({ + adapter: toolThenText.adapter, + messages: [{ role: 'user', content: 'search' }], + tools: [serverSearchTool()], + runId: 'r1', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const turn1 = await persistence.stores.messages!.loadThread('t1') + // User, assistant(tool call), tool result, assistant(reply) — all must have ids. + expect(turn1.length).toBeGreaterThanOrEqual(3) + expect( + turn1.every((m) => typeof m.id === 'string' && m.id.length > 0), + ).toBe(true) + const idsBefore = turn1.map((m) => m.id) + + // Turn 2 continues from the stored thread. The earlier messages already carry + // ids, so ensureMessageIds is a no-op on them: same ids survive the round-trip. + const { adapter: turn2Adapter } = mockAdapter([ + [ev.runStarted('r2'), ev.text('more'), ev.runFinished('r2')], + ]) + await collect( + chat({ + adapter: turn2Adapter, + messages: [...turn1, { role: 'user', content: 'again' }], + runId: 'r2', + threadId: 't1', + middleware: [withPersistence(persistence)], + }) as AsyncIterable, + ) + + const turn2 = await persistence.stores.messages!.loadThread('t1') + expect(turn2.slice(0, idsBefore.length).map((m) => m.id)).toEqual(idsBefore) + }) + it('persists cumulative usage across model calls', async () => { const persistence = memoryPersistence() const { adapter } = mockAdapter([ @@ -299,7 +377,7 @@ describe('withPersistence (state-only)', () => { // onStart persisted the user turn before the failure, so it is not lost. expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'hi' }, + expect.objectContaining({ role: 'user', content: 'hi' }), ]) expect((await persistence.stores.runs!.get('r1'))?.status).toBe('failed') }) @@ -343,7 +421,7 @@ describe('withPersistence (state-only)', () => { // The partial assistant reply was snapshotted mid-stream, so it survives — // tagged with its stream messageId so a reload resumes the same bubble. expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'hi' }, + expect.objectContaining({ role: 'user', content: 'hi' }), expect.objectContaining({ role: 'assistant', content: 'Half a stor', @@ -397,7 +475,7 @@ describe('withPersistence (state-only)', () => { ).rejects.toThrow('crash mid-stream') expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'hi' }, + expect.objectContaining({ role: 'user', content: 'hi' }), expect.objectContaining({ role: 'assistant', content: 'Half a stor', @@ -527,7 +605,7 @@ describe('withPersistence (state-only)', () => { // Identity round-trip: the persisted assistant turn keeps the stream id, so // `modelMessagesToUIMessages` reuses it and a reload can resume in place. expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'hi' }, + expect.objectContaining({ role: 'user', content: 'hi' }), expect.objectContaining({ role: 'assistant', content: 'hello', @@ -789,7 +867,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'extract' }, + expect.objectContaining({ role: 'user', content: 'extract' }), expect.objectContaining({ id: 'structured-native', role: 'assistant', @@ -852,7 +930,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'extract' }, + expect.objectContaining({ role: 'user', content: 'extract' }), expect.objectContaining({ id: 'harness-prose', role: 'assistant', @@ -920,7 +998,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'extract' }, + expect.objectContaining({ role: 'user', content: 'extract' }), expect.objectContaining({ id: 'harness-prose', role: 'assistant', @@ -977,7 +1055,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'extract' }, + expect.objectContaining({ role: 'user', content: 'extract' }), expect.objectContaining({ id: 'harness-prose', role: 'assistant', @@ -1036,7 +1114,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'name her' }, + expect.objectContaining({ role: 'user', content: 'name her' }), expect.objectContaining({ id: 'think-msg', role: 'assistant', @@ -1071,7 +1149,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - { role: 'user', content: 'extract' }, + expect.objectContaining({ role: 'user', content: 'extract' }), expect.objectContaining({ role: 'assistant', content: raw, From ed24a680edd0003c07d5e2fa4aeb6b14706985d6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 26 Aug 2026 17:37:59 +0200 Subject: [PATCH 09/13] feat(ai-compaction): preserve history with persisted checkpoints (#1250) * feat: preserve history during compaction * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../compaction-persistence-integration.md | 8 + .changeset/persistence-stable-message-ids.md | 12 -- docs/advanced/compaction.md | 36 +++-- docs/advanced/middleware.md | 10 +- docs/config.json | 8 +- docs/persistence/chat-persistence.md | 13 +- docs/persistence/store-reference.md | 31 +--- packages/ai-compaction/README.md | 17 +- packages/ai-compaction/src/index.test.ts | 129 ++++++++++++++- packages/ai-compaction/src/index.ts | 153 ++++++++++++++++-- packages/ai-persistence/src/middleware.ts | 32 +--- packages/ai-persistence/src/types.ts | 34 +--- .../ai-persistence/tests/interrupts.test.ts | 2 +- .../tests/metadata-capability.test.ts | 114 +++++++++++++ .../tests/with-persistence.test.ts | 121 ++++---------- packages/ai/src/activities/chat/index.ts | 8 +- .../src/activities/chat/middleware/compose.ts | 16 +- .../src/activities/chat/middleware/index.ts | 3 + .../activities/chat/middleware/metadata.ts | 20 +++ .../src/activities/chat/middleware/types.ts | 3 + packages/ai/src/index.ts | 4 + packages/ai/tests/provider-messages.test.ts | 104 ++++++++++++ testing/e2e/src/routes/api.compaction-wire.ts | 56 +++++-- testing/e2e/tests/compaction-wire.spec.ts | 13 ++ 24 files changed, 705 insertions(+), 242 deletions(-) create mode 100644 .changeset/compaction-persistence-integration.md delete mode 100644 .changeset/persistence-stable-message-ids.md create mode 100644 packages/ai-persistence/tests/metadata-capability.test.ts create mode 100644 packages/ai/src/activities/chat/middleware/metadata.ts create mode 100644 packages/ai/tests/provider-messages.test.ts diff --git a/.changeset/compaction-persistence-integration.md b/.changeset/compaction-persistence-integration.md new file mode 100644 index 0000000000..3721134e51 --- /dev/null +++ b/.changeset/compaction-persistence-integration.md @@ -0,0 +1,8 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-compaction': minor +'@tanstack/ai-persistence': patch +--- + +Keep canonical chat history separate from compacted provider context. Reuse +validated compaction checkpoints through an optional persistence metadata store. diff --git a/.changeset/persistence-stable-message-ids.md b/.changeset/persistence-stable-message-ids.md deleted file mode 100644 index a818d28499..0000000000 --- a/.changeset/persistence-stable-message-ids.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@tanstack/ai-persistence': minor ---- - -`withPersistence` now stamps a stable `id` on every message it saves. The chat -engine already ids assistant messages; the middleware fills one in for incoming -user messages, engine-created tool messages, and compaction-injected messages -that would otherwise be saved without one. It mutates the shared message objects, -so the same message keeps its id across a run's saves and, when the server owns -the thread, across the next turn's reload. This lets a row-keyed store reconcile -by id (`SELECT id, version` then delete/insert/update) instead of rewriting the -whole transcript. See "Storing messages per row" in the store reference. diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md index 8ef68e4980..25508b5c05 100644 --- a/docs/advanced/compaction.md +++ b/docs/advanced/compaction.md @@ -14,7 +14,7 @@ keywords: A long chat or a multi-step agent loop keeps adding messages. At some point the transcript passes the model's context limit and the call fails. You want the conversation to keep working without hitting that wall. -`withCompaction` shrinks the history before each model call. When the transcript passes `maxTokens`, it runs a **strategy** that rewrites the messages. It is an ordinary [`ChatMiddleware`](./middleware), so you add it to the `middleware` array of any `chat()` call. +`withCompaction` shrinks provider context before each model call. When the context passes `maxTokens`, a **strategy** rewrites what the model sees. The canonical transcript does not change. Add this [`ChatMiddleware`](./middleware) to the `middleware` array of any `chat()` call. ## Install @@ -135,9 +135,17 @@ const keepLastOnly: CompactionStrategy = (messages) => { return messages.slice(-1); }; -withCompaction({ maxTokens: 100_000, strategy: keepLastOnly }); +withCompaction({ + maxTokens: 100_000, + strategy: keepLastOnly, + strategyKey: "keep-last-v1", +}); ``` +Set `strategyKey` when you combine a custom strategy with persistence. Change +the key when the strategy can produce different output. This prevents an old +checkpoint from using stale behavior. + ## Combine strategies `composeStrategies` runs several strategies in order and **escalates**: it stops as soon as the result is back under `maxTokens`. Put the cheap, targeted strategy first and a broad fallback last. Here it clears old tool output first, and only drops old messages if that was not enough. @@ -165,6 +173,7 @@ withCompaction({ | `maxTokens` | `number` | - | **Required.** Compact when the estimated tokens across `messages` pass this. | | `strategy` | `CompactionStrategy` | `evictOldest()` | How to shrink the messages. | | `estimateTokens` | `(message: ModelMessage) => number` | characters / 4 | Per-message token estimate. Pass a real tokenizer if you need exact counts. | +| `strategyKey` | `string` | built-in strategy identity | Stable checkpoint identity. Set it for custom strategies, custom estimators, `summarizeOldest`, or a custom eviction marker. | | `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. `info` is `{ before, after, messagesBefore, messagesAfter }` (token and message counts). | ### Strategy options @@ -182,21 +191,28 @@ The token count is a rough `characters / 4` estimate. It is good enough to trigg - **The system prompt is never dropped.** `chat()` keeps it separate from `messages`, so compaction only touches the conversation. - **Tool calls stay paired with their results.** The built-in strategies never leave an orphaned tool result, so the request stays valid. - **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again, and a later `summarizeOldest` pass folds an earlier summary into the new one. +- **The canonical transcript stays complete.** Compaction writes provider-only context. Persistence and other middleware still read `ctx.messages`. ## Compaction and persistence -Compaction rewrites the messages the model sees. If you also save the thread on the server, know which copy you save. - -`withCompaction` and server-side [`withPersistence`](../persistence/chat-persistence) share one message array for the run. Compaction shrinks that array, and `withPersistence` saves it on finish with a full-overwrite `saveThread`. So the stored thread becomes the compacted one. Dropped, summarized, or stubbed messages are gone from the store. The middleware order does not change this. +Compaction and server-side [`withPersistence`](../persistence/chat-persistence) +use two message views: -This is what you want when the compacted thread is the memory. It is data loss when you expected the store to keep every message. +- `messages` is the complete canonical transcript. Persistence saves this view. +- `providerMessages` is temporary model context. Compaction rewrites this view. -Two ways to keep a full transcript and still compact: +Middleware order does not change this split. Dropped, summarized, and stubbed +content remains in the message store. -- **Client-authoritative persistence.** The browser keeps the full transcript. The server compacts only for the model call. See [Client persistence](../persistence/client-persistence). -- **Save the transcript yourself first.** Persist the incoming `messages`, then call `chat()` with compaction. +If the persistence adapter has a `metadata` store, compaction also saves a small +checkpoint. The next request validates the canonical prefix, restores the last +compacted result, and adds only new messages. A changed prefix or strategy key +invalidates the checkpoint. -Do you use server-side [Chat persistence](../persistence/chat-persistence) and want the saved thread to stay readable? Prefer `clearToolResults` or `summarizeOldest` over `evictOldest`. They keep the shape of the conversation instead of dropping turns. +The default strategy, standard `evictOldest`, `clearToolResults`, and safe +compositions get a strategy key automatically. Set `strategyKey` for +`summarizeOldest`, custom strategies, custom estimators, or custom marker +functions. Without a metadata store or safe key, compaction stays stateless. ## Next steps diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 838ef05738..70b720e005 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -157,7 +157,8 @@ const dynamicTemperature: ChatMiddleware = { | Field | Type | Description | |-------|------|-------------| -| `messages` | `ModelMessage[]` | Conversation history | +| `messages` | `ModelMessage[]` | Canonical conversation history. Persistence and `ctx.messages` use this field. | +| `providerMessages` | `ModelMessage[]` | Temporary context sent to the provider. Defaults to `messages`. | | `systemPrompts` | `string[]` | System prompts | | `tools` | `Tool[]` | Available tools | | `metadata` | `Record` | Request metadata | @@ -165,6 +166,10 @@ const dynamicTemperature: ChatMiddleware = { When multiple middleware define `onConfig`, the config is **piped** through them in order — each receives the merged config from the previous middleware. +Return `providerMessages` when a transform must affect only the model call. For +compatibility, returning `messages` also updates provider input unless the same +result sets `providerMessages` explicitly. + ### onStructuredOutputConfig Called once at the start of the final structured-output adapter call — only when `chat()` was invoked with `outputSchema` **and** `supportsCombinedToolsAndSchema()` does not return `true` for the current model/options. Pipes through middleware in order, like `onConfig`, but with access to the **JSON Schema** being sent to the provider. Use this hook when you need to transform the schema (e.g., inject `$defs`, strip vendor-incompatible keywords) or apply structured-output-specific behavior (e.g., suppress system prompts on the final call). @@ -195,7 +200,8 @@ const injectDefs: ChatMiddleware = { | Field | Type | Description | |-------|------|-------------| -| `messages` | `ModelMessage[]` | Conversation history sent to the final call | +| `messages` | `ModelMessage[]` | Canonical conversation history | +| `providerMessages` | `ModelMessage[]` | Temporary context sent to the final call | | `systemPrompts` | `SystemPrompt[]` | System prompts on the final call | | `metadata` | `Record` | Request metadata | | `modelOptions` | `Record` | Provider-native options — this is where sampling params (`temperature`, `top_p` / `topP`, the provider's `max*Tokens` key) now live, alongside every other model-specific knob. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). | diff --git a/docs/config.json b/docs/config.json index 71cc9d3d49..ea12c9d6c1 100644 --- a/docs/config.json +++ b/docs/config.json @@ -278,7 +278,7 @@ "label": "Chat Persistence", "to": "persistence/chat-persistence", "addedAt": "2026-08-04", - "updatedAt": "2026-08-25" + "updatedAt": "2026-08-26" }, { "label": "Client Persistence", @@ -348,7 +348,7 @@ "label": "Store Reference", "to": "persistence/store-reference", "addedAt": "2026-08-04", - "updatedAt": "2026-08-25" + "updatedAt": "2026-08-26" }, { "label": "How Persistence Works", @@ -537,7 +537,7 @@ "label": "Middleware", "to": "advanced/middleware", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-08-26" }, { "label": "Built-in Middleware", @@ -549,7 +549,7 @@ "label": "Compaction", "to": "advanced/compaction", "addedAt": "2026-08-24", - "updatedAt": "2026-08-25" + "updatedAt": "2026-08-26" }, { "label": "Locks", diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md index 741aec7426..088b754c16 100644 --- a/docs/persistence/chat-persistence.md +++ b/docs/persistence/chat-persistence.md @@ -91,13 +91,16 @@ generation hooks. [How persistence works](./internals) has the rest. middleware loads the stored transcript and the run picks up from there, so the client does not have to re-send history. -## Compaction rewrites what you save +## Compaction keeps the transcript complete Do you add [`withCompaction`](../advanced/compaction) to the same `chat()`? The -saved thread is the compacted one. Compaction and `withPersistence` share the -message array of the run, and `saveThread` overwrites the thread in full. The -stored transcript then matches what the model saw, not the original messages. To -keep a full transcript, see +saved thread remains canonical. Compaction changes only the provider context, +not `ctx.messages`. The message store keeps dropped content, summaries do not +replace old turns, and cleared tool output remains available for reloads. + +If your adapter provides `stores.metadata`, `withPersistence` exposes it to +other middleware. Compaction uses it automatically for validated checkpoints. +See [Compaction and persistence](../advanced/compaction#compaction-and-persistence). ## What gets persisted, and when diff --git a/docs/persistence/store-reference.md b/docs/persistence/store-reference.md index 6540b94301..6143eb2f72 100644 --- a/docs/persistence/store-reference.md +++ b/docs/persistence/store-reference.md @@ -40,31 +40,6 @@ interface MessageStore { `saveThread` receives the full authoritative model-message history, not a delta. `loadThread` returns `[]` (never `null`) for a thread that was never saved. -### Storing messages per row - -The simplest `saveThread` writes the whole transcript as one row: a JSON blob -keyed by `threadId`. That is what the shipped adapters do, and it stays cheap -until threads get very long. - -To store one row per message instead, reconcile against what you already have -rather than rewrite everything. Every persisted message carries a stable `id`. -The middleware fills one in for any message that lacks it, including messages -that [compaction](../advanced/compaction) rewrote. So you can key rows by the -`id`: - -1. `SELECT id, version FROM messages WHERE thread_id = ?` to read the light index. -2. Diff the incoming array against it: insert new ids, delete absent ids, update - rows whose `version` changed. -3. Set `version` to a content hash, so an in-place edit (a cleared tool result) - shows up as a change. - -Keep an order column, because compaction can insert a message at the front. -Assign a sortable value once (a gapped or fractional index) so an insert does not -renumber every row. Order the load by it. - -The stable `id` holds when the server owns the thread. A client-authoritative -caller that re-sends the transcript must keep the ids itself. - ## RunStore `RunStore` and `RunRecord` come from `@tanstack/ai`; `@tanstack/ai-persistence` @@ -294,6 +269,12 @@ composite identity. A stored `null` is indistinguishable from absence at the typ level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or reject nullish values outright the way the SQLite store above does. +`withPersistence` also provides this store through the core +`MetadataCapability`. Middleware can use it for derived state without depending +on `@tanstack/ai-persistence`. For example, `withCompaction` stores validated +context checkpoints here. Do not place the canonical transcript in metadata; +the `messages` store owns it. + ## GenerationRunStore The generation counterpart to `RunStore`. Keyed by its own `runId`, with diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md index 3184a9a716..b8f092f15b 100644 --- a/packages/ai-compaction/README.md +++ b/packages/ai-compaction/README.md @@ -2,9 +2,9 @@ Context-window compaction as a `chat()` middleware. When the working message set grows past `maxTokens`, `withCompaction` runs a pluggable **strategy** that -rewrites the messages. It runs before every model call, so compaction is -incremental and rolling. The system prompt is untouched (`chat()` keeps it -separate from `messages`). +rewrites provider context. It runs before every model call, so compaction is +incremental and rolling. The canonical transcript and system prompt stay +unchanged. ```bash npm install @tanstack/ai-compaction @@ -94,6 +94,12 @@ import type { CompactionStrategy } from '@tanstack/ai-compaction' const keepLastOnly: CompactionStrategy = (messages) => messages.length <= 1 ? null : messages.slice(-1) + +withCompaction({ + maxTokens: 100_000, + strategy: keepLastOnly, + strategyKey: 'keep-last-v1', +}) ``` ## Options @@ -105,6 +111,7 @@ const keepLastOnly: CompactionStrategy = (messages) => | `maxTokens` | (required) | Compact when estimated tokens exceed this. | | `strategy` | `evictOldest()` | How to shrink the messages. | | `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. | +| `strategyKey` | built-in key | Stable checkpoint identity. Set it for custom strategies or estimators. | | `onCompact` | — | Observe each compaction (`before`/`after`/`messagesBefore`/`messagesAfter`). | ### Strategy options @@ -117,3 +124,7 @@ const keepLastOnly: CompactionStrategy = (messages) => The token estimate is a rough `chars / 4` heuristic, good enough to trigger on, not exact. Pass `estimateTokens` if you need provider-accurate counts. + +When `withPersistence` provides a metadata store, compaction saves a validated +checkpoint automatically. The next request reuses the compacted prefix and adds +new canonical messages. Without metadata, compaction remains stateless. diff --git a/packages/ai-compaction/src/index.test.ts b/packages/ai-compaction/src/index.test.ts index 1053ce43e2..c24f94565e 100644 --- a/packages/ai-compaction/src/index.test.ts +++ b/packages/ai-compaction/src/index.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import type { ChatMiddlewareConfig, ChatMiddlewareContext, + MetadataStore, ModelMessage, ToolCall, } from '@tanstack/ai' +import { provideMetadata } from '@tanstack/ai' import { clearToolResults, composeStrategies, @@ -20,13 +22,28 @@ const CTX = {} as unknown as ChatMiddlewareContext function runOnConfig( mw: ReturnType, messages: Array, + ctx = CTX, ) { const config: ChatMiddlewareConfig = { messages, systemPrompts: [], tools: [], } - return mw.onConfig?.(CTX, config) + return mw.onConfig?.(ctx, config) +} + +function checkpointContext( + store: MetadataStore, + options: { aborted?: boolean } = {}, +): ChatMiddlewareContext { + // oxlint-disable-next-line eslint-js/no-restricted-syntax -- focused hook stub; only capability identity, threadId, and signal are read + const ctx = { + threadId: 'thread-1', + signal: options.aborted ? AbortSignal.abort() : undefined, + capabilities: { markProvided: () => undefined }, + } as unknown as ChatMiddlewareContext + provideMetadata(ctx, store) + return ctx } const text = (role: ModelMessage['role'], content: string): ModelMessage => ({ @@ -56,7 +73,7 @@ describe('withCompaction', () => { const mw = withCompaction({ maxTokens: 100 }) const msgs = [big('user'), big('assistant'), big('user'), big('assistant')] const result = await runOnConfig(mw, msgs) - const out = result?.messages ?? [] + const out = result?.providerMessages ?? [] expect(out[0]?.content).toContain('omitted') expect(out[out.length - 1]).toBe(msgs[msgs.length - 1]) }) @@ -75,6 +92,102 @@ describe('withCompaction', () => { expect(info.after).toBeLessThan(info.before) expect(info.messagesAfter).toBeLessThan(info.messagesBefore) }) + + it('reuses a persisted checkpoint for an unchanged canonical prefix', async () => { + const values = new Map() + const store: MetadataStore = { + get: async (namespace, key) => values.get(`${namespace}:${key}`) ?? null, + set: async (namespace, key, value) => { + values.set(`${namespace}:${key}`, value) + }, + delete: async (namespace, key) => { + values.delete(`${namespace}:${key}`) + }, + } + const summarize = vi.fn(async () => 'the gist') + const messages = [ + big('user'), + big('assistant'), + big('user'), + big('assistant'), + ] + const options = { + maxTokens: 100, + strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }), + strategyKey: 'summary-v1', + } + + const first = await runOnConfig( + withCompaction(options), + messages, + checkpointContext(store), + ) + const appended = [...messages, text('user', 'new')] + const second = await runOnConfig( + withCompaction(options), + appended, + checkpointContext(store), + ) + + expect(summarize).toHaveBeenCalledOnce() + expect(first?.providerMessages?.[0]?.content).toContain('the gist') + expect(second?.providerMessages?.[0]?.content).toContain('the gist') + expect(second?.providerMessages?.at(-1)?.content).toBe('new') + expect(appended).toHaveLength(5) + }) + + it('rejects a checkpoint when the canonical prefix changes', async () => { + const values = new Map() + const store: MetadataStore = { + get: async (namespace, key) => values.get(`${namespace}:${key}`) ?? null, + set: async (namespace, key, value) => { + values.set(`${namespace}:${key}`, value) + }, + delete: async () => undefined, + } + const summarize = vi.fn(async () => 'the gist') + const options = { + maxTokens: 100, + strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }), + strategyKey: 'summary-v1', + } + const messages = [ + big('user'), + big('assistant'), + big('user'), + big('assistant'), + ] + + await runOnConfig( + withCompaction(options), + messages, + checkpointContext(store), + ) + await runOnConfig( + withCompaction(options), + [text('user', 'changed'.repeat(30)), ...messages.slice(1)], + checkpointContext(store), + ) + + expect(summarize).toHaveBeenCalledTimes(2) + }) + + it('does not write a checkpoint after cancellation', async () => { + const set = vi.fn() + const store: MetadataStore = { + get: async () => null, + set, + delete: async () => undefined, + } + + await runOnConfig( + withCompaction({ maxTokens: 100 }), + [big('user'), big('assistant'), big('user'), big('assistant')], + checkpointContext(store, { aborted: true }), + ) + + expect(set).not.toHaveBeenCalled() + }) }) describe('evictOldest', () => { @@ -84,7 +197,7 @@ describe('evictOldest', () => { strategy: evictOldest({ keepRecentTokens: 50 }), }) const msgs = [big('user'), big('assistant'), big('user'), big('assistant')] - const out = (await runOnConfig(mw, msgs))?.messages ?? [] + const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] expect(out[0]?.content).toContain('omitted') expect(out[out.length - 1]).toBe(msgs[msgs.length - 1]) }) @@ -105,7 +218,7 @@ describe('evictOldest', () => { maxTokens: 100, strategy: evictOldest({ keepRecentTokens: 45 }), }) - const out = (await runOnConfig(mw, msgs))?.messages ?? [] + const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] expect(out.slice(1).some((m) => m.role === 'tool')).toBe(false) }) }) @@ -124,7 +237,7 @@ describe('summarizeOldest', () => { big('assistant'), ]) expect(summarize).toHaveBeenCalledOnce() - expect(result?.messages?.[0]?.content).toBe( + expect(result?.providerMessages?.[0]?.content).toBe( 'Summary of earlier conversation:\nthe gist', ) }) @@ -149,7 +262,7 @@ describe('clearToolResults', () => { maxTokens: 100, strategy: clearToolResults({ keepRecentToolResults: 2 }), }) - const out = (await runOnConfig(mw, msgs))?.messages ?? [] + const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] // Same number of messages — structure is untouched. expect(out.length).toBe(msgs.length) // Oldest two tool results are stubbed. @@ -201,7 +314,7 @@ describe('composeStrategies', () => { ), }) const msgs = history() - const out = (await runOnConfig(mw, msgs))?.messages ?? [] + const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] // Clearing one tool result was enough, so evict never ran: // the head message and full message count survive. expect(out.length).toBe(msgs.length) @@ -218,7 +331,7 @@ describe('composeStrategies', () => { ), }) const msgs = history() - const out = (await runOnConfig(mw, msgs))?.messages ?? [] + const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] // Clearing was not enough, so evict ran too: the head is dropped. expect(out.some((m) => m.content === 'HEAD_MARKER')).toBe(false) expect(out[0]?.content).toContain('omitted') diff --git a/packages/ai-compaction/src/index.ts b/packages/ai-compaction/src/index.ts index 1fd3a169d5..b35388b2ae 100644 --- a/packages/ai-compaction/src/index.ts +++ b/packages/ai-compaction/src/index.ts @@ -12,8 +12,70 @@ * The system prompt is never touched — `chat()` keeps it separate from * `messages`. */ +import { MetadataCapability, getMetadata } from '@tanstack/ai' import type { ChatMiddleware, ModelMessage } from '@tanstack/ai' +const strategyKeys = new WeakMap() +const CHECKPOINT_NAMESPACE = '@tanstack/ai-compaction' + +interface CompactionCheckpoint { + schemaVersion: 1 + sourceMessageCount: number + sourceHash: string + strategyKey: string + compactedMessages: Array +} + +function identifyStrategy( + strategy: CompactionStrategy, + key: string | undefined, +): CompactionStrategy { + if (key) strategyKeys.set(strategy, key) + return strategy +} + +async function hashMessages( + messages: ReadonlyArray, +): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(messages)) + const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes) + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join('') +} + +function isModelMessage(value: unknown): value is ModelMessage { + return ( + typeof value === 'object' && + value !== null && + 'role' in value && + (value.role === 'user' || + value.role === 'assistant' || + value.role === 'tool') && + 'content' in value + ) +} + +function isCompactionCheckpoint(value: unknown): value is CompactionCheckpoint { + return ( + typeof value === 'object' && + value !== null && + 'schemaVersion' in value && + value.schemaVersion === 1 && + 'sourceMessageCount' in value && + typeof value.sourceMessageCount === 'number' && + Number.isInteger(value.sourceMessageCount) && + value.sourceMessageCount >= 0 && + 'sourceHash' in value && + typeof value.sourceHash === 'string' && + 'strategyKey' in value && + typeof value.strategyKey === 'string' && + 'compactedMessages' in value && + Array.isArray(value.compactedMessages) && + value.compactedMessages.every(isModelMessage) + ) +} + /** Rough token estimate for one message. Default: characters / 4. */ export function estimateMessageTokens(message: ModelMessage): number { let text = @@ -60,6 +122,11 @@ export interface CompactionOptions { strategy?: CompactionStrategy /** Per-message token estimator. Default: {@link estimateMessageTokens}. */ estimateTokens?: (message: ModelMessage) => number + /** + * Stable identity for persisted checkpoints. Set this for custom strategies + * or estimators, and change it when their output can change. + */ + strategyKey?: string /** Observe each compaction (logging, metrics). */ onCompact?: (info: CompactionInfo) => void } @@ -108,7 +175,7 @@ export function evictOldest( marker?: (droppedCount: number) => string } = {}, ): CompactionStrategy { - return (messages, ctx) => { + const strategy: CompactionStrategy = (messages, ctx) => { const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2) const cut = splitAtRecent(messages, ctx.estimate, keep) // ponytail: can't shrink past the recent window; raise keepRecentTokens or @@ -119,6 +186,12 @@ export function evictOldest( `[${cut} earlier message(s) omitted to save context.]` return [{ role: 'user', content: marker }, ...messages.slice(cut)] } + return identifyStrategy( + strategy, + options.marker + ? undefined + : `evict-oldest:${options.keepRecentTokens ?? 'half'}`, + ) } /** @@ -164,7 +237,7 @@ export function clearToolResults( ): CompactionStrategy { const keepN = options.keepRecentToolResults ?? 3 const stub = options.stub ?? '[tool output cleared to save context]' - return (messages) => { + const strategy: CompactionStrategy = (messages) => { const toolIndexes: Array = [] messages.forEach((m, i) => { if (m.role === 'tool') toolIndexes.push(i) @@ -181,6 +254,7 @@ export function clearToolResults( }) return changed ? next : null } + return identifyStrategy(strategy, `clear-tool-results:${keepN}:${stub}`) } /** @@ -201,12 +275,12 @@ export function clearToolResults( export function composeStrategies( ...strategies: Array ): CompactionStrategy { - return async (messages, ctx) => { + const strategy: CompactionStrategy = async (messages, ctx) => { let current: ReadonlyArray = messages let result: Array | null = null - for (const strategy of strategies) { + for (const itemStrategy of strategies) { if (sum(current, ctx.estimate) <= ctx.maxTokens) break - const out = await strategy(current, ctx) + const out = await itemStrategy(current, ctx) if (out) { current = out result = out @@ -214,6 +288,11 @@ export function composeStrategies( } return result } + const keys = strategies.map((item) => strategyKeys.get(item)) + return identifyStrategy( + strategy, + keys.every((key) => key !== undefined) ? keys.join('|') : undefined, + ) } /** @@ -231,28 +310,78 @@ export function composeStrategies( export function withCompaction(options: CompactionOptions): ChatMiddleware { const estimate = options.estimateTokens ?? estimateMessageTokens const strategy = options.strategy ?? evictOldest() + const strategyKey = + options.strategyKey ?? + (options.estimateTokens ? undefined : strategyKeys.get(strategy)) + const checkpointStrategyKey = strategyKey + ? `${strategyKey}:maxTokens=${options.maxTokens}` + : undefined return { name: 'compaction', - async onConfig(_ctx, config) { + optionalRequires: [MetadataCapability], + async onConfig(ctx, config) { const { messages } = config - const before = sum(messages, estimate) - if (before <= options.maxTokens) return + const inputMessages = config.providerMessages ?? messages + const metadata = getMetadata(ctx, { optional: true }) + let workingMessages = inputMessages + let reusedCheckpoint = false + + if (metadata && checkpointStrategyKey && inputMessages === messages) { + const stored = await metadata.get(CHECKPOINT_NAMESPACE, ctx.threadId) + if ( + isCompactionCheckpoint(stored) && + stored.strategyKey === checkpointStrategyKey && + stored.sourceMessageCount <= messages.length && + stored.sourceHash === + (await hashMessages(messages.slice(0, stored.sourceMessageCount))) + ) { + workingMessages = [ + ...stored.compactedMessages, + ...messages.slice(stored.sourceMessageCount), + ] + reusedCheckpoint = true + } + } - const next = await strategy(messages, { + const before = sum(workingMessages, estimate) + if (before <= options.maxTokens) { + return reusedCheckpoint + ? { providerMessages: workingMessages } + : undefined + } + + const next = await strategy(workingMessages, { maxTokens: options.maxTokens, estimate, }) - if (!next || next === messages) return + if (!next || next === workingMessages) { + return reusedCheckpoint + ? { providerMessages: workingMessages } + : undefined + } options.onCompact?.({ before, after: sum(next, estimate), - messagesBefore: messages.length, + messagesBefore: workingMessages.length, messagesAfter: next.length, }) - return { messages: next } + if (metadata && checkpointStrategyKey && inputMessages === messages) { + const checkpoint: CompactionCheckpoint = { + schemaVersion: 1, + sourceMessageCount: messages.length, + sourceHash: await hashMessages(messages), + strategyKey: checkpointStrategyKey, + compactedMessages: next, + } + if (!ctx.signal?.aborted) { + await metadata.set(CHECKPOINT_NAMESPACE, ctx.threadId, checkpoint) + } + } + + return { providerMessages: next } }, } } diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 1f56919658..2ccb09635a 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -1,9 +1,10 @@ import { defineChatMiddleware, fromSpecTokenUsage, - generateMessageId, getDetachableRun, InterruptResumeValidationError, + MetadataCapability, + provideMetadata, readInterruptBinding, validateInterruptResumeBatch, wasCancelRequested, @@ -46,7 +47,6 @@ import type { GenerationMiddleware, GenerationMiddlewareContext, Interrupt, - ModelMessage, PendingInterruptResumeRecord, PersistedArtifactActivity, PersistedArtifactRef, @@ -1939,25 +1939,6 @@ export interface WithPersistenceOptions { snapshotIntervalMs?: number } -/** - * Stamp a stable `id` on any message that lacks one, in place, before the - * transcript is saved. The engine always ids assistant messages but leaves - * incoming user messages (when the client omits an id), engine-created tool - * messages, and compaction-injected messages without one. Mutating the shared - * `ctx.messages` objects means the SAME message keeps its id across this run's - * saves and, when the server owns the thread, across the next turn's reload. - * That is what lets a row-keyed store reconcile by id instead of rewriting the - * whole transcript. - * - * ponytail: server-authoritative only. A client that owns the transcript must - * send its own ids; upgrade path is stamping ids in the engine if that matters. - */ -function ensureMessageIds(messages: ReadonlyArray): void { - for (const message of messages) { - if (message.id === undefined) message.id = generateMessageId() - } -} - /** * @param persistence - Must satisfy {@link ChatTranscriptStores} (messages * required). Known-absent `messages` or `interrupts` without `runs` fail at @@ -1982,6 +1963,7 @@ export function withPersistence( const provides = [ PersistenceCapability, PersistenceCompletionCapability, + ...(persistence.stores.metadata ? [MetadataCapability] : []), ...(wantsInterrupts ? [InterruptsCapability] : []), ] @@ -1990,6 +1972,9 @@ export function withPersistence( provides, setup(ctx: ChatMiddlewareContext) { providePersistence(ctx, persistence) + if (persistence.stores.metadata) { + provideMetadata(ctx, persistence.stores.metadata) + } let resolveCompletion: () => void = () => undefined let rejectCompletion: (error: unknown) => void = () => undefined @@ -2033,7 +2018,6 @@ export function withPersistence( // The SAME rule `onConfig` applies when it merges. Kept here, in the // owner, because `saveThread` REPLACES the thread: a caller that stored // only the newly-sent list would delete the history. - ensureMessageIds(ctx.messages) const list = ctx.messages.length > 0 ? [...ctx.messages] : stored await messageStore.saveThread(ctx.threadId, list) }, @@ -2112,7 +2096,6 @@ export function withPersistence( // it before the assistant reply exists. Best-effort: a failed eager // snapshot must not abort the run — the authoritative save is `onFinish`. try { - ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [...ctx.messages]) } catch { // Eager pre-save is best-effort; the run continues and onFinish saves. @@ -2165,7 +2148,6 @@ export function withPersistence( if (now - (snapshotState.lastSnapshotAt ?? 0) >= snapshotIntervalMs) { snapshotState.lastSnapshotAt = now try { - ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [ ...ctx.messages, { @@ -2221,7 +2203,6 @@ export function withPersistence( : (state.usage ?? chunkUsage) state.usage = usage await interruptRun(runs, ctx.runId, usage) - ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [...ctx.messages]) state.interrupted = true }, @@ -2240,7 +2221,6 @@ export function withPersistence( // or consuming approvals before the durable history lands leaves a // "finished" run whose transcript is missing the terminal turn. try { - ensureMessageIds(ctx.messages) await messageStore.saveThread(ctx.threadId, [...ctx.messages]) await commitPendingResumes(state, persistence.stores.interrupts) await completeRun(runs, ctx.runId, state?.usage ?? info.usage) diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index d16e992395..084042c08c 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -1,5 +1,6 @@ import type { ModelMessage, + MetadataStore, PersistedArtifactRef, RunStatus, RunStore, @@ -11,7 +12,7 @@ import type { // `@tanstack/ai` or `@tanstack/ai-persistence`. See {@link Scope} security notes: // pair a client-visible `threadId` with a server-trusted `userId`/`tenantId` // before authorizing load/save (e.g. via `reconstructChat({ authorize })`). -export type { Scope } +export type { MetadataStore, Scope } // =========================================================================== // Store contracts @@ -292,37 +293,6 @@ export interface InterruptStore { listPendingByRun: (runId: string) => Promise> } -/** - * Namespaced key/value store for arbitrary JSON metadata (app-owned). - * - * The first argument is an **app-defined namespace string**, not the shared - * {@link Scope} identity type from `@tanstack/ai`. Composite identity is - * `(namespace, key)` as two independent fields (SQL backends use a composite - * primary key; the in-memory store uses nested maps). Do not encode both into a - * single delimited string — `${namespace}:${key}` collides when either part - * contains `:`. - * - * The same `key` under different namespaces is independent. - */ -export interface MetadataStore { - /** - * Return the stored value for `(namespace, key)`, or `null` if absent. - * - * CAVEAT: the return type is `unknown | null`, where `| null` collapses into - * `unknown` — a stored value of `null` is therefore **indistinguishable from - * absence** at the type level. Callers that must persist a real `null` - * distinctly from "not set" should wrap it (e.g. store `{ value: null }`). - */ - get: (namespace: string, key: string) => Promise - /** Insert or overwrite the value for `(namespace, key)`. */ - set: (namespace: string, key: string, value: unknown) => Promise - /** - * Remove `(namespace, key)`. A no-op if absent. Does not affect other - * namespaces. - */ - delete: (namespace: string, key: string) => Promise -} - // =========================================================================== // Store typers // =========================================================================== diff --git a/packages/ai-persistence/tests/interrupts.test.ts b/packages/ai-persistence/tests/interrupts.test.ts index c46e8b1dca..756e30a515 100644 --- a/packages/ai-persistence/tests/interrupts.test.ts +++ b/packages/ai-persistence/tests/interrupts.test.ts @@ -269,7 +269,7 @@ describe('interrupt persistence', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'hi' }), + { role: 'user', content: 'hi' }, ]) }) diff --git a/packages/ai-persistence/tests/metadata-capability.test.ts b/packages/ai-persistence/tests/metadata-capability.test.ts new file mode 100644 index 0000000000..c01fca8344 --- /dev/null +++ b/packages/ai-persistence/tests/metadata-capability.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + EventType, + MetadataCapability, + chat, + defineChatMiddleware, + getMetadata, +} from '@tanstack/ai' +import type { AnyTextAdapter, MetadataStore, StreamChunk } from '@tanstack/ai' +import { memoryPersistence } from '../src/memory' +import { withPersistence } from '../src/middleware' +import { defineAIPersistence, defineMessageStore } from '../src/types' + +function mockAdapter() { + return { + kind: 'text', + name: 'mock', + model: 'test-model', + '~types': { + providerOptions: undefined, + inputModalities: undefined, + messageMetadataByModality: undefined, + toolCapabilities: undefined, + toolCallMetadata: undefined, + systemPromptMetadata: undefined, + }, + chatStream: () => + (async function* () { + yield { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: 1, + } as const + yield { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop', + timestamp: 1, + } as const + })(), + structuredOutput: async () => ({ data: {}, rawText: '{}' }), + } satisfies AnyTextAdapter +} + +async function collect(stream: AsyncIterable) { + for await (const _chunk of stream) { + // Drain the stream so terminal middleware hooks run. + } +} + +describe('metadata capability', () => { + it('provides the persistence metadata store before onConfig in either order', async () => { + const persistence = memoryPersistence() + let metadata: MetadataStore | undefined + const consumer = defineChatMiddleware({ + name: 'metadata-consumer', + optionalRequires: [MetadataCapability], + onConfig(ctx) { + metadata = getMetadata(ctx, { optional: true }) + }, + }) + + await collect( + chat({ + adapter: mockAdapter(), + messages: [{ role: 'user', content: 'hello' }], + middleware: [consumer, withPersistence(persistence)], + }), + ) + + expect(metadata).toBe(persistence.stores.metadata) + }) + + it('leaves the capability absent when persistence has no metadata store', async () => { + const threads = new Map>() + const persistence = defineAIPersistence({ + stores: { + messages: defineMessageStore({ + loadThread: async (threadId) => threads.get(threadId) ?? [], + saveThread: async (threadId, messages) => { + threads.set( + threadId, + messages.filter( + (message): message is { role: 'user'; content: string } => + message.role === 'user' && + typeof message.content === 'string', + ), + ) + }, + }), + }, + }) + let metadata: MetadataStore | undefined + const consumer = defineChatMiddleware({ + name: 'metadata-consumer', + optionalRequires: [MetadataCapability], + onConfig(ctx) { + metadata = getMetadata(ctx, { optional: true }) + }, + }) + + await collect( + chat({ + adapter: mockAdapter(), + messages: [{ role: 'user', content: 'hello' }], + middleware: [withPersistence(persistence), consumer], + }), + ) + + expect(metadata).toBeUndefined() + }) +}) diff --git a/packages/ai-persistence/tests/with-persistence.test.ts b/packages/ai-persistence/tests/with-persistence.test.ts index 2a9d207842..3aa1a54087 100644 --- a/packages/ai-persistence/tests/with-persistence.test.ts +++ b/packages/ai-persistence/tests/with-persistence.test.ts @@ -136,26 +136,22 @@ describe('withPersistence (state-only)', () => { // assistant's terminal text reply. expect((await persistence.stores.runs!.get('r1'))?.status).toBe('completed') expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'hi' }), + { role: 'user', content: 'hi' }, expect.objectContaining({ role: 'assistant', content: 'hello' }), ]) }) - it('saves the compacted transcript when a middleware drops messages in onConfig', async () => { - // Reproduces the compaction + persistence seam: a middleware (here a stand-in - // for `withCompaction`) shrinks `config.messages` in `onConfig`. The engine - // makes that the run's live message array, so `onFinish` saves the shrunk set - // via a full-overwrite `saveThread`. The dropped message is gone from the store. + it('saves canonical history when middleware compacts provider messages', async () => { const persistence = memoryPersistence() - const { adapter } = mockAdapter([ + const { adapter, calls } = mockAdapter([ [ev.runStarted(), ev.text('hello'), ev.runFinished()], ]) const dropOldest: ChatMiddleware = { name: 'drop-oldest', - onConfig(_ctx, config) { - if (config.messages.length <= 1) return - return { messages: config.messages.slice(1) } + onConfig(ctx, config) { + if (ctx.phase !== 'beforeModel' || config.messages.length <= 1) return + return { providerMessages: config.messages.slice(1) } }, } @@ -172,92 +168,41 @@ describe('withPersistence (state-only)', () => { }) as AsyncIterable, ) - // The saved thread is the compacted one: the dropped message is absent, and - // the kept message plus the assistant reply remain. const thread = await persistence.stores.messages!.loadThread('t1') expect(thread).toEqual([ - expect.objectContaining({ role: 'user', content: 'KEEP_ME_LAST' }), + { role: 'user', content: 'DROP_ME_FIRST' }, + { role: 'user', content: 'KEEP_ME_LAST' }, expect.objectContaining({ role: 'assistant', content: 'hello' }), ]) - expect(JSON.stringify(thread)).not.toContain('DROP_ME_FIRST') + expect(calls[0]).toEqual( + expect.objectContaining({ + messages: [{ role: 'user', content: 'KEEP_ME_LAST' }], + }), + ) }) - it('stamps a stable id on every persisted message and keeps it across turns', async () => { - // The engine ids assistant messages but leaves incoming user messages and - // engine-created tool messages without one. Persistence must fill those so a - // row-keyed adapter can reconcile by id. Turn 1 runs a tool round-trip - // (user 'search' -> assistant tool call -> tool result -> assistant reply). + it('does not add ids to caller messages while saving', async () => { const persistence = memoryPersistence() - const toolThenText = mockAdapter([ - [ - ev.runStarted(), - { - type: EventType.TEXT_MESSAGE_START, - messageId: 'agent-tool', - role: 'assistant', - timestamp: 1, - }, - { - type: EventType.TOOL_CALL_START, - toolCallId: 'call_1', - toolCallName: 'search', - toolName: 'search', - parentMessageId: 'agent-tool', - timestamp: 1, - }, - { - type: EventType.TOOL_CALL_ARGS, - toolCallId: 'call_1', - delta: '{}', - timestamp: 1, - }, - { - type: EventType.RUN_FINISHED, - runId: 'r1', - threadId: 't1', - finishReason: 'tool_calls', - timestamp: 1, - }, - ], - [ev.runStarted(), ev.text('done'), ev.runFinished()], + const { adapter } = mockAdapter([ + [ev.runStarted(), ev.text('hello'), ev.runFinished()], ]) + const userMessage: ModelMessage = { role: 'user', content: 'hello' } await collect( chat({ - adapter: toolThenText.adapter, - messages: [{ role: 'user', content: 'search' }], - tools: [serverSearchTool()], + adapter, + messages: [userMessage], runId: 'r1', threadId: 't1', middleware: [withPersistence(persistence)], }) as AsyncIterable, ) - const turn1 = await persistence.stores.messages!.loadThread('t1') - // User, assistant(tool call), tool result, assistant(reply) — all must have ids. - expect(turn1.length).toBeGreaterThanOrEqual(3) - expect( - turn1.every((m) => typeof m.id === 'string' && m.id.length > 0), - ).toBe(true) - const idsBefore = turn1.map((m) => m.id) - - // Turn 2 continues from the stored thread. The earlier messages already carry - // ids, so ensureMessageIds is a no-op on them: same ids survive the round-trip. - const { adapter: turn2Adapter } = mockAdapter([ - [ev.runStarted('r2'), ev.text('more'), ev.runFinished('r2')], + expect(userMessage).toEqual({ role: 'user', content: 'hello' }) + expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ + { role: 'user', content: 'hello' }, + expect.objectContaining({ role: 'assistant', content: 'hello' }), ]) - await collect( - chat({ - adapter: turn2Adapter, - messages: [...turn1, { role: 'user', content: 'again' }], - runId: 'r2', - threadId: 't1', - middleware: [withPersistence(persistence)], - }) as AsyncIterable, - ) - - const turn2 = await persistence.stores.messages!.loadThread('t1') - expect(turn2.slice(0, idsBefore.length).map((m) => m.id)).toEqual(idsBefore) }) it('persists cumulative usage across model calls', async () => { @@ -377,7 +322,7 @@ describe('withPersistence (state-only)', () => { // onStart persisted the user turn before the failure, so it is not lost. expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'hi' }), + { role: 'user', content: 'hi' }, ]) expect((await persistence.stores.runs!.get('r1'))?.status).toBe('failed') }) @@ -421,7 +366,7 @@ describe('withPersistence (state-only)', () => { // The partial assistant reply was snapshotted mid-stream, so it survives — // tagged with its stream messageId so a reload resumes the same bubble. expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'hi' }), + { role: 'user', content: 'hi' }, expect.objectContaining({ role: 'assistant', content: 'Half a stor', @@ -475,7 +420,7 @@ describe('withPersistence (state-only)', () => { ).rejects.toThrow('crash mid-stream') expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'hi' }), + { role: 'user', content: 'hi' }, expect.objectContaining({ role: 'assistant', content: 'Half a stor', @@ -605,7 +550,7 @@ describe('withPersistence (state-only)', () => { // Identity round-trip: the persisted assistant turn keeps the stream id, so // `modelMessagesToUIMessages` reuses it and a reload can resume in place. expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'hi' }), + { role: 'user', content: 'hi' }, expect.objectContaining({ role: 'assistant', content: 'hello', @@ -867,7 +812,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'extract' }), + { role: 'user', content: 'extract' }, expect.objectContaining({ id: 'structured-native', role: 'assistant', @@ -930,7 +875,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'extract' }), + { role: 'user', content: 'extract' }, expect.objectContaining({ id: 'harness-prose', role: 'assistant', @@ -998,7 +943,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'extract' }), + { role: 'user', content: 'extract' }, expect.objectContaining({ id: 'harness-prose', role: 'assistant', @@ -1055,7 +1000,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'extract' }), + { role: 'user', content: 'extract' }, expect.objectContaining({ id: 'harness-prose', role: 'assistant', @@ -1114,7 +1059,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'name her' }), + { role: 'user', content: 'name her' }, expect.objectContaining({ id: 'think-msg', role: 'assistant', @@ -1149,7 +1094,7 @@ describe('withPersistence (state-only)', () => { ) expect(await persistence.stores.messages!.loadThread('t1')).toEqual([ - expect.objectContaining({ role: 'user', content: 'extract' }), + { role: 'user', content: 'extract' }, expect.objectContaining({ role: 'assistant', content: raw, diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 91f84d3490..17132ecddb 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -789,6 +789,7 @@ class TextEngine< private readonly effectiveSignal?: AbortSignal private messages: Array + private providerMessages: Array private iterationCount = 0 /** Cumulative tool calls counted in this run (emitted + pending resume). */ private toolCallCount = 0 @@ -936,6 +937,7 @@ class TextEngine< // Convert messages to ModelMessage format (handles both UIMessage and ModelMessage input) // This ensures consistent internal format regardless of what the client sends this.messages = convertMessagesToModelMessages(config.params.messages) + this.providerMessages = this.messages // Initialize lazy tool manager after messages are converted (needs message history for scanning) assertUniqueToolNames(config.params.tools || []) @@ -1498,7 +1500,7 @@ class TextEngine< for await (const raw of this.adapter.chatStream({ model: this.params.model, - messages: this.messages, + messages: this.providerMessages, tools: toolsWithJsonSchemas, metadata, request: this.effectiveRequest, @@ -3471,7 +3473,7 @@ class TextEngine< const structuredCallOptions = { chatOptions: { model: this.params.model, - messages: this.messages, + messages: this.providerMessages, metadata: postOnConfig.metadata, modelOptions: postOnConfig.modelOptions, systemPrompts: postOnConfig.systemPrompts, @@ -3950,6 +3952,7 @@ class TextEngine< private buildMiddlewareConfig(): ChatMiddlewareConfig { return { messages: this.messages, + providerMessages: this.messages, systemPrompts: [...this.systemPrompts], tools: [...this.tools], resume: this.params.resume, @@ -4368,6 +4371,7 @@ class TextEngine< private applyMiddlewareConfig(config: ChatMiddlewareConfig): void { this.applyResumeToolState(config.resumeToolState) this.messages = config.messages + this.providerMessages = config.providerMessages ?? config.messages this.systemPrompts = config.systemPrompts assertUniqueToolNames(config.tools) this.tools = config.tools diff --git a/packages/ai/src/activities/chat/middleware/compose.ts b/packages/ai/src/activities/chat/middleware/compose.ts index 3e811cfbaa..5493e20485 100644 --- a/packages/ai/src/activities/chat/middleware/compose.ts +++ b/packages/ai/src/activities/chat/middleware/compose.ts @@ -166,7 +166,13 @@ export class MiddlewareRunner< const result = await mw.onConfig(ctx, current) const hasTransform = result !== undefined && result !== null if (hasTransform) { - current = { ...current, ...result } + current = { + ...current, + ...result, + ...('messages' in result && !('providerMessages' in result) + ? { providerMessages: result.messages } + : {}), + } if (!skip) { this.logger.config( `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`, @@ -221,7 +227,13 @@ export class MiddlewareRunner< const result = await mw.onStructuredOutputConfig(ctx, current) const hasTransform = result !== undefined && result !== null if (hasTransform) { - current = { ...current, ...result } + current = { + ...current, + ...result, + ...('messages' in result && !('providerMessages' in result) + ? { providerMessages: result.messages } + : {}), + } if (!skip) { this.logger.config( `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`, diff --git a/packages/ai/src/activities/chat/middleware/index.ts b/packages/ai/src/activities/chat/middleware/index.ts index 1f913cfa77..53da815b24 100644 --- a/packages/ai/src/activities/chat/middleware/index.ts +++ b/packages/ai/src/activities/chat/middleware/index.ts @@ -67,6 +67,9 @@ export { } from './locks' export type { LockStore } from './locks' +export { MetadataCapability, getMetadata, provideMetadata } from './metadata' +export type { MetadataStore } from './metadata' + export { isRunStatus, isTerminalRunStatus, diff --git a/packages/ai/src/activities/chat/middleware/metadata.ts b/packages/ai/src/activities/chat/middleware/metadata.ts new file mode 100644 index 0000000000..08d71987f1 --- /dev/null +++ b/packages/ai/src/activities/chat/middleware/metadata.ts @@ -0,0 +1,20 @@ +import { createCapability } from './capabilities' + +/** + * Namespaced key/value store for app and middleware metadata. + * + * `(namespace, key)` is the composite identity. Keep both values separate; + * joining them with a delimiter can create collisions. + */ +export interface MetadataStore { + /** Return the value for `(namespace, key)`, or `null` when it is absent. */ + get: (namespace: string, key: string) => Promise + /** Insert or replace the value for `(namespace, key)`. */ + set: (namespace: string, key: string, value: unknown) => Promise + /** Delete `(namespace, key)`. Do nothing when it is absent. */ + delete: (namespace: string, key: string) => Promise +} + +export const MetadataCapability = createCapability()('metadata') + +export const [getMetadata, provideMetadata] = MetadataCapability diff --git a/packages/ai/src/activities/chat/middleware/types.ts b/packages/ai/src/activities/chat/middleware/types.ts index 6cd5de10fb..f17f04d642 100644 --- a/packages/ai/src/activities/chat/middleware/types.ts +++ b/packages/ai/src/activities/chat/middleware/types.ts @@ -305,7 +305,10 @@ export interface ChatMiddlewareContext { * that middleware is allowed to modify. */ export interface ChatMiddlewareConfig { + /** Canonical conversation history. Middleware and persistence read this. */ messages: Array + /** Provider-only context. Defaults to `messages` when it is not set. */ + providerMessages?: Array | undefined systemPrompts: Array tools: Array resume?: Array | undefined diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 39b77827cf..8a300522bd 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -281,6 +281,9 @@ export { createCapability, defineChatMiddleware, createChatMiddleware, + MetadataCapability, + getMetadata, + provideMetadata, } from './activities/chat/middleware/index' export type { Capability, @@ -290,6 +293,7 @@ export type { CapabilityProvider, DefinedChatMiddleware, AnyChatMiddleware, + MetadataStore, } from './activities/chat/middleware/index' // Locks are a distributed-mutex primitive — coordination, not chat state — and // live behind their own subpath: `@tanstack/ai/locks` (see ./locks.ts). diff --git a/packages/ai/tests/provider-messages.test.ts b/packages/ai/tests/provider-messages.test.ts new file mode 100644 index 0000000000..50ef604687 --- /dev/null +++ b/packages/ai/tests/provider-messages.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { chat } from '../src/activities/chat/index' +import { defineChatMiddleware } from '../src/activities/chat/middleware/define' +import { collectChunks, createMockAdapter, ev, serverTool } from './test-utils' +import type { ModelMessage, StreamChunk } from '../src/types' + +describe('provider-only messages', () => { + it('changes provider input without changing the canonical transcript', async () => { + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('done'), + ev.textEnd(), + ev.runFinished(), + ], + ], + }) + let finalMessages: Array = [] + + const providerFilter = defineChatMiddleware({ + name: 'provider-filter', + onConfig(ctx, config) { + if (ctx.phase !== 'beforeModel') return + return { providerMessages: config.messages.slice(1) } + }, + onFinish(ctx) { + finalMessages = [...ctx.messages] + }, + }) + + await collectChunks( + chat({ + adapter, + messages: [ + { role: 'user', content: 'DROP_FROM_PROVIDER' }, + { role: 'user', content: 'KEEP_FOR_PROVIDER' }, + ], + middleware: [providerFilter], + }) as AsyncIterable, + ) + + expect(calls[0]?.messages.map((message) => message.content)).toEqual([ + 'KEEP_FOR_PROVIDER', + ]) + expect(finalMessages.map((message) => message.content)).toEqual([ + 'DROP_FROM_PROVIDER', + 'KEEP_FOR_PROVIDER', + 'done', + ]) + }) + + it('includes new tool-loop messages in later provider calls', async () => { + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('call-1', 'lookup'), + ev.toolArgs('call-1', '{}'), + ev.runFinished('tool_calls'), + ], + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('done'), + ev.textEnd(), + ev.runFinished('stop'), + ], + ], + }) + let finalMessages: Array = [] + const providerFilter = defineChatMiddleware({ + name: 'provider-filter', + onConfig(ctx, config) { + if (ctx.phase !== 'beforeModel') return + return { providerMessages: config.messages.slice(1) } + }, + onFinish(ctx) { + finalMessages = [...ctx.messages] + }, + }) + + await collectChunks( + chat({ + adapter, + messages: [ + { role: 'user', content: 'DROP_FROM_PROVIDER' }, + { role: 'user', content: 'KEEP_FOR_PROVIDER' }, + ], + tools: [serverTool('lookup', () => ({ value: 1 }))], + middleware: [providerFilter], + }) as AsyncIterable, + ) + + expect(calls[1]?.messages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + ]) + expect(calls[1]?.messages[0]?.content).toBe('KEEP_FOR_PROVIDER') + expect(finalMessages[0]?.content).toBe('DROP_FROM_PROVIDER') + }) +}) diff --git a/testing/e2e/src/routes/api.compaction-wire.ts b/testing/e2e/src/routes/api.compaction-wire.ts index 7161b169f8..8ed4c496da 100644 --- a/testing/e2e/src/routes/api.compaction-wire.ts +++ b/testing/e2e/src/routes/api.compaction-wire.ts @@ -8,13 +8,14 @@ import { } from '@tanstack/ai-compaction' import type { CompactionStrategy } from '@tanstack/ai-compaction' import type { ModelMessage } from '@tanstack/ai' +import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence' const DUMMY_KEY = 'sk-e2e-test-dummy-key' -function makeTextStream(): ReadableStream { +function makeTextStream(callNumber: number): ReadableStream { const encoder = new TextEncoder() - const responseId = 'resp_compaction' - const itemId = 'msg_compaction' + const responseId = `resp_compaction_${callNumber}` + const itemId = `msg_compaction_${callNumber}` const events = [ { type: 'response.created', @@ -111,15 +112,13 @@ export const Route = createFileRoute('/api/compaction-wire')({ const clear = new URL(request.url).searchParams.get('strategy') === 'clear' - let firstRequestBody: unknown + const requestBodies: Array = [] const mockFetch: typeof fetch = async (input, init) => { const req = input instanceof Request ? input : new Request(input, init) - if (firstRequestBody === undefined) { - firstRequestBody = JSON.parse(await req.text()) - } - return new Response(makeTextStream(), { + requestBodies.push(JSON.parse(await req.text())) + return new Response(makeTextStream(requestBodies.length), { headers: { 'Content-Type': 'text/event-stream' }, }) } @@ -132,16 +131,45 @@ export const Route = createFileRoute('/api/compaction-wire')({ const adapter = createOpenaiChat('gpt-5.2', DUMMY_KEY, { fetch: mockFetch, }) + const persistence = memoryPersistence() + let compactionCount = 0 try { for await (const _ of chat({ ...createChatOptions({ adapter }), messages, - middleware: [withCompaction({ maxTokens: 60, strategy })], + threadId: 'compaction-wire', + runId: 'compaction-wire-1', + middleware: [ + withPersistence(persistence), + withCompaction({ + maxTokens: 60, + strategy, + onCompact: () => compactionCount++, + }), + ], agentLoopStrategy: maxIterations(1), })) { // Drain the stream. } + + for await (const _ of chat({ + ...createChatOptions({ adapter }), + messages: [], + threadId: 'compaction-wire', + runId: 'compaction-wire-2', + middleware: [ + withPersistence(persistence), + withCompaction({ + maxTokens: 60, + strategy, + onCompact: () => compactionCount++, + }), + ], + agentLoopStrategy: maxIterations(1), + })) { + // Drain the restored run. + } } catch (error) { return Response.json({ ok: false, @@ -149,7 +177,15 @@ export const Route = createFileRoute('/api/compaction-wire')({ }) } - return Response.json({ ok: true, firstRequestBody }) + const canonicalMessages = + await persistence.stores.messages.loadThread('compaction-wire') + return Response.json({ + ok: true, + firstRequestBody: requestBodies[0], + secondRequestBody: requestBodies[1], + canonicalMessages, + compactionCount, + }) }, }, }, diff --git a/testing/e2e/tests/compaction-wire.spec.ts b/testing/e2e/tests/compaction-wire.spec.ts index 5c9a63b1fc..4669f27c26 100644 --- a/testing/e2e/tests/compaction-wire.spec.ts +++ b/testing/e2e/tests/compaction-wire.spec.ts @@ -16,6 +16,9 @@ test.describe('withCompaction — wire format', () => { ok: boolean error?: string firstRequestBody: unknown + secondRequestBody: unknown + canonicalMessages: unknown + compactionCount: number } if (!result.ok) throw new Error(`Route failed: ${result.error}`) @@ -26,6 +29,16 @@ test.describe('withCompaction — wire format', () => { expect(wire).toContain('omitted to save context') // The oldest message is gone. expect(wire).not.toContain('SECRET_ALPHA_ONE') + + // Persistence keeps the canonical transcript, while a later request reuses + // the compacted checkpoint without compacting the same prefix again. + expect(JSON.stringify(result.canonicalMessages)).toContain( + 'SECRET_ALPHA_ONE', + ) + expect(JSON.stringify(result.secondRequestBody)).not.toContain( + 'SECRET_ALPHA_ONE', + ) + expect(result.compactionCount).toBe(1) }) test('clearToolResults stubs old tool output and keeps the recent one', async ({ From 547dc3394c654e474f174ad194778066cb17d548 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 14:14:07 +0200 Subject: [PATCH 10/13] fix(ai-compaction): keep trailing tool turns and skip init --- docs/advanced/compaction.md | 19 +-- packages/ai-compaction/README.md | 10 +- packages/ai-compaction/src/index.test.ts | 143 +++++++++++++++++++---- packages/ai-compaction/src/index.ts | 27 ++++- 4 files changed, 160 insertions(+), 39 deletions(-) diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md index 25508b5c05..ea6c94684b 100644 --- a/docs/advanced/compaction.md +++ b/docs/advanced/compaction.md @@ -173,7 +173,7 @@ withCompaction({ | `maxTokens` | `number` | - | **Required.** Compact when the estimated tokens across `messages` pass this. | | `strategy` | `CompactionStrategy` | `evictOldest()` | How to shrink the messages. | | `estimateTokens` | `(message: ModelMessage) => number` | characters / 4 | Per-message token estimate. Pass a real tokenizer if you need exact counts. | -| `strategyKey` | `string` | built-in strategy identity | Stable checkpoint identity. Set it for custom strategies, custom estimators, `summarizeOldest`, or a custom eviction marker. | +| `strategyKey` | `string` | built-in strategy identity | Stable checkpoint identity. Set it for custom strategies, custom estimators, or a custom eviction marker. Change it when your `summarize` function can change. | | `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. `info` is `{ before, after, messagesBefore, messagesAfter }` (token and message counts). | ### Strategy options @@ -181,7 +181,7 @@ withCompaction({ | Strategy | Options | |----------|---------| | `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | -| `summarizeOldest` | `summarize` (**required**), `keepRecentTokens`, `summaryRole` | +| `summarizeOldest` | `summarize` (**required**), `keepRecentTokens`, `summaryRole` (default `assistant`) | | `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | The token count is a rough `characters / 4` estimate. It is good enough to trigger on, not exact. Pass `estimateTokens` for provider-accurate counts. @@ -190,7 +190,7 @@ The token count is a rough `characters / 4` estimate. It is good enough to trigg - **The system prompt is never dropped.** `chat()` keeps it separate from `messages`, so compaction only touches the conversation. - **Tool calls stay paired with their results.** The built-in strategies never leave an orphaned tool result, so the request stays valid. -- **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again, and a later `summarizeOldest` pass folds an earlier summary into the new one. +- **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again. - **The canonical transcript stays complete.** Compaction writes provider-only context. Persistence and other middleware still read `ctx.messages`. ## Compaction and persistence @@ -209,10 +209,15 @@ checkpoint. The next request validates the canonical prefix, restores the last compacted result, and adds only new messages. A changed prefix or strategy key invalidates the checkpoint. -The default strategy, standard `evictOldest`, `clearToolResults`, and safe -compositions get a strategy key automatically. Set `strategyKey` for -`summarizeOldest`, custom strategies, custom estimators, or custom marker -functions. Without a metadata store or safe key, compaction stays stateless. +When a checkpoint is reused, a later `summarizeOldest` pass sees the previous +summary plus new messages. Then it folds the old summary into the new one. +Folding needs a metadata store and a strategy key. + +The default strategy, standard `evictOldest`, `summarizeOldest`, +`clearToolResults`, and safe compositions get a strategy key automatically. +Set `strategyKey` for custom strategies, custom estimators, or custom marker +functions. Change `strategyKey` when your `summarize` function can change. +Without a metadata store or safe key, compaction stays stateless. ## Next steps diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md index b8f092f15b..b7a793ae89 100644 --- a/packages/ai-compaction/README.md +++ b/packages/ai-compaction/README.md @@ -116,11 +116,11 @@ withCompaction({ ### Strategy options -| Strategy | Options | -| ------------------ | --------------------------------------------------------- | -| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | -| `summarizeOldest` | `summarize` (required), `keepRecentTokens`, `summaryRole` | -| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | +| Strategy | Options | +| ------------------ | ------------------------------------------------------------------------------- | +| `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | +| `summarizeOldest` | `summarize` (required), `keepRecentTokens`, `summaryRole` (default `assistant`) | +| `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | The token estimate is a rough `chars / 4` heuristic, good enough to trigger on, not exact. Pass `estimateTokens` if you need provider-accurate counts. diff --git a/packages/ai-compaction/src/index.test.ts b/packages/ai-compaction/src/index.test.ts index c24f94565e..e9b28e9ee1 100644 --- a/packages/ai-compaction/src/index.test.ts +++ b/packages/ai-compaction/src/index.test.ts @@ -34,11 +34,12 @@ function runOnConfig( function checkpointContext( store: MetadataStore, - options: { aborted?: boolean } = {}, + options: { aborted?: boolean; phase?: ChatMiddlewareContext['phase'] } = {}, ): ChatMiddlewareContext { - // oxlint-disable-next-line eslint-js/no-restricted-syntax -- focused hook stub; only capability identity, threadId, and signal are read + // oxlint-disable-next-line eslint-js/no-restricted-syntax -- focused hook stub; only capability identity, threadId, signal, and phase are read const ctx = { threadId: 'thread-1', + phase: options.phase, signal: options.aborted ? AbortSignal.abort() : undefined, capabilities: { markProvided: () => undefined }, } as unknown as ChatMiddlewareContext @@ -46,6 +47,26 @@ function checkpointContext( return ctx } +function memoryStore(): MetadataStore { + const values = new Map() + return { + get: async (namespace, key) => values.get(`${namespace}:${key}`) ?? null, + set: async (namespace, key, value) => { + values.set(`${namespace}:${key}`, value) + }, + delete: async (namespace, key) => { + values.delete(`${namespace}:${key}`) + }, + } +} + +function phaseContext( + phase: ChatMiddlewareContext['phase'], +): ChatMiddlewareContext { + // oxlint-disable-next-line eslint-js/no-restricted-syntax -- focused hook stub; onConfig only reads phase here + return { phase } as unknown as ChatMiddlewareContext +} + const text = (role: ModelMessage['role'], content: string): ModelMessage => ({ role, content, @@ -93,17 +114,20 @@ describe('withCompaction', () => { expect(info.messagesAfter).toBeLessThan(info.messagesBefore) }) + it('does not compact during init', async () => { + const onCompact = vi.fn() + const mw = withCompaction({ maxTokens: 100, onCompact }) + const result = await runOnConfig( + mw, + [big('user'), big('assistant'), big('user'), big('assistant')], + phaseContext('init'), + ) + expect(result).toBeUndefined() + expect(onCompact).not.toHaveBeenCalled() + }) + it('reuses a persisted checkpoint for an unchanged canonical prefix', async () => { - const values = new Map() - const store: MetadataStore = { - get: async (namespace, key) => values.get(`${namespace}:${key}`) ?? null, - set: async (namespace, key, value) => { - values.set(`${namespace}:${key}`, value) - }, - delete: async (namespace, key) => { - values.delete(`${namespace}:${key}`) - }, - } + const store = memoryStore() const summarize = vi.fn(async () => 'the gist') const messages = [ big('user'), @@ -137,14 +161,7 @@ describe('withCompaction', () => { }) it('rejects a checkpoint when the canonical prefix changes', async () => { - const values = new Map() - const store: MetadataStore = { - get: async (namespace, key) => values.get(`${namespace}:${key}`) ?? null, - set: async (namespace, key, value) => { - values.set(`${namespace}:${key}`, value) - }, - delete: async () => undefined, - } + const store = memoryStore() const summarize = vi.fn(async () => 'the gist') const options = { maxTokens: 100, @@ -221,6 +238,60 @@ describe('evictOldest', () => { const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] expect(out.slice(1).some((m) => m.role === 'tool')).toBe(false) }) + + it('keeps the trailing assistant plus tool result when the transcript ends in a tool', async () => { + const assistantCall: ModelMessage = { + role: 'assistant', + content: 'x'.repeat(160), + toolCalls: [call], + } + const toolResult: ModelMessage = { + role: 'tool', + content: 'x'.repeat(160), + toolCallId: 't1', + } + const msgs = [big('user'), assistantCall, toolResult] + const mw = withCompaction({ + maxTokens: 100, + strategy: evictOldest({ keepRecentTokens: 45 }), + }) + const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] + expect(out.at(-2)).toBe(assistantCall) + expect(out.at(-1)).toBe(toolResult) + expect(out.some((m) => m.role === 'tool')).toBe(true) + }) + + it('keeps a trailing parallel tool-result group with its assistant', async () => { + const assistantCall: ModelMessage = { + role: 'assistant', + content: 'x'.repeat(160), + toolCalls: [ + call, + { + id: 't2', + type: 'function', + function: { name: 'g', arguments: '{}' }, + }, + ], + } + const toolA: ModelMessage = { + role: 'tool', + content: 'x'.repeat(160), + toolCallId: 't1', + } + const toolB: ModelMessage = { + role: 'tool', + content: 'x'.repeat(160), + toolCallId: 't2', + } + const msgs = [big('user'), assistantCall, toolA, toolB] + const mw = withCompaction({ + maxTokens: 100, + strategy: evictOldest({ keepRecentTokens: 45 }), + }) + const out = (await runOnConfig(mw, msgs))?.providerMessages ?? [] + expect(out.slice(-3)).toEqual([assistantCall, toolA, toolB]) + }) }) describe('summarizeOldest', () => { @@ -237,10 +308,40 @@ describe('summarizeOldest', () => { big('assistant'), ]) expect(summarize).toHaveBeenCalledOnce() + expect(result?.providerMessages?.[0]?.role).toBe('assistant') expect(result?.providerMessages?.[0]?.content).toBe( - 'Summary of earlier conversation:\nthe gist', + '\nthe gist\n', ) }) + + it('reuses a checkpoint without an explicit strategyKey', async () => { + const store = memoryStore() + const summarize = vi.fn(async () => 'the gist') + const messages = [ + big('user'), + big('assistant'), + big('user'), + big('assistant'), + ] + const options = { + maxTokens: 100, + strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }), + } + + await runOnConfig( + withCompaction(options), + messages, + checkpointContext(store), + ) + const second = await runOnConfig( + withCompaction(options), + [...messages, text('user', 'new')], + checkpointContext(store), + ) + + expect(summarize).toHaveBeenCalledOnce() + expect(second?.providerMessages?.at(-1)?.content).toBe('new') + }) }) describe('clearToolResults', () => { diff --git a/packages/ai-compaction/src/index.ts b/packages/ai-compaction/src/index.ts index b35388b2ae..e1c95e2924 100644 --- a/packages/ai-compaction/src/index.ts +++ b/packages/ai-compaction/src/index.ts @@ -160,6 +160,13 @@ function splitAtRecent( // Always keep at least the last message. if (cut >= messages.length) cut = messages.length - 1 while (cut < messages.length && messages[cut]?.role === 'tool') cut++ + // Trailing tool results: skipping orphans would drop the whole tail (the + // normal agent-loop state). Keep those results and the message that owns them. + if (cut >= messages.length) { + cut = messages.length + while (cut > 0 && messages[cut - 1]?.role === 'tool') cut-- + if (cut > 0) cut-- + } return cut } @@ -178,8 +185,8 @@ export function evictOldest( const strategy: CompactionStrategy = (messages, ctx) => { const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2) const cut = splitAtRecent(messages, ctx.estimate, keep) - // ponytail: can't shrink past the recent window; raise keepRecentTokens or - // lower maxTokens if compaction never fires. + // Can't shrink past the recent window; raise keepRecentTokens or lower + // maxTokens if compaction never fires. if (cut <= 0) return null const marker = options.marker?.(cut) ?? @@ -203,22 +210,26 @@ export function summarizeOldest(options: { summarize: (messages: Array) => Promise /** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */ keepRecentTokens?: number - /** Role of the injected summary message. Default `'user'`. */ + /** Role of the injected summary message. Default `'assistant'`. */ summaryRole?: 'user' | 'assistant' }): CompactionStrategy { - return async (messages, ctx) => { + const strategy: CompactionStrategy = async (messages, ctx) => { const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2) const cut = splitAtRecent(messages, ctx.estimate, keep) if (cut <= 0) return null const summary = await options.summarize(messages.slice(0, cut)) return [ { - role: options.summaryRole ?? 'user', - content: `Summary of earlier conversation:\n${summary}`, + role: options.summaryRole ?? 'assistant', + content: `\n${summary}\n`, }, ...messages.slice(cut), ] } + return identifyStrategy( + strategy, + `summarize-oldest:${options.keepRecentTokens ?? 'half'}:${options.summaryRole ?? 'assistant'}`, + ) } /** @@ -321,6 +332,10 @@ export function withCompaction(options: CompactionOptions): ChatMiddleware { name: 'compaction', optionalRequires: [MetadataCapability], async onConfig(ctx, config) { + // init is discarded by the engine rebuild and can run before persistence + // hydrates the thread. Compact only on model-bound phases. + if (ctx.phase === 'init') return + const { messages } = config const inputMessages = config.providerMessages ?? messages const metadata = getMetadata(ctx, { optional: true }) From e6c3edcf609820dfaa5b81c59b0dc75ae7fce31c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 14:33:01 +0200 Subject: [PATCH 11/13] feat(ai-compaction): show compaction stats in AI DevTools --- .changeset/compaction-devtools.md | 11 ++ docs/advanced/compaction.md | 10 + docs/config.json | 2 +- examples/ts-react-chat/package.json | 1 + .../ts-react-chat/src/components/Header.tsx | 14 ++ examples/ts-react-chat/src/routeTree.gen.ts | 42 +++++ .../src/routes/api.compaction.ts | 160 ++++++++++++++++ .../ts-react-chat/src/routes/compaction.tsx | 176 ++++++++++++++++++ packages/ai-client/src/chat-client.ts | 3 + packages/ai-client/src/devtools-noop.ts | 1 + packages/ai-client/src/devtools.ts | 44 +++++ packages/ai-client/tests/devtools.test.ts | 59 ++++++ packages/ai-compaction/README.md | 4 + packages/ai-compaction/src/index.test.ts | 42 ++++- packages/ai-compaction/src/index.ts | 84 ++++++++- .../components/conversation/IterationCard.tsx | 1 + packages/ai-devtools/src/store/ai-context.tsx | 42 +++++ packages/ai-event-client/src/index.ts | 21 +++ pnpm-lock.yaml | 4 +- 19 files changed, 710 insertions(+), 11 deletions(-) create mode 100644 .changeset/compaction-devtools.md create mode 100644 examples/ts-react-chat/src/routes/api.compaction.ts create mode 100644 examples/ts-react-chat/src/routes/compaction.tsx diff --git a/.changeset/compaction-devtools.md b/.changeset/compaction-devtools.md new file mode 100644 index 0000000000..f2328f40db --- /dev/null +++ b/.changeset/compaction-devtools.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai-compaction': minor +'@tanstack/ai-client': patch +'@tanstack/ai-event-client': patch +'@tanstack/ai-devtools-core': patch +--- + +Show compaction in TanStack AI DevTools. `withCompaction` injects a +`compaction:state` CUSTOM stream event with before/after token and message +counts. The chat client re-emits `compaction:applied` so the AI panel can +render an `onCompact` step. diff --git a/docs/advanced/compaction.md b/docs/advanced/compaction.md index ea6c94684b..a449919067 100644 --- a/docs/advanced/compaction.md +++ b/docs/advanced/compaction.md @@ -193,6 +193,16 @@ The token count is a rough `characters / 4` estimate. It is good enough to trigg - **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again. - **The canonical transcript stays complete.** Compaction writes provider-only context. Persistence and other middleware still read `ctx.messages`. +## DevTools + +After a compaction, the chat stream includes a `compaction:state` CUSTOM event. +TanStack AI DevTools shows a `compaction` / `onCompact` step on that iteration. +The step lists before and after token and message counts. + +Open the AI plugin in the DevTools panel (the `ts-react-chat` example mounts it). Then inspect the iteration that ran the model call. + +The `/compaction` route in `examples/ts-react-chat` uses a small `maxTokens` so this fires after a few turns. + ## Compaction and persistence Compaction and server-side [`withPersistence`](../persistence/chat-persistence) diff --git a/docs/config.json b/docs/config.json index ea12c9d6c1..82059ec821 100644 --- a/docs/config.json +++ b/docs/config.json @@ -549,7 +549,7 @@ "label": "Compaction", "to": "advanced/compaction", "addedAt": "2026-08-24", - "updatedAt": "2026-08-26" + "updatedAt": "2026-08-27" }, { "label": "Locks", diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index 15cd77c5b5..846f3745e9 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -25,6 +25,7 @@ "@tanstack/ai-byteplus": "workspace:*", "@tanstack/ai-claude-code": "workspace:*", "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-compaction": "workspace:*", "@tanstack/ai-code-mode": "workspace:*", "@tanstack/ai-codex": "workspace:*", "@tanstack/ai-elevenlabs": "workspace:*", diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index ae2a3cbf3a..fc6d50cbb5 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -21,6 +21,7 @@ import { PauseCircle, Plug, RefreshCw, + Scissors, Server, Sparkles, Video, @@ -250,6 +251,19 @@ export default function Header() { Examples

+ 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', + }} + > + + Compaction + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index c4dd562270..f90415fe67 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -29,6 +29,7 @@ import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenericInterruptsRouteImport } from './routes/generic-interrupts' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' +import { Route as CompactionRouteImport } from './routes/compaction' import { Route as AppStudioRouteImport } from './routes/app-studio' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' @@ -66,6 +67,7 @@ import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-r import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiGenericInterruptsRouteImport } from './routes/api.generic-interrupts' import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' +import { Route as ApiCompactionRouteImport } from './routes/api.compaction' import { Route as ApiArtifactsRouteImport } from './routes/api.artifacts' import { Route as ApiAppStudioForkRouteImport } from './routes/api.app-studio-fork' import { Route as ApiAppStudioRouteImport } from './routes/api.app-studio' @@ -177,6 +179,11 @@ const CapabilityDemoRoute = CapabilityDemoRouteImport.update({ path: '/capability-demo', getParentRoute: () => rootRouteImport, } as any) +const CompactionRoute = CompactionRouteImport.update({ + id: '/compaction', + path: '/compaction', + getParentRoute: () => rootRouteImport, +} as any) const AppStudioRoute = AppStudioRouteImport.update({ id: '/app-studio', path: '/app-studio', @@ -367,6 +374,11 @@ const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ path: '/api/capability-demo', getParentRoute: () => rootRouteImport, } as any) +const ApiCompactionRoute = ApiCompactionRouteImport.update({ + id: '/api/compaction', + path: '/api/compaction', + getParentRoute: () => rootRouteImport, +} as any) const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ id: '/api/artifacts', path: '/api/artifacts', @@ -423,6 +435,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/app-studio': typeof AppStudioRoute '/capability-demo': typeof CapabilityDemoRoute + '/compaction': typeof CompactionRoute '/generation-hooks': typeof GenerationHooksRoute '/generic-interrupts': typeof GenericInterruptsRoute '/image-gen': typeof ImageGenRoute @@ -446,6 +459,7 @@ export interface FileRoutesByFullPath { '/api/app-studio-fork': typeof ApiAppStudioForkRoute '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute + '/api/compaction': typeof ApiCompactionRoute '/api/generic-interrupts': typeof ApiGenericInterruptsRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -492,6 +506,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/app-studio': typeof AppStudioRoute '/capability-demo': typeof CapabilityDemoRoute + '/compaction': typeof CompactionRoute '/generation-hooks': typeof GenerationHooksRoute '/generic-interrupts': typeof GenericInterruptsRoute '/image-gen': typeof ImageGenRoute @@ -515,6 +530,7 @@ export interface FileRoutesByTo { '/api/app-studio-fork': typeof ApiAppStudioForkRoute '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute + '/api/compaction': typeof ApiCompactionRoute '/api/generic-interrupts': typeof ApiGenericInterruptsRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -562,6 +578,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/app-studio': typeof AppStudioRoute '/capability-demo': typeof CapabilityDemoRoute + '/compaction': typeof CompactionRoute '/generation-hooks': typeof GenerationHooksRoute '/generic-interrupts': typeof GenericInterruptsRoute '/image-gen': typeof ImageGenRoute @@ -585,6 +602,7 @@ export interface FileRoutesById { '/api/app-studio-fork': typeof ApiAppStudioForkRoute '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute + '/api/compaction': typeof ApiCompactionRoute '/api/generic-interrupts': typeof ApiGenericInterruptsRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -633,6 +651,7 @@ export interface FileRouteTypes { | '/' | '/app-studio' | '/capability-demo' + | '/compaction' | '/generation-hooks' | '/generic-interrupts' | '/image-gen' @@ -656,6 +675,7 @@ export interface FileRouteTypes { | '/api/app-studio-fork' | '/api/artifacts' | '/api/capability-demo' + | '/api/compaction' | '/api/generic-interrupts' | '/api/image-gen' | '/api/image-tool-repro' @@ -702,6 +722,7 @@ export interface FileRouteTypes { | '/' | '/app-studio' | '/capability-demo' + | '/compaction' | '/generation-hooks' | '/generic-interrupts' | '/image-gen' @@ -725,6 +746,7 @@ export interface FileRouteTypes { | '/api/app-studio-fork' | '/api/artifacts' | '/api/capability-demo' + | '/api/compaction' | '/api/generic-interrupts' | '/api/image-gen' | '/api/image-tool-repro' @@ -771,6 +793,7 @@ export interface FileRouteTypes { | '/' | '/app-studio' | '/capability-demo' + | '/compaction' | '/generation-hooks' | '/generic-interrupts' | '/image-gen' @@ -794,6 +817,7 @@ export interface FileRouteTypes { | '/api/app-studio-fork' | '/api/artifacts' | '/api/capability-demo' + | '/api/compaction' | '/api/generic-interrupts' | '/api/image-gen' | '/api/image-tool-repro' @@ -841,6 +865,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AppStudioRoute: typeof AppStudioRoute CapabilityDemoRoute: typeof CapabilityDemoRoute + CompactionRoute: typeof CompactionRoute GenerationHooksRoute: typeof GenerationHooksRoute GenericInterruptsRoute: typeof GenericInterruptsRoute ImageGenRoute: typeof ImageGenRoute @@ -864,6 +889,7 @@ export interface RootRouteChildren { ApiAppStudioForkRoute: typeof ApiAppStudioForkRoute ApiArtifactsRoute: typeof ApiArtifactsRoute ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute + ApiCompactionRoute: typeof ApiCompactionRoute ApiGenericInterruptsRoute: typeof ApiGenericInterruptsRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute @@ -1048,6 +1074,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CapabilityDemoRouteImport parentRoute: typeof rootRouteImport } + '/compaction': { + id: '/compaction' + path: '/compaction' + fullPath: '/compaction' + preLoaderRoute: typeof CompactionRouteImport + parentRoute: typeof rootRouteImport + } '/app-studio': { id: '/app-studio' path: '/app-studio' @@ -1307,6 +1340,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCapabilityDemoRouteImport parentRoute: typeof rootRouteImport } + '/api/compaction': { + id: '/api/compaction' + path: '/api/compaction' + fullPath: '/api/compaction' + preLoaderRoute: typeof ApiCompactionRouteImport + parentRoute: typeof rootRouteImport + } '/api/artifacts': { id: '/api/artifacts' path: '/api/artifacts' @@ -1395,6 +1435,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AppStudioRoute: AppStudioRoute, CapabilityDemoRoute: CapabilityDemoRoute, + CompactionRoute: CompactionRoute, GenerationHooksRoute: GenerationHooksRoute, GenericInterruptsRoute: GenericInterruptsRoute, ImageGenRoute: ImageGenRoute, @@ -1418,6 +1459,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAppStudioForkRoute: ApiAppStudioForkRoute, ApiArtifactsRoute: ApiArtifactsRoute, ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, + ApiCompactionRoute: ApiCompactionRoute, ApiGenericInterruptsRoute: ApiGenericInterruptsRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, diff --git a/examples/ts-react-chat/src/routes/api.compaction.ts b/examples/ts-react-chat/src/routes/api.compaction.ts new file mode 100644 index 0000000000..6b4b2ad3b4 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.compaction.ts @@ -0,0 +1,160 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + createChatOptions, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { + evictOldest, + summarizeOldest, + withCompaction, +} from '@tanstack/ai-compaction' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { grokText } from '@tanstack/ai-grok' +import { openaiText } from '@tanstack/ai-openai' +import { ollamaText } from '@tanstack/ai-ollama' +import { openRouterText } from '@tanstack/ai-openrouter' +import type { AnyTextAdapter, ModelMessage } from '@tanstack/ai' +import type { Provider } from '@/lib/model-selection' + +async function summarizeWith( + adapter: AnyTextAdapter, + messages: Array, +): Promise { + let text = '' + for await (const chunk of chat({ + adapter, + messages: [ + ...messages, + { + role: 'user', + content: 'Summarize the conversation above in 3-4 sentences.', + }, + ], + agentLoopStrategy: maxIterations(1), + })) { + if (chunk.type === 'TEXT_MESSAGE_CONTENT') text += chunk.delta + } + return text +} + +const SYSTEM_PROMPT = `You are a helpful assistant. Keep answers reasonably long +(a paragraph or two) so this demo's context fills up quickly.` + +/** + * Chat endpoint for `/compaction`. Uses a small `maxTokens` so compaction + * fires after a few turns. Stats ride the stream as `compaction:state` + * CUSTOM events and show up in TanStack AI DevTools. + */ +export const Route = createFileRoute('/api/compaction')({ + server: { + handlers: { + POST: async ({ request }) => { + const requestSignal = request.signal + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + const body = await request.json() + const messages = body.messages + const data = body.data || {} + + const provider: Provider = data.provider || 'openai' + const model: string | undefined = data.model + const maxTokens: number = + typeof data.maxTokens === 'number' && data.maxTokens > 0 + ? data.maxTokens + : 400 + const strategyName: 'evict' | 'summarize' = + data.strategy === 'summarize' ? 'summarize' : 'evict' + + try { + const adapterConfig: Partial< + Record { adapter: AnyTextAdapter }> + > = { + anthropic: () => + createChatOptions({ + adapter: anthropicText( + (model || 'claude-sonnet-4-6') as 'claude-sonnet-4-6', + ), + }), + gemini: () => + createChatOptions({ + adapter: geminiText( + (model || + 'gemini-3.1-pro-preview') as 'gemini-3.1-pro-preview', + ), + }), + grok: () => + createChatOptions({ + adapter: grokText( + (model || 'grok-build-0.1') as 'grok-build-0.1', + ), + }), + ollama: () => + createChatOptions({ + adapter: ollamaText((model || 'mistral:7b') as 'mistral:7b'), + }), + openai: () => + createChatOptions({ + adapter: openaiText((model || 'gpt-5.5') as 'gpt-5.5'), + }), + openrouter: () => + createChatOptions({ + adapter: openRouterText( + (model || 'openai/gpt-5.1') as 'openai/gpt-5.1', + ), + }), + } + + const makeOptions = adapterConfig[provider] ?? adapterConfig.openai + if (!makeOptions) { + return new Response(JSON.stringify({ error: 'Unknown provider' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + const options = makeOptions() + const { adapter } = options + + const strategy = + strategyName === 'summarize' + ? summarizeOldest({ + summarize: (msgs) => summarizeWith(adapter, msgs), + }) + : evictOldest() + + const stream = chat({ + ...options, + adapter, + tools: [], + systemPrompts: [SYSTEM_PROMPT], + middleware: [withCompaction({ maxTokens, strategy })], + agentLoopStrategy: maxIterations(5), + messages, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error) { + const message = + error instanceof Error ? error.message : 'An error occurred' + console.error('[api.compaction] Error:', message) + if ( + (error instanceof Error && error.name === 'AbortError') || + abortController.signal.aborted + ) { + return new Response(null, { status: 499 }) + } + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/compaction.tsx b/examples/ts-react-chat/src/routes/compaction.tsx new file mode 100644 index 0000000000..d11c6e7c34 --- /dev/null +++ b/examples/ts-react-chat/src/routes/compaction.tsx @@ -0,0 +1,176 @@ +import { useMemo, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { Send, Scissors } from 'lucide-react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import type { UIMessage } from '@tanstack/ai-react' +import { DEFAULT_MODEL_OPTION, MODEL_OPTIONS } from '@/lib/model-selection' +import type { ModelOption } from '@/lib/model-selection' + +function getMessageText(parts: UIMessage['parts']): string { + return parts + .filter( + (part): part is Extract<(typeof parts)[number], { type: 'text' }> => + part.type === 'text', + ) + .map((part) => part.content) + .join('') +} + +function CompactionPage() { + const [selectedModel, setSelectedModel] = + useState(DEFAULT_MODEL_OPTION) + const [maxTokens, setMaxTokens] = useState(400) + const [strategy, setStrategy] = useState<'evict' | 'summarize'>('evict') + const [input, setInput] = useState('') + + const body = useMemo( + () => ({ + provider: selectedModel.provider, + model: selectedModel.model, + maxTokens, + strategy, + }), + [selectedModel.provider, selectedModel.model, maxTokens, strategy], + ) + + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/compaction'), + body, + devtools: { name: 'Compaction' }, + }) + + const submit = () => { + const text = input.trim() + if (!text || isLoading) return + sendMessage(text) + setInput('') + } + + return ( +
+
+
+ +

Compaction

+
+

+ Chat until the transcript passes maxTokens. Then open TanStack + DevTools (bottom-right), pick the AI plugin, and inspect the + compaction / onCompact step for before and after token counts. +

+
+ + +
+
+ + setMaxTokens(parseInt(e.target.value))} + className="w-full accent-cyan-500" + /> +
+
+ + +
+
+ +
+ {messages.length === 0 ? ( +

+ Send a few long messages. Once the running transcript passes{' '} + {maxTokens} estimated tokens, older messages are compacted for the + model only. The chat still shows the full transcript. +

+ ) : ( + messages.map(({ id, role, parts }) => ( +
+
+ {getMessageText(parts)} +
+
+ )) + )} +
+ +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + submit() + } + }} + placeholder="Type a message…" + disabled={isLoading} + className="flex-1 rounded-lg border border-cyan-500/20 bg-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-cyan-500/50 disabled:opacity-50" + /> + +
+
+
+ ) +} + +export const Route = createFileRoute('/compaction')({ + component: CompactionPage, +}) diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index ac7afe8194..24cb362738 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -820,6 +820,9 @@ export class ChatClient< if (eventType === 'memory:state') { this.devtoolsBridge.recordMemoryState(data) } + if (eventType === 'compaction:state') { + this.devtoolsBridge.recordCompactionState(data) + } this.callbacksRef.current.onCustomEvent(eventType, data, context) }, }, diff --git a/packages/ai-client/src/devtools-noop.ts b/packages/ai-client/src/devtools-noop.ts index 6a1f1ce00b..9672112cdf 100644 --- a/packages/ai-client/src/devtools-noop.ts +++ b/packages/ai-client/src/devtools-noop.ts @@ -87,6 +87,7 @@ export class NoOpChatDevtoolsBridge { } observeChunk(_chunk: StreamChunk): void {} recordMemoryState(_value: unknown): void {} + recordCompactionState(_value: unknown): void {} beginRun(_runId: string, _threadId: string): void {} getCurrentRunEventContext(): ChatClientRunEventContext | undefined { return undefined diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index a5bee8a6a8..448e5cfdaf 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -712,6 +712,7 @@ export class ClientDevtoolsBridge { | 'memory:retrieve:started' | 'memory:retrieve:completed' | 'memory:snapshot' + | 'compaction:applied' | AIDevtoolsRunEventType, visibility: AIDevtoolsEventVisibility = 'client-state', context: { runId?: string } = {}, @@ -770,6 +771,8 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge { client.dispose() }) + it('re-emits compaction:applied from a transported compaction:state CUSTOM chunk', async () => { + const runContexts: Array = [] + const chunks: Array = [ + runStartedChunk({ threadId: 'thread-1', runId: 'run-cmp' }), + { + type: EventType.CUSTOM, + metadata: { tanstack: { model: 'test' } }, + timestamp: Date.now(), + name: 'compaction:state', + value: { + before: 400, + after: 180, + messagesBefore: 8, + messagesAfter: 3, + reusedCheckpoint: false, + }, + }, + textContentChunk({ + messageId: 'msg-cmp', + delta: 'ok', + content: 'ok', + }), + runFinishedChunk({ threadId: 'thread-1', runId: 'run-cmp' }), + ] + const client = createClient({ + connection: createRunTrackingAdapter([chunks], runContexts), + }) + vi.clearAllMocks() + + await client.sendMessage('keep going') + await waitForCondition( + () => eventClientMock.emitted('compaction:applied').length > 0, + ) + + expect(eventClientMock.emitted('compaction:applied')).toEqual([ + [ + 'compaction:applied', + expect.objectContaining({ + before: 400, + after: 180, + messagesBefore: 8, + messagesAfter: 3, + reusedCheckpoint: false, + }), + ], + ]) + + vi.clearAllMocks() + eventClientMock.dispatch('devtools:request-state', {}) + await waitForCondition( + () => eventClientMock.emitted('compaction:applied').length > 0, + ) + expect(eventClientMock.emitted('compaction:applied')).toEqual([ + ['compaction:applied', expect.objectContaining({ after: 180 })], + ]) + + client.dispose() + }) + it('batches structured output update events while preserving final state', async () => { const runContexts: Array = [] const finalObject = { title: 'Pasta', servings: 2 } diff --git a/packages/ai-compaction/README.md b/packages/ai-compaction/README.md index b7a793ae89..9d50c72548 100644 --- a/packages/ai-compaction/README.md +++ b/packages/ai-compaction/README.md @@ -128,3 +128,7 @@ not exact. Pass `estimateTokens` if you need provider-accurate counts. When `withPersistence` provides a metadata store, compaction saves a validated checkpoint automatically. The next request reuses the compacted prefix and adds new canonical messages. Without metadata, compaction remains stateless. + +TanStack AI DevTools shows each compaction as an `onCompact` step (before/after +token and message counts). The stats ride the chat stream as a `compaction:state` +CUSTOM event. diff --git a/packages/ai-compaction/src/index.test.ts b/packages/ai-compaction/src/index.test.ts index e9b28e9ee1..e1b5cc8fc4 100644 --- a/packages/ai-compaction/src/index.test.ts +++ b/packages/ai-compaction/src/index.test.ts @@ -4,10 +4,12 @@ import type { ChatMiddlewareContext, MetadataStore, ModelMessage, + StreamChunk, ToolCall, } from '@tanstack/ai' -import { provideMetadata } from '@tanstack/ai' +import { EventType, provideMetadata } from '@tanstack/ai' import { + COMPACTION_STATE_EVENT, clearToolResults, composeStrategies, estimateMessageTokens, @@ -114,6 +116,44 @@ describe('withCompaction', () => { expect(info.messagesAfter).toBeLessThan(info.messagesBefore) }) + it('injects a compaction:state CUSTOM chunk after compacting', async () => { + const mw = withCompaction({ maxTokens: 100 }) + const ctx = phaseContext('beforeModel') + const msgs = [big('user'), big('assistant'), big('user'), big('assistant')] + await runOnConfig(mw, msgs, ctx) + const chunk: StreamChunk = { + type: EventType.RUN_STARTED, + timestamp: Date.now(), + threadId: 't', + runId: 'r', + } + const out = await mw.onChunk?.(ctx, chunk) + expect(Array.isArray(out)).toBe(true) + const custom = Array.isArray(out) ? out[1] : undefined + expect(custom).toMatchObject({ + type: 'CUSTOM', + name: COMPACTION_STATE_EVENT, + }) + if (custom && custom.type === 'CUSTOM') { + expect(custom.value).toMatchObject({ + reusedCheckpoint: false, + }) + } + }) + + it('does not inject a CUSTOM chunk when under the token budget', async () => { + const mw = withCompaction({ maxTokens: 1000 }) + const ctx = phaseContext('beforeModel') + await runOnConfig(mw, [text('user', 'hi')], ctx) + const chunk: StreamChunk = { + type: EventType.RUN_STARTED, + timestamp: Date.now(), + threadId: 't', + runId: 'r', + } + expect(await mw.onChunk?.(ctx, chunk)).toBeUndefined() + }) + it('does not compact during init', async () => { const onCompact = vi.fn() const mw = withCompaction({ maxTokens: 100, onCompact }) diff --git a/packages/ai-compaction/src/index.ts b/packages/ai-compaction/src/index.ts index e1c95e2924..0bd85c34b8 100644 --- a/packages/ai-compaction/src/index.ts +++ b/packages/ai-compaction/src/index.ts @@ -13,7 +13,42 @@ * `messages`. */ import { MetadataCapability, getMetadata } from '@tanstack/ai' -import type { ChatMiddleware, ModelMessage } from '@tanstack/ai' +import type { + ChatMiddleware, + ChatMiddlewareContext, + ModelMessage, + StreamChunk, +} from '@tanstack/ai' + +/** CUSTOM stream event that carries compaction stats to client DevTools. */ +export const COMPACTION_STATE_EVENT = 'compaction:state' + +/** Payload of {@link COMPACTION_STATE_EVENT}. Token and message counts only. */ +export interface CompactionStateEventValue { + before: number + after: number + messagesBefore: number + messagesAfter: number + reusedCheckpoint: boolean +} + +interface CompactionRequestState { + pending: Array +} + +const stateByCtx = new WeakMap() + +function stageCompactionState( + ctx: ChatMiddlewareContext, + value: CompactionStateEventValue, +) { + let state = stateByCtx.get(ctx) + if (!state) { + state = { pending: [] } + stateByCtx.set(ctx, state) + } + state.pending.push(value) +} const strategyKeys = new WeakMap() const CHECKPOINT_NAMESPACE = '@tanstack/ai-compaction' @@ -361,9 +396,17 @@ export function withCompaction(options: CompactionOptions): ChatMiddleware { const before = sum(workingMessages, estimate) if (before <= options.maxTokens) { - return reusedCheckpoint - ? { providerMessages: workingMessages } - : undefined + if (reusedCheckpoint) { + stageCompactionState(ctx, { + before, + after: before, + messagesBefore: workingMessages.length, + messagesAfter: workingMessages.length, + reusedCheckpoint: true, + }) + return { providerMessages: workingMessages } + } + return } const next = await strategy(workingMessages, { @@ -371,16 +414,29 @@ export function withCompaction(options: CompactionOptions): ChatMiddleware { estimate, }) if (!next || next === workingMessages) { - return reusedCheckpoint - ? { providerMessages: workingMessages } - : undefined + if (reusedCheckpoint) { + stageCompactionState(ctx, { + before, + after: before, + messagesBefore: workingMessages.length, + messagesAfter: workingMessages.length, + reusedCheckpoint: true, + }) + return { providerMessages: workingMessages } + } + return } - options.onCompact?.({ + const info = { before, after: sum(next, estimate), messagesBefore: workingMessages.length, messagesAfter: next.length, + } + options.onCompact?.(info) + stageCompactionState(ctx, { + ...info, + reusedCheckpoint, }) if (metadata && checkpointStrategyKey && inputMessages === messages) { @@ -398,5 +454,17 @@ export function withCompaction(options: CompactionOptions): ChatMiddleware { return { providerMessages: next } }, + onChunk(ctx, chunk) { + const state = stateByCtx.get(ctx) + if (!state?.pending.length) return + const pending = state.pending.splice(0) + const customs: Array = pending.map((value) => ({ + type: 'CUSTOM', + name: COMPACTION_STATE_EVENT, + value, + timestamp: Date.now(), + })) + return [chunk, ...customs] + }, } } diff --git a/packages/ai-devtools/src/components/conversation/IterationCard.tsx b/packages/ai-devtools/src/components/conversation/IterationCard.tsx index 1654746eb8..20c083d7e9 100644 --- a/packages/ai-devtools/src/components/conversation/IterationCard.tsx +++ b/packages/ai-devtools/src/components/conversation/IterationCard.tsx @@ -169,6 +169,7 @@ const MiddlewareStep: Component<{ if (ev().wasDropped) return 'DROP' if (ev().hookName === 'onChunk' && ev().hasTransform) return 'TRANSFORM' if (ev().hookName === 'onConfig' && ev().hasTransform) return 'TRANSFORM' + if (ev().hookName === 'onCompact') return 'COMPACT' if (ev().hookName === 'onBeforeToolCall' && ev().hasTransform) return 'DECISION' return null diff --git a/packages/ai-devtools/src/store/ai-context.tsx b/packages/ai-devtools/src/store/ai-context.tsx index 3a95463587..44ad029f98 100644 --- a/packages/ai-devtools/src/store/ai-context.tsx +++ b/packages/ai-devtools/src/store/ai-context.tsx @@ -2957,6 +2957,48 @@ export const AIProvider: ParentComponent = (props) => { }), ) + cleanupFns.push( + aiEventClient.on('compaction:applied', (e) => { + const { requestId, streamId, clientId } = e.payload + + const conversationId = + clientId || + (streamId ? streamToConversation.get(streamId) : undefined) || + requestToConversation.get(requestId) + if (!conversationId || !state.conversations[conversationId]) return + + const conv = state.conversations[conversationId] + const iterIndex = findLatestIterationIndex(conv, requestId) + if (iterIndex < 0) return + + const mwEvent: MiddlewareEvent = { + id: `mw-cmp-${Date.now()}-${Math.random()}`, + middlewareName: 'compaction', + hookName: 'onCompact', + timestamp: e.payload.timestamp, + hasTransform: true, + configChanges: { + before: e.payload.before, + after: e.payload.after, + messagesBefore: e.payload.messagesBefore, + messagesAfter: e.payload.messagesAfter, + reusedCheckpoint: e.payload.reusedCheckpoint, + }, + } + + setState( + 'conversations', + conversationId, + 'iterations', + iterIndex, + 'middlewareEvents', + produce((arr: Array) => { + arr.push(mwEvent) + }), + ) + }), + ) + cleanupFns.push( aiEventClient.on('summarize:request:started', (e) => { const { requestId, model, inputLength, timestamp, clientId } = e.payload diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 37546bbcfe..55211be2e7 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -979,6 +979,24 @@ export interface VideoUsageEvent extends BaseEventContext { usage: TokenUsage } +// --------------------------------------------------------------------------- +// Compaction events +// --------------------------------------------------------------------------- + +/** Emitted when `withCompaction` rewrites provider context for a model call. */ +export interface CompactionAppliedEvent extends BaseEventContext { + /** Estimated tokens before compaction. */ + before: number + /** Estimated tokens after compaction. */ + after: number + /** Message count before compaction. */ + messagesBefore: number + /** Message count after compaction. */ + messagesAfter: number + /** True when a persisted checkpoint supplied the compacted prefix. */ + reusedCheckpoint: boolean +} + // --------------------------------------------------------------------------- // Memory events // --------------------------------------------------------------------------- @@ -1322,6 +1340,9 @@ export interface AIDevtoolsEventMap { 'client:reloaded': ClientReloadedEvent 'client:stopped': ClientStoppedEvent + // Compaction events + 'compaction:applied': CompactionAppliedEvent + // Memory events 'memory:retrieve:started': MemoryRetrieveStartedEvent 'memory:retrieve:completed': MemoryRetrieveCompletedEvent diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60c9a0a7ff..e489e973be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,6 +697,9 @@ importers: '@tanstack/ai-codex': specifier: workspace:* version: link:../../packages/ai-codex + '@tanstack/ai-compaction': + specifier: workspace:* + version: link:../../packages/ai-compaction '@tanstack/ai-elevenlabs': specifier: workspace:* version: link:../../packages/ai-elevenlabs @@ -11801,7 +11804,6 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' From f3fc67b8819329c8882229ee700755924b1f95e3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 14:58:51 +0200 Subject: [PATCH 12/13] fix(examples): use BYOK on compaction chat and expand DevTools rows --- examples/ts-react-chat/src/routeTree.gen.ts | 52 ++--- .../src/routes/api.compaction.ts | 190 +++++++++++++----- .../ts-react-chat/src/routes/compaction.tsx | 50 ++++- .../components/conversation/IterationCard.tsx | 13 +- 4 files changed, 213 insertions(+), 92 deletions(-) diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index f90415fe67..7c8b6ab002 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -28,8 +28,8 @@ import { Route as ImageToolReproRouteImport } from './routes/image-tool-repro' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenericInterruptsRouteImport } from './routes/generic-interrupts' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' -import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' import { Route as CompactionRouteImport } from './routes/compaction' +import { Route as CapabilityDemoRouteImport } from './routes/capability-demo' import { Route as AppStudioRouteImport } from './routes/app-studio' import { Route as IndexRouteImport } from './routes/index' import { Route as GenerationsVideoRouteImport } from './routes/generations.video' @@ -66,8 +66,8 @@ import { Route as ApiInterruptsRouteImport } from './routes/api.interrupts' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiGenericInterruptsRouteImport } from './routes/api.generic-interrupts' -import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' import { Route as ApiCompactionRouteImport } from './routes/api.compaction' +import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' import { Route as ApiArtifactsRouteImport } from './routes/api.artifacts' import { Route as ApiAppStudioForkRouteImport } from './routes/api.app-studio-fork' import { Route as ApiAppStudioRouteImport } from './routes/api.app-studio' @@ -174,16 +174,16 @@ const GenerationHooksRoute = GenerationHooksRouteImport.update({ path: '/generation-hooks', getParentRoute: () => rootRouteImport, } as any) -const CapabilityDemoRoute = CapabilityDemoRouteImport.update({ - id: '/capability-demo', - path: '/capability-demo', - getParentRoute: () => rootRouteImport, -} as any) const CompactionRoute = CompactionRouteImport.update({ id: '/compaction', path: '/compaction', getParentRoute: () => rootRouteImport, } as any) +const CapabilityDemoRoute = CapabilityDemoRouteImport.update({ + id: '/capability-demo', + path: '/capability-demo', + getParentRoute: () => rootRouteImport, +} as any) const AppStudioRoute = AppStudioRouteImport.update({ id: '/app-studio', path: '/app-studio', @@ -369,16 +369,16 @@ const ApiGenericInterruptsRoute = ApiGenericInterruptsRouteImport.update({ path: '/api/generic-interrupts', getParentRoute: () => rootRouteImport, } as any) -const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ - id: '/api/capability-demo', - path: '/api/capability-demo', - getParentRoute: () => rootRouteImport, -} as any) const ApiCompactionRoute = ApiCompactionRouteImport.update({ id: '/api/compaction', path: '/api/compaction', getParentRoute: () => rootRouteImport, } as any) +const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ + id: '/api/capability-demo', + path: '/api/capability-demo', + getParentRoute: () => rootRouteImport, +} as any) const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ id: '/api/artifacts', path: '/api/artifacts', @@ -1067,13 +1067,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationHooksRouteImport parentRoute: typeof rootRouteImport } - '/capability-demo': { - id: '/capability-demo' - path: '/capability-demo' - fullPath: '/capability-demo' - preLoaderRoute: typeof CapabilityDemoRouteImport - parentRoute: typeof rootRouteImport - } '/compaction': { id: '/compaction' path: '/compaction' @@ -1081,6 +1074,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CompactionRouteImport parentRoute: typeof rootRouteImport } + '/capability-demo': { + id: '/capability-demo' + path: '/capability-demo' + fullPath: '/capability-demo' + preLoaderRoute: typeof CapabilityDemoRouteImport + parentRoute: typeof rootRouteImport + } '/app-studio': { id: '/app-studio' path: '/app-studio' @@ -1333,13 +1333,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGenericInterruptsRouteImport parentRoute: typeof rootRouteImport } - '/api/capability-demo': { - id: '/api/capability-demo' - path: '/api/capability-demo' - fullPath: '/api/capability-demo' - preLoaderRoute: typeof ApiCapabilityDemoRouteImport - parentRoute: typeof rootRouteImport - } '/api/compaction': { id: '/api/compaction' path: '/api/compaction' @@ -1347,6 +1340,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCompactionRouteImport parentRoute: typeof rootRouteImport } + '/api/capability-demo': { + id: '/api/capability-demo' + path: '/api/capability-demo' + fullPath: '/api/capability-demo' + preLoaderRoute: typeof ApiCapabilityDemoRouteImport + parentRoute: typeof rootRouteImport + } '/api/artifacts': { id: '/api/artifacts' path: '/api/artifacts' diff --git a/examples/ts-react-chat/src/routes/api.compaction.ts b/examples/ts-react-chat/src/routes/api.compaction.ts index 6b4b2ad3b4..5c18d97352 100644 --- a/examples/ts-react-chat/src/routes/api.compaction.ts +++ b/examples/ts-react-chat/src/routes/api.compaction.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { chat, + chatParamsFromRequestBody, createChatOptions, maxIterations, toServerSentEventsResponse, @@ -10,15 +11,58 @@ import { summarizeOldest, withCompaction, } from '@tanstack/ai-compaction' -import { anthropicText } from '@tanstack/ai-anthropic' -import { geminiText } from '@tanstack/ai-gemini' -import { grokText } from '@tanstack/ai-grok' -import { openaiText } from '@tanstack/ai-openai' +import { createAnthropicChat } from '@tanstack/ai-anthropic' +import { anthropicByok } from '@tanstack/ai-anthropic/byok' +import { createGeminiChat } from '@tanstack/ai-gemini' +import { geminiByok } from '@tanstack/ai-gemini/byok' +import { createGrokText } from '@tanstack/ai-grok' +import { grokByok } from '@tanstack/ai-grok/byok' +import { createGroqText } from '@tanstack/ai-groq' +import { groqByok } from '@tanstack/ai-groq/byok' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { openaiByok } from '@tanstack/ai-openai/byok' import { ollamaText } from '@tanstack/ai-ollama' -import { openRouterText } from '@tanstack/ai-openrouter' +import { createOpenRouterText } from '@tanstack/ai-openrouter' +import { openrouterByok } from '@tanstack/ai-openrouter/byok' +import { byokMissing, getByokKey } from '@tanstack/ai/byok/server' import type { AnyTextAdapter, ModelMessage } from '@tanstack/ai' +import type { ByokProvider } from '@tanstack/ai/byok' import type { Provider } from '@/lib/model-selection' +const BYOK_PROVIDERS: Partial> = { + openai: openaiByok, + anthropic: anthropicByok, + gemini: geminiByok, + openrouter: openrouterByok, + groq: groqByok, + grok: grokByok, +} + +function chatByokProvider(provider: Provider): ByokProvider | undefined { + if (provider === 'gemini-interactions') return geminiByok + return BYOK_PROVIDERS[provider] +} + +function resolveByokApiKey( + request: Request, + provider: Provider, +): + | { missing: false; apiKey: string | null } + | { missing: true; provider: ByokProvider } { + const byokProvider = chatByokProvider(provider) + if (!byokProvider) return { missing: false, apiKey: null } + const apiKey = getByokKey(request, byokProvider) + if (!apiKey) return { missing: true, provider: byokProvider } + return { missing: false, apiKey } +} + +function requireApiKey(apiKey: string | null): string { + if (!apiKey) { + throw new Error('API key is required') + } + return apiKey +} + async function summarizeWith( adapter: AnyTextAdapter, messages: Array, @@ -45,8 +89,7 @@ const SYSTEM_PROMPT = `You are a helpful assistant. Keep answers reasonably long /** * Chat endpoint for `/compaction`. Uses a small `maxTokens` so compaction - * fires after a few turns. Stats ride the stream as `compaction:state` - * CUSTOM events and show up in TanStack AI DevTools. + * fires after a few turns. Keys come from BYOK headers, same as `/api/tanchat`. */ export const Route = createFileRoute('/api/compaction')({ server: { @@ -58,66 +101,105 @@ export const Route = createFileRoute('/api/compaction')({ } const abortController = new AbortController() - const body = await request.json() - const messages = body.messages - const data = body.data || {} - const provider: Provider = data.provider || 'openai' - const model: string | undefined = data.model + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const requestedProvider = + typeof params.forwardedProps.provider === 'string' + ? params.forwardedProps.provider + : 'openai' + const model: string = + typeof params.forwardedProps.model === 'string' + ? params.forwardedProps.model + : 'gpt-5.5' const maxTokens: number = - typeof data.maxTokens === 'number' && data.maxTokens > 0 - ? data.maxTokens + typeof params.forwardedProps.maxTokens === 'number' && + params.forwardedProps.maxTokens > 0 + ? params.forwardedProps.maxTokens : 400 const strategyName: 'evict' | 'summarize' = - data.strategy === 'summarize' ? 'summarize' : 'evict' + params.forwardedProps.strategy === 'summarize' ? 'summarize' : 'evict' + + const adapterConfig: Partial< + Record< + Provider, + (apiKey: string | null) => { adapter: AnyTextAdapter } + > + > = { + anthropic: (apiKey) => + createChatOptions({ + adapter: createAnthropicChat( + (model || 'claude-sonnet-4-6') as 'claude-sonnet-4-6', + requireApiKey(apiKey), + ), + }), + gemini: (apiKey) => + createChatOptions({ + adapter: createGeminiChat( + (model || 'gemini-3.1-pro-preview') as 'gemini-3.1-pro-preview', + requireApiKey(apiKey), + ), + }), + grok: (apiKey) => + createChatOptions({ + adapter: createGrokText( + (model || 'grok-build-0.1') as 'grok-build-0.1', + requireApiKey(apiKey), + ), + }), + groq: (apiKey) => + createChatOptions({ + adapter: createGroqText( + (model || 'openai/gpt-oss-120b') as 'openai/gpt-oss-120b', + requireApiKey(apiKey), + ), + }), + ollama: () => + createChatOptions({ + adapter: ollamaText((model || 'mistral:7b') as 'mistral:7b'), + }), + openai: (apiKey) => + createChatOptions({ + adapter: createOpenaiChat( + (model || 'gpt-5.5') as 'gpt-5.5', + requireApiKey(apiKey), + ), + }), + openrouter: (apiKey) => + createChatOptions({ + adapter: createOpenRouterText( + (model || 'openai/gpt-5.1') as 'openai/gpt-5.1', + requireApiKey(apiKey), + ), + }), + } try { - const adapterConfig: Partial< - Record { adapter: AnyTextAdapter }> - > = { - anthropic: () => - createChatOptions({ - adapter: anthropicText( - (model || 'claude-sonnet-4-6') as 'claude-sonnet-4-6', - ), - }), - gemini: () => - createChatOptions({ - adapter: geminiText( - (model || - 'gemini-3.1-pro-preview') as 'gemini-3.1-pro-preview', - ), - }), - grok: () => - createChatOptions({ - adapter: grokText( - (model || 'grok-build-0.1') as 'grok-build-0.1', - ), - }), - ollama: () => - createChatOptions({ - adapter: ollamaText((model || 'mistral:7b') as 'mistral:7b'), - }), - openai: () => - createChatOptions({ - adapter: openaiText((model || 'gpt-5.5') as 'gpt-5.5'), - }), - openrouter: () => - createChatOptions({ - adapter: openRouterText( - (model || 'openai/gpt-5.1') as 'openai/gpt-5.1', - ), - }), + const provider: Provider = + requestedProvider in adapterConfig + ? (requestedProvider as Provider) + : 'openai' + const resolvedKey = resolveByokApiKey(request, provider) + if (resolvedKey.missing) { + return byokMissing(resolvedKey.provider) } - const makeOptions = adapterConfig[provider] ?? adapterConfig.openai + const makeOptions = adapterConfig[provider] if (!makeOptions) { return new Response(JSON.stringify({ error: 'Unknown provider' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }) } - const options = makeOptions() + const options = makeOptions(resolvedKey.apiKey) const { adapter } = options const strategy = @@ -134,7 +216,7 @@ export const Route = createFileRoute('/api/compaction')({ systemPrompts: [SYSTEM_PROMPT], middleware: [withCompaction({ maxTokens, strategy })], agentLoopStrategy: maxIterations(5), - messages, + messages: params.messages, abortController, }) diff --git a/examples/ts-react-chat/src/routes/compaction.tsx b/examples/ts-react-chat/src/routes/compaction.tsx index d11c6e7c34..e4d7d1b82b 100644 --- a/examples/ts-react-chat/src/routes/compaction.tsx +++ b/examples/ts-react-chat/src/routes/compaction.tsx @@ -1,8 +1,11 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { Send, Scissors } from 'lucide-react' -import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { fetchServerSentEvents, useByok, useChat } from '@tanstack/ai-react' import type { UIMessage } from '@tanstack/ai-react' +import type { ProviderId } from '@tanstack/ai/byok' +import { ByokKeyDialog } from '@/components/ByokKeyDialog' +import { byok, getEnvKeyStatus, toByokProvider } from '@/lib/byok' import { DEFAULT_MODEL_OPTION, MODEL_OPTIONS } from '@/lib/model-selection' import type { ModelOption } from '@/lib/model-selection' @@ -22,8 +25,26 @@ function CompactionPage() { const [maxTokens, setMaxTokens] = useState(400) const [strategy, setStrategy] = useState<'evict' | 'summarize'>('evict') const [input, setInput] = useState('') + const selectedProviderRef = useRef(selectedModel.provider) + selectedProviderRef.current = selectedModel.provider + const snapshot = useByok(byok) + const [envKeyStatus, setEnvKeyStatus] = useState>({}) + const [keyDialog, setKeyDialog] = useState<{ + open: boolean + provider: ProviderId | null + }>({ open: false, provider: null }) - const body = useMemo( + useEffect(() => { + void getEnvKeyStatus().then(setEnvKeyStatus) + }, []) + + useEffect(() => { + if (snapshot.prompt?.reason === 'missing') { + setKeyDialog({ open: true, provider: snapshot.prompt.provider }) + } + }, [snapshot.prompt]) + + const forwardedProps = useMemo( () => ({ provider: selectedModel.provider, model: selectedModel.model, @@ -33,9 +54,11 @@ function CompactionPage() { [selectedModel.provider, selectedModel.model, maxTokens, strategy], ) - const { messages, sendMessage, isLoading } = useChat({ + const { messages, sendMessage, isLoading, error } = useChat({ connection: fetchServerSentEvents('/api/compaction'), - body, + byok, + byokProvider: () => toByokProvider(selectedProviderRef.current), + forwardedProps, devtools: { name: 'Compaction' }, }) @@ -55,8 +78,8 @@ function CompactionPage() {

Chat until the transcript passes maxTokens. Then open TanStack - DevTools (bottom-right), pick the AI plugin, and inspect the - compaction / onCompact step for before and after token counts. + DevTools (bottom-right), pick the AI plugin, and click the compaction + step to see before and after token counts.

+ setKeyDialog((s) => ({ ...s, open }))} + envStatus={envKeyStatus} + activeProvider={toByokProvider(selectedModel.provider)} + highlightProvider={keyDialog.provider} + />
+ {error && ( +
+ {error.message} +
+ )} +
-
+
{ + if (hasChanges()) setExpanded(!expanded()) + }} + > Middleware @@ -197,10 +203,7 @@ const MiddlewareStep: Component<{ {suffix()} - setExpanded(!expanded())} - > + {expanded() ? 'hide changes' : 'show changes'} From 58209db7117e42fc93da21b33d1e05835f6bb9c6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 15:07:07 +0200 Subject: [PATCH 13/13] fix(ai-devtools): selecting a hook leaves the dashboard --- .../ts-react-chat/src/routes/compaction.tsx | 1 + .../src/components/hooks/HookDashboard.tsx | 5 +++-- .../src/components/hooks/HookDetails.tsx | 9 ++++++--- packages/ai-devtools/src/store/ai-context.tsx | 17 ++++++++++------- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/examples/ts-react-chat/src/routes/compaction.tsx b/examples/ts-react-chat/src/routes/compaction.tsx index e4d7d1b82b..eb925fa8c1 100644 --- a/examples/ts-react-chat/src/routes/compaction.tsx +++ b/examples/ts-react-chat/src/routes/compaction.tsx @@ -56,6 +56,7 @@ function CompactionPage() { const { messages, sendMessage, isLoading, error } = useChat({ connection: fetchServerSentEvents('/api/compaction'), + threadId: 'compaction-demo', byok, byokProvider: () => toByokProvider(selectedProviderRef.current), forwardedProps, diff --git a/packages/ai-devtools/src/components/hooks/HookDashboard.tsx b/packages/ai-devtools/src/components/hooks/HookDashboard.tsx index 8c5bf4f329..dcbc441232 100644 --- a/packages/ai-devtools/src/components/hooks/HookDashboard.tsx +++ b/packages/ai-devtools/src/components/hooks/HookDashboard.tsx @@ -55,8 +55,9 @@ export const HookDashboard: Component = () => { const handleSelect = (hook: HookRecord) => { selectHook(hook.id) - if (state.conversations[hook.id]) { - selectConversation(hook.id) + const conversationId = hook.id || hook.clientId || hook.threadId + if (conversationId && state.conversations[conversationId]) { + selectConversation(conversationId) } } diff --git a/packages/ai-devtools/src/components/hooks/HookDetails.tsx b/packages/ai-devtools/src/components/hooks/HookDetails.tsx index 8ae7860e04..f33661650c 100644 --- a/packages/ai-devtools/src/components/hooks/HookDetails.tsx +++ b/packages/ai-devtools/src/components/hooks/HookDetails.tsx @@ -119,7 +119,8 @@ export const HookDetails: Component = () => { const hook = createMemo((): HookRecord | undefined => { const id = state.hooks.activeHookId - return id ? state.hooks.hooks[id] : undefined + if (id == null) return undefined + return state.hooks.hooks[id] }) const conversation = createMemo(() => { @@ -204,8 +205,10 @@ export const HookDetails: Component = () => { { selectHook(selectedHook.id) - if (state.conversations[selectedHook.id]) { - selectConversation(selectedHook.id) + const conversationId = + selectedHook.id || selectedHook.clientId || selectedHook.threadId + if (conversationId && state.conversations[conversationId]) { + selectConversation(conversationId) } }} /> diff --git a/packages/ai-devtools/src/store/ai-context.tsx b/packages/ai-devtools/src/store/ai-context.tsx index 44ad029f98..69d986a028 100644 --- a/packages/ai-devtools/src/store/ai-context.tsx +++ b/packages/ai-devtools/src/store/ai-context.tsx @@ -6,9 +6,9 @@ import { applyHookEvent, clearHookRegistry, createHookRegistryState, + markHookViewed, removeSavedFixture, replaceSavedFixtures, - setActiveHook, } from './hook-registry' import { createClientToolCallMessage, @@ -661,12 +661,15 @@ export const AIProvider: ParentComponent = (props) => { } function selectHook(id: string | null) { - setState( - 'hooks', - produce((hooks: HookRegistryState) => { - setActiveHook(hooks, id) - }), - ) + setState('hooks', 'activeHookId', id) + if (id) { + setState( + 'hooks', + produce((hooks: HookRegistryState) => { + markHookViewed(hooks, id) + }), + ) + } } function saveToolFixture(fixture: ToolFixtureRecord) {