@tanstack/ai-compaction 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/index.ts"],"sourcesContent":["/**\n * `@tanstack/ai-compaction` — context-window compaction as a `chat()`\n * middleware. `withCompaction({ maxTokens, strategy })` runs before each model\n * call: when the working message set grows past `maxTokens`, the chosen\n * `CompactionStrategy` rewrites the messages. Because it runs every call,\n * compaction is incremental and rolling.\n *\n * Strategies are pluggable, mirroring `AgentLoopStrategy`. Three are built in:\n * {@link evictOldest}, {@link summarizeOldest}, and {@link clearToolResults}.\n * Write your own by passing any {@link CompactionStrategy}.\n *\n * The system prompt is never touched — `chat()` keeps it separate from\n * `messages`.\n */\nimport { MetadataCapability, getMetadata } from '@tanstack/ai'\nimport type {\n ChatMiddleware,\n ChatMiddlewareContext,\n ModelMessage,\n} from '@tanstack/ai'\n\n/** CUSTOM stream event: compaction is about to run. */\nexport const COMPACTION_STARTED_EVENT = 'compaction:started'\n/** CUSTOM stream event: compaction result (counts and previews). */\nexport const COMPACTION_STATE_EVENT = 'compaction:state'\n/** CUSTOM stream event: compaction finished. */\nexport const COMPACTION_ENDED_EVENT = 'compaction:ended'\n\nexport type CompactionStreamEventName =\n | typeof COMPACTION_STARTED_EVENT\n | typeof COMPACTION_STATE_EVENT\n | typeof COMPACTION_ENDED_EVENT\n\nconst PREVIEW_CHARS = 4000\nconst MAX_PREVIEWS = 24\n\n/** One message in a `compaction:state` preview list. */\nexport interface CompactionMessagePreview {\n role: string\n tokens: number\n text: string\n}\n\n/** Payload of {@link COMPACTION_STARTED_EVENT}. */\nexport interface CompactionStartedEventValue {\n before: number\n messagesBefore: number\n reusedCheckpoint: boolean\n maxTokens: number\n strategyKey?: string\n}\n\n/** Payload of {@link COMPACTION_STATE_EVENT}. */\nexport interface CompactionStateEventValue {\n before: number\n after: number\n messagesBefore: number\n messagesAfter: number\n reusedCheckpoint: boolean\n maxTokens: number\n strategyKey?: string\n /** Messages removed or rewritten. */\n dropped?: Array<CompactionMessagePreview>\n /** Messages the model will see after compaction. */\n result?: Array<CompactionMessagePreview>\n}\n\n/** Payload of {@link COMPACTION_ENDED_EVENT}. */\nexport interface CompactionEndedEventValue {\n after: number\n messagesAfter: number\n reusedCheckpoint: boolean\n maxTokens: number\n durationMs: number\n strategyKey?: string\n}\n\nfunction emitCompactionStarted(\n ctx: ChatMiddlewareContext,\n value: CompactionStartedEventValue,\n) {\n ctx.emitCustomEvent(COMPACTION_STARTED_EVENT, value)\n}\n\nfunction emitCompactionState(\n ctx: ChatMiddlewareContext,\n value: CompactionStateEventValue,\n) {\n ctx.emitCustomEvent(COMPACTION_STATE_EVENT, value)\n}\n\nfunction emitCompactionEnded(\n ctx: ChatMiddlewareContext,\n value: CompactionEndedEventValue,\n) {\n ctx.emitCustomEvent(COMPACTION_ENDED_EVENT, value)\n}\n\nconst strategyKeys = new WeakMap<CompactionStrategy, string>()\nconst CHECKPOINT_NAMESPACE = '@tanstack/ai-compaction'\n\ninterface CompactionCheckpoint {\n schemaVersion: 1\n sourceMessageCount: number\n sourceHash: string\n strategyKey: string\n compactedMessages: Array<ModelMessage>\n}\n\nfunction identifyStrategy(\n strategy: CompactionStrategy,\n key: string | undefined,\n): CompactionStrategy {\n if (key) strategyKeys.set(strategy, key)\n return strategy\n}\n\nasync function hashMessages(\n messages: ReadonlyArray<ModelMessage>,\n): Promise<string> {\n const bytes = new TextEncoder().encode(JSON.stringify(messages))\n const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes)\n return Array.from(new Uint8Array(digest), (byte) =>\n byte.toString(16).padStart(2, '0'),\n ).join('')\n}\n\nfunction isModelMessage(value: unknown): value is ModelMessage {\n return (\n typeof value === 'object' &&\n value !== null &&\n 'role' in value &&\n (value.role === 'user' ||\n value.role === 'assistant' ||\n value.role === 'tool') &&\n 'content' in value\n )\n}\n\nfunction isCompactionCheckpoint(value: unknown): value is CompactionCheckpoint {\n return (\n typeof value === 'object' &&\n value !== null &&\n 'schemaVersion' in value &&\n value.schemaVersion === 1 &&\n 'sourceMessageCount' in value &&\n typeof value.sourceMessageCount === 'number' &&\n Number.isInteger(value.sourceMessageCount) &&\n value.sourceMessageCount >= 0 &&\n 'sourceHash' in value &&\n typeof value.sourceHash === 'string' &&\n 'strategyKey' in value &&\n typeof value.strategyKey === 'string' &&\n 'compactedMessages' in value &&\n Array.isArray(value.compactedMessages) &&\n value.compactedMessages.every(isModelMessage)\n )\n}\n\nfunction messagePreviewText(message: ModelMessage): string {\n if (typeof message.content === 'string') return message.content\n return JSON.stringify(message.content ?? '')\n}\n\nfunction toMessagePreview(\n message: ModelMessage,\n estimate: (message: ModelMessage) => number,\n): CompactionMessagePreview {\n const text = messagePreviewText(message)\n return {\n role: message.role,\n tokens: estimate(message),\n text:\n text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}…` : text,\n }\n}\n\nfunction previewList(\n messages: ReadonlyArray<ModelMessage>,\n estimate: (message: ModelMessage) => number,\n): Array<CompactionMessagePreview> {\n const mapped = messages.map((message) => toMessagePreview(message, estimate))\n if (mapped.length <= MAX_PREVIEWS) return mapped\n return mapped.slice(0, MAX_PREVIEWS)\n}\n\nfunction droppedMessages(\n before: ReadonlyArray<ModelMessage>,\n after: ReadonlyArray<ModelMessage>,\n): Array<ModelMessage> {\n const afterKeys = new Set(after.map((message) => JSON.stringify(message)))\n return before.filter((message) => !afterKeys.has(JSON.stringify(message)))\n}\n\nfunction compactionStateValue(args: {\n before: number\n after: number\n messagesBefore: number\n messagesAfter: number\n reusedCheckpoint: boolean\n maxTokens: number\n strategyKey?: string\n beforeMessages?: ReadonlyArray<ModelMessage>\n afterMessages?: ReadonlyArray<ModelMessage>\n estimate: (message: ModelMessage) => number\n}): CompactionStateEventValue {\n const value: CompactionStateEventValue = {\n before: args.before,\n after: args.after,\n messagesBefore: args.messagesBefore,\n messagesAfter: args.messagesAfter,\n reusedCheckpoint: args.reusedCheckpoint,\n maxTokens: args.maxTokens,\n ...(args.strategyKey ? { strategyKey: args.strategyKey } : {}),\n }\n if (args.afterMessages) {\n value.result = previewList(args.afterMessages, args.estimate)\n }\n if (args.beforeMessages && args.afterMessages) {\n value.dropped = previewList(\n droppedMessages(args.beforeMessages, args.afterMessages),\n args.estimate,\n )\n }\n return value\n}\n\n/** Rough token estimate for one message. Default: characters / 4. */\nexport function estimateMessageTokens(message: ModelMessage): number {\n let text = messagePreviewText(message)\n if (message.toolCalls?.length) text += JSON.stringify(message.toolCalls)\n return Math.ceil(text.length / 4)\n}\n\n/** What a {@link CompactionStrategy} receives alongside the messages. */\nexport interface CompactionContext {\n /** The `maxTokens` budget from `withCompaction`. */\n maxTokens: number\n /** The shared token estimator (default {@link estimateMessageTokens}). */\n estimate: (message: ModelMessage) => number\n}\n\n/**\n * Shrinks a message list. Called only when the estimate is over budget.\n * Return the rewritten messages, or `null` to leave them unchanged.\n */\nexport type CompactionStrategy = (\n messages: ReadonlyArray<ModelMessage>,\n ctx: CompactionContext,\n) => Array<ModelMessage> | null | Promise<Array<ModelMessage> | null>\n\n/** Reported to `onCompact` after each compaction event. */\nexport interface CompactionInfo {\n /** Estimated tokens before compaction. */\n before: number\n /** Estimated tokens after compaction. */\n after: number\n /** Message count before compaction. */\n messagesBefore: number\n /** Message count after compaction (unchanged for {@link clearToolResults}). */\n messagesAfter: number\n}\n\nexport interface CompactionOptions {\n /** Compact when estimated tokens across `messages` exceed this. */\n maxTokens: number\n /** How to shrink the messages. Default: {@link evictOldest}. */\n strategy?: CompactionStrategy\n /** Per-message token estimator. Default: {@link estimateMessageTokens}. */\n estimateTokens?: (message: ModelMessage) => number\n /**\n * Stable identity for persisted checkpoints. Set this for custom strategies\n * or estimators, and change it when their output can change.\n */\n strategyKey?: string\n /** Observe each compaction (logging, metrics). */\n onCompact?: (info: CompactionInfo) => void\n}\n\nconst sum = (\n messages: ReadonlyArray<ModelMessage>,\n estimate: (m: ModelMessage) => number,\n) => messages.reduce((total, m) => total + estimate(m), 0)\n\n/**\n * Find the split point that keeps the most recent messages up to\n * `keepRecentTokens`, then moves the cut forward past any leading tool result\n * so the kept tail never starts with an orphan (its tool call would be dropped).\n * Returns the index where the tail begins (head is `messages[0..cut)`).\n */\nfunction splitAtRecent(\n messages: ReadonlyArray<ModelMessage>,\n estimate: (m: ModelMessage) => number,\n keepRecentTokens: number,\n): number {\n let kept = 0\n let cut = messages.length\n while (cut > 0) {\n const prev = messages[cut - 1]\n if (!prev) break\n const size = estimate(prev)\n if (kept + size > keepRecentTokens) break\n kept += size\n cut--\n }\n // Always keep at least the last message.\n if (cut >= messages.length) cut = messages.length - 1\n while (cut < messages.length && messages[cut]?.role === 'tool') cut++\n // Trailing tool results: skipping orphans would drop the whole tail (the\n // normal agent-loop state). Keep those results and the message that owns them.\n if (cut >= messages.length) {\n cut = messages.length\n while (cut > 0 && messages[cut - 1]?.role === 'tool') cut--\n if (cut > 0) cut--\n }\n return cut\n}\n\n/**\n * Drop the oldest messages and replace them with a short marker. Cheapest\n * strategy — no extra model call. This is the default.\n */\nexport function evictOldest(\n options: {\n /** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */\n keepRecentTokens?: number\n /** Build the marker that replaces the dropped head. */\n marker?: (droppedCount: number) => string\n } = {},\n): CompactionStrategy {\n const strategy: CompactionStrategy = (messages, ctx) => {\n const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2)\n const cut = splitAtRecent(messages, ctx.estimate, keep)\n // Can't shrink past the recent window; raise keepRecentTokens or lower\n // maxTokens if compaction never fires.\n if (cut <= 0) return null\n const marker =\n options.marker?.(cut) ??\n `[${cut} earlier message(s) omitted to save context.]`\n return [{ role: 'user', content: marker }, ...messages.slice(cut)]\n }\n return identifyStrategy(\n strategy,\n options.marker\n ? undefined\n : `evict-oldest:${options.keepRecentTokens ?? 'half'}`,\n )\n}\n\n/**\n * Drop the oldest messages and replace them with an LLM summary. Keeps the gist\n * of old turns at the cost of one summarization call. Wire `summarize` to\n * `summarize()` or any model call.\n */\nexport function summarizeOldest(options: {\n summarize: (messages: Array<ModelMessage>) => Promise<string>\n /** Tokens of recent messages to keep verbatim. Default `floor(maxTokens/2)`. */\n keepRecentTokens?: number\n /** Role of the injected summary message. Default `'assistant'`. */\n summaryRole?: 'user' | 'assistant'\n}): CompactionStrategy {\n const strategy: CompactionStrategy = async (messages, ctx) => {\n const keep = options.keepRecentTokens ?? Math.floor(ctx.maxTokens / 2)\n const cut = splitAtRecent(messages, ctx.estimate, keep)\n if (cut <= 0) return null\n const summary = await options.summarize(messages.slice(0, cut))\n return [\n {\n role: options.summaryRole ?? 'assistant',\n content: `<untrusted-conversation-summary>\\n${summary}\\n</untrusted-conversation-summary>`,\n },\n ...messages.slice(cut),\n ]\n }\n return identifyStrategy(\n strategy,\n `summarize-oldest:${options.keepRecentTokens ?? 'half'}:${options.summaryRole ?? 'assistant'}`,\n )\n}\n\n/**\n * Replace the content of old tool-result messages with a stub, keeping every\n * message and its tool-call pairing in place. Best for agent loops where tool\n * output (file reads, command output) dominates the token count — it clears the\n * bulk without disturbing the conversation shape. No extra model call.\n */\nexport function clearToolResults(\n options: {\n /** Number of most-recent tool results to keep verbatim. Default `3`. */\n keepRecentToolResults?: number\n /** Text that replaces a cleared tool result. */\n stub?: string\n } = {},\n): CompactionStrategy {\n const keepN = options.keepRecentToolResults ?? 3\n const stub = options.stub ?? '[tool output cleared to save context]'\n const strategy: CompactionStrategy = (messages) => {\n const toolIndexes: Array<number> = []\n messages.forEach((m, i) => {\n if (m.role === 'tool') toolIndexes.push(i)\n })\n if (toolIndexes.length <= keepN) return null\n const clearBefore = toolIndexes[toolIndexes.length - keepN] ?? 0\n let changed = false\n const next = messages.map((m, i) => {\n if (m.role === 'tool' && i < clearBefore && m.content !== stub) {\n changed = true\n return { ...m, content: stub }\n }\n return m\n })\n return changed ? next : null\n }\n return identifyStrategy(strategy, `clear-tool-results:${keepN}:${stub}`)\n}\n\n/**\n * Run several strategies in order, escalating: stop as soon as the running\n * estimate is back under `maxTokens`. Put the cheap, targeted strategy first\n * (for example {@link clearToolResults}) and a broad fallback last (for example\n * {@link evictOldest}) — the fallback only runs when clearing was not enough.\n * A strategy that returns `null` (no change) is skipped and the next one runs.\n *\n * @example\n * ```ts\n * withCompaction({\n * maxTokens: 100_000,\n * strategy: composeStrategies(clearToolResults(), evictOldest()),\n * })\n * ```\n */\nexport function composeStrategies(\n ...strategies: Array<CompactionStrategy>\n): CompactionStrategy {\n const strategy: CompactionStrategy = async (messages, ctx) => {\n let current: ReadonlyArray<ModelMessage> = messages\n let result: Array<ModelMessage> | null = null\n for (const itemStrategy of strategies) {\n if (sum(current, ctx.estimate) <= ctx.maxTokens) break\n const out = await itemStrategy(current, ctx)\n if (out) {\n current = out\n result = out\n }\n }\n return result\n }\n const keys = strategies.map((item) => strategyKeys.get(item))\n return identifyStrategy(\n strategy,\n keys.every((key) => key !== undefined) ? keys.join('|') : undefined,\n )\n}\n\n/**\n * Context-compaction middleware. Add to `chat({ middleware: [...] })`.\n *\n * @example\n * ```ts\n * chat({\n * adapter,\n * messages,\n * middleware: [withCompaction({ maxTokens: 100_000 })], // evictOldest by default\n * })\n * ```\n */\nexport function withCompaction(options: CompactionOptions): ChatMiddleware {\n const estimate = options.estimateTokens ?? estimateMessageTokens\n const strategy = options.strategy ?? evictOldest()\n const strategyKey =\n options.strategyKey ??\n (options.estimateTokens ? undefined : strategyKeys.get(strategy))\n const checkpointStrategyKey = strategyKey\n ? `${strategyKey}:maxTokens=${options.maxTokens}`\n : undefined\n\n return {\n name: 'compaction',\n optionalRequires: [MetadataCapability],\n async onConfig(ctx, config) {\n // init is discarded by the engine rebuild and can run before persistence\n // hydrates the thread. Compact only on model-bound phases.\n if (ctx.phase === 'init') return\n\n const startedAt = Date.now()\n const { messages } = config\n const inputMessages = config.providerMessages ?? messages\n const metadata = getMetadata(ctx, { optional: true })\n let workingMessages = inputMessages\n let reusedCheckpoint = false\n\n if (metadata && checkpointStrategyKey && inputMessages === messages) {\n const stored = await metadata.get(CHECKPOINT_NAMESPACE, ctx.threadId)\n if (\n isCompactionCheckpoint(stored) &&\n stored.strategyKey === checkpointStrategyKey &&\n stored.sourceMessageCount <= messages.length &&\n stored.sourceHash ===\n (await hashMessages(messages.slice(0, stored.sourceMessageCount)))\n ) {\n workingMessages = [\n ...stored.compactedMessages,\n ...messages.slice(stored.sourceMessageCount),\n ]\n reusedCheckpoint = true\n }\n }\n\n const before = sum(workingMessages, estimate)\n const startedValue: CompactionStartedEventValue = {\n before,\n messagesBefore: workingMessages.length,\n reusedCheckpoint,\n maxTokens: options.maxTokens,\n ...(checkpointStrategyKey\n ? { strategyKey: checkpointStrategyKey }\n : {}),\n }\n\n if (before <= options.maxTokens) {\n if (reusedCheckpoint) {\n emitCompactionStarted(ctx, startedValue)\n const stateValue = compactionStateValue({\n before,\n after: before,\n messagesBefore: workingMessages.length,\n messagesAfter: workingMessages.length,\n reusedCheckpoint: true,\n maxTokens: options.maxTokens,\n strategyKey: checkpointStrategyKey,\n afterMessages: workingMessages,\n estimate,\n })\n emitCompactionState(ctx, stateValue)\n emitCompactionEnded(ctx, {\n after: before,\n messagesAfter: workingMessages.length,\n reusedCheckpoint: true,\n maxTokens: options.maxTokens,\n durationMs: Date.now() - startedAt,\n ...(checkpointStrategyKey\n ? { strategyKey: checkpointStrategyKey }\n : {}),\n })\n return { providerMessages: workingMessages }\n }\n return\n }\n\n emitCompactionStarted(ctx, startedValue)\n const next = await strategy(workingMessages, {\n maxTokens: options.maxTokens,\n estimate,\n })\n if (!next || next === workingMessages) {\n emitCompactionEnded(ctx, {\n after: before,\n messagesAfter: workingMessages.length,\n reusedCheckpoint,\n maxTokens: options.maxTokens,\n durationMs: Date.now() - startedAt,\n ...(checkpointStrategyKey\n ? { strategyKey: checkpointStrategyKey }\n : {}),\n })\n if (reusedCheckpoint) {\n return { providerMessages: workingMessages }\n }\n return\n }\n\n const info = {\n before,\n after: sum(next, estimate),\n messagesBefore: workingMessages.length,\n messagesAfter: next.length,\n }\n options.onCompact?.(info)\n emitCompactionState(\n ctx,\n compactionStateValue({\n ...info,\n reusedCheckpoint,\n maxTokens: options.maxTokens,\n strategyKey: checkpointStrategyKey,\n beforeMessages: workingMessages,\n afterMessages: next,\n estimate,\n }),\n )\n emitCompactionEnded(ctx, {\n after: info.after,\n messagesAfter: info.messagesAfter,\n reusedCheckpoint,\n maxTokens: options.maxTokens,\n durationMs: Date.now() - startedAt,\n ...(checkpointStrategyKey\n ? { strategyKey: checkpointStrategyKey }\n : {}),\n })\n\n if (metadata && checkpointStrategyKey && inputMessages === messages) {\n const checkpoint: CompactionCheckpoint = {\n schemaVersion: 1,\n sourceMessageCount: messages.length,\n sourceHash: await hashMessages(messages),\n strategyKey: checkpointStrategyKey,\n compactedMessages: next,\n }\n if (!ctx.signal?.aborted) {\n await metadata.set(CHECKPOINT_NAMESPACE, ctx.threadId, checkpoint)\n }\n }\n\n return { providerMessages: next }\n },\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,10 +1,50 @@
1
- {
2
- "description": "OIDC trusted publishing setup package for @tanstack/ai-compaction",
3
- "name": "@tanstack/ai-compaction",
4
- "version": "0.0.0",
5
- "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
10
- }
1
+ {
2
+ "name": "@tanstack/ai-compaction",
3
+ "version": "0.1.1",
4
+ "description": "Context-window compaction middleware for TanStack AI chat()",
5
+ "author": "",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/TanStack/ai.git",
10
+ "directory": "packages/ai-compaction"
11
+ },
12
+ "type": "module",
13
+ "module": "./dist/esm/index.js",
14
+ "types": "./dist/esm/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/esm/index.d.ts",
18
+ "import": "./dist/esm/index.js"
19
+ }
20
+ },
21
+ "sideEffects": false,
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
26
+ "keywords": [
27
+ "ai",
28
+ "tanstack",
29
+ "compaction",
30
+ "context",
31
+ "middleware"
32
+ ],
33
+ "peerDependencies": {
34
+ "@tanstack/ai": "^0.53.0"
35
+ },
36
+ "devDependencies": {
37
+ "@vitest/coverage-v8": "4.1.10",
38
+ "@tanstack/ai": "0.53.0"
39
+ },
40
+ "scripts": {
41
+ "build": "vite build",
42
+ "clean": "premove ./build ./dist",
43
+ "lint:fix": "oxlint src --type-aware --fix",
44
+ "test:build": "publint --strict",
45
+ "test:oxlint": "oxlint src --type-aware",
46
+ "test:lib": "vitest --passWithNoTests",
47
+ "test:lib:dev": "pnpm test:lib --watch",
48
+ "test:types": "tsc"
49
+ }
50
+ }
@@ -0,0 +1,543 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type {
3
+ ChatMiddlewareConfig,
4
+ ChatMiddlewareContext,
5
+ MetadataStore,
6
+ ModelMessage,
7
+ ToolCall,
8
+ } from '@tanstack/ai'
9
+ import { provideMetadata } from '@tanstack/ai'
10
+ import {
11
+ COMPACTION_ENDED_EVENT,
12
+ COMPACTION_STARTED_EVENT,
13
+ COMPACTION_STATE_EVENT,
14
+ clearToolResults,
15
+ composeStrategies,
16
+ estimateMessageTokens,
17
+ evictOldest,
18
+ summarizeOldest,
19
+ withCompaction,
20
+ } from './index'
21
+
22
+ interface RecordedCustom {
23
+ name: string
24
+ value: Record<string, unknown>
25
+ }
26
+
27
+ function recordingContext(
28
+ phase: ChatMiddlewareContext['phase'] = 'beforeModel',
29
+ extras: Partial<ChatMiddlewareContext> = {},
30
+ ): { ctx: ChatMiddlewareContext; events: Array<RecordedCustom> } {
31
+ const events: Array<RecordedCustom> = []
32
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- focused hook stub
33
+ const ctx = {
34
+ phase,
35
+ emitCustomEvent: (name: string, value: Record<string, unknown>) => {
36
+ events.push({ name, value })
37
+ },
38
+ ...extras,
39
+ } as unknown as ChatMiddlewareContext
40
+ return { ctx, events }
41
+ }
42
+
43
+ function runOnConfig(
44
+ mw: ReturnType<typeof withCompaction>,
45
+ messages: Array<ModelMessage>,
46
+ ctx: ChatMiddlewareContext = recordingContext().ctx,
47
+ ) {
48
+ const config: ChatMiddlewareConfig = {
49
+ messages,
50
+ systemPrompts: [],
51
+ tools: [],
52
+ }
53
+ return mw.onConfig?.(ctx, config)
54
+ }
55
+
56
+ function checkpointContext(
57
+ store: MetadataStore,
58
+ options: { aborted?: boolean; phase?: ChatMiddlewareContext['phase'] } = {},
59
+ ): ChatMiddlewareContext {
60
+ const recorded = recordingContext(options.phase)
61
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- focused hook stub
62
+ const ctx = {
63
+ ...recorded.ctx,
64
+ threadId: 'thread-1',
65
+ signal: options.aborted ? AbortSignal.abort() : undefined,
66
+ capabilities: { markProvided: () => undefined },
67
+ } as unknown as ChatMiddlewareContext
68
+ provideMetadata(ctx, store)
69
+ return ctx
70
+ }
71
+
72
+ function memoryStore(): MetadataStore {
73
+ const values = new Map<string, unknown>()
74
+ return {
75
+ get: async (namespace, key) => values.get(`${namespace}:${key}`) ?? null,
76
+ set: async (namespace, key, value) => {
77
+ values.set(`${namespace}:${key}`, value)
78
+ },
79
+ delete: async (namespace, key) => {
80
+ values.delete(`${namespace}:${key}`)
81
+ },
82
+ }
83
+ }
84
+
85
+ function phaseContext(
86
+ phase: ChatMiddlewareContext['phase'],
87
+ ): ChatMiddlewareContext {
88
+ return recordingContext(phase).ctx
89
+ }
90
+
91
+ const text = (role: ModelMessage['role'], content: string): ModelMessage => ({
92
+ role,
93
+ content,
94
+ })
95
+ // ~40 tokens each at chars/4.
96
+ const big = (role: ModelMessage['role']) => text(role, 'x'.repeat(160))
97
+
98
+ const call: ToolCall = {
99
+ id: 't1',
100
+ type: 'function',
101
+ function: { name: 'f', arguments: '{}' },
102
+ }
103
+
104
+ describe('withCompaction', () => {
105
+ it('passes through when under the token budget', async () => {
106
+ const mw = withCompaction({ maxTokens: 1000 })
107
+ const result = await runOnConfig(mw, [
108
+ text('user', 'hi'),
109
+ text('assistant', 'hello'),
110
+ ])
111
+ expect(result).toBeUndefined()
112
+ })
113
+
114
+ it('defaults to evictOldest', async () => {
115
+ const mw = withCompaction({ maxTokens: 100 })
116
+ const msgs = [big('user'), big('assistant'), big('user'), big('assistant')]
117
+ const result = await runOnConfig(mw, msgs)
118
+ const out = result?.providerMessages ?? []
119
+ expect(out[0]?.content).toContain('omitted')
120
+ expect(out[out.length - 1]).toBe(msgs[msgs.length - 1])
121
+ })
122
+
123
+ it('reports before/after token and message counts via onCompact', async () => {
124
+ const onCompact = vi.fn()
125
+ const mw = withCompaction({ maxTokens: 100, onCompact })
126
+ await runOnConfig(mw, [
127
+ big('user'),
128
+ big('assistant'),
129
+ big('user'),
130
+ big('assistant'),
131
+ ])
132
+ expect(onCompact).toHaveBeenCalledOnce()
133
+ const info = onCompact.mock.calls[0]?.[0]
134
+ expect(info.after).toBeLessThan(info.before)
135
+ expect(info.messagesAfter).toBeLessThan(info.messagesBefore)
136
+ })
137
+
138
+ it('emits started, state, and ended custom events when compacting', async () => {
139
+ const mw = withCompaction({ maxTokens: 100 })
140
+ const { ctx, events } = recordingContext('beforeModel')
141
+ const msgs = [big('user'), big('assistant'), big('user'), big('assistant')]
142
+ await runOnConfig(mw, msgs, ctx)
143
+ expect(events.map((event) => event.name)).toEqual([
144
+ COMPACTION_STARTED_EVENT,
145
+ COMPACTION_STATE_EVENT,
146
+ COMPACTION_ENDED_EVENT,
147
+ ])
148
+ const stateValue = events[1]?.value
149
+ expect(stateValue).toMatchObject({
150
+ reusedCheckpoint: false,
151
+ maxTokens: 100,
152
+ })
153
+ expect(Array.isArray(stateValue?.dropped)).toBe(true)
154
+ expect(Array.isArray(stateValue?.result)).toBe(true)
155
+ expect(
156
+ Array.isArray(stateValue?.dropped) ? stateValue.dropped.length : 0,
157
+ ).toBeGreaterThan(0)
158
+ expect(typeof events[2]?.value.durationMs).toBe('number')
159
+ })
160
+
161
+ it('emits started before summarizeOldest finishes', async () => {
162
+ let release!: (summary: string) => void
163
+ const gate = new Promise<string>((resolve) => {
164
+ release = resolve
165
+ })
166
+ const mw = withCompaction({
167
+ maxTokens: 100,
168
+ strategy: summarizeOldest({ summarize: () => gate }),
169
+ })
170
+ const { ctx, events } = recordingContext('beforeModel')
171
+ const pending = runOnConfig(
172
+ mw,
173
+ [big('user'), big('assistant'), big('user'), big('assistant')],
174
+ ctx,
175
+ )
176
+ await vi.waitFor(() => {
177
+ expect(events.map((event) => event.name)).toEqual([
178
+ COMPACTION_STARTED_EVENT,
179
+ ])
180
+ })
181
+ release('the gist')
182
+ await pending
183
+ expect(events.map((event) => event.name)).toEqual([
184
+ COMPACTION_STARTED_EVENT,
185
+ COMPACTION_STATE_EVENT,
186
+ COMPACTION_ENDED_EVENT,
187
+ ])
188
+ })
189
+
190
+ it('does not emit custom events when under the token budget', async () => {
191
+ const mw = withCompaction({ maxTokens: 1000 })
192
+ const { ctx, events } = recordingContext('beforeModel')
193
+ await runOnConfig(mw, [text('user', 'hi')], ctx)
194
+ expect(events).toEqual([])
195
+ })
196
+
197
+ it('does not compact during init', async () => {
198
+ const onCompact = vi.fn()
199
+ const mw = withCompaction({ maxTokens: 100, onCompact })
200
+ const result = await runOnConfig(
201
+ mw,
202
+ [big('user'), big('assistant'), big('user'), big('assistant')],
203
+ phaseContext('init'),
204
+ )
205
+ expect(result).toBeUndefined()
206
+ expect(onCompact).not.toHaveBeenCalled()
207
+ })
208
+
209
+ it('runs summarize once per beforeModel call with no metadata store', async () => {
210
+ const summarize = vi.fn(async () => 'the gist')
211
+ const onCompact = vi.fn()
212
+ const mw = withCompaction({
213
+ maxTokens: 100,
214
+ strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }),
215
+ onCompact,
216
+ })
217
+ const msgs = [big('user'), big('assistant'), big('user'), big('assistant')]
218
+
219
+ await runOnConfig(mw, msgs, phaseContext('init'))
220
+ await runOnConfig(mw, msgs, phaseContext('beforeModel'))
221
+
222
+ expect(summarize).toHaveBeenCalledOnce()
223
+ expect(onCompact).toHaveBeenCalledOnce()
224
+ })
225
+
226
+ it('reuses a persisted checkpoint for an unchanged canonical prefix', async () => {
227
+ const store = memoryStore()
228
+ const summarize = vi.fn(async () => 'the gist')
229
+ const messages = [
230
+ big('user'),
231
+ big('assistant'),
232
+ big('user'),
233
+ big('assistant'),
234
+ ]
235
+ const options = {
236
+ maxTokens: 100,
237
+ strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }),
238
+ strategyKey: 'summary-v1',
239
+ }
240
+
241
+ const first = await runOnConfig(
242
+ withCompaction(options),
243
+ messages,
244
+ checkpointContext(store),
245
+ )
246
+ const appended = [...messages, text('user', 'new')]
247
+ const second = await runOnConfig(
248
+ withCompaction(options),
249
+ appended,
250
+ checkpointContext(store),
251
+ )
252
+
253
+ expect(summarize).toHaveBeenCalledOnce()
254
+ expect(first?.providerMessages?.[0]?.content).toContain('the gist')
255
+ expect(second?.providerMessages?.[0]?.content).toContain('the gist')
256
+ expect(second?.providerMessages?.at(-1)?.content).toBe('new')
257
+ expect(appended).toHaveLength(5)
258
+ })
259
+
260
+ it('rejects a checkpoint when the canonical prefix changes', async () => {
261
+ const store = memoryStore()
262
+ const summarize = vi.fn(async () => 'the gist')
263
+ const options = {
264
+ maxTokens: 100,
265
+ strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }),
266
+ strategyKey: 'summary-v1',
267
+ }
268
+ const messages = [
269
+ big('user'),
270
+ big('assistant'),
271
+ big('user'),
272
+ big('assistant'),
273
+ ]
274
+
275
+ await runOnConfig(
276
+ withCompaction(options),
277
+ messages,
278
+ checkpointContext(store),
279
+ )
280
+ await runOnConfig(
281
+ withCompaction(options),
282
+ [text('user', 'changed'.repeat(30)), ...messages.slice(1)],
283
+ checkpointContext(store),
284
+ )
285
+
286
+ expect(summarize).toHaveBeenCalledTimes(2)
287
+ })
288
+
289
+ it('does not write a checkpoint after cancellation', async () => {
290
+ const set = vi.fn<MetadataStore['set']>()
291
+ const store: MetadataStore = {
292
+ get: async () => null,
293
+ set,
294
+ delete: async () => undefined,
295
+ }
296
+
297
+ await runOnConfig(
298
+ withCompaction({ maxTokens: 100 }),
299
+ [big('user'), big('assistant'), big('user'), big('assistant')],
300
+ checkpointContext(store, { aborted: true }),
301
+ )
302
+
303
+ expect(set).not.toHaveBeenCalled()
304
+ })
305
+ })
306
+
307
+ describe('evictOldest', () => {
308
+ it('keeps the recent tail and drops the head', async () => {
309
+ const mw = withCompaction({
310
+ maxTokens: 100,
311
+ strategy: evictOldest({ keepRecentTokens: 50 }),
312
+ })
313
+ const msgs = [big('user'), big('assistant'), big('user'), big('assistant')]
314
+ const out = (await runOnConfig(mw, msgs))?.providerMessages ?? []
315
+ expect(out[0]?.content).toContain('omitted')
316
+ expect(out[out.length - 1]).toBe(msgs[msgs.length - 1])
317
+ })
318
+
319
+ it('never lets the tail start with an orphaned tool result', async () => {
320
+ const assistantCall: ModelMessage = {
321
+ role: 'assistant',
322
+ content: 'x'.repeat(160),
323
+ toolCalls: [call],
324
+ }
325
+ const toolResult: ModelMessage = {
326
+ role: 'tool',
327
+ content: 'x'.repeat(160),
328
+ toolCallId: 't1',
329
+ }
330
+ const msgs = [big('user'), assistantCall, toolResult, big('user')]
331
+ const mw = withCompaction({
332
+ maxTokens: 100,
333
+ strategy: evictOldest({ keepRecentTokens: 45 }),
334
+ })
335
+ const out = (await runOnConfig(mw, msgs))?.providerMessages ?? []
336
+ expect(out.slice(1).some((m) => m.role === 'tool')).toBe(false)
337
+ })
338
+
339
+ it('keeps the trailing assistant plus tool result when the transcript ends in a tool', async () => {
340
+ const assistantCall: ModelMessage = {
341
+ role: 'assistant',
342
+ content: 'x'.repeat(160),
343
+ toolCalls: [call],
344
+ }
345
+ const toolResult: ModelMessage = {
346
+ role: 'tool',
347
+ content: 'x'.repeat(160),
348
+ toolCallId: 't1',
349
+ }
350
+ const msgs = [big('user'), assistantCall, toolResult]
351
+ const mw = withCompaction({
352
+ maxTokens: 100,
353
+ strategy: evictOldest({ keepRecentTokens: 45 }),
354
+ })
355
+ const out = (await runOnConfig(mw, msgs))?.providerMessages ?? []
356
+ expect(out.at(-2)).toBe(assistantCall)
357
+ expect(out.at(-1)).toBe(toolResult)
358
+ expect(out.some((m) => m.role === 'tool')).toBe(true)
359
+ })
360
+
361
+ it('keeps a trailing parallel tool-result group with its assistant', async () => {
362
+ const assistantCall: ModelMessage = {
363
+ role: 'assistant',
364
+ content: 'x'.repeat(160),
365
+ toolCalls: [
366
+ call,
367
+ {
368
+ id: 't2',
369
+ type: 'function',
370
+ function: { name: 'g', arguments: '{}' },
371
+ },
372
+ ],
373
+ }
374
+ const toolA: ModelMessage = {
375
+ role: 'tool',
376
+ content: 'x'.repeat(160),
377
+ toolCallId: 't1',
378
+ }
379
+ const toolB: ModelMessage = {
380
+ role: 'tool',
381
+ content: 'x'.repeat(160),
382
+ toolCallId: 't2',
383
+ }
384
+ const msgs = [big('user'), assistantCall, toolA, toolB]
385
+ const mw = withCompaction({
386
+ maxTokens: 100,
387
+ strategy: evictOldest({ keepRecentTokens: 45 }),
388
+ })
389
+ const out = (await runOnConfig(mw, msgs))?.providerMessages ?? []
390
+ expect(out.slice(-3)).toEqual([assistantCall, toolA, toolB])
391
+ })
392
+ })
393
+
394
+ describe('summarizeOldest', () => {
395
+ it('replaces the head with a summary', async () => {
396
+ const summarize = vi.fn(async () => 'the gist')
397
+ const mw = withCompaction({
398
+ maxTokens: 100,
399
+ strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }),
400
+ })
401
+ const result = await runOnConfig(mw, [
402
+ big('user'),
403
+ big('assistant'),
404
+ big('user'),
405
+ big('assistant'),
406
+ ])
407
+ expect(summarize).toHaveBeenCalledOnce()
408
+ expect(result?.providerMessages?.[0]?.role).toBe('assistant')
409
+ expect(result?.providerMessages?.[0]?.content).toBe(
410
+ '<untrusted-conversation-summary>\nthe gist\n</untrusted-conversation-summary>',
411
+ )
412
+ })
413
+
414
+ it('reuses a checkpoint without an explicit strategyKey', async () => {
415
+ const store = memoryStore()
416
+ const summarize = vi.fn(async () => 'the gist')
417
+ const messages = [
418
+ big('user'),
419
+ big('assistant'),
420
+ big('user'),
421
+ big('assistant'),
422
+ ]
423
+ const options = {
424
+ maxTokens: 100,
425
+ strategy: summarizeOldest({ summarize, keepRecentTokens: 50 }),
426
+ }
427
+
428
+ await runOnConfig(
429
+ withCompaction(options),
430
+ messages,
431
+ checkpointContext(store),
432
+ )
433
+ const second = await runOnConfig(
434
+ withCompaction(options),
435
+ [...messages, text('user', 'new')],
436
+ checkpointContext(store),
437
+ )
438
+
439
+ expect(summarize).toHaveBeenCalledOnce()
440
+ expect(second?.providerMessages?.at(-1)?.content).toBe('new')
441
+ })
442
+ })
443
+
444
+ describe('clearToolResults', () => {
445
+ const toolMsg = (id: string): ModelMessage => ({
446
+ role: 'tool',
447
+ content: 'x'.repeat(400),
448
+ toolCallId: id,
449
+ })
450
+
451
+ it('stubs old tool results but keeps recent ones and message count', async () => {
452
+ const msgs: Array<ModelMessage> = [
453
+ text('user', 'go'),
454
+ toolMsg('a'),
455
+ toolMsg('b'),
456
+ toolMsg('c'),
457
+ toolMsg('d'),
458
+ ]
459
+ const mw = withCompaction({
460
+ maxTokens: 100,
461
+ strategy: clearToolResults({ keepRecentToolResults: 2 }),
462
+ })
463
+ const out = (await runOnConfig(mw, msgs))?.providerMessages ?? []
464
+ // Same number of messages — structure is untouched.
465
+ expect(out.length).toBe(msgs.length)
466
+ // Oldest two tool results are stubbed.
467
+ expect(out[1]?.content).toBe('[tool output cleared to save context]')
468
+ expect(out[2]?.content).toBe('[tool output cleared to save context]')
469
+ // Two most recent tool results are untouched.
470
+ expect(out[3]?.content).toBe('x'.repeat(400))
471
+ expect(out[4]?.content).toBe('x'.repeat(400))
472
+ })
473
+
474
+ it('no-ops when there are not enough tool results to clear', async () => {
475
+ const msgs: Array<ModelMessage> = [big('user'), toolMsg('a'), big('user')]
476
+ const mw = withCompaction({
477
+ maxTokens: 50,
478
+ strategy: clearToolResults({ keepRecentToolResults: 3 }),
479
+ })
480
+ expect(await runOnConfig(mw, msgs)).toBeUndefined()
481
+ })
482
+ })
483
+
484
+ describe('composeStrategies', () => {
485
+ const assistantCall = (id: string): ModelMessage => ({
486
+ role: 'assistant',
487
+ content: '',
488
+ toolCalls: [
489
+ { id, type: 'function', function: { name: 'f', arguments: '{}' } },
490
+ ],
491
+ })
492
+ const toolMsg = (id: string): ModelMessage => ({
493
+ role: 'tool',
494
+ content: 'x'.repeat(800), // ~200 tokens
495
+ toolCallId: id,
496
+ })
497
+ const history = (): Array<ModelMessage> => [
498
+ text('user', 'HEAD_MARKER'),
499
+ assistantCall('a'),
500
+ toolMsg('a'),
501
+ assistantCall('b'),
502
+ toolMsg('b'),
503
+ text('user', 'last'),
504
+ ]
505
+
506
+ it('stops after the first strategy once back under budget', async () => {
507
+ const mw = withCompaction({
508
+ maxTokens: 260,
509
+ strategy: composeStrategies(
510
+ clearToolResults({ keepRecentToolResults: 1 }),
511
+ evictOldest({ keepRecentTokens: 50 }),
512
+ ),
513
+ })
514
+ const msgs = history()
515
+ const out = (await runOnConfig(mw, msgs))?.providerMessages ?? []
516
+ // Clearing one tool result was enough, so evict never ran:
517
+ // the head message and full message count survive.
518
+ expect(out.length).toBe(msgs.length)
519
+ expect(out.some((m) => m.content === 'HEAD_MARKER')).toBe(true)
520
+ expect(out[2]?.content).toBe('[tool output cleared to save context]')
521
+ })
522
+
523
+ it('escalates to the next strategy when the first is not enough', async () => {
524
+ const mw = withCompaction({
525
+ maxTokens: 60,
526
+ strategy: composeStrategies(
527
+ clearToolResults({ keepRecentToolResults: 1 }),
528
+ evictOldest({ keepRecentTokens: 30 }),
529
+ ),
530
+ })
531
+ const msgs = history()
532
+ const out = (await runOnConfig(mw, msgs))?.providerMessages ?? []
533
+ // Clearing was not enough, so evict ran too: the head is dropped.
534
+ expect(out.some((m) => m.content === 'HEAD_MARKER')).toBe(false)
535
+ expect(out[0]?.content).toContain('omitted')
536
+ })
537
+ })
538
+
539
+ describe('estimateMessageTokens', () => {
540
+ it('counts content and tool calls', () => {
541
+ expect(estimateMessageTokens(text('user', 'x'.repeat(40)))).toBe(10)
542
+ })
543
+ })