diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 523cb41e9..da9372a52 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -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; @@ -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, - initialCompactedCount: number, ): Promise { 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, - initialCompactedCount: number, ): Promise { 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( + 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( + 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( messages: readonly T[], ): T[] { diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 0562b5ddb..d842e82eb 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -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: - 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: - 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 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: ", ], [ @@ -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: ", + ], + [ + "user: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "user: ", + ], + [ + "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((resolve) => { ctx.emitter.once('full_compaction.complete', () => { resolve();