Skip to content

Commit 911e7c3

Browse files
authored
fix: replay compaction records on resume (#617)
1 parent dcf3075 commit 911e7c3

10 files changed

Lines changed: 213 additions & 7 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
Show completed and cancelled compaction records correctly when resuming a session.

apps/kimi-code/src/tui/controllers/session-replay.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type { SessionEventHandler } from './session-event-handler';
4545
import type { TUIState } from '../tui-state';
4646

4747
type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>;
48+
type CompactionReplayRecord = Extract<AgentReplayRecord, { type: 'compaction' }>;
4849
type GoalReplayLifecycleChange = GoalChange & { readonly kind: 'lifecycle' };
4950

5051
export interface SessionReplayHost {
@@ -178,6 +179,9 @@ export class SessionReplayRenderer {
178179
case 'message':
179180
this.renderMessage(context, record.message);
180181
return;
182+
case 'compaction':
183+
this.renderCompaction(context, record);
184+
return;
181185
case 'goal_updated':
182186
this.renderGoalReplayRecord(context, record);
183187
return;
@@ -373,6 +377,30 @@ export class SessionReplayRenderer {
373377
});
374378
}
375379

380+
private renderCompaction(context: ReplayRenderContext, record: CompactionReplayRecord): void {
381+
this.flushAssistant(context);
382+
if (record.result === undefined) return;
383+
if (record.result === 'cancelled') {
384+
this.host.appendTranscriptEntry({
385+
...replayEntry(context, 'status', 'Compaction cancelled', 'plain'),
386+
compactionData: {
387+
result: 'cancelled',
388+
instruction: record.instruction,
389+
},
390+
});
391+
return;
392+
}
393+
394+
this.host.appendTranscriptEntry({
395+
...replayEntry(context, 'status', 'Compaction complete', 'plain'),
396+
compactionData: {
397+
tokensBefore: record.result.tokensBefore,
398+
tokensAfter: record.result.tokensAfter,
399+
instruction: record.instruction,
400+
},
401+
});
402+
}
403+
376404
private renderGoalReplayRecord(context: ReplayRenderContext, record: GoalReplayRecord): void {
377405
this.flushAssistant(context);
378406
const { change } = record;

apps/kimi-code/src/tui/kimi-tui.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1308,7 +1308,11 @@ export class KimiTUI {
13081308
if (entry.compactionData !== undefined) {
13091309
const data = entry.compactionData;
13101310
const block = new CompactionComponent(this.state.ui, data.instruction);
1311-
block.markDone(data.tokensBefore, data.tokensAfter);
1311+
if (data.result === 'cancelled') {
1312+
block.markCanceled();
1313+
} else {
1314+
block.markDone(data.tokensBefore, data.tokensAfter);
1315+
}
13121316
return block;
13131317
}
13141318

apps/kimi-code/src/tui/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export interface BackgroundAgentStatusData {
104104
}
105105

106106
export interface CompactionTranscriptData {
107+
readonly result?: 'cancelled';
107108
readonly tokensBefore?: number;
108109
readonly tokensAfter?: number;
109110
readonly instruction?: string;

apps/kimi-code/test/tui/message-replay.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,61 @@ describe('KimiTUI resume message replay', () => {
990990
expect(transcript).toContain('hook response 2');
991991
});
992992

993+
it('renders replayed compaction records as completed compaction blocks', async () => {
994+
const driver = await replayIntoDriver([
995+
message('user', [{ type: 'text', text: 'prompt before compaction' }]),
996+
{
997+
type: 'compaction',
998+
result: {
999+
summary: 'Compacted transcript summary.',
1000+
compactedCount: 4,
1001+
tokensBefore: 120,
1002+
tokensAfter: 24,
1003+
},
1004+
instruction: 'preserve implementation notes',
1005+
},
1006+
message('user', [{ type: 'text', text: 'prompt after compaction' }]),
1007+
]);
1008+
1009+
const compactionEntry = driver.state.transcriptEntries.find(
1010+
(entry) => entry.compactionData !== undefined,
1011+
);
1012+
expect(compactionEntry?.compactionData).toEqual({
1013+
tokensBefore: 120,
1014+
tokensAfter: 24,
1015+
instruction: 'preserve implementation notes',
1016+
});
1017+
const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n'));
1018+
expect(transcript).toContain('Compaction complete');
1019+
expect(transcript).toContain('120 → 24 tokens');
1020+
expect(transcript).toContain('preserve implementation notes');
1021+
expect(transcript).not.toContain('Compacted transcript summary.');
1022+
});
1023+
1024+
it('renders replayed cancelled compaction records as cancelled compaction blocks', async () => {
1025+
const driver = await replayIntoDriver([
1026+
message('user', [{ type: 'text', text: 'prompt before cancellation' }]),
1027+
{
1028+
type: 'compaction',
1029+
result: 'cancelled',
1030+
instruction: 'preserve implementation notes',
1031+
},
1032+
message('user', [{ type: 'text', text: 'prompt after cancellation' }]),
1033+
]);
1034+
1035+
const compactionEntry = driver.state.transcriptEntries.find(
1036+
(entry) => entry.compactionData !== undefined,
1037+
);
1038+
expect(compactionEntry?.compactionData).toEqual({
1039+
result: 'cancelled',
1040+
instruction: 'preserve implementation notes',
1041+
});
1042+
const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n'));
1043+
expect(transcript).toContain('Compaction cancelled');
1044+
expect(transcript).toContain('preserve implementation notes');
1045+
expect(transcript).not.toContain('Compaction complete');
1046+
});
1047+
9931048
it('renders plan permission and approval replay notices', async () => {
9941049
const driver = await replayIntoDriver([
9951050
{ time: REPLAY_TIME, type: 'plan_updated', enabled: true },

packages/agent-core/src/agent/compaction/full.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ export class FullCompaction {
100100
}
101101
if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return;
102102
if (this.agent.records.restoring) {
103+
this.agent.replayBuilder.push({
104+
type: 'compaction',
105+
instruction: data.instruction,
106+
});
103107
return;
104108
}
105109
const compactedCount = this.strategy.computeCompactCount(this.agent.context.history, data.source);
@@ -139,6 +143,9 @@ export class FullCompaction {
139143
}
140144

141145
private markCanceled(): void {
146+
this.agent.replayBuilder.patchLast('compaction', {
147+
result: 'cancelled',
148+
});
142149
if (!this.compacting) return;
143150
this.agent.records.logRecord({
144151
type: 'full_compaction.cancel',

packages/agent-core/src/agent/context/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,26 +146,34 @@ export class ContextMemory {
146146
}
147147
}
148148

149-
applyCompaction(summary: CompactionResult): void {
149+
applyCompaction(result: CompactionResult): void {
150150
this.agent.records.logRecord({
151151
type: 'context.apply_compaction',
152-
...summary,
152+
...result,
153+
});
154+
this.agent.replayBuilder.patchLast('compaction', {
155+
result: {
156+
summary: result.summary,
157+
compactedCount: result.compactedCount,
158+
tokensBefore: result.tokensBefore,
159+
tokensAfter: result.tokensAfter,
160+
},
153161
});
154162
this._history = [
155163
{
156164
role: 'assistant',
157-
content: [{ type: 'text', text: summary.summary }],
165+
content: [{ type: 'text', text: result.summary }],
158166
toolCalls: [],
159167
origin: { kind: 'compaction_summary' },
160168
},
161-
...this._history.slice(summary.compactedCount),
169+
...this._history.slice(result.compactedCount),
162170
];
163171
this.openSteps.clear();
164172
this.flushDeferredMessagesIfToolExchangeClosed();
165-
this._tokenCount = summary.tokensAfter;
173+
this._tokenCount = result.tokensAfter;
166174
this.tokenCountCoveredMessageCount = this._history.length;
167175
this.agent.microCompaction.reset();
168-
this.agent.injection.onContextCompacted(summary.compactedCount);
176+
this.agent.injection.onContextCompacted(result.compactedCount);
169177
this.agent.emitStatusUpdated();
170178
}
171179

packages/agent-core/src/agent/replay/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ export class ReplayBuilder {
1717
}
1818
}
1919

20+
patchLast<T extends AgentReplayRecord['type']>(
21+
type: T,
22+
patch: Partial<Extract<AgentReplayRecord, { type: T }>>,
23+
): void {
24+
if (this.agent.records.restoring) {
25+
const last = this.records.at(-1);
26+
if (last && last.type === type) {
27+
Object.assign(last, patch);
28+
}
29+
}
30+
}
31+
2032
removeLastMessages(removedMessages: ReadonlySet<ContextMessage>): void {
2133
if (removedMessages.size === 0) return;
2234
for (let i = this.records.length - 1; i >= 0; i--) {

packages/agent-core/src/rpc/resumed.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AgentType } from '#/agent';
22
import type { BackgroundTaskInfo } from '#/agent/background';
3+
import type { CompactionResult } from '#/agent/compaction';
34
import type { AgentConfigData, AgentConfigUpdateData } from '#/agent/config';
45
import type { AgentContextData, ContextMessage } from '#/agent/context';
56
import type { GoalChange, GoalSnapshot } from '#/agent/goal';
@@ -16,6 +17,7 @@ import type { SessionMeta } from '#/session';
1617

1718
export type AgentReplayRecordPayload =
1819
| { type: 'message'; message: ContextMessage }
20+
| { type: 'compaction'; result?: CompactionResult | 'cancelled'; instruction?: string }
1921
| {
2022
type: 'goal_updated';
2123
snapshot: GoalSnapshot;

packages/agent-core/test/agent/resume.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,89 @@ describe('Agent resume', () => {
224224
}
225225
});
226226

227+
it('projects restored compactions into replay records', async () => {
228+
const persistence = new RecordingAgentPersistence([
229+
{
230+
type: 'context.append_message',
231+
message: {
232+
role: 'user',
233+
content: [{ type: 'text', text: 'Historical prompt before compaction' }],
234+
toolCalls: [],
235+
origin: { kind: 'user' },
236+
},
237+
},
238+
{
239+
type: 'full_compaction.begin',
240+
source: 'manual',
241+
instruction: 'preserve implementation notes',
242+
},
243+
{
244+
type: 'full_compaction.complete',
245+
},
246+
{
247+
type: 'context.apply_compaction',
248+
summary: 'Compacted implementation notes.',
249+
compactedCount: 1,
250+
tokensBefore: 120,
251+
tokensAfter: 24,
252+
},
253+
]);
254+
const ctx = testAgent({ persistence });
255+
256+
await ctx.agent.resume();
257+
258+
expect(ctx.agent.context.history).toEqual([
259+
expect.objectContaining({
260+
role: 'assistant',
261+
content: [{ type: 'text', text: 'Compacted implementation notes.' }],
262+
origin: { kind: 'compaction_summary' },
263+
}),
264+
]);
265+
expect(ctx.agent.replayBuilder.buildResult()).toEqual([
266+
expect.objectContaining({
267+
type: 'message',
268+
message: expect.objectContaining({
269+
role: 'user',
270+
content: [{ type: 'text', text: 'Historical prompt before compaction' }],
271+
}),
272+
}),
273+
{
274+
type: 'compaction',
275+
result: {
276+
summary: 'Compacted implementation notes.',
277+
compactedCount: 1,
278+
tokensBefore: 120,
279+
tokensAfter: 24,
280+
},
281+
instruction: 'preserve implementation notes',
282+
},
283+
]);
284+
});
285+
286+
it('projects restored cancelled compactions into replay records', async () => {
287+
const persistence = new RecordingAgentPersistence([
288+
{
289+
type: 'full_compaction.begin',
290+
source: 'manual',
291+
instruction: 'preserve implementation notes',
292+
},
293+
{
294+
type: 'full_compaction.cancel',
295+
},
296+
]);
297+
const ctx = testAgent({ persistence });
298+
299+
await ctx.agent.resume();
300+
301+
expect(ctx.agent.replayBuilder.buildResult()).toEqual([
302+
{
303+
type: 'compaction',
304+
result: 'cancelled',
305+
instruction: 'preserve implementation notes',
306+
},
307+
]);
308+
});
309+
227310
it('persists undelivered restored background notifications during resume', async () => {
228311
const persistence = new RecordingAgentPersistence([
229312
{

0 commit comments

Comments
 (0)