Download packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 159 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts
-
curl -L -o fullCompaction.test.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts
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); | |
| }); | |
| }); | |