fix(agent-core-v2): align full compaction with v1

This commit is contained in:
_Kerman 2026-07-08 20:44:37 +08:00
parent 3ea9f8474f
commit a8a134f64a
2 changed files with 200 additions and 139 deletions

View file

@ -2,7 +2,7 @@ import { Disposable } from "#/_base/di/lifecycle";
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { renderPrompt } from "#/_base/utils/render-prompt";
import { estimateTokensForMessages } from "#/_base/utils/tokens";
import { estimateTokensForMessage, estimateTokensForMessages } from "#/_base/utils/tokens";
import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
@ -68,6 +68,8 @@ declare module '#/agent/wireRecord/wireRecord' {
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024;
const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5;
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
type CompactionTelemetryProperties = Record<string, string | number | boolean | undefined>;
@ -159,6 +161,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return false;
const history = this.context.get();
if (history.length === 0) {
throw new KimiError(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.');
}
if (data.source === 'manual' && this.turn.getActiveTurn() !== undefined) {
throw new KimiError(
ErrorCodes.COMPACTION_UNABLE,
@ -166,10 +171,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
);
}
const tokenCount = estimateTokensForMessages(history);
const compactedCount = this.strategy.computeCompactCount(history, data.source);
if (compactedCount === 0) {
throw new KimiError(ErrorCodes.COMPACTION_UNABLE, 'No prefix that can be compacted in current history.');
}
this.wire.dispatch(fullCompactionBegin(data));
@ -194,7 +195,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
void this.compactionWorker(
active,
data,
data.source === 'manual' ? history.length : compactedCount,
)
.then(resolveCompaction, rejectCompaction);
void active.promise.catch(() => undefined);
@ -330,47 +330,18 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
private async compactionWorker(
active: ActiveCompaction,
data: Readonly<CompactionBeginData>,
initialCompactedCount: number,
): Promise<CompactionResult> {
try {
const finalResult: CompactionResult = {
summary: '',
contextSummary: '',
compactedCount: 1,
tokensBefore: 0,
tokensAfter: 0,
keptUserMessageCount: 0,
};
let compactedCount = initialCompactedCount;
for (let round = 1; ; round++) {
const result = await this.compactionRound(active, round, data, compactedCount);
if (this._compacting !== active) throw compactionCancelledReason(active);
finalResult.summary = result.summary;
finalResult.contextSummary = result.contextSummary;
finalResult.compactedCount += result.compactedCount - 1;
finalResult.tokensBefore += result.tokensBefore - finalResult.tokensAfter;
finalResult.tokensAfter = result.tokensAfter;
finalResult.keptUserMessageCount = result.keptUserMessageCount;
finalResult.keptHeadUserMessageCount = result.keptHeadUserMessageCount;
finalResult.droppedCount = result.droppedCount;
if (result.tokensBefore - result.tokensAfter < 1024) break;
if (!this.strategy.shouldBlock(result.tokensAfter)) break;
compactedCount = this.strategy.computeCompactCount(this.context.get(), data.source);
if (compactedCount === 0) break;
}
const result = await this.compactionRound(active, data);
if (this._compacting !== active) throw compactionCancelledReason(active);
this.lastCompactedTokenCount = finalResult.tokensAfter;
if (!this.markCompleted(active, completeData(finalResult))) {
this.lastCompactedTokenCount = result.tokensAfter;
if (!this.markCompleted(active, completeData(result))) {
throw compactionCancelledReason(active);
}
const { contextSummary: _contextSummary, ...eventResult } = finalResult;
const { contextSummary: _contextSummary, ...eventResult } = result;
void _contextSummary;
this.eventBus.publish({ type: 'compaction.completed', result: eventResult, trigger: data.source });
return finalResult;
return result;
} catch (error) {
if (active.abortController.signal.aborted || isAbortError(error)) {
this.cancelActive(active);
@ -393,9 +364,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
private async compactionRound(
active: ActiveCompaction,
round: number,
data: Readonly<CompactionBeginData>,
initialCompactedCount: number,
): Promise<CompactionResult> {
const startedAt = Date.now();
const originalHistory = [...this.context.get()];
@ -403,15 +372,10 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
let retryCount = 0;
try {
const compactedCount = Math.min(initialCompactedCount, originalHistory.length);
const signal = active.abortController.signal;
signal.throwIfAborted();
// One logical compaction fires the hook once, even when it takes
// multiple window-sized rounds to bring the context under the ratio.
if (round === 1) {
await this.hooks.onWillCompact.run(active);
}
await this.hooks.onWillCompact.run(active);
const resolvedModel = this.profile.resolveModelContext();
const maxContextTokens = resolvedModel.modelCapabilities.max_context_tokens;
@ -427,8 +391,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS);
let attempt: CompactionAttemptResult | undefined;
let historyForModel = originalHistory.slice(0, compactedCount);
let historyForModel: readonly ContextMessage[] = originalHistory;
let droppedCount = 0;
let overflowShrinkCount = 0;
let emptyOrTruncatedShrinkCount = 0;
while (true) {
const messagesToCompact = historyForModel;
@ -454,14 +419,19 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
break;
} catch (error) {
if (this.shouldRecoverFromCompactionOverflow(error, messages)) {
const reduced = this.strategy.reduceCompactOnOverflow(messagesToCompact);
// An overflow that cannot shrink further would replay the same
// request; give up (v1: throws when the history cannot shrink).
if (reduced >= messagesToCompact.length) {
overflowShrinkCount += 1;
if (
overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS ||
messagesToCompact.length <= 1
) {
throw error;
}
droppedCount += messagesToCompact.length - reduced;
historyForModel = messagesToCompact.slice(0, reduced);
const before = messagesToCompact.length;
historyForModel = shrinkCompactionHistoryAfterOverflow(
messagesToCompact,
overflowShrinkCount,
);
droppedCount += before - historyForModel.length;
retryCount = 0;
continue;
}
@ -504,20 +474,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
throw compactionCancelledReason(active);
}
const shouldKeepPartialTail =
data.source === 'auto' &&
maxContextTokens > 0 &&
compactedCount < originalHistory.length &&
tokensBefore > maxContextTokens;
const appliedCompactedCount = shouldKeepPartialTail ? historyForModel.length : compactedCount;
const summary = this.postProcessSummary(attempt.summary);
const result = this.context.applyCompaction({
summary,
contextSummary: buildCompactionSummaryText(summary),
compactedCount: appliedCompactedCount,
compactedCount: originalHistory.length,
tokensBefore,
droppedCount: droppedCount === 0 ? undefined : droppedCount,
legacyTail: shouldKeepPartialTail ? true : undefined,
});
this.telemetry.track('compaction_finished', {
@ -529,7 +492,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
compacted_count: result.compactedCount,
dropped_count: result.droppedCount,
retry_count: retryCount,
round,
round: 1,
thinking_effort: this.profile.data().thinkingLevel,
...usageTelemetry(attempt.usage),
});
@ -540,7 +503,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
source: data.source,
tokens_before: tokensBefore,
duration_ms: Date.now() - startedAt,
round,
round: 1,
retry_count: retryCount,
thinking_effort: this.profile.data().thinkingLevel,
error_type: error instanceof Error ? error.name : 'Unknown',
@ -638,6 +601,34 @@ function historySafeToCompact(
return current.slice(original.length).every(isRealUserInput);
}
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
messages: readonly T[],
attempt: number,
): T[] {
if (messages.length <= 1) return messages.slice();
const ratio = COMPACTION_OVERFLOW_SHRINK_RATIOS[
Math.min(attempt - 1, COMPACTION_OVERFLOW_SHRINK_RATIOS.length - 1)
]!;
const tokenBudget = Math.floor(estimateTokensForMessages(messages) * ratio);
return takeRecentMessagesWithinTokenBudget(messages, tokenBudget);
}
function takeRecentMessagesWithinTokenBudget<T extends Message>(
messages: readonly T[],
tokenBudget: number,
): T[] {
let start = messages.length;
let tokens = 0;
for (let i = messages.length - 1; i >= 0; i--) {
const messageTokens = estimateTokensForMessage(messages[i]!);
if (tokens + messageTokens > tokenBudget) break;
tokens += messageTokens;
start = i;
}
if (start === 0) start = 1;
return dropLeadingToolResults(messages.slice(start));
}
function dropOldestMessageAndLeadingToolResults<T extends { readonly role: string }>(
messages: readonly T[],
): T[] {

View file

@ -20,7 +20,7 @@ import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHando
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness';
import { appServices, createCommandRunner, execEnvServices, testAgent } from '../harness';
import { appServices, createCommandRunner, execEnvServices, sessionServices, testAgent } from '../harness';
import {
IAgentFullCompactionService,
IAgentMicroCompactionService,
@ -1227,7 +1227,7 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('auto-compacts very large context in window-sized rounds', async () => {
it('auto-compacts very large context in one full-history round when the summarizer accepts it', async () => {
const maxContextTokens = 4_000;
const ctx = testAgent();
ctx.configure({
@ -1246,9 +1246,7 @@ describe('FullCompaction', () => {
}
const initialTokens = estimateTokensForMessages(ctx.context.get());
const completed = ctx.once('compaction.completed');
for (let i = 1; i <= 30; i++) {
ctx.mockNextResponse({ type: 'text', text: `Auto summary ${String(i)}.` });
}
ctx.mockNextResponse({ type: 'text', text: 'Auto summary.' });
ctx.get(IAgentFullCompactionService).begin({ source: 'auto', instruction: undefined });
await completed;
@ -1260,8 +1258,8 @@ describe('FullCompaction', () => {
expect(initialTokens).toBeGreaterThan(maxContextTokens * 9);
expect(countEvents(events, 'full_compaction.complete')).toBe(1);
expect(countEvents(events, 'compaction.completed')).toBe(1);
expect(compactedPrefixSizes.length).toBeGreaterThan(1);
expect(compactedPrefixSizes.every((size) => size <= maxContextTokens)).toBe(true);
expect(compactedPrefixSizes).toHaveLength(1);
expect(compactedPrefixSizes[0]).toBe(initialTokens);
expect(ctx.contextData().tokenCount).toBeLessThan(maxContextTokens * 0.85);
await ctx.expectResumeMatches();
});
@ -1285,22 +1283,23 @@ describe('FullCompaction', () => {
const events = ctx.newEvents();
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: '[wire]', event: 'context.splice' }),
expect.objectContaining({ type: '[wire]', event: 'full_compaction.begin' }),
// Clearing context is a full-history `context.splice` (the v1.5
// equivalent of the legacy `context.clear` record).
expect.objectContaining({
type: '[wire]',
event: 'context.splice',
args: expect.objectContaining({ start: 0, deleteCount: 4, messages: [] }),
}),
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
tools: Agent, AgentSwarm, EnterPlanMode, ExitPlanMode
messages:
user: text "old user one"
assistant: text "old assistant one"
@ -1360,37 +1359,53 @@ describe('FullCompaction', () => {
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: '[wire]', event: 'context.splice' }),
expect.objectContaining({ type: '[wire]', event: 'turn.launch' }),
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
tools: Agent, AgentSwarm, 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:
assistant: text "Auto compacted summary."
user: text "recent user three"
assistant: text "recent assistant three"
user: text "Answer after compacting"
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.\\nAuto compacted summary."
`);
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'auto',
tokens_before: 46,
tokens_after: 28,
compacted_count: 4,
tokens_after: 166,
compacted_count: 7,
retry_count: 0,
}),
});
@ -1540,46 +1555,43 @@ describe('FullCompaction', () => {
]);
});
it('fails the turn with compaction.unable when auto compaction has no compactable prefix', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: {
...CATALOGUED_MODEL_CAPABILITIES,
max_context_tokens: 2_000,
},
});
const oversizedPrompt = `initial-pending-verbatim:${'x'.repeat(8_000)}`;
await ctx.rpc.prompt({ input: [{ type: 'text', text: oversizedPrompt }] });
const events = await ctx.untilTurnEnd();
expect(eventIndex(events, 'compaction.started')).toBe(-1);
expect(ctx.llmCalls).toHaveLength(0);
expect(events).toContainEqual(
expect.objectContaining({
event: 'turn.ended',
args: expect.objectContaining({
reason: 'failed',
error: expect.objectContaining({ code: 'compaction.unable' }),
}),
}),
);
await ctx.expectResumeMatches();
});
it('rejects manual compaction with compaction.unable when no prefix is compactable', async () => {
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');
await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({
code: 'compaction.unable',
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.`,
},
]);
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,
});
expect(ctx.llmCalls).toHaveLength(0);
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);
@ -1587,23 +1599,37 @@ describe('FullCompaction', () => {
const compacted = ctx.once('full_compaction.complete');
const completed = ctx.once('compaction.completed');
ctx.mockNextResponse({ type: 'text', text: 'Compacted after no-op cancel.' });
ctx.mockNextResponse({ type: 'text', text: 'Compacted after single-message compact.' });
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(ctx.llmCalls).toHaveLength(1);
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 no-op cancel.'),
text: expect.stringContaining('Compacted after single-message compact.'),
},
]);
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: {
@ -1661,7 +1687,7 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('keeps an oversized pending user prompt out of auto compaction', async () => {
it('includes an oversized pending user prompt in auto compaction', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
@ -1681,8 +1707,15 @@ describe('FullCompaction', () => {
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(false);
expect(compactionCall?.history.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
// The whole history is compacted, so the pending prompt is included in the
// compaction input and kept verbatim in the post-compaction replacement.
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);
@ -1712,8 +1745,15 @@ describe('FullCompaction', () => {
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(false);
expect(compactionCall?.history.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
// The whole history is compacted, so the pending prompt is included in the
// compaction input and kept verbatim in the post-compaction replacement.
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);
@ -1769,7 +1809,7 @@ describe('FullCompaction', () => {
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Overflow compacted summary.',
compactedCount: 2,
compactedCount: 4,
}),
}),
}),
@ -1790,6 +1830,7 @@ describe('FullCompaction', () => {
[
"user: old user one",
"assistant: old assistant one",
"user: Retry after provider overflow",
"user: <compaction-instruction>",
],
[
@ -2044,7 +2085,7 @@ describe('FullCompaction', () => {
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Unknown window compacted summary.',
compactedCount: 2,
compactedCount: 4,
}),
}),
}),
@ -2204,8 +2245,10 @@ describe('FullCompaction', () => {
it('ignores filtered assistant placeholders when checking the retained overflow suffix', async () => {
let callCount = 0;
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
const inputs: string[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history, callbacks) => {
callCount += 1;
inputs.push(inputHistorySnapshot(history));
if (callCount === 1) {
throw new APIContextOverflowError(
400,
@ -2255,7 +2298,8 @@ describe('FullCompaction', () => {
args: expect.objectContaining({
result: expect.objectContaining({
summary: 'Placeholder compacted summary.',
compactedCount: 2,
compactedCount: 3,
droppedCount: 2,
}),
}),
}),
@ -2266,11 +2310,42 @@ describe('FullCompaction', () => {
args: { turnId: 0, reason: 'completed' },
}),
);
expect(inputs).toMatchInlineSnapshot(`
[
[
"user: old user one",
"assistant: old assistant one",
"user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"user: <compaction-instruction>",
],
[
"user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"user: <compaction-instruction>",
],
[
"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.
Placeholder compacted summary.",
],
]
`);
});
it('appends the todo list to the compaction summary', async () => {
const ctx = testAgent();
const todos = [
{ title: 'Fix the auth bug', status: 'in_progress' },
{ title: 'Add tests', status: 'pending' },
] as const;
const ctx = testAgent(
sessionServices((reg) => {
reg.definePartialInstance(ISessionTodoService, {
getTodos: () => todos,
});
}),
);
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
@ -2278,11 +2353,6 @@ describe('FullCompaction', () => {
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
ctx.get(ISessionTodoService).setTodos([
{ title: 'Fix the auth bug', status: 'in_progress' },
{ title: 'Add tests', status: 'pending' },
]);
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('full_compaction.complete', () => {
resolve();