mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
The session already derives the protected system head as a Message, and both adapters accept leading system history. Passing it through a separate SummarizationInput.system string unnecessarily flattens that value and rebuilds the same wire message in the adapter. Prepend the derived head to messages and remove textContent, the separate field, and GenerateOptions.system plumbing from the summarizer. Keep range selection, shadowed seq accounting, session head protection, routed tools, image references, target policy, and the model-visible compaction instruction unchanged. Empty-content heads still derive to null and contribute no request message, but their surface node remains protected. Update subclass consumers/tests, EN/ZH package and subsystem prose, and the existing system-prompt surface owning note with refreshed pairing records. Evidence: pnpm exec vitest run packages/compaction/compaction-basic/tests packages/llm/llm-deepseek/tests/serialize.spec.ts packages/llm/llm-pi-ai/tests/context.spec.ts --coverage --coverage.include='packages/compaction/compaction-basic/src/region.ts' --coverage.include='packages/compaction/compaction-basic/src/summarizer.ts' passed 203 tests in 6 files; both changed sources have 100% statements, branches, functions, and lines. Region-to-default-summarizer cases pin exact prefix and tools for nonempty Unicode/multiline, empty, and absent heads. DeepSeek JSON byte equality and pi-ai context equality pin leading-message vs separate-system equivalence on text and image-capable conversion paths. pnpm run doc-sync passed all 33 gates including doc-typecheck, documentation build, translation pairing and model-experience checks. git diff --check passed. Own dependencies installed with pnpm install --frozen-lockfile. An initial test iteration used a nonexistent ctx.dispose teardown on the in-memory fixture; corrected to its existing fixture lifecycle and reran successfully. No runtime/model behavior, normalizer marker, main worktree, push, or rebase changes.
2139 lines
81 KiB
TypeScript
2139 lines
81 KiB
TypeScript
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
|
import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic'
|
|
import type { BasicCompactionConfig } from '@deepseek-ai/dsh-compaction-basic'
|
|
import { selectCompactableRange } from '@deepseek-ai/dsh-compaction-basic/src/region.ts'
|
|
import { frameSummary } from '@deepseek-ai/dsh-compaction-basic/src/summarizer.ts'
|
|
import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compaction-basic/src/summarizer.ts'
|
|
import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
|
|
import {
|
|
resolveCompactSpec,
|
|
resolveConfig,
|
|
resolveTargetPolicy,
|
|
} from '@deepseek-ai/dsh-compaction-basic/src/config.ts'
|
|
import type { CompactionResult } from '@deepseek-ai/dsh-compaction'
|
|
import LlmRuntime, { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, createSystemMessage, createToolResultMessage, LlmAdapter , createMessage } from '@deepseek-ai/dsh-llm'
|
|
import type {
|
|
ContentBlock,
|
|
GenerateOptions,
|
|
LlmFailure,
|
|
LlmResolvedModelInfo,
|
|
Message,
|
|
StreamChunk,
|
|
TokenUsage,
|
|
} from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
|
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
|
import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
|
import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
|
|
const SIGNAL = new AbortController().signal
|
|
const MODEL = 'test-model'
|
|
|
|
class ContextAdapter extends LlmAdapter {
|
|
constructor(private readonly contextWindow: number) {
|
|
super()
|
|
}
|
|
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
return Promise.resolve({
|
|
provider,
|
|
id: model,
|
|
name: model,
|
|
context: { contextWindow: this.contextWindow },
|
|
})
|
|
}
|
|
|
|
override async * stream(): AsyncIterable<StreamChunk> {
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
}
|
|
}
|
|
|
|
class RoutedContextAdapter extends LlmAdapter {
|
|
constructor(private readonly windows: Readonly<Record<string, number>>) {
|
|
super()
|
|
}
|
|
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
const contextWindow = this.windows[provider]
|
|
return Promise.resolve({
|
|
provider,
|
|
id: model,
|
|
name: model,
|
|
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
|
})
|
|
}
|
|
|
|
override async * stream(): AsyncIterable<StreamChunk> {
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
}
|
|
}
|
|
|
|
function createContext(contextWindow = 1_000): Context {
|
|
const ctx = new Context()
|
|
void new LlmRuntime(ctx)
|
|
// The registry is a required injection of TokenMeter (its three
|
|
// projection units register in the constructor); mount it synchronously.
|
|
new SessionProjectionRegistry(ctx)
|
|
void new TokenMeter(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: [createUserMessage({
|
|
content: [{ type: 'text', text }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})] }
|
|
}
|
|
|
|
const SYSTEM_PROMPT_PLUGIN = '@deepseek-ai/dsh-system-prompt'
|
|
|
|
/**
|
|
* Closed two-message turns followed by one open turn for durable compaction events.
|
|
* A `system` text puts a `system/message` at surface node 0 before turn 1's user message.
|
|
*/
|
|
function conversation(turns = 4, text = 'fixture '.repeat(40).trim(), system?: string): Session {
|
|
const session = Session.create(SessionId(`conversation-${turns}`))
|
|
for (let turn = 1; turn <= turns; turn += 1) {
|
|
session.append('turn/start', { turn })
|
|
if (turn === 1 && system !== undefined) {
|
|
session.append('system/message', {
|
|
turn,
|
|
step: 1,
|
|
message: createSystemMessage(system, SYSTEM_PROMPT_PLUGIN),
|
|
}, { surfaceOp: 'append' })
|
|
}
|
|
session.append('user/message', createUserMessage({
|
|
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', {
|
|
stream: [],
|
|
turn,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: `${text} assistant ${turn}` }],
|
|
source: {
|
|
kind: 'model',
|
|
...{ provider: MODEL, model: MODEL },
|
|
},
|
|
}),
|
|
}, { surfaceOp: 'append' })
|
|
session.append('step/end', { turn, step: 1 })
|
|
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
|
}
|
|
session.append('turn/start', {
|
|
turn: turns + 1,
|
|
})
|
|
return session
|
|
}
|
|
|
|
function toolConversation(): Session {
|
|
const session = Session.create(SessionId('tools'))
|
|
for (let turn = 1; turn <= 3; turn += 1) {
|
|
const callId = ToolCallId(`call-${turn}`)
|
|
session.append('turn/start', { turn })
|
|
session.append('user/message', createUserMessage({
|
|
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', {
|
|
stream: [],
|
|
turn,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: `calling ${turn} `.repeat(300) },
|
|
{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' },
|
|
],
|
|
source: {
|
|
kind: 'model',
|
|
...{ provider: MODEL, model: MODEL },
|
|
},
|
|
}),
|
|
}, { surfaceOp: 'append' })
|
|
session.append('tool/call', { turn, step: 1, callId, name: 'read', arguments: '{}' })
|
|
session.append('tool/result', {
|
|
turn,
|
|
step: 1,
|
|
message: createToolResultMessage({
|
|
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 })
|
|
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 = Session.create(SessionId(`oversized-tool-${chars}`))
|
|
const callId = ToolCallId('oversized')
|
|
session.append('turn/start', { turn: 1 })
|
|
if (withCompactablePrompt) {
|
|
session.append('user/message', createUserMessage({
|
|
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', {
|
|
stream: [],
|
|
turn: 1,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
|
source: {
|
|
kind: 'model',
|
|
...{ 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,
|
|
message: createToolResultMessage({
|
|
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 })
|
|
return session
|
|
}
|
|
|
|
class TestCompactionEngine extends BasicCompactionEngine {
|
|
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
|
|
rawOutput: ContentBlock[] | undefined
|
|
usage: TokenUsage | undefined
|
|
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[]
|
|
rawOutput?: ContentBlock[]
|
|
provider: string
|
|
model: string
|
|
maxTokens?: number
|
|
usage?: TokenUsage
|
|
}> {
|
|
this.calls.push({ input, signal })
|
|
this.mutateDuringSummary?.()
|
|
if (this.error !== undefined) throw this.error
|
|
return {
|
|
summary: this.summary,
|
|
...this.rawOutput === undefined ? {} : { rawOutput: this.rawOutput },
|
|
provider: this.summaryProvider,
|
|
model: this.summaryModel,
|
|
maxTokens: 123,
|
|
...this.usage === undefined ? {} : { usage: this.usage },
|
|
}
|
|
}
|
|
}
|
|
|
|
function service(
|
|
config: BasicCompactionConfig = { auto: false },
|
|
ctx = createContext(),
|
|
): TestCompactionEngine {
|
|
return new TestCompactionEngine(ctx, config)
|
|
}
|
|
|
|
async function compactIfNeeded(
|
|
compact: BasicCompactionEngine,
|
|
session: Session,
|
|
trigger: 'pressure' | 'context-overflow' = 'pressure',
|
|
model: string | undefined = MODEL,
|
|
): Promise<CompactionResult | null> {
|
|
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 } } }, /BasicCompactionConfig: unknown key "models"/],
|
|
[{ thresholdRato: 0.5 }, /BasicCompactionConfig: unknown key "thresholdRato"/],
|
|
] as Array<[unknown, RegExp]>
|
|
|
|
for (const [config, pattern] of bad) {
|
|
expect(() => resolveConfig(config as BasicCompactionConfig)).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: BasicCompactionConfig = {
|
|
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 = Session.create(SessionId('headerless'))
|
|
session.append('turn/start', { turn: 1 })
|
|
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('forwards turn cancellation to proactive model metadata resolution', async () => {
|
|
const ctx = createContext()
|
|
const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
|
|
const compact = service(compactConfig, ctx)
|
|
const session = conversation()
|
|
const signal = new AbortController().signal
|
|
|
|
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', signal))
|
|
.resolves.not.toBeNull()
|
|
expect(resolveModelInfo).toHaveBeenCalledWith(MODEL, MODEL, signal)
|
|
})
|
|
|
|
it('re-resolves capacity after a same-model-id provider switch in one session', async () => {
|
|
const ctx = new Context()
|
|
void new LlmRuntime(ctx)
|
|
new SessionProjectionRegistry(ctx)
|
|
void new TokenMeter(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 LlmRuntime(ctx)
|
|
new SessionProjectionRegistry(ctx)
|
|
void new TokenMeter(ctx)
|
|
ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000))
|
|
vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
|
|
provider,
|
|
id: model,
|
|
name: model,
|
|
}))
|
|
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 = Session.create(SessionId('single-tool-pair'))
|
|
const callId = ToolCallId('single-call')
|
|
session.append('turn/start', { turn: 1 })
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
session.append('request/header', {
|
|
header: { config: { provider: MODEL, model: MODEL } },
|
|
reason: 'initial',
|
|
})
|
|
session.append('assistant/message', {
|
|
stream: [],
|
|
turn: 1,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
|
|
source: {
|
|
kind: 'model',
|
|
...{ provider: MODEL, model: MODEL },
|
|
},
|
|
}),
|
|
}, { surfaceOp: 'append' })
|
|
session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
|
|
session.append('tool/result', {
|
|
turn: 1,
|
|
step: 1,
|
|
message: createToolResultMessage({
|
|
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.snapshotEvents().some(event => event.type === 'compaction/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 head = session.surface.nodes[0]!
|
|
const result = await compactIfNeeded(compact, session)
|
|
expect(result).not.toBeNull()
|
|
expect(result?.shadowedSeqs[0]).toBe(head)
|
|
expect(result?.shadowedSeqs.length).toBeGreaterThan(2)
|
|
expect(session.surface.nodes.length).toBeLessThan(8)
|
|
})
|
|
|
|
it('starts the range after a system head and keeps that node at surface position 0', async () => {
|
|
const ctx = createContext()
|
|
const compact = service(compactConfig, ctx)
|
|
const session = conversation(4, undefined, 'SYSTEM HEAD')
|
|
const [head, firstUser] = session.surface.nodes
|
|
expect(session.eventAt(head!)?.type).toBe('system/message')
|
|
|
|
const range = selectCompactableRange(session, ctx.tokenMeter.measure(session), compactConfig.retainTokens!)
|
|
expect(range?.start).toBe(firstUser)
|
|
|
|
const result = await compactIfNeeded(compact, session)
|
|
expect(result).not.toBeNull()
|
|
expect(result?.shadowedSeqs).not.toContain(head)
|
|
expect(result?.shadowedSeqs[0]).toBe(firstUser)
|
|
expect(session.surface.nodes[0]).toBe(head)
|
|
expect(session.deriveMessages()[0]).toMatchObject({
|
|
role: 'system',
|
|
content: [{ type: 'text', text: 'SYSTEM HEAD' }],
|
|
})
|
|
expect(compact.calls[0]!.input.messages[0]).toEqual(session.deriveMessages()[0])
|
|
expect(compact.calls[0]!.input.messages.filter(message => message.role === 'system')).toHaveLength(1)
|
|
})
|
|
|
|
it('declines when only the system head precedes the retained tail', () => {
|
|
const ctx = createContext()
|
|
const session = conversation(1, undefined, 'SYSTEM HEAD')
|
|
const priced = ctx.tokenMeter.measure(session)
|
|
const [, user, assistant] = priced.nodes
|
|
expect(priced.nodes).toHaveLength(3)
|
|
expect(selectCompactableRange(session, priced, user!.tokens + assistant!.tokens)).toBeNull()
|
|
})
|
|
|
|
it('counts the durable routed request envelope without putting it 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()
|
|
|
|
session.append('request/header', {
|
|
header: {
|
|
config: { provider: MODEL, model: MODEL },
|
|
tools: [{ name: 'bulk', description: 's'.repeat(2_000), parameters: { type: 'object' } }],
|
|
},
|
|
reason: 'resume',
|
|
})
|
|
const result = await compactIfNeeded(compact, session)
|
|
expect(result).not.toBeNull()
|
|
})
|
|
|
|
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 bulkTools = [{ name: 'bulk', description: 'x'.repeat(100_000), parameters: { type: 'object' } }]
|
|
const empty = Session.create(SessionId('empty'))
|
|
empty.append('turn/start', { turn: 1 })
|
|
empty.append('request/header', {
|
|
header: { config: { provider: MODEL, model: MODEL }, tools: bulkTools },
|
|
reason: 'initial',
|
|
})
|
|
expect(await compactIfNeeded(compact, empty)).toBeNull()
|
|
|
|
const retained = conversation(1)
|
|
retained.append('request/header', {
|
|
header: { config: { provider: MODEL, model: MODEL }, tools: bulkTools },
|
|
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<string>()
|
|
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 = Session.create(SessionId('one-tool-pair'))
|
|
const callId = ToolCallId('only')
|
|
session.append('turn/start', { turn: 1 })
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
session.append('assistant/message', {
|
|
stream: [],
|
|
turn: 1,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
|
|
source: {
|
|
kind: 'model',
|
|
...{ provider: MODEL, model: MODEL },
|
|
},
|
|
}),
|
|
}, { surfaceOp: 'append' })
|
|
session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
|
|
session.append('tool/result', {
|
|
turn: 1,
|
|
step: 1,
|
|
message: createToolResultMessage({
|
|
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 ToolResultPruner(ctx, pruneConfig)
|
|
const compact = new TestCompactionEngine(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 ToolResultPruner(ctx, pruneConfig)
|
|
const compact = new TestCompactionEngine(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 ToolResultPruner(ctx, pruneConfig)
|
|
const compact = new TestCompactionEngine(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 compaction-basic behavior without the optional plugin', async () => {
|
|
const ctx = createContext(2_000)
|
|
const compact = new TestCompactionEngine(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.snapshotEvents().find(event => event.type === 'tool/result')
|
|
expect(original?.type === 'tool/result' && original.data.message.content[0].content[0])
|
|
.toEqual({ type: 'text', text: 'X'.repeat(3_000) })
|
|
expect(session.snapshotEvents().filter(event =>
|
|
event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('compaction region transaction', () => {
|
|
it('lands a framed, replayable checkpoint with exact source seqs and token price', async () => {
|
|
const compact = service()
|
|
compact.rawOutput = [
|
|
{ type: 'reasoning', text: 'private compact thought' },
|
|
...compact.summary,
|
|
]
|
|
compact.usage = { inputTokens: 40, outputTokens: 5 }
|
|
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.snapshotEvents().findLast(event => event.type === 'compaction/summary')
|
|
expect(summary?.data).toMatchObject({
|
|
shadowedSeqs: result.shadowedSeqs,
|
|
shadowedTokenCount: result.shadowedTokenCount,
|
|
provider: 'summary-provider',
|
|
model: 'summary-model',
|
|
maxTokens: 123,
|
|
rawOutput: compact.rawOutput,
|
|
usage: compact.usage,
|
|
})
|
|
expect(summary?.data).not.toHaveProperty('llmStreamCall')
|
|
const head = session.deriveMessages()[0]!
|
|
expect(head.content[0]?.type).toBe('text')
|
|
expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
|
|
expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
|
|
|
|
const replay = Session.create(SessionId('replay'), session.snapshotEvents())
|
|
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
|
|
})
|
|
|
|
it('replays the system head and latest routed tools so the summarizer reuses the cache', async () => {
|
|
const compact = service()
|
|
const session = conversation(3, undefined, 'CONVERSATION SYSTEM')
|
|
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
|
|
session.append('request/header', {
|
|
header: { config: { provider: MODEL, model: MODEL }, tools },
|
|
reason: 'resume',
|
|
})
|
|
const nodes = session.surface.nodes
|
|
const prefix = session.deriveMessages().slice(0, 3)
|
|
await compact.compactRegion(nodes[1]!, nodes[2]!, agent(session, MODEL), SIGNAL)
|
|
|
|
const { input } = compact.calls[0]!
|
|
expect(input).toEqual({ messages: prefix, tools })
|
|
})
|
|
|
|
it('omits the summarizer system prompt for an empty system head or a system-less surface', async () => {
|
|
const compact = service()
|
|
const emptyHead = conversation(3, undefined, '')
|
|
const emptyNodes = emptyHead.surface.nodes
|
|
expect(emptyHead.eventAt(emptyNodes[0]!)?.type).toBe('system/message')
|
|
await compact.compactRegion(emptyNodes[1]!, emptyNodes[2]!, agent(emptyHead, MODEL), SIGNAL)
|
|
expect(compact.calls[0]!.input.messages[0]).toMatchObject({ role: 'user' })
|
|
expect(compact.calls[0]!.input.messages.some(message => message.role === 'system')).toBe(false)
|
|
expect(emptyHead.surface.nodes[0]).toBe(emptyNodes[0])
|
|
|
|
const headless = conversation(3)
|
|
const nodes = headless.surface.nodes
|
|
await compact.compactRegion(nodes[0]!, nodes[1]!, agent(headless, MODEL), SIGNAL)
|
|
expect(compact.calls[1]!.input.messages.some(message => message.role === 'system')).toBe(false)
|
|
expect(compact.calls[1]!.input.messages[0]).toMatchObject({ role: 'user' })
|
|
})
|
|
|
|
it('surfaces the session rejection of a range that covers the system head', async () => {
|
|
const compact = service()
|
|
const session = conversation(3, undefined, 'PROTECTED SYSTEM')
|
|
const nodes = [...session.surface.nodes]
|
|
await expect(compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL))
|
|
.rejects.toThrow(/node 0 holds the system prompt/)
|
|
|
|
expect(session.surface.nodes).toEqual(nodes)
|
|
const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end')
|
|
expect(end?.type === 'compaction/end' && end.data.error).toBeDefined()
|
|
})
|
|
|
|
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 === undefined ? nodes[0]! : SessionSeq(startOverride),
|
|
endOverride === undefined ? nodes[1]! : SessionSeq(endOverride),
|
|
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('compaction/start', {
|
|
compactionId: CompactionId('locked-compaction'),
|
|
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 = Session.create(SessionId('turnless'))
|
|
session.append('user/message', createUserMessage({
|
|
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.snapshotEvents().findLast(event => event.type === 'compaction/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.snapshotEvents().findLast(event => event.type === 'compaction/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.snapshotEvents().some(event => event.type === 'compaction/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', createUserMessage({
|
|
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.snapshotEvents().some(event => event.type === 'compaction/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.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false)
|
|
})
|
|
|
|
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
|
|
const compact = service()
|
|
const session = Session.create(SessionId('model-less-region'))
|
|
session.append('turn/start', { turn: 1 })
|
|
session.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'history '.repeat(100) }],
|
|
source: { kind: 'user' },
|
|
}), { surfaceOp: 'append' })
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
session.append('assistant/message', {
|
|
stream: [],
|
|
turn: 1,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'answer '.repeat(100) }],
|
|
source: {
|
|
kind: 'model',
|
|
...{ provider: 'historical', model: 'historical' },
|
|
},
|
|
}),
|
|
}, { 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
|
|
usage: TokenUsage | undefined
|
|
|
|
constructor(
|
|
private readonly blocks: readonly ContentBlock[],
|
|
private readonly finish: (StreamChunk & { type: 'finish' })['reason'] = { kind: 'stop' },
|
|
) {
|
|
super()
|
|
}
|
|
|
|
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
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 }
|
|
}
|
|
}
|
|
if (this.usage !== undefined) yield { type: 'usage', usage: this.usage }
|
|
yield { type: 'finish', reason: this.finish }
|
|
}
|
|
}
|
|
|
|
class ExposedCompactionEngine extends BasicCompactionEngine {
|
|
runSummarize(
|
|
input: SummarizationInput,
|
|
owner: Agent,
|
|
signal?: AbortSignal,
|
|
): Promise<{
|
|
summary: ContentBlock[]
|
|
rawOutput?: ContentBlock[]
|
|
provider: string
|
|
model: string
|
|
maxTokens?: number
|
|
usage?: TokenUsage
|
|
}> {
|
|
return this.summarize(input, owner, signal)
|
|
}
|
|
}
|
|
|
|
async function summarizerHarness(
|
|
blocks: readonly ContentBlock[],
|
|
finish?: (StreamChunk & { type: 'finish' })['reason'],
|
|
model = MODEL,
|
|
config: BasicCompactionConfig = { auto: false },
|
|
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactionEngine }> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmRuntime)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
void new TokenMeter(ctx)
|
|
const adapter = new ScriptedAdapter(blocks, finish)
|
|
ctx.llm.registerAdapter([model], adapter)
|
|
const compact = new ExposedCompactionEngine(ctx, config)
|
|
return { ctx, adapter, compact }
|
|
}
|
|
|
|
describe('default one-shot summarizer', () => {
|
|
it.each([undefined, '', 'SYSTEM HEAD\n精确前缀\n'])('preserves the routed prefix through region summarization with system %j', async (system) => {
|
|
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
|
|
const session = conversation(3, undefined, system)
|
|
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
|
|
session.append('request/header', {
|
|
header: { config: { provider: MODEL, model: MODEL }, tools },
|
|
reason: 'resume',
|
|
})
|
|
const nodes = [...session.surface.nodes]
|
|
const start = system === undefined ? 0 : 1
|
|
const prefix = session.deriveMessages().slice(0, system ? 3 : 2)
|
|
const result = await compact.compactRegion(nodes[start]!, nodes[start + 1]!, agent(session, MODEL), SIGNAL)
|
|
|
|
expect(adapter.lastOptions).not.toHaveProperty('system')
|
|
expect(adapter.lastOptions?.tools).toEqual(tools)
|
|
expect(adapter.lastOptions?.messages.slice(0, -1)).toEqual(prefix)
|
|
expect(adapter.lastOptions?.messages.at(-1)).toMatchObject({ role: 'user' })
|
|
expect(result.shadowedSeqs).toEqual(nodes.slice(start, start + 2))
|
|
if (system !== undefined) expect(session.surface.nodes[0]).toBe(nodes[0])
|
|
})
|
|
|
|
it('requires complete raw output when a subclass marks one local LLM stream call', () => {
|
|
expectTypeOf<{
|
|
summary: ContentBlock[]
|
|
llmStreamCall: true
|
|
provider: string
|
|
model: string
|
|
}>().not.toExtend<SummaryResult>()
|
|
})
|
|
|
|
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: ToolCallId('unexpected'), name: 'x', arguments: '{}' },
|
|
], undefined, MODEL, {
|
|
auto: false,
|
|
summarizationProvider: MODEL,
|
|
summarizationModel: MODEL,
|
|
maxTokens: 321,
|
|
})
|
|
const session = conversation(1)
|
|
adapter.usage = { inputTokens: 12, outputTokens: 3 }
|
|
const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL)
|
|
|
|
expect(output).toEqual({
|
|
summary: [{ type: 'text', text: 'public summary' }],
|
|
rawOutput: [
|
|
{ type: 'reasoning', text: 'private' },
|
|
{ type: 'text', text: 'public summary' },
|
|
{ type: 'tool-call', id: ToolCallId('unexpected'), name: 'x', arguments: '{}' },
|
|
],
|
|
llmStreamCall: true,
|
|
provider: MODEL,
|
|
model: MODEL,
|
|
maxTokens: 321,
|
|
usage: adapter.usage,
|
|
})
|
|
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 = createUserMessage({
|
|
content: [
|
|
{ type: 'text', text: 'earlier turn' },
|
|
{
|
|
type: 'image',
|
|
attachment: {
|
|
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
|
mediaType: 'image/png',
|
|
bytes: 1,
|
|
width: 1,
|
|
height: 1,
|
|
},
|
|
},
|
|
],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})
|
|
const system = createSystemMessage('REPLAYED SYSTEM', SYSTEM_PROMPT_PLUGIN)
|
|
await compact.runSummarize({
|
|
tools,
|
|
messages: [system, prefix],
|
|
}, agent(conversation(1), MODEL))
|
|
|
|
expect(adapter.lastOptions).not.toHaveProperty('system')
|
|
expect(adapter.lastOptions?.tools).toEqual(tools)
|
|
const messages = adapter.lastOptions?.messages ?? []
|
|
expect(messages.slice(0, -1)).toEqual([system, prefix])
|
|
const last = messages.at(-1)?.content[0]
|
|
const lastText = last?.type === 'text' ? last.text : ''
|
|
expect(lastText).toContain('Write concise English engineering prose.')
|
|
expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
|
|
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 = createUserMessage({
|
|
content: [{ type: 'text', text: 'warm prefix' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})
|
|
|
|
const system = createSystemMessage('WARM SYSTEM', SYSTEM_PROMPT_PLUGIN)
|
|
const output = await compact.runSummarize({
|
|
messages: [system, 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,
|
|
})
|
|
expect(policyAdapter.lastOptions).not.toHaveProperty('system')
|
|
expect(policyAdapter.lastOptions?.messages.slice(0, -1)).toEqual([system, 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.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({
|
|
summary: [{ type: 'text', text: 'routed summary' }],
|
|
llmStreamCall: true,
|
|
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(LlmRuntime)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
void new TokenMeter(ctx)
|
|
const compact = new ExposedCompactionEngine(ctx, { auto: false })
|
|
await expect(compact.runSummarize(promptInput('history'), agent(Session.create(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 = Session.create(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: Session.create(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/)
|
|
})
|
|
|
|
it('rejects image summary output instead of silently dropping it', async () => {
|
|
const { compact } = await summarizerHarness([
|
|
{
|
|
type: 'image',
|
|
attachment: {
|
|
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
|
|
mediaType: 'image/png',
|
|
bytes: 1,
|
|
width: 1,
|
|
height: 1,
|
|
},
|
|
},
|
|
{ type: 'text', text: 'partial summary' },
|
|
])
|
|
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
|
|
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
|
})
|
|
|
|
it('rejects image summary output nested in a tool result', async () => {
|
|
const { compact } = await summarizerHarness([{
|
|
type: 'tool-result',
|
|
toolCallId: ToolCallId('summary-tool'),
|
|
content: [{
|
|
type: 'image',
|
|
attachment: {
|
|
attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
|
|
mediaType: 'image/png',
|
|
bytes: 1,
|
|
width: 1,
|
|
height: 1,
|
|
},
|
|
}],
|
|
}])
|
|
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
|
|
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
|
})
|
|
})
|
|
|
|
describe('automatic listener and loader composition', () => {
|
|
function preStep(ctx: Context, owner: Agent, signal = SIGNAL) {
|
|
return agentEvents(ctx, owner).waterfall(
|
|
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
|
|
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
|
)
|
|
}
|
|
|
|
function recover(
|
|
ctx: Context,
|
|
owner: Agent,
|
|
error: Error & { code?: string },
|
|
signal = SIGNAL,
|
|
next: () => Promise<RequestErrorAction> = () => Promise.resolve(undefined),
|
|
): Promise<boolean> {
|
|
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
|
|
const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1
|
|
return agentEvents(ctx, owner).waterfall(
|
|
'agent/request-error',
|
|
{ turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal },
|
|
next,
|
|
).then(action => action?.kind === 'retry')
|
|
}
|
|
|
|
function overflow(message = 'provider overflow'): Error & { code: string } {
|
|
return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE })
|
|
}
|
|
|
|
it('compacts before a step above threshold using the durable routed model and remains idle below it', async () => {
|
|
const ctx = createContext()
|
|
const compact = new TestCompactionEngine(ctx, {
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 180,
|
|
})
|
|
const pressured = conversation(4)
|
|
await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
|
|
expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true)
|
|
|
|
const small = conversation(1)
|
|
await preStep(ctx, agent(small, MODEL))
|
|
expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
|
|
expect(compact.calls).toHaveLength(1)
|
|
})
|
|
|
|
it('skips pre-step pressure when the step signal is already aborted', async () => {
|
|
const ctx = createContext()
|
|
const compact = new TestCompactionEngine(ctx, {
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 180,
|
|
})
|
|
const pressured = conversation(4)
|
|
const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
|
|
|
|
await expect(preStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted')))
|
|
.resolves.toEqual({ kind: 'enter', messages: [] })
|
|
|
|
expect(compactIfNeeded).not.toHaveBeenCalled()
|
|
expect(pressured.snapshotEvents().some(event => event.type === 'compaction/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 TestCompactionEngine(ctx, {
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 180,
|
|
})
|
|
compact.error = 'temporary failure'
|
|
const session = conversation(4)
|
|
|
|
await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] })
|
|
expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
|
|
expect(session.snapshotEvents().some(event => event.type === 'compaction/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, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
|
|
provider,
|
|
id: model,
|
|
name: model,
|
|
}))
|
|
void new TestCompactionEngine(ctx, {
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 180,
|
|
})
|
|
const session = conversation(4)
|
|
|
|
await preStep(ctx, agent(session, MODEL))
|
|
await preStep(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 TestCompactionEngine(ctx, {
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 500,
|
|
})
|
|
const session = conversation(4)
|
|
|
|
await preStep(ctx, agent(session, MODEL))
|
|
await preStep(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 TestCompactionEngine(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).toBe(true)
|
|
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
|
|
expect(session.snapshotEvents().some(event => event.type === 'compaction/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 ToolResultPruner(ctx, {
|
|
thresholdChars: 100,
|
|
headChars: 20,
|
|
tailChars: 10,
|
|
})
|
|
const compact = new TestCompactionEngine(ctx, {
|
|
thresholdRatio: 1,
|
|
retainTokens: 900,
|
|
})
|
|
const session = oversizedToolResult()
|
|
|
|
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
|
|
expect(session.surface.replaceGeneration).toBe(1)
|
|
expect(session.snapshotEvents().some(event => event.type === 'compaction/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 ToolResultPruner(ctx, {
|
|
thresholdChars: 100,
|
|
headChars: 20,
|
|
tailChars: 10,
|
|
})
|
|
const compact = new TestCompactionEngine(ctx, {
|
|
thresholdRatio: 1,
|
|
retainTokens: 900,
|
|
})
|
|
const session = toolConversation()
|
|
|
|
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
|
|
expect(session.snapshotEvents().some(event => event.type === 'compaction/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 ToolResultPruner(ctx, {
|
|
thresholdChars: 100,
|
|
headChars: 20,
|
|
tailChars: 10,
|
|
})
|
|
const compact = new TestCompactionEngine(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())).toBe(true)
|
|
expect(session.surface.replaceGeneration).toBe(1)
|
|
expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2)
|
|
expect(session.snapshotEvents().findLast(event => event.type === 'compaction/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 ToolResultPruner(ctx, {
|
|
thresholdChars: 100,
|
|
headChars: 20,
|
|
tailChars: 10,
|
|
})
|
|
const compact = new TestCompactionEngine(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(), controller.signal)).toBe(false)
|
|
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 TestCompactionEngine(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())).toBe(true)
|
|
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 TestCompactionEngine(ctx)
|
|
const session = conversation(2)
|
|
const fakeResult: CompactionResult = {
|
|
compactionId: CompactionId('fake-compaction'),
|
|
startSeq: SessionSeq(1),
|
|
summarySeq: SessionSeq(2),
|
|
endSeq: SessionSeq(3),
|
|
summary: [{ type: 'text', text: 'fake' }],
|
|
shadowedRange: { start: SessionSeq(1), end: SessionSeq(2) },
|
|
shadowedSeqs: [SessionSeq(1), SessionSeq(2)],
|
|
shadowedTokenCount: 10,
|
|
}
|
|
vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
|
|
|
|
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
|
expect(session.surface.replaceGeneration).toBe(0)
|
|
})
|
|
|
|
it('delegates downstream exactly once when no replacement is available', async () => {
|
|
const ctx = createContext()
|
|
const compact = new TestCompactionEngine(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(),
|
|
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 TestCompactionEngine(ctx)
|
|
compact.error = new Error('summary unavailable')
|
|
const original = overflow('original provider overflow')
|
|
|
|
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false)
|
|
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 TestCompactionEngine(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, SIGNAL, () => {
|
|
delegations += 1
|
|
return Promise.resolve(undefined)
|
|
})
|
|
|
|
expect(decision).toBe(false)
|
|
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 TestCompactionEngine(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')))
|
|
.toBe(true)
|
|
})
|
|
|
|
it('delegates canonical overflow when no durable routed target exists', async () => {
|
|
const ctx = createContext()
|
|
void new TestCompactionEngine(ctx)
|
|
const session = Session.create(SessionId('headerless-overflow'))
|
|
session.append('turn/start', {
|
|
turn: 1,
|
|
})
|
|
|
|
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
|
|
})
|
|
|
|
it('honors retry caps and ignores non-context failures', async () => {
|
|
const ctx = createContext()
|
|
const compact = new TestCompactionEngine(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' })))
|
|
.toBe(false)
|
|
expect(await recover(ctx, owner, overflow())).toBe(true)
|
|
compactSpy.mockClear()
|
|
expect(await recover(ctx, owner, overflow())).toBe(false)
|
|
expect(compactSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('applies the routed model override to the overflow retry cap', async () => {
|
|
const ctx = createContext()
|
|
const compact = new TestCompactionEngine(ctx, {
|
|
maxOverflowRetries: 2,
|
|
modelPolicies: [{
|
|
provider: MODEL,
|
|
model: MODEL,
|
|
maxOverflowRetries: 1,
|
|
}],
|
|
})
|
|
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
|
|
const owner = agent(conversation(3), MODEL)
|
|
|
|
expect(await recover(ctx, owner, overflow())).toBe(true)
|
|
compactSpy.mockClear()
|
|
expect(await recover(ctx, owner, overflow())).toBe(false)
|
|
expect(compactSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not retry when cancellation lands during an awaited compaction', async () => {
|
|
const ctx = createContext()
|
|
const compact = new TestCompactionEngine(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(), controller.signal)).toBe(false)
|
|
expect(session.surface.replaceGeneration).toBe(generation + 1)
|
|
})
|
|
|
|
it('maxOverflowRetries:0 disables recovery without disabling post-step pressure', async () => {
|
|
const ctx = createContext()
|
|
void new TestCompactionEngine(ctx, {
|
|
maxOverflowRetries: 0,
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 180,
|
|
})
|
|
const session = conversation(4)
|
|
await preStep(ctx, agent(session, MODEL))
|
|
const summaries = session.snapshotEvents().filter(event => event.type === 'compaction/summary').length
|
|
expect(summaries).toBe(1)
|
|
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
|
expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries)
|
|
})
|
|
|
|
it('auto:false installs neither automatic listener', async () => {
|
|
const ctx = createContext()
|
|
void new TestCompactionEngine(ctx, {
|
|
auto: false,
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 180,
|
|
})
|
|
const session = conversation(4)
|
|
await preStep(ctx, agent(session, MODEL))
|
|
expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
|
|
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
|
})
|
|
|
|
it('loads and disposes the real zero-config service stack', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmRuntime)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
const meterFiber = await ctx.plugin(TokenMeter)
|
|
const compactFiber = await ctx.plugin(BasicCompactionEngine, { auto: false })
|
|
|
|
expect(ctx.get('compaction')).toBeInstanceOf(BasicCompactionEngine)
|
|
await compactFiber.dispose()
|
|
expect(ctx.get('compaction')).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(LlmRuntime)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
await ctx.plugin(TokenMeter)
|
|
const fiber = await ctx.plugin(TestCompactionEngine, {
|
|
thresholdRatio: 0.5,
|
|
retainTokens: 180,
|
|
})
|
|
await fiber.dispose()
|
|
|
|
const session = conversation(4)
|
|
await preStep(ctx, agent(session, MODEL))
|
|
expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false)
|
|
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('route-priced image pressure', () => {
|
|
const IMAGE_VISUAL_TOKENS = 300
|
|
const IMAGE_HANDLE_TEXT = 'request preview'
|
|
|
|
class PricedContextAdapter extends ContextAdapter {
|
|
override imageRequestPricing(): { priceImages: (images: readonly unknown[]) => Array<{ visualTokens: number; text: string }> } {
|
|
return {
|
|
priceImages: images => images.map(() => ({
|
|
visualTokens: IMAGE_VISUAL_TOKENS,
|
|
text: IMAGE_HANDLE_TEXT,
|
|
})),
|
|
}
|
|
}
|
|
}
|
|
|
|
function pricedContext(contextWindow = 1_000): Context {
|
|
const ctx = new Context()
|
|
void new LlmRuntime(ctx)
|
|
new SessionProjectionRegistry(ctx)
|
|
void new TokenMeter(ctx)
|
|
ctx.llm.registerAdapter([MODEL], new PricedContextAdapter(contextWindow))
|
|
return ctx
|
|
}
|
|
|
|
/** Closed short-text turns whose user messages each carry one image. */
|
|
function imageConversation(turns = 4): Session {
|
|
const session = Session.create(SessionId(`image-dense-${turns}`))
|
|
for (let turn = 1; turn <= turns; turn += 1) {
|
|
session.append('turn/start', { turn })
|
|
session.append('user/message', createUserMessage({
|
|
content: [
|
|
{ type: 'text', text: `image turn ${turn}` },
|
|
{
|
|
type: 'image',
|
|
attachment: {
|
|
attachmentId: AttachmentId(`sha256:${String(turn).repeat(8)}`),
|
|
mediaType: 'image/png',
|
|
bytes: 2048,
|
|
width: 800,
|
|
height: 800,
|
|
name: `shot-${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', {
|
|
stream: [],
|
|
turn,
|
|
step: 1,
|
|
message: createMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: `ok ${turn}` }],
|
|
source: {
|
|
kind: 'model',
|
|
...{ provider: MODEL, model: MODEL },
|
|
},
|
|
}),
|
|
}, { surfaceOp: 'append' })
|
|
session.append('step/end', { turn, step: 1 })
|
|
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
|
}
|
|
session.append('turn/start', { turn: turns + 1 })
|
|
return session
|
|
}
|
|
|
|
it('selects an image-dense range only when the routed price counts visual tokens', () => {
|
|
const session = imageConversation()
|
|
const routed = pricedContext().tokenMeter.measure(session)
|
|
const neutral = createContext().tokenMeter.measure(session)
|
|
|
|
expect(routed.surfaceTokens).toBeGreaterThan(neutral.surfaceTokens + 4 * IMAGE_VISUAL_TOKENS - 200)
|
|
expect(routed.nodes.map(node => node.seq)).toEqual(neutral.nodes.map(node => node.seq))
|
|
expect(routed.nodes.map(node => node.heuristicTokens)).toEqual(neutral.nodes.map(node => node.tokens))
|
|
|
|
// The same verbatim tail budget retains almost everything under the
|
|
// neutral heuristic but forces a cut once visual tokens are counted.
|
|
expect(selectCompactableRange(session, neutral, 350)).toBeNull()
|
|
const range = selectCompactableRange(session, routed, 350)
|
|
expect(range).not.toBeNull()
|
|
})
|
|
|
|
it('accepts a summary larger than the span heuristic when the route price shrinks', async () => {
|
|
// A single short image message prices below a framed summary under the
|
|
// fixed heuristic but far above it under the route: the shrink comparison
|
|
// must ask whether the replacement lowers route pressure.
|
|
const ctx = pricedContext(1_000)
|
|
const session = imageConversation(1)
|
|
const before = ctx.tokenMeter.measure(session)
|
|
const imageNode = before.nodes[0]!
|
|
const compact = new TestCompactionEngine(ctx, { auto: false })
|
|
compact.summary = [{
|
|
type: 'text',
|
|
text: 'summary text sized between the heuristic and route prices of the shadowed image message, '
|
|
+ 'long enough that the fixed heuristic alone would reject it as not smaller '
|
|
+ 'while the route-priced comparison accepts the pressure reduction.',
|
|
}]
|
|
const framed = ctx.tokenMeter.estimateMessage(createUserMessage({
|
|
content: frameSummary(compact.summary),
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
}))
|
|
expect(framed).toBeGreaterThan(imageNode.heuristicTokens)
|
|
expect(framed).toBeLessThan(imageNode.tokens)
|
|
|
|
const result = await compact.compactRegion(imageNode.seq, imageNode.seq, agent(session), SIGNAL)
|
|
expect(result.shadowedSeqs).toEqual([imageNode.seq])
|
|
expect(result.shadowedTokenCount).toBe(imageNode.heuristicTokens)
|
|
})
|
|
|
|
it('triggers pressure compaction from routed visual tokens and logs heuristic shadow prices', async () => {
|
|
const ctx = pricedContext(1_000)
|
|
const session = imageConversation()
|
|
const before = ctx.tokenMeter.measure(session)
|
|
const compact = new TestCompactionEngine(ctx, {
|
|
auto: false,
|
|
thresholdRatio: 0.8,
|
|
retainTokens: 350,
|
|
})
|
|
|
|
// The same history stays below the 800-token threshold without pricing.
|
|
const neutralResult = await compactIfNeeded(service({
|
|
auto: false,
|
|
thresholdRatio: 0.8,
|
|
retainTokens: 350,
|
|
}), session)
|
|
expect(neutralResult).toBeNull()
|
|
|
|
const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL)
|
|
expect(result).not.toBeNull()
|
|
const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary')
|
|
expect(summaryEvent).toBeDefined()
|
|
const shadowedHeuristic = before.nodes
|
|
.filter(node => result?.shadowedSeqs.includes(node.seq))
|
|
.reduce((total, node) => total + node.heuristicTokens, 0)
|
|
expect(summaryEvent?.data.shadowedTokenCount).toBe(shadowedHeuristic)
|
|
})
|
|
})
|