mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
c51d8df461
33 changed files with 1764 additions and 113 deletions
|
|
@ -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 |
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
11
packages/agent-core-v2/src/session/workspaceCommand/index.ts
Normal file
11
packages/agent-core-v2/src/session/workspaceCommand/index.ts
Normal 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';
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export function stubWorkspaceContext(
|
|||
workDir,
|
||||
additionalDirs,
|
||||
setWorkDir: () => {},
|
||||
setAdditionalDirs: () => {},
|
||||
resolve: (rel) => `${workDir}/${rel}`,
|
||||
isWithin: () => true,
|
||||
assertAllowed: (absPath) => absPath,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function workspaceStub(workDir: string): {
|
|||
setWorkDir: (dir: string) => {
|
||||
current = dir;
|
||||
},
|
||||
setAdditionalDirs: () => {},
|
||||
resolve: (rel: string) => rel,
|
||||
isWithin: () => true,
|
||||
assertAllowed: (p: string) => p,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue