diff --git a/.changeset/model-message-durable-compaction.md b/.changeset/model-message-durable-compaction.md new file mode 100644 index 0000000..437a01f --- /dev/null +++ b/.changeset/model-message-durable-compaction.md @@ -0,0 +1,13 @@ +--- +'@context-chef/ai-sdk-middleware': minor +--- + +Add ModelMessage-altitude durable compaction. + +`compactModelMessages`, `planCompactionModelMessages`, and `summarizeModelMessages` operate on `ModelMessage[]` — the message type `generateText`/`prepareStep` actually use — so you can run durable compaction directly against your own message store, or inside a `ToolLoopAgent` `prepareStep`. They reuse the provider-agnostic core engine, and `compactModelMessages` preserves the no-op reference-identity contract (returns the input array unchanged when there is nothing old enough to compact, so callers can skip persistence). + +`createCompressionAdapter` now accepts `ai`'s `LanguageModel` (a model id string, or a V3/V2 model) — matching what `prepareStep`/`generateText` hand you — instead of only `LanguageModelV3`. + +Deprecates the `LanguageModelV3Prompt`-typed `compactHistory` / `planCompaction` (still exported and fully working) in favor of the ModelMessage variants; they are slated for removal in the next major. `summarizeMessages` is unchanged. + +Also fixes three round-trip issues in both AI-SDK adapters (V3 and ModelMessage): provider-executed (inline) tool-results no longer trigger a spurious `[No tool result available]` placeholder; tool-message-level `providerOptions` (e.g. Anthropic cache control) is now preserved; and a tool-call with `undefined` input serializes to `"{}"` instead of a non-string value. diff --git a/docs/superpowers/plans/2026-06-17-model-message-durable-compaction-altitude.md b/docs/superpowers/plans/2026-06-17-model-message-durable-compaction-altitude.md new file mode 100644 index 0000000..c1ad972 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-model-message-durable-compaction-altitude.md @@ -0,0 +1,1130 @@ +# ModelMessage Durable-Compaction Altitude — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `ModelMessage[]`-altitude durable-compaction API to `@context-chef/ai-sdk-middleware` (the type `prepareStep`/`generateText` actually hand the host), and deprecate the mis-altitude `V3Prompt` versions — additive, non-breaking, shipped as a minor. + +**Architecture:** A new `ModelMessage ↔ IR` adapter (`modelMessageAdapter.ts`) mirrors the existing V3 `adapter.ts` with the same lossless pass-through strategy, handling the three `ModelMessage`-only shapes (string-shorthand content, `ImagePart`, approval parts). Thin durable entry points (`compactModelMessages`, `planCompactionModelMessages`, `summarizeModelMessages`) reuse the **same** core IR engine (`compactHistory`/`planCompaction`/`summarizeHistory`) and `createCompressionAdapter` — no new compaction or flattening logic. Core is untouched. + +**Tech Stack:** TypeScript (strict), Vitest, pnpm workspace monorepo, AI SDK v6 (`ai` + `@ai-sdk/provider`), Biome. + +**Spec:** `docs/superpowers/specs/2026-06-17-model-message-durable-compaction-altitude-design.md` + +--- + +## File Structure + +| File | Responsibility | Action | +|------|----------------|--------| +| `packages/ai-sdk-middleware/src/adapter.ts` | V3 `fromAISDK`/`toAISDK` + shared `stringifyToolOutput` | Modify — `export` the helper | +| `packages/ai-sdk-middleware/src/modelMessageAdapter.ts` | `ModelMessage ↔ IR` (`fromModelMessages`/`toModelMessages`) | Create | +| `packages/ai-sdk-middleware/src/compaction.ts` | Durable entries; add ModelMessage `compact`/`plan`; `@deprecated` on V3 pair | Modify | +| `packages/ai-sdk-middleware/src/middleware.ts` | Widen `createCompressionAdapter` to `LanguageModel`; add `summarizeModelMessages`; repoint warning | Modify | +| `packages/ai-sdk-middleware/src/index.ts` | Public exports | Modify | +| `packages/ai-sdk-middleware/tests/modelMessageAdapter.test.ts` | Adapter round-trip coverage | Create | +| `packages/ai-sdk-middleware/tests/compactionModelMessages.test.ts` | ModelMessage compact/plan boundary tests | Create | +| `packages/ai-sdk-middleware/tests/summarizeMessages.test.ts` | Add `summarizeModelMessages` case | Modify | +| `packages/ai-sdk-middleware/README.md` / `README.zh-CN.md` | Headline durable example → ModelMessage; deprecation note | Modify | + +**Invariants every task must preserve (regression-covered):** no-op returns the **input reference** (`toBe`); reasoning byte-exact via pass-through; tool-role flattening reused from `createCompressionAdapter`; turn-safe split inherited from core. + +--- + +## Task 0: Worktree setup & baseline green + +**Files:** none (environment only) + +- [ ] **Step 1: Install deps in the worktree** + +Run: `pnpm install` +Expected: completes; `node_modules` populated (worktrees do not inherit the parent's install). + +- [ ] **Step 2: Build core (middleware resolves it via `dist`)** + +Run: `pnpm --filter @context-chef/core build` +Expected: emits `packages/core/dist/*`; exit 0. + +- [ ] **Step 3: Confirm the middleware suite is green before changes** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware test` +Expected: all existing tests PASS (adapter, compaction, summarizeMessages, middleware, truncator). + +--- + +## Task 1: Export the shared `stringifyToolOutput` + +**Files:** +- Modify: `packages/ai-sdk-middleware/src/adapter.ts:259` + +The ModelMessage adapter must reuse this helper, not copy it. It currently reads only `.type`/`.value`, so it works for both `LanguageModelV3ToolResultOutput` and `ModelMessage`'s structurally-identical `ToolResultOutput`. + +- [ ] **Step 1: Add the `export` keyword** + +Change the declaration at `adapter.ts:259` from: + +```ts +function stringifyToolOutput(output: LanguageModelV3ToolResultOutput): string { +``` + +to: + +```ts +export function stringifyToolOutput(output: LanguageModelV3ToolResultOutput): string { +``` + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware typecheck` +Expected: exit 0 (no other change). + +- [ ] **Step 3: Commit** + +```bash +git add packages/ai-sdk-middleware/src/adapter.ts +git commit -m "refactor(ai-sdk-middleware): export stringifyToolOutput for reuse" +``` + +--- + +## Task 2: ModelMessage ↔ IR adapter (TDD) + +**Files:** +- Create: `packages/ai-sdk-middleware/src/modelMessageAdapter.ts` +- Test: `packages/ai-sdk-middleware/tests/modelMessageAdapter.test.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `packages/ai-sdk-middleware/tests/modelMessageAdapter.test.ts`: + +```ts +import type { ModelMessage } from 'ai'; +import { describe, expect, it } from 'vitest'; +import { fromModelMessages, toModelMessages } from '../src/modelMessageAdapter'; + +describe('fromModelMessages', () => { + it('keeps string-shorthand user content as text in IR', () => { + const ir = fromModelMessages([{ role: 'user', content: 'hello' }]); + expect(ir[0].content).toBe('hello'); + expect(ir[0]._mmUserContent).toBe('hello'); + }); + + it('extracts text and records image + file parts as attachments', () => { + const messages: ModelMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { type: 'image', image: 'imgdata', mediaType: 'image/png' }, + { type: 'file', data: 'pdfdata', mediaType: 'application/pdf', filename: 'r.pdf' }, + ], + }, + ]; + const ir = fromModelMessages(messages); + expect(ir[0].content).toBe('look'); + expect(ir[0].attachments).toEqual([ + { mediaType: 'image/png', data: 'imgdata' }, + { mediaType: 'application/pdf', data: 'pdfdata', filename: 'r.pdf' }, + ]); + }); + + it('extracts assistant tool calls and reasoning', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'use a tool' }, + { + role: 'assistant', + content: [ + { type: 'reasoning', text: 'thinking' }, + { type: 'text', text: 'answer' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: { a: 1 } }, + ], + }, + { + role: 'tool', + content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'foo', output: { type: 'text', value: 'ok' } }], + }, + ]; + const assistant = fromModelMessages(messages).find((m) => m.role === 'assistant'); + expect(assistant?.content).toBe('answer'); + expect(assistant?.thinking).toEqual({ thinking: 'thinking' }); + expect(assistant?.tool_calls).toEqual([ + { id: 'c1', type: 'function', function: { name: 'foo', arguments: '{"a":1}' } }, + ]); + }); + + it('splits a tool message into one IR message per tool-result', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'do both' }, + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: {} }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'bar', input: {} }, + ], + }, + { + role: 'tool', + content: [ + { type: 'tool-result', toolCallId: 'c1', toolName: 'foo', output: { type: 'text', value: 'r1' } }, + { type: 'tool-result', toolCallId: 'c2', toolName: 'bar', output: { type: 'json', value: { n: 2 } } }, + ], + }, + ]; + const ir = fromModelMessages(messages).filter((m) => m.role === 'tool'); + expect(ir).toHaveLength(2); + expect(ir[0]).toMatchObject({ content: 'r1', tool_call_id: 'c1' }); + expect(ir[1]).toMatchObject({ content: '{"n":2}', tool_call_id: 'c2' }); + }); +}); + +describe('round-trip (ModelMessage → IR → ModelMessage)', () => { + // Fixtures are valid histories (lead with user; every tool-result has a + // preceding assistant tool-call) so ensureValidHistory is a no-op and the + // round-trip is verbatim — same discipline as tests/adapter.test.ts. + const cases: Record = { + 'string content stays a string': [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'yo' }, + ], + 'array content with text + file': [ + { + role: 'user', + content: [ + { type: 'text', text: 'see' }, + { type: 'file', data: 'd', mediaType: 'image/png' }, + ], + }, + ], + 'image parts': [ + { role: 'user', content: [{ type: 'image', image: 'imgdata', mediaType: 'image/png' }] }, + ], + 'reasoning byte-exact': [ + { role: 'user', content: 'reason' }, + { + role: 'assistant', + content: [ + { type: 'reasoning', text: 'exact reasoning bytes' }, + { type: 'text', text: 'final' }, + ], + }, + ], + 'tool call + result': [ + { role: 'user', content: 'search' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: { q: 'x' } }], + }, + { + role: 'tool', + content: [ + { type: 'tool-result', toolCallId: 'c1', toolName: 'foo', output: { type: 'text', value: 'ok' } }, + ], + }, + ], + 'assistant tool-approval-request rides through verbatim': [ + { role: 'user', content: 'approve?' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'need approval' }, + { type: 'tool-approval-request', approvalId: 'a1', toolCallId: 'c1' }, + ], + }, + ], + 'tool-approval-response preserved in order after its result': [ + { role: 'user', content: 'run' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: {} }], + }, + { + role: 'tool', + content: [ + { type: 'tool-result', toolCallId: 'c1', toolName: 'foo', output: { type: 'text', value: 'ok' } }, + { type: 'tool-approval-response', approvalId: 'a1', approved: true }, + ], + }, + ], + 'providerOptions on a message': [ + { + role: 'user', + content: [{ type: 'text', text: 'hi' }], + providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, + }, + ], + }; + + for (const [name, original] of Object.entries(cases)) { + it(name, () => { + const roundTripped = toModelMessages(fromModelMessages(original)); + expect(roundTripped).toEqual(original); + }); + } +}); + +describe('toModelMessages', () => { + it('emits a text-part array for synthetic messages (e.g. summary) with no pass-through', () => { + const result = toModelMessages([{ role: 'user', content: 'summary text' }]); + expect(result).toEqual([{ role: 'user', content: [{ type: 'text', text: 'summary text' }] }]); + }); + + it('reconstructs from IR fields when content was modified (e.g. cleared tool result)', () => { + const ir = fromModelMessages([ + { role: 'user', content: 'run' }, + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'run', input: {} }] }, + { + role: 'tool', + content: [ + { type: 'tool-result', toolCallId: 'c1', toolName: 'run', output: { type: 'text', value: 'long' } }, + ], + }, + ]); + const toolIr = ir.find((m) => m.role === 'tool'); + if (!toolIr) throw new Error('expected tool IR message'); + toolIr.content = '[cleared]'; // simulate Janitor edit + const result = toModelMessages(ir); + const toolMsg = result.find((m) => m.role === 'tool'); + if (toolMsg?.role === 'tool') { + const part = toolMsg.content[0]; + if (part.type === 'tool-result') { + expect(part.output).toEqual({ type: 'text', value: '[cleared]' }); + expect(part.toolName).toBe('run'); + } + } + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware exec vitest run tests/modelMessageAdapter.test.ts` +Expected: FAIL — `Cannot find module '../src/modelMessageAdapter'`. + +- [ ] **Step 3: Create the adapter implementation** + +Create `packages/ai-sdk-middleware/src/modelMessageAdapter.ts`: + +```ts +import type { LanguageModelV3ToolResultOutput } from '@ai-sdk/provider'; +import { + type Attachment, + ensureValidHistory, + type Message, + type ToolCall, +} from '@context-chef/core'; +import type { ModelMessage } from 'ai'; + +import { stringifyToolOutput } from './adapter'; + +// Content/part types derived from ModelMessage — no part-type imports needed +// (provider-utils does not export them all stably). Same trick as adapter.ts. +type UserContent = Extract['content']; +type AssistantContent = Extract['content']; +type ToolContent = Extract['content']; +type ProviderOptions = Extract['providerOptions']; + +/** + * IR message carrying the original ModelMessage content for lossless round-trip. + * Parallel to AISDKMessage (the V3 adapter's carrier) but typed to the + * application-layer ModelMessage shapes, and on distinct `_mm*` fields so the two + * adapters can never read each other's pass-through by accident. + */ +export interface ModelMessageIR extends Message { + _mmUserContent?: UserContent; + _mmAssistantContent?: AssistantContent; + _mmToolContent?: ToolContent; + _mmOriginalText?: string; + _mmProviderOptions?: ProviderOptions; + _mmToolName?: string; +} + +/** + * Converts AI SDK `ModelMessage[]` (the application/SDK altitude — what + * `generateText`/`prepareStep` use) into context-chef IR. + * + * `content` may be a plain `string` (the SDK shorthand); it is preserved on the + * `_mm*Content` pass-through so an unmodified message round-trips byte-exact + * (string stays string). Boundary-sanitized via `ensureValidHistory`. + * + * Tool messages: one IR `role:'tool'` message per `tool-result` part (so + * `groupIntoTurns`/orphan detection works per result). `tool-approval-response` + * parts have no IR home; they are appended in order to the adjacent result's + * pass-through so coalescing in `toModelMessages` restores them. A tool message + * with no tool-result at all is dropped by sanitization (not a real durable + * input). + */ +export function fromModelMessages(messages: ModelMessage[]): ModelMessageIR[] { + const ir: ModelMessageIR[] = []; + + for (const msg of messages) { + if (msg.role === 'system') { + ir.push({ + role: 'system', + content: msg.content, + ...(msg.providerOptions ? { _mmProviderOptions: msg.providerOptions } : {}), + }); + continue; + } + + if (msg.role === 'user') { + const text = + typeof msg.content === 'string' + ? msg.content + : msg.content + .filter((p) => p.type === 'text') + .map((p) => p.text) + .join('\n'); + + const attachments: Attachment[] = []; + if (typeof msg.content !== 'string') { + for (const part of msg.content) { + if (part.type === 'file') { + attachments.push({ + mediaType: part.mediaType, + data: typeof part.data === 'string' ? part.data : '', + ...(part.filename ? { filename: part.filename } : {}), + }); + } else if (part.type === 'image') { + attachments.push({ + mediaType: part.mediaType ?? 'image/*', + data: typeof part.image === 'string' ? part.image : '', + }); + } + } + } + + const m: ModelMessageIR = { + role: 'user', + content: text, + _mmUserContent: msg.content, + _mmOriginalText: text, + ...(msg.providerOptions ? { _mmProviderOptions: msg.providerOptions } : {}), + }; + if (attachments.length) m.attachments = attachments; + ir.push(m); + continue; + } + + if (msg.role === 'assistant') { + const textParts: string[] = []; + const toolCalls: ToolCall[] = []; + const attachments: Attachment[] = []; + let thinking: { thinking: string } | undefined; + + if (typeof msg.content === 'string') { + textParts.push(msg.content); + } else { + for (const part of msg.content) { + if (part.type === 'text') { + textParts.push(part.text); + } else if (part.type === 'tool-call') { + toolCalls.push({ + id: part.toolCallId, + type: 'function', + function: { + name: part.toolName, + arguments: typeof part.input === 'string' ? part.input : JSON.stringify(part.input), + }, + }); + } else if (part.type === 'reasoning') { + thinking = { thinking: part.text }; + } else if (part.type === 'file') { + attachments.push({ + mediaType: part.mediaType, + data: typeof part.data === 'string' ? part.data : '', + ...(part.filename ? { filename: part.filename } : {}), + }); + } + // text-result / tool-approval-request parts ride through _mmAssistantContent verbatim. + } + } + + const joined = textParts.join('\n'); + const m: ModelMessageIR = { + role: 'assistant', + content: joined, + _mmAssistantContent: msg.content, + _mmOriginalText: joined, + ...(msg.providerOptions ? { _mmProviderOptions: msg.providerOptions } : {}), + }; + if (toolCalls.length) m.tool_calls = toolCalls; + if (thinking) m.thinking = thinking; + if (attachments.length) m.attachments = attachments; + ir.push(m); + continue; + } + + if (msg.role === 'tool') { + let anchor: ModelMessageIR | undefined; + const pending: ToolContent = []; + for (const part of msg.content) { + if (part.type === 'tool-result') { + const text = stringifyToolOutput(part.output as LanguageModelV3ToolResultOutput); + anchor = { + role: 'tool', + content: text, + tool_call_id: part.toolCallId, + _mmToolContent: [...pending, part], + _mmOriginalText: text, + _mmToolName: part.toolName, + }; + pending.length = 0; + ir.push(anchor); + } else if (anchor?._mmToolContent) { + anchor._mmToolContent.push(part); + } else { + pending.push(part); + } + } + } + } + + return ensureValidHistory(ir) as ModelMessageIR[]; +} + +function asMM(msg: Message): ModelMessageIR { + return msg; +} + +/** + * Converts context-chef IR back to AI SDK `ModelMessage[]`. + * + * Unmodified messages emit their original content verbatim (via `_mm*` fields), + * so string content stays a string and reasoning/approval parts round-trip + * byte-exact. Janitor-modified messages and synthetic messages (e.g. a + * compression summary, which has no pass-through) are rebuilt from IR fields. + */ +export function toModelMessages(messages: Message[]): ModelMessage[] { + const out: ModelMessage[] = []; + + let i = 0; + while (i < messages.length) { + const msg = asMM(messages[i]); + const modified = msg._mmOriginalText !== undefined && msg._mmOriginalText !== msg.content; + + if (msg.role === 'system') { + out.push({ + role: 'system', + content: msg.content, + ...(msg._mmProviderOptions ? { providerOptions: msg._mmProviderOptions } : {}), + }); + i++; + continue; + } + + if (msg.role === 'user') { + out.push({ + role: 'user', + content: + !modified && msg._mmUserContent !== undefined + ? msg._mmUserContent + : [{ type: 'text', text: msg.content }], + ...(msg._mmProviderOptions ? { providerOptions: msg._mmProviderOptions } : {}), + }); + i++; + continue; + } + + if (msg.role === 'assistant') { + out.push({ + role: 'assistant', + content: + !modified && msg._mmAssistantContent !== undefined + ? msg._mmAssistantContent + : [{ type: 'text', text: msg.content }], + ...(msg._mmProviderOptions ? { providerOptions: msg._mmProviderOptions } : {}), + }); + i++; + continue; + } + + if (msg.role === 'tool') { + const content: ToolContent = []; + while (i < messages.length && messages[i].role === 'tool') { + const t = asMM(messages[i]); + const tModified = t._mmOriginalText !== undefined && t._mmOriginalText !== t.content; + if (!tModified && t._mmToolContent) { + content.push(...t._mmToolContent); + } else { + content.push({ + type: 'tool-result', + toolCallId: t.tool_call_id ?? '', + toolName: t._mmToolName ?? t.name ?? 'unknown', + output: { type: 'text', value: t.content }, + }); + } + i++; + } + out.push({ role: 'tool', content }); + continue; + } + + i++; + } + + return out; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware exec vitest run tests/modelMessageAdapter.test.ts` +Expected: PASS (all cases). +If `tsc`/vitest complains that `part.output` is not assignable to `LanguageModelV3ToolResultOutput`, the two output unions diverged — keep a thin local stringifier instead of the shared one; this is the one verify-at-implementation point from the spec. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware typecheck` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add packages/ai-sdk-middleware/src/modelMessageAdapter.ts packages/ai-sdk-middleware/tests/modelMessageAdapter.test.ts +git commit -m "feat(ai-sdk-middleware): ModelMessage <-> IR adapter" +``` + +--- + +## Task 3: Widen `createCompressionAdapter` to `LanguageModel` + +**Files:** +- Modify: `packages/ai-sdk-middleware/src/middleware.ts:18,385-387` + +The ModelMessage entries pass the host's `model` straight through. `prepareStep`/`generateText` give `LanguageModel` (`string id | V3 | V2`), not the provider-layer `LanguageModelV3`. The adapter only forwards to `generateText` (which accepts `LanguageModel`), and the existing in-flight caller passes a `LanguageModelV3` (a subtype), so this is backward-compatible. + +- [ ] **Step 1: Import `LanguageModel`** + +At `middleware.ts:18`, change: + +```ts +import { generateText, type LanguageModelMiddleware, type ModelMessage, pruneMessages } from 'ai'; +``` + +to: + +```ts +import { + generateText, + type LanguageModel, + type LanguageModelMiddleware, + type ModelMessage, + pruneMessages, +} from 'ai'; +``` + +- [ ] **Step 2: Widen the parameter type** + +At `middleware.ts:385`, change: + +```ts +export function createCompressionAdapter( + model: LanguageModelV3, +): (messages: Message[]) => Promise { +``` + +to: + +```ts +export function createCompressionAdapter( + model: LanguageModel, +): (messages: Message[]) => Promise { +``` + +- [ ] **Step 3: Typecheck + full suite (catch any in-flight-caller regression)** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware typecheck && pnpm --filter @context-chef/ai-sdk-middleware test` +Expected: exit 0; all tests PASS (the `createJanitor` and `summarizeMessages` callers still pass a `LanguageModelV3`, a subtype). + +- [ ] **Step 4: Commit** + +```bash +git add packages/ai-sdk-middleware/src/middleware.ts +git commit -m "refactor(ai-sdk-middleware): accept ai's LanguageModel in createCompressionAdapter" +``` + +--- + +## Task 4: `compactModelMessages` + `planCompactionModelMessages` (TDD) + +**Files:** +- Modify: `packages/ai-sdk-middleware/src/compaction.ts` +- Test: `packages/ai-sdk-middleware/tests/compactionModelMessages.test.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `packages/ai-sdk-middleware/tests/compactionModelMessages.test.ts`: + +```ts +import type { + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3GenerateResult, +} from '@ai-sdk/provider'; +import type { ModelMessage } from 'ai'; +import { describe, expect, it } from 'vitest'; +import { compactModelMessages, planCompactionModelMessages } from '../src/compaction'; + +/** Minimal V3 model whose summarization call returns a fixed string. A V3 model + * is a valid `LanguageModel`, so it exercises the widened model param too. */ +function createSummarizerModel(summaryText = 'SUMMARY'): LanguageModelV3 { + return { + specificationVersion: 'v3', + provider: 'test', + modelId: 'test-model', + supportedUrls: {}, + async doGenerate(_opts: LanguageModelV3CallOptions): Promise { + const content: LanguageModelV3Content[] = [{ type: 'text', text: summaryText }]; + const finishReason: LanguageModelV3FinishReason = { unified: 'stop', raw: undefined }; + return { + content, + finishReason, + warnings: [], + usage: { + inputTokens: { total: 50, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: undefined, reasoning: undefined }, + }, + response: { id: 'id', timestamp: new Date(), modelId: 'test-model' }, + }; + }, + async doStream() { + throw new Error('not used'); + }, + }; +} + +/** N plain user/assistant turns (string shorthand), optional leading system. */ +function plainTurns(n: number, withSystem = true): ModelMessage[] { + const msgs: ModelMessage[] = withSystem ? [{ role: 'system', content: 'You are helpful.' }] : []; + for (let i = 0; i < n; i++) { + msgs.push({ role: 'user', content: `q${i}` }); + msgs.push({ role: 'assistant', content: `a${i}` }); + } + return msgs; +} + +describe('planCompactionModelMessages', () => { + it('splits on turn boundaries and round-trips each slice as ModelMessage[]', () => { + const plan = planCompactionModelMessages(plainTurns(3), { keepRecentTurns: 2 }); + expect(plan.system.map((m) => m.role)).toEqual(['system']); + expect(plan.toSummarize).toHaveLength(4); + expect(plan.toKeep).toHaveLength(2); + expect(plan.toKeep[0].role).toBe('user'); + }); + + it('never splits an assistant tool-call from its tool result', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'q1' }, + { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: { a: 1 } }] }, + { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'foo', output: { type: 'text', value: 'ok' } }] }, + { role: 'user', content: 'q2' }, + { role: 'assistant', content: 'a2' }, + ]; + const plan = planCompactionModelMessages(messages, { keepRecentTurns: 3 }); + expect(plan.toSummarize.map((m) => m.role)).toEqual(['user']); + expect(plan.toKeep.map((m) => m.role)).toEqual(['assistant', 'tool', 'user', 'assistant']); + }); +}); + +describe('compactModelMessages', () => { + it('returns [...system, summary, ...toKeep] with a wrapped user summary', async () => { + const messages = plainTurns(4); // system + 8 messages + const result = await compactModelMessages(messages, createSummarizerModel('Hello'), { + keepRecentTurns: 2, + }); + expect(result[0]).toEqual(messages[0]); // system preserved + const summary = result[1]; + const text = + summary.role === 'user' && typeof summary.content !== 'string' && summary.content[0].type === 'text' + ? summary.content[0].text + : ''; + expect(text).toContain('Hello'); + expect(text).toContain('continued from a previous conversation'); + expect(result.length).toBe(1 + 1 + 2); + }); + + it('returns the INPUT reference unchanged when nothing is old enough', async () => { + const messages = plainTurns(2); + const result = await compactModelMessages(messages, createSummarizerModel(), { + keepRecentTurns: 99, + }); + expect(result).toBe(messages); // same reference — caller skips persistence + }); + + it('returns the INPUT reference unchanged when the summary is blank', async () => { + const messages = plainTurns(4); + const result = await compactModelMessages(messages, createSummarizerModel(' '), { + keepRecentTurns: 1, + }); + expect(result).toBe(messages); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware exec vitest run tests/compactionModelMessages.test.ts` +Expected: FAIL — `compactModelMessages`/`planCompactionModelMessages` are not exported. + +- [ ] **Step 3: Add the implementation to `compaction.ts`** + +In `packages/ai-sdk-middleware/src/compaction.ts`, update the imports at the top: + +```ts +import type { LanguageModel, ModelMessage } from 'ai'; +import type { LanguageModelV3, LanguageModelV3Prompt } from '@ai-sdk/provider'; +import { + compactHistory as coreCompactHistory, + planCompaction as corePlanCompaction, + type PlanCompactionOptions, +} from '@context-chef/core'; + +import { fromAISDK, toAISDK } from './adapter'; +import { fromModelMessages, toModelMessages } from './modelMessageAdapter'; +import { createCompressionAdapter, type SummarizeMessagesOptions } from './middleware'; +``` + +Then append the new entries at the end of the file: + +```ts +export interface CompactionPlanModelMessages { + /** System messages, preserved verbatim — standing instructions are never summarized. */ + system: ModelMessage[]; + /** The old conversation slice to summarize (system excluded). Empty when nothing is old enough. */ + toSummarize: ModelMessage[]; + /** The recent conversation turns to keep verbatim. */ + toKeep: ModelMessage[]; +} + +/** + * Turn-safe split for durable compaction at the **ModelMessage** altitude — the + * type `prepareStep`/`generateText` hand you. Converts to IR via + * {@link fromModelMessages}, splits on turn boundaries via core's + * `planCompaction`, and converts each slice back via {@link toModelMessages}. + * Summarize `toSummarize`, then persist `[...system, , ...toKeep]`. + */ +export function planCompactionModelMessages( + messages: ModelMessage[], + options: PlanCompactionOptions, +): CompactionPlanModelMessages { + const plan = corePlanCompaction(fromModelMessages(messages), options); + return { + system: toModelMessages(plan.system), + toSummarize: toModelMessages(plan.toSummarize), + toKeep: toModelMessages(plan.toKeep), + }; +} + +/** + * One-shot durable compaction at the **ModelMessage** altitude: plan a turn-safe + * split, summarize the old slice, and return a new `ModelMessage[]` ready to + * persist — `[...system, , ...toKeep]`. Use it in your own own-the-store + * loop, or inside a `ToolLoopAgent` `prepareStep` (`return { messages: await + * compactModelMessages(messages, model, opts) }`). + * + * `model` is `ai`'s `LanguageModel` (string id | V3 | V2) — exactly what + * `prepareStep`/`generateText` give you. Reuses core's `compactHistory` + + * `createCompressionAdapter` (tool-role flattening); no model is called directly. + * + * Returns the **input `messages` reference unchanged** when there is nothing old + * enough to compact or the summarizer yields no text, so callers can skip + * persistence on a no-op via `result === messages`. Throws only if the model call + * throws. + */ +export async function compactModelMessages( + messages: ModelMessage[], + model: LanguageModel, + options: PlanCompactionOptions & SummarizeMessagesOptions, +): Promise { + const ir = fromModelMessages(messages); + const result = await coreCompactHistory(ir, createCompressionAdapter(model), options); + // core returns the input IR reference on a no-op — preserve the original + // `messages` reference so callers can skip persistence via `result === messages`. + return result === ir ? messages : toModelMessages(result); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware exec vitest run tests/compactionModelMessages.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware typecheck` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add packages/ai-sdk-middleware/src/compaction.ts packages/ai-sdk-middleware/tests/compactionModelMessages.test.ts +git commit -m "feat(ai-sdk-middleware): compactModelMessages + planCompactionModelMessages" +``` + +--- + +## Task 5: `summarizeModelMessages` (TDD) + +**Files:** +- Modify: `packages/ai-sdk-middleware/src/middleware.ts` (add export near `summarizeMessages`) +- Test: `packages/ai-sdk-middleware/tests/summarizeMessages.test.ts` + +- [ ] **Step 1: Add a failing test** + +Append to `packages/ai-sdk-middleware/tests/summarizeMessages.test.ts` (inside the existing top-level `describe`, or a new one — match the file's current structure). Add the import `summarizeModelMessages` to the existing `../src/middleware` import line, and add: + +```ts +describe('summarizeModelMessages', () => { + it('summarizes a ModelMessage slice into a string, dropping system messages', async () => { + const messages: ModelMessage[] = [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'first answer' }, + ]; + const text = await summarizeModelMessages(messages, createSummarizerModel('RECAP')); + expect(text).toBe('RECAP'); + }); + + it('returns empty string for an empty slice without a model call', async () => { + const text = await summarizeModelMessages([], createSummarizerModel()); + expect(text).toBe(''); + }); +}); +``` + +(If `summarizeMessages.test.ts` lacks `createSummarizerModel`/`ModelMessage`, copy the `createSummarizerModel` factory from `tests/compactionModelMessages.test.ts` and add `import type { ModelMessage } from 'ai';`.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware exec vitest run tests/summarizeMessages.test.ts` +Expected: FAIL — `summarizeModelMessages` is not exported. + +- [ ] **Step 3: Implement next to `summarizeMessages` in `middleware.ts`** + +Add the import at the top of `middleware.ts` (with the other local imports, after the `./adapter` import): + +```ts +import { fromModelMessages } from './modelMessageAdapter'; +``` + +Then, immediately after the `summarizeMessages` function (end of `middleware.ts`), add: + +```ts +/** + * ModelMessage-altitude sibling of {@link summarizeMessages}: summarize a + * `ModelMessage[]` slice into a single summary string via the same pipeline + * (role-flattening + core `summarizeHistory`). System messages are dropped. + * Empty input returns `''` without a model call; throws if the model call fails. + */ +export async function summarizeModelMessages( + messages: ModelMessage[], + model: LanguageModel, + opts: SummarizeMessagesOptions = {}, +): Promise { + const ir = fromModelMessages(messages).filter((m) => m.role !== 'system'); + return summarizeHistory(ir, createCompressionAdapter(model), opts); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware exec vitest run tests/summarizeMessages.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/ai-sdk-middleware/src/middleware.ts packages/ai-sdk-middleware/tests/summarizeMessages.test.ts +git commit -m "feat(ai-sdk-middleware): summarizeModelMessages" +``` + +--- + +## Task 6: Export new API; deprecate V3 pair; repoint warning + +**Files:** +- Modify: `packages/ai-sdk-middleware/src/index.ts:9-15` +- Modify: `packages/ai-sdk-middleware/src/compaction.ts` (deprecation JSDoc) +- Modify: `packages/ai-sdk-middleware/src/middleware.ts:86` (warning text) + +- [ ] **Step 1: Export the new symbols from `index.ts`** + +Replace the compaction export block (`index.ts:9-14`) with: + +```ts +export { + type CompactionPlan, + type CompactionPlanModelMessages, + compactHistory, + compactModelMessages, + type PlanCompactionOptions, + planCompaction, + planCompactionModelMessages, +} from './compaction'; +``` + +And update the middleware export line (`index.ts:15`) to add `summarizeModelMessages`: + +```ts +export { + createMiddleware, + summarizeMessages, + summarizeModelMessages, + type SummarizeMessagesOptions, +} from './middleware'; +``` + +- [ ] **Step 2: Add `@deprecated` JSDoc to the V3 pair** + +In `compaction.ts`, prepend to the existing JSDoc of `planCompaction` (above `export function planCompaction`): + +``` + * @deprecated Use {@link planCompactionModelMessages}. This V3-prompt variant is + * the provider-protocol altitude — a type you never persist. Removed in the next + * major. +``` + +And to `compactHistory`: + +``` + * @deprecated Use {@link compactModelMessages}. `LanguageModelV3Prompt` is the + * provider-protocol altitude (ephemeral, never persisted); durable compaction + * belongs at the ModelMessage altitude. Removed in the next major. +``` + +- [ ] **Step 3: Repoint the persistence warning to the durable entry** + +In `middleware.ts` (the `onCompressionFired` warning, ~`:86`), change the closing sentence from: + +```ts + '`summarizeMessages` for durable compaction.', +``` + +to: + +```ts + '`compactModelMessages` for durable compaction.', +``` + +- [ ] **Step 4: Typecheck + full suite + lint** + +Run: `pnpm --filter @context-chef/ai-sdk-middleware typecheck && pnpm --filter @context-chef/ai-sdk-middleware test && pnpm exec biome check packages/ai-sdk-middleware/src` +Expected: exit 0; all tests PASS; Biome clean (no `@deprecated`-usage lint errors, since the package no longer calls the deprecated functions internally — verify no internal caller remains). + +- [ ] **Step 5: Commit** + +```bash +git add packages/ai-sdk-middleware/src/index.ts packages/ai-sdk-middleware/src/compaction.ts packages/ai-sdk-middleware/src/middleware.ts +git commit -m "feat(ai-sdk-middleware): export ModelMessage durable API; deprecate V3 compactHistory/planCompaction" +``` + +--- + +## Task 7: Docs (READMEs) + +**Files:** +- Modify: `packages/ai-sdk-middleware/README.md` +- Modify: `packages/ai-sdk-middleware/README.zh-CN.md` + +(Root `README.md` / `README.zh-CN.md` mirror these; update them the same way if they carry the durable section.) + +- [ ] **Step 1: Make the ModelMessage API the headline durable example** + +In each README's durable-compaction section, replace the primary `compactHistory(prompt, model, …)` example with the ModelMessage one, including the `prepareStep` use: + +````md +### `compactModelMessages(messages, model, options)` + +One-shot durable compaction at the ModelMessage altitude — the type +`generateText` and `prepareStep` use. Run it in your own loop (own the store and +write the result back), or inside a `ToolLoopAgent`: + +```ts +import { compactModelMessages } from '@context-chef/ai-sdk-middleware'; + +const agent = new ToolLoopAgent({ + model, + tools, + prepareStep: async ({ messages, model }) => ({ + messages: await compactModelMessages(messages, model, { keepRecentTurns: 4 }), + }), +}); +``` + +Returns the **same `messages` reference** when nothing is old enough to compact, +so you can skip persistence: `if (next !== messages) await save(next)`. +```` + +- [ ] **Step 2: Demote the V3 functions to a deprecation note** + +Under the V3 `compactHistory`/`planCompaction` headings, add: + +```md +> **Deprecated.** `compactHistory` / `planCompaction` take and return +> `LanguageModelV3Prompt` — the provider-protocol altitude, which nobody +> persists. Use [`compactModelMessages`](#compactmodelmessagesmessages-model-options) +> / `planCompactionModelMessages` instead. Removed in the next major. +``` + +- [ ] **Step 3: Lint the docs (if Biome covers md) / visual check** + +Run: `pnpm exec biome check packages/ai-sdk-middleware/README.md packages/ai-sdk-middleware/README.zh-CN.md` (skip if Biome is not configured for markdown). +Expected: clean, or no-op. + +- [ ] **Step 4: Commit** + +```bash +git add packages/ai-sdk-middleware/README.md packages/ai-sdk-middleware/README.zh-CN.md README.md README.zh-CN.md +git commit -m "docs(ai-sdk-middleware): ModelMessage durable API + deprecate V3 prompt variants" +``` + +--- + +## Final verification (whole package) + +- [ ] **Run the complete suite once more** + +Run: `pnpm --filter @context-chef/core build && pnpm --filter @context-chef/ai-sdk-middleware typecheck && pnpm --filter @context-chef/ai-sdk-middleware test && pnpm exec biome check packages/ai-sdk-middleware/src` +Expected: build OK, typecheck exit 0, **all** tests PASS, Biome clean. + +## Changeset (deferred — only on "ship") + +Per the standing "batch until ship" preference, do **not** add a changeset during implementation. When the author says ship, add one: + +```bash +cat > .changeset/model-message-durable-compaction.md <<'EOF' +--- +'@context-chef/ai-sdk-middleware': minor +--- + +Add ModelMessage-altitude durable compaction (`compactModelMessages`, +`planCompactionModelMessages`, `summarizeModelMessages`) — the type +`prepareStep`/`generateText` use. Deprecate the `LanguageModelV3Prompt` variants +`compactHistory`/`planCompaction` (still exported; removed next major). +EOF +``` + +(`@context-chef/core` is unchanged → no core changeset.) + +--- + +## Self-Review + +**1. Spec coverage:** +- New ModelMessage adapter → Task 2. ✓ +- `compactModelMessages` / `planCompactionModelMessages` → Task 4. ✓ +- `summarizeModelMessages` (recommended sibling) → Task 5. ✓ +- Model param at `LanguageModel` altitude + widen `createCompressionAdapter` → Task 3 + Task 4. ✓ +- Deprecate `compactHistory`/`planCompaction` only (not `summarizeMessages`); repoint `:86` warning → Task 6. ✓ +- No-op reference contract / reasoning byte-exact / tool flatten reuse / turn-safe split → asserted in Task 2 & Task 4 tests; reuse of core engine + `createCompressionAdapter` is structural. ✓ +- Shared round-trip fixture matrix across both adapters → **partially**: Task 2 exercises the ModelMessage adapter with its own fixtures; the V3 adapter already has equivalent coverage in `tests/adapter.test.ts`. A single literally-shared fixture file is not built (the two adapters take different input types — V3Prompt vs ModelMessage — so a shared array cannot type-check against both). Drift is instead guarded by parallel case-for-case coverage. *This is a conscious deviation from the spec's "one fixture set" wording; flag at review if a shared parametrized harness is wanted.* +- UIMessage entry → out of scope (spec). ✓ +- Docs + deferred changeset → Task 7 + Changeset section. ✓ + +**2. Placeholder scan:** No TBD/TODO; every code step carries complete code; every run step has an exact command + expected result. ✓ + +**3. Type consistency:** `fromModelMessages`/`toModelMessages`, `ModelMessageIR`, `_mm*` fields, `CompactionPlanModelMessages`, `compactModelMessages`/`planCompactionModelMessages`/`summarizeModelMessages` are spelled identically across Tasks 2/4/5/6 and the index export. `model: LanguageModel` is consistent in Tasks 3/4/5. ✓ + +**One open item for the reviewer:** the "shared fixture set across both adapters" (spec) is implemented as parallel coverage, not a single shared array, because the two adapters' input types differ. Confirm that's acceptable, or I'll add a small parametrized harness that feeds semantically-equal V3 and ModelMessage fixtures through each. diff --git a/docs/superpowers/specs/2026-06-17-model-message-durable-compaction-altitude-design.md b/docs/superpowers/specs/2026-06-17-model-message-durable-compaction-altitude-design.md new file mode 100644 index 0000000..e039a75 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-model-message-durable-compaction-altitude-design.md @@ -0,0 +1,263 @@ +# ModelMessage durable-compaction altitude + +**Date:** 2026-06-17 +**Status:** Draft design, pending author review +**Packages:** `@context-chef/ai-sdk-middleware` (minor) — additive, non-breaking + +## Problem + +The durable-compaction host API is typed at the wrong altitude. `compactHistory` +and `planCompaction` in `packages/ai-sdk-middleware/src/compaction.ts` take and +return `LanguageModelV3Prompt` (from `@ai-sdk/provider`). That is the +**provider-protocol** layer — the type a middleware's `transformParams` briefly +sees and a provider consumes. It is ephemeral; nobody persists it. + +"Durable compaction" means "summarize, then write the result back to **your +persisted message store**." But your store holds `UIMessage[]` (useChat / DB) or +`ModelMessage[]` (the `messages` you pass to `generateText`/`streamText`, and the +ones `prepareStep` hands you). It never holds a `LanguageModelV3Prompt`. So a +durable API that consumes and returns `V3Prompt` is self-contradictory — it asks +you to persist a value you never had. + +The mismatch is already documented in the codebase and it already bites: + +- `middleware.ts:280` notes that `LanguageModelV3Message` and `ModelMessage` + "share identical runtime structure but differ at the TypeScript level + (e.g. ImagePart, FilePart.data)." +- `adapter.ts:59` (`fromAISDK`) extracts user text with + `msg.content.filter((p) => p.type === 'text')`. `ModelMessage` user/assistant + `content` may be a **plain `string`** (the SDK's shorthand). A bare cast of + `ModelMessage[]` to `LanguageModelV3Prompt` therefore throws here the moment + any message uses string content. +- AI SDK ships **no** public `ModelMessage → LanguageModelV3Prompt` converter + (only the reverse-semantics `convertToModelMessages`: `UIMessage → ModelMessage`). + +The concrete trigger: a host wants the ModelMessage altitude at two call sites — +inside a `ToolLoopAgent`'s `prepareStep` (`PrepareStepFunction` gives +`messages: ModelMessage[]`, `model: LanguageModel`, accepts +`{ messages?: ModelMessage[] }`, and may be async), and in its own own-the-store +loop around `generateText`. There is no safe, supported way to feed either into +the current `V3Prompt`-typed API. + +Note on "durable": returning `{ messages }` from `prepareStep` overrides only +*that step's* request — it is not itself persistence. "Durable" means the caller +owns the message store and writes the result back. The ModelMessage altitude is +what both call sites need; the API does not make `prepareStep` durable by itself. + +The compaction **engine** already lives at the right altitude — core's +provider-agnostic IR `compactHistory`/`planCompaction` in +`packages/core/src/modules/janitor/durableCompaction.ts`. This change is purely +about adding the correct host-facing **boundary** in the middleware package. Core +is not touched. + +## Decisions + +1. **Compat: additive, `ai-sdk-middleware` minor, non-breaking.** Add a + `ModelMessage[]` altitude alongside the existing V3 functions; mark the two V3 + persist-back functions (`compactHistory`, `planCompaction`) `@deprecated`. + (Author's call: "统一 minor,反正也没人用" — + 1.5.2 shipped 2026-06-16, near-zero adoption, so deprecating costs nothing and + a major bump is unwarranted.) +2. **Scope: `ModelMessage[]` only this change.** UIMessage entry is out of scope + (see below). *Recommended default — flip at review if you want UIMessage now.* +3. **Naming: `*ModelMessages` suffix.** `compactModelMessages`, + `planCompactionModelMessages`. Explicit about altitude, reads clearly next to + the deprecated V3 names. *Recommended default — flip at review.* + +## Design + +### New adapter — `packages/ai-sdk-middleware/src/modelMessageAdapter.ts` + +A `ModelMessage ↔ IR` adapter mirroring the V3 `fromAISDK`/`toAISDK`, with the +same lossless pass-through strategy (store original content on the IR message; +detect Janitor edits via `_originalText`). New parallel pass-through carrier: + +```ts +import type { ModelMessage } from 'ai'; + +interface ModelMessageIR extends Message { + _userContent?: UserContent; // ModelMessage's, incl. string shorthand + _assistantContent?: AssistantContent; + _toolContent?: ToolContent; + _originalText?: string; + _providerOptions?: SharedV3ProviderOptions; + _toolName?: string; +} + +export function fromModelMessages(messages: ModelMessage[]): ModelMessageIR[]; +export function toModelMessages(ir: Message[]): ModelMessage[]; +``` + +Deltas vs the V3 adapter — exactly the three `ModelMessage`-only shapes: + +- **string-shorthand content.** When `content` is a `string`, IR `content` is + that string and `_userContent`/`_assistantContent` stores the original string. + On the way back, an unmodified message hands the original string straight back + (a `string` is a valid `ModelMessage` content), so the shape round-trips + exactly rather than being re-expanded into a text-part array. +- **`ImagePart` (`type: 'image'`).** Recorded as an attachment for Janitor's + presence checks, same as `type: 'file'`; the real payload rides through + `_userContent`/`_assistantContent` verbatim. +- **approval parts** (`ToolApprovalRequest` in assistant content, + `ToolApprovalResponse` in tool content). No IR concept; preserved verbatim via + the pass-through fields and covered by a round-trip test (must not be dropped). + +Reused, not duplicated: IR types and `ensureValidHistory` from core; +`stringifyToolOutput` is promoted to a shared helper (exported from `adapter.ts` +or a small shared module) and imported here — no second copy. `reasoning` parts +ride through `_assistantContent` byte-exact, identical to the V3 path. + +`fromModelMessages` ends with `ensureValidHistory(...)` (same boundary +sanitization as `fromAISDK`). + +Implementation notes: content/part types are derived via +`Extract['content']` (no part-type imports — same +trick as `adapter.ts`). Before sharing `stringifyToolOutput`, verify +`ToolResultOutput` (provider-utils) and `LanguageModelV3ToolResultOutput` +(`@ai-sdk/provider`) are structurally identical; if they diverge, keep a thin +ModelMessage-specific stringifier instead of forcing a shared one. + +### New durable entries — in `compaction.ts` + +```ts +export interface CompactionPlanModelMessages { + system: ModelMessage[]; + toSummarize: ModelMessage[]; + toKeep: ModelMessage[]; +} + +export function planCompactionModelMessages( + messages: ModelMessage[], + options: PlanCompactionOptions, +): CompactionPlanModelMessages { + const plan = corePlanCompaction(fromModelMessages(messages), options); + return { + system: toModelMessages(plan.system), + toSummarize: toModelMessages(plan.toSummarize), + toKeep: toModelMessages(plan.toKeep), + }; +} + +export async function compactModelMessages( + messages: ModelMessage[], + model: LanguageModel, // `ai`'s type (string id | V3 | V2) — what prepareStep hands you + options: PlanCompactionOptions & SummarizeMessagesOptions, +): Promise { + const ir = fromModelMessages(messages); + const result = await coreCompactHistory(ir, createCompressionAdapter(model), options); + // core returns the input IR reference on a no-op — preserve the original + // `messages` reference so callers can skip persistence via `result === messages`. + return result === ir ? messages : toModelMessages(result); +} +``` + +Both are thin `from → core → to` shells, exactly like the V3 wrappers, and reuse +the **same** core engine and `createCompressionAdapter` (tool-role flattening) — +no new compaction or flattening logic. + +**Model altitude.** `model` is typed `LanguageModel` (from `ai`: +`GlobalProviderModelId | LanguageModelV3 | LanguageModelV2`), the exact type +`prepareStep`/`generateText` hand the host — not the provider-layer +`LanguageModelV3` the deprecated V3 functions take. `createCompressionAdapter` is +widened `LanguageModelV3 → LanguageModel`; it only forwards to `generateText` +(which already accepts `LanguageModel`), and the existing in-flight caller passes +a `LanguageModelV3` (a subtype), so the widening is backward-compatible. + +`summarizeModelMessages(messages, model, opts): Promise` is a natural +third sibling (mirrors `summarizeMessages`) and is nearly free atop the adapter. I +recommend including it so the ModelMessage altitude is complete +(compact / plan / summarize); flag at review if you'd rather drop it. + +### Deprecate the two V3 persist-back functions (not `summarizeMessages`) + +`compactHistory` and `planCompaction` return `LanguageModelV3Prompt` *for you to +persist* — the literally self-contradictory pair (durable + a type nobody +persists). They keep their current signatures and behavior, stay exported, and +gain `@deprecated` JSDoc pointing to `compactModelMessages` / +`planCompactionModelMessages`. They have no internal callers (the in-flight path +uses `janitor.compress`), so deprecation is signposting only — zero behavior +change, tests stay green. + +`summarizeMessages` is **not** deprecated. Its output is a neutral `string`, and +it is actively recommended by a runtime warning (`middleware.ts:86`), `types.ts`, +and both READMEs — deprecating it would have those recommend a deprecated +function. Instead it gains a `summarizeModelMessages` sibling (host-native input), +and the docs phase repoints the `:86` warning toward `compactModelMessages` as the +one-shot durable path. + +### Invariants preserved (regression-covered) + +- **No-op reference contract.** `compactModelMessages` returns the **input + `messages` reference** when there is nothing to compact or the summary is + blank — same `result === ir ? input : convert` short-circuit as the V3 wrapper. +- **Reasoning byte-exact round-trip.** Via `_assistantContent` pass-through, + unchanged from the V3 adapter. +- **Tool-role flattening reused.** `createCompressionAdapter` maps `role:'tool'` + and assistant `tool_calls` to plain user/assistant text before summarizing — no + new flattener. +- **Turn-safe split.** Inherited from core `planCompaction` /`groupIntoTurns`; + cuts land only on atomic-turn boundaries, never orphaning a tool result. + +## Out of scope: UIMessage entry + +Deliberately deferred (YAGNI; no concrete demand yet). It is not free: + +- AI SDK has no `ModelMessage → UIMessage` converter, so a UIMessage entry cannot + reuse the ModelMessage path symmetrically. +- A *faithful* UIMessage compaction is doable but is its own code path: split + `UIMessage[]` on turn boundaries (UIMessages carry tool calls/results as inline + parts, not `role:'tool'` messages, so `groupIntoTurns` doesn't apply directly), + convert only the old slice via `convertToModelMessages` to summarize, then + return `[summaryUIMessage, ...keptUIMessages]` with the kept tail preserved + verbatim (ids/metadata intact). Separate grouping logic + its own tests. + +Documented here so the decision is explicit, not forgotten. + +## Testing + +New `modelMessageAdapter.test.ts` — round-trip correctness for: +**string-shorthand content, `tool_calls`, tool results, file parts, image parts, +reasoning parts, approval parts.** Assert reasoning is byte-exact and that +unmodified string content returns as a string. To guard against the two adapters +drifting, the shared round-trip cases run from one fixture set against **both** +`fromAISDK/toAISDK` and `fromModelMessages/toModelMessages`. + +New cases in `compaction.test.ts` (or a sibling) for the ModelMessage entries: +no-op returns the **input reference** (`toBe(messages)`), blank-summary returns +input reference, turn-safe split, `keepRecentTurns = 0` summarizes all, +delegation to core. Algorithm-level assertions stay in core's +`durableCompaction.test.ts` (unchanged). + +## Versioning + +One package changes: `@context-chef/ai-sdk-middleware` → **minor** (new public +API). Core is untouched, so no core changeset. Per the standing "batch until ship" +preference, I will **not** write the changeset until you say ship — flag if you +want it now. + +## File layout & phases (≤5 files each, verify between) + +1. **Adapter** — `modelMessageAdapter.ts` + shared `stringifyToolOutput` export + from `adapter.ts` + `modelMessageAdapter.test.ts`. + Verify: build core, then `pnpm --filter @context-chef/ai-sdk-middleware typecheck && test`. +2. **Durable entries** — `compaction.ts` (add ModelMessage compact/plan/summarize, + `@deprecated` on V3 `compactHistory`/`planCompaction`, widen + `createCompressionAdapter`) + `index.ts` exports + compaction tests. Verify: same. +3. **Docs** (+ changeset only on ship) — README durable-compaction section gains + the ModelMessage/`prepareStep` example. Verify: `biome check`. + +Worktree note: run `pnpm install` first; build `@context-chef/core` before the +middleware test run (it resolves core via `dist`). + +## Alternatives considered + +- **Breaking — retype V3 `compactHistory`/`planCompaction` to `ModelMessage[]` + in place.** Single clean surface, no suffix. Rejected: breaks published 1.5.2 + and needs a major bump for a 1-day-old API — not worth it. +- **Normalize `ModelMessage → V3` then reuse `fromAISDK`/`toAISDK`.** Rejected: + loses string-shorthand and image-part fidelity (V3 has no `ImagePart`), so the + round-trip would silently reshape host input. A dedicated adapter preserves the + original shape via pass-through. +- **One generic adapter parameterized by a dialect descriptor.** Rejected as + over-engineering; the deltas are three small, localized cases. Two focused + adapters sharing leaf helpers is clearer and lower-risk. diff --git a/packages/ai-sdk-middleware/README.md b/packages/ai-sdk-middleware/README.md index cb7cef3..96d9399 100644 --- a/packages/ai-sdk-middleware/README.md +++ b/packages/ai-sdk-middleware/README.md @@ -65,7 +65,7 @@ const model = withContextChef(openai('gpt-4o'), { }); ``` -> **In-flight vs durable.** Middleware `compress` is *in-flight*: it rewrites each outgoing request but does **not** mutate your message store. So for a *sustained* over-budget conversation (a long chat, or a long multi-step loop) the summary is discarded each call and the history re-expands — compression then effectively fires only every other call and the payload keeps growing. For a one-off spike that's fine; for sustained use, **persist** the summary via `onCompress`, or compact your own store with [`compactHistory`](#compacthistoryprompt-model-options) (recommended). The middleware logs a one-time warning if `compress` keeps firing without `onCompress`. +> **In-flight vs durable.** Middleware `compress` is *in-flight*: it rewrites each outgoing request but does **not** mutate your message store. So for a *sustained* over-budget conversation (a long chat, or a long multi-step loop) the summary is discarded each call and the history re-expands — compression then effectively fires only every other call and the payload keeps growing. For a one-off spike that's fine; for sustained use, **persist** the summary via `onCompress`, or compact your own store with [`compactModelMessages`](#compactmodelmessagesmessages-model-options) (recommended). The middleware logs a one-time warning if `compress` keeps firing without `onCompress`. ### Tool Result Truncation @@ -226,29 +226,43 @@ const summary = await summarizeMessages(promptSlice, model, { > **Coordination:** if you drive summarization this way, do **not** also configure `compress` (with a `model`) on the same middleware path for the same conversation — that double-compresses (once at call time, again at persist time). A notification-only `onCompress`, plus `truncate`, `clear`, and `dynamicState`, are safe alongside it. -### `compactHistory(prompt, model, options)` +### `compactModelMessages(messages, model, options)` -**Durable compaction in one call** — the recommended way to keep a long conversation lean when you own the message store (a long agent loop, or a chat past the budget). It splits the history on turn boundaries, summarizes the old slice, and returns a new prompt ready to persist — `[...system, , ...recent turns]`: +**Durable compaction in one call** — the recommended way to keep a long conversation lean when you own the message store (a long agent loop, or a chat past the budget). It works at the **ModelMessage altitude** — the `ModelMessage[]` type `generateText` and `prepareStep` actually hand you. It splits the history on turn boundaries, summarizes the old slice, and returns a new `ModelMessage[]` ready to persist — `[...system, , ...recent turns]`. Run it in your own loop (own the store and write the result back), or inside a `ToolLoopAgent`: ```typescript -import { compactHistory } from '@context-chef/ai-sdk-middleware'; +import { compactModelMessages } from '@context-chef/ai-sdk-middleware'; -// Between model calls, when you own `messages`: -messages = await compactHistory(messages, summarizerModel, { +const agent = new ToolLoopAgent({ + model, + tools, + prepareStep: async ({ messages, model }) => ({ + messages: await compactModelMessages(messages, model, { keepRecentTurns: 4 }), + }), +}); +``` + +Or between model calls, when you own `messages`: + +```typescript +const next = await compactModelMessages(messages, summarizerModel, { keepRecentTurns: 4, // keep the last 4 atomic turns verbatim toolResultStubThreshold: 5000, }); -// Persist the result — history actually shrinks and stays shrunk. +if (next !== messages) await save(next); // skip persistence on a no-op ``` +- `model` is `ai`'s `LanguageModel` (`string id | V3 | V2`) — exactly what `prepareStep` / `generateText` give you. - The cut lands only on **turn boundaries** (an assistant + its tool results stay together), so it never orphans a tool result or splits a multi-block assistant message. - System messages are preserved verbatim and never summarized. -- Returns the prompt **unchanged** when there is nothing old enough to compact (no more turns than `keepRecentTurns`) or the summarizer yields no text — safe to call unconditionally. Throws only if the model call throws. +- Returns the **same `messages` reference** when there is nothing old enough to compact (no more turns than `keepRecentTurns`) or the summarizer yields no text — so you can skip persistence on a no-op via `next !== messages`. Safe to call unconditionally; throws only if the model call throws. - Accepts the same `SummarizeMessagesOptions` (`customCompressionInstructions`, `toolResultStubThreshold`) as `summarizeMessages`. > Use this **or** in-flight `compress` for a given conversation — not both (that double-compresses). -> **Under the hood:** `compactHistory` and `planCompaction` are thin AI-SDK wrappers over the provider-agnostic engine in [`@context-chef/core`](https://www.npmjs.com/package/@context-chef/core) — they convert the prompt to core's IR and back. If you drive a provider directly (without the AI SDK), call core's `planCompaction` / `compactHistory` instead. +> **`keepRecentTurns` is message-level turns, not `ToolLoopAgent` steps.** A turn is one user/assistant message, or an assistant with its tool-calls plus all their tool results (kept together). A single tool-using step is often 2–3 turns, so size `keepRecentTurns` for your worst-case step — a tool-dense agent loop needs a larger value than a plain chat. The summary is inserted as a `user` message, so when the kept tail also begins with a user turn the result can hold two consecutive `user` messages — a valid `ModelMessage[]` that the AI SDK provider layer normalizes (Anthropic merges same-role, OpenAI accepts it). + +> **Under the hood:** `compactModelMessages`, `planCompactionModelMessages`, and `summarizeModelMessages` are thin AI-SDK wrappers over the provider-agnostic engine in [`@context-chef/core`](https://www.npmjs.com/package/@context-chef/core) — they convert `ModelMessage[]` to core's IR and back. If you drive a provider directly (without the AI SDK), call core's `planCompaction` / `compactHistory` instead. #### Full compaction (Claude Code style) @@ -257,7 +271,7 @@ Pass `keepRecentTurns: 0` to summarize the **entire** conversation into a single The trade-off is that **no verbatim recent context survives** — the model continues purely from a lossy summary. So the summary's quality is everything; steer it toward a structured handoff with `customCompressionInstructions`: ```typescript -messages = await compactHistory(messages, summarizerModel, { +const next = await compactModelMessages(messages, summarizerModel, { keepRecentTurns: 0, // full compaction — collapse everything into the summary customCompressionInstructions: [ 'Write the summary as a handoff for resuming the task:', @@ -267,13 +281,65 @@ messages = await compactHistory(messages, summarizerModel, { '- The exact next step to take', ].join('\n'), }); -// `messages` is now [system, summary] — trivial to persist, no boundary bookkeeping. +// `next` is now [system, summary] — trivial to persist, no boundary bookkeeping. ``` Prefer a small `keepRecentTurns` (e.g. `2`–`4`) when you want the in-flight turn kept verbatim (safer for an autonomous loop mid-task); prefer `0` when you want maximal shrink and a clean, boundary-free store. +### `planCompactionModelMessages(messages, options)` + +The synchronous split behind `compactModelMessages`, for when you want the boundary without summarizing (persist your own marker, or use a different summarizer). Returns `{ system, toSummarize, toKeep }` (all `ModelMessage[]`), cut on turn boundaries: + +```typescript +import { planCompactionModelMessages, summarizeModelMessages } from '@context-chef/ai-sdk-middleware'; +import { Prompts } from '@context-chef/core'; + +const { system, toSummarize, toKeep } = planCompactionModelMessages(messages, { keepRecentTurns: 4 }); +if (toSummarize.length > 0) { + const summary = await summarizeModelMessages(toSummarize, model); + messages = [ + ...system, + { role: 'user', content: [{ type: 'text', text: Prompts.getCompactSummaryWrapper(summary) }] }, + ...toKeep, + ]; +} +``` + +### `summarizeModelMessages(messages, model, options?)` + +ModelMessage-altitude sibling of [`summarizeMessages`](#summarizemessagesprompt-model-options): summarize a `ModelMessage[]` slice into a single summary string via the same pipeline (role-flattening + core `summarizeHistory`). System messages are dropped. An empty slice returns `''` without a model call; it throws if the model call fails. Use it with `planCompactionModelMessages` when you want to drive summarization yourself instead of the one-shot `compactModelMessages`. + +### `compactHistory(prompt, model, options)` + +> **Deprecated.** `compactHistory` / `planCompaction` take and return +> `LanguageModelV3Prompt` — the provider-protocol altitude, which nobody +> persists. Use [`compactModelMessages`](#compactmodelmessagesmessages-model-options) +> / `planCompactionModelMessages` instead. Still exported and working; removed in the next major. + +The V3-prompt variant of `compactModelMessages`. It splits the history on turn boundaries, summarizes the old slice, and returns a new prompt ready to persist — `[...system, , ...recent turns]`: + +```typescript +import { compactHistory } from '@context-chef/ai-sdk-middleware'; + +// Between model calls, when you own `messages`: +messages = await compactHistory(messages, summarizerModel, { + keepRecentTurns: 4, // keep the last 4 atomic turns verbatim + toolResultStubThreshold: 5000, +}); +// Persist the result — history actually shrinks and stays shrunk. +``` + +- The cut lands only on **turn boundaries** (an assistant + its tool results stay together), so it never orphans a tool result or splits a multi-block assistant message. +- System messages are preserved verbatim and never summarized. +- Returns the prompt **unchanged** when there is nothing old enough to compact (no more turns than `keepRecentTurns`) or the summarizer yields no text — safe to call unconditionally. Throws only if the model call throws. +- Accepts the same `SummarizeMessagesOptions` (`customCompressionInstructions`, `toolResultStubThreshold`) as `summarizeMessages`. + ### `planCompaction(prompt, options)` +> **Deprecated.** Use [`planCompactionModelMessages`](#plancompactionmodelmessagesmessages-options). +> This V3-prompt variant is the provider-protocol altitude — a type you never +> persist. Still exported and working; removed in the next major. + The synchronous split behind `compactHistory`, for when you want the boundary without summarizing (persist your own marker, or use a different summarizer). Returns `{ system, toSummarize, toKeep }` (all `LanguageModelV3Prompt`), cut on turn boundaries: ```typescript diff --git a/packages/ai-sdk-middleware/README.zh-CN.md b/packages/ai-sdk-middleware/README.zh-CN.md index f409b25..af19769 100644 --- a/packages/ai-sdk-middleware/README.zh-CN.md +++ b/packages/ai-sdk-middleware/README.zh-CN.md @@ -67,7 +67,7 @@ const model = withContextChef(openai('gpt-4o'), { }); ``` -> **In-flight 与 durable 的区别。** 中间件的 `compress` 是 *in-flight* 的:它只重写每次发出去的请求,**不会**改动你的消息存储。所以对于**持续超预算**的对话(长聊天,或长的多步 loop),摘要每次调用后即被丢弃、历史随即回胀 —— 压缩实际上只会隔次触发,payload 还会持续增长。一次性尖峰无所谓;若是持续场景,请通过 `onCompress` **持久化**摘要,或用 [`compactHistory`](#compacthistoryprompt-model-options) 压缩你自己的存储(推荐)。若 `compress` 反复触发却没配 `onCompress`,中间件会打印一次告警。 +> **In-flight 与 durable 的区别。** 中间件的 `compress` 是 *in-flight* 的:它只重写每次发出去的请求,**不会**改动你的消息存储。所以对于**持续超预算**的对话(长聊天,或长的多步 loop),摘要每次调用后即被丢弃、历史随即回胀 —— 压缩实际上只会隔次触发,payload 还会持续增长。一次性尖峰无所谓;若是持续场景,请通过 `onCompress` **持久化**摘要,或用 [`compactModelMessages`](#compactmodelmessagesmessages-model-options) 压缩你自己的存储(推荐)。若 `compress` 反复触发却没配 `onCompress`,中间件会打印一次告警。 ### 工具结果截断 @@ -228,29 +228,43 @@ const summary = await summarizeMessages(promptSlice, model, { > **协调注意:** 若以此方式驱动摘要,请**不要**在同一中间件路径上对同一对话再配置 `compress`(带 `model`)—— 那会重复压缩(调用时一次、持久化时再一次)。仅做通知的 `onCompress`,以及 `truncate`、`clear`、`dynamicState` 可安全并用。 -### `compactHistory(prompt, model, options)` +### `compactModelMessages(messages, model, options)` -**一站式 durable 压缩** —— 当你拥有消息存储时(长 agent loop,或超预算的聊天),让长对话保持精简的推荐方式。它在 turn 边界切分历史、对旧切片摘要,返回一份可直接持久化的新 prompt —— `[...system, <摘要>, ...最近若干轮]`: +**一站式 durable 压缩** —— 当你拥有消息存储时(长 agent loop,或超预算的聊天),让长对话保持精简的推荐方式。它工作在 **ModelMessage 层级** —— 即 `generateText` 和 `prepareStep` 实际交给你的 `ModelMessage[]` 类型。它在 turn 边界切分历史、对旧切片摘要,返回一份可直接持久化的新 `ModelMessage[]` —— `[...system, <摘要>, ...最近若干轮]`。可在你自己的 loop 里运行(拥有存储并把结果写回),或放进 `ToolLoopAgent`: ```typescript -import { compactHistory } from '@context-chef/ai-sdk-middleware'; +import { compactModelMessages } from '@context-chef/ai-sdk-middleware'; -// 在模型调用之间,当你持有 `messages` 时: -messages = await compactHistory(messages, summarizerModel, { +const agent = new ToolLoopAgent({ + model, + tools, + prepareStep: async ({ messages, model }) => ({ + messages: await compactModelMessages(messages, model, { keepRecentTurns: 4 }), + }), +}); +``` + +或在模型调用之间,当你持有 `messages` 时: + +```typescript +const next = await compactModelMessages(messages, summarizerModel, { keepRecentTurns: 4, // 逐字保留最近 4 个原子轮次 toolResultStubThreshold: 5000, }); -// 持久化结果 —— 历史真正变小并保持精简。 +if (next !== messages) await save(next); // no-op 时跳过持久化 ``` +- `model` 是 `ai` 的 `LanguageModel`(`string id | V3 | V2`)—— 正是 `prepareStep` / `generateText` 交给你的类型。 - 切点只落在 **turn 边界**(assistant 及其 tool 结果作为整体),所以不会 orphan tool 结果、也不会切进多 block 的 assistant 消息。 - system 消息逐字保留、永不被摘要。 -- 当没有足够旧的内容可压(轮数不超过 `keepRecentTurns`)、或摘要器无输出时,**原样返回** prompt —— 可无条件调用。仅当模型调用抛错时才抛错。 +- 当没有足够旧的内容可压(轮数不超过 `keepRecentTurns`)、或摘要器无输出时,**原样返回同一个 `messages` 引用** —— 因此可通过 `next !== messages` 在 no-op 时跳过持久化。可无条件调用;仅当模型调用抛错时才抛错。 - 接受与 `summarizeMessages` 相同的 `SummarizeMessagesOptions`(`customCompressionInstructions`、`toolResultStubThreshold`)。 > 对同一对话用这个**或** in-flight `compress`,二选一(同时用会重复压缩)。 -> **内部实现:** `compactHistory` 和 `planCompaction` 是 [`@context-chef/core`](https://www.npmjs.com/package/@context-chef/core) 中 provider 无关引擎的 AI-SDK 薄壳 —— 它们把 prompt 转成 core 的 IR 再转回来。若你直接对接某个 provider(不经 AI SDK),请改用 core 的 `planCompaction` / `compactHistory`。 +> **`keepRecentTurns` 数的是消息级 turn,不是 `ToolLoopAgent` 的 step。** 一个 turn 是一条 user/assistant 消息,或一条带 tool-calls 的 assistant 加它全部 tool 结果(绑成一体)。一次用工具的 step 往往是 2–3 个 turn,所以请按你**最坏单 step 的消息数**来设 `keepRecentTurns` —— 工具密集的 agent loop 要比纯聊天设得更大。摘要是按 `user` 消息插入的,所以当保留尾部也以 user turn 开头时,结果可能出现连续两条 `user` 消息 —— 这是合法的 `ModelMessage[]`,AI SDK 的 provider 层会归一化(Anthropic 合并同角色、OpenAI 直接接受)。 + +> **内部实现:** `compactModelMessages`、`planCompactionModelMessages` 和 `summarizeModelMessages` 是 [`@context-chef/core`](https://www.npmjs.com/package/@context-chef/core) 中 provider 无关引擎的 AI-SDK 薄壳 —— 它们把 `ModelMessage[]` 转成 core 的 IR 再转回来。若你直接对接某个 provider(不经 AI SDK),请改用 core 的 `planCompaction` / `compactHistory`。 #### 全压(Claude Code 式) @@ -259,7 +273,7 @@ messages = await compactHistory(messages, summarizerModel, { 代价是**没有任何逐字近况留存** —— 模型完全从一份有损摘要继续。所以摘要质量就是一切;用 `customCompressionInstructions` 把它导向结构化交接: ```typescript -messages = await compactHistory(messages, summarizerModel, { +const next = await compactModelMessages(messages, summarizerModel, { keepRecentTurns: 0, // 全压 —— 把一切塌进摘要 customCompressionInstructions: [ '把摘要写成可续作任务的交接文档:', @@ -269,13 +283,64 @@ messages = await compactHistory(messages, summarizerModel, { '- 下一步要做的确切动作', ].join('\n'), }); -// 此时 `messages` 就是 [system, summary] —— 持久化极简,无需任何边界记账。 +// 此时 `next` 就是 [system, summary] —— 持久化极简,无需任何边界记账。 ``` 想保住「进行中那一轮」的逐字上下文时(无人值守的 loop 做事中更安全),用小的 `keepRecentTurns`(如 `2`–`4`);想最大化收缩、要一个干净无边界的存储时,用 `0`。 +### `planCompactionModelMessages(messages, options)` + +`compactModelMessages` 背后的同步切分函数,适用于只要边界、不想立刻摘要的场景(持久化你自己的标记,或用别的摘要器)。返回 `{ system, toSummarize, toKeep }`(均为 `ModelMessage[]`),按 turn 边界切分: + +```typescript +import { planCompactionModelMessages, summarizeModelMessages } from '@context-chef/ai-sdk-middleware'; +import { Prompts } from '@context-chef/core'; + +const { system, toSummarize, toKeep } = planCompactionModelMessages(messages, { keepRecentTurns: 4 }); +if (toSummarize.length > 0) { + const summary = await summarizeModelMessages(toSummarize, model); + messages = [ + ...system, + { role: 'user', content: [{ type: 'text', text: Prompts.getCompactSummaryWrapper(summary) }] }, + ...toKeep, + ]; +} +``` + +### `summarizeModelMessages(messages, model, options?)` + +[`summarizeMessages`](#summarizemessagesprompt-model-options) 的 ModelMessage 层级兄弟函数:用同一流水线(角色扁平化 + core `summarizeHistory`)把一段 `ModelMessage[]` 切片摘要为单个字符串。system 消息会被丢弃。空切片不调用模型直接返回 `''`,模型调用失败时抛出异常。当你想自行驱动摘要、而非用一站式的 `compactModelMessages` 时,配合 `planCompactionModelMessages` 使用。 + +### `compactHistory(prompt, model, options)` + +> **已废弃。** `compactHistory` / `planCompaction` 收发的是 +> `LanguageModelV3Prompt` —— provider 协议层级,没人会持久化这个类型。请改用 +> [`compactModelMessages`](#compactmodelmessagesmessages-model-options) +> / `planCompactionModelMessages`。仍然导出且可用;下个大版本移除。 + +`compactModelMessages` 的 V3-prompt 变体。它在 turn 边界切分历史、对旧切片摘要,返回一份可直接持久化的新 prompt —— `[...system, <摘要>, ...最近若干轮]`: + +```typescript +import { compactHistory } from '@context-chef/ai-sdk-middleware'; + +// 在模型调用之间,当你持有 `messages` 时: +messages = await compactHistory(messages, summarizerModel, { + keepRecentTurns: 4, // 逐字保留最近 4 个原子轮次 + toolResultStubThreshold: 5000, +}); +// 持久化结果 —— 历史真正变小并保持精简。 +``` + +- 切点只落在 **turn 边界**(assistant 及其 tool 结果作为整体),所以不会 orphan tool 结果、也不会切进多 block 的 assistant 消息。 +- system 消息逐字保留、永不被摘要。 +- 当没有足够旧的内容可压(轮数不超过 `keepRecentTurns`)、或摘要器无输出时,**原样返回** prompt —— 可无条件调用。仅当模型调用抛错时才抛错。 +- 接受与 `summarizeMessages` 相同的 `SummarizeMessagesOptions`(`customCompressionInstructions`、`toolResultStubThreshold`)。 + ### `planCompaction(prompt, options)` +> **已废弃。** 请改用 [`planCompactionModelMessages`](#plancompactionmodelmessagesmessages-options)。 +> 这个 V3-prompt 变体是 provider 协议层级 —— 一个你永远不会持久化的类型。仍然导出且可用;下个大版本移除。 + `compactHistory` 背后的同步切分函数,适用于只要边界、不想立刻摘要的场景(持久化你自己的标记,或用别的摘要器)。返回 `{ system, toSummarize, toKeep }`(均为 `LanguageModelV3Prompt`),按 turn 边界切分: ```typescript diff --git a/packages/ai-sdk-middleware/src/adapter.ts b/packages/ai-sdk-middleware/src/adapter.ts index fddcbad..80b29b8 100644 --- a/packages/ai-sdk-middleware/src/adapter.ts +++ b/packages/ai-sdk-middleware/src/adapter.ts @@ -97,15 +97,27 @@ export function fromAISDK(prompt: LanguageModelV3Prompt): AISDKMessage[] { const attachments: Attachment[] = []; let thinking: { thinking: string } | undefined; + // Provider-executed tools (web search, code exec) carry their tool-result + // INLINE in the same assistant message. Such calls are self-answered and + // must not appear as open IR tool_calls — otherwise ensureValidHistory + // injects a spurious placeholder, duplicating the inline result on + // round-trip. The inline result round-trips verbatim via _assistantContent. + const inlineAnsweredIds = new Set(); + for (const part of msg.content) { + if (part.type === 'tool-result') inlineAnsweredIds.add(part.toolCallId); + } + for (const part of msg.content) { if (part.type === 'text') text.push(part.text); else if (part.type === 'tool-call') { + if (inlineAnsweredIds.has(part.toolCallId)) continue; toolCalls.push({ id: part.toolCallId, type: 'function', function: { name: part.toolName, - arguments: typeof part.input === 'string' ? part.input : JSON.stringify(part.input), + arguments: + typeof part.input === 'string' ? part.input : JSON.stringify(part.input ?? {}), }, }); } else if (part.type === 'reasoning') { @@ -137,6 +149,11 @@ export function fromAISDK(prompt: LanguageModelV3Prompt): AISDKMessage[] { } if (msg.role === 'tool') { + // One source tool message maps to N IR tool messages (one per result), + // but message-level providerOptions (e.g. an Anthropic cache breakpoint) + // belongs to the whole turn — attach it to the FIRST IR message only, so + // toAISDK re-emits exactly one providerOptions when coalescing the group. + let firstOfMessage = true; for (const part of msg.content) { if (part.type === 'tool-result') { const text = stringifyToolOutput(part.output); @@ -147,7 +164,11 @@ export function fromAISDK(prompt: LanguageModelV3Prompt): AISDKMessage[] { _toolContent: [part], _originalText: text, _toolName: part.toolName, + ...(firstOfMessage && msg.providerOptions + ? { _providerOptions: msg.providerOptions } + : {}), }); + firstOfMessage = false; } } } @@ -220,11 +241,19 @@ export function toAISDK(messages: Message[]): LanguageModelV3Prompt { if (msg.role === 'tool') { const toolResults: LanguageModelV3ToolResultPart[] = []; + // Re-attach the message-level providerOptions captured on the first IR + // message of the original tool turn (see fromAISDK). Take the first + // non-undefined across the coalesced group. + let providerOptions: SharedV3ProviderOptions | undefined; while (i < messages.length && messages[i].role === 'tool') { const toolMsg = asAISDK(messages[i]); const toolModified = toolMsg._originalText !== undefined && toolMsg._originalText !== toolMsg.content; + if (providerOptions === undefined && toolMsg._providerOptions) { + providerOptions = toolMsg._providerOptions; + } + if (!toolModified && toolMsg._toolContent) { for (const part of toolMsg._toolContent) { if (part.type === 'tool-result') { @@ -246,7 +275,11 @@ export function toAISDK(messages: Message[]): LanguageModelV3Prompt { } i++; } - prompt.push({ role: 'tool', content: toolResults }); + prompt.push({ + role: 'tool', + content: toolResults, + ...(providerOptions ? { providerOptions } : {}), + }); continue; } @@ -256,7 +289,7 @@ export function toAISDK(messages: Message[]): LanguageModelV3Prompt { return prompt; } -function stringifyToolOutput(output: LanguageModelV3ToolResultOutput): string { +export function stringifyToolOutput(output: LanguageModelV3ToolResultOutput): string { switch (output.type) { case 'text': case 'error-text': diff --git a/packages/ai-sdk-middleware/src/compaction.ts b/packages/ai-sdk-middleware/src/compaction.ts index 38d1953..7da2bb0 100644 --- a/packages/ai-sdk-middleware/src/compaction.ts +++ b/packages/ai-sdk-middleware/src/compaction.ts @@ -4,9 +4,11 @@ import { planCompaction as corePlanCompaction, type PlanCompactionOptions, } from '@context-chef/core'; +import type { LanguageModel, ModelMessage } from 'ai'; import { fromAISDK, toAISDK } from './adapter'; import { createCompressionAdapter, type SummarizeMessagesOptions } from './middleware'; +import { fromModelMessages, toModelMessages } from './modelMessageAdapter'; export type { PlanCompactionOptions } from '@context-chef/core'; @@ -23,6 +25,10 @@ export interface CompactionPlan { } /** + * @deprecated Use {@link planCompactionModelMessages}. This V3-prompt variant is + * the provider-protocol altitude — a type you never persist. Removed in the next + * major. + * * Splits an AI SDK prompt into `{ system, toSummarize, toKeep }` on **turn * boundaries**, for durable (caller-owned) compaction. * @@ -50,6 +56,10 @@ export function planCompaction( } /** + * @deprecated Use {@link compactModelMessages}. `LanguageModelV3Prompt` is the + * provider-protocol altitude (ephemeral, never persisted); durable compaction + * belongs at the ModelMessage altitude. Removed in the next major. + * * One-shot durable compaction: plan a turn-safe split, summarize the old slice, * and return a new prompt ready to persist — `[...system, , ...toKeep]`. * @@ -90,3 +100,80 @@ export async function compactHistory( // prompt reference so callers can skip persistence via `result === prompt`. return result === ir ? prompt : toAISDK(result); } + +export interface CompactionPlanModelMessages { + /** System messages, preserved verbatim — standing instructions are never summarized. */ + system: ModelMessage[]; + /** The old conversation slice to summarize (system excluded). Empty when nothing is old enough. */ + toSummarize: ModelMessage[]; + /** The recent conversation turns to keep verbatim. */ + toKeep: ModelMessage[]; +} + +/** + * Turn-safe split for durable compaction at the **ModelMessage** altitude — the + * type `prepareStep`/`generateText` hand you. Converts to IR via + * {@link fromModelMessages}, splits on turn boundaries via core's + * `planCompaction`, and converts each slice back via {@link toModelMessages}. + * Summarize `toSummarize`, then persist `[...system, , ...toKeep]`. + * + * `keepRecentTurns` counts **message-level turns, not `ToolLoopAgent` steps**: a + * turn is one user/assistant message, or an assistant with its tool-calls plus + * all their tool results (kept together so a result is never orphaned). System + * messages are always preserved and never counted. A single tool-using step is + * often 2–3 turns, so size it for your worst-case step — tool-dense loops need a + * larger value than a plain chat. + */ +export function planCompactionModelMessages( + messages: ModelMessage[], + options: PlanCompactionOptions, +): CompactionPlanModelMessages { + const plan = corePlanCompaction(fromModelMessages(messages), options); + return { + system: toModelMessages(plan.system), + toSummarize: toModelMessages(plan.toSummarize), + toKeep: toModelMessages(plan.toKeep), + }; +} + +/** + * One-shot durable compaction at the **ModelMessage** altitude: plan a turn-safe + * split, summarize the old slice, and return a new `ModelMessage[]` ready to + * persist — `[...system, , ...toKeep]`. Use it in your own own-the-store + * loop, or inside a `ToolLoopAgent` `prepareStep` (`return { messages: await + * compactModelMessages(messages, model, opts) }`). + * + * `model` is `ai`'s `LanguageModel` (string id | V3 | V2) — exactly what + * `prepareStep`/`generateText` give you. Reuses core's `compactHistory` + + * `createCompressionAdapter` (tool-role flattening); no model is called directly. + * + * Returns the **input `messages` reference unchanged** when there is nothing old + * enough to compact or the summarizer yields no text, so callers can skip + * persistence on a no-op via `result === messages`. Throws only if the model call + * throws. + * + * `keepRecentTurns` counts **message-level turns, not `ToolLoopAgent` steps** — a + * turn is one user/assistant message, or an assistant with its tool-calls plus + * all their tool results (so a result is never orphaned); system messages are + * always preserved and never counted. A single tool-using step is often 2–3 + * turns, so size it for your worst-case step (tool-dense loops need more than a + * plain chat). + * + * The summary is inserted as a `user` message (Claude Code style), so when the + * kept tail also begins with a user turn the result can hold two consecutive + * `user` messages. That is a valid `ModelMessage[]` — the AI SDK provider layer + * normalizes it (Anthropic merges same-role, OpenAI accepts it) — but if you feed + * the output to a non-AI-SDK consumer that requires strict alternation, account + * for it. + */ +export async function compactModelMessages( + messages: ModelMessage[], + model: LanguageModel, + options: PlanCompactionOptions & SummarizeMessagesOptions, +): Promise { + const ir = fromModelMessages(messages); + const result = await coreCompactHistory(ir, createCompressionAdapter(model), options); + // core returns the input IR reference on a no-op — preserve the original + // `messages` reference so callers can skip persistence via `result === messages`. + return result === ir ? messages : toModelMessages(result); +} diff --git a/packages/ai-sdk-middleware/src/index.ts b/packages/ai-sdk-middleware/src/index.ts index 6c090c4..4b23b7a 100644 --- a/packages/ai-sdk-middleware/src/index.ts +++ b/packages/ai-sdk-middleware/src/index.ts @@ -8,11 +8,19 @@ export type { ClearTarget } from '@context-chef/core'; export { type AISDKMessage, fromAISDK, toAISDK } from './adapter'; export { type CompactionPlan, + type CompactionPlanModelMessages, compactHistory, + compactModelMessages, type PlanCompactionOptions, planCompaction, + planCompactionModelMessages, } from './compaction'; -export { createMiddleware, type SummarizeMessagesOptions, summarizeMessages } from './middleware'; +export { + createMiddleware, + type SummarizeMessagesOptions, + summarizeMessages, + summarizeModelMessages, +} from './middleware'; export type { CompactConfig, CompressOptions, diff --git a/packages/ai-sdk-middleware/src/middleware.ts b/packages/ai-sdk-middleware/src/middleware.ts index b80fb6b..1dc34ba 100644 --- a/packages/ai-sdk-middleware/src/middleware.ts +++ b/packages/ai-sdk-middleware/src/middleware.ts @@ -15,9 +15,16 @@ import { summarizeHistory, XmlGenerator, } from '@context-chef/core'; -import { generateText, type LanguageModelMiddleware, type ModelMessage, pruneMessages } from 'ai'; +import { + generateText, + type LanguageModel, + type LanguageModelMiddleware, + type ModelMessage, + pruneMessages, +} from 'ai'; import { fromAISDK, toAISDK } from './adapter'; +import { fromModelMessages } from './modelMessageAdapter'; import { truncateToolResults } from './truncator'; import type { ContextChefOptions, DynamicStateConfig } from './types'; @@ -83,7 +90,7 @@ export function createMiddleware(options: ContextChefOptions): LanguageModelMidd 'not persisted, so your message history re-expands on the next call and the payload grows ' + 'unbounded (eventually overflowing the context window). For sustained compression, persist ' + 'the summary via `onCompress` (replace the compressed slice in your own store), or use ' + - '`summarizeMessages` for durable compaction.', + '`compactModelMessages` for durable compaction.', ); }; @@ -383,7 +390,7 @@ function toCompressRole(role: string): CompressRole { * since generateText only accepts system/user/assistant roles. */ export function createCompressionAdapter( - model: LanguageModelV3, + model: LanguageModel, ): (messages: Message[]) => Promise { return async (messages: Message[]): Promise => { const formatted = messages.map((m): { role: CompressRole; content: string } => { @@ -450,3 +457,18 @@ export async function summarizeMessages( const ir = fromAISDK(prompt).filter((m) => m.role !== 'system'); return summarizeHistory(ir, createCompressionAdapter(model), opts); } + +/** + * ModelMessage-altitude sibling of {@link summarizeMessages}: summarize a + * `ModelMessage[]` slice into a single summary string via the same pipeline + * (role-flattening + core `summarizeHistory`). System messages are dropped. + * Empty input returns `''` without a model call; throws if the model call fails. + */ +export async function summarizeModelMessages( + messages: ModelMessage[], + model: LanguageModel, + opts: SummarizeMessagesOptions = {}, +): Promise { + const ir = fromModelMessages(messages).filter((m) => m.role !== 'system'); + return summarizeHistory(ir, createCompressionAdapter(model), opts); +} diff --git a/packages/ai-sdk-middleware/src/modelMessageAdapter.ts b/packages/ai-sdk-middleware/src/modelMessageAdapter.ts new file mode 100644 index 0000000..3b5f39f --- /dev/null +++ b/packages/ai-sdk-middleware/src/modelMessageAdapter.ts @@ -0,0 +1,303 @@ +import type { LanguageModelV3ToolResultOutput } from '@ai-sdk/provider'; +import { + type Attachment, + ensureValidHistory, + type Message, + type ToolCall, +} from '@context-chef/core'; +import type { ModelMessage } from 'ai'; + +import { stringifyToolOutput } from './adapter'; + +// NOTE: This adapter intentionally parallels src/adapter.ts (the V3 adapter). The +// user/assistant/file extraction logic is duplicated by design; keep the two in +// sync. The deltas here are deliberate: string-shorthand content, ImagePart, and +// approval parts — none of which exist at the V3 (LanguageModelV3Prompt) altitude. + +// Content/part types derived from ModelMessage — no part-type imports needed +// (provider-utils does not export them all stably). Same trick as adapter.ts. +type UserContent = Extract['content']; +type AssistantContent = Extract['content']; +type ToolContent = Extract['content']; +type ProviderOptions = Extract['providerOptions']; + +/** + * IR message carrying the original ModelMessage content for lossless round-trip. + * Parallel to AISDKMessage (the V3 adapter's carrier) but typed to the + * application-layer ModelMessage shapes, and on distinct `_mm*` fields so the two + * adapters can never read each other's pass-through by accident. + */ +export interface ModelMessageIR extends Message { + _mmUserContent?: UserContent; + _mmAssistantContent?: AssistantContent; + _mmToolContent?: ToolContent; + _mmOriginalText?: string; + _mmProviderOptions?: ProviderOptions; + _mmToolName?: string; +} + +/** + * Converts AI SDK `ModelMessage[]` (the application/SDK altitude — what + * `generateText`/`prepareStep` use) into context-chef IR. + * + * `content` may be a plain `string` (the SDK shorthand); it is preserved on the + * `_mm*Content` pass-through so an unmodified message round-trips byte-exact + * (string stays string). Boundary-sanitized via `ensureValidHistory`. + * + * Tool messages: one IR `role:'tool'` message per `tool-result` part (so + * `groupIntoTurns`/orphan detection works per result). `tool-approval-response` + * parts have no IR home; they are appended in order to the adjacent result's + * pass-through so coalescing in `toModelMessages` restores them. A tool message + * with no tool-result at all is dropped by sanitization (not a real durable + * input). + */ +export function fromModelMessages(messages: ModelMessage[]): ModelMessageIR[] { + const ir: ModelMessageIR[] = []; + + for (const msg of messages) { + if (msg.role === 'system') { + ir.push({ + role: 'system', + content: msg.content, + ...(msg.providerOptions ? { _mmProviderOptions: msg.providerOptions } : {}), + }); + continue; + } + + if (msg.role === 'user') { + const text = + typeof msg.content === 'string' + ? msg.content + : msg.content + .filter((p) => p.type === 'text') + .map((p) => p.text) + .join('\n'); + + const attachments: Attachment[] = []; + if (typeof msg.content !== 'string') { + for (const part of msg.content) { + if (part.type === 'file') { + attachments.push({ + mediaType: part.mediaType, + data: typeof part.data === 'string' ? part.data : '', + ...(part.filename ? { filename: part.filename } : {}), + }); + } else if (part.type === 'image') { + attachments.push({ + mediaType: part.mediaType ?? 'image/*', + data: typeof part.image === 'string' ? part.image : '', + }); + } + } + } + + const m: ModelMessageIR = { + role: 'user', + content: text, + _mmUserContent: msg.content, + _mmOriginalText: text, + ...(msg.providerOptions ? { _mmProviderOptions: msg.providerOptions } : {}), + }; + if (attachments.length) m.attachments = attachments; + ir.push(m); + continue; + } + + if (msg.role === 'assistant') { + const textParts: string[] = []; + const toolCalls: ToolCall[] = []; + const attachments: Attachment[] = []; + let thinking: { thinking: string } | undefined; + + if (typeof msg.content === 'string') { + textParts.push(msg.content); + } else { + // Provider-executed tools (web search, code exec) carry their tool-result + // INLINE in the same assistant message. Such calls are self-answered and + // must not appear as open IR tool_calls — otherwise ensureValidHistory + // injects a spurious placeholder, duplicating the inline result on + // round-trip. The inline result round-trips verbatim via _mmAssistantContent. + const inlineAnsweredIds = new Set(); + for (const part of msg.content) { + if (part.type === 'tool-result') inlineAnsweredIds.add(part.toolCallId); + } + + for (const part of msg.content) { + if (part.type === 'text') { + textParts.push(part.text); + } else if (part.type === 'tool-call') { + if (inlineAnsweredIds.has(part.toolCallId)) continue; + toolCalls.push({ + id: part.toolCallId, + type: 'function', + function: { + name: part.toolName, + arguments: + typeof part.input === 'string' ? part.input : JSON.stringify(part.input ?? {}), + }, + }); + } else if (part.type === 'reasoning') { + thinking = { thinking: part.text }; + } else if (part.type === 'file') { + attachments.push({ + mediaType: part.mediaType, + data: typeof part.data === 'string' ? part.data : '', + ...(part.filename ? { filename: part.filename } : {}), + }); + } + // tool-approval-request parts ride through _mmAssistantContent verbatim (no IR projection). + } + } + + const joined = textParts.join('\n'); + const m: ModelMessageIR = { + role: 'assistant', + content: joined, + _mmAssistantContent: msg.content, + _mmOriginalText: joined, + ...(msg.providerOptions ? { _mmProviderOptions: msg.providerOptions } : {}), + }; + if (toolCalls.length) m.tool_calls = toolCalls; + if (thinking) m.thinking = thinking; + if (attachments.length) m.attachments = attachments; + ir.push(m); + continue; + } + + if (msg.role === 'tool') { + let anchor: ModelMessageIR | undefined; + const pending: ToolContent = []; + // One source tool message maps to N IR tool messages (one per result), + // but message-level providerOptions (e.g. an Anthropic cache breakpoint) + // belongs to the whole turn — attach it to the FIRST IR message only, so + // toModelMessages re-emits exactly one providerOptions when coalescing. + let firstOfMessage = true; + for (const part of msg.content) { + if (part.type === 'tool-result') { + // ModelMessage's ToolResultOutput is a structural superset of V3's (extra content[] members); stringifyToolOutput only reads .type/.value, so the cast is safe and matches the V3 adapter's projection. + const text = stringifyToolOutput(part.output as LanguageModelV3ToolResultOutput); + anchor = { + role: 'tool', + content: text, + tool_call_id: part.toolCallId, + _mmToolContent: [...pending, part], + _mmOriginalText: text, + _mmToolName: part.toolName, + ...(firstOfMessage && msg.providerOptions + ? { _mmProviderOptions: msg.providerOptions } + : {}), + }; + firstOfMessage = false; + pending.length = 0; + ir.push(anchor); + } else if (anchor?._mmToolContent) { + anchor._mmToolContent.push(part); + } else { + // Approval part seen before any tool-result: buffer it to prepend to + // the next result. If no result ever follows in this message (a tool + // message with zero results), fromModelMessages emits NO IR message at + // all — so there is nothing for ensureValidHistory to sanitize, and the + // buffered parts are simply dropped. We accept that edge shape (a + // leading approval with no following result in the same message). + pending.push(part); + } + } + } + } + + return ensureValidHistory(ir) as ModelMessageIR[]; +} + +function asMM(msg: Message): ModelMessageIR { + return msg; +} + +/** + * Converts context-chef IR back to AI SDK `ModelMessage[]`. + * + * Unmodified messages emit their original content verbatim (via `_mm*` fields), + * so string content stays a string and reasoning/approval parts round-trip + * byte-exact. Janitor-modified messages and synthetic messages (e.g. a + * compression summary, which has no pass-through) are rebuilt from IR fields. + * For a modified tool message this rebuild emits only a single `tool-result` + * from the IR text — any co-located `tool-approval-response` parts are + * intentionally dropped, since a modified/cleared result is being collapsed + * anyway. + */ +export function toModelMessages(messages: Message[]): ModelMessage[] { + const out: ModelMessage[] = []; + + let i = 0; + while (i < messages.length) { + const msg = asMM(messages[i]); + const modified = msg._mmOriginalText !== undefined && msg._mmOriginalText !== msg.content; + + if (msg.role === 'system') { + out.push({ + role: 'system', + content: msg.content, + ...(msg._mmProviderOptions ? { providerOptions: msg._mmProviderOptions } : {}), + }); + i++; + continue; + } + + if (msg.role === 'user') { + out.push({ + role: 'user', + content: + !modified && msg._mmUserContent !== undefined + ? msg._mmUserContent + : [{ type: 'text', text: msg.content }], + ...(msg._mmProviderOptions ? { providerOptions: msg._mmProviderOptions } : {}), + }); + i++; + continue; + } + + if (msg.role === 'assistant') { + out.push({ + role: 'assistant', + content: + !modified && msg._mmAssistantContent !== undefined + ? msg._mmAssistantContent + : [{ type: 'text', text: msg.content }], + ...(msg._mmProviderOptions ? { providerOptions: msg._mmProviderOptions } : {}), + }); + i++; + continue; + } + + if (msg.role === 'tool') { + const content: ToolContent = []; + // Re-attach the message-level providerOptions captured on the first IR + // message of the original tool turn (see fromModelMessages). Take the + // first non-undefined across the coalesced group. + let providerOptions: ProviderOptions; + while (i < messages.length && messages[i].role === 'tool') { + const t = asMM(messages[i]); + const tModified = t._mmOriginalText !== undefined && t._mmOriginalText !== t.content; + if (providerOptions === undefined && t._mmProviderOptions) { + providerOptions = t._mmProviderOptions; + } + if (!tModified && t._mmToolContent) { + content.push(...t._mmToolContent); + } else { + content.push({ + type: 'tool-result', + toolCallId: t.tool_call_id ?? '', + toolName: t._mmToolName ?? t.name ?? 'unknown', + output: { type: 'text', value: t.content }, + }); + } + i++; + } + out.push({ role: 'tool', content, ...(providerOptions ? { providerOptions } : {}) }); + continue; + } + + i++; + } + + return out; +} diff --git a/packages/ai-sdk-middleware/src/types.ts b/packages/ai-sdk-middleware/src/types.ts index 4fe1007..b04cc28 100644 --- a/packages/ai-sdk-middleware/src/types.ts +++ b/packages/ai-sdk-middleware/src/types.ts @@ -62,7 +62,7 @@ export interface TruncateOptions { * grows unbounded and compression effectively fires only every other call * (E10 suppression). For sustained use, persist the summary via * {@link ContextChefOptions.onCompress} — replace the compressed slice in - * your own store — or use `summarizeMessages` for durable compaction. The + * your own store — or use `compactModelMessages` for durable compaction. The * middleware warns once if compression keeps firing without `onCompress`. */ export interface CompressOptions { diff --git a/packages/ai-sdk-middleware/tests/adapter.test.ts b/packages/ai-sdk-middleware/tests/adapter.test.ts index 512043a..5604902 100644 --- a/packages/ai-sdk-middleware/tests/adapter.test.ts +++ b/packages/ai-sdk-middleware/tests/adapter.test.ts @@ -305,6 +305,40 @@ describe('fromAISDK', () => { expect(placeholder?.name).toBe('run'); }); + // ─── FIX #3: tool-call with input:undefined yields a string '{}' ─── + it('serializes a tool-call with undefined input to "{}" (a string)', () => { + const prompt: LanguageModelV3Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'go' }] }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'run', + // biome-ignore lint/suspicious/noExplicitAny: deliberately exercising the undefined-input edge + input: undefined as any, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call_1', + toolName: 'run', + output: { type: 'text', value: 'done' }, + }, + ], + }, + ]; + const result = fromAISDK(prompt); + const args = result[1].tool_calls?.[0].function.arguments; + expect(args).toBe('{}'); + expect(typeof args).toBe('string'); + }); + it('round-trips sanitized placeholder with original toolName (not "unknown")', () => { // Regression guard for the boundary-sanitize bug where injected placeholders // round-tripped as `toolName: 'unknown'` because toAISDK only read _toolName. @@ -533,4 +567,112 @@ describe('round-trip', () => { const roundTripped = toAISDK(ir); expect(roundTripped).toEqual(original); }); + + // ─── FIX #1: inline (provider-executed) tool-result must not trigger a placeholder ─── + it('does not inject a placeholder for an inline (provider-executed) tool-result', () => { + const original: LanguageModelV3Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'search the web' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Searching.' }, + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'web_search', + input: { q: 'context-chef' }, + }, + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'web_search', + output: { type: 'text', value: 'inline result' }, + }, + ], + }, + ]; + + const ir = fromAISDK(original); + // The self-answered call must not appear as an open call in IR. + const assistant = ir.find((m) => m.role === 'assistant'); + expect(assistant?.tool_calls).toBeUndefined(); + // No placeholder tool message injected by ensureValidHistory. + expect(ir.some((m) => m.role === 'tool')).toBe(false); + + const roundTripped = toAISDK(ir); + expect(roundTripped).toEqual(original); + }); + + it('pairs a normal tool-call and skips an inline one in the same assistant message', () => { + const original: LanguageModelV3Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'do two things' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Working.' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NY' } }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'web_search', input: { q: 'x' } }, + { + type: 'tool-result', + toolCallId: 'c2', + toolName: 'web_search', + output: { type: 'text', value: 'inline answer' }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'get_weather', + output: { type: 'text', value: 'Sunny' }, + }, + ], + }, + ]; + + const ir = fromAISDK(original); + // Only the genuinely-open call (c1) is projected to IR tool_calls. + const assistant = ir.find((m) => m.role === 'assistant'); + expect(assistant?.tool_calls).toEqual([ + { id: 'c1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"NY"}' } }, + ]); + // The separate tool message pairs c1; no placeholder for c2. + const toolMessages = ir.filter((m) => m.role === 'tool'); + expect(toolMessages).toHaveLength(1); + expect(toolMessages[0].tool_call_id).toBe('c1'); + expect(ir.some((m) => m.content === '[No tool result available]')).toBe(false); + + const roundTripped = toAISDK(ir); + expect(roundTripped).toEqual(original); + }); + + // ─── FIX #2: tool-message-level providerOptions survives round-trip ─── + it('preserves tool-message-level providerOptions through round-trip', () => { + const original: LanguageModelV3Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'run it' }] }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'run', input: {} }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'run', + output: { type: 'text', value: 'ok' }, + }, + ], + providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, + }, + ]; + + const ir = fromAISDK(original); + const roundTripped = toAISDK(ir); + expect(roundTripped).toEqual(original); + }); }); diff --git a/packages/ai-sdk-middleware/tests/compactionModelMessages.test.ts b/packages/ai-sdk-middleware/tests/compactionModelMessages.test.ts new file mode 100644 index 0000000..441eb51 --- /dev/null +++ b/packages/ai-sdk-middleware/tests/compactionModelMessages.test.ts @@ -0,0 +1,125 @@ +import type { + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3Content, + LanguageModelV3FinishReason, + LanguageModelV3GenerateResult, +} from '@ai-sdk/provider'; +import type { ModelMessage } from 'ai'; +import { describe, expect, it } from 'vitest'; +import { compactModelMessages, planCompactionModelMessages } from '../src/compaction'; + +/** Minimal V3 model whose summarization call returns a fixed string. A V3 model + * is a valid `LanguageModel`, so it exercises the widened model param too. */ +function createSummarizerModel(summaryText = 'SUMMARY'): LanguageModelV3 { + return { + specificationVersion: 'v3', + provider: 'test', + modelId: 'test-model', + supportedUrls: {}, + async doGenerate(_opts: LanguageModelV3CallOptions): Promise { + const content: LanguageModelV3Content[] = [{ type: 'text', text: summaryText }]; + const finishReason: LanguageModelV3FinishReason = { unified: 'stop', raw: undefined }; + return { + content, + finishReason, + warnings: [], + usage: { + inputTokens: { + total: 50, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 10, text: undefined, reasoning: undefined }, + }, + response: { id: 'id', timestamp: new Date(), modelId: 'test-model' }, + }; + }, + async doStream() { + throw new Error('not used'); + }, + }; +} + +/** N plain user/assistant turns (string shorthand), optional leading system. */ +function plainTurns(n: number, withSystem = true): ModelMessage[] { + const msgs: ModelMessage[] = withSystem ? [{ role: 'system', content: 'You are helpful.' }] : []; + for (let i = 0; i < n; i++) { + msgs.push({ role: 'user', content: `q${i}` }); + msgs.push({ role: 'assistant', content: `a${i}` }); + } + return msgs; +} + +describe('planCompactionModelMessages', () => { + it('splits on turn boundaries and round-trips each slice as ModelMessage[]', () => { + const plan = planCompactionModelMessages(plainTurns(3), { keepRecentTurns: 2 }); + expect(plan.system.map((m) => m.role)).toEqual(['system']); + expect(plan.toSummarize).toHaveLength(4); + expect(plan.toKeep).toHaveLength(2); + expect(plan.toKeep[0].role).toBe('user'); + }); + + it('never splits an assistant tool-call from its tool result', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'q1' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: { a: 1 } }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'ok' }, + }, + ], + }, + { role: 'user', content: 'q2' }, + { role: 'assistant', content: 'a2' }, + ]; + const plan = planCompactionModelMessages(messages, { keepRecentTurns: 3 }); + expect(plan.toSummarize.map((m) => m.role)).toEqual(['user']); + expect(plan.toKeep.map((m) => m.role)).toEqual(['assistant', 'tool', 'user', 'assistant']); + }); +}); + +describe('compactModelMessages', () => { + it('returns [...system, summary, ...toKeep] with a wrapped user summary', async () => { + const messages = plainTurns(4); // system + 8 messages + const result = await compactModelMessages(messages, createSummarizerModel('Hello'), { + keepRecentTurns: 2, + }); + expect(result[0]).toEqual(messages[0]); // system preserved + const summary = result[1]; + const text = + summary.role === 'user' && + typeof summary.content !== 'string' && + summary.content[0].type === 'text' + ? summary.content[0].text + : ''; + expect(text).toContain('Hello'); + expect(text).toContain('continued from a previous conversation'); + expect(result.length).toBe(1 + 1 + 2); + }); + + it('returns the INPUT reference unchanged when nothing is old enough', async () => { + const messages = plainTurns(2); + const result = await compactModelMessages(messages, createSummarizerModel(), { + keepRecentTurns: 99, + }); + expect(result).toBe(messages); // same reference — caller skips persistence + }); + + it('returns the INPUT reference unchanged when the summary is blank', async () => { + const messages = plainTurns(4); + const result = await compactModelMessages(messages, createSummarizerModel(' '), { + keepRecentTurns: 1, + }); + expect(result).toBe(messages); + }); +}); diff --git a/packages/ai-sdk-middleware/tests/modelMessageAdapter.test.ts b/packages/ai-sdk-middleware/tests/modelMessageAdapter.test.ts new file mode 100644 index 0000000..f1fbbd4 --- /dev/null +++ b/packages/ai-sdk-middleware/tests/modelMessageAdapter.test.ts @@ -0,0 +1,468 @@ +import type { Message } from '@context-chef/core'; +import type { ModelMessage } from 'ai'; +import { describe, expect, it } from 'vitest'; +import { fromModelMessages, toModelMessages } from '../src/modelMessageAdapter'; + +describe('fromModelMessages', () => { + it('keeps string-shorthand user content as text in IR', () => { + const ir = fromModelMessages([{ role: 'user', content: 'hello' }]); + expect(ir[0].content).toBe('hello'); + expect(ir[0]._mmUserContent).toBe('hello'); + }); + + it('extracts text and records image + file parts as attachments', () => { + const messages: ModelMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { type: 'image', image: 'imgdata', mediaType: 'image/png' }, + { type: 'file', data: 'pdfdata', mediaType: 'application/pdf', filename: 'r.pdf' }, + ], + }, + ]; + const ir = fromModelMessages(messages); + expect(ir[0].content).toBe('look'); + expect(ir[0].attachments).toEqual([ + { mediaType: 'image/png', data: 'imgdata' }, + { mediaType: 'application/pdf', data: 'pdfdata', filename: 'r.pdf' }, + ]); + }); + + it('extracts assistant tool calls and reasoning', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'use a tool' }, + { + role: 'assistant', + content: [ + { type: 'reasoning', text: 'thinking' }, + { type: 'text', text: 'answer' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: { a: 1 } }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'ok' }, + }, + ], + }, + ]; + const assistant = fromModelMessages(messages).find((m) => m.role === 'assistant'); + expect(assistant?.content).toBe('answer'); + expect(assistant?.thinking).toEqual({ thinking: 'thinking' }); + expect(assistant?.tool_calls).toEqual([ + { id: 'c1', type: 'function', function: { name: 'foo', arguments: '{"a":1}' } }, + ]); + }); + + // ─── FIX #3: tool-call with undefined input serializes to '{}' ─── + it('serializes a tool-call with undefined input to "{}" (a string)', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'go' }, + { + role: 'assistant', + content: [ + // biome-ignore lint/suspicious/noExplicitAny: deliberately exercising the undefined-input edge + { type: 'tool-call', toolCallId: 'c1', toolName: 'run', input: undefined as any }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'run', + output: { type: 'text', value: 'done' }, + }, + ], + }, + ]; + const assistant = fromModelMessages(messages).find((m) => m.role === 'assistant'); + const args = assistant?.tool_calls?.[0].function.arguments; + expect(args).toBe('{}'); + expect(typeof args).toBe('string'); + }); + + // ─── FIX #1: inline (provider-executed) tool-result must not trigger a placeholder ─── + it('does not project an inline tool-result as an open IR tool_call', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'search' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Searching.' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'web_search', input: { q: 'cc' } }, + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'web_search', + output: { type: 'text', value: 'inline result' }, + }, + ], + }, + ]; + const ir = fromModelMessages(messages); + const assistant = ir.find((m) => m.role === 'assistant'); + expect(assistant?.tool_calls).toBeUndefined(); + expect(ir.some((m) => m.role === 'tool')).toBe(false); + }); + + it('pairs a normal tool-call and skips an inline one in the same assistant message', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'two things' }, + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NY' } }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'web_search', input: { q: 'x' } }, + { + type: 'tool-result', + toolCallId: 'c2', + toolName: 'web_search', + output: { type: 'text', value: 'inline answer' }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'get_weather', + output: { type: 'text', value: 'Sunny' }, + }, + ], + }, + ]; + const ir = fromModelMessages(messages); + const assistant = ir.find((m) => m.role === 'assistant'); + expect(assistant?.tool_calls).toEqual([ + { id: 'c1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"NY"}' } }, + ]); + const toolMessages = ir.filter((m) => m.role === 'tool'); + expect(toolMessages).toHaveLength(1); + expect(toolMessages[0].tool_call_id).toBe('c1'); + expect(ir.some((m) => m.content === '[No tool result available]')).toBe(false); + }); + + it('splits a tool message into one IR message per tool-result', () => { + const messages: ModelMessage[] = [ + { role: 'user', content: 'do both' }, + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: {} }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'bar', input: {} }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'r1' }, + }, + { + type: 'tool-result', + toolCallId: 'c2', + toolName: 'bar', + output: { type: 'json', value: { n: 2 } }, + }, + ], + }, + ]; + const ir = fromModelMessages(messages).filter((m) => m.role === 'tool'); + expect(ir).toHaveLength(2); + expect(ir[0]).toMatchObject({ content: 'r1', tool_call_id: 'c1' }); + expect(ir[1]).toMatchObject({ content: '{"n":2}', tool_call_id: 'c2' }); + }); +}); + +describe('round-trip (ModelMessage → IR → ModelMessage)', () => { + // Fixtures are valid histories (lead with user; every tool-result has a + // preceding assistant tool-call) so ensureValidHistory is a no-op and the + // round-trip is verbatim — same discipline as tests/adapter.test.ts. + const cases: Record = { + 'string content stays a string': [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'yo' }, + ], + 'array content with text + file': [ + { + role: 'user', + content: [ + { type: 'text', text: 'see' }, + { type: 'file', data: 'd', mediaType: 'image/png' }, + ], + }, + ], + 'image parts': [ + { role: 'user', content: [{ type: 'image', image: 'imgdata', mediaType: 'image/png' }] }, + ], + 'reasoning byte-exact': [ + { role: 'user', content: 'reason' }, + { + role: 'assistant', + content: [ + { type: 'reasoning', text: 'exact reasoning bytes' }, + { type: 'text', text: 'final' }, + ], + }, + ], + 'tool call + result': [ + { role: 'user', content: 'search' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: { q: 'x' } }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'ok' }, + }, + ], + }, + ], + 'assistant tool-approval-request rides through verbatim': [ + { role: 'user', content: 'approve?' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'need approval' }, + { type: 'tool-approval-request', approvalId: 'a1', toolCallId: 'c1' }, + ], + }, + ], + 'tool-approval-response preserved in order after its result': [ + { role: 'user', content: 'run' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: {} }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'ok' }, + }, + { type: 'tool-approval-response', approvalId: 'a1', approved: true }, + ], + }, + ], + 'tool-approval-response BEFORE its result (leading approval, pending buffer)': [ + { role: 'user', content: 'run' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: {} }], + }, + { + role: 'tool', + content: [ + { type: 'tool-approval-response', approvalId: 'a1', approved: true }, + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'ok' }, + }, + ], + }, + ], + 'tool parts interleaved (result, approval, result) preserved in order': [ + { role: 'user', content: 'two tools' }, + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: {} }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'bar', input: {} }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'r1' }, + }, + { type: 'tool-approval-response', approvalId: 'a1', approved: true }, + { + type: 'tool-result', + toolCallId: 'c2', + toolName: 'bar', + output: { type: 'text', value: 'r2' }, + }, + ], + }, + ], + 'providerOptions on a message': [ + { + role: 'user', + content: [{ type: 'text', text: 'hi' }], + providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, + }, + ], + 'inline (provider-executed) tool-result rides through without a placeholder': [ + { role: 'user', content: 'search the web' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Searching.' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'web_search', input: { q: 'cc' } }, + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'web_search', + output: { type: 'text', value: 'inline result' }, + }, + ], + }, + ], + 'inline tool-result alongside a separately-answered call': [ + { role: 'user', content: 'two things' }, + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NY' } }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'web_search', input: { q: 'x' } }, + { + type: 'tool-result', + toolCallId: 'c2', + toolName: 'web_search', + output: { type: 'text', value: 'inline answer' }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'get_weather', + output: { type: 'text', value: 'Sunny' }, + }, + ], + }, + ], + 'tool-message-level providerOptions preserved': [ + { role: 'user', content: 'run it' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'run', input: {} }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'run', + output: { type: 'text', value: 'ok' }, + }, + ], + providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }, + }, + ], + }; + + for (const [name, original] of Object.entries(cases)) { + it(name, () => { + const roundTripped = toModelMessages(fromModelMessages(original)); + expect(roundTripped).toEqual(original); + }); + } +}); + +describe('toModelMessages', () => { + it('emits a text-part array for synthetic messages (e.g. summary) with no pass-through', () => { + const result = toModelMessages([{ role: 'user', content: 'summary text' }]); + expect(result).toEqual([{ role: 'user', content: [{ type: 'text', text: 'summary text' }] }]); + }); + + it('reconstructs from IR fields when content was modified (e.g. cleared tool result)', () => { + const ir = fromModelMessages([ + { role: 'user', content: 'run' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'run', input: {} }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'run', + output: { type: 'text', value: 'long' }, + }, + ], + }, + ]); + const toolIr = ir.find((m) => m.role === 'tool'); + if (!toolIr) throw new Error('expected tool IR message'); + toolIr.content = '[cleared]'; // simulate Janitor edit + const result = toModelMessages(ir); + const toolMsg = result.find((m) => m.role === 'tool'); + if (toolMsg?.role !== 'tool') throw new Error('expected a tool message'); + const part = toolMsg.content[0]; + if (part.type !== 'tool-result') throw new Error('expected a tool-result part'); + expect(part.output).toEqual({ type: 'text', value: '[cleared]' }); + expect(part.toolName).toBe('run'); + }); + + it('keeps empty-string content as a string (not a text-part array)', () => { + const result = toModelMessages([ + { role: 'user', content: '', _mmUserContent: '', _mmOriginalText: '' } as Message, + ]); + expect(result).toEqual([{ role: 'user', content: '' }]); + }); + + it('drops co-located approval parts when the tool result is modified (lossy by design)', () => { + const ir = fromModelMessages([ + { role: 'user', content: 'run' }, + { + role: 'assistant', + content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'foo', input: {} }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'foo', + output: { type: 'text', value: 'ok' }, + }, + { type: 'tool-approval-response', approvalId: 'a1', approved: true }, + ], + }, + ]); + const toolIr = ir.find((m) => m.role === 'tool'); + if (!toolIr) throw new Error('expected tool IR message'); + toolIr.content = '[cleared]'; // simulate a Janitor edit → modified rebuild path + const result = toModelMessages(ir); + const toolMsg = result.find((m) => m.role === 'tool'); + if (toolMsg?.role !== 'tool') throw new Error('expected a tool message'); + expect(toolMsg.content).toHaveLength(1); + expect(toolMsg.content[0].type).toBe('tool-result'); + }); +}); diff --git a/packages/ai-sdk-middleware/tests/summarizeMessages.test.ts b/packages/ai-sdk-middleware/tests/summarizeMessages.test.ts index db07c5f..e1b56f6 100644 --- a/packages/ai-sdk-middleware/tests/summarizeMessages.test.ts +++ b/packages/ai-sdk-middleware/tests/summarizeMessages.test.ts @@ -7,9 +7,44 @@ import type { LanguageModelV3Prompt, LanguageModelV3StreamPart, } from '@ai-sdk/provider'; +import type { ModelMessage } from 'ai'; import { describe, expect, it, vi } from 'vitest'; import { summarizeMessages } from '../src'; +import { summarizeModelMessages } from '../src/middleware'; + +/** Minimal V3 model whose summarization call returns a fixed string. A V3 model + * is a valid `LanguageModel`, so it exercises the widened model param too. */ +function createSummarizerModel(summaryText = 'SUMMARY'): LanguageModelV3 { + return { + specificationVersion: 'v3', + provider: 'test', + modelId: 'test-model', + supportedUrls: {}, + async doGenerate(_opts: LanguageModelV3CallOptions): Promise { + const content: LanguageModelV3Content[] = [{ type: 'text', text: summaryText }]; + const finishReason: LanguageModelV3FinishReason = { unified: 'stop', raw: undefined }; + return { + content, + finishReason, + warnings: [], + usage: { + inputTokens: { + total: 50, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 10, text: undefined, reasoning: undefined }, + }, + response: { id: 'id', timestamp: new Date(), modelId: 'test-model' }, + }; + }, + async doStream() { + throw new Error('not used'); + }, + }; +} function mockModel(text: string): LanguageModelV3 { const model: LanguageModelV3 = { @@ -159,3 +194,20 @@ describe('summarizeMessages', () => { expect(seen).not.toContain('SYSTEM_MARKER_DROP_ME'); }); }); + +describe('summarizeModelMessages', () => { + it('summarizes a ModelMessage slice into a string, dropping system messages', async () => { + const messages: ModelMessage[] = [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'first answer' }, + ]; + const text = await summarizeModelMessages(messages, createSummarizerModel('RECAP')); + expect(text).toBe('RECAP'); + }); + + it('returns empty string for an empty slice without a model call', async () => { + const text = await summarizeModelMessages([], createSummarizerModel()); + expect(text).toBe(''); + }); +});