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; 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((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; }; 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: '