Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/compaction-resume-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the agent resuming the wrong request after automatic context compaction in long sessions.
4 changes: 3 additions & 1 deletion apps/vis/server/src/lib/context-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,9 @@ export function projectContext(
},
};
const legacyTail = rec.legacyTail === true || rec.keptUserMessageCount === undefined;
const summaryIndex = legacyTail ? 0 : shape.messages.length - 1;
const summaryIndex = legacyTail
? 0
: shape.messages.findIndex((message) => message.origin?.kind === 'compaction_summary');
const modelSummaryBubble: ProjectedMessage = {
...summaryBubble,
message: modelFacingMessage(shape.messages[summaryIndex] ?? summaryBubble.message),
Expand Down
44 changes: 30 additions & 14 deletions apps/vis/server/test/lib/context-projector.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// apps/vis/server/test/lib/context-projector.test.ts
import { describe, it, expect, afterEach } from 'vitest';
import { estimateTokensForMessages } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/tokens';
import { buildCompactionContinuationText } from '@moonshot-ai/agent-core-v2/agent/contextMemory/compactionHandoff';
import { buildSessionFixture } from '../fixtures/build';
import { projectContext } from '../../src/lib/context-projector';
import { readAgentWire } from '../../src/lib/wire-reader';
Expand Down Expand Up @@ -440,16 +441,23 @@ describe('context-projector', () => {
keptUserMessageCount: 2 }, raw: {} },
];
const proj = projectContext(entries as any);
// [m0, m1, summary] — real user prompts are kept verbatim, the assistant
// tail is dropped.
expect(proj.messages).toHaveLength(3);
// [m0, m1, summary, anchor] — real user prompts are kept verbatim, the
// assistant tail is dropped, and the continuation anchor follows the summary.
expect(proj.messages).toHaveLength(4);
expect(proj.messages.map((m) => m.source)).toEqual([
'append_message', 'append_message', 'compaction_summary',
'append_message', 'append_message', 'compaction_summary', 'append_message',
]);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'm0' });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm1' });
expect(proj.messages[2]!.compaction).toEqual({ compactedCount: 3, tokensBefore: 100, tokensAfter: 10 });
expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'sum' });
expect(proj.messages[3]!.message.origin).toEqual({
kind: 'injection',
variant: 'compaction_continuation',
});
expect(proj.messages[3]!.message.content[0]).toMatchObject({
text: buildCompactionContinuationText(),
});
});

it('apply_compaction mirrors the legacy verbatim tail for records without keptUserMessageCount (model)', () => {
Expand Down Expand Up @@ -499,9 +507,10 @@ describe('context-projector', () => {
];

const proj = projectContext(entries as any);
// [FIRST, head slice of middle, marker, tail slice of middle, LAST, summary]
// — mirrors the engine's selectCompactionUserMessages + elision marker.
expect(proj.messages).toHaveLength(6);
// [FIRST, head slice of middle, marker, tail slice of middle, LAST, summary, anchor]
// — mirrors the engine's selectCompactionUserMessages + elision marker, with
// the continuation anchor after the summary.
expect(proj.messages).toHaveLength(7);
const texts = proj.messages.map((m) =>
m.message.content.map((p: any) => (p.type === 'text' ? p.text : '')).join(''),
);
Expand All @@ -517,9 +526,14 @@ describe('context-projector', () => {
expect(middle.endsWith(texts[3]!)).toBe(true);
expect(texts[4]).toBe(last);
expect(proj.messages[5]!.source).toBe('compaction_summary');
expect(proj.messages[6]!.message.origin).toEqual({
kind: 'injection',
variant: 'compaction_continuation',
});
expect(texts[6]).toBe(buildCompactionContinuationText());
// Synthesized entries (the head slice of the same message that anchors the
// tail, and the marker) get fractional lineNos so keys stay unique.
expect(new Set(proj.messages.map((m) => m.lineNo)).size).toBe(6);
expect(new Set(proj.messages.map((m) => m.lineNo)).size).toBe(7);
});

it('apply_compaction drops shell/local-command/background messages in model mode only', () => {
Expand All @@ -543,10 +557,10 @@ describe('context-projector', () => {

const model = projectContext(entries as any);
expect(model.messages.map((m) => m.source)).toEqual([
'append_message', 'compaction_summary', 'append_message',
'append_message', 'compaction_summary', 'append_message', 'append_message',
]);
expect(model.messages.map((m) => m.message.content[0])).toMatchObject([
{ text: 'real user' }, { text: 'sum' }, { text: 'new' },
{ text: 'real user' }, { text: 'sum' }, { text: buildCompactionContinuationText() }, { text: 'new' },
]);

const full = projectContext(entries as any, 'full');
Expand Down Expand Up @@ -591,12 +605,14 @@ describe('context-projector', () => {
keptUserMessageCount: 3 }, raw: {} },
];
const proj = projectContext(entries as any);
// Correct: [u1, u3, u4, summary]. The marker is gone, all real prompts kept.
// Correct: [u1, u3, u4, summary, anchor]. The marker is gone, all real
// prompts kept, and the continuation anchor follows the summary.
expect(proj.messages.map((m) => m.source)).toEqual([
'append_message', 'append_message', 'append_message', 'compaction_summary',
'append_message', 'append_message', 'append_message', 'compaction_summary', 'append_message',
]);
expect(proj.messages.map((m) => m.message.content[0])).toMatchObject([
{ text: 'u1' }, { text: 'u3' }, { text: 'u4' }, { text: 'sum' },
{ text: buildCompactionContinuationText() },
]);
});

Expand Down Expand Up @@ -994,10 +1010,10 @@ describe('context-projector', () => {
keptUserMessageCount: 2 }, raw: {} },
];
// No 2nd arg → 'model' default: the real user prompts are kept verbatim and
// the summary is appended after them.
// the summary is appended after them, followed by the continuation anchor.
const proj = projectContext(entries as any);
expect(proj.messages.map((m) => m.source)).toEqual([
'append_message', 'append_message', 'compaction_summary',
'append_message', 'append_message', 'compaction_summary', 'append_message',
]);
expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'm0' });
expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm1' });
Expand Down
6 changes: 4 additions & 2 deletions apps/vis/server/test/routes/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach } from 'vitest';
import { buildCompactionContinuationText } from '@moonshot-ai/agent-core-v2/agent/contextMemory/compactionHandoff';
import { buildSessionFixture } from '../fixtures/build';
import { contextRoute } from '../../src/routes/context';

Expand Down Expand Up @@ -77,10 +78,11 @@ describe('context route', () => {
messages: { source: string; message: { content: { type: string; text?: string }[] } }[];
};
expect(modelBody.messages.map((m) => m.source)).toEqual([
'append_message', 'compaction_summary', 'append_message',
'append_message', 'compaction_summary', 'append_message', 'append_message',
]);
expect(modelBody.messages[0]!.message.content[0]).toMatchObject({ text: 'before compaction' });
expect(modelBody.messages[2]!.message.content[0]).toMatchObject({ text: 'after compaction' });
expect(modelBody.messages[2]!.message.content[0]).toMatchObject({ text: buildCompactionContinuationText() });
expect(modelBody.messages[3]!.message.content[0]).toMatchObject({ text: 'after compaction' });

// Full history: every pre-compaction message (user prompt + assistant reply)
// is KEPT, then the summary marker, then the post-compaction tail.
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
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 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.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd();
export const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000;
export const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000;
export const COMPACTION_ELISION_VARIANT = 'compaction_elision';
export const COMPACTION_CONTINUATION_VARIANT = 'compaction_continuation';

type MessageLike = ContextMessage;

Expand Down Expand Up @@ -94,11 +95,12 @@ export function buildContextCompactionShape(
? [...selection.head, ...selection.tail]
: [...selection.head, elisionMessage, ...selection.tail];
const contextSummary = input.contextSummary ?? input.summary;
const continuationMessage = createCompactionContinuationMessage();
const tokensAfter =
input.tokensAfter ??
(input.requestOverheadTokens ?? 0) +
(input.summaryOutputTokens ?? estimate.text(contextSummary)) +
estimate.messages(keptMessages);
estimate.messages([...keptMessages, continuationMessage]);
const keptUserMessageCount =
input.keptUserMessageCount ?? selection.head.length + selection.tail.length;
const keptHeadUserMessageCount =
Expand All @@ -113,7 +115,11 @@ export function buildContextCompactionShape(
keptUserMessageCount,
keptHeadUserMessageCount,
droppedCount: input.droppedCount,
messages: [...keptMessages, createCompactionSummaryMessage(contextSummary)],
messages: [
...keptMessages,
createCompactionSummaryMessage(contextSummary),
continuationMessage,
],
};
}

Expand Down Expand Up @@ -146,6 +152,21 @@ export function buildCompactionElisionText(omittedTokens: number): string {
);
}

