Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-07 13:59:20 +08:00
commit c51d8df461
33 changed files with 1764 additions and 113 deletions

View file

@ -54,6 +54,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>agentLifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "<b>interaction</b>\n<size:9><i>Session</i></size>\n IInteractionService" as interaction #D5F5E3
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "<b>workspaceCommand</b>\n<size:9><i>Session</i></size>\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3
rectangle "<b>sessionLog</b>\n<size:9><i>Session binding</i></size>\n ILogService" as sessionLog #D5F5E3
rectangle "<b>sessionSkillCatalog</b>\n<size:9><i>Session</i></size>\n ISessionSkillCatalog\n ISkillCatalogSink\n Workspace/PluginSkillSource" as sessionSkillCatalog #D5F5E3
rectangle "<b>sessionFs</b>\n<size:9><i>Session</i></size>\n ISessionFsService" as sessionFs #D5F5E3
@ -141,6 +142,10 @@ modelCatalog --> model #34495E
modelCatalog --> config #34495E
modelCatalog --> auth #34495E
workspaceContext --> session_context #34495E
workspaceCommand --> bootstrap #34495E
workspaceCommand --> workspaceContext #34495E
workspaceCommand --> agent_lifecycle #34495E
workspaceCommand --> hostFs #34495E
sessionLog --> session_context #34495E
sessionSkillCatalog --> skillCatalog #34495E
sessionSkillCatalog --> plugin #34495E

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Before After
Before After

View file

@ -177,6 +177,13 @@ const DOMAIN_LAYER = new Map([
['sessionActivity', 6],
['session', 6],
['terminal', 6],
// `workspaceCommand` orchestrates session-level workspace mutations
// (`addAdditionalDir`): it reaches through `agentLifecycle` (L6) to the
// `main` agent's `contextMemory` (L4) to mirror the action's stdout, and
// reads/writes the workspace-local config through `os/interface` (L1). Its
// highest real dependency is `agentLifecycle`, so it sits in L6 beside the
// other coordination domains.
['workspaceCommand', 6],
// L7 — boundary
['approval', 7],
['question', 7],

View file

@ -0,0 +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.

View file

@ -0,0 +1,333 @@
/**
* `contextMemory` domain helper derives the v1-compatible full-compaction
* handoff shape for live rewrites, wire replay, and snapshot reducers.
*/
import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/_base/utils/tokens';
import type { ContentPart } from '#/app/llmProtocol';
import summaryPrefixTemplate from './compaction-summary-prefix.md?raw';
import type { ContextMessage, PromptOrigin } from './types';
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';
type MessageLike = ContextMessage;
export interface CompactionUserSelection<T> {
readonly head: T[];
readonly tail: T[];
readonly elided: boolean;
readonly omittedTokens: number;
}
export interface ContextCompactionShapeInput {
readonly summary: string;
readonly legacySummaryMessage?: ContextMessage;
readonly contextSummary?: string;
readonly compactedCount: number;
readonly tokensBefore: number;
readonly tokensAfter?: number;
readonly keptUserMessageCount?: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
readonly legacyTail?: boolean;
}
export interface ContextCompactionShape {
readonly summary: string;
readonly contextSummary: string;
readonly compactedCount: number;
readonly tokensBefore: number;
readonly tokensAfter: number;
readonly keptUserMessageCount: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
readonly messages: readonly ContextMessage[];
}
export function buildContextCompactionShape(
history: readonly ContextMessage[],
input: ContextCompactionShapeInput,
): ContextCompactionShape {
if (usesLegacyTailShape(input)) {
const contextSummary = input.contextSummary ?? input.summary;
const messages = [
input.legacySummaryMessage ?? createCompactionSummaryMessage(contextSummary),
...history.slice(input.compactedCount),
];
return {
summary: input.summary,
contextSummary,
compactedCount: input.compactedCount,
tokensBefore: input.tokensBefore,
tokensAfter: input.tokensAfter ?? estimateTokensForMessages(messages),
keptUserMessageCount: 0,
droppedCount: input.droppedCount,
messages,
};
}
const compactableUserMessages = collectCompactableUserMessages(history);
const selection = input.keptHeadUserMessageCount === undefined
? {
head: [],
tail: selectRecentUserMessages(compactableUserMessages),
elided: false,
omittedTokens: 0,
}
: selectCompactionUserMessages(compactableUserMessages);
const elisionMessage = selection.elided
? createCompactionElisionMessage(selection.omittedTokens)
: undefined;
const keptMessages = elisionMessage === undefined
? [...selection.head, ...selection.tail]
: [...selection.head, elisionMessage, ...selection.tail];
const contextSummary = input.contextSummary ?? input.summary;
const tokensAfter =
input.tokensAfter ?? estimateTokens(contextSummary) + estimateTokensForMessages(keptMessages);
const keptUserMessageCount =
input.keptUserMessageCount ?? selection.head.length + selection.tail.length;
const keptHeadUserMessageCount =
input.keptHeadUserMessageCount ?? (selection.elided ? selection.head.length : undefined);
return {
summary: input.summary,
contextSummary,
compactedCount: input.compactedCount,
tokensBefore: input.tokensBefore,
tokensAfter,
keptUserMessageCount,
keptHeadUserMessageCount,
droppedCount: input.droppedCount,
messages: [...keptMessages, createCompactionSummaryMessage(contextSummary)],
};
}
export function buildCompactionSummaryText(summary: string): string {
const suffix = summary.trim();
return `${COMPACTION_SUMMARY_PREFIX}\n${suffix.length > 0 ? suffix : '(no summary available)'}`;
}
export function createCompactionSummaryMessage(text: string): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
};
}
export function createCompactionElisionMessage(omittedTokens: number): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text: buildCompactionElisionText(omittedTokens) }],
toolCalls: [],
origin: { kind: 'injection', variant: COMPACTION_ELISION_VARIANT },
};
}
export function buildCompactionElisionText(omittedTokens: number): string {
return [
'<system-reminder>',
`Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`,
'</system-reminder>',
].join('\n');
}
export function collectCompactableUserMessages<T extends MessageLike>(messages: readonly T[]): T[] {
return messages.filter(
(message) => isRealUserInput(message) && !isCompactionSummaryMessage(message),
);
}
export function isCompactionSummaryMessage(message: MessageLike): boolean {
return message.origin?.kind === 'compaction_summary';
}
export function isRealUserInput(message: MessageLike): boolean {
return message.role === 'user' && compactionUserMessageDisposition(message.origin) === 'keep';
}
export function compactionUserMessageDisposition(
origin: PromptOrigin | undefined,
): 'keep' | 'drop' {
if (origin === undefined) return 'keep';
switch (origin.kind) {
case 'user':
return 'keep';
case 'skill_activation':
case 'plugin_command':
return origin.trigger === 'user-slash' ? 'keep' : 'drop';
case 'injection':
case 'compaction_summary':
case 'system_trigger':
case 'task':
case 'cron_job':
case 'cron_missed':
case 'hook_result':
case 'retry':
return 'drop';
default: {
const exhaustive: never = origin;
void exhaustive;
return 'drop';
}
}
}
export function selectRecentUserMessages<T extends MessageLike>(
messages: readonly T[],
maxTokens: number = COMPACT_USER_MESSAGE_MAX_TOKENS,
): T[] {
const selected: T[] = [];
let remaining = maxTokens;
for (let i = messages.length - 1; i >= 0 && remaining > 0; i--) {
const message = messages[i]!;
const tokens = estimateTokensForMessage(message);
if (tokens <= remaining) {
selected.push(message);
remaining -= tokens;
} else {
selected.push(truncateUserMessage(message, remaining));
break;
}
}
selected.reverse();
return selected;
}
export function selectCompactionUserMessages<T extends MessageLike>(
messages: readonly T[],
maxTokens: number = COMPACT_USER_MESSAGE_MAX_TOKENS,
headTokens: number = COMPACT_USER_MESSAGE_HEAD_TOKENS,
): CompactionUserSelection<T> {
let totalTokens = 0;
for (const message of messages) {
totalTokens += estimateTokensForMessage(message);
}
if (totalTokens <= maxTokens) {
return { head: [], tail: [...messages], elided: false, omittedTokens: 0 };
}
const headBudget = Math.min(Math.max(headTokens, 0), maxTokens);
const tailBudget = maxTokens - headBudget;
const tail: T[] = [];
let tailRemaining = tailBudget;
let headEndExclusive = messages.length;
let tailBoundaryDroppedPrefix: T | null = null;
for (let i = messages.length - 1; i >= 0 && tailRemaining > 0; i--) {
const message = messages[i]!;
const tokens = estimateTokensForMessage(message);
if (tokens <= tailRemaining) {
tail.push(message);
tailRemaining -= tokens;
headEndExclusive = i;
continue;
}
const fullText = extractText(message.content);
const keptSuffix = truncateTextToTokensFromEnd(fullText, tailRemaining);
tail.push(replaceMessageText(message, keptSuffix));
headEndExclusive = i;
const droppedPrefix = fullText.slice(0, fullText.length - keptSuffix.length);
if (droppedPrefix.length > 0) {
tailBoundaryDroppedPrefix = replaceMessageText(message, droppedPrefix);
}
break;
}
tail.reverse();
const headCandidates = messages.slice(0, headEndExclusive);
if (tailBoundaryDroppedPrefix !== null) {
headCandidates.push(tailBoundaryDroppedPrefix);
}
const head: T[] = [];
let headRemaining = headBudget;
for (const message of headCandidates) {
if (headRemaining <= 0) break;
const tokens = estimateTokensForMessage(message);
if (tokens <= headRemaining) {
head.push(message);
headRemaining -= tokens;
continue;
}
head.push(truncateUserMessage(message, headRemaining));
break;
}
let keptTokens = 0;
for (const message of head) keptTokens += estimateTokensForMessage(message);
for (const message of tail) keptTokens += estimateTokensForMessage(message);
return { head, tail, elided: true, omittedTokens: Math.max(0, totalTokens - keptTokens) };
}
function usesLegacyTailShape(input: ContextCompactionShapeInput): boolean {
return input.legacyTail === true;
}
function extractText(content: readonly ContentPart[]): string {
let text = '';
for (const part of content) {
if (part.type === 'text') {
text += part.text;
}
}
return text;
}
function truncateTextToTokens(text: string, maxTokens: number): string {
if (maxTokens <= 0) return '';
let asciiCount = 0;
let nonAsciiCount = 0;
let end = 0;
for (const char of text) {
if (char.codePointAt(0)! <= 127) {
asciiCount++;
} else {
nonAsciiCount++;
}
if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break;
end += char.length;
}
return text.slice(0, end);
}
function truncateTextToTokensFromEnd(text: string, maxTokens: number): string {
if (maxTokens <= 0) return '';
let asciiCount = 0;
let nonAsciiCount = 0;
let start = text.length;
for (let i = text.length - 1; i >= 0; i--) {
let isAscii = false;
const code = text.charCodeAt(i);
if (code >= 0xdc00 && code <= 0xdfff && i > 0) {
const high = text.charCodeAt(i - 1);
if (high >= 0xd800 && high <= 0xdbff) {
i--;
}
} else {
isAscii = code <= 127;
}
if (isAscii) {
asciiCount++;
} else {
nonAsciiCount++;
}
if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break;
start = i;
}
return text.slice(start);
}
function replaceMessageText<T extends MessageLike>(message: T, text: string): T {
return {
...message,
content: [{ type: 'text', text }],
toolCalls: [],
} as unknown as T;
}
function truncateUserMessage<T extends MessageLike>(message: T, maxTokens: number): T {
return replaceMessageText(message, truncateTextToTokens(extractText(message.content), maxTokens));
}

View file

@ -4,9 +4,25 @@ import type { UndoCut } from './contextOps';
import type { ContextMessage } from './types';
export interface ContextCompactionInput {
readonly count: number;
readonly summary: ContextMessage;
readonly tokens?: number;
readonly summary: string;
readonly contextSummary?: string;
readonly compactedCount: number;
readonly tokensBefore: number;
readonly tokensAfter?: number;
readonly keptUserMessageCount?: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
}
export interface ContextCompactionResult {
summary: string;
contextSummary: string;
compactedCount: number;
tokensBefore: number;
tokensAfter: number;
keptUserMessageCount: number;
keptHeadUserMessageCount?: number;
droppedCount?: number;
}
export interface IAgentContextMemoryService {
@ -28,8 +44,8 @@ export interface IAgentContextMemoryService {
*/
undo(count: number): UndoCut;
/** Replace the leading `count` messages with a compaction summary (`context.apply_compaction`). */
applyCompaction(input: ContextCompactionInput): void;
/** Rewrite the live history into the v1-compatible compaction handoff shape. */
applyCompaction(input: ContextCompactionInput): ContextCompactionResult;
/**
* Arbitrary splice (`context.splice`). Retained for replay of protocol 1.5

View file

@ -20,7 +20,12 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentWireService, type IWireService } from '#/wire';
import { IAgentContextMemoryService, type ContextCompactionInput } from './contextMemory';
import {
IAgentContextMemoryService,
type ContextCompactionInput,
type ContextCompactionResult,
} from './contextMemory';
import { buildContextCompactionShape } from './compactionHandoff';
import {
computeUndoCut,
ContextModel,
@ -103,15 +108,30 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
return cut;
}
applyCompaction(input: ContextCompactionInput): void {
const summary = ensureMessageId(input.summary);
this.wire.dispatch(contextApplyCompaction({ count: input.count, summary }));
applyCompaction(input: ContextCompactionInput): ContextCompactionResult {
const history = this.get();
const result = buildContextCompactionShape(history, input);
this.wire.dispatch(
contextApplyCompaction({
summary: result.summary,
contextSummary: result.contextSummary,
compactedCount: result.compactedCount,
tokensBefore: result.tokensBefore,
tokensAfter: result.tokensAfter,
keptUserMessageCount: result.keptUserMessageCount,
keptHeadUserMessageCount: result.keptHeadUserMessageCount,
droppedCount: result.droppedCount,
}),
);
this.eventBus.publish({ type: 'context.spliced',
start: 0,
deleteCount: input.count,
messages: [summary],
tokens: input.tokens,
deleteCount: history.length,
messages: [...result.messages],
tokens: result.tokensAfter,
});
const { messages: _messages, ...publicResult } = result;
void _messages;
return publicResult;
}
splice(

View file

@ -35,6 +35,11 @@ import type { ContentPart } from '#/app/llmProtocol/message';
import { defineModel, defineOp, type PartsTransformer } from '#/wire';
import type { PersistedRecord } from '#/wire';
import {
buildContextCompactionShape,
createCompactionSummaryMessage,
type ContextCompactionShapeInput,
} from './compactionHandoff';
import {
foldAppendMessage,
foldLoopEvent,
@ -136,16 +141,136 @@ export const contextClear = defineOp(ContextModel, 'context.clear', {
apply: (state): ContextMessage[] => (state.length === 0 ? state : resetFold([]) as ContextMessage[]),
});
export interface ContextCompactionPayload {
readonly count: number;
readonly summary: ContextMessage;
interface ContextCompactionBasePayload {
readonly tokensBefore?: number;
readonly tokensAfter?: number;
readonly keptUserMessageCount?: number;
readonly keptHeadUserMessageCount?: number;
readonly droppedCount?: number;
}
export interface TextSummaryCompactionPayload extends ContextCompactionBasePayload {
readonly summary: string;
readonly compactedCount: number;
readonly contextSummary?: string;
}
interface ContextSummaryCompactionPayload extends ContextCompactionBasePayload {
readonly contextSummary: string;
readonly compactedCount: number;
readonly summary?: string;
}
interface LegacyMessageSummaryCompactionPayload extends ContextCompactionBasePayload {
readonly summary: ContextMessage;
readonly count: number;
readonly compactedCount?: number;
}
export type ContextCompactionPayload =
| TextSummaryCompactionPayload
| ContextSummaryCompactionPayload
| LegacyMessageSummaryCompactionPayload;
export const contextApplyCompaction = defineOp(ContextModel, 'context.apply_compaction', {
apply: (state, p: ContextCompactionPayload): ContextMessage[] =>
resetFold([p.summary, ...state.slice(p.count)]) as ContextMessage[],
apply: (state, p: ContextCompactionPayload): ContextMessage[] => {
const result = buildContextCompactionShape(state, readContextCompactionShapeInput(p));
return resetFold([...result.messages]) as ContextMessage[];
},
});
interface UnknownRecord {
readonly [key: string]: unknown;
}
type ContextCompactionRecord = ContextCompactionPayload | UnknownRecord;
export function applyContextCompactionRecord(
state: readonly ContextMessage[],
record: ContextCompactionRecord,
): ContextMessage[] {
const result = buildContextCompactionShape(state, readContextCompactionShapeInput(record));
return resetFold([...result.messages]) as ContextMessage[];
}
export function readContextCompactionShapeInput(
record: ContextCompactionRecord,
): ContextCompactionShapeInput {
const fields = record as UnknownRecord;
const keptUserMessageCount = readOptionalNumber(fields, 'keptUserMessageCount');
return {
summary: readContextCompactionRawSummary(fields),
legacySummaryMessage: readLegacySummaryMessage(fields),
contextSummary: readOptionalString(fields, 'contextSummary'),
compactedCount: readContextCompactedCount(fields),
tokensBefore: readOptionalNumber(fields, 'tokensBefore') ?? 0,
tokensAfter: readOptionalNumber(fields, 'tokensAfter'),
keptUserMessageCount,
keptHeadUserMessageCount: readOptionalNumber(fields, 'keptHeadUserMessageCount'),
droppedCount: readOptionalNumber(fields, 'droppedCount'),
legacyTail: keptUserMessageCount === undefined,
};
}
export function readContextCompactedCount(record: ContextCompactionRecord): number {
const fields = record as UnknownRecord;
const compactedCount = fields['compactedCount'];
if (typeof compactedCount === 'number') return compactedCount;
const legacyCount = fields['count'];
if (typeof legacyCount === 'number') return legacyCount;
throw new Error('Invalid context.apply_compaction record: missing compactedCount');
}
export function readContextCompactionSummary(record: ContextCompactionRecord): ContextMessage {
const fields = record as UnknownRecord;
const contextSummary = fields['contextSummary'];
if (typeof contextSummary === 'string') return createCompactionSummaryMessage(contextSummary);
const summary = fields['summary'];
if (typeof summary === 'string') return createCompactionSummaryMessage(summary);
if (isContextMessage(summary)) return summary;
throw new Error('Invalid context.apply_compaction record: missing summary');
}
function readContextCompactionRawSummary(record: UnknownRecord): string {
const summary = record['summary'];
if (typeof summary === 'string') return summary;
const contextSummary = record['contextSummary'];
if (typeof contextSummary === 'string') return contextSummary;
if (isContextMessage(summary)) {
return textOf(summary);
}
throw new Error('Invalid context.apply_compaction record: missing summary');
}
function readLegacySummaryMessage(record: UnknownRecord): ContextMessage | undefined {
const summary = record['summary'];
return isContextMessage(summary) ? summary : undefined;
}
function readOptionalNumber(record: UnknownRecord, key: string): number | undefined {
const value = record[key];
return typeof value === 'number' ? value : undefined;
}
function readOptionalString(record: UnknownRecord, key: string): string | undefined {
const value = record[key];
return typeof value === 'string' ? value : undefined;
}
function textOf(message: ContextMessage): string {
let text = '';
for (const part of message.content) {
if (part.type === 'text') text += part.text;
}
return text;
}
function isContextMessage(value: unknown): value is ContextMessage {
if (value === null || typeof value !== 'object') return false;
const message = value as { role?: unknown; content?: unknown };
return typeof message.role === 'string' && Array.isArray(message.content);
}
export interface ContextUndoPayload {
readonly count: number;
}

View file

@ -5,8 +5,8 @@
export * from './contextMemory';
export * from './contextMemoryService';
export * from './contextOps';
export * from './compactionHandoff';
export * from './loopEventFold';
export * from './messageId';
export * from './messageProjection';
export * from './types';

View file

@ -5,7 +5,7 @@ import type {
import { createDecorator } from "#/_base/di/instantiation";
import type { Hooks } from '#/hooks';
export type FullCompactionCompleteData = Omit<CompactionResult, 'summary'>;
export type FullCompactionCompleteData = Omit<CompactionResult, 'summary' | 'contextSummary'>;
export interface CompactInput {
readonly source: CompactionSource;

View file

@ -2,9 +2,13 @@ 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 { estimateTokens, estimateTokensForMessages } from "#/_base/utils/tokens";
import { estimateTokensForMessages } from "#/_base/utils/tokens";
import type { ContextMessage } from '#/agent/contextMemory';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import {
buildCompactionSummaryText,
IAgentContextMemoryService,
isRealUserInput,
} from '#/agent/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize';
import {
IAgentLLMRequesterService,
@ -298,9 +302,11 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
try {
const finalResult: CompactionResult = {
summary: '',
contextSummary: '',
compactedCount: 1,
tokensBefore: 0,
tokensAfter: 0,
keptUserMessageCount: 0,
};
let compactedCount = initialCompactedCount;
@ -310,9 +316,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
if (this.compacting !== active) return;
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;
@ -323,7 +333,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
if (this.compacting !== active) return;
this.lastCompactedTokenCount = finalResult.tokensAfter;
this.markCompleted(completeData(finalResult));
this.eventBus.publish({ type: 'compaction.completed', result: finalResult, trigger: data.source });
const { contextSummary: _contextSummary, ...eventResult } = finalResult;
void _contextSummary;
this.eventBus.publish({ type: 'compaction.completed', result: eventResult, trigger: data.source });
} catch (error) {
if (isAbortError(error)) return;
const blockedByTurn = this.compacting === active && active.blockedByTurn;
@ -428,20 +440,18 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
);
}
if (!historyUnchanged(this.context.get(), originalHistory)) {
if (!historySafeToCompact(this.context.get(), originalHistory)) {
this.cancel();
return undefined;
}
const summary = this.postProcessSummary(attempt.summary);
const recent = originalHistory.slice(compactedCount);
const tokensAfter = estimateTokens(summary) + estimateTokensForMessages(recent);
const result: CompactionResult = {
const result = this.context.applyCompaction({
summary,
contextSummary: buildCompactionSummaryText(summary),
compactedCount,
tokensBefore,
tokensAfter,
};
});
this.telemetry.track('compaction_finished', {
// Never send `data.instruction` (user-authored content) to telemetry.
@ -455,12 +465,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
thinking_level: this.profile.data().thinkingLevel,
...usageTelemetry(attempt.usage),
});
this.context.applyCompaction({
count: compactedCount,
summary: createCompactionSummaryMessage(summary),
tokens: result.tokensAfter,
});
return result;
} catch (error) {
if (isAbortError(error)) return undefined;
@ -514,34 +518,24 @@ function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult {
return { summary, usage: finish.usage };
}
function createCompactionSummaryMessage(summary: string): ContextMessage {
return {
role: 'assistant',
content: [{ type: 'text', text: summary }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
};
}
function completeData(result: CompactionResult): FullCompactionCompleteData {
return {
compactedCount: result.compactedCount,
tokensBefore: result.tokensBefore,
tokensAfter: result.tokensAfter,
keptUserMessageCount: result.keptUserMessageCount,
keptHeadUserMessageCount: result.keptHeadUserMessageCount,
droppedCount: result.droppedCount,
};
}
function historyUnchanged(
function historySafeToCompact(
current: readonly ContextMessage[],
original: readonly ContextMessage[],
): boolean {
// Only the compacted prefix must be intact. Messages appended to the tail
// while the summary request was in flight are fine — unlike legacy's
// whole-history rebuild (which had to cancel when non-user messages grew the
// tail), the splice replaces just the prefix and leaves the appended tail in
// place, so nothing appended concurrently can be lost.
if (current.length < original.length) return false;
return original.every((message, index) => message === current[index]);
if (!original.every((message, index) => message === current[index])) return false;
return current.slice(original.length).every(isRealUserInput);
}
function usageTelemetry(usage: TokenUsage | null): CompactionTelemetryProperties {

View file

@ -1,8 +1,14 @@
export interface CompactionResult {
import type { CompactionResult as ProtocolCompactionResult } from '@moonshot-ai/protocol';
export interface CompactionResult extends ProtocolCompactionResult {
summary: string;
contextSummary?: string;
compactedCount: number;
tokensBefore: number;
tokensAfter: number;
keptUserMessageCount?: number;
keptHeadUserMessageCount?: number;
droppedCount?: number;
}
export type CompactionSource = 'manual' | 'auto';

View file

@ -71,6 +71,7 @@ export * from '#/agent/questionTools';
export * from '#/app/gateway';
export * from '#/session/workspaceContext';
export * from '#/session/workspaceCommand';
export * from '#/app/workspaceRegistry';
export * from '#/session/process';
export * from '#/session/sessionFs';

View file

@ -0,0 +1,11 @@
/**
* `workspaceCommand` domain barrel re-exports the workspace-command contract
* (`workspaceCommand`), its scoped service (`workspaceCommandService`), and the
* workspace-local-config helpers (`workspaceLocalConfig`). Importing this
* barrel registers the `ISessionWorkspaceCommandService` binding into the scope
* registry.
*/
export * from './workspaceCommand';
export * from './workspaceCommandService';
export * from './workspaceLocalConfig';

View file

@ -0,0 +1,33 @@
/**
* `workspaceCommand` domain (L6) workspace mutation command contract.
*
* Defines the `ISessionWorkspaceCommandService` that orchestrates session-level
* workspace mutations (`addAdditionalDir`): persisting workspace-local config
* when asked, updating `ISessionWorkspaceContext`, and mirroring the
* action's stdout into the main agent's context as a `local-command-stdout`
* injection so the agent observes the change. Session-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface AddAdditionalDirInput {
readonly path: string;
/** When `true` (default), persist the directory into `<projectRoot>/.kimi-code/local.toml`. */
readonly persist?: boolean;
}
export interface WorkspaceAdditionalDirsResult {
readonly projectRoot: string;
readonly configPath: string;
readonly additionalDirs: readonly string[];
readonly persisted: boolean;
}
export interface ISessionWorkspaceCommandService {
readonly _serviceBrand: undefined;
addAdditionalDir(input: AddAdditionalDirInput): Promise<WorkspaceAdditionalDirsResult>;
}
export const ISessionWorkspaceCommandService: ServiceIdentifier<ISessionWorkspaceCommandService> =
createDecorator<ISessionWorkspaceCommandService>('sessionWorkspaceCommandService');

View file

@ -0,0 +1,138 @@
/**
* `workspaceCommand` domain (L6) `ISessionWorkspaceCommandService` implementation.
*
* Coordinates session-level workspace mutations. `addAdditionalDir` persists
* the directory into the workspace-local config file when `persist` is true
* (`<projectRoot>/.kimi-code/local.toml`, through `IHostFileSystem`), updates
* `ISessionWorkspaceContext`, and mirrors the action's stdout into the main
* agent's context as a `local-command-stdout` injection (via
* `IAgentContextMemoryService` on the `main` handle from `agentLifecycle`).
* If the main agent does not exist yet, the injection is queued and flushed
* from the `onDidCreateMain` subscription. Bound at Session scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IBootstrapService } from '#/app/bootstrap';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import {
type AddAdditionalDirInput,
ISessionWorkspaceCommandService,
type WorkspaceAdditionalDirsResult,
} from './workspaceCommand';
import {
appendWorkspaceAdditionalDir,
normalizeAdditionalDirs,
readWorkspaceAdditionalDirs,
resolveWorkspaceAdditionalDirs,
type WorkspaceLocalDeps,
} from './workspaceLocalConfig';
export class SessionWorkspaceCommandService
extends Disposable
implements ISessionWorkspaceCommandService
{
declare readonly _serviceBrand: undefined;
private readonly pendingMainInjections: ContextMessage[] = [];
private mutationQueue: Promise<void> = Promise.resolve();
constructor(
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
@IHostFileSystem private readonly hostFs: IHostFileSystem,
) {
super();
this._register(
this.agents.onDidCreateMain((handle) => {
if (this.pendingMainInjections.length === 0) return;
const pending = this.pendingMainInjections.splice(0);
handle.accessor.get(IAgentContextMemoryService).append(...pending);
}),
);
}
async addAdditionalDir(input: AddAdditionalDirInput): Promise<WorkspaceAdditionalDirsResult> {
return this.enqueueMutation(() => this.applyAddAdditionalDir(input));
}
private async applyAddAdditionalDir(
input: AddAdditionalDirInput,
): Promise<WorkspaceAdditionalDirsResult> {
const persist = input.persist ?? true;
const deps: WorkspaceLocalDeps = { fs: this.hostFs, homeDir: this.bootstrap.homeDir };
if (persist) {
const persisted = await appendWorkspaceAdditionalDir(
deps,
this.workspace.workDir,
input.path,
);
const additionalDirs = normalizeAdditionalDirs([
...this.workspace.additionalDirs,
...persisted.additionalDirs,
]);
this.workspace.setAdditionalDirs(additionalDirs);
this.injectAdditionalDirAdded(input.path, true, persisted.configPath);
return {
projectRoot: persisted.projectRoot,
configPath: persisted.configPath,
additionalDirs,
persisted: true,
};
}
const workspace = await readWorkspaceAdditionalDirs(deps, this.workspace.workDir);
const resolved = await resolveWorkspaceAdditionalDirs(deps, this.workspace.workDir, [
input.path,
]);
const additionalDirs = normalizeAdditionalDirs([...this.workspace.additionalDirs, ...resolved]);
this.workspace.setAdditionalDirs(additionalDirs);
this.injectAdditionalDirAdded(input.path, false, workspace.configPath);
return {
projectRoot: workspace.projectRoot,
configPath: workspace.configPath,
additionalDirs,
persisted: false,
};
}
private enqueueMutation<T>(work: () => Promise<T>): Promise<T> {
const run = this.mutationQueue.then(work, work);
this.mutationQueue = run.then(() => undefined, () => undefined);
return run;
}
private injectAdditionalDirAdded(path: string, persisted: boolean, configPath: string): void {
const stdout = persisted
? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}`
: `Added workspace directory:\n ${path}\n For this session only`;
const text = `<local-command-stdout>\n${stdout.trim()}\n</local-command-stdout>`;
const message: ContextMessage = {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin: { kind: 'injection', variant: 'local-command-stdout' },
};
const main = this.agents.getHandle(MAIN_AGENT_ID);
if (main !== undefined) {
main.accessor.get(IAgentContextMemoryService).append(message);
return;
}
this.pendingMainInjections.push(message);
}
}
registerScopedService(
LifecycleScope.Session,
ISessionWorkspaceCommandService,
SessionWorkspaceCommandService,
InstantiationType.Delayed,
'workspaceCommand',
);

View file

@ -0,0 +1,318 @@
/**
* `workspaceCommand` domain (L6) workspace local-config file helpers.
*
* Reads and writes the `<projectRoot>/.kimi-code/local.toml` file that records
* additional workspace directories persisted across sessions. Pure IO
* functions over `IHostFileSystem` plus the host home directory; no scoped
* state. Ported from v1's `config/workspace-local.ts` with the Kaos primitive
* swapped for v2's host filesystem.
*/
import { dirname, isAbsolute, join, normalize, resolve } from 'pathe';
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
import { z } from 'zod';
import { ErrorCodes, KimiError } from '#/errors';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
const WorkspaceLocalTomlSchema = z.object({
workspace: z
.object({
additional_dir: z.array(z.string()),
})
.optional(),
});
type WorkspaceLocalToml = z.infer<typeof WorkspaceLocalTomlSchema>;
export interface WorkspaceLocalDeps {
readonly fs: IHostFileSystem;
readonly homeDir: string;
}
export interface WorkspaceAdditionalDirsLoadResult {
readonly projectRoot: string;
readonly configPath: string;
readonly additionalDirs: readonly string[];
}
interface WorkspaceLocalTomlFile {
readonly raw: Record<string, unknown>;
readonly parsed: WorkspaceLocalToml;
}
export async function readWorkspaceAdditionalDirs(
deps: WorkspaceLocalDeps,
workDir: string,
): Promise<WorkspaceAdditionalDirsLoadResult> {
const projectRoot = await findProjectRoot(deps, workDir);
const configPath = getWorkspaceLocalConfigPath(projectRoot);
const file = await readWorkspaceLocalToml(deps, configPath);
const additionalDirs = file?.parsed.workspace?.additional_dir;
if (additionalDirs === undefined) {
return { projectRoot, configPath, additionalDirs: [] };
}
return {
projectRoot,
configPath,
additionalDirs: await resolveAdditionalDirs(deps, projectRoot, additionalDirs),
};
}
export async function resolveWorkspaceAdditionalDirs(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDirs: readonly string[],
): Promise<string[]> {
return resolveAdditionalDirs(deps, projectRoot, additionalDirs);
}
export async function appendWorkspaceAdditionalDir(
deps: WorkspaceLocalDeps,
workDir: string,
inputPath: string,
): Promise<WorkspaceAdditionalDirsLoadResult> {
const projectRoot = await findProjectRoot(deps, workDir);
const configPath = getWorkspaceLocalConfigPath(projectRoot);
const additionalDir = await resolveAdditionalDir(deps, workDir, inputPath);
const file = (await readWorkspaceLocalToml(deps, configPath)) ?? { raw: {}, parsed: {} };
const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? [];
const fileExistingDirs = resolveExistingAdditionalDirs(deps, projectRoot, fileAdditionalDirs);
if (hasSameAdditionalDir(fileExistingDirs, additionalDir)) {
return { projectRoot, configPath, additionalDirs: fileExistingDirs };
}
const workspace = cloneRecord(file.raw['workspace']);
workspace['additional_dir'] = [...fileExistingDirs, additionalDir];
file.raw['workspace'] = workspace;
await deps.fs.mkdir(dirname(configPath), { recursive: true });
await deps.fs.writeText(configPath, `${stringifyToml(file.raw)}\n`);
return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] };
}
export function normalizeAdditionalDirs(additionalDirs: readonly string[]): string[] {
const seen = new Set<string>();
const normalizedDirs: string[] = [];
for (const additionalDir of additionalDirs) {
const normalized = normalize(additionalDir);
if (seen.has(normalized)) continue;
seen.add(normalized);
normalizedDirs.push(normalized);
}
return normalizedDirs;
}
function getWorkspaceLocalConfigPath(projectRoot: string): string {
return join(projectRoot, '.kimi-code', 'local.toml');
}
async function findProjectRoot(deps: WorkspaceLocalDeps, workDir: string): Promise<string> {
const initial = normalize(workDir);
let current = initial;
while (true) {
if (await pathExists(deps, join(current, '.git'))) return current;
const parent = dirname(current);
if (parent === current) return initial;
current = parent;
}
}
async function readWorkspaceLocalToml(
deps: WorkspaceLocalDeps,
configPath: string,
): Promise<WorkspaceLocalTomlFile | undefined> {
let text: string;
try {
text = await deps.fs.readText(configPath);
} catch (error: unknown) {
if (isPathMissing(error)) return undefined;
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Failed to read ${configPath}: ${describeError(error)}`,
{ cause: error },
);
}
if (text.trim().length === 0) return { raw: {}, parsed: {} };
let raw: unknown;
try {
raw = parseToml(text);
} catch (error: unknown) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Invalid TOML in ${configPath}: ${describeError(error)}`,
{ cause: error },
);
}
if (!isPlainObject(raw)) {
throw new KimiError(ErrorCodes.CONFIG_INVALID, `Invalid workspace local config in ${configPath}`);
}
return { raw: cloneRecord(raw), parsed: parseWorkspaceLocalToml(raw) };
}
function parseWorkspaceLocalToml(raw: Record<string, unknown>): WorkspaceLocalToml {
try {
return WorkspaceLocalTomlSchema.parse(raw);
} catch (error: unknown) {
if (error instanceof z.ZodError) {
throw new KimiError(ErrorCodes.CONFIG_INVALID, describeWorkspaceLocalValidationError(error), {
cause: error,
});
}
throw error;
}
}
function describeWorkspaceLocalValidationError(error: z.ZodError): string {
const issue = error.issues[0];
if (issue?.path[0] === 'workspace' && issue.path[1] === 'additional_dir') {
return 'workspace.additional_dir must be an array of strings';
}
if (issue?.path[0] === 'workspace') return 'workspace must be a table';
return `Invalid workspace local config: ${error.message}`;
}
async function resolveAdditionalDirs(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDirs: readonly string[],
): Promise<string[]> {
const resolvedDirs: string[] = [];
for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
const resolvedDir = await resolveAdditionalDir(deps, projectRoot, additionalDir);
if (hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue;
resolvedDirs.push(resolvedDir);
}
return resolvedDirs;
}
function resolveExistingAdditionalDirs(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDirs: readonly string[],
): string[] {
const resolvedDirs: string[] = [];
for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) {
const resolvedDir = resolvePath(deps, projectRoot, additionalDir);
if (hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue;
resolvedDirs.push(resolvedDir);
}
return resolvedDirs;
}
async function resolveAdditionalDir(
deps: WorkspaceLocalDeps,
projectRoot: string,
additionalDir: string,
): Promise<string> {
const normalizedInput = normalizeAdditionalDirInput(additionalDir);
const resolvedDir = resolvePath(deps, projectRoot, normalizedInput);
await assertDirectory(deps, resolvedDir);
return resolvedDir;
}
function normalizeAdditionalDirInput(additionalDir: string): string {
if (typeof additionalDir !== 'string') {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must be an array of strings',
);
}
const trimmed = additionalDir.trim();
if (trimmed.length === 0) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must exist and be a directory',
);
}
return normalize(trimmed);
}
function resolvePath(deps: WorkspaceLocalDeps, projectRoot: string, additionalDir: string): string {
const expanded = expandHome(deps, additionalDir);
return isAbsolute(expanded) ? normalize(expanded) : resolve(projectRoot, expanded);
}
function expandHome(deps: WorkspaceLocalDeps, value: string): string {
if (value === '~') return deps.homeDir;
if (value.startsWith('~/')) return join(deps.homeDir, value.slice(2));
return value;
}
function hasSameAdditionalDir(dirs: readonly string[], target: string): boolean {
const normalizedTarget = normalize(target);
return dirs.some((dir) => normalize(dir) === normalizedTarget);
}
async function assertDirectory(deps: WorkspaceLocalDeps, filePath: string): Promise<void> {
let stat: Awaited<ReturnType<IHostFileSystem['stat']>>;
try {
stat = await deps.fs.stat(filePath);
} catch (error: unknown) {
if (isPathMissing(error)) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must exist and be a directory',
);
}
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
`Failed to stat ${filePath}: ${describeError(error)}`,
{ cause: error },
);
}
if (!stat.isDirectory) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'workspace.additional_dir must exist and be a directory',
);
}
}
async function pathExists(deps: WorkspaceLocalDeps, filePath: string): Promise<boolean> {
try {
await deps.fs.stat(filePath);
return true;
} catch {
return false;
}
}
function cloneRecord(value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) return {};
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isPathMissing(error: unknown): boolean {
const code = getErrorCode(error);
return code === 'ENOENT' || code === 'ENOTDIR';
}
function getErrorCode(error: unknown): unknown {
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
return (error as { code: unknown }).code;
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -17,6 +17,7 @@ export interface ISessionWorkspaceContext {
readonly workDir: string;
readonly additionalDirs: readonly string[];
setWorkDir(workDir: string): void;
setAdditionalDirs(dirs: readonly string[]): void;
resolve(rel: string): string;
isWithin(absPath: string): boolean;
assertAllowed(absPath: string, op: PathAccessOperation): string;

View file

@ -35,6 +35,10 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext
this._workDir = resolve(workDir);
}
setAdditionalDirs(dirs: readonly string[]): void {
this._additionalDirs = [...new Set(dirs.map((d) => resolve(d)))];
}
resolve(rel: string): string {
return isAbsolute(rel) ? resolve(rel) : resolve(this._workDir, rel);
}

View file

@ -24,6 +24,8 @@ import {
IAgentContextMemoryService,
type ContextMessage,
} from '#/agent/contextMemory';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import type { ContentPart } from '#/app/llmProtocol/message';
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
@ -119,15 +121,6 @@ function userMessage(text: string): ContextMessage {
return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] };
}
function summaryMessage(text: string): ContextMessage {
return {
role: 'assistant',
content: [{ type: 'text', text }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
};
}
function imageMessage(payload: string): ContextMessage {
const part = {
type: 'image',
@ -141,6 +134,12 @@ function mediaUrl(message: ContextMessage): string {
return part.source.url;
}
function textOf(message: ContextMessage): string {
const part = message.content[0] as unknown as { text?: unknown };
if (typeof part.text !== 'string') throw new Error('expected text content');
return part.text;
}
let disposables: DisposableStore;
let blob: StubBlobService;
@ -148,6 +147,7 @@ interface Host {
wire: IWireService;
svc: IAgentContextMemoryService;
log: IAppendLogStore;
eventBus: IEventBus;
}
function buildHost(key: string): Host {
@ -161,11 +161,13 @@ function buildHost(key: string): Host {
]),
);
ix.stub(IAgentBlobService, blob);
ix.set(IEventBus, new SyncDescriptor(EventBusService));
ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
return {
wire: ix.get(IAgentWireService),
svc: ix.get(IAgentContextMemoryService),
log: ix.get(IAppendLogStore),
eventBus: ix.get(IEventBus),
};
}
@ -203,9 +205,16 @@ describe('AgentContextMemoryService (wire-backed)', () => {
expect(model()).toHaveLength(2);
prev = model();
host.wire.dispatch(contextApplyCompaction({ count: 1, summary: summaryMessage('sum') }));
host.wire.dispatch(
contextApplyCompaction({ summary: 'sum', compactedCount: 1, tokensBefore: 0, tokensAfter: 0 }),
);
expect(model()).not.toBe(prev);
expect(model()![0]).toMatchObject({ content: [{ type: 'text', text: 'sum' }] });
expect(model()).toHaveLength(2);
expect(model()![0]).toMatchObject({
role: 'user',
content: [{ type: 'text', text: 'sum' }],
origin: { kind: 'compaction_summary' },
});
prev = model();
host.wire.dispatch(contextClear({}));
@ -278,6 +287,116 @@ describe('AgentContextMemoryService (wire-backed)', () => {
expect(model[2]!.toolCallId).toBe('call_1');
});
it('replays v1 context.apply_compaction records with contextSummary as the model summary', async () => {
const records: PersistedRecord[] = [
{ type: 'context.append_message', message: userMessage('old') },
{ type: 'context.append_message', message: userMessage('tail') },
{
type: 'context.apply_compaction',
summary: 'human-facing summary',
contextSummary: 'model-facing summary',
compactedCount: 1,
tokensBefore: 100,
tokensAfter: 20,
},
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model.map(textOf)).toEqual(['model-facing summary', 'tail']);
expect(model[0]).toMatchObject({
role: 'user',
origin: { kind: 'compaction_summary' },
});
});
it('replays new context.apply_compaction records with kept user messages before contextSummary', async () => {
const records: PersistedRecord[] = [
{ type: 'context.append_message', message: userMessage('old user') },
{
type: 'context.append_message',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'old assistant' }],
toolCalls: [],
},
},
{ type: 'context.append_message', message: userMessage('recent user') },
{
type: 'context.apply_compaction',
summary: 'raw summary',
contextSummary: 'model-facing summary',
compactedCount: 3,
tokensBefore: 100,
tokensAfter: 20,
keptUserMessageCount: 2,
},
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']);
expect(model.map(textOf)).toEqual(['old user', 'recent user', 'model-facing summary']);
expect(model[2]).toMatchObject({
origin: { kind: 'compaction_summary' },
});
});
it('replays pre-contextSummary kept-user records without adding a new prefix', async () => {
const records: PersistedRecord[] = [
{ type: 'context.append_message', message: userMessage('old user') },
{ type: 'context.append_message', message: userMessage('recent user') },
{
type: 'context.apply_compaction',
summary: 'OLD SUMMARY',
compactedCount: 2,
tokensBefore: 100,
tokensAfter: 20,
keptUserMessageCount: 2,
},
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']);
expect(model[2]).toMatchObject({
role: 'user',
origin: { kind: 'compaction_summary' },
});
});
it('replays legacy v2 context.apply_compaction records with count and summary message', async () => {
const legacySummary: ContextMessage = {
role: 'assistant',
content: [{ type: 'text', text: 'legacy summary message' }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
};
const records: PersistedRecord[] = [
{ type: 'context.append_message', message: userMessage('old') },
{ type: 'context.append_message', message: userMessage('tail') },
{
type: 'context.apply_compaction',
count: 1,
summary: legacySummary,
},
];
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(model).toHaveLength(2);
expect(model[0]).toEqual(legacySummary);
expect(textOf(model[1]!)).toBe('tail');
});
it('offloads an oversized content part on dispatch and rehydrates it byte-for-byte on replay', async () => {
const host = buildHost(KEY);
const big = 'A'.repeat(200);
@ -307,13 +426,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
expect(mediaUrl(rebuilt[0]!)).toBe(dataUri);
});
it('onSpliced fires on live dispatch and is silent on replay', async () => {
it('publishes context.spliced on live dispatch and is silent on replay', async () => {
const host = buildHost(KEY);
const live: { start: number; deleteCount: number }[] = [];
host.svc.hooks.onSpliced.register('test', async (ctx, next) => {
live.push({ start: ctx.start, deleteCount: ctx.deleteCount });
await next();
});
disposables.add(host.eventBus.subscribe('context.spliced', (event) => {
live.push({ start: event.start, deleteCount: event.deleteCount });
}));
host.svc.splice(0, 0, [userMessage('x')]);
host.svc.splice(0, 0, [userMessage('y')]);
@ -323,10 +441,9 @@ describe('AgentContextMemoryService (wire-backed)', () => {
const replay = buildHost(REPLAY_KEY);
const replayed: { start: number; deleteCount: number }[] = [];
replay.svc.hooks.onSpliced.register('test', async (ctx, next) => {
replayed.push({ start: ctx.start, deleteCount: ctx.deleteCount });
await next();
});
disposables.add(replay.eventBus.subscribe('context.spliced', (event) => {
replayed.push({ start: event.start, deleteCount: event.deleteCount });
}));
await replay.wire.replay(...records);
expect(replayed).toHaveLength(0);
expect(replay.wire.getModel(ContextModel) as readonly ContextMessage[]).toHaveLength(2);

View file

@ -11,7 +11,15 @@ import { toDisposable } from '#/_base/di/lifecycle';
import type { ServiceRegistration } from '#/_base/di/test';
import { createHooks } from '#/hooks';
import type { Hooks } from '#/hooks';
import { computeUndoCut, ensureMessageId, IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import {
buildContextCompactionShape,
computeUndoCut,
ensureMessageId,
IAgentContextMemoryService,
type ContextCompactionInput,
type ContextCompactionResult,
type ContextMessage,
} from '#/agent/contextMemory';
import { IAgentWireRecordService } from '#/agent/wireRecord';
/**
@ -84,10 +92,19 @@ export function stubContextMemory(): StubContextMemory {
}
return cut;
},
applyCompaction: ({ count, summary, tokens }) => {
const stamped = ensureMessageId(summary);
messages.splice(0, count, stamped);
void hooks.onSpliced.run({ start: 0, deleteCount: count, messages: [stamped], tokens });
applyCompaction: (input: ContextCompactionInput): ContextCompactionResult => {
const shape = buildContextCompactionShape(messages, input);
const previousLength = messages.length;
messages.splice(0, previousLength, ...shape.messages);
void hooks.onSpliced.run({
start: 0,
deleteCount: previousLength,
messages: [...shape.messages],
tokens: shape.tokensAfter,
});
const { messages: _messages, ...result } = shape;
void _messages;
return result;
},
splice: (start, deleteCount, inserted, tokens) => {
const stamped = inserted.map(ensureMessageId);

View file

@ -8,7 +8,15 @@ import {
} from '#/_base/di/test';
import { Event } from '#/_base/event';
import { emptyUsage } from '#/app/llmProtocol/usage';
import { computeUndoCut, ensureMessageId, IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import {
buildContextCompactionShape,
computeUndoCut,
ensureMessageId,
IAgentContextMemoryService,
type ContextCompactionInput,
type ContextCompactionResult,
type ContextMessage,
} from '#/agent/contextMemory';
import { IAgentTaskService } from '#/agent/task';
import {
AgentExternalHooksService,
@ -90,8 +98,12 @@ function stubContextMemory(): IAgentContextMemoryService & {
}
return cut;
},
applyCompaction: ({ count, summary }) => {
messages.splice(0, count, ensureMessageId(summary));
applyCompaction: (input: ContextCompactionInput): ContextCompactionResult => {
const shape = buildContextCompactionShape(messages, input);
messages.splice(0, messages.length, ...shape.messages);
const { messages: _messages, ...result } = shape;
void _messages;
return result;
},
splice: (start, deleteCount, inserted) => {
messages.splice(start, deleteCount, ...inserted);

View file

@ -16,6 +16,7 @@ export function stubWorkspaceContext(
workDir,
additionalDirs,
setWorkDir: () => {},
setAdditionalDirs: () => {},
resolve: (rel) => `${workDir}/${rel}`,
isWithin: () => true,
assertAllowed: (absPath) => absPath,

View file

@ -33,6 +33,7 @@ type GenerateFn = NonNullable<TestAgentOptions['generate']>;
const CATALOGUED_PROVIDER = {
type: 'kimi',
apiKey: 'test-key',
baseUrl: 'https://api.example/v1',
model: 'kimi-code',
} as const;
const CATALOGUED_MODEL_CAPABILITIES = {
@ -239,7 +240,7 @@ describe('FullCompaction', () => {
expect(completeEvent?.args).not.toHaveProperty('summary');
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"
@ -249,21 +250,25 @@ describe('FullCompaction', () => {
assistant: text "recent assistant three"
user: text <compaction-instruction>
`);
expect(ctx.compactHistory()).toMatchInlineSnapshot(`
[
{
"role": "assistant",
"text": "Compacted summary.",
},
]
`);
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'old user two' },
{ role: 'user', text: 'recent user three' },
{
role: 'user',
text: expect.stringContaining('Compacted summary.'),
},
]);
expect(ctx.context.get().at(-1)?.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('The conversation so far has been compacted'),
});
expect(records).toContainEqual({
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'manual',
instruction: 'Keep the important test facts.',
tokens_before: 39,
tokens_after: 5,
tokens_after: expect.any(Number),
duration_ms: expect.any(Number),
compacted_count: 6,
retry_count: 0,
@ -1071,7 +1076,7 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('continues a manual compaction run when the first pass still exceeds the trigger', async () => {
it('cancels a manual compaction when an assistant exchange is appended while compacting', async () => {
const ctx = testAgent();
ctx.configure({
provider: CATALOGUED_PROVIDER,
@ -1088,26 +1093,34 @@ describe('FullCompaction', () => {
);
const firstSummary = `large manual summary ${'x'.repeat(14_000)}`;
ctx.mockNextResponse({ type: 'text', text: firstSummary });
ctx.mockNextResponse({ type: 'text', text: 'Second manual summary.' });
const completed = ctx.once('compaction.completed');
const cancelled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
ctx.appendExchange(2, 'new user while compacting', 'new assistant while compacting', 6_000);
await completed;
await cancelled;
const events = ctx.newEvents();
expect(countEvents(events, 'full_compaction.complete')).toBe(1);
expect(countEvents(events, 'full_compaction.cancel')).toBe(1);
expect(countEvents(events, 'compaction.started')).toBe(1);
expect(countEvents(events, 'compaction.completed')).toBe(1);
expect(ctx.llmCalls).toHaveLength(2);
const [firstCompactionCall, secondCompactionCall] = ctx.llmCalls;
expect(countEvents(events, 'compaction.completed')).toBe(0);
expect(ctx.llmCalls).toHaveLength(1);
const [firstCompactionCall] = ctx.llmCalls;
expect(firstCompactionCall?.history.map(messageText)).not.toContain('new user while compacting');
expect(secondCompactionCall?.history.map(messageText)).toContain(firstSummary);
expect(secondCompactionCall?.history.map(messageText)).toContain('new user while compacting');
expect(secondCompactionCall?.history.map(messageText)).toContain('new assistant while compacting');
expect(ctx.compactHistory()).toEqual([
{
role: 'user',
text: `old user one ${'u'.repeat(14_000)}`,
},
{
role: 'assistant',
text: 'Second manual summary.',
text: `old assistant one ${'a'.repeat(14_000)}`,
},
{
role: 'user',
text: 'new user while compacting',
},
{
role: 'assistant',
text: 'new assistant while compacting',
},
]);
await ctx.expectResumeMatches();
@ -1198,6 +1211,34 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('cancels when a droppable user-role tail is appended during the summary request', async () => {
let ctx!: TestAgentContext;
const generate: GenerateFn = async () => {
ctx.appendSystemReminder('RACE-NOTIFY-OUTPUT', {
kind: 'injection',
variant: 'race-notification',
});
return textResult('Stale compacted summary.');
};
ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: SNAPSHOT_VISIBLE_TOOLS,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
const cancelled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
await cancelled;
expect(ctx.compactHistory().map((entry) => entry.text).join('\n')).toContain(
'RACE-NOTIFY-OUTPUT',
);
expect(countEvents(ctx.newEvents(), 'full_compaction.complete')).toBe(0);
await ctx.expectResumeMatches();
});
it('blocks the turn until auto compaction finishes', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
@ -2102,10 +2143,24 @@ describe('FullCompaction', () => {
await completed;
const history = ctx.compactHistory();
expect(history).toHaveLength(1);
expect(history).toHaveLength(3);
expect(history[0]).toMatchObject({
role: 'assistant',
text: 'Compacted summary.\n\n## TODO List\n [in_progress] Fix the auth bug\n [pending] Add tests',
role: 'user',
text: 'old user one',
});
expect(history[1]).toMatchObject({
role: 'user',
text: 'recent user two',
});
expect(history[2]).toMatchObject({
role: 'user',
text: expect.stringContaining(
'Compacted summary.\n\n## TODO List\n [in_progress] Fix the auth bug\n [pending] Add tests',
),
});
expect(ctx.context.get().at(-1)?.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('The conversation so far has been compacted'),
});
await ctx.expectResumeMatches();
});

View file

@ -739,6 +739,9 @@ function workspaceStub(initialWorkDir: string): ISessionWorkspaceContext {
setWorkDir: (nextWorkDir) => {
workDir = nextWorkDir;
},
setAdditionalDirs: (dirs) => {
additionalDirs = [...dirs];
},
resolve: (path) => path,
isWithin: () => true,
assertAllowed: (path) => path,

View file

@ -27,6 +27,7 @@ function stubWorkspace(): ISessionWorkspaceContext {
workDir: WORK_DIR,
additionalDirs: [],
setWorkDir: () => {},
setAdditionalDirs: () => {},
resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)),
isWithin: (abs) => {
const r = relative(WORK_DIR, abs);

View file

@ -55,6 +55,7 @@ function workspaceStub(workDir: string): {
setWorkDir: (dir: string) => {
current = dir;
},
setAdditionalDirs: () => {},
resolve: (rel: string) => rel,
isWithin: () => true,
assertAllowed: (p: string) => p,

View file

@ -74,6 +74,7 @@ function stubWorkspace(workDir = '/ws'): ISessionWorkspaceContext {
workDir,
additionalDirs: [],
setWorkDir: () => {},
setAdditionalDirs: () => {},
resolve: (rel) => resolve(workDir, rel),
isWithin: () => true,
assertAllowed: (absPath) => resolve(workDir, absPath),

View file

@ -477,7 +477,7 @@ describe('Agent resume', () => {
expect(ctx.context.get()).toEqual([
expect.objectContaining({
role: 'assistant',
role: 'user',
content: [{ type: 'text', text: 'Compacted implementation notes.' }],
origin: { kind: 'compaction_summary' },
}),

View file

@ -0,0 +1,373 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import { LifecycleScope } from '#/_base/di/scope';
import type { ServiceIdentifier } from '#/_base/di/instantiation';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { Emitter } from '#/_base/event';
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IBootstrapService } from '#/app/bootstrap';
import { ErrorCodes, KimiError } from '#/errors';
import {
type HostDirEntry,
type HostFileStat,
IHostFileSystem,
} from '#/os/interface/hostFileSystem';
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle';
import { ISessionContext, makeSessionContext } from '#/session/sessionContext';
import {
ISessionWorkspaceCommandService,
SessionWorkspaceCommandService,
} from '#/session/workspaceCommand';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { SessionWorkspaceContextService } from '#/session/workspaceContext';
import { stubContextMemory, type StubContextMemory } from '../contextMemory/stubs';
const WORK_DIR = '/repo/work';
const EXTRA_DIR = `${WORK_DIR}/extra`;
const DIR_A = `${WORK_DIR}/a`;
const DIR_B = `${WORK_DIR}/b`;
class MemoryHostFs implements IHostFileSystem {
declare readonly _serviceBrand: undefined;
readonly files = new Map<string, string>();
readonly dirs = new Set<string>();
readonly readsDuringPausedWrite: string[] = [];
private pausedWrites = 0;
private nextWritePause:
| {
readonly started: () => void;
readonly release: Promise<void>;
}
| undefined;
constructor(seedDirs: readonly string[] = []) {
for (const d of seedDirs) this.dirs.add(d);
}
async readText(path: string): Promise<string> {
if (this.pausedWrites > 0) this.readsDuringPausedWrite.push(path);
const text = this.files.get(path);
if (text === undefined) throw enoent(path);
return text;
}
async writeText(path: string, data: string): Promise<void> {
const pause = this.nextWritePause;
if (pause !== undefined) {
this.nextWritePause = undefined;
this.pausedWrites++;
pause.started();
try {
await pause.release;
} finally {
this.pausedWrites--;
}
}
this.files.set(path, data);
}
pauseNextWrite(): { readonly started: Promise<void>; readonly release: () => void } {
let started!: () => void;
let release!: () => void;
const startedPromise = new Promise<void>((resolve) => {
started = resolve;
});
const releasePromise = new Promise<void>((resolve) => {
release = resolve;
});
this.nextWritePause = { started, release: releasePromise };
return { started: startedPromise, release };
}
async readBytes(): Promise<Uint8Array> {
throw new Error('not implemented');
}
async writeBytes(): Promise<void> {
throw new Error('not implemented');
}
async *readLines(): AsyncGenerator<string> {
throw new Error('not implemented');
}
async createExclusive(): Promise<boolean> {
throw new Error('not implemented');
}
async stat(path: string): Promise<HostFileStat> {
if (this.files.has(path)) {
return { isFile: true, isDirectory: false, size: this.files.get(path)?.length ?? 0 };
}
if (this.dirs.has(path)) return { isFile: false, isDirectory: true, size: 0 };
throw enoent(path);
}
async readdir(): Promise<readonly HostDirEntry[]> {
throw new Error('not implemented');
}
async mkdir(path: string): Promise<void> {
this.dirs.add(path);
}
async remove(path: string): Promise<void> {
this.files.delete(path);
this.dirs.delete(path);
}
}
function enoent(path: string): NodeJS.ErrnoException {
const error = new Error(`ENOENT: ${path}`) as NodeJS.ErrnoException;
error.code = 'ENOENT';
return error;
}
interface AgentsStub extends IAgentLifecycleService {
readonly mainContext: StubContextMemory;
setMain(present: boolean): void;
}
function agentsStub(): AgentsStub {
const mainContext = stubContextMemory();
let mainPresent = false;
const mainCreated = new Emitter<IAgentScopeHandle>();
const mainHandle: IAgentScopeHandle = {
id: MAIN_AGENT_ID,
kind: LifecycleScope.Agent,
accessor: {
get: <T>(id: ServiceIdentifier<T>): T => {
if (id === IAgentContextMemoryService) return mainContext as unknown as T;
throw new Error(`unexpected service on main handle: ${String(id)}`);
},
},
dispose: () => {},
};
return {
_serviceBrand: undefined,
mainContext,
onDidCreate: () => ({ dispose: () => {} }),
onDidCreateMain: mainCreated.event,
onDidDispose: () => ({ dispose: () => {} }),
create: () => Promise.reject(new Error('not implemented')),
ensureMcpReady: () => Promise.resolve(),
notifyMainCreated: (handle) => mainCreated.fire(handle),
fork: () => Promise.reject(new Error('not implemented')),
run: () => {
throw new Error('not implemented');
},
getHandle: (id) => (id === MAIN_AGENT_ID && mainPresent ? mainHandle : undefined),
list: () => [],
remove: () => Promise.resolve(),
setMain: (present) => {
mainPresent = present;
if (present) mainCreated.fire(mainHandle);
},
};
}
function bootstrapStub(): IBootstrapService {
return {
_serviceBrand: undefined,
homeDir: '/home/test',
} as IBootstrapService;
}
function sessionContext(workDir = WORK_DIR): ISessionContext {
return makeSessionContext({
sessionId: 'ses',
workspaceId: 'ws',
sessionDir: '/tmp/sessions/ws/ses',
sessionScope: 'sessions/ws/ses',
cwd: workDir,
});
}
function nextMacrotask(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
interface Harness {
readonly svc: ISessionWorkspaceCommandService;
readonly fs: MemoryHostFs;
readonly agents: AgentsStub;
readonly workspace: ISessionWorkspaceContext;
}
describe('SessionWorkspaceCommandService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
});
afterEach(() => {
disposables.dispose();
});
function build(
seedDirs: readonly string[],
mainPresent: boolean,
workDir = WORK_DIR,
gitDir = `${workDir}/.git`,
): Harness {
const fs = new MemoryHostFs([gitDir, workDir, ...seedDirs]);
const agents = agentsStub();
const ctx = sessionContext(workDir);
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(ISessionContext, ctx);
reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService);
reg.defineInstance(IBootstrapService, bootstrapStub());
reg.defineInstance(IHostFileSystem, fs);
reg.defineInstance(IAgentLifecycleService, agents);
reg.define(ISessionWorkspaceCommandService, SessionWorkspaceCommandService);
},
});
const workspace = ix.get(ISessionWorkspaceContext);
const svc = ix.get(ISessionWorkspaceCommandService);
agents.setMain(mainPresent);
return { svc, fs, agents, workspace };
}
it('persists the directory and injects a local-command-stdout message when main exists', async () => {
const { svc, fs, agents, workspace } = build([EXTRA_DIR], true);
const result = await svc.addAdditionalDir({ path: 'extra', persist: true });
expect(result.persisted).toBe(true);
expect(result.configPath).toBe(`${WORK_DIR}/.kimi-code/local.toml`);
expect(result.additionalDirs).toContain(EXTRA_DIR);
expect(workspace.additionalDirs).toContain(EXTRA_DIR);
const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
expect(written).toContain('additional_dir');
expect(written).toContain(EXTRA_DIR);
expect(agents.mainContext.messages).toHaveLength(1);
expect(agents.mainContext.messages[0]?.content).toEqual([
{
type: 'text',
text: `<local-command-stdout>\nAdded workspace directory:\n extra\n Saved to:\n ${WORK_DIR}/.kimi-code/local.toml\n</local-command-stdout>`,
},
]);
expect(agents.mainContext.messages[0]?.origin).toEqual({
kind: 'injection',
variant: 'local-command-stdout',
});
});
it('does not persist and injects a session-only message when persist is false', async () => {
const { svc, fs, agents, workspace } = build([EXTRA_DIR], true);
const result = await svc.addAdditionalDir({ path: 'extra', persist: false });
expect(result.persisted).toBe(false);
expect(workspace.additionalDirs).toContain(EXTRA_DIR);
expect(fs.files.has(`${WORK_DIR}/.kimi-code/local.toml`)).toBe(false);
expect(agents.mainContext.messages).toHaveLength(1);
expect(agents.mainContext.messages[0]?.content).toEqual([
{
type: 'text',
text: '<local-command-stdout>\nAdded workspace directory:\n extra\n For this session only\n</local-command-stdout>',
},
]);
});
it('queues the injection until the main agent is created', async () => {
const { svc, agents } = build([EXTRA_DIR], false);
await svc.addAdditionalDir({ path: 'extra', persist: true });
expect(agents.mainContext.messages).toHaveLength(0);
agents.setMain(true);
expect(agents.mainContext.messages).toHaveLength(1);
expect(agents.mainContext.messages[0]?.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('Added workspace directory:'),
});
});
it('keeps the persisted config idempotent when the same dir is added twice', async () => {
const { svc, fs } = build([EXTRA_DIR], true);
await svc.addAdditionalDir({ path: 'extra', persist: true });
await svc.addAdditionalDir({ path: 'extra', persist: true });
const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
expect(written).toBeDefined();
const matches = written?.match(new RegExp(EXTRA_DIR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'));
expect(matches).toHaveLength(1);
});
it('serializes concurrent persisted additions so local.toml keeps both directories', async () => {
const { svc, fs, workspace } = build([DIR_A, DIR_B], true);
const pause = fs.pauseNextWrite();
const first = svc.addAdditionalDir({ path: 'a', persist: true });
await pause.started;
const second = svc.addAdditionalDir({ path: 'b', persist: true });
await nextMacrotask();
const overlappingReads = [...fs.readsDuringPausedWrite];
pause.release();
const [, secondResult] = await Promise.all([first, second]);
expect(overlappingReads).toEqual([]);
expect(secondResult.additionalDirs).toEqual([DIR_A, DIR_B]);
expect(workspace.additionalDirs).toEqual([DIR_A, DIR_B]);
const written = fs.files.get(`${WORK_DIR}/.kimi-code/local.toml`);
expect(written).toContain(DIR_A);
expect(written).toContain(DIR_B);
});
it('resolves caller-relative dirs against the session workDir when project root is above it', async () => {
const projectRoot = '/repo/project';
const workDir = `${projectRoot}/apps/foo`;
const sharedDir = `${workDir}/shared`;
const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`);
const result = await svc.addAdditionalDir({ path: 'shared', persist: true });
expect(result.projectRoot).toBe(projectRoot);
expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`);
expect(result.additionalDirs).toEqual([sharedDir]);
expect(workspace.additionalDirs).toEqual([sharedDir]);
expect(fs.files.get(`${projectRoot}/.kimi-code/local.toml`)).toContain(sharedDir);
});
it('resolves session-only relative dirs against the session workDir when project root is above it', async () => {
const projectRoot = '/repo/project';
const workDir = `${projectRoot}/apps/foo`;
const sharedDir = `${workDir}/shared`;
const { svc, fs, workspace } = build([sharedDir], true, workDir, `${projectRoot}/.git`);
const result = await svc.addAdditionalDir({ path: 'shared', persist: false });
expect(result.projectRoot).toBe(projectRoot);
expect(result.configPath).toBe(`${projectRoot}/.kimi-code/local.toml`);
expect(result.additionalDirs).toEqual([sharedDir]);
expect(workspace.additionalDirs).toEqual([sharedDir]);
expect(fs.files.has(`${projectRoot}/.kimi-code/local.toml`)).toBe(false);
});
it('rejects a relative path that does not resolve to an existing directory', async () => {
const { svc } = build([], true);
await expect(svc.addAdditionalDir({ path: 'missing', persist: true })).rejects.toSatisfy(
(error) => error instanceof KimiError && error.code === ErrorCodes.CONFIG_INVALID,
);
});
});

View file

@ -31,6 +31,7 @@ import {
ISessionLifecycleService,
IWorkspaceRegistry,
normalizeSessionMeta,
applyContextCompactionRecord,
resetFold,
toProtocolMessage,
type ContextMessage,
@ -290,7 +291,7 @@ interface ContextRecord {
* `IAgentContextMemoryService.get()` view:
* append_message push (deferred while a tool exchange is open) ·
* append_loop_event v1 fold (step/content/tool) · splice array splice ·
* clear drop all · apply_compaction `[summary, ...state.slice(count)]` ·
* clear drop all · apply_compaction `[summary, ...state.slice(compactedCount)]` ·
* undo trailing cut.
*/
export function reduceContextRecords(records: Iterable<ContextRecord>): ContextMessage[] {
@ -320,9 +321,7 @@ export function reduceContextRecords(records: Iterable<ContextRecord>): ContextM
break;
}
case 'context.apply_compaction': {
const count = record['count'] as number;
const summary = record['summary'] as ContextMessage;
state = resetFold([summary, ...state.slice(count)]);
state = applyContextCompactionRecord(state, record);
break;
}
case 'context.undo': {
@ -388,4 +387,4 @@ async function resolveBlobRef(
}
cache.set(url, resolved);
return resolved;
}
}

View file

@ -53,6 +53,7 @@ import {
IAgentToolState,
IAgentUsageService,
ISessionWorkspaceContext,
ISessionWorkspaceCommandService,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
@ -152,7 +153,10 @@ export const actionMap: Record<ScopeKind, Record<string, ActionTarget>> = {
'workspace:resolve': { service: ISessionWorkspaceContext, method: 'resolve', readonly: true },
'workspace:isWithin': { service: ISessionWorkspaceContext, method: 'isWithin', readonly: true },
'workspace:setWorkDir': { service: ISessionWorkspaceContext, method: 'setWorkDir' },
'workspace:addAdditionalDir': { service: ISessionWorkspaceContext, method: 'addAdditionalDir' },
'workspace:addAdditionalDir': {
service: ISessionWorkspaceCommandService,
method: 'addAdditionalDir',
},
'workspace:removeAdditionalDir': { service: ISessionWorkspaceContext, method: 'removeAdditionalDir' },
'fs:search': { service: ISessionFsService, method: 'search', readonly: true },

View file

@ -260,6 +260,60 @@ describe('SnapshotReader.read', () => {
expect(texts).toEqual(['summary', 'after']);
});
it('reduces v1-shaped string summary compaction records', async () => {
const f = await makeFixtureAsync();
await seedSession(f, 'sess_compact_v1');
await writeWire(f.sessionDir('sess_compact_v1'), [
{ type: 'context.append_message', message: userMessage('old-1') },
{ type: 'context.append_message', message: userMessage('old-2') },
{
type: 'context.apply_compaction',
summary: 'summary',
compactedCount: 2,
tokensBefore: 100,
tokensAfter: 20,
},
{ type: 'context.append_message', message: userMessage('after') },
]);
const snap = await f.reader.read('sess_compact_v1');
const messages = snap.messages.items;
const texts = messages.map((m) => (m.content[0] as { text: string }).text);
expect(texts).toEqual(['summary', 'after']);
expect(messages[0]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } });
});
it('reduces new compaction records to kept user messages followed by contextSummary', async () => {
const f = await makeFixtureAsync();
await seedSession(f, 'sess_compact_kept_users');
await writeWire(f.sessionDir('sess_compact_kept_users'), [
{ type: 'context.append_message', message: userMessage('old user') },
{
type: 'context.append_message',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'old assistant' }],
toolCalls: [],
},
},
{ type: 'context.append_message', message: userMessage('recent user') },
{
type: 'context.apply_compaction',
summary: 'raw summary',
contextSummary: 'model-facing summary',
compactedCount: 3,
tokensBefore: 100,
tokensAfter: 20,
keptUserMessageCount: 2,
},
]);
const snap = await f.reader.read('sess_compact_kept_users');
const messages = snap.messages.items;
const texts = messages.map((m) => (m.content[0] as { text: string }).text);
expect(messages.map((m) => m.role)).toEqual(['user', 'user', 'user']);
expect(texts).toEqual(['old user', 'recent user', 'model-facing summary']);
expect(messages[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } });
});
it('honors context.clear and context.undo', async () => {
const f = await makeFixtureAsync();
await seedSession(f, 'sess_ops');