kimi-code/packages/agent-core/test/agent/compaction/micro.test.ts
Kaiyi 3e42ae7607 fix(agent-core): repair misplaced tool results in projected context
Long tool-use sessions could get stuck with repeated 400 errors when a tool result was not adjacent to its tool_use (for example a notification flushed between step.begin and the first tool.call, or an interrupted or nested step delaying the result). Micro compaction exposed this latent misordering by busting the prompt cache and forcing a full revalidation.

Repair the adjacency at projection time so every tool_use is immediately followed by its tool_result(s), and default the experimental micro compaction flag off.
2026-06-30 13:28:02 +08:00

989 lines
29 KiB
TypeScript

import type { ContentPart, Message } from '@moonshot-ai/kosong';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentRecord } from '../../../src/agent';
import {
AGENT_WIRE_PROTOCOL_VERSION,
InMemoryAgentRecordPersistence,
} from '../../../src/agent/records';
import { FLAG_DEFINITIONS, FlagResolver, MASTER_ENV } from '../../../src/flags';
import { estimateTokensForMessages } from '../../../src/utils/tokens';
import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry';
import { testAgent, type TestAgentContext } from '../harness/agent';
const CATALOGUED_PROVIDER = {
type: 'kimi',
apiKey: 'test-key',
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 MINUTE = 60 * 1000;
const DEFAULT_MARKER = '[Old tool result content cleared]';
const MICRO_COMPACTION_FLAG_ENV = getMicroCompactionFlagEnv();
describe('MicroCompaction', () => {
beforeEach(() => {
vi.stubEnv(MASTER_ENV, '0');
vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '1');
});
it('defaults the micro_compaction flag off', () => {
expect(new FlagResolver({}, FLAG_DEFINITIONS).enabled('micro_compaction')).toBe(false);
});
it('truncates old tool results after cache miss', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 4,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
minContextUsageRatio: 0,
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
ctx.appendToolExchange();
expect(ctx.agent.context.messages).toHaveLength(9);
vi.setSystemTime(61 * 60 * 1000);
ctx.agent.microCompaction.detect();
const messages = ctx.agent.context.messages;
expect(messages[2]).toMatchObject({
role: 'tool',
content: [{ type: 'text', text: DEFAULT_MARKER }],
});
expect(messages[5]).toMatchObject({
role: 'tool',
content: [{ type: 'text', text: 'lookup result' }],
});
expect(messages[8]).toMatchObject({
role: 'tool',
content: [{ type: 'text', text: 'lookup result' }],
});
});
it('does nothing before cache miss threshold', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 4,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(30 * 60 * 1000);
const messages = ctx.agent.context.messages;
expect(hasMarker(messages)).toBe(false);
});
it('persists cutoff across calls until cache miss resets it', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
minContextUsageRatio: 0,
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
ctx.agent.microCompaction.detect();
const first = ctx.agent.context.messages;
expect(first[2]).toMatchObject({
role: 'tool',
content: [{ type: 'text', text: DEFAULT_MARKER }],
});
vi.setSystemTime(62 * 60 * 1000);
ctx.agent.microCompaction.detect();
const second = ctx.agent.context.messages;
expect(second[2]).toMatchObject({
role: 'tool',
content: [{ type: 'text', text: DEFAULT_MARKER }],
});
});
it('clears cutoff on reset', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 4,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
ctx.agent.microCompaction.reset();
const messages = ctx.agent.context.messages;
expect(hasMarker(messages)).toBe(false);
});
it('skips tool results below minContentTokens', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 2,
minContentTokens: 100,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
const messages = ctx.agent.context.messages;
expect(hasMarker(messages)).toBe(false);
});
it('skips non-tool messages', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
});
vi.setSystemTime(0);
ctx.appendExchange(1, 'user one', 'assistant one', 10);
ctx.appendExchange(2, 'user two', 'assistant two', 10);
ctx.appendExchange(3, 'user three', 'assistant three', 10);
vi.setSystemTime(61 * 60 * 1000);
const messages = ctx.agent.context.messages;
expect(messages.every((m) => m.role === 'user' || m.role === 'assistant')).toBe(true);
expect(hasMarker(messages)).toBe(false);
});
it('clears cutoff on context clear', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
ctx.agent.context.clear();
expect(ctx.agent.context.messages).toHaveLength(0);
expect(ctx.agent.context.lastAssistantAt).toBeNull();
});
it('sends truncated old tool results to the next model request without mutating history', async () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 4,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'old result one' });
appendMicroToolExchange(ctx, 2, { output: 'middle result two' });
appendMicroToolExchange(ctx, 3, { output: 'recent result three' });
vi.setSystemTime(61 * MINUTE);
ctx.mockNextResponse({ type: 'text', text: 'done after micro compaction' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] });
await ctx.untilTurnEnd();
const call = ctx.llmCalls.at(-1);
expect(textOf(call?.history[2])).toBe(DEFAULT_MARKER);
expect(textOf(call?.history[5])).toBe(DEFAULT_MARKER);
expect(textOf(call?.history[8])).toBe('recent result three');
expect(textOf(ctx.agent.context.history[2])).toBe('old result one');
expect(textOf(ctx.agent.context.history[5])).toBe('middle result two');
expect(textOf(ctx.agent.context.history[8])).toBe('recent result three');
await ctx.expectResumeMatches();
});
it('restores lastAssistantAt from record time before applying cache-miss rules', async () => {
vi.useFakeTimers();
const assistantRecordTime = 2_000;
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
persistence: new InMemoryAgentRecordPersistence(
resumeToolExchangeRecords(assistantRecordTime),
),
});
vi.setSystemTime(999_999);
await ctx.agent.resume();
expect(ctx.agent.context.lastAssistantAt).toBe(assistantRecordTime);
vi.setSystemTime(assistantRecordTime + 30 * MINUTE);
expect(hasMarker(ctx.agent.context.messages)).toBe(false);
vi.setSystemTime(assistantRecordTime + 61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER]);
});
it('preserves the restored cutoff when resuming before the next cache miss', async () => {
vi.useFakeTimers();
const persistence = new InMemoryAgentRecordPersistence();
const config = {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
};
const ctx = testAgent({
microCompaction: config,
persistence,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
appendMicroToolExchange(ctx, 2, { output: 'result two' });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER, 'result two']);
expect(lastMicroCompactionCutoff(persistence.records)).toBe(4);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
const resumed = testAgent({
microCompaction: config,
persistence: new InMemoryAgentRecordPersistence(cloneRecords(persistence.records)),
});
vi.setSystemTime(63 * MINUTE);
await resumed.agent.resume();
expect(resumed.agent.context.lastAssistantAt).toBe(62 * MINUTE);
expect(toolTexts(resumed.agent.context.messages)).toEqual([
DEFAULT_MARKER,
'result two',
'result three',
]);
});
it('recomputes the restored cutoff when resuming after the cache-miss threshold', async () => {
vi.useFakeTimers();
const persistence = new InMemoryAgentRecordPersistence();
const config = {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
};
const ctx = testAgent({
microCompaction: config,
persistence,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
appendMicroToolExchange(ctx, 2, { output: 'result two' });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER, 'result two']);
expect(lastMicroCompactionCutoff(persistence.records)).toBe(4);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
const resumedPersistence = new InMemoryAgentRecordPersistence(
cloneRecords(persistence.records),
);
const resumed = testAgent({
microCompaction: config,
persistence: resumedPersistence,
});
vi.setSystemTime(123 * MINUTE);
await resumed.agent.resume();
expect(resumed.agent.context.lastAssistantAt).toBe(62 * MINUTE);
resumed.agent.microCompaction.detect();
expect(toolTexts(resumed.agent.context.messages)).toEqual([
DEFAULT_MARKER,
DEFAULT_MARKER,
'result three',
]);
expect(lastMicroCompactionCutoff(resumedPersistence.records)).toBe(7);
});
it('keeps an old cutoff while cache is warm and advances it on the next miss', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
appendMicroToolExchange(ctx, 2, { output: 'result two' });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([DEFAULT_MARKER, 'result two']);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
vi.setSystemTime(63 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([
DEFAULT_MARKER,
'result two',
'result three',
]);
vi.setSystemTime(123 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([
DEFAULT_MARKER,
DEFAULT_MARKER,
'result three',
]);
});
it('clamps cutoff when undo shortens the context', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
appendMicroToolExchange(ctx, 2, { output: 'result two' });
appendMicroToolExchange(ctx, 3, { output: 'result three' });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([
DEFAULT_MARKER,
DEFAULT_MARKER,
'result three',
]);
ctx.agent.context.undo(2);
appendMicroToolExchange(ctx, 4, { output: 'result four' });
expect(toolTexts(ctx.agent.context.messages)).toEqual([
DEFAULT_MARKER,
'result four',
]);
});
it('tracks telemetry when a cache miss advances the micro_compaction cutoff', () => {
vi.useFakeTimers();
const records: TelemetryRecord[] = [];
const microCompaction = {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
};
const ctx = testAgent({
telemetry: recordingTelemetry(records),
microCompaction,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one '.repeat(20) });
appendMicroToolExchange(ctx, 2, { output: 'result two '.repeat(20) });
appendMicroToolExchange(ctx, 3, { output: 'result three' });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([
DEFAULT_MARKER,
DEFAULT_MARKER,
'result three',
]);
const event = singleTelemetryEvent(records, 'micro_compaction_finished');
expect(event.properties).toMatchObject({
keep_recent_messages: microCompaction.keepRecentMessages,
min_content_tokens: microCompaction.minContentTokens,
cache_missed_threshold_ms: microCompaction.cacheMissedThresholdMs,
truncated_marker: DEFAULT_MARKER,
min_context_usage_ratio: microCompaction.minContextUsageRatio,
previous_cutoff: 0,
cutoff: 7,
message_count: 9,
cache_age_ms: 61 * MINUTE,
truncated_tool_result_count: 2,
truncated_tool_result_tokens_before: expect.any(Number),
truncated_tool_result_tokens_after: expect.any(Number),
tokens_before: expect.any(Number),
tokens_after: expect.any(Number),
thinking_level: 'off',
});
expect(numberProperty(event, 'truncated_tool_result_tokens_before')).toBeGreaterThan(
numberProperty(event, 'truncated_tool_result_tokens_after'),
);
expect(numberProperty(event, 'tokens_before')).toBeGreaterThan(
numberProperty(event, 'tokens_after'),
);
expect(ctx.agent.context.messages).toHaveLength(9);
expect(records.filter((record) => record.event === 'micro_compaction_finished')).toHaveLength(1);
});
it('reports context token deltas from the previously compacted projection', () => {
vi.useFakeTimers();
const records: TelemetryRecord[] = [];
const microCompaction = {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
};
const ctx = testAgent({
telemetry: recordingTelemetry(records),
microCompaction,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one '.repeat(20) });
appendMicroToolExchange(ctx, 2, { output: 'result two '.repeat(20) });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([
DEFAULT_MARKER,
'result two '.repeat(20),
]);
vi.setSystemTime(62 * MINUTE);
appendMicroToolExchange(ctx, 3, { output: 'result three' });
const expectedContextTokensBefore = estimateTokensForMessages(ctx.agent.context.messages);
vi.setSystemTime(123 * MINUTE);
ctx.agent.microCompaction.detect();
const events = records.filter((record) => record.event === 'micro_compaction_finished');
expect(events).toHaveLength(2);
const secondEvent = events[1]!;
expect(secondEvent.properties).toMatchObject({
previous_cutoff: 4,
cutoff: 7,
truncated_tool_result_count: 2,
tokens_before: expectedContextTokensBefore,
tokens_after: estimateTokensForMessages(ctx.agent.context.messages),
});
});
it('leaves context unchanged when the micro_compaction flag is disabled', () => {
vi.stubEnv(MICRO_COMPACTION_FLAG_ENV, '0');
vi.useFakeTimers();
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
persistence,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual(['result one']);
expect(lastMicroCompactionCutoff(persistence.records)).toBeUndefined();
});
it('uses the custom marker at the minContentTokens boundary', () => {
vi.useFakeTimers();
const marker = '[tool output removed for test]';
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
truncatedMarker: marker,
minContextUsageRatio: 0,
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'abcd' });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
expect(toolTexts(ctx.agent.context.messages)).toEqual([marker]);
expect(textOf(ctx.agent.context.history[2])).toBe('abcd');
});
it('keeps raw pending token accounting even when projection truncates tool output', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
});
ctx.configure();
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, {
output: 'x'.repeat(400),
usageTokens: 50,
});
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
const rawPending = ctx.agent.context.history.slice(-1);
const projectedPending = ctx.agent.context.project(rawPending);
expect(textOf(projectedPending[0])).toBe(DEFAULT_MARKER);
expect(ctx.agent.context.tokenCountWithPending).toBe(
ctx.agent.context.tokenCount + estimateTokensForMessages(rawPending),
);
expect(ctx.agent.context.tokenCountWithPending).toBeGreaterThan(
ctx.agent.context.tokenCount + estimateTokensForMessages(projectedPending),
);
});
it('replaces rich error tool content while preserving context metadata before projection', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0,
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, {
output: [
{ type: 'text', text: 'large rich output' },
{ type: 'video_url', videoUrl: { url: 'ms://video-1', id: 'video-1' } },
],
isError: true,
});
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
const compacted = ctx.agent.microCompaction.compact(ctx.agent.context.history);
const tool = compacted.find((message) => message.role === 'tool');
expect(tool).toMatchObject({
role: 'tool',
toolCallId: 'call_micro_1',
isError: true,
content: [{ type: 'text', text: DEFAULT_MARKER }],
});
expect(tool?.content).toHaveLength(1);
});
it('does not truncate tool-shaped messages without a toolCallId', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
},
});
vi.setSystemTime(61 * MINUTE);
ctx.agent.context.appendMessage({
role: 'tool',
content: [{ type: 'text', text: 'orphan tool-like output' }],
toolCalls: [],
});
expect(toolTexts(ctx.agent.context.messages)).toEqual(['orphan tool-like output']);
});
it('clears cutoff on full compaction', async () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 2,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
vi.setSystemTime(0);
ctx.appendExchange(1, 'old user', 'old assistant', 20);
ctx.appendExchange(2, 'recent user', 'recent assistant', 80);
vi.setSystemTime(61 * 60 * 1000);
const compacted = ctx.once('context.apply_compaction');
ctx.mockNextResponse({ type: 'text', text: 'Summary.' });
await ctx.rpc.beginCompaction({});
await compacted;
expect(ctx.agent.context.messages).toHaveLength(1);
expect(ctx.agent.context.messages[0]).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Summary.' }],
});
});
it('does not apply when context usage is below minContextUsageRatio', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0.9,
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'result one' });
vi.setSystemTime(61 * MINUTE);
const messages = ctx.agent.context.messages;
expect(hasMarker(messages)).toBe(false);
});
it('applies when context usage is above minContextUsageRatio', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 0,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * MINUTE,
minContextUsageRatio: 0.5,
},
});
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
image_in: true,
video_in: true,
audio_in: false,
thinking: true,
tool_use: true,
max_context_tokens: 100,
},
});
vi.setSystemTime(0);
appendMicroToolExchange(ctx, 1, { output: 'x'.repeat(300) });
vi.setSystemTime(61 * MINUTE);
ctx.agent.microCompaction.detect();
const messages = ctx.agent.context.messages;
expect(hasMarker(messages)).toBe(true);
});
it('does not truncate when messages are fewer than keepRecentMessages', () => {
vi.useFakeTimers();
const ctx = testAgent({
microCompaction: {
keepRecentMessages: 20,
minContentTokens: 1,
cacheMissedThresholdMs: 60 * 60 * 1000,
},
});
vi.setSystemTime(0);
ctx.appendToolExchange();
ctx.appendToolExchange();
vi.setSystemTime(61 * 60 * 1000);
const messages = ctx.agent.context.messages;
expect(hasMarker(messages)).toBe(false);
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
interface MicroToolExchangeOptions {
readonly output?: string | ContentPart[] | undefined;
readonly isError?: boolean | undefined;
readonly usageTokens?: number | undefined;
}
function appendMicroToolExchange(
ctx: TestAgentContext,
index: number,
options: MicroToolExchangeOptions = {},
): void {
const stepUuid = `micro-tool-step-${String(index)}`;
const toolCallId = `call_micro_${String(index)}`;
const output = options.output ?? `lookup result ${String(index)}`;
const usage =
options.usageTokens === undefined
? undefined
: {
inputOther: options.usageTokens - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
};
ctx.agent.context.appendUserMessage([{ type: 'text', text: `lookup ${String(index)}` }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: index },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `micro-tool-part-${String(index)}`,
turnId: '',
step: index,
stepUuid,
part: { type: 'text', text: `calling Lookup ${String(index)}` },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: '',
step: index,
stepUuid,
toolCallId,
name: 'Lookup',
args: { query: `item-${String(index)}` },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: index,
usage,
finishReason: 'tool_use',
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: toolCallId,
toolCallId,
result: { output, isError: options.isError },
},
});
}
function resumeToolExchangeRecords(assistantRecordTime: number): AgentRecord[] {
return [
{
type: 'metadata',
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
created_at: 1,
},
{
type: 'context.append_message',
time: 1_000,
message: {
role: 'user',
content: [{ type: 'text', text: 'lookup from restored session' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
{
type: 'context.append_loop_event',
time: assistantRecordTime,
event: { type: 'step.begin', uuid: 'resume-micro-step', turnId: '0', step: 1 },
},
{
type: 'context.append_loop_event',
time: assistantRecordTime + 1,
event: {
type: 'content.part',
uuid: 'resume-micro-part',
turnId: '0',
step: 1,
stepUuid: 'resume-micro-step',
part: { type: 'text', text: 'calling restored Lookup' },
},
},
{
type: 'context.append_loop_event',
time: assistantRecordTime + 2,
event: {
type: 'tool.call',
uuid: 'resume-micro-call',
turnId: '0',
step: 1,
stepUuid: 'resume-micro-step',
toolCallId: 'resume_micro_call',
name: 'Lookup',
args: { query: 'restored' },
},
},
{
type: 'context.append_loop_event',
time: assistantRecordTime + 3,
event: {
type: 'step.end',
uuid: 'resume-micro-step',
turnId: '0',
step: 1,
finishReason: 'tool_use',
},
},
{
type: 'context.append_loop_event',
time: assistantRecordTime + 4,
event: {
type: 'tool.result',
parentUuid: 'resume-micro-call',
toolCallId: 'resume_micro_call',
result: { output: 'restored lookup result' },
},
},
];
}
function cloneRecords(records: readonly AgentRecord[]): AgentRecord[] {
return records.map((record) => structuredClone(record));
}
function lastMicroCompactionCutoff(records: readonly AgentRecord[]): number | undefined {
return records.findLast((record) => record.type === 'micro_compaction.apply')?.cutoff;
}
function toolTexts(messages: readonly Message[]): string[] {
return messages
.filter((message) => message.role === 'tool')
.map((message) => textOf(message));
}
function textOf(message: Message | undefined): string {
return (
message?.content
.map((part) => {
if (part.type === 'text') return part.text;
return '';
})
.join('') ?? ''
);
}
function hasMarker(messages: readonly Message[]): boolean {
return toolTexts(messages).includes(DEFAULT_MARKER);
}
function getMicroCompactionFlagEnv(): string {
const flag = FLAG_DEFINITIONS.find((definition) => definition.id === 'micro_compaction');
if (flag === undefined) {
throw new Error('Missing micro_compaction flag definition.');
}
return flag.env;
}
function singleTelemetryEvent(
records: readonly TelemetryRecord[],
event: string,
): TelemetryRecord {
const matches = records.filter((record) => record.event === event);
expect(matches).toHaveLength(1);
return matches[0]!;
}
function numberProperty(record: TelemetryRecord, key: string): number {
const value = record.properties?.[key];
expect(typeof value).toBe('number');
return value as number;
}