kimi-code / packages /agent-core-v2 /test /agent /fullCompaction /fullCompaction.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f0634fb verified
Raw History Blame Contribute Delete
159 kB
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { UNKNOWN_CAPABILITY } from '#/llm-adapter/contract/capability';
import {
APIConnectionError,
APIContextOverflowError,
APIRequestTooLargeError,
APIStatusError,
} from '#/llm-adapter/contract/errors';
import { type Message } from '#/llm-adapter/contract/message';
import { type StreamedMessagePart, type ToolCall } from '#human/llm/message';
import type { FinishReason } from '#human/llm/finish-reason';
import { fromLlmMessage } from '#/llm-adapter/contract/message';
import type { TokenUsage } from '#human/llm/usage';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DefaultCompactionStrategy,
} from '#/agent/fullCompaction/strategy';
import {
buildCompactionContinuationText,
COMPACTION_SUMMARY_PREFIX,
} from '#/agent/contextMemory/compactionHandoff';
import { makeHookRunner } from '../../features/externalHooks/runner-stub';
import type { IExternalHooksRunnerService } from '#/features/externalHooks/app/externalHooksRunner';
import { MASTER_ENV } from '#/app/flag/flagService';
import { estimateTokensForMessages } from '#/llm-adapter/contract/tokens';
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../../harness';
import { agentService, appService, appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, requesterFromGenerateFn, sessionServices, testAgent as createTestAgent, type LegacyGenerateResult } from '../../harness';
import { IFileSystemStorageService } from '#/persistence/interface/storage';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting';
import { renderCompactionInstruction } from '#/agent/fullCompaction/compactionInstruction';
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
import {
IAgentFullCompactionService,
IModelOAuthTokens,
IAgentProfileService,
ITelemetryService,
IAgentToolRegistryService,
DYNAMIC_TOOL_SCHEMA_VARIANT,
normalizeAgentProfile,
type ExecutableTool,
type ResolvedAgentProfile,
type ToolExecution,
} from '#/index';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IWireService } from '#/wire/wire';
import { IAgentTodoService } from '#/features/todo/todoService';
import { IAgentGoalService } from '#/features/goal/goalService';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
type GenerateFn = NonNullable<TestAgentOptions['generate']>;
function testAgent(
...inputs: readonly (TestAgentServiceOverride | TestAgentOptions)[]
): TestAgentContext {
const context = createTestAgent(...inputs);
return context;
}
const CATALOGUED_PROVIDER = {
type: 'kimi',
apiKey: 'test-key',
baseUrl: 'https://api.example/v1',
model: 'kimi-code',
} as const;
const CATALOGUED_MODEL_CAPABILITIES = {
image_in: true,
video_in: true,
audio_in: false,
thinking: true,
tool_use: true,
max_context_tokens: 256_000,
} as const;
const SNAPSHOT_VISIBLE_TOOLS = [
'Agent',
'AgentSwarm',
'CronCreate',
'CronDelete',
'CronList',
'EnterPlanMode',
'ExitPlanMode',
] as const;
const LARGE_MCP_TOOL = 'mcp__srv__large';
const EXACT_COMPACTION_PROFILE: ResolvedAgentProfile = normalizeAgentProfile({
name: 'exact-compaction-refresh',
systemPrompt: (context) =>
[
`cwd:${context.cwd ?? ''}`,
`os:${context.osKind ?? ''}`,
`shell:${context.shellName ?? ''}:${context.shellPath ?? ''}`,
`agents:${context.agentsMd ?? ''}`,
`ls:${context.cwdListing ?? ''}`,
`extra:${context.additionalDirsInfo ?? ''}`,
].join('\n'),
tools: ['Read', 'Write', 'Skill'],
});
describe('FullCompaction', () => {
it('keeps oversized trailing user messages as recent', () => {
const strategy = testCompactionStrategy();
const single = [
textMessage('user', 'old user'),
textMessage('assistant', 'old assistant'),
textMessage('user', `pending user ${'x'.repeat(1_200)}`),
];
expect(strategy.computeCompactCount(single, 'auto')).toBe(2);
const consecutive = [
textMessage('user', 'old user'),
textMessage('assistant', 'old assistant'),
textMessage('user', `pending user one ${'x'.repeat(1_200)}`),
textMessage('user', `pending user two ${'x'.repeat(1_200)}`),
];
expect(strategy.computeCompactCount(consecutive, 'auto')).toBe(2);
});
it('compacts the prefix when the trailing exchange itself is oversized', () => {
const strategy = testCompactionStrategy();
const messages = [
textMessage('user', 'old user'),
textMessage('assistant', 'old assistant'),
textMessage('user', 'recent user'),
textMessage('assistant', `recent assistant ${'x'.repeat(1_200)}`),
];
expect(strategy.computeCompactCount(messages, 'auto')).toBe(2);
});
it('returns 0 when there is nothing to compact', () => {
const strategy = testCompactionStrategy();
expect(strategy.computeCompactCount([], 'auto')).toBe(0);
expect(strategy.computeCompactCount([textMessage('user', 'only pending')], 'auto')).toBe(0);
expect(
strategy.computeCompactCount(
[
textMessage('user', 'a'),
textMessage('user', 'b'),
textMessage('user', 'c'),
],
'auto',
),
).toBe(0);
});
it('returns 0 when no intermediate split exists and the last message is also unsplittable', () => {
const strategy = testCompactionStrategy();
const messages: Message[] = [
textMessage('user', 'inspect'),
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_a', name: 'Lookup', arguments: '{}' }],
},
];
expect(strategy.computeCompactCount(messages, 'auto')).toBe(0);
});
it('does not split inside a parallel tool exchange', () => {
const strategy = testCompactionStrategy();
const messages: Message[] = [
textMessage('user', 'old user'),
textMessage('assistant', 'old assistant'),
textMessage('user', 'run both tools'),
{
role: 'assistant',
content: [],
toolCalls: [
{ type: 'function', id: 'call_a', name: 'Lookup', arguments: '{}' },
{ type: 'function', id: 'call_b', name: 'Lookup', arguments: '{}' },
],
},
{ role: 'tool', content: [{ type: 'text', text: 'a' }], toolCalls: [], toolCallId: 'call_a' },
{ role: 'tool', content: [{ type: 'text', text: 'b' }], toolCalls: [], toolCallId: 'call_b' },
textMessage('user', 'next prompt'),
];
expect(strategy.computeCompactCount(messages, 'auto')).toBe(2);
});
it('reserves response context by default before the ratio threshold is reached', () => {
const strategy = new DefaultCompactionStrategy(() => 256_000);
expect(strategy.shouldCompact(210_000)).toBe(true);
expect(strategy.shouldBlock(210_000)).toBe(true);
});
it('backs off overflow compaction by at least five percent of the context window', () => {
const strategy = testCompactionStrategy(1_000);
const messages = [
textMessage('user', 'old user'),
textMessage('assistant', 'old assistant'),
...Array.from({ length: 20 }, () => [
textMessage('user', 'continue'),
textMessage('assistant', ''),
]).flat(),
];
const reduced = strategy.reduceCompactOnOverflow(messages);
const removed = messages.slice(reduced);
expect(reduced).toBeGreaterThan(0);
expect(estimateTokensForMessages(removed)).toBeGreaterThanOrEqual(50);
});
it('ignores reserved context when the reserve is not smaller than the model window', () => {
const strategy = new DefaultCompactionStrategy(() => 32_000, {
triggerRatio: 0.85,
blockRatio: 0.85,
reservedContextSize: 50_000,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 3,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
minOverflowReductionRatio: 0.05,
});
expect(strategy.shouldCompact(1)).toBe(false);
expect(strategy.shouldBlock(1)).toBe(false);
expect(strategy.shouldCompact(28_000)).toBe(true);
expect(strategy.shouldBlock(28_000)).toBe(true);
});
it('runs manual compaction and applies the compacted context', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('full_compaction.complete', () => {
resolve();
});
});
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
await ctx.rpc.beginCompaction({ instruction: 'Keep the important test facts.' });
await compacted;
await completed;
const events = ctx.newEvents();
expect(countEvents(events, 'context.append_message')).toBeGreaterThanOrEqual(6);
expect(countEvents(events, 'context.apply_compaction')).toBeGreaterThanOrEqual(1);
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: '[wire]', event: 'full_compaction.begin' }),
expect.objectContaining({ type: '[rpc]', event: 'compaction.started' }),
expect.objectContaining({ type: '[wire]', event: 'full_compaction.complete' }),
expect.objectContaining({ type: '[rpc]', event: 'compaction.completed' }),
]),
);
type WireCompleteEvent = {
type: '[wire]';
event: 'full_compaction.complete';
args: Record<string, unknown>;
};
const completeEvent = events.find((event): event is WireCompleteEvent => {
if (event === null || typeof event !== 'object') return false;
const candidate = event as { type?: unknown; event?: unknown };
return candidate.type === '[wire]' && candidate.event === 'full_compaction.complete';
});
expect(completeEvent?.args).toEqual({ agentId: 'main', time: '<time>' });
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AgentSwarm, CronCreate, CronDelete, CronList, EnterPlanMode, ExitPlanMode
messages:
user: text "old user one"
assistant: text "old assistant one"
user: text "old user two"
assistant: text "old assistant two"
user: text "recent user three"
assistant: text "recent assistant three"
user: text <compaction-instruction>
`);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'old user two' },
{ role: 'user', text: 'recent user three' },
{
role: 'user',
text: expect.stringContaining('Compacted summary.'),
},
{ role: 'user', text: buildCompactionContinuationText() },
]);
expect(ctx.context.get().at(-2)?.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('The conversation so far has been compacted'),
});
expect(ctx.context.get().at(-1)).toMatchObject({
role: 'user',
origin: { kind: 'injection', variant: 'compaction_continuation' },
});
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
agent_id: 'main',
source: 'manual',
tokens_before: 6_135,
tokens_after: expect.any(Number),
duration_ms: expect.any(Number),
compacted_count: 6,
retry_count: 0,
thinking_effort: 'off',
input_tokens: 1192,
output_tokens: 8,
input_cache_read: 0,
input_cache_creation: 0,
}),
});
await ctx.expectResumeMatches();
});
it('holds the loop quiescence lease for the full manual compaction', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
let release!: () => void;
const canCompact = new Promise<void>((resolve) => {
release = resolve;
});
let started!: () => void;
const compactionStarted = new Promise<void>((resolve) => {
started = resolve;
});
const hook = ctx.get(IAgentFullCompactionService).hooks.onWillCompact.register(
'test-quiescence',
async (_task, next) => {
started();
await canCompact;
await next();
},
);
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
expect(ctx.get(IAgentFullCompactionService).begin({ source: 'manual' })).toBe(true);
await compactionStarted;
expect(ctx.get(IAgentLoopService).tryAcquireQuiescence()).toBeUndefined();
release();
await ctx.get(IAgentFullCompactionService).compacting?.promise;
const lease = ctx.get(IAgentLoopService).tryAcquireQuiescence();
expect(lease).toBeDefined();
lease?.dispose();
hook.dispose();
});
it('keeps the active profile system prompt frozen after compaction without resetting active tools', async () => {
const homeDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-home-'));
const workDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-work-'));
try {
writeFileSync(join(workDir, 'AGENTS.md'), 'old project instructions', 'utf-8');
const ctx = testAgent(
execEnvServices({ hostFs: new HostFileSystem() }),
hostEnvironmentServices(homeDir),
{ autoConfigure: false, cwd: workDir },
);
ctx.configureRuntimeModel(CATALOGUED_PROVIDER, CATALOGUED_MODEL_CAPABILITIES);
const profile = ctx.get(IAgentProfileService);
await profile.applyProfile(EXACT_COMPACTION_PROFILE);
profile.update({ activeToolNames: ['Read'] });
const before = profile.data().systemPrompt;
expect(before).toBe(exactCompactionPrompt(workDir, 'old project instructions'));
writeFileSync(join(workDir, 'AGENTS.md'), 'new project instructions', 'utf-8');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
await ctx.rpc.beginCompaction({});
await completed;
expect(profile.data().systemPrompt).toBe(before);
expect(profile.getActiveToolNames()).toEqual(['Read']);
} finally {
rmSync(homeDir, { recursive: true, force: true });
rmSync(workDir, { recursive: true, force: true });
}
});
it('rejects a manual compaction while a turn is active', async () => {
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') }));
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: ['Bash'],
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start the active turn' }] });
const approval = await ctx.takeApprovalRequest();
expect(ctx.get(IAgentLoopService).snapshot().activeTurnId).toBeDefined();
await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({
code: 'compaction.unable',
message: 'Cannot compact while a turn is active. Wait for it to finish, then retry.',
});
const events = ctx.newEvents();
expect(eventIndex(events, 'full_compaction.begin')).toBe(-1);
expect(eventIndex(events, 'compaction.started')).toBe(-1);
expect(ctx.get(IAgentFullCompactionService).compacting).toBeNull();
expect(ctx.llmCalls).toHaveLength(1);
ctx.mockNextResponse({ type: 'text', text: 'Turn done.' });
approval.respond({ decision: 'rejected', selectedLabel: 'reject' });
await ctx.untilTurnEnd();
expect(ctx.get(IAgentLoopService).snapshot().activeTurnId).toBeUndefined();
});
it('projects the compacted prefix before sending the summary request', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
await ctx.dispatch({
type: 'context.append_message',
message: { role: 'assistant', content: [], toolCalls: [] },
});
ctx.appendExchange(3, 'old user two', 'old assistant two', 40);
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('full_compaction.complete', () => {
resolve();
});
});
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
await ctx.rpc.beginCompaction({ instruction: 'Keep the important test facts.' });
await compacted;
const [compactionCall] = ctx.llmCalls;
expect(compactionCall?.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'user',
'assistant',
'user',
]);
expect(
compactionCall?.history.some(
(message) =>
message.role === 'assistant' &&
message.content.length === 0 &&
message.toolCalls.length === 0,
),
).toBe(false);
});
it('force-refreshes OAuth credentials on compaction 401 and treats replay 401 as provider auth error', async () => {
const tokenCalls: Array<boolean | undefined> = [];
const authKeys: string[] = [];
const oauthOptions = oauthTestAgentOptions(async (options) => {
tokenCalls.push(options?.force);
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
});
const generate: GenerateFn = requesterFromGenerateFn(async (
_provider,
_system,
_tools,
_history,
_callbacks,
options,
) => {
authKeys.push(options?.auth?.apiKey ?? '<missing>');
if (authKeys.length <= 2) {
throw new APIStatusError(401, 'Unauthorized', 'req-compact-401');
}
return textResult('Recovered compacted summary.');
});
const ctx = testAgent(oauthOptions.services, {
initialConfig: oauthOptions.initialConfig,
generate,
});
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const outcome = ctx.onceAny(['full_compaction.complete', 'error']);
await ctx.rpc.beginCompaction({});
expect(await outcome).toBe('error');
expect(ctx.newEvents()).toContainEqual(
expect.objectContaining({
event: 'error',
args: expect.objectContaining({
code: 'provider.auth_error',
details: expect.objectContaining({
statusCode: 401,
requestId: 'req-compact-401',
}),
}),
}),
);
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([undefined, true]);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'assistant', text: 'old assistant one' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
]);
const retryOutcome = ctx.onceAny(['full_compaction.complete', 'error']);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
expect(await retryOutcome).toBe('full_compaction.complete');
await completed;
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token', 'fresh-token']);
expect(tokenCalls).toEqual([undefined, true, undefined]);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'recent user two' },
{
role: 'user',
text: expect.stringContaining('Recovered compacted summary.'),
},
{ role: 'user', text: buildCompactionContinuationText() },
]);
await ctx.expectResumeMatches();
});
it('fires PreCompact and PostCompact hooks from the compaction module', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-compact-hooks-'));
const hookLog = join(dir, 'hooks.jsonl');
const hookCommand = hookPayloadLoggerCommand(hookLog);
const ctx = testAgent({
hookEngine: makeHookRunner(
[
{ event: 'PreCompact', matcher: 'auto', command: hookCommand, timeout: 5 },
{ event: 'PostCompact', matcher: 'auto', command: hookCommand, timeout: 5 },
],
{ cwd: dir },
),
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
const compacted = ctx.once('full_compaction.complete');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
ctx.get(IAgentFullCompactionService).begin({ source: 'auto', instruction: undefined });
await compacted;
await vi.waitFor(() => {
expect(readHookPayloads(hookLog).map((payload) => payload['hook_event_name'])).toEqual([
'PreCompact',
'PostCompact',
]);
});
const [pre, post] = readHookPayloads(hookLog);
expect(pre).toMatchObject({
hook_event_name: 'PreCompact',
session_id: 'test-session',
cwd: dir,
trigger: 'auto',
token_count: 6_135,
});
expect(post).toMatchObject({
hook_event_name: 'PostCompact',
session_id: 'test-session',
cwd: dir,
trigger: 'auto',
estimated_token_count: ctx.contextData().tokenCount,
});
});
it('cancels while waiting for a PreCompact hook', async () => {
let preCompactSignal: AbortSignal | undefined;
const trigger = vi.fn(
async (_event: string, args?: { signal?: AbortSignal }) => {
preCompactSignal = args?.signal;
await new Promise<void>((resolve) => {
args?.signal?.addEventListener(
'abort',
() => {
resolve();
},
{ once: true },
);
});
return [];
},
);
const ctx = testAgent({ hookEngine: { trigger } as unknown as IExternalHooksRunnerService });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
void ctx.rpc.beginCompaction({ instruction: undefined });
await vi.waitFor(() => {
expect(preCompactSignal).toBeInstanceOf(AbortSignal);
});
const canceled = ctx.once('compaction.cancelled');
void ctx.rpc.cancelCompaction({});
await canceled;
expect(trigger).toHaveBeenCalledWith(
'PreCompact',
expect.objectContaining({
matcherValue: 'manual',
inputData: expect.objectContaining({ trigger: 'manual' }),
}),
);
expect(preCompactSignal?.aborted).toBe(true);
expect(ctx.llmCalls).toHaveLength(0);
});
it('reports compaction retry_count after a retryable generation failure recovers', async () => {
const records: TelemetryRecord[] = [];
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) {
throw new APIConnectionError('socket hang up');
}
return textResult('Recovered compacted summary.', 'trace-compact-1');
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(2);
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'manual',
tokens_before: expect.any(Number),
retry_count: 1,
trace_id: 'trace-compact-1',
}),
});
await ctx.expectResumeMatches();
});
it('retries any compaction request error indefinitely when KIMI_CODE_INFINITE_RETRY is set', async () => {
vi.stubEnv('KIMI_CODE_INFINITE_RETRY', '1');
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) throw new APIStatusError(400, 'endpoint broken', null, 1);
if (attempts === 2) throw new APIStatusError(404, 'model not found', null, 1);
return textResult('Recovered compacted summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(3);
await ctx.expectResumeMatches();
});
it('lets context overflow reach compaction shrink instead of retrying when KIMI_CODE_INFINITE_RETRY is set', async () => {
vi.stubEnv('KIMI_CODE_INFINITE_RETRY', '1');
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) throw new APIContextOverflowError(400, 'context length exceeded');
return textResult('Recovered compacted summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(2);
await ctx.expectResumeMatches();
});
it('recovers from an image-format rejection with a media-stripped resend', async () => {
let attempts = 0;
let sawMedia = false;
let sawStrippedResend = false;
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => {
attempts += 1;
const hasMedia = history.some((message) =>
message.content.some((part) => part.type === 'image_url' || part.type === 'video_url'),
);
if (hasMedia) {
sawMedia = true;
throw new APIStatusError(400, 'unsupported image format: image/avif');
}
sawStrippedResend = true;
return textResult('Recovered compacted summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendRichToolExchange();
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(2);
expect(sawMedia).toBe(true);
expect(sawStrippedResend).toBe(true);
await ctx.expectResumeMatches();
});
it('recovers from a request-body 413 with a media-degraded resend', async () => {
let attempts = 0;
let sawFullMedia = false;
let sawDegradedResend = false;
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => {
attempts += 1;
const mediaCount = history.reduce(
(count, message) =>
count +
message.content.filter((part) => part.type === 'image_url' || part.type === 'video_url')
.length,
0,
);
if (mediaCount > 2) {
sawFullMedia = true;
throw new APIRequestTooLargeError(413, 'Request Entity Too Large');
}
sawDegradedResend = true;
return textResult('Recovered compacted summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendRichToolExchange();
ctx.appendRichToolExchange();
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(2);
expect(sawFullMedia).toBe(true);
expect(sawDegradedResend).toBe(true);
await ctx.expectResumeMatches();
});
it('retries compaction responses with empty summaries before applying context', async () => {
vi.useFakeTimers();
const firstEmptySummary = deferred<void>();
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts <= 2) {
if (attempts === 1) firstEmptySummary.resolve();
return textResult(attempts === 1 ? '' : ' \n');
}
return textResult('Recovered compacted summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await firstEmptySummary.promise;
await vi.advanceTimersByTimeAsync(10_000);
await compacted;
await completed;
expect(attempts).toBe(3);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'recent user two' },
{ role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` },
{ role: 'user', text: buildCompactionContinuationText() },
]);
expect(
ctx.allEvents.filter((event) => event.event === 'compaction.completed'),
).toEqual([
expect.objectContaining({
args: expect.objectContaining({
result: expect.objectContaining({
summary: expect.stringContaining('Recovered compacted summary.'),
}),
}),
}),
]);
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('reduces the compacted prefix and retries when the model returns only thinking content', async () => {
vi.useFakeTimers();
const firstThinkOnly = deferred<void>();
const inputs: string[][] = [];
const generate = realKosongGenerate((attempt, history) => {
inputs.push(inputHistorySnapshot(history));
if (attempt === 1) {
firstThinkOnly.resolve();
return mockStreamedMessage([
{ type: 'think', think: 'Reasoning about the summary but never writing it...' },
]);
}
return mockStreamedMessage([{ type: 'text', text: 'Recovered compacted summary.' }]);
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await firstThinkOnly.promise;
await vi.advanceTimersByTimeAsync(10_000);
await compacted;
await completed;
expect(inputs).toHaveLength(2);
expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'recent user two' },
{ role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` },
{ role: 'user', text: buildCompactionContinuationText() },
]);
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('reduces the compacted prefix and retries when compaction receives plain 413', async () => {
vi.useFakeTimers();
const firstAttemptFailed = deferred<void>();
let attempts = 0;
const inputs: string[][] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => {
attempts += 1;
inputs.push(inputHistorySnapshot(history));
if (attempts === 1) {
firstAttemptFailed.resolve();
throw new APIStatusError(413, 'Request Entity Too Large', 'req-compact-plain-413');
}
return textResult('Recovered compacted summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 20_000,
},
});
ctx.appendExchange(1, 'old user one', `old assistant one ${'x'.repeat(45_000)}`, 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
await vi.advanceTimersByTimeAsync(10_000);
await compacted;
await completed;
expect(inputs).toHaveLength(2);
expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length);
const compactedHistory = ctx.compactHistory();
expect(compactedHistory.some((message) => message.text.includes('old assistant one'))).toBe(false);
expect(compactedHistory.some((message) => message.text.includes('Recovered compacted summary.'))).toBe(true);
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('fails after exhausting retries when the model only ever returns thinking content', async () => {
vi.useFakeTimers();
const records: TelemetryRecord[] = [];
const inputs: string[][] = [];
const firstResponse = deferred<void>();
const generate = realKosongGenerate((attempt, history) => {
inputs.push(inputHistorySnapshot(history));
if (attempt === 1) {
firstResponse.resolve();
}
return mockStreamedMessage([
{ type: 'think', think: 'Still only thinking, no summary produced.' },
]);
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await firstResponse.promise;
await vi.advanceTimersByTimeAsync(60_000);
await failed;
expect(inputs).toHaveLength(5);
expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length);
expect(records).toContainEqual({
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
retry_count: 1,
error_type: 'APIEmptyResponseError',
}),
});
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'assistant', text: 'old assistant one' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
]);
});
it('fails fast without shrinking when the provider filters the compaction response', async () => {
const inputs: string[][] = [];
const generate = realKosongGenerate((_attempt, history) => {
inputs.push(inputHistorySnapshot(history));
return mockStreamedMessage(
[{ type: 'think', think: 'Filtered while reasoning about the summary.' }],
null,
{ finishReason: 'filtered', rawFinishReason: 'content_filter' },
);
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
expect(inputs).toHaveLength(1);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'assistant', text: 'old assistant one' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
]);
});
it('fails the compaction instead of compacting an empty history when overflow shrink drops everything', async () => {
let calls = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
calls += 1;
if (calls === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-shrink-empty');
}
return textResult('Groundless summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'small user one', 'small assistant one', 20);
ctx.context.append({
role: 'user',
content: [{ type: 'text', text: 'X'.repeat(400_000) }],
toolCalls: [],
});
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
expect(calls).toBe(1);
expect(ctx.context.get()).toHaveLength(3);
});
it('waits before retrying compaction generation after a retryable failure', async () => {
vi.useFakeTimers();
const firstAttemptFailed = deferred<void>();
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) {
firstAttemptFailed.resolve();
throw new APIConnectionError('socket hang up');
}
return textResult('Recovered compacted summary.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
await vi.advanceTimersByTimeAsync(299);
expect(attempts).toBe(1);
await vi.advanceTimersByTimeAsync(10_000);
await compacted;
expect(attempts).toBe(2);
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('cancels retry backoff with the failed compaction request trace', async () => {
vi.useFakeTimers();
const records: TelemetryRecord[] = [];
const firstAttemptFailed = deferred<void>();
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) {
firstAttemptFailed.resolve();
}
throw new APIStatusError(429, 'rate limited', null, null, 'trace-compact-retry');
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const cancelled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
const fullCompaction = ctx.get(IAgentFullCompactionService);
for (let i = 0; i < 10 && fullCompaction.compacting?.traceId === undefined; i += 1) {
await Promise.resolve();
}
expect(fullCompaction.compacting?.traceId).toBe('trace-compact-retry');
void ctx.rpc.cancelCompaction({});
await cancelled;
await vi.advanceTimersByTimeAsync(10_000);
expect(attempts).toBe(1);
expect(records).toContainEqual({
event: 'cancel',
properties: {
agent_id: 'main',
from: 'compacting',
trace_id: 'trace-compact-retry',
mode: 'agent',
model: 'kimi-code',
protocol: 'openai',
provider_type: 'kimi',
},
});
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('cancels the compaction lifecycle when manual compaction generation fails', async () => {
const records: TelemetryRecord[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async () => {
throw new Error('compaction exploded');
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
const events = ctx.newEvents();
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: '[wire]', event: 'full_compaction.cancel' }),
expect.objectContaining({ type: '[rpc]', event: 'compaction.cancelled' }),
expect.objectContaining({ type: '[rpc]', event: 'error' }),
]),
);
expect(eventIndex(events, 'compaction.cancelled')).toBeLessThan(eventIndex(events, 'error'));
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'assistant', text: 'old assistant one' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
]);
expect(records).toContainEqual({
event: 'compaction_failed',
properties: expect.objectContaining({
agent_id: 'main',
source: 'manual',
tokens_before: expect.any(Number),
duration_ms: expect.any(Number),
round: 1,
retry_count: 0,
error_type: 'Error',
}),
});
expect(
records.find((record) => record.event === 'compaction_failed')?.properties,
).not.toHaveProperty('tokens_after');
await ctx.expectResumeMatches();
});
it('attaches the failed request trace id to compaction_failed', async () => {
const records: TelemetryRecord[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async () => {
throw new APIStatusError(400, 'Bad request', null, null, 'trace-compact-fail');
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
expect(records).toContainEqual({
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
error_type: 'APIStatusError',
trace_id: 'trace-compact-fail',
}),
});
await ctx.expectResumeMatches();
});
it('attributes compaction_failed to the in-flight request trace on a mid-stream failure', async () => {
const records: TelemetryRecord[] = [];
const generate = realKosongGenerate(() => {
const base = mockStreamedMessage([], 'trace-mid-stream');
return {
...base,
async *[Symbol.asyncIterator]() {
yield { type: 'text', text: 'partial summary' } as StreamedMessagePart;
throw new Error('stream reset');
},
};
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
ctx.get(ITelemetryService).setContext({ trace_id: 'trace-turn-1' });
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
const apiError = records.find((record) => record.event === 'api_error');
expect(apiError?.properties?.['trace_id']).toBe('trace-mid-stream');
expect(records).toContainEqual({
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
trace_id: 'trace-mid-stream',
}),
});
expect(ctx.get(ITelemetryService).getContext().trace_id).toBe('trace-turn-1');
await ctx.expectResumeMatches();
});
it('fails a blocked turn when auto compaction generation fails', async () => {
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
throw new APIStatusError(400, 'Bad request');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: { ...CATALOGUED_MODEL_CAPABILITIES, max_context_tokens: 14 },
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 1);
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'x'.repeat(40) }] });
const events = await ctx.untilTurnEnd();
expect(attempts).toBe(1);
expect(events).not.toContainEqual(expect.objectContaining({ event: 'error' }));
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
turnId: 0,
reason: 'failed',
error: expect.objectContaining({
code: 'compaction.failed',
message: 'APIStatusError: Bad request',
}),
interruptReason: 'error',
}),
}),
);
const errorEvents = (ctx.newEvents() as readonly { event?: string }[]).filter(
(entry) => entry.event === 'error',
);
expect(errorEvents).toHaveLength(1);
expect(errorEvents[0]).toMatchObject({
event: 'error',
args: expect.objectContaining({
code: 'compaction.failed',
message: 'APIStatusError: Bad request',
}),
});
await ctx.expectResumeMatches();
});
it('aborts an in-flight compaction when the agent is disposed', async () => {
const started = deferred<void>();
let signal: AbortSignal | undefined;
const generate: GenerateFn = requesterFromGenerateFn(async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => {
signal = options?.signal;
started.resolve();
return new Promise(() => {});
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const pending = ctx.rpc.beginCompaction({}).catch(() => {});
await started.promise;
await ctx.dispose();
expect(signal?.aborted).toBe(true);
await pending;
});
it('names truncated compaction responses when retries are exhausted', async () => {
vi.useFakeTimers();
const firstAttemptFinished = deferred<void>();
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) {
firstAttemptFinished.resolve();
}
return {
...textResult('Partial summary.'),
finishReason: 'truncated',
rawFinishReason: 'length',
};
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await firstAttemptFinished.promise;
await vi.advanceTimersByTimeAsync(60_000);
await failed;
expect(attempts).toBe(4);
expect(ctx.newEvents()).toContainEqual(
expect.objectContaining({
event: 'error',
args: expect.objectContaining({
code: 'compaction.failed',
message:
'CompactionTruncatedError: Compaction response was truncated before producing a complete summary.',
name: 'Error2',
}),
}),
);
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('reports compaction retry_count when retryable generation failures are exhausted', async () => {
vi.useFakeTimers();
const records: TelemetryRecord[] = [];
const firstAttemptFailed = deferred<void>();
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) {
firstAttemptFailed.resolve();
}
throw new APIConnectionError('socket hang up');
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
await vi.advanceTimersByTimeAsync(60_000);
await failed;
expect(attempts).toBe(5);
expect(records).toContainEqual({
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
tokens_before: expect.any(Number),
duration_ms: expect.any(Number),
retry_count: 4,
error_type: 'APIConnectionError',
}),
});
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('honors loopControl.compactionMaxAttempts for retryable generation failures', async () => {
vi.useFakeTimers();
const records: TelemetryRecord[] = [];
const firstAttemptFailed = deferred<void>();
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) {
firstAttemptFailed.resolve();
}
throw new APIConnectionError('socket hang up');
});
const ctx = testAgent({
generate,
telemetry: recordingTelemetry(records),
initialConfig: {
providers: {},
loopControl: { compactionMaxAttempts: 2 },
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
await vi.advanceTimersByTimeAsync(60_000);
await failed;
expect(attempts).toBe(2);
expect(records).toContainEqual({
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
retry_count: 1,
error_type: 'APIConnectionError',
}),
});
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('fails a truncated compaction immediately when compactionMaxAttempts is 1', async () => {
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
return {
...textResult('Partial summary.'),
finishReason: 'truncated',
rawFinishReason: 'length',
};
});
const ctx = testAgent({
generate,
initialConfig: {
providers: {},
loopControl: { compactionMaxAttempts: 1 },
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
expect(attempts).toBe(1);
expect(ctx.newEvents()).toContainEqual(
expect.objectContaining({
event: 'error',
args: expect.objectContaining({
code: 'compaction.failed',
name: 'Error2',
}),
}),
);
await ctx.expectResumeMatches();
});
it('counts requests across recovery paths against compactionMaxAttempts', async () => {
vi.useFakeTimers();
const firstAttemptFailed = deferred<void>();
let attempts = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
attempts += 1;
if (attempts === 1) {
firstAttemptFailed.resolve();
throw new APIConnectionError('socket hang up');
}
return {
...textResult('Partial summary.'),
finishReason: 'truncated',
rawFinishReason: 'length',
};
});
const ctx = testAgent({
generate,
initialConfig: {
providers: {},
loopControl: { compactionMaxAttempts: 2 },
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
await vi.advanceTimersByTimeAsync(60_000);
await failed;
expect(attempts).toBe(2);
vi.useRealTimers();
await ctx.expectResumeMatches();
});
it('renders rich compacted history without dropping non-text context', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendRichToolExchange();
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('full_compaction.complete', () => {
resolve();
});
});
ctx.mockNextResponse({ type: 'text', text: 'Rich summary.' });
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
await ctx.expectResumeMatches();
});
it('closes an unresolved tool exchange in the compaction prompt with a synthetic result', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendPartiallyResolvedParallelToolExchange();
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted before open tools.' });
await ctx.rpc.beginCompaction({ instruction: 'Keep stable facts.' });
await compacted;
await completed;
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AgentSwarm, CronCreate, CronDelete, CronList, EnterPlanMode, ExitPlanMode
messages:
user: text "old user one"
assistant: text "old assistant one"
user: text "run both tools"
assistant: [] calls call_open_one:LookupOne { "query": "one" }, call_open_two:LookupTwo { "query": "two" }
tool[call_open_one]: text "one result"
tool[call_open_two]: text "Tool result is not available in the current context. Do not assume the tool completed successfully."
user: text <compaction-instruction>
`);
expect(ctx.context.get().map((message) => message.role)).toEqual([
'user',
'user',
'user',
'user',
]);
await ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_two',
toolCallId: 'call_open_two',
result: { output: 'two result' },
},
});
expect(ctx.context.get().map((message) => message.role)).toEqual([
'user',
'user',
'user',
'user',
]);
await ctx.expectResumeMatches();
});
it('keeps messages appended while compacting an unchanged prefix', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted prefix.' });
await ctx.rpc.beginCompaction({});
ctx.appendUserMessage([{ type: 'text', text: 'new user while compacting' }]);
await compacted;
await completed;
const events = ctx.newEvents();
expect(countEvents(events, 'context.append_message')).toBeGreaterThanOrEqual(5);
expect(countEvents(events, 'context.apply_compaction')).toBeGreaterThanOrEqual(1);
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: '[wire]', event: 'full_compaction.begin' }),
expect.objectContaining({ type: '[wire]', event: 'full_compaction.complete' }),
expect.objectContaining({ type: '[rpc]', event: 'compaction.completed' }),
]),
);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AgentSwarm, CronCreate, CronDelete, CronList, EnterPlanMode, ExitPlanMode
messages:
user: text "old user one"
assistant: text "old assistant one"
user: text "recent user two"
assistant: text "recent assistant two"
user: text <compaction-instruction>
`);
expect(ctx.compactHistory()).toMatchInlineSnapshot(`
[
{
"role": "user",
"text": "old user one",
},
{
"role": "user",
"text": "recent user two",
},
{
"role": "user",
"text": "new user while compacting",
},
{
"role": "user",
"text": "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed.
Compacted prefix.",
},
{
"role": "user",
"text": "<system-reminder>
Context compaction is complete — continue the work that was in progress when it began.
</system-reminder>",
},
]
`);
await ctx.expectResumeMatches();
});
it('cancels a manual compaction when an assistant exchange is appended while compacting', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 4_000,
},
});
ctx.appendExchange(
1,
`old user one ${'u'.repeat(14_000)}`,
`old assistant one ${'a'.repeat(14_000)}`,
6_000,
);
const firstSummary = `large manual summary ${'x'.repeat(14_000)}`;
ctx.mockNextResponse({ type: 'text', text: firstSummary });
const cancelled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
ctx.appendExchange(2, 'new user while compacting', 'new assistant while compacting', 6_000);
await cancelled;
const events = ctx.newEvents();
expect(countEvents(events, 'full_compaction.cancel')).toBe(1);
expect(countEvents(events, 'compaction.started')).toBe(1);
expect(countEvents(events, 'compaction.completed')).toBe(0);
expect(ctx.llmCalls).toHaveLength(1);
const [firstCompactionCall] = ctx.llmCalls;
expect(firstCompactionCall?.history.map(messageText)).not.toContain('new user while compacting');
expect(ctx.compactHistory()).toEqual([
{
role: 'user',
text: `old user one ${'u'.repeat(14_000)}`,
},
{
role: 'assistant',
text: `old assistant one ${'a'.repeat(14_000)}`,
},
{
role: 'user',
text: 'new user while compacting',
},
{
role: 'assistant',
text: 'new assistant while compacting',
},
]);
await ctx.expectResumeMatches();
});
it('auto-compacts very large context in one full-history round when the summarizer accepts it', async () => {
const maxContextTokens = 22_000;
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
tools: SNAPSHOT_VISIBLE_TOOLS,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: maxContextTokens,
},
});
for (let i = 1; i <= 22; i++) {
ctx.appendAssistantTextWithUsage(
i,
`history chunk ${String(i)} ${'x'.repeat(7_200)}`,
i * 1_850,
);
}
const initialTokens = estimateTokensForMessages(ctx.context.get());
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Auto summary.' });
ctx.get(IAgentFullCompactionService).begin({ source: 'auto', instruction: undefined });
await completed;
await ctx.wire.flush();
const events = ctx.newEvents();
const compactedPrefixSizes = ctx.llmCalls.map((call) =>
estimateTokensForMessages(call.history.slice(0, -1)),
);
expect(initialTokens).toBeGreaterThan(maxContextTokens);
expect(countEvents(events, 'full_compaction.complete')).toBe(1);
expect(countEvents(events, 'compaction.completed')).toBe(1);
expect(compactedPrefixSizes).toHaveLength(1);
expect(compactedPrefixSizes[0]).toBe(initialTokens);
expect(ctx.contextData().tokenCount).toBeLessThan(maxContextTokens * 0.85);
await ctx.expectResumeMatches();
});
it('cancels when the compacted prefix changes before completion', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const canceled = ctx.once('full_compaction.cancel');
ctx.mockNextResponse({ type: 'text', text: 'Stale summary.' });
await ctx.rpc.beginCompaction({});
await ctx.rpc.clearContext({});
await canceled;
const events = ctx.newEvents();
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: '[wire]', event: 'full_compaction.begin' }),
expect.objectContaining({ type: '[wire]', event: 'context.clear' }),
expect.objectContaining({ type: '[wire]', event: 'full_compaction.cancel' }),
expect.objectContaining({ type: '[rpc]', event: 'compaction.cancelled' }),
]),
);
expect(eventIndex(events, 'full_compaction.begin')).toBeLessThan(
eventIndex(events, 'context.clear'),
);
expect(eventIndex(events, 'context.clear')).toBeLessThan(
eventIndex(events, 'full_compaction.cancel'),
);
expect(countEvents(events, 'context.apply_compaction')).toBe(0);
expect(countEvents(events, 'full_compaction.complete')).toBe(0);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AgentSwarm, CronCreate, CronDelete, CronList, EnterPlanMode, ExitPlanMode
messages:
user: text "old user one"
assistant: text "old assistant one"
user: text "recent user two"
assistant: text "recent assistant two"
user: text <compaction-instruction>
`);
expect(ctx.compactHistory()).toMatchInlineSnapshot(`[]`);
await ctx.expectResumeMatches();
});
it('cancels when a droppable user-role tail is appended during the summary request', async () => {
let ctx!: TestAgentContext;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
ctx.appendSystemReminder('RACE-NOTIFY-OUTPUT', {
kind: 'injection',
variant: 'race-notification',
});
return textResult('Stale compacted summary.');
});
ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
const cancelled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
await cancelled;
expect(ctx.compactHistory().map((entry) => entry.text).join('\n')).toContain(
'RACE-NOTIFY-OUTPUT',
);
expect(countEvents(ctx.newEvents(), 'full_compaction.complete')).toBe(0);
await ctx.expectResumeMatches();
});
it('blocks the turn until auto compaction finishes', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 100);
ctx.appendExchange(2, 'old user two', 'old assistant two', 200);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 950_000);
ctx.mockNextResponse({ type: 'text', text: 'Auto compacted summary.' });
ctx.mockNextResponse({ type: 'text', text: 'I can answer after compaction.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Answer after compacting' }] });
const events = await ctx.untilTurnEnd();
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: '[wire]', event: 'context.append_message' }),
expect.objectContaining({ type: '[wire]', event: 'turn.prompt' }),
expect.objectContaining({ type: '[rpc]', event: 'turn.started' }),
expect.objectContaining({ type: '[wire]', event: 'full_compaction.begin' }),
expect.objectContaining({ type: '[rpc]', event: 'compaction.blocked' }),
expect.objectContaining({ type: '[wire]', event: 'full_compaction.complete' }),
expect.objectContaining({ type: '[rpc]', event: 'turn.step.started' }),
expect.objectContaining({ type: '[rpc]', event: 'turn.ended' }),
]),
);
expect(eventIndex(events, 'turn.prompt')).toBeLessThan(
eventIndex(events, 'full_compaction.begin'),
);
expect(eventIndex(events, 'full_compaction.begin')).toBeLessThan(
eventIndex(events, 'full_compaction.complete'),
);
expect(eventIndex(events, 'compaction.blocked')).toBeLessThan(
eventIndex(events, 'full_compaction.complete'),
);
expect(eventIndex(events, 'full_compaction.complete')).toBeLessThan(
eventIndex(events, 'turn.step.started'),
);
expect(ctx.llmInputs()).toMatchInlineSnapshot(`
call 1:
system: <system-prompt>
tools: Agent, AgentSwarm, CronCreate, CronDelete, CronList, EnterPlanMode, ExitPlanMode
messages:
user: text "old user one"
assistant: text "old assistant one"
user: text "old user two"
assistant: text "old assistant two"
user: text "recent user three"
assistant: text "recent assistant three"
user: text "Answer after compacting"
user: text <compaction-instruction>
call 2:
messages:
user: text "old user one\\n\\nold user two\\n\\nrecent user three\\n\\nAnswer after compacting"
user: text "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed.\\nAuto compacted summary."
user: text "<system-reminder>\\nContext compaction is complete — continue the work that was in progress when it began.\\n</system-reminder>"
`);
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'auto',
tokens_before: 6_142,
tokens_after: 6_159,
compacted_count: 7,
retry_count: 0,
}),
});
await ctx.expectResumeMatches();
});
it('attributes background auto compaction to the turn that started it', async () => {
const compactionRequested = deferred<void>();
const releaseCompaction = deferred<void>();
const records: TelemetryRecord[] = [];
let ctx!: TestAgentContext;
let llmCallCount = 0;
const generate: GenerateFn = requesterFromGenerateFn(async () => {
llmCallCount += 1;
if (llmCallCount === 1) return textResult('Turn response.');
if (llmCallCount === 2) {
compactionRequested.resolve();
await releaseCompaction.promise;
return textResult('Background compacted summary.');
}
throw new Error(`Unexpected generate call ${String(llmCallCount)}`);
});
ctx = testAgent({
generate,
telemetry: recordingTelemetry(records),
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
ctx.get(IAgentLoopService).hooks.onDidFinishStep.register(
'test-auto-compaction',
async (_step, next) => {
if (!ctx.get(IAgentFullCompactionService).begin({ source: 'auto' })) {
throw new Error('Expected auto compaction to start');
}
await next();
},
);
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start background compaction' }] });
await compactionRequested.promise;
await ctx.untilTurnEnd();
releaseCompaction.resolve();
await ctx.once('compaction.completed');
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
agent_id: 'main',
turn_id: 0,
source: 'auto',
}),
});
await ctx.expectResumeMatches();
});
it('keeps a deferred system reminder behind an unresolved tool exchange across compaction', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendUnresolvedToolExchange(0);
ctx.appendSystemReminder('host note', {
kind: 'injection',
variant: 'host',
});
expect(ctx.context.get().map((m) => m.role)).toEqual([
'user',
'assistant',
'user',
'assistant',
'user',
]);
expect(ctx.project().map((m) => m.role)).toEqual([
'user',
'assistant',
'user',
'assistant',
'tool',
'tool',
'user',
]);
const compacted = ctx.once('full_compaction.complete');
ctx.mockNextResponse({ type: 'text', text: 'Compacted with open tools.' });
await ctx.rpc.beginCompaction({});
await compacted;
expect(ctx.context.get().map((m) => m.role)).toEqual([
'user',
'user',
'user',
'user',
]);
expect(ctx.context.get().at(-2)?.origin).toEqual({ kind: 'compaction_summary' });
expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' });
await ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_unresolved_one',
toolCallId: 'call_unresolved_one',
result: { output: 'one result' },
},
});
await ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_unresolved_two',
toolCallId: 'call_unresolved_two',
result: { output: 'two result' },
},
});
expect(ctx.context.get().map((m) => m.role)).toEqual([
'user',
'user',
'user',
'user',
]);
});
it('keeps a deferred system reminder behind a partially resolved tool exchange across compaction', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendUnresolvedToolExchange(1);
ctx.appendSystemReminder('host note', {
kind: 'injection',
variant: 'host',
});
expect(ctx.context.get().map((m) => m.role)).toEqual([
'user',
'assistant',
'user',
'assistant',
'tool',
'user',
]);
expect(ctx.project().map((m) => m.role)).toEqual([
'user',
'assistant',
'user',
'assistant',
'tool',
'tool',
'user',
]);
const compacted = ctx.once('full_compaction.complete');
ctx.mockNextResponse({ type: 'text', text: 'Compacted with partial tools.' });
await ctx.rpc.beginCompaction({});
await compacted;
expect(ctx.context.get().map((m) => m.role)).toEqual([
'user',
'user',
'user',
'user',
]);
expect(ctx.context.get().at(-2)?.origin).toEqual({ kind: 'compaction_summary' });
expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' });
await ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_unresolved_two',
toolCallId: 'call_unresolved_two',
result: { output: 'two result' },
},
});
expect(ctx.context.get().map((m) => m.role)).toEqual([
'user',
'user',
'user',
'user',
]);
});
it('compacts a single user message and keeps it ahead of the summary', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendUserMessage([{ type: 'text', text: 'only pending user' }]);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Single message summary.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'only pending user' },
{
role: 'user',
text: `${COMPACTION_SUMMARY_PREFIX}\nSingle message summary.`,
},
{ role: 'user', text: buildCompactionContinuationText() },
]);
await ctx.expectResumeMatches();
});
it('manual compaction can run after a previous single-message compaction', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendUserMessage([{ type: 'text', text: 'only pending user' }]);
ctx.mockNextResponse({ type: 'text', text: 'Single message summary.' });
await ctx.rpc.beginCompaction({});
await ctx.once('compaction.completed');
ctx.clearContext();
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted after single-message compact.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(ctx.llmCalls).toHaveLength(2);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'recent user two' },
{
role: 'user',
text: expect.stringContaining('Compacted after single-message compact.'),
},
{ role: 'user', text: buildCompactionContinuationText() },
]);
await ctx.expectResumeMatches();
});
it('rejects manual compaction with compaction.unable when history is empty', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({
code: 'compaction.unable',
});
expect(ctx.llmCalls).toHaveLength(0);
await ctx.expectResumeMatches();
});
it('does not auto compact small contexts when reserved size exceeds the model window', async () => {
const ctx = testAgent({
initialConfig: {
providers: {},
loopControl: { reservedContextSize: 50_000 },
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 32_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_000);
ctx.mockNextResponse({ type: 'text', text: 'I can answer without reserved compaction.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] });
const events = await ctx.untilTurnEnd();
expect(eventIndex(events, 'compaction.started')).toBe(-1);
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.llmCalls[0]?.history.map(messageText)).toContain('old assistant one');
expect(messageText(ctx.llmCalls[0]?.history.at(-1))).toBe('small prompt');
await ctx.expectResumeMatches();
});
it('does not trigger auto compaction from a deferred loaded MCP schema', async () => {
vi.stubEnv(MASTER_ENV, '1');
const ctx = testAgent(
agentService(IAgentToolSelectAnnouncementsService, { _serviceBrand: undefined }),
{
initialConfig: {
providers: {},
loopControl: { reservedContextSize: 0 },
},
},
);
const parameters = {
type: 'object',
properties: {
payload: {
type: 'string',
description: 'x'.repeat(40_000),
},
},
};
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 2_000,
dynamically_loaded_tools: true,
},
tools: [LARGE_MCP_TOOL],
});
const registration = ctx
.get(IAgentToolRegistryService)
.register(mcpTool(LARGE_MCP_TOOL, parameters), { source: 'mcp', disclosure: 'deferred' });
try {
ctx.context.append({
role: 'system',
content: [],
toolCalls: [],
tools: [
{
name: LARGE_MCP_TOOL,
description: `${LARGE_MCP_TOOL} desc`,
parameters,
},
],
origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT },
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.mockNextResponse({ type: 'text', text: 'Answered without tool-schema compaction.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] });
const events = await ctx.untilTurnEnd();
expect(eventIndex(events, 'compaction.started')).toBe(-1);
expect(ctx.llmCalls).toHaveLength(1);
expect(messageText(ctx.llmCalls[0]?.history.at(-1))).toBe('small prompt');
} finally {
registration.dispose();
}
});
it('triggers auto compaction when pending tokens cross the reserved threshold', async () => {
const ctx = testAgent({
initialConfig: {
providers: {},
loopControl: { reservedContextSize: 500 },
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 2_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_400);
ctx.mockNextResponse({ type: 'text', text: 'Reserved compacted summary.' });
ctx.mockNextResponse({ type: 'text', text: 'I can answer after reserved compaction.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'x'.repeat(440) }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(2);
const [compactionCall, answerCall] = ctx.llmCalls;
expect(messageText(compactionCall?.history.at(-1))).toContain('Create a handoff summary for the');
expect(
answerCall?.history.map(messageText).some((text) => text.includes('Reserved compacted summary.')),
).toBe(true);
await ctx.expectResumeMatches();
});
it('includes an oversized pending user prompt in auto compaction', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 2_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_650);
const oversizedPrompt = `keep-this-pending-verbatim:${'x'.repeat(1_800)}`;
ctx.mockNextResponse({ type: 'text', text: 'Oversized prompt summary.' });
ctx.mockNextResponse({ type: 'text', text: 'I can answer the oversized prompt.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: oversizedPrompt }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(2);
const [compactionCall, answerCall] = ctx.llmCalls;
const compactionTexts = compactionCall?.history.map(messageText) ?? [];
expect(compactionTexts.some((text) => text.includes('keep-this-pending-verbatim'))).toBe(true);
expect(compactionCall?.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'user',
'user',
]);
expect(
answerCall?.history.map(messageText).some((text) => text.includes('Oversized prompt summary.')),
).toBe(true);
expect(
answerCall?.history.map(messageText).some((text) => text.includes('keep-this-pending-verbatim')),
).toBe(true);
await ctx.expectResumeMatches();
});
it('triggers auto compaction when pending tokens cross the ratio threshold', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 1_000_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 840_000);
const pendingPrompt = `ratio-pending-verbatim:${'x'.repeat(60_000)}`;
ctx.mockNextResponse({ type: 'text', text: 'Ratio compacted summary.' });
ctx.mockNextResponse({ type: 'text', text: 'I can answer the ratio pending prompt.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: pendingPrompt }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(2);
const [compactionCall, answerCall] = ctx.llmCalls;
const compactionTexts = compactionCall?.history.map(messageText) ?? [];
expect(compactionTexts.some((text) => text.includes('ratio-pending-verbatim'))).toBe(true);
expect(compactionCall?.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'user',
'user',
]);
expect(
answerCall?.history.map(messageText).some((text) => text.includes('Ratio compacted summary.')),
).toBe(true);
expect(
answerCall?.history.map(messageText).some((text) => text.includes('ratio-pending-verbatim')),
).toBe(true);
await ctx.expectResumeMatches();
});
it('compacts and retries when the provider reports context overflow', async () => {
let callCount = 0;
const inputs: string[][] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history, callbacks) => {
callCount += 1;
inputs.push(inputHistorySnapshot(history));
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-context-overflow');
}
if (callCount === 2) {
return textResult('Overflow compacted summary.');
}
if (callCount === 3) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after overflow compaction.',
});
return textResult('Recovered after overflow compaction.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after provider overflow' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.started',
args: expect.objectContaining({ trigger: 'auto' }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.completed',
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Overflow compacted summary.',
compactedCount: 4,
}),
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 0, reason: 'completed' }),
}),
);
expect(inputs).toMatchInlineSnapshot(`
[
[
"user: old user one",
"assistant: old assistant one",
"user: Retry after provider overflow",
],
[
"user: old user one",
"assistant: old assistant one",
"user: Retry after provider overflow",
"user: You are about to run out of context. Create a handoff summary for the
model that will resume this task after the earlier conversation is cleared.
--- This message is a direct task, not part of the above conversation ---
Do not impose rigid section headings; let the shape follow the task. Write it
in the same language the conversation has been using — do not switch to English
just because these instructions happen to be in English.
Make the summary self-sufficient: the next turn will see only the preserved
messages and this summary — every other assistant message, tool call, and tool
result above will be gone. In your own words, preserve what you genuinely need
to continue:
- What the latest request is actually asking for: your reading of its intent and
any ambiguity you have already resolved — not a re-transcription, since what
fits is kept verbatim in the preserved messages. But those kept messages are
size-capped, so a long request is truncated there: if the latest request is
large (a big paste or file), preserve the parts at risk of being dropped —
above all the actual ask. If several requests are in play, say which one governs
the next move, and re-quote any still-relevant earlier request that may have
scrolled out of the kept messages.
- The instructions and constraints currently in force (user preferences,
project rules, environment and tooling limits) — condensed to what still
matters, keeping decisions you have already settled (what you chose and why)
separate from questions still open, so you neither silently reopen a closed
choice nor treat an undecided point as decided.
- What has actually been done, at high fidelity: keep the exact commands that
were run, the exact file paths touched, and whether each succeeded or failed —
and the results themselves, not just the commands: the concrete values
returned, the key lines or error text, the schema or signature a lookup
revealed, since re-running to recover them may be slow or impossible. Keep only
the final working version of any code; drop intermediate attempts and
already-resolved errors.
- What you still don't know: context the next step depends on that this
conversation never established — files or paths referenced but not yet read,
schemas or APIs assumed but unseen, questions the user has not answered. Name
these gaps so the next turn goes and checks them instead of assuming.
- The forward plan — and this is the moment to invest in it. Right now you
hold more context on this task than you ever will again; the next turn
resumes with less, so the plan you commit here is the one it will follow.
Give the exact next command or tool call, but don't stop at the next step:
set out the remaining sequence to finish, the decisions you have already
made for those upcoming steps (so the next turn doesn't reopen them), the
obstacles or edge cases you can already foresee and how you mean to handle
them, and any work you can commit to now — the exact patch, query, or shape
of the final answer you already know you will produce. Anything you settle
here is one less thing the next turn must rediscover. Include any required
format for the final answer.
This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up.
Your TODO list is re-attached automatically below this summary from its live
source, so do not transcribe it — copying it wastes space and can contradict the
live version. What that list cannot hold is the reasoning between tasks — why one
was reordered or dropped, or a decision on one that constrains another — so
record that instead.
Be honest about uncertainty. If an earlier step claimed something was done but
was never verified (tests "passing", a fix "working", a file "created"), say so
plainly and treat it as unverified rather than fact — re-check before relying
on it.
Be concise, and keep the summary proportional to the task: a long multi-step
task warrants detail, but a trivial or nearly finished exchange needs only a
sentence or two — do not pad it out. Include the critical data, identifiers, and
references needed to continue, and omit anything that does not change the next
move.
Respond with text only. Do not call any tools — you already have everything you
need in the conversation history.",
],
[
"user: old user one
Retry after provider overflow",
"user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed.
Overflow compacted summary.",
"user: <system-reminder>
Context compaction is complete — continue the work that was in progress when it began.
</system-reminder>",
],
]
`);
await ctx.expectResumeMatches();
});
it('recovers from compaction-request overflow under the measured token-counting strategy', async () => {
let callCount = 0;
const compactionInputLengths: number[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history, callbacks) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-measured-overflow');
}
if (callCount === 2) {
compactionInputLengths.push(history.length);
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-measured-shrink');
}
if (callCount === 3) {
compactionInputLengths.push(history.length);
return textResult('Measured-strategy compacted summary.');
}
if (callCount === 4) {
await callbacks?.onMessagePart?.({ type: 'text', text: 'Recovered under measured.' });
return textResult('Recovered under measured.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
});
const ctx = testAgent({
generate,
initialConfig: {
providers: {},
tokenCounting: { strategy: 'measured' },
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after measured overflow' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(4);
expect(compactionInputLengths).toHaveLength(2);
expect(compactionInputLengths[1]!).toBeLessThan(compactionInputLengths[0]!);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.completed',
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Measured-strategy compacted summary.',
}),
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 0, reason: 'completed' }),
}),
);
await ctx.expectResumeMatches();
});
it('remembers the observed provider context window after overflow', async () => {
let callCount = 0;
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-observed-window');
}
if (callCount === 2) {
return textResult('Observed recovery summary.');
}
if (callCount === 3) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after observed overflow.',
});
return textResult('Recovered after observed overflow.');
}
if (callCount === 4) {
return textResult('Observed preemptive summary.');
}
if (callCount === 5) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Answered after observed-window precompaction.',
});
return textResult('Answered after observed-window precompaction.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
},
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'learn observed window' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
ctx.appendExchange(2, 'near observed user', 'near observed assistant', 120_000);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'use observed window' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(5);
expect(eventIndex(events, 'compaction.started')).toBeLessThan(
eventIndex(events, 'turn.step.started'),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.completed',
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Observed preemptive summary.',
}),
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 2, reason: 'completed' }),
}),
);
await ctx.expectResumeMatches();
});
it('triggers preemptive compaction against the declared input cap, not the total window', async () => {
let callCount = 0;
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => {
callCount += 1;
if (callCount === 1) {
return textResult('Preemptive summary under the input cap.');
}
await callbacks?.onMessagePart?.({ type: 'text', text: 'Answered after input-cap compaction.' });
return textResult('Answered after input-cap compaction.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
max_input_tokens: 150_000,
},
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 160_000);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(2);
expect(events).toContainEqual(
expect.objectContaining({ event: 'compaction.started' }),
);
});
it('honors the observed provider window over a declared input cap', async () => {
let callCount = 0;
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-observed-window');
}
if (callCount === 2) {
return textResult('Observed recovery summary.');
}
if (callCount === 3) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after observed overflow.',
});
return textResult('Recovered after observed overflow.');
}
if (callCount === 4) {
return textResult('Observed preemptive summary.');
}
if (callCount === 5) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Answered after observed-window precompaction.',
});
return textResult('Answered after observed-window precompaction.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
max_input_tokens: 150_000,
},
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'learn observed window' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
ctx.appendExchange(2, 'near observed user', 'near observed assistant', 120_000);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'use observed window' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(5);
expect(eventIndex(events, 'compaction.started')).toBeLessThan(
eventIndex(events, 'turn.step.started'),
);
});
it('recovers from plain 413 when estimated request is over effective max', async () => {
let callCount = 0;
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => {
callCount += 1;
if (callCount === 1) {
throw new APIStatusError(413, 'Request Entity Too Large', 'req-plain-413');
}
if (callCount === 2) {
return textResult('Plain 413 compacted summary.');
}
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after plain 413 compaction.',
});
return textResult('Recovered after plain 413 compaction.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
},
});
ctx.appendExchange(1, 'old user one', `old assistant one ${'x'.repeat(600_000)}`, 150_000);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after plain 413' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.started',
args: expect.objectContaining({ trigger: 'auto' }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.completed',
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Plain 413 compacted summary.',
}),
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 0, reason: 'completed' }),
}),
);
await ctx.expectResumeMatches();
});
it('does not compact plain 413 when estimated request is small', async () => {
const generate: GenerateFn = requesterFromGenerateFn(async () => {
throw new APIStatusError(413, 'Request Entity Too Large', 'req-small-413');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 200_000,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] });
const events = await ctx.untilTurnEnd();
expect(eventIndex(events, 'compaction.started')).toBe(-1);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 0, reason: 'failed' }),
}),
);
await ctx.expectResumeMatches();
});
it('does not reset the step budget after provider context overflow compaction', async () => {
let callCount = 0;
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-budget-overflow');
}
if (callCount === 2) {
return textResult('Budget compacted summary.');
}
await callbacks?.onMessagePart?.({ type: 'text', text: 'Should not run.' });
return textResult('Should not run.');
});
const ctx = testAgent({
generate,
initialConfig: {
providers: {},
loopControl: { maxStepsPerTurn: 1 },
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after provider overflow' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(2);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({
code: 'loop.max_steps_exceeded',
details: expect.objectContaining({
maxSteps: 1,
}),
}),
}),
}),
);
await ctx.expectResumeMatches();
});
it('preserves thinking effort when compacting after provider context overflow', async () => {
let callCount = 0;
const records: TelemetryRecord[] = [];
const thinkingEfforts: unknown[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => {
callCount += 1;
thinkingEfforts.push(options?.thinking?.effort);
if (callCount === 1) {
throw new APIContextOverflowError(
400,
'Context length exceeded',
'req-thinking-context-overflow',
);
}
if (callCount === 2) {
return textResult('Thinking compacted summary.');
}
if (callCount === 3) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after thinking compaction.',
});
return textResult('Recovered after thinking compaction.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
});
const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.get(IAgentProfileService).update({ thinkingLevel: 'high' });
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with thinking preserved' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(thinkingEfforts).toEqual(['on', 'on', 'on']);
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
agent_id: 'main',
turn_id: expect.any(Number),
source: 'auto',
thinking_effort: 'on',
}),
});
});
it('compacts provider overflow when model context size is unknown', async () => {
let callCount = 0;
const compactionMaxCompletionTokens: unknown[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-unknown-context');
}
if (callCount === 2) {
compactionMaxCompletionTokens.push(options?.maxCompletionTokens);
return textResult('Unknown window compacted summary.');
}
if (callCount === 3) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered with unknown context size.',
});
return textResult('Recovered with unknown context size.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
const modelResolver = ctx.modelResolver;
if (modelResolver === undefined) throw new Error('Expected model provider');
const get = modelResolver.get.bind(modelResolver);
modelResolver.get = (id: string) => {
const resolved = get(id);
Object.defineProperty(resolved, 'capabilities', { value: UNKNOWN_CAPABILITY });
return resolved;
};
expect(ctx.get(IAgentProfileService).data().modelCapabilities.max_context_tokens).toBe(0);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry without known model window' }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(compactionMaxCompletionTokens).toEqual([32000]);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.started',
args: expect.objectContaining({ trigger: 'auto' }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.completed',
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Unknown window compacted summary.',
compactedCount: 4,
}),
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 0, reason: 'completed' }),
}),
);
});
it('honors completion budget env hard caps during compaction', async () => {
vi.stubEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS', '8192');
let callCount = 0;
const compactionMaxCompletionTokens: unknown[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-hard-cap');
}
if (callCount === 2) {
compactionMaxCompletionTokens.push(options?.maxCompletionTokens);
return textResult('Hard cap compacted summary.');
}
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered with hard cap.',
});
return textResult('Recovered with hard cap.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with hard cap' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(compactionMaxCompletionTokens).toEqual([8192]);
});
it.each(['0', '-1'])(
'honors completion budget env opt-out (%s) during compaction',
async (maxCompletionTokens) => {
vi.stubEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS', maxCompletionTokens);
let callCount = 0;
const compactionMaxCompletionTokens: unknown[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-opt-out');
}
if (callCount === 2) {
compactionMaxCompletionTokens.push(options?.maxCompletionTokens);
return textResult('Opt-out compacted summary.');
}
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered with opt-out.',
});
return textResult('Recovered with opt-out.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with opt-out' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(compactionMaxCompletionTokens).toEqual([undefined]);
},
);
it('honors maxOutputSize from model config during compaction', async () => {
let callCount = 0;
const compactionMaxCompletionTokens: unknown[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-max-output');
}
if (callCount === 2) {
compactionMaxCompletionTokens.push(options?.maxCompletionTokens);
return textResult('Max output compacted summary.');
}
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered with max output.',
});
return textResult('Recovered with max output.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
const models = (ctx as unknown as MutableKimiConfig).kimiConfig.models;
models![CATALOGUED_PROVIDER.model] = {
...models![CATALOGUED_PROVIDER.model]!,
maxOutputSize: 64_000,
};
ctx.notifyModelConfigChanged();
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with max output' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(compactionMaxCompletionTokens).toEqual([64_000]);
});
it('uses default 128k hardCap when maxOutputSize is not configured', async () => {
let callCount = 0;
const compactionMaxCompletionTokens: unknown[] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => {
callCount += 1;
if (callCount === 1) {
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-default-cap');
}
if (callCount === 2) {
compactionMaxCompletionTokens.push(options?.maxCompletionTokens);
return textResult('Default cap compacted summary.');
}
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered with default cap.',
});
return textResult('Recovered with default cap.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry with default cap' }] });
await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(compactionMaxCompletionTokens).toEqual([128 * 1024]);
});
it('ignores filtered assistant placeholders when checking the retained overflow suffix', async () => {
let callCount = 0;
const inputs: string[][] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history, callbacks) => {
callCount += 1;
inputs.push(inputHistorySnapshot(history));
if (callCount === 1) {
throw new APIContextOverflowError(
400,
'Context length exceeded',
'req-placeholder-boundary',
);
}
if (callCount === 2) {
return textResult('Placeholder compacted summary.');
}
if (callCount === 3) {
await callbacks?.onMessagePart?.({
type: 'text',
text: 'Recovered after ignoring the placeholder.',
});
return textResult('Recovered after ignoring the placeholder.');
}
throw new Error(`Unexpected generate call ${String(callCount)}`);
});
const ctx = testAgent({
generate,
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 14,
},
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 1);
const promptThatFitsWithoutPlaceholder = 'x'.repeat(40);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: promptThatFitsWithoutPlaceholder }] });
const events = await ctx.untilTurnEnd();
expect(callCount).toBe(3);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.started',
args: expect.objectContaining({ trigger: 'auto' }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
event: 'compaction.completed',
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Placeholder compacted summary.',
compactedCount: 3,
droppedCount: 2,
}),
}),
}),
);
type WireRequestEvent = {
type: '[wire]';
event: 'llm.request';
args: Record<string, unknown>;
};
const requestEvents = events.filter((event): event is WireRequestEvent => {
if (event === null || typeof event !== 'object') return false;
const candidate = event as { type?: unknown; event?: unknown };
return candidate.type === '[wire]' && candidate.event === 'llm.request';
});
expect(
requestEvents.map((event) => [event.args['kind'], event.args['droppedCount']]),
).toEqual([
['compaction', 0],
['compaction', 2],
['loop', undefined],
]);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ turnId: 0, reason: 'completed' }),
}),
);
expect(inputs).toMatchInlineSnapshot(`
[
[
"user: old user one",
"assistant: old assistant one",
"user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"user: You are about to run out of context. Create a handoff summary for the
model that will resume this task after the earlier conversation is cleared.
--- This message is a direct task, not part of the above conversation ---
Do not impose rigid section headings; let the shape follow the task. Write it
in the same language the conversation has been using — do not switch to English
just because these instructions happen to be in English.
Make the summary self-sufficient: the next turn will see only the preserved
messages and this summary — every other assistant message, tool call, and tool
result above will be gone. In your own words, preserve what you genuinely need
to continue:
- What the latest request is actually asking for: your reading of its intent and
any ambiguity you have already resolved — not a re-transcription, since what
fits is kept verbatim in the preserved messages. But those kept messages are
size-capped, so a long request is truncated there: if the latest request is
large (a big paste or file), preserve the parts at risk of being dropped —
above all the actual ask. If several requests are in play, say which one governs
the next move, and re-quote any still-relevant earlier request that may have
scrolled out of the kept messages.
- The instructions and constraints currently in force (user preferences,
project rules, environment and tooling limits) — condensed to what still
matters, keeping decisions you have already settled (what you chose and why)
separate from questions still open, so you neither silently reopen a closed
choice nor treat an undecided point as decided.
- What has actually been done, at high fidelity: keep the exact commands that
were run, the exact file paths touched, and whether each succeeded or failed —
and the results themselves, not just the commands: the concrete values
returned, the key lines or error text, the schema or signature a lookup
revealed, since re-running to recover them may be slow or impossible. Keep only
the final working version of any code; drop intermediate attempts and
already-resolved errors.
- What you still don't know: context the next step depends on that this
conversation never established — files or paths referenced but not yet read,
schemas or APIs assumed but unseen, questions the user has not answered. Name
these gaps so the next turn goes and checks them instead of assuming.
- The forward plan — and this is the moment to invest in it. Right now you
hold more context on this task than you ever will again; the next turn
resumes with less, so the plan you commit here is the one it will follow.
Give the exact next command or tool call, but don't stop at the next step:
set out the remaining sequence to finish, the decisions you have already
made for those upcoming steps (so the next turn doesn't reopen them), the
obstacles or edge cases you can already foresee and how you mean to handle
them, and any work you can commit to now — the exact patch, query, or shape
of the final answer you already know you will produce. Anything you settle
here is one less thing the next turn must rediscover. Include any required
format for the final answer.
This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up.
Your TODO list is re-attached automatically below this summary from its live
source, so do not transcribe it — copying it wastes space and can contradict the
live version. What that list cannot hold is the reasoning between tasks — why one
was reordered or dropped, or a decision on one that constrains another — so
record that instead.
Be honest about uncertainty. If an earlier step claimed something was done but
was never verified (tests "passing", a fix "working", a file "created"), say so
plainly and treat it as unverified rather than fact — re-check before relying
on it.
Be concise, and keep the summary proportional to the task: a long multi-step
task warrants detail, but a trivial or nearly finished exchange needs only a
sentence or two — do not pad it out. Include the critical data, identifiers, and
references needed to continue, and omit anything that does not change the next
move.
Respond with text only. Do not call any tools — you already have everything you
need in the conversation history.",
],
[
"user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"user: You are about to run out of context. Create a handoff summary for the
model that will resume this task after the earlier conversation is cleared.
--- This message is a direct task, not part of the above conversation ---
Do not impose rigid section headings; let the shape follow the task. Write it
in the same language the conversation has been using — do not switch to English
just because these instructions happen to be in English.
Make the summary self-sufficient: the next turn will see only the preserved
messages and this summary — every other assistant message, tool call, and tool
result above will be gone. In your own words, preserve what you genuinely need
to continue:
- What the latest request is actually asking for: your reading of its intent and
any ambiguity you have already resolved — not a re-transcription, since what
fits is kept verbatim in the preserved messages. But those kept messages are
size-capped, so a long request is truncated there: if the latest request is
large (a big paste or file), preserve the parts at risk of being dropped —
above all the actual ask. If several requests are in play, say which one governs
the next move, and re-quote any still-relevant earlier request that may have
scrolled out of the kept messages.
- The instructions and constraints currently in force (user preferences,
project rules, environment and tooling limits) — condensed to what still
matters, keeping decisions you have already settled (what you chose and why)
separate from questions still open, so you neither silently reopen a closed
choice nor treat an undecided point as decided.
- What has actually been done, at high fidelity: keep the exact commands that
were run, the exact file paths touched, and whether each succeeded or failed —
and the results themselves, not just the commands: the concrete values
returned, the key lines or error text, the schema or signature a lookup
revealed, since re-running to recover them may be slow or impossible. Keep only
the final working version of any code; drop intermediate attempts and
already-resolved errors.
- What you still don't know: context the next step depends on that this
conversation never established — files or paths referenced but not yet read,
schemas or APIs assumed but unseen, questions the user has not answered. Name
these gaps so the next turn goes and checks them instead of assuming.
- The forward plan — and this is the moment to invest in it. Right now you
hold more context on this task than you ever will again; the next turn
resumes with less, so the plan you commit here is the one it will follow.
Give the exact next command or tool call, but don't stop at the next step:
set out the remaining sequence to finish, the decisions you have already
made for those upcoming steps (so the next turn doesn't reopen them), the
obstacles or edge cases you can already foresee and how you mean to handle
them, and any work you can commit to now — the exact patch, query, or shape
of the final answer you already know you will produce. Anything you settle
here is one less thing the next turn must rediscover. Include any required
format for the final answer.
This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up.
Your TODO list is re-attached automatically below this summary from its live
source, so do not transcribe it — copying it wastes space and can contradict the
live version. What that list cannot hold is the reasoning between tasks — why one
was reordered or dropped, or a decision on one that constrains another — so
record that instead.
Be honest about uncertainty. If an earlier step claimed something was done but
was never verified (tests "passing", a fix "working", a file "created"), say so
plainly and treat it as unverified rather than fact — re-check before relying
on it.
Be concise, and keep the summary proportional to the task: a long multi-step
task warrants detail, but a trivial or nearly finished exchange needs only a
sentence or two — do not pad it out. Include the critical data, identifiers, and
references needed to continue, and omit anything that does not change the next
move.
Respond with text only. Do not call any tools — you already have everything you
need in the conversation history.",
],
[
"user: old user one
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed.
Placeholder compacted summary.",
"user: <system-reminder>
Context compaction is complete — continue the work that was in progress when it began.
</system-reminder>",
],
]
`);
});
it('appends the todo list to the compaction summary', async () => {
const todos = [
{ title: 'Fix the auth bug', status: 'in_progress' },
{ title: 'Add tests', status: 'pending' },
] as const;
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
await ctx.get(IAgentTodoService).replace(todos);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('full_compaction.complete', () => {
resolve();
});
});
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
const history = ctx.compactHistory();
expect(history).toHaveLength(4);
expect(history[0]).toMatchObject({
role: 'user',
text: 'old user one',
});
expect(history[1]).toMatchObject({
role: 'user',
text: 'recent user two',
});
expect(history[2]).toMatchObject({
role: 'user',
text: expect.stringContaining(
'Compacted summary.\n\n## TODO List\n [in_progress] Fix the auth bug\n [pending] Add tests',
),
});
expect(history[3]).toMatchObject({
role: 'user',
text: buildCompactionContinuationText(),
});
expect(ctx.context.get().at(-2)?.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('The conversation so far has been compacted'),
});
expect(ctx.context.get().at(-1)).toMatchObject({
role: 'user',
origin: { kind: 'injection', variant: 'compaction_continuation' },
});
await ctx.expectResumeMatches();
});
});
describe('FullCompaction context recovery pointer', () => {
const JOURNAL_HOME = '/home/user/.kimi-code';
interface ApplyCompactionArgs {
readonly summary?: string;
readonly contextSummary?: string;
readonly wireLines?: { readonly start: number; readonly end: number };
}
function locatedStorage(base: string): IFileSystemStorageService {
const memory = new InMemoryStorageService();
return new Proxy(memory, {
get(target, property, receiver) {
if (property === 'pathFor') {
return (scope: string, key: string) => `${base}/${scope}/${key}`;
}
const value = Reflect.get(target, property, receiver) as unknown;
return typeof value === 'function'
? (value as (...args: unknown[]) => unknown).bind(target)
: value;
},
}) as unknown as IFileSystemStorageService;
}
function recoveryAgent(
...inputs: readonly (TestAgentServiceOverride | TestAgentOptions)[]
): TestAgentContext {
const ctx = testAgent(...inputs);
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
return ctx;
}
async function compactOnce(ctx: TestAgentContext, summary: string): Promise<void> {
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: summary });
await ctx.rpc.beginCompaction({});
await completed;
}
function noteText(ctx: TestAgentContext): string {
const part = ctx.context.get().at(-2)?.content[0];
return part?.type === 'text' ? part.text : '';
}
function applyCompactionRecords(ctx: TestAgentContext): ApplyCompactionArgs[] {
return ctx.newEvents().flatMap((event) => {
if (event === null || typeof event !== 'object') return [];
const candidate = event as { type?: unknown; event?: unknown; args?: unknown };
if (candidate.type !== '[wire]' || candidate.event !== 'context.apply_compaction') return [];
return [candidate.args as ApplyCompactionArgs];
});
}
it('appends the journal location and window line ranges to the model-facing note', async () => {
const ctx = recoveryAgent(appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME)));
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40);
await compactOnce(ctx, 'Compacted summary.');
const [record] = applyCompactionRecords(ctx);
expect(record?.wireLines).toEqual({ start: 1, end: expect.any(Number) });
const end = record!.wireLines!.end;
expect(end).toBeGreaterThan(1);
const note = noteText(ctx);
expect(note).toContain('Compacted summary.');
expect(note).toContain('## Context Recovery');
expect(note).toContain(`${JOURNAL_HOME}/`);
expect(note).toContain('/wire.jsonl');
expect(note).toContain(`window 1: lines 1–${String(end)} ← the conversation this note summarizes`);
expect(note).toContain(`window 2 (the one you are in now) starts at line ${String(end + 1)}`);
expect(note).toContain('context.append_loop_event');
expect(record?.summary).not.toContain('Context Recovery');
expect(record?.contextSummary).toContain('Context Recovery');
await ctx.expectResumeMatches();
});
it('lists every earlier window after repeated compactions', async () => {
const ctx = recoveryAgent(appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME)));
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
await compactOnce(ctx, 'First summary.');
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40);
await compactOnce(ctx, 'Second summary.');
const [first, second] = applyCompactionRecords(ctx);
const firstLines = first!.wireLines!;
const secondLines = second!.wireLines!;
expect(secondLines.start).toBe(firstLines.end + 1);
expect(secondLines.end).toBeGreaterThan(secondLines.start);
const note = noteText(ctx);
expect(note).toContain(`window 1: lines 1–${String(firstLines.end)}\n`);
expect(note).not.toContain(`window 1: lines 1–${String(firstLines.end)} ←`);
expect(note).toContain(
`window 2: lines ${String(secondLines.start)}–${String(secondLines.end)} ← the conversation this note summarizes`,
);
expect(note).toContain(`window 3 (the one you are in now) starts at line ${String(secondLines.end + 1)}`);
await ctx.expectResumeMatches();
});
it('renders recovery windows over a journal carrying undo switch edges', async () => {
const ctx = recoveryAgent(appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME)));
await ctx.restorePersisted();
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'doomed user two', 'doomed assistant two', 40);
await ctx.rpc.undoHistory({ count: 1 });
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 40);
await compactOnce(ctx, 'Summary after undo.');
const [record] = applyCompactionRecords(ctx);
expect(record?.wireLines).toEqual({ start: 1, end: expect.any(Number) });
const note = noteText(ctx);
expect(note).toContain('## Context Recovery');
expect(note).not.toContain('doomed user two');
await ctx.expectResumeMatches();
});
it('records window line ranges but omits the pointer when the journal has no on-disk path', async () => {
const ctx = recoveryAgent();
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40);
await compactOnce(ctx, 'Compacted summary.');
const [record] = applyCompactionRecords(ctx);
expect(record?.wireLines).toEqual({ start: 1, end: expect.any(Number) });
expect(noteText(ctx)).not.toContain('Context Recovery');
expect(record?.contextSummary).not.toContain('Context Recovery');
});
it('starts the window after the latest context.clear record', async () => {
const ctx = recoveryAgent(appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME)));
ctx.appendExchange(1, 'discarded user one', 'discarded assistant one', 20);
ctx.context.clear();
ctx.appendExchange(2, 'post-clear user two', 'post-clear assistant two', 40);
await compactOnce(ctx, 'Post-clear summary.');
const wire = ctx.get(IWireService);
await wire.flush();
let line = 0;
let clearLine = 0;
for await (const record of wire.readJournal()) {
line += 1;
if (record.type === 'context.clear') clearLine = line;
}
expect(clearLine).toBeGreaterThan(1);
const [record] = applyCompactionRecords(ctx);
expect(record?.wireLines?.start).toBe(clearLine + 1);
expect(record!.wireLines!.end).toBeGreaterThan(clearLine);
const note = noteText(ctx);
expect(note).toContain(`window 1: lines ${String(clearLine + 1)}–`);
expect(note).not.toContain('window 1: lines 1–');
});
it('counts the appended recovery footer into the compacted token floor', async () => {
const withFooter = recoveryAgent(
appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME)),
);
const bare = recoveryAgent();
for (const ctx of [withFooter, bare]) {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40);
await compactOnce(ctx, 'Compacted summary.');
}
const [footerRecord] = applyCompactionRecords(withFooter);
const contextSummary = footerRecord!.contextSummary!;
const footer = contextSummary.slice(contextSummary.indexOf('## Context Recovery'));
expect(footer.length).toBeGreaterThan(0);
const withFooterTokens = withFooter.tokenCounting.get().size;
const bareTokens = bare.tokenCounting.get().size;
expect(withFooterTokens - bareTokens).toBe(
withFooter.get(ISessionTokenCountingService).estimateText(footer),
);
});
it('tells the summarizer a recovery pointer follows the summary', () => {
const withPointer = renderCompactionInstruction({});
const withCustom = renderCompactionInstruction({ customInstruction: ' keep the API facts ' });
expect(withPointer).toContain('a recovery pointer is appended below this summary automatically');
expect(withPointer).toContain('format for the final answer.\n\nThis conversation');
expect(withPointer).not.toContain('${');
expect(withCustom).toContain('Optional user instruction:\nkeep the API facts');
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function eventIndex(events: ReturnType<TestAgentContext['newEvents']>, type: string): number {
return events.findIndex((event) => {
if (typeof event !== 'object' || event === null) return false;
return (event as { readonly event?: unknown }).event === type;
});
}
function countEvents(events: ReturnType<TestAgentContext['newEvents']>, type: string): number {
return events.filter((event) => {
if (typeof event !== 'object' || event === null) return false;
return (event as { readonly event?: unknown }).event === type;
}).length;
}
function exactCompactionPrompt(workDir: string, agentsMd: string): string {
return [
`cwd:${workDir}`,
'os:Linux',
'shell:bash:/bin/bash',
`agents:<!-- From: ${join(workDir, 'AGENTS.md')} -->\n${agentsMd}`,
'ls:\u2514\u2500\u2500 AGENTS.md',
'extra:',
].join('\n');
}
function oauthTestAgentOptions(
getAccessToken: (options?: { readonly force?: boolean }) => Promise<string>,
): {
readonly initialConfig: TestAgentOptions['initialConfig'];
readonly services: TestAgentServiceOverride;
} {
return {
initialConfig: {
defaultModel: 'kimi-code',
providers: {
'managed:kimi-code': {
type: 'google-genai',
baseUrl: 'https://api.example/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
},
},
models: {
'kimi-code': {
provider: 'managed:kimi-code',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
},
},
},
services: appServices((reg) => {
reg.defineInstance(IModelOAuthTokens, {
_serviceBrand: undefined,
hasCachedAccessToken: () => Promise.resolve(true),
getAccessToken: (_provider, _oauthRef, options) =>
getAccessToken(options?.force === true ? { force: true } : undefined),
} satisfies IModelOAuthTokens);
}),
};
}
type MutableKimiConfig = {
kimiConfig: {
models?: Record<string, { maxOutputSize?: number }>;
};
};
function textResult(text: string, traceId: string | null = null): LegacyGenerateResult {
return {
id: 'mock-compaction-oauth-retry',
message: {
role: 'assistant',
content: [{ type: 'text', text }],
toolCalls: [],
},
usage: {
inputOther: 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'completed',
rawFinishReason: 'stop',
traceId,
};
}
interface ScriptedStream {
readonly id: string | null;
readonly usage: TokenUsage | null;
readonly finishReason: FinishReason | null;
readonly rawFinishReason: string | null;
readonly traceId: string | null;
[Symbol.asyncIterator](): AsyncIterator<StreamedMessagePart>;
}
function mockStreamedMessage(
parts: readonly StreamedMessagePart[],
traceId: string | null = null,
opts?: { finishReason?: FinishReason | null; rawFinishReason?: string | null },
): ScriptedStream {
return {
id: 'mock-stream',
usage: null,
finishReason: opts?.finishReason ?? null,
rawFinishReason: opts?.rawFinishReason ?? null,
traceId,
async *[Symbol.asyncIterator](): AsyncIterator<StreamedMessagePart> {
for (const part of parts) {
yield part;
}
},
};
}
function realKosongGenerate(
script: (attempt: number, history: readonly Message[]) => ScriptedStream,
): GenerateFn {
let attempt = 0;
return {
generate: async (config, content, control) => {
attempt += 1;
const streamed = script(attempt, content.messages.map(fromLlmMessage));
const emit = control.onEvent;
emit?.({ type: 'llm.sent' });
emit?.({
type: 'llm.streaming.headers',
headers: streamed.traceId === null ? {} : { 'x-trace-id': streamed.traceId },
});
for await (const part of streamed) {
emit?.({ type: 'llm.streaming.part', part });
control.signal.throwIfAborted();
}
if (streamed.usage !== null) {
emit?.({ type: 'llm.streaming.usage', usage: streamed.usage });
}
emit?.({
type: 'llm.streaming.finish',
finish: {
finishReason: streamed.finishReason,
rawFinishReason: streamed.rawFinishReason,
},
});
if (streamed.id !== null) {
emit?.({ type: 'llm.streaming.message_id', messageId: streamed.id });
}
emit?.({ type: 'llm.done' });
},
};
}
function testCompactionStrategy(maxSize: number = 1_000): DefaultCompactionStrategy {
return new DefaultCompactionStrategy(() => maxSize, {
triggerRatio: 0.85,
blockRatio: 0.85,
reservedContextSize: 0,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 10,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
minOverflowReductionRatio: 0.05,
});
}
function overflowOnlyCompactionStrategy(maxSize: number = 14): DefaultCompactionStrategy {
return new DefaultCompactionStrategy(() => maxSize, {
triggerRatio: Infinity,
blockRatio: Infinity,
reservedContextSize: 0,
maxCompactionPerTurn: 3,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 3,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
minOverflowReductionRatio: 0.05,
});
}
function textMessage(role: 'user' | 'assistant', text: string): Message {
return {
role,
content: [{ type: 'text', text }],
toolCalls: [],
};
}
function mcpTool(
name: string,
parameters: Record<string, unknown>,
): ExecutableTool<Record<string, unknown>> {
return {
name,
description: `${name} desc`,
parameters,
resolveExecution(): ToolExecution {
return {
approvalRule: name,
execute: async () => ({ output: 'mcp ok' }),
};
},
};
}
function bashCall(): ToolCall {
return {
type: 'function',
id: 'call_bash',
name: 'Bash',
arguments: JSON.stringify({ command: 'printf should-not-run', timeout: 60 }),
};
}
function messageText(message: Message | undefined): string {
return message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? '';
}
function hookPayloadLoggerCommand(logPath: string): string {
const scriptPath = `${logPath}.cjs`;
const script = [
"const fs = require('node:fs');",
"let input = '';",
"process.stdin.on('data', (chunk) => { input += chunk; });",
"process.stdin.on('end', () => {",
` fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(JSON.parse(input)) + '\\n');`,
'});',
].join('');
writeFileSync(scriptPath, script);
return `${process.execPath} ${scriptPath}`;
}
function readHookPayloads(logPath: string): Array<Record<string, unknown>> {
if (!existsSync(logPath)) return [];
const text = readFileSync(logPath, 'utf-8').trim();
if (text.length === 0) return [];
return text.split('\n').map((line) => JSON.parse(line) as Record<string, unknown>);
}
function inputHistorySnapshot(history: readonly Message[]): string[] {
return history.map((message) => {
const text = message.content
.map((part) => (part.type === 'text' ? normalizeInputText(part.text) : ''))
.join('');
return `${message.role}: ${text}`;
});
}
function normalizeInputText(text: string): string {
return text.includes('first-person handoff note') ? '<compaction-instruction>' : text;
}
describe('prompt deferral during full compaction', () => {
it('defers a prompt submitted mid-compaction and replays it after completion', async () => {
const compactionRequested = deferred<void>();
const releaseCompaction = deferred<void>();
let llmCallCount = 0;
const llmInputs: string[][] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => {
llmCallCount += 1;
llmInputs.push(history.map(messageText));
if (llmCallCount === 1) {
compactionRequested.resolve();
await releaseCompaction.promise;
return textResult('Compacted summary.');
}
return textResult('Deferred turn reply.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compactionRequested.promise;
const launch = await ctx.rpc.prompt({
input: [{ type: 'text', text: 'deferred prompt' }],
});
expect(launch).toBeUndefined();
releaseCompaction.resolve();
await completed;
const events = await ctx.untilTurnEnd();
expect(countEvents(events, 'compaction.cancelled')).toBe(0);
expect(countEvents(events, 'compaction.completed')).toBe(1);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'completed' }),
}),
);
expect(llmCallCount).toBe(2);
const turnHistory = llmInputs.at(-1) ?? [];
expect(turnHistory.some((text) => text.includes('Compacted summary.'))).toBe(true);
expect(turnHistory).toContain('deferred prompt');
await ctx.expectResumeMatches();
});
it('replays a prompt deferred during compaction after the compaction fails', async () => {
const compactionRequested = deferred<void>();
const releaseCompaction = deferred<void>();
let llmCallCount = 0;
const llmInputs: string[][] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => {
llmCallCount += 1;
llmInputs.push(history.map(messageText));
if (llmCallCount === 1) {
compactionRequested.resolve();
await releaseCompaction.promise;
throw new Error('compaction exploded');
}
return textResult('Recovered turn reply.');
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const cancelled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
await compactionRequested.promise;
const launch = await ctx.rpc.prompt({
input: [{ type: 'text', text: 'deferred prompt' }],
});
expect(launch).toBeUndefined();
releaseCompaction.resolve();
await cancelled;
const events = await ctx.untilTurnEnd();
expect(countEvents(events, 'compaction.completed')).toBe(0);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({ reason: 'completed' }),
}),
);
expect(llmCallCount).toBe(2);
const turnHistory = llmInputs.at(-1) ?? [];
expect(turnHistory).toContain('deferred prompt');
expect(turnHistory.some((text) => text.includes('Compacted'))).toBe(false);
await ctx.expectResumeMatches();
});
});
describe('goal reminder re-injection after full compaction', () => {
const GOAL_OBJECTIVE = 'ship the goal parity fixes';
function goalReminderCount(history: readonly Message[] | readonly string[]): number {
const texts =
typeof history[0] === 'string'
? (history as readonly string[])
: (history as readonly Message[]).map(messageText);
return texts.filter((text) => text.includes(GOAL_OBJECTIVE) && text.includes('active goal'))
.length;
}
it('re-injects the goal reminder before the first post-compaction request', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
await ctx.restorePersisted();
await ctx.get(IAgentGoalService).createGoal({ objective: GOAL_OBJECTIVE });
ctx.appendExchange(1, 'old user one', 'old assistant one', 100);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 950_000);
ctx.mockNextResponse({ type: 'text', text: 'Auto compacted summary.' });
ctx.mockNextResponse({ type: 'text', text: 'I can answer after compaction.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Answer after compacting' }] });
await ctx.untilTurnEnd();
expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(2);
expect(goalReminderCount(ctx.llmCalls[0]!.history)).toBe(1);
expect(goalReminderCount(ctx.llmCalls[1]!.history)).toBe(1);
});
it('re-injects the goal reminder at the first step after compaction', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
await ctx.restorePersisted();
await ctx.get(IAgentGoalService).createGoal({ objective: GOAL_OBJECTIVE });
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
await ctx.rpc.beginCompaction({});
await completed;
const reminderMessages = ctx.context
.get()
.filter(
(message) => message.origin?.kind === 'injection' && message.origin.variant === 'goal',
);
expect(reminderMessages).toHaveLength(0);
const tokensAfter = records.find((record) => record.event === 'compaction_finished')
?.properties?.['tokens_after'];
expect(typeof tokensAfter).toBe('number');
const floor = (
ctx.get(IAgentFullCompactionService) as unknown as {
lastCompactedTokenCount: number | null;
}
).lastCompactedTokenCount;
expect(floor).toBe(ctx.tokenCounting.get().size);
expect(floor).toBe(tokensAfter);
ctx.mockNextResponse({ type: 'text', text: 'Reply after compaction.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'next prompt' }] });
await ctx.untilTurnEnd();
expect(goalReminderCount(ctx.llmCalls.at(-1)!.history)).toBe(1);
});
it('replays a deferred prompt whose first request carries the re-injected goal reminder', async () => {
const compactionRequested = deferred<void>();
const releaseCompaction = deferred<void>();
let llmCallCount = 0;
const llmInputs: string[][] = [];
const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => {
llmCallCount += 1;
llmInputs.push(history.map(messageText));
if (llmCallCount === 1) {
compactionRequested.resolve();
await releaseCompaction.promise;
return textResult('Compacted summary.');
}
if (llmCallCount === 2) return textResult('Deferred turn reply.');
throw new Error(`Unexpected generate call #${String(llmCallCount)}`);
});
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
await ctx.restorePersisted();
await ctx.get(IAgentGoalService).createGoal({ objective: GOAL_OBJECTIVE });
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compactionRequested.promise;
const launch = await ctx.rpc.prompt({
input: [{ type: 'text', text: 'deferred prompt' }],
});
expect(launch).toBeUndefined();
releaseCompaction.resolve();
await completed;
await ctx.untilTurnEnd();
const turnRequest = llmInputs[1] ?? [];
expect(turnRequest).toContain('deferred prompt');
expect(goalReminderCount(turnRequest)).toBeGreaterThanOrEqual(1);
expect(turnRequest.some((text) => text.includes('Compacted summary.'))).toBe(true);
});
});