export function createCompactionContinuationMessage(): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text: buildCompactionContinuationText() }],
toolCalls: [],
origin: { kind: 'injection', variant: COMPACTION_CONTINUATION_VARIANT },
};
}

export function buildCompactionContinuationText(): string {
return wrapSystemReminder(
'Context compaction is complete — continue the work that was in progress when it began.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prioritize user input received during compaction

When a user steers the session after compaction starts, historySafeToCompact accepts the appended user message and buildContextCompactionShape retains it, but the summarizer never saw that message; the keeps messages appended while compacting an unchanged prefix test confirms it is then placed before this newer reminder. Consequently, the final model-visible instruction says to continue the older work that existed when compaction began, potentially overriding or obscuring the user's newly appended request. Place post-start user messages after the handoff or make the reminder explicitly prioritize them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against the existing keeps messages appended while compacting an unchanged prefix test — the race is real and the anchor does sharpen the pre-existing positioning gap into a misdirection here. Accepting as a known limitation for this PR (window is narrow and the fresh message survives verbatim); tracked in #3604 with both fix directions (positional anchor text vs. repositioning post-begin appends past the handoff).

);
Comment on lines +164 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the active non-user turn after compaction

When automatic compaction runs during a system_trigger, task, cron_job, or similar turn, this reminder redirects the model to an unrelated earlier user request. The full-compaction hook runs before every step in fullCompactionService.ts, while compactionUserMessageDisposition deliberately removes those non-user inputs from the rebuilt context, leaving their instructions only in the summary. The continuation should therefore refer to the in-flight task or summary rather than unconditionally selecting the latest user message.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8d4968c — the continuation no longer unconditionally selects the latest user message; it now says to continue the work that was in progress when compaction began, which covers non-user turns (cron/system_trigger/task) whose prompts are dropped from the rebuilt context.

}

export function collectCompactableUserMessages<T extends MessageLike>(messages: readonly T[]): T[] {
return messages.filter(
(message) => isRealUserInput(message) && !isCompactionSummaryMessage(message),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ function recoverFoldedLength(
const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount');
const compactedCount = readNumber(record, 'compactedCount');
if (keptUserMessageCount !== undefined) {
return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2);
return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 2 : 3);
}
if (compactedCount !== undefined && compactedCount < foldedLength) {
return 1 + (foldedLength - compactedCount);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
You are about to run out of context. Write a first-person handoff note to
yourself so you can seamlessly continue this task after the earlier
conversation is cleared.
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 ---

Write the note as your own continuing train of thought — first person, present
tense, the way you would reason through the next move. Do not write a
third-party report about someone else's work, and do not impose rigid section
headings; let the shape follow the task. Write the note in the same language the
conversation has been using — do not switch to English just because these
instructions happen to be in English.
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 note self-sufficient: the next turn will see only your most recent user
messages and this note — every assistant message, tool call, and tool result
above will be gone. In your own words, preserve what you genuinely need to
continue:
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 your most recent messages. But those kept messages are
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
Expand Down Expand Up @@ -52,9 +48,9 @@ continue:
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 your note 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.
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 note from its live
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
Expand All @@ -65,9 +61,9 @@ 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 note 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
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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -788,8 +788,9 @@ describe('Agent context', () => {
);

expect(shape.tokensAfter).toBe(0);
expect(shape.messages.map((m) => m.role)).toEqual(['user', 'user']);
expect(shape.messages.map((m) => m.role)).toEqual(['user', 'user', 'user']);
expect(shape.messages[1]?.origin?.kind).toBe('compaction_summary');
expect(shape.messages[2]?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' });
});

it('prefers the measured summary output tokens over the text estimate', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe('reduceContextTranscript', () => {
compaction('SUM', 3, 1),
appendMessage(userMessage('u4')),
]);
expect(result.foldedLength).toBe(3);
expect(result.foldedLength).toBe(4);
});

it('accounts for the elision marker when the record kept a head segment', () => {
Expand All @@ -119,7 +119,7 @@ describe('reduceContextTranscript', () => {
...assistantStep('s1', 'a1'),
compaction('SUM', 3, 2, 1),
]);
expect(result.foldedLength).toBe(4);
expect(result.foldedLength).toBe(5);
});

it('carries the originating wire record time per entry', () => {
Expand Down Expand Up @@ -159,7 +159,7 @@ describe('reduceContextTranscript', () => {
]);
expect(texts(result)).toEqual(['message A', 'reply A', 'summary text']);
expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'user']);
expect(result.foldedLength).toBe(2);
expect(result.foldedLength).toBe(3);
});

it('undo without compaction keeps the earlier exchange intact', () => {
Expand Down Expand Up @@ -388,9 +388,10 @@ describe('live fold parity', () => {
];
const live = foldLive(records);
const transcript = reduceContextTranscript(records);
expect(live).toHaveLength(5);
expect(live).toHaveLength(6);
expect(transcript.foldedLength).toBe(live.length);
expect(live[2]!.origin).toEqual({ kind: 'compaction_summary' });
expect(live[3]!.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' });
});

it('settles a frame left open by a failed attempt when compaction lands mid-fold', () => {
Expand All @@ -403,7 +404,7 @@ describe('live fold parity', () => {
];
const live = foldLive(records);
const transcript = reduceContextTranscript(records);
expect(live.map((m) => m.role)).toEqual(['user', 'user', 'assistant']);
expect(live.map((m) => m.role)).toEqual(['user', 'user', 'user', 'assistant']);
expect(texts(transcript)).toEqual(['u1', 'a1', 'SUM', 'a3']);
expect(transcript.foldedLength).toBe(live.length);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ContextUndo,
} from '#/agent/contextMemory/contextEvents';
import { contextMemoryKey } from '#/agent/contextMemory/contextOps';
import { buildCompactionContinuationText } from '#/agent/contextMemory/compactionHandoff';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting';
import { IEventBus } from '#/app/event/eventBus';
Expand Down Expand Up @@ -376,11 +377,19 @@ describe('AgentContextMemoryService (wire-backed)', () => {
);

const model = replay.agentState.get(contextMemoryKey);
expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']);
expect(model.map(textOf)).toEqual(['old user', 'recent user', 'model-facing summary']);
expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user', 'user']);
expect(model.map(textOf)).toEqual([
'old user',
'recent user',
'model-facing summary',
buildCompactionContinuationText(),
]);
expect(model[2]).toMatchObject({
origin: { kind: 'compaction_summary' },
});
expect(model[3]).toMatchObject({
origin: { kind: 'injection', variant: 'compaction_continuation' },
});
});

it('replays pre-contextSummary kept-user records without adding a new prefix', async () => {
Expand All @@ -406,7 +415,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
);

const model = replay.agentState.get(contextMemoryKey);
expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']);
expect(model.map(textOf)).toEqual([
'old user',
'recent user',
'OLD SUMMARY',
buildCompactionContinuationText(),
]);
expect(model[2]).toMatchObject({
role: 'user',
origin: { kind: 'compaction_summary' },
Expand Down
Loading
Loading