import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveCompactSpec, resolveConfig, resolveTargetPolicy, } from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, LlmFailure, LlmModelContext, Message, StreamChunk, } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' const SIGNAL = new AbortController().signal const MODEL = 'test-model' class ContextAdapter extends LlmAdapter { constructor(private readonly contextWindow: number) { super() } override resolveModelContext(): Promise { return Promise.resolve({ contextWindow: this.contextWindow }) } override async * stream(): AsyncIterable { yield { type: 'finish', reason: { kind: 'stop' } } } } class RoutedContextAdapter extends LlmAdapter { constructor(private readonly windows: Readonly>) { super() } override resolveModelContext(provider: string): Promise { const contextWindow = this.windows[provider] return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) } override async * stream(): AsyncIterable { yield { type: 'finish', reason: { kind: 'stop' } } } } function createContext(contextWindow = 1_000): Context { const ctx = new Context() void new LlmService(ctx) void new TokenMeterService(ctx) ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow)) return ctx } function agent(session: Session, model?: string): Agent { return { session, options: model === undefined ? {} : { provider: model, model } } as Agent } /** Flatten every text fragment the summarizer received, recursing tool-result blocks. */ function summarizedText(input: SummarizationInput): string { const collect = (blocks: readonly ContentBlock[]): string => blocks.map(block => block.type === 'text' ? block.text : block.type === 'tool-result' ? collect(block.content) : '').join('\n') return input.messages.map(message => collect(message.content)).join('\n') } /** A minimal replayed prefix carrying one user message of the given text. */ function promptInput(text: string): SummarizationInput { return { messages: [{ role: 'user', content: [{ type: 'text', text }] }] } } /** Closed two-message turns followed by one open turn for durable compaction events. */ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { const session = new Session(SessionId(`conversation-${turns}`)) for (let turn = 1; turn <= turns; turn += 1) { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: `${text} user ${turn}` }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) session.append('step/start', { turn, step: 1 }) if (turn === 1) { session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, reason: 'initial', }) } session.append('assistant/message', { provenance: { provider: MODEL, model: MODEL }, turn, step: 1, content: [{ type: 'text', text: `${text} assistant ${turn}` }], }, { surfaceOp: 'append' }) session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } }, }) return session } function toolConversation(): Session { const session = new Session(SessionId('tools')) for (let turn = 1; turn <= 3; turn += 1) { const callId = CallId(`call-${turn}`) session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: `request ${turn} `.repeat(300) }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) session.append('step/start', { turn, step: 1 }) if (turn === 1) { session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, reason: 'initial', }) } session.append('assistant/message', { provenance: { provider: MODEL, model: MODEL }, turn, step: 1, content: [ { type: 'text', text: `calling ${turn} `.repeat(300) }, { type: 'tool-call', id: callId, name: 'read', arguments: '{}' }, ], }, { surfaceOp: 'append' }) session.append('tool/call', { turn, step: 1, callId, name: 'read', arguments: '{}' }) session.append('tool/result', { turn, step: 1, callId, content: [{ type: 'text', text: `result ${turn} `.repeat(300) }], isError: false, }, { surfaceOp: 'append' }) session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) return session } /** One closed routed tool step followed by an open turn for rewrite events. */ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session { const session = new Session(SessionId(`oversized-tool-${chars}`)) const callId = CallId('oversized') session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) if (withCompactablePrompt) { session.append('user/message', { content: [{ type: 'text', text: 'older history '.repeat(200) }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) } session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, reason: 'initial', }) session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }], provenance: { provider: MODEL, model: MODEL }, }, { surfaceOp: 'append' }) session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' }) session.append('tool/result', { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'X'.repeat(chars) }], isError: false, meta: { presentation: 'preserved' }, }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) return session } class TestCompactService extends BasicCompactService { summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }] summaryProvider = 'summary-provider' summaryModel = 'summary-model' error: unknown mutateDuringSummary: (() => void) | undefined calls: Array<{ input: SummarizationInput; signal: AbortSignal | undefined }> = [] override async summarize( input: SummarizationInput, _agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { this.calls.push({ input, signal }) this.mutateDuringSummary?.() if (this.error !== undefined) throw this.error return { summary: this.summary, provider: this.summaryProvider, model: this.summaryModel, maxTokens: 123, } } } function service( config: BasicCompactConfig = { auto: false }, ctx = createContext(), ): TestCompactService { return new TestCompactService(ctx, config) } async function compactIfNeeded( compact: BasicCompactService, session: Session, trigger: 'pressure' | 'context-overflow' = 'pressure', model: string | undefined = MODEL, ): Promise { return compact.compactIfNeeded(agent(session, model), trigger, SIGNAL) } describe('compact configuration and defaults', () => { it('uses low-friction service-wide defaults', () => { const resolved = resolveConfig({}) expect(resolved).toEqual({ thresholdRatio: 0.8, retainRatio: 0.16, summarizationProvider: '', summarizationModel: '', maxTokens: 8192, compactionRetries: 1, maxOverflowRetries: 1, modelPolicies: [], auto: true, }) expect(Object.isFrozen(resolved)).toBe(true) }) it('resolves threshold and retention overrides independently', () => { const thresholdOnly = resolveConfig({ thresholdRatio: 0.5, }) expect(thresholdOnly).toMatchObject({ thresholdRatio: 0.5, retainRatio: 0.16, }) const retentionOnly = resolveConfig({ retainTokens: 70, }) expect(retentionOnly).toMatchObject({ thresholdRatio: 0.8, retainTokens: 70, }) expect(retentionOnly).not.toHaveProperty('retainRatio') }) it('merges exact provider/model policy overrides and scales ratios per model', () => { const config = resolveConfig({ thresholdRatio: 0.8, retainRatio: 0.1, modelPolicies: [{ provider: 'small-provider', model: 'shared-id', thresholdRatio: 0.5, retainTokens: 120, }], }) const small = resolveTargetPolicy(config, { provider: 'small-provider', model: 'shared-id', }) const otherProvider = resolveTargetPolicy(config, { provider: 'large-provider', model: 'shared-id', }) expect(resolveCompactSpec(small, 1_000)).toMatchObject({ thresholdTokens: 500, retainTokens: 120, }) expect(resolveCompactSpec(otherProvider, 2_000)).toMatchObject({ thresholdTokens: 1_600, retainTokens: 200, }) const ratioOverride = resolveTargetPolicy(resolveConfig({ retainTokens: 200, modelPolicies: [{ provider: 'ratio-provider', model: 'ratio-model', thresholdRatio: 0.6, retainRatio: 0.2, summarizationProvider: 'summary-provider', summarizationModel: 'summary-model', maxTokens: 512, compactionRetries: 2, maxOverflowRetries: 3, }], }), { provider: 'ratio-provider', model: 'ratio-model' }) expect(resolveCompactSpec(ratioOverride, 2_000)).toMatchObject({ thresholdTokens: 1_200, retainTokens: 400, summarizationProvider: 'summary-provider', summarizationModel: 'summary-model', maxTokens: 512, compactionRetries: 2, maxOverflowRetries: 3, }) }) it('inherits, clears, and replaces the summarization target as a pair', () => { const config = resolveConfig({ summarizationProvider: 'default-provider', summarizationModel: 'default-model', modelPolicies: [ { provider: 'inherit-provider', model: MODEL }, { provider: 'clear-provider', model: MODEL, summarizationProvider: '', summarizationModel: '', }, { provider: 'replace-provider', model: MODEL, summarizationProvider: 'replacement-provider', summarizationModel: 'replacement-model', }, ], }) expect(resolveTargetPolicy(config, { provider: 'inherit-provider', model: MODEL })) .toMatchObject({ summarizationProvider: 'default-provider', summarizationModel: 'default-model', }) expect(resolveTargetPolicy(config, { provider: 'clear-provider', model: MODEL })) .toMatchObject({ summarizationProvider: '', summarizationModel: '' }) expect(resolveTargetPolicy(config, { provider: 'replace-provider', model: MODEL })) .toMatchObject({ summarizationProvider: 'replacement-provider', summarizationModel: 'replacement-model', }) }) it('validates common values and pressure-policy invariants', () => { const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], [{ maxOverflowRetries: -1 }, /maxOverflowRetries/], [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationProvider: 1 }, /summarizationProvider must be a string/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], [{ summarizationProvider: MODEL }, /must be set together/], [{ summarizationModel: MODEL }, /must be set together/], [{ summarizationProvider: '' }, /must be set together/], [{ summarizationModel: '' }, /must be set together/], [{ thresholdRatio: 0 }, /number in \(0, 1\]/], [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], [{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/], [{ thresholdRatio: 0.1 }, /retainRatio \(0.16\) must be less than the resolved thresholdRatio \(0.1\)/], [{ retainTokens: -1 }, /non-negative integer/], [{ retainRatio: 0.2, retainTokens: 100 }, /mutually exclusive/], [{ modelPolicies: {} }, /modelPolicies must be an array/], [{ modelPolicies: [1] }, /modelPolicies\[0\] must be an object/], [{ modelPolicies: [null] }, /modelPolicies\[0\] must be an object/], [{ modelPolicies: [[]] }, /modelPolicies\[0\] must be an object/], [{ modelPolicies: [{ provider: 1, model: MODEL }] }, /provider must be a non-empty string/], [{ modelPolicies: [{ provider: '', model: MODEL }] }, /provider must be a non-empty string/], [{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/], [{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/], [{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/], [{ summarizationProvider: 'default-provider', summarizationModel: 'default-model', modelPolicies: [{ provider: MODEL, model: MODEL, summarizationModel: '' }], }, /modelPolicies\[0\].*must be set together/], [{ summarizationProvider: 'default-provider', summarizationModel: 'default-model', modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: '' }], }, /modelPolicies\[0\].*must be set together/], [{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/], [ { modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] }, /modelPolicies\[0\]: retainRatio \(0.16\).*thresholdRatio \(0.1\)/, ], [ { modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.9 }] }, /modelPolicies\[0\]: retainRatio \(0.9\).*thresholdRatio \(0.8\)/, ], [{ modelPolicies: [{ provider: MODEL, model: MODEL }, { provider: MODEL, model: MODEL }] }, /duplicate model policy/], [{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/], [{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/], ] as Array<[unknown, RegExp]> for (const [config, pattern] of bad) { expect(() => resolveConfig(config as BasicCompactConfig)).toThrow(pattern) } const invalidPressure = resolveTargetPolicy(resolveConfig({ thresholdRatio: 0.5, retainTokens: 500, }), { provider: MODEL, model: MODEL }) expect(() => resolveCompactSpec(invalidPressure, 1_000)).toThrow(/less than threshold/) expect(() => resolveCompactSpec(invalidPressure, 1.5)).toThrow(/positive integer/) expect(() => resolveCompactSpec(invalidPressure, 0)).toThrow(/positive integer/) }) }) describe('pressure measurement and retention', () => { const compactConfig: BasicCompactConfig = { auto: false, thresholdRatio: 0.5, retainTokens: 180, } it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => { const compact = service(compactConfig) const session = new Session(SessionId('headerless')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL)) .resolves.toBeNull() expect(compact.calls).toHaveLength(0) }) it('meters an unlisted model when its provider adapter supplies context metadata', async () => { const compact = service(compactConfig) const session = conversation() session.append('request/header', { header: { config: { provider: 'unlisted-provider', model: 'unlisted-model' } }, reason: 'resume', }) await expect(compactIfNeeded(compact, session)) .resolves.not.toBeNull() }) it('re-resolves capacity after a same-model-id provider switch in one session', async () => { const ctx = new Context() void new LlmService(ctx) void new TokenMeterService(ctx) ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({ large: 10_000, small: 1_000, })) const compact = service({ auto: false, thresholdRatio: 0.5, retainRatio: 0.1, }, ctx) const session = conversation(4) session.append('request/header', { header: { config: { provider: 'large', model: 'shared-id' } }, reason: 'resume', }) await expect(compactIfNeeded(compact, session)).resolves.toBeNull() session.append('request/header', { header: { config: { provider: 'small', model: 'shared-id' } }, reason: 'change', }) await expect(compactIfNeeded(compact, session)).resolves.not.toBeNull() }) it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => { const ctx = new Context() void new LlmService(ctx) void new TokenMeterService(ctx) ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000)) vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) const compact = service(compactConfig, ctx) const session = conversation(4) session.append('request/header', { header: { config: { provider: 'unknown-context', model: 'model' } }, reason: 'resume', }) await expect(compactIfNeeded(compact, session, 'pressure')) .rejects.toThrow(/no context capacity for unknown-context\/model/) await expect(compactIfNeeded(compact, session, 'context-overflow')) .resolves.not.toBeNull() }) it('declines forced overflow when the whole surface is one indivisible tool pair', async () => { const compact = service(compactConfig) const session = new Session(SessionId('single-tool-pair')) const callId = CallId('single-call') session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, reason: 'initial', }) session.append('assistant/message', { provenance: { provider: MODEL, model: MODEL }, turn: 1, step: 1, content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }], }, { surfaceOp: 'append' }) session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' }) session.append('tool/result', { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'result' }], isError: false, }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) const generation = session.surface.replaceGeneration await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() expect(session.surface.replaceGeneration).toBe(generation) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) }) it('does nothing below threshold and compacts a priced head above threshold', async () => { const compact = service(compactConfig) expect(await compactIfNeeded(compact, conversation(2))).toBeNull() const session = conversation(4) const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() expect(result?.shadowedSeqs.length).toBeGreaterThan(2) expect(session.surface.nodes.length).toBeLessThan(8) }) it('counts the durable routed request envelope without putting its prefix on the surface', async () => { const compact = service({ auto: false, thresholdRatio: 0.9, retainTokens: 50, }) const session = conversation(2, 'x'.repeat(600)) expect(await compactIfNeeded(compact, session)).toBeNull() const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }] session.append('request/header', { header: { config: { provider: MODEL, model: MODEL }, system: 's'.repeat(600), messagePrefix: prefix, }, reason: 'resume', }) const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() expect(prefix).toHaveLength(1) // The routed request prefix must not reach the surface as its own message // (the compaction summary itself is an expected plugin-sourced checkpoint). expect(session.events.some(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false) }) it('uses the latest logged request envelope without an AgentOptions override', async () => { const ctx = createContext() const compact = service({ auto: false, thresholdRatio: 0.5, retainTokens: 180, }, ctx) const session = conversation(4) session.append('request/header', { header: { config: { provider: 'actual', model: 'actual' } }, reason: 'initial', }) const measure = vi.spyOn(ctx.tokenMeter, 'measure') const result = await compactIfNeeded(compact, session, 'pressure', 'fallback') expect(result).not.toBeNull() expect(session.requestHeader()?.config.model).toBe('actual') expect(measure.mock.calls[0]).toEqual([session]) }) it('declines when envelope pressure is high but the surface has no compactable range', async () => { const compact = service(compactConfig) const empty = new Session(SessionId('empty')) empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) empty.append('request/header', { header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) }, reason: 'initial', }) expect(await compactIfNeeded(compact, empty)).toBeNull() const retained = conversation(1) retained.append('request/header', { header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) }, reason: 'resume', }) expect(await compactIfNeeded(compact, retained)).toBeNull() }) it('uses one unified measurement for each pressure-and-retention decision', async () => { const ctx = createContext() const compact = service(compactConfig, ctx) const measure = vi.spyOn(ctx.tokenMeter, 'measure') const stop = new Error('stop after first decision') vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop) await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop) expect(measure).toHaveBeenCalledTimes(1) }) it('bounds retries when a shrinking checkpoint remains above threshold', async () => { const compact = service({ auto: false, compactionRetries: 0, thresholdRatio: 0.3, retainTokens: 180, }) compact.summary = Array.from({ length: 7 }, (_, index) => ({ type: 'text', text: `summary ${index}`, })) await expect(compactIfNeeded(compact, conversation(4))) .rejects.toThrow(/still above threshold after 1 compaction attempts/) }) it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => { const compact = service({ auto: false, thresholdRatio: 0.8, retainTokens: 80, }, createContext(4_000)) const session = toolConversation() const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() const messages = session.deriveMessages() const calls = new Set() for (const message of messages) { for (const block of message.content) { if (block.type === 'tool-call') calls.add(block.id) if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true) } } }) it('rejects a priced surface that is not the current positional surface', () => { const ctx = createContext() const session = conversation(2) const priced = ctx.tokenMeter.measure(session) expect(() => selectCompactableRange(session, { ...priced, nodes: priced.nodes.slice(1), }, 1)).toThrow(/does not match/) }) it('declines when rounding a cut would consume the only tool pair', () => { const ctx = createContext() const session = new Session(SessionId('one-tool-pair')) const callId = CallId('only') session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { provenance: { provider: MODEL, model: MODEL }, turn: 1, step: 1, content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }], }, { surfaceOp: 'append' }) session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' }) session.append('tool/result', { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'result' }], isError: false, }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) const priced = ctx.tokenMeter.measure(session) expect(selectCompactableRange(session, priced, 1)).toBeNull() }) }) describe('optional model-free tool-result pruning', () => { const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 } it('does not prune a below-pressure session opportunistically', async () => { const ctx = createContext(10_000) const prune = new ToolResultPruneService(ctx, pruneConfig) const compact = new TestCompactService(ctx, { auto: false, thresholdRatio: 0.8, retainTokens: 100, }) const session = oversizedToolResult() const pruneSession = vi.spyOn(prune, 'pruneSession') expect(await compactIfNeeded(compact, session)).toBeNull() expect(pruneSession).not.toHaveBeenCalled() expect(compact.calls).toHaveLength(0) expect(session.surface.replaceGeneration).toBe(0) }) it('skips LLM summarization when pruning alone clears pressure', async () => { const ctx = createContext(1_000) void new ToolResultPruneService(ctx, pruneConfig) const compact = new TestCompactService(ctx, { auto: false, thresholdRatio: 0.5, retainTokens: 50, }) const session = oversizedToolResult() expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500) expect(await compactIfNeeded(compact, session)).toBeNull() expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500) expect(compact.calls).toHaveLength(0) expect(session.surface.replaceGeneration).toBe(1) }) it('summarizes the pruned surface when pruning is insufficient', async () => { const ctx = createContext(2_000) void new ToolResultPruneService(ctx, pruneConfig) const compact = new TestCompactService(ctx, { auto: false, thresholdRatio: 0.5, retainTokens: 50, }) const session = toolConversation() expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') expect(summarizedText(compact.calls[0]!.input)).not.toContain('result 1 '.repeat(300)) }) it('retains the original compact-basic behavior without the optional plugin', async () => { const ctx = createContext(2_000) const compact = new TestCompactService(ctx, { auto: false, thresholdRatio: 0.5, retainTokens: 50, }) const session = oversizedToolResult(3_000, true) expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) const original = session.events.find(event => event.type === 'tool/result') expect(original?.type === 'tool/result' && original.data.content[0]) .toEqual({ type: 'text', text: 'X'.repeat(3_000) }) expect(session.events.filter(event => event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0) }) }) describe('compaction region transaction', () => { it('lands a framed, replayable checkpoint with exact pricing provenance', async () => { const compact = service() const session = conversation(3) const before = [...session.surface.nodes] const result = await compact.compactRegion( before[0]!, before[3]!, agent(session, MODEL), SIGNAL, ) expect(result.shadowedSeqs).toEqual(before.slice(0, 4)) expect(result.shadowedTokenCount).toBeGreaterThan(0) expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1') const summary = session.events.findLast(event => event.type === 'compact/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, shadowedTokenCount: result.shadowedTokenCount, provider: 'summary-provider', model: 'summary-model', maxTokens: 123, }) const head = session.deriveMessages()[0]! expect(head.content[0]?.type).toBe('text') expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) const replay = new Session(SessionId('replay'), [...session.events]) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) it('replays the latest routed header prefix so the summarizer reuses the cache', async () => { const compact = service() const session = conversation(3) const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }] session.append('request/header', { header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix }, reason: 'resume', }) const nodes = session.surface.nodes await compact.compactRegion(nodes[0]!, nodes[1]!, agent(session, MODEL), SIGNAL) const { input } = compact.calls[0]! expect(input.system).toBe('CONVERSATION SYSTEM') expect(input.tools).toEqual(tools) expect(input.messages[0]).toEqual(messagePrefix[0]) expect(summarizedText(input)).toContain('fixture user 1') }) it.each([ ['start missing', 9_001, undefined, /start seq 9001 not found/], ['end missing', undefined, 9_002, /end seq 9002 not found/], ])('rejects %s', async (_label, startOverride, endOverride, pattern) => { const compact = service() const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( startOverride ?? nodes[0]!, endOverride ?? nodes[1]!, agent(session, MODEL), )).rejects.toThrow(pattern) }) it('rejects reversed and tool-unbalanced positional boundaries', async () => { const compact = service() const plain = conversation(2) const nodes = plain.surface.nodes await expect(compact.compactRegion( nodes[2]!, nodes[1]!, agent(plain, MODEL), )).rejects.toThrow(/is after end/) const tools = toolConversation() const toolNodes = tools.surface.nodes await expect(compact.compactRegion( toolNodes[2]!, toolNodes[4]!, agent(tools, MODEL), )).rejects.toThrow(/start seq .* not a balanced boundary/) await expect(compact.compactRegion( toolNodes[0]!, toolNodes[1]!, agent(tools, MODEL), )).rejects.toThrow(/end seq .* not a balanced boundary/) }) it('requires an open turn and an idle compaction bracket', async () => { const compact = service() const closed = conversation(1) closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) const nodes = closed.surface.nodes await expect(compact.compactRegion( nodes[0]!, nodes[1]!, agent(closed, MODEL), )).rejects.toThrow(/no open turn/) const locked = conversation(1) locked.append('compact/start', { turn: 2 }) const lockedNodes = locked.surface.nodes await expect(compact.compactRegion( lockedNodes[0]!, lockedNodes[1]!, agent(locked, MODEL), )).rejects.toThrow(/already in progress/) }) it('rejects a session with no turn boundary at all', async () => { const compact = service() const session = new Session(SessionId('turnless')) session.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) const node = session.surface.nodes[0]! await expect(compact.compactRegion( node, node, agent(session, MODEL), )).rejects.toThrow(/no open turn/) }) it('rejects a meter snapshot that changed before summarization began', async () => { const ctx = createContext() const meter = ctx.tokenMeter const original = meter.measure.bind(meter) vi.spyOn(meter, 'measure').mockImplementationOnce((session) => { const measurement = original(session) return { ...measurement, nodes: measurement.nodes.slice(1) } }) const compact = service({ auto: false }, ctx) const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( nodes[0]!, nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/selected surface changed/) }) it('records summarizer failures without mutating the surface', async () => { const compact = service() compact.error = new Error('summary unavailable') const session = conversation(2) const before = session.surface.nodes await expect(compact.compactRegion( before[0]!, before[2]!, agent(session, MODEL), )).rejects.toThrow('summary unavailable') expect(session.surface.nodes).toEqual(before) expect(session.events.findLast(event => event.type === 'compact/end')?.data) .toMatchObject({ error: 'summary unavailable' }) }) it('stringifies non-Error failures in the durable end bracket', async () => { const compact = service() compact.error = 'plain failure' const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( nodes[0]!, nodes[2]!, agent(session, MODEL), )).rejects.toBe('plain failure') expect(session.events.findLast(event => event.type === 'compact/end')?.data) .toMatchObject({ error: 'plain failure' }) }) it('tolerates concurrent log-only appends while the selected surface is stable', async () => { const compact = service() const session = conversation(2) compact.mutateDuringSummary = () => { session.append('request/header', { header: { config: { provider: MODEL, model: MODEL } }, reason: 'change', }) } const nodes = session.surface.nodes await expect(compact.compactRegion( nodes[0]!, nodes[2]!, agent(session, MODEL), )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) }) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) }) it('rejects concurrent surface appends before committing the replacement', async () => { const compact = service() const session = conversation(2) compact.mutateDuringSummary = () => { session.append('user/message', { content: [{ type: 'text', text: 'concurrent surface mutation' }], source: { kind: 'plugin', plugin: 'test' }, }, { surfaceOp: 'append' }) } const nodes = session.surface.nodes await expect(compact.compactRegion( nodes[0]!, nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/session surface changed/) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) it('rejects a non-shrinking framed summary under the conversation meter', async () => { const compact = service() compact.summary = Array.from({ length: 100 }, (_, index) => ({ type: 'text', text: `verbose ${index}`, })) const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( nodes[0]!, nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/summary is not smaller/) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) it('lets a model-independent custom summarizer compact without a conversation model', async () => { const compact = service() const session = new Session(SessionId('model-less-region')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { content: [{ type: 'text', text: 'history '.repeat(100) }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) session.append('step/start', { turn: 1, step: 1 }) session.append('assistant/message', { provenance: { provider: 'historical', model: 'historical' }, turn: 1, step: 1, content: [{ type: 'text', text: 'answer '.repeat(100) }], }, { surfaceOp: 'append' }) session.append('step/end', { turn: 1, step: 1 }) const nodes = session.surface.nodes await expect(compact.compactRegion( nodes[0]!, nodes[1]!, agent(session), )).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!, nodes[1]!] }) }) }) class ScriptedAdapter extends LlmAdapter { lastOptions: GenerateOptions | undefined constructor( private readonly blocks: readonly ContentBlock[], private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' }, ) { super() } override async * stream(options: GenerateOptions): AsyncIterable { this.lastOptions = options for (const [index, block] of this.blocks.entries()) { yield { type: 'block-start', index, blockType: block.type } if (block.type === 'text') { yield { type: 'text-delta', index, text: block.text } } else if (block.type === 'reasoning') { yield { type: 'reasoning-delta', index, text: block.text } } else { yield { type: 'block-end', index, block } } } yield { type: 'finish', reason: this.finish } } } class ExposedCompactService extends BasicCompactService { runSummarize( input: SummarizationInput, owner: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { return this.summarize(input, owner, signal) } } async function summarizerHarness( blocks: readonly ContentBlock[], finish?: (StreamChunk & { type: 'finish' })['reason'], model = MODEL, config: BasicCompactConfig = { auto: false }, ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) void new TokenMeterService(ctx) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) const compact = new ExposedCompactService(ctx, config) return { ctx, adapter, compact } } describe('default one-shot summarizer', () => { it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => { const { adapter, compact } = await summarizerHarness([ { type: 'reasoning', text: 'private' }, { type: 'text', text: 'public summary' }, { type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' }, ], undefined, MODEL, { auto: false, summarizationProvider: MODEL, summarizationModel: MODEL, maxTokens: 321, }) const session = conversation(1) const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL) expect(output).toEqual({ summary: [{ type: 'text', text: 'public summary' }], provider: MODEL, model: MODEL, maxTokens: 321, }) expect(adapter.lastOptions).toMatchObject({ provider: MODEL, model: MODEL, maxTokens: 321, signal: SIGNAL, sessionId: session.id, purpose: 'compaction', }) const instruction = adapter.lastOptions?.messages.at(-1)?.content[0] expect(instruction?.type === 'text' ? instruction.text : '').toContain('## Primary Request and Intent') }) it('replays the conversation prefix and appends the instruction as the final message', async () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] } await compact.runSummarize({ system: 'REPLAYED SYSTEM', tools, messages: [prefix], }, agent(conversation(1), MODEL)) expect(adapter.lastOptions?.system).toBe('REPLAYED SYSTEM') expect(adapter.lastOptions?.tools).toEqual(tools) const messages = adapter.lastOptions?.messages ?? [] expect(messages[0]).toEqual(prefix) const last = messages.at(-1)?.content[0] const lastText = last?.type === 'text' ? last.text : '' expect(lastText).toContain('Condense the conversation ABOVE') expect(lastText).toContain('## Primary Request and Intent') }) it('applies the routed model policy without changing the replayed prefix', async () => { const { ctx, compact } = await summarizerHarness( [{ type: 'text', text: 'unused default summary' }], undefined, MODEL, { auto: false, maxTokens: 111, modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 'policy-summary', summarizationModel: 'policy-summary', maxTokens: 222, }], }, ) const policyAdapter = new ScriptedAdapter([{ type: 'text', text: 'policy summary' }]) ctx.llm.registerAdapter(['policy-summary'], policyAdapter) const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'warm prefix' }] } const output = await compact.runSummarize({ system: 'WARM SYSTEM', messages: [prefix], }, agent(conversation(1), 'fallback')) expect(output).toMatchObject({ provider: 'policy-summary', model: 'policy-summary', maxTokens: 222, }) expect(policyAdapter.lastOptions).toMatchObject({ provider: 'policy-summary', model: 'policy-summary', maxTokens: 222, system: 'WARM SYSTEM', }) expect(policyAdapter.lastOptions?.messages[0]).toEqual(prefix) }) it('resolves the latest routed provider/model before the AgentOptions pair', async () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed') const session = conversation(1) session.append('request/header', { header: { config: { provider: 'routed', model: 'routed' } }, reason: 'initial', }) const output = await compact.runSummarize(promptInput('history'), agent(session, 'fallback')) expect(output.provider).toBe('routed') expect(output.model).toBe('routed') expect(adapter.lastOptions?.provider).toBe('routed') expect(adapter.lastOptions?.model).toBe('routed') }) it('records the model actually dispatched after one-shot stream routing', async () => { const { ctx, compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) const routedAdapter = new ScriptedAdapter([{ type: 'text', text: 'routed summary' }]) ctx.llm.registerAdapter(['routed-summary-provider'], routedAdapter) ctx.on('llm/stream', (options, next) => { options.provider = 'routed-summary-provider' options.model = 'routed-summary-model' return next() }) const session = conversation(3, 'large history '.repeat(500)) const nodes = session.surface.nodes await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL) expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({ summary: [{ type: 'text', text: 'routed summary' }], provider: 'routed-summary-provider', model: 'routed-summary-model', }) expect(routedAdapter.lastOptions?.provider).toBe('routed-summary-provider') expect(routedAdapter.lastOptions?.model).toBe('routed-summary-model') }) it('fails clearly when no complete summarization target can be resolved', async () => { const ctx = new Context() await ctx.plugin(LlmService) void new TokenMeterService(ctx) const compact = new ExposedCompactService(ctx, { auto: false }) await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less'))))) .rejects.toThrow(/no provider\/model available for summarization/) }) it('uses a complete AgentOptions target when no durable route exists', async () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) const session = new Session(SessionId('headerless-summary')) await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({ provider: MODEL, model: MODEL, }) expect(adapter.lastOptions).toMatchObject({ provider: MODEL, model: MODEL }) }) it.each([ { provider: '', model: MODEL }, { provider: MODEL }, { provider: MODEL, model: '' }, ])('rejects incomplete AgentOptions target %#', async (options) => { const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) const owner = { session: new Session(SessionId(`incomplete-${String(options.model)}`)), options, } as Agent await expect(compact.runSummarize(promptInput('history'), owner)) .rejects.toThrow(/no provider\/model available for summarization/) }) it.each([ [{ kind: 'error', failure: { message: 'provider failed', code: 'PROVIDER' } }, 'PROVIDER', /provider failed/], [{ kind: 'error', failure: { message: 'opaque', code: 'UNKNOWN' } }, 'UNKNOWN', /opaque/], [{ kind: 'aborted', failure: { message: 'summarization aborted', code: 'ABORTED' } }, 'ABORTED', /aborted/], [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/], ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) ( 'rejects terminal finish %#', async (finish, code, pattern) => { const { compact } = await summarizerHarness([], finish) let thrown: unknown try { await compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)) } catch (error: unknown) { thrown = error } expect(thrown).toBeInstanceOf(Error) expect((thrown as Error).message).toMatch(pattern) expect((thrown as Error & { code?: string }).code).toBe(code) }, ) it('rejects empty or reasoning-only successful output', async () => { const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }]) await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) }) describe('automatic listener and loader composition', () => { function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise { return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal) } function recover( ctx: Context, owner: Agent, error: Error & { code?: string }, retryAttempt = 0, signal = SIGNAL, next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }), ): Promise<{ action: 'fail' | 'retry' }> { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure)) return agentEvents(ctx, owner).waterfall( 'agent/request-error', 1, 1, error, failure, priorFailures, signal, next, ) } function overflow(message = 'provider overflow'): Error & { code: string } { return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE }) } it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, }) const pressured = conversation(4) await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true) const small = conversation(1) await postStep(ctx, agent(small, MODEL)) expect(small.events.some(event => event.type === 'compact/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) it('skips post-step pressure when the step signal is already aborted', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, }) const pressured = conversation(4) const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded') await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted'))) .resolves.toBeUndefined() expect(compactIfNeeded).not.toHaveBeenCalled() expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false) }) it('warns and continues after operational failures, including non-Errors', async () => { const ctx = createContext() const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, }) compact.error = 'temporary failure' const session = conversation(4) await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) it('warns once per routed target when proactive pressure has no context metadata', async () => { const ctx = createContext() const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) void new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, }) const session = conversation(4) await postStep(ctx, agent(session, MODEL)) await postStep(ctx, agent(session, MODEL)) expect(warnings).toEqual([ expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`), ]) }) it('warns once per routed target when absolute retention exceeds its resolved threshold', async () => { const ctx = createContext() const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn void new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 500, }) const session = conversation(4) await postStep(ctx, agent(session, MODEL)) await postStep(ctx, agent(session, MODEL)) expect(warnings).toEqual([ expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'), ]) }) it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => { const ctx = createContext(10_000) void new TestCompactService(ctx, { thresholdRatio: 1, retainTokens: 900, }) const session = conversation(3) const beforeGeneration = session.surface.replaceGeneration const retainedSeq = session.surface.nodes.at(-1)! const threshold = 10_000 expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold) const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow()) expect(decision).toEqual({ action: 'retry' }) expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) expect(session.surface.nodes).toContain(retainedSeq) }) it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => { const ctx = createContext(10_000) void new ToolResultPruneService(ctx, { thresholdChars: 100, headChars: 20, tailChars: 10, }) const compact = new TestCompactService(ctx, { thresholdRatio: 1, retainTokens: 900, }) const session = oversizedToolResult() expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) expect(session.surface.replaceGeneration).toBe(1) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) expect(compact.calls).toHaveLength(0) }) it('continues overflow recovery with summarization on the pruned surface', async () => { const ctx = createContext(10_000) void new ToolResultPruneService(ctx, { thresholdChars: 100, headChars: 20, tailChars: 10, }) const compact = new TestCompactService(ctx, { thresholdRatio: 1, retainTokens: 900, }) const session = toolConversation() expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) expect(compact.calls).toHaveLength(1) expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') }) it('retries from a durable prune when later overflow summarization throws', async () => { const ctx = createContext(10_000) const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn void new ToolResultPruneService(ctx, { thresholdChars: 100, headChars: 20, tailChars: 10, }) const compact = new TestCompactService(ctx, { thresholdRatio: 1, retainTokens: 900, }) compact.error = new Error('summary unavailable after prune') const session = oversizedToolResult(3_000, true) expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) expect(session.surface.replaceGeneration).toBe(1) expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2) expect(session.events.findLast(event => event.type === 'compact/end')?.data) .toMatchObject({ error: 'summary unavailable after prune' }) expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface')) }) it('lets cancellation win when summary throws after a durable prune', async () => { const ctx = createContext(10_000) const controller = new AbortController() void new ToolResultPruneService(ctx, { thresholdChars: 100, headChars: 20, tailChars: 10, }) const compact = new TestCompactService(ctx, { thresholdRatio: 1, retainTokens: 900, }) compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') } compact.error = new Error('summary cancelled after prune') const session = oversizedToolResult(3_000, true) expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal)) .toEqual({ action: 'fail' }) expect(session.surface.replaceGeneration).toBe(1) }) it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => { const ctx = createContext() void new TestCompactService(ctx, { thresholdRatio: 1, retainTokens: 90, }) const session = toolConversation() const newestAssistant = session.surface.nodes.at(-2)! const newestResult = session.surface.nodes.at(-1)! expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) const currentAssistant = session.surface.nodes.find(node => node === newestAssistant) const currentResult = session.surface.nodes.find(node => node === newestResult) expect(currentAssistant).toBeDefined() expect(currentResult).toBeDefined() expect(toolPairingBalancedBefore(session, currentAssistant!)).toBe(true) expect(toolPairingBalancedAfter(session, currentResult!)).toBe(true) }) it('does not retry when a backend reports success without replacing the surface', async () => { const ctx = createContext() const compact = new TestCompactService(ctx) const session = conversation(2) const fakeResult: CompactionResult = { startSeq: 1, summarySeq: 2, endSeq: 3, summary: [{ type: 'text', text: 'fake' }], shadowedRange: { start: 1, end: 2 }, shadowedSeqs: [1, 2], shadowedTokenCount: 10, } vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult) expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) expect(session.surface.replaceGeneration).toBe(0) }) it('delegates downstream exactly once when no replacement is available', async () => { const ctx = createContext() const compact = new TestCompactService(ctx) vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(null) const downstream = new Error('downstream recovery failed') let calls = 0 await expect(recover( ctx, agent(conversation(2), MODEL), overflow(), 0, SIGNAL, () => { calls += 1 return Promise.reject(downstream) }, )).rejects.toBe(downstream) expect(calls).toBe(1) }) it('preserves the original provider error when recovery throws', async () => { const ctx = createContext() const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn const compact = new TestCompactService(ctx) compact.error = new Error('summary unavailable') const original = overflow('original provider overflow') expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' }) expect(original).toMatchObject({ message: 'original provider overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE, }) expect(warnings).toContainEqual(expect.stringContaining('preserving the original request error')) }) it('delegates once when overflow recovery throws a non-Error value', async () => { const ctx = createContext() const warnings: string[] = [] ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn const compact = new TestCompactService(ctx) compact.error = 'non-error recovery failure' const session = conversation(3) const generation = session.surface.replaceGeneration const original = overflow('original provider failure') let delegations = 0 const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => { delegations += 1 return Promise.resolve({ action: 'fail' }) }) expect(decision).toEqual({ action: 'fail' }) expect(delegations).toBe(1) expect(session.surface.replaceGeneration).toBe(generation) expect(original).toMatchObject({ message: 'original provider failure', code: CONTEXT_WINDOW_EXCEEDED_CODE, }) expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure')) }) it('recovers an overflow for an unlisted routed model', async () => { const ctx = createContext() void new TestCompactService(ctx) const session = conversation(2) session.append('request/header', { header: { config: { provider: 'unknown-routed-provider', model: 'unknown-routed-model' } }, reason: 'resume', }) expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow'))) .toEqual({ action: 'retry' }) }) it('delegates canonical overflow when no durable routed target exists', async () => { const ctx = createContext() void new TestCompactService(ctx) const session = new Session(SessionId('headerless-overflow')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } }, }) await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' }) }) it('honors retry caps, non-context failures, and cancellation', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 }) const compactSpy = vi.spyOn(compact, 'compactIfNeeded') const owner = agent(conversation(3), MODEL) expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' }))) .toEqual({ action: 'fail' }) expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' }) const controller = new AbortController() controller.abort('cancelled') expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' }) expect(compactSpy).not.toHaveBeenCalled() }) it('applies the routed model override to the overflow retry cap', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { maxOverflowRetries: 2, modelPolicies: [{ provider: MODEL, model: MODEL, maxOverflowRetries: 1, }], }) const compactSpy = vi.spyOn(compact, 'compactIfNeeded') expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1)) .toEqual({ action: 'fail' }) expect(compactSpy).not.toHaveBeenCalled() }) it('does not retry when cancellation lands during an awaited compaction', async () => { const ctx = createContext() const compact = new TestCompactService(ctx) const controller = new AbortController() compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') } const session = conversation(3) const generation = session.surface.replaceGeneration expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal)) .toEqual({ action: 'fail' }) expect(session.surface.replaceGeneration).toBe(generation + 1) }) it('maxOverflowRetries:0 disables recovery without disabling post-step pressure', async () => { const ctx = createContext() void new TestCompactService(ctx, { maxOverflowRetries: 0, thresholdRatio: 0.5, retainTokens: 180, }) const session = conversation(4) await postStep(ctx, agent(session, MODEL)) const summaries = session.events.filter(event => event.type === 'compact/summary').length expect(summaries).toBe(1) expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries) }) it('auto:false installs neither automatic listener', async () => { const ctx = createContext() void new TestCompactService(ctx, { auto: false, thresholdRatio: 0.5, retainTokens: 180, }) const session = conversation(4) await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) }) it('loads and disposes the real zero-config service stack', async () => { const ctx = new Context() await ctx.plugin(LlmService) const meterFiber = await ctx.plugin(TokenMeterService) const compactFiber = await ctx.plugin(BasicCompactService, { auto: false }) expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) await compactFiber.dispose() expect(ctx.get('compact')).toBeUndefined() await meterFiber.dispose() expect(ctx.get('tokenMeter')).toBeUndefined() }) it('removes its automatic listener with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(TokenMeterService) const fiber = await ctx.plugin(TestCompactService, { thresholdRatio: 0.5, retainTokens: 180, }) await fiber.dispose() const session = conversation(4) await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) }) })