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

This commit is contained in:
_Kerman 2026-07-08 14:26:44 +08:00
commit bc84b2e90b
17 changed files with 783 additions and 235 deletions

View file

@ -0,0 +1,317 @@
/**
* `contextMemory` transcript reducer rebuilds the FULL message history of an
* agent from its `context.*` wire records for UI display (snapshot / messages).
*
* The live `ContextModel` (`ContextMemoryService`) rewrites the model-facing
* context on `context.apply_compaction` into `[...keptUserMessages,
* compaction_summary]`, so reading the live context after a compaction loses
* everything before the fold. The wire log keeps every record, though, so this
* reducer re-reduces the `context.*` records with the same semantics as the
* live `ContextMemory` restore, EXCEPT that `context.apply_compaction` KEEPS
* the full history and appends a user-role summary marker the same view the
* v1 transcript / TUI shows after resume. `foldedLength` tracks what the live
* (folded) `context.history.length` would be, so a caller can detect and
* append an unflushed live tail.
*
* Mirrors v1 `reduceWireRecords`
* (`packages/agent-core/src/services/message/transcript.ts`):
* - `context.append_message` append (deferred while a tool exchange is open)
* - `context.append_loop_event` step.begin/content.part/tool.call mutate the
* open assistant; tool.result appends a tool
* message with the raw output
* - `context.apply_compaction` keep the full history, append the user-role
* summary marker, recover `foldedLength` from
* the recorded kept-count fields
* - `context.undo` remove tail messages (skip injections, stop
* at compaction summaries / clear floor)
* - `context.clear` keep prior transcript entries but reset the
* folded view
* - `context.splice` array splice (legacy 1.5 / internal)
*/
import { type ContentPart, type ToolCall } from '#/app/llmProtocol/message';
import type { PersistedRecord } from '#/wire/wireService';
import {
COMPACT_USER_MESSAGE_MAX_TOKENS,
collectCompactableUserMessages,
isRealUserInput,
selectRecentUserMessages,
} from './compactionHandoff';
import type { LoopRecordedEvent } from './loopEventFold';
import type { ContextMessage } from './types';
const TOOL_INTERRUPTED_ON_RESUME_OUTPUT =
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';
export interface ContextTranscript {
/** Full message history, compacted prefixes included. */
readonly entries: readonly ContextMessage[];
/** Length the live (folded) `context.history` would have after these records. */
readonly foldedLength: number;
}
interface MutableMessage {
id?: string;
role: ContextMessage['role'];
content: ContentPart[];
toolCalls: ToolCall[];
toolCallId?: string;
isError?: boolean;
origin?: ContextMessage['origin'];
}
interface MutableEntry {
message: MutableMessage;
}
/** Reduce `context.*` wire records into the full transcript. Pure (no I/O). */
export function reduceContextTranscript(records: Iterable<PersistedRecord>): ContextTranscript {
const transcript: MutableEntry[] = [];
/** What `context.history.length` would be right now (post-folding). */
let foldedLength = 0;
/** Transcript index `context.undo` may not cross (set by `context.clear`). */
let clearFloor = 0;
const openSteps = new Map<string, MutableEntry>();
const pendingToolResultIds = new Set<string>();
let deferred: MutableEntry[] = [];
const push = (...entries: MutableEntry[]): void => {
transcript.push(...entries);
foldedLength += entries.length;
};
const flushDeferredIfToolExchangeClosed = (): void => {
if (pendingToolResultIds.size > 0 || deferred.length === 0) return;
push(...deferred);
deferred = [];
};
const closePendingToolResults = (): void => {
if (pendingToolResultIds.size === 0) return;
const interruptedToolCallIds = [...pendingToolResultIds];
for (const toolCallId of interruptedToolCallIds) {
push({
message: {
role: 'tool',
content: [{ type: 'text', text: TOOL_INTERRUPTED_ON_RESUME_OUTPUT }],
toolCalls: [],
toolCallId,
isError: true,
},
});
pendingToolResultIds.delete(toolCallId);
}
flushDeferredIfToolExchangeClosed();
};
const resetOpenState = (): void => {
openSteps.clear();
pendingToolResultIds.clear();
deferred = [];
};
const applyLoopEvent = (event: LoopRecordedEvent): void => {
switch (event.type) {
case 'step.begin': {
closePendingToolResults();
const entry: MutableEntry = {
message: { role: 'assistant', content: [], toolCalls: [] },
};
push(entry);
openSteps.set(event.uuid, entry);
return;
}
case 'step.end': {
openSteps.delete(event.uuid);
flushDeferredIfToolExchangeClosed();
return;
}
case 'content.part': {
// Lenient where the live reducer throws: a dangling part in a damaged
// file should not take the whole transcript down.
openSteps.get(event.stepUuid)?.message.content.push(event.part);
return;
}
case 'tool.call': {
const openStep = openSteps.get(event.stepUuid);
if (openStep === undefined) return;
const call: ToolCall = {
type: 'function',
id: event.toolCallId,
name: event.name,
arguments: event.args === undefined ? null : JSON.stringify(event.args),
...(event.extras !== undefined ? { extras: event.extras } : {}),
};
openStep.message.toolCalls.push(call);
pendingToolResultIds.add(event.toolCallId);
return;
}
case 'tool.result': {
if (!pendingToolResultIds.has(event.toolCallId)) return;
push({
message: {
role: 'tool',
content: rawToolResultContent(event.result.output),
toolCalls: [],
toolCallId: event.toolCallId,
isError: event.result.isError,
},
});
pendingToolResultIds.delete(event.toolCallId);
flushDeferredIfToolExchangeClosed();
return;
}
}
};
const applyUndo = (count: number): void => {
if (count <= 0) return;
let removedUserCount = 0;
for (let i = transcript.length - 1; i >= clearFloor; i--) {
const message = transcript[i]!.message;
if (message.origin?.kind === 'injection') continue;
if (message.origin?.kind === 'compaction_summary') break;
transcript.splice(i, 1);
foldedLength = Math.max(0, foldedLength - 1);
if (isRealUserInput(message)) {
removedUserCount++;
if (removedUserCount >= count) break;
}
}
resetOpenState();
};
for (const record of records) {
switch (record.type) {
case 'context.append_message': {
const message = record['message'] as ContextMessage;
const entry: MutableEntry = {
message: {
...(message.id !== undefined ? { id: message.id } : {}),
role: message.role,
content: [...message.content],
toolCalls: [...message.toolCalls],
...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}),
...(message.isError !== undefined ? { isError: message.isError } : {}),
...(message.origin !== undefined ? { origin: message.origin } : {}),
},
};
if (pendingToolResultIds.size > 0) deferred.push(entry);
else push(entry);
break;
}
case 'context.append_loop_event':
applyLoopEvent(record['event'] as LoopRecordedEvent);
break;
case 'context.splice': {
const start = record['start'] as number;
const deleteCount = record['deleteCount'] as number;
const messages = record['messages'] as readonly ContextMessage[];
if (deleteCount === 0 && messages.length === 0) break;
// The transcript is append-only (matches the TUI / replay-builder
// transcript): a splice's inserted messages land at the tail and the
// deleted messages stay for display, so legacy compaction-via-splice
// keeps the pre-compaction prefix. `foldedLength` tracks the literal
// live-context length after the splice.
for (const message of messages) transcript.push(toMutableEntry(message));
const clampedStart = Math.min(Math.max(start, 0), foldedLength);
const removed = Math.min(deleteCount, foldedLength - clampedStart);
foldedLength = foldedLength - removed + messages.length;
resetOpenState();
break;
}
case 'context.apply_compaction': {
// The live context folds into `[...keptUserMessages, summary]`; the
// transcript keeps the full history and appends the summary marker.
transcript.push({
message: {
role: 'user',
content: [{ type: 'text', text: readCompactionSummaryText(record) }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
},
});
const keptUserMessageCount = readNumber(record, 'keptUserMessageCount');
const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount');
const compactedCount = readNumber(record, 'compactedCount');
if (keptUserMessageCount !== undefined) {
// +1 for the summary message; +1 more when the selection split into
// head + tail (the live context then also holds an elision marker).
foldedLength = keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2);
} else if (compactedCount !== undefined && compactedCount < foldedLength) {
// Legacy record that kept `history.slice(compactedCount)` verbatim.
foldedLength = 1 + (foldedLength - compactedCount);
} else {
// Legacy record covering the whole live history: re-derive from the
// post-clear transcript only (the live context rebuilds from the
// post-`/clear` messages).
const keptUserMessages = selectRecentUserMessages(
collectCompactableUserMessages(transcript.slice(clearFloor).map((e) => e.message)),
COMPACT_USER_MESSAGE_MAX_TOKENS,
);
foldedLength = keptUserMessages.length + 1;
}
resetOpenState();
break;
}
case 'context.undo':
applyUndo(record['count'] as number);
break;
case 'context.clear':
clearFloor = transcript.length;
foldedLength = 0;
resetOpenState();
break;
default:
break;
}
}
return { entries: transcript.map((e) => e.message), foldedLength };
}
function toMutableEntry(message: ContextMessage): MutableEntry {
return {
message: {
...(message.id !== undefined ? { id: message.id } : {}),
role: message.role,
content: [...message.content],
toolCalls: [...message.toolCalls],
...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}),
...(message.isError !== undefined ? { isError: message.isError } : {}),
...(message.origin !== undefined ? { origin: message.origin } : {}),
},
};
}
function readCompactionSummaryText(record: PersistedRecord): string {
const summary = record['summary'];
if (typeof summary === 'string') return summary;
const contextSummary = record['contextSummary'];
if (typeof contextSummary === 'string') return contextSummary;
// Legacy record whose `summary` is a whole ContextMessage — flatten its text.
if (isContextMessageLike(summary)) return textOfParts(summary.content);
return '';
}
function isContextMessageLike(value: unknown): value is ContextMessage {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
const message = value as { role?: unknown; content?: unknown };
return typeof message.role === 'string' && Array.isArray(message.content);
}
function textOfParts(content: readonly ContentPart[]): string {
let text = '';
for (const part of content) {
if (part.type === 'text') text += part.text;
}
return text;
}
function readNumber(record: PersistedRecord, key: string): number | undefined {
const value = record[key];
return typeof value === 'number' ? value : undefined;
}
/** Raw output verbatim — status text is added only at LLM projection, never in the transcript. */
function rawToolResultContent(output: string | readonly ContentPart[]): ContentPart[] {
return typeof output === 'string' ? [{ type: 'text', text: output }] : [...output];
}

View file

@ -3,25 +3,44 @@
*
* Stateless App-scope dispatcher: each call resolves the target session (and
* its main agent), sources the transcript, and projects it into the v1 wire
* shape. Both live and cold sessions are read from the main agent's
* `IAgentContextMemoryService`: live sessions already hold the folded history in
* memory, and cold sessions are resumed (restoring the main agent's wire log and
* replaying it into the `ContextModel`) before the read, so the same `get()`
* yields the full transcript. Pagination, id derivation, and the role filter
* mirror v1's `MessageService`
* shape.
*
* History source is the main agent's `wire.jsonl` record log, NOT the live
* `IAgentContextMemoryService.get()`: that live history is the model's CURRENT
* context after a compaction it collapses into `[...keptUserMessages,
* compaction_summary]`, which made `GET /sessions/{sid}/messages` lose
* everything before the fold. The wire log keeps every record, so
* `reduceContextTranscript` rebuilds the full transcript (compaction inserts a
* summary marker instead of dropping the prefix) the same view v1's
* `MessageService` serves. Records reach disk through an async flush queue, so
* a request on a live session may find the wire a few records behind memory:
* `foldedLength` is what the live history length WOULD be from the file's
* records, and anything beyond it in the real live context is appended as the
* unflushed tail. Pagination, id derivation, and the role filter mirror v1's
* `MessageService`
* (`packages/agent-core/src/services/message/messageService.ts`).
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Message, PageResponse } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
import { type ISessionScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import {
reduceContextTranscript,
type ContextTranscript,
} from '#/agent/contextMemory/contextTranscript';
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { ErrorCodes, KimiError } from '#/errors';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import type { PersistedRecord } from '#/wire/wireService';
import { IMessageLegacyService, type MessageListQuery } from './messageLegacy';
@ -97,33 +116,85 @@ export class MessageLegacyService implements IMessageLegacyService {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
const agent = await this.resolveMainAgent(sessionId);
if (agent === undefined) return [];
// The transcript is the main agent's `ContextModel`: live sessions already
// hold it in memory, and cold sessions have been resumed (wire log restored
// + replayed into the Model) by `resolveMainAgent` before we get here, so a
// single `get()` covers both. The legacy replay read model
// (`IAgentRecordService.buildReplay`) is empty on every path and is gone.
const source = agent.accessor.get(IAgentContextMemoryService).get();
return source.map((msg, index) => toProtocolMessage(sessionId, index, msg, summary.createdAt));
}
/**
* Resolve the session's main agent, loading + restoring it from the persisted
* wire log when the session is cold (delegated to `ISessionLifecycleService.resume`).
* Returns `undefined` only when a cold session's workspace is gone and the
* session directory cannot be reconstructed (mirrors the `fork` limitation).
*/
private async resolveMainAgent(sessionId: string): Promise<IAgentScopeHandle | undefined> {
const session = await this.lifecycle.resume(sessionId);
if (session === undefined) return undefined;
// Live session whose main agent has not been materialized yet: create it
// fresh. No restore here — the session was already live, so any persisted
// wire is already reflected in memory; re-restoring would re-apply splices.
return ensureMainAgent(session);
if (session === undefined) return [];
// Materialize the main agent so the live context is available for the
// unflushed-tail merge below. `resume` already restored + replayed the
// wire for a cold session; a live session is already current.
const agent = await ensureMainAgent(session);
// Read the wire file BEFORE the live context so the in-memory history is
// always at least as new as the file snapshot and the tail merge can only
// append (mirrors v1 `MessageService`).
const transcript = await this.readTranscript(session);
const contextMessages = agent.accessor.get(IAgentContextMemoryService).get();
const entries = mergeLiveTail(transcript, contextMessages);
return entries.map((msg, index) => toProtocolMessage(sessionId, index, msg, summary.createdAt));
}
/** Reduce the main agent's persisted wire log into the full transcript. */
private async readTranscript(session: ISessionScopeHandle): Promise<ContextTranscript> {
const ctx = session.accessor.get(ISessionContext);
const wirePath = join(ctx.sessionDir, 'agents', MAIN_AGENT_ID, 'wire.jsonl');
const records = await readWireRecords(wirePath);
return reduceContextTranscript(records);
}
}
/**
* Append the unflushed live tail: when the in-memory (folded) context is
* longer than the wire-derived `foldedLength`, the surplus is records that
* have not reached disk yet and must be appended so a read on a live session
* does not trail memory.
*/
function mergeLiveTail(
transcript: ContextTranscript,
contextMessages: readonly ContextMessage[],
): readonly ContextMessage[] {
if (contextMessages.length <= transcript.foldedLength) return transcript.entries;
return [...transcript.entries, ...contextMessages.slice(transcript.foldedLength)];
}
/**
* Parse a `wire.jsonl` file. A torn final line (crash mid-flush) is dropped;
* corruption anywhere else throws. A missing file yields an empty record list
* (a brand-new session whose context has not been flushed yet).
*/
async function readWireRecords(wirePath: string): Promise<PersistedRecord[]> {
let raw: string;
try {
raw = await readFile(wirePath, 'utf8');
} catch (error) {
if (isEnoent(error)) return [];
throw error;
}
const lines = raw.split('\n');
const records: PersistedRecord[] = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i]!;
if (line.endsWith('\r')) line = line.slice(0, -1);
if (line.length === 0) continue;
try {
records.push(JSON.parse(line) as PersistedRecord);
} catch (parseError) {
if (i === lines.length - 1) break;
throw new Error(
`wire.jsonl: corrupted line ${i + 1} in ${wirePath}: ${String(parseError)}`,
{ cause: parseError },
);
}
}
return records;
}
function isEnoent(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}
registerScopedService(

View file

@ -82,7 +82,7 @@ export class SessionLegacyService implements ISessionLegacyService {
sessionId: string,
body: UpdateSessionProfileRequest,
): Promise<SessionWireFields> {
const session = this.lifecycle.get(sessionId);
const session = await this.lifecycle.resume(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
@ -274,7 +274,10 @@ export class SessionLegacyService implements ISessionLegacyService {
// Native `ISessionLifecycleService.archive` is a no-op for sessions that
// are not live, so gate on the live handle (matches the previous route
// behaviour): a missing live session is reported as `session.not_found`.
if (this.lifecycle.get(sessionId) === undefined) {
// `resume` (not `get`) so archiving a freshly-opened cold session still
// works; `resume` returns undefined only when the session is unknown or its
// workspace is gone, which is reported as `session.not_found`.
if ((await this.lifecycle.resume(sessionId)) === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
await this.lifecycle.archive(sessionId);
@ -315,11 +318,6 @@ export class SessionLegacyService implements ISessionLegacyService {
};
}
/**
* Resolve the session's main agent, creating it on demand (mirrors v1's
* `resumeSession`; delegates to the `agentLifecycle` domain's
* `ensureMainAgent` bootstrap helper).
*/
/**
* Apply the v1 `agent_config` patch onto the main agent. Mirrors v1's
* `IPromptService.applyAgentState` (`promptService.ts:650-743`) in both order
@ -383,7 +381,13 @@ export class SessionLegacyService implements ISessionLegacyService {
}
private async resolveMainAgent(sessionId: string): Promise<IAgentScopeHandle> {
const session = this.lifecycle.get(sessionId);
// `resume` (not `get`) so a persisted-but-cold session — freshly opened in
// the web UI before any prompt, or created by a previous process — is loaded
// from disk instead of being reported as `session.not_found`. Mirrors v1's
// `SessionService.undo`/`compact`, which call `resumeSession` first; `resume`
// returns `undefined` only when the session is unknown or its workspace is
// gone, so a genuinely missing session still 404s.
const session = await this.lifecycle.resume(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}

View file

@ -302,6 +302,7 @@ export * from '#/agent/contextMemory/compactionHandoff';
export * from '#/agent/contextMemory/loopEventFold';
export * from '#/agent/contextMemory/messageId';
export * from '#/agent/contextMemory/messageProjection';
export * from '#/agent/contextMemory/contextTranscript';
export * from '#/agent/contextMemory/types';
export * from '#/agent/systemReminder/systemReminder';
export * from '#/agent/systemReminder/systemReminderService';

View file

@ -0,0 +1,212 @@
/**
* Tests for `reduceContextTranscript` the wire-transcript reducer used by the
* snapshot and messages endpoints. Mirrors v1 `reduceWireRecords` expectations:
* compaction keeps the prefix and appends a summary marker; undo removes the
* tail but stops at compaction summaries / clear floors; clear keeps the
* transcript but resets the folded view.
*/
import { describe, expect, it } from 'vitest';
import {
reduceContextTranscript,
type ContextTranscript,
} from '../../src/agent/contextMemory/contextTranscript';
import type { LoopRecordedEvent } from '../../src/agent/contextMemory/loopEventFold';
import type { ContextMessage, PromptOrigin } from '../../src/agent/contextMemory/types';
import type { PersistedRecord } from '../../src/wire/wireService';
function userMessage(text: string, origin?: PromptOrigin): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
...(origin === undefined ? {} : { origin }),
};
}
function assistantMessage(text: string): ContextMessage {
return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] };
}
function appendMessage(message: ContextMessage): PersistedRecord {
return { type: 'context.append_message', message };
}
function loopEvent(event: LoopRecordedEvent): PersistedRecord {
return { type: 'context.append_loop_event', event };
}
function assistantStep(uuid: string, text: string): PersistedRecord[] {
return [
loopEvent({ type: 'step.begin', uuid }),
loopEvent({ type: 'content.part', stepUuid: uuid, part: { type: 'text', text } }),
loopEvent({ type: 'step.end', uuid }),
];
}
function compaction(
summary: string,
compactedCount: number,
keptUserMessageCount?: number,
keptHeadUserMessageCount?: number,
): PersistedRecord {
return {
type: 'context.apply_compaction',
summary,
contextSummary: `prefixed ${summary}`,
compactedCount,
tokensBefore: 1000,
tokensAfter: 100,
...(keptUserMessageCount === undefined ? {} : { keptUserMessageCount }),
...(keptHeadUserMessageCount === undefined ? {} : { keptHeadUserMessageCount }),
};
}
function undo(count: number): PersistedRecord {
return { type: 'context.undo', count };
}
function texts(result: ContextTranscript): string[] {
return result.entries.map((m) =>
m.content.map((p) => (p.type === 'text' ? p.text : `[${p.type}]`)).join(''),
);
}
describe('reduceContextTranscript', () => {
it('builds the transcript from append_message and loop events', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
]);
expect(texts(result)).toEqual(['u1', 'a1']);
expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant']);
expect(result.foldedLength).toBe(2);
});
it('compaction keeps the prefix and appends a user-role summary marker', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
appendMessage(userMessage('u2')),
...assistantStep('s2', 'a2'),
compaction('SUM', 4),
appendMessage(userMessage('u3')),
]);
expect(texts(result)).toEqual(['u1', 'a1', 'u2', 'a2', 'SUM', 'u3']);
expect(result.entries[4]!.origin).toEqual({ kind: 'compaction_summary' });
expect(result.entries[4]!.role).toBe('user');
// live folded view would be [u1, u2, SUM, u3]
expect(result.foldedLength).toBe(4);
});
it('uses the recorded kept-user count for foldedLength when present', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('u1')),
appendMessage(userMessage('u2')),
appendMessage(userMessage('u3')),
compaction('SUM', 3, 1),
appendMessage(userMessage('u4')),
]);
// 1 kept user message + summary + u4 appended after compaction.
expect(result.foldedLength).toBe(3);
});
it('accounts for the elision marker when the record kept a head segment', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('u1')),
appendMessage(userMessage('u2')),
...assistantStep('s1', 'a1'),
compaction('SUM', 3, 2, 1),
]);
// Live context: head user + elision marker + tail user + summary.
expect(result.foldedLength).toBe(4);
});
it('preserves the pre-compaction assistant reply after a later undo', () => {
// The reported regression: send A, /compact, send B, undo. The snapshot
// must still show A's assistant reply (compaction only folds the live
// context; the transcript keeps the full history).
const result = reduceContextTranscript([
appendMessage(userMessage('message A')),
appendMessage(assistantMessage('reply A')),
compaction('summary text', 2, 1),
appendMessage(userMessage('message B')),
appendMessage(assistantMessage('reply B')),
undo(1),
]);
expect(texts(result)).toEqual(['message A', 'reply A', 'summary text']);
expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'user']);
expect(result.foldedLength).toBe(2);
});
it('undo without compaction keeps the earlier exchange intact', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('message A')),
appendMessage(assistantMessage('reply A')),
appendMessage(userMessage('message B')),
appendMessage(assistantMessage('reply B')),
undo(1),
]);
expect(texts(result)).toEqual(['message A', 'reply A']);
});
it('undo stops at a compaction summary', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('old')),
compaction('SUM', 1, 1),
appendMessage(userMessage('recent')),
appendMessage(assistantMessage('answer')),
undo(2),
]);
// Only the post-compaction exchange is removed; the summary blocks further undo.
expect(texts(result)).toEqual(['old', 'SUM']);
});
it('clear keeps prior transcript entries but resets the folded view', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('u1')),
appendMessage(userMessage('u2')),
{ type: 'context.clear' },
appendMessage(userMessage('u3')),
]);
expect(texts(result)).toEqual(['u1', 'u2', 'u3']);
expect(result.foldedLength).toBe(1);
});
it('undo does not cross a clear floor', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('u1')),
{ type: 'context.clear' },
appendMessage(userMessage('u2')),
appendMessage(assistantMessage('a2')),
undo(1),
]);
// The post-clear exchange (u2 + a2) is removed; pre-clear u1 stays in the
// transcript and the clear floor blocks undo from reaching it.
expect(texts(result)).toEqual(['u1']);
expect(result.foldedLength).toBe(0);
});
it('folds tool calls and results from loop events', () => {
const result = reduceContextTranscript([
appendMessage(userMessage('q')),
loopEvent({ type: 'step.begin', uuid: 's1' }),
loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'hi' } }),
loopEvent({
type: 'tool.call',
stepUuid: 's1',
toolCallId: 'call_1',
name: 'Bash',
args: { command: 'echo hi' },
}),
loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'hi' } }),
loopEvent({ type: 'step.end', uuid: 's1' }),
]);
expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']);
expect(result.entries[1]!.toolCalls).toHaveLength(1);
expect(result.entries[1]!.toolCalls[0]!.id).toBe('call_1');
expect(result.entries[2]!.toolCallId).toBe('call_1');
expect(result.foldedLength).toBe(3);
});
});

View file

@ -100,7 +100,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo
},
async (req, reply) => {
const { session_id } = req.params;
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
@ -134,7 +134,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo
},
async (req, reply) => {
const { session_id, approval_id } = req.params;
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),

View file

@ -145,6 +145,18 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void {
}
const fsAction = action as FsAction;
// Cold-load a persisted-but-not-live session so fs actions (which only
// need the work dir) do not 404 on a freshly-opened session. Matches v1,
// which reads the persisted cwd. `resume` returns undefined only when the
// session is unknown or its workspace is gone.
const session = await core.accessor.get(ISessionLifecycleService).resume(session_id);
if (session === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
);
return;
}
try {
switch (fsAction) {
case 'list':
@ -232,6 +244,16 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void {
return;
}
// Cold-load so a freshly-opened (persisted but not live) session can still
// serve downloads; `resume` only returns undefined for unknown / workspace-gone.
const session = await core.accessor.get(ISessionLifecycleService).resume(session_id);
if (session === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
);
return;
}
let resolved: Awaited<ReturnType<ISessionFsService['resolveDownload']>>;
try {
resolved = await resolveFs(core, session_id).resolveDownload(relPath);

View file

@ -124,7 +124,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
},
async (req, reply) => {
const { session_id } = req.params;
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
@ -173,7 +173,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
const questionId = parsed.id;
const action: 'resolve' | 'dismiss' = parsed.kind === 'bare' ? 'resolve' : parsed.action;
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),

View file

@ -622,7 +622,9 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
}
if (parsed.action === 'btw') {
const session = core.accessor.get(ISessionLifecycleService).get(parsed.id);
// `resume` (not `get`) so a freshly-opened cold session can start a
// side-channel agent; matches v1's `startBtw` which resumes first.
const session = await core.accessor.get(ISessionLifecycleService).resume(parsed.id);
if (session === undefined) {
throw new KimiError(
ErrorCodes.SESSION_NOT_FOUND,
@ -772,7 +774,9 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
},
async (req, reply) => {
const { session_id } = req.params;
const session = core.accessor.get(ISessionLifecycleService).get(session_id);
// `resume` (not `get`) so a freshly-opened cold session still computes its
// warnings; matches v1's best-effort `resumeSession` before reading them.
const session = await core.accessor.get(ISessionLifecycleService).resume(session_id);
if (session === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),

View file

@ -141,7 +141,11 @@ async function resolveActivatedSession(
sessionId: string,
requestId: string,
): Promise<ResolvedSession> {
const handle = core.accessor.get(ISessionLifecycleService).get(sessionId);
// `resume` (not `get`) so listing/activating skills on a freshly-opened cold
// session cold-loads it instead of reporting "not activated"; matches v1's
// `resumeSession` in SkillService. `resume` returns undefined only when the
// session is unknown or its workspace is gone.
const handle = await core.accessor.get(ISessionLifecycleService).resume(sessionId);
if (handle !== undefined) return { handle };
const summary = await core.accessor.get(ISessionIndex).get(sessionId);

View file

@ -77,12 +77,13 @@ const sessionAndTailParamSchema = z.object({
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
/**
* Resolve the session's `ISessionTerminalService` from the URL session id. Throws a
* coded `session.not_found` when the session is not live terminals are
* inherently live PTY state, so a persisted-but-not-live session is `40401`.
* Resolve the session's `ISessionTerminalService` from the URL session id,
* cold-loading a persisted-but-not-live session first (matches v1, which spawns
* from the persisted cwd). Throws `session.not_found` only when the session is
* unknown or its workspace is gone.
*/
function resolveTerminal(core: Scope, sessionId: string): ISessionTerminalService {
const session = core.accessor.get(ISessionLifecycleService).get(sessionId);
async function resolveTerminal(core: Scope, sessionId: string): Promise<ISessionTerminalService> {
const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
@ -106,7 +107,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v
async (req, reply) => {
try {
const { session_id } = req.params;
const items = await resolveTerminal(core, session_id).list();
const items = await (await resolveTerminal(core, session_id)).list();
reply.send(okEnvelope({ items }, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);
@ -137,7 +138,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v
async (req, reply) => {
try {
const { session_id } = req.params;
const terminal = await resolveTerminal(core, session_id).create(req.body);
const terminal = await (await resolveTerminal(core, session_id)).create(req.body);
reply.send(okEnvelope(terminal, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);
@ -167,7 +168,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v
async (req, reply) => {
try {
const { session_id, terminal_id } = req.params;
const terminal = await resolveTerminal(core, session_id).get(terminal_id);
const terminal = await (await resolveTerminal(core, session_id)).get(terminal_id);
reply.send(okEnvelope(terminal, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);
@ -209,7 +210,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id));
return;
}
const result = await resolveTerminal(core, session_id).close(parsed.id);
const result = await (await resolveTerminal(core, session_id)).close(parsed.id);
reply.send(okEnvelope(result, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);

View file

@ -2,7 +2,6 @@ export type { ISnapshotReader } from './snapshot';
export { SnapshotNotFoundError, SnapshotTimeoutError } from './snapshot';
export {
SnapshotReader,
reduceContextRecords,
readWireRecords,
type SnapshotReaderDeps,
type SnapshotReaderLogger,

View file

@ -5,11 +5,13 @@
* Reads `<homeDir>/sessions/<workspaceId>/<sid>/state.json` and
* `…/agents/main/wire.jsonl` directly, bypassing
* `ISessionLifecycleService.resume` (DI-scope materialization, MCP connect,
* full wire replay). The transcript is reduced from the folded `context.*`
* records with the exact `contextOps` apply semantics, so the result is
* byte-identical to the live `IAgentContextMemoryService.get()` view used by
* the `legacy` (resume) path. `(size, mtimeMs)` transcript cache and the
* watermark both come from in-memory state, keeping warm reads sub-ms.
* full wire replay). The transcript is reduced from the `context.*` records
* with `reduceContextTranscript`, which mirrors the live reducers EXCEPT that
* `context.apply_compaction` keeps the full history and appends a summary
* marker instead of dropping the compacted prefix the same full-transcript
* view v1 serves (so compacted-away assistant replies stay visible after a
* later undo). `(size, mtimeMs)` transcript cache and the watermark both come
* from in-memory state, keeping warm reads sub-ms.
*
* Pending approvals/questions, the live status, and `current_prompt_id` are
* only available while the session is live; for a cold session they correctly
@ -20,9 +22,6 @@ import { readFile, stat as fsStat } from 'node:fs/promises';
import { join } from 'node:path';
import {
computeUndoCut,
foldAppendMessage,
foldLoopEvent,
IAgentLifecycleService,
IAgentPromptLegacyService,
ISessionActivity,
@ -31,11 +30,9 @@ import {
ISessionLifecycleService,
IWorkspaceRegistry,
normalizeSessionMeta,
applyContextCompactionRecord,
resetFold,
reduceContextTranscript,
toProtocolMessage,
type ContextMessage,
type LoopRecordedEvent,
type Scope,
type SessionMeta,
} from '@moonshot-ai/agent-core-v2';
@ -211,7 +208,7 @@ export class SnapshotReader implements ISnapshotReader {
if (cached !== undefined) this.transcriptCache.delete(sid);
const records = await readWireRecords(wirePath);
const messages = reduceContextRecords(records);
const messages = [...reduceContextTranscript(records).entries];
this.transcriptCache.set(sid, { size: info.size, mtimeMs: info.mtimeMs, messages });
while (this.transcriptCache.size > this.deps.config.cacheLimit) {
const oldest = this.transcriptCache.keys().next().value;
@ -283,61 +280,6 @@ interface ContextRecord {
readonly [key: string]: unknown;
}
/**
* Reduce folded `context.*` wire records into the live `ContextMessage[]` view,
* routing through the exact `contextOps.ts` reducers (`foldAppendMessage` /
* `foldLoopEvent` / `resetFold`) so the result is byte-identical to the live
* `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(compactedCount)]` ·
* undo trailing cut.
*/
export function reduceContextRecords(records: Iterable<ContextRecord>): ContextMessage[] {
let state: readonly ContextMessage[] = [];
for (const record of records) {
switch (record.type) {
case 'context.append_message': {
state = foldAppendMessage(state, record['message'] as ContextMessage);
break;
}
case 'context.append_loop_event': {
state = foldLoopEvent(state, record['event'] as LoopRecordedEvent);
break;
}
case 'context.splice': {
const start = record['start'] as number;
const deleteCount = record['deleteCount'] as number;
const messages = record['messages'] as readonly ContextMessage[];
if (deleteCount === 0 && messages.length === 0) break;
const next = state.slice();
next.splice(start, deleteCount, ...messages);
state = resetFold(next);
break;
}
case 'context.clear': {
if (state.length !== 0) state = resetFold([]);
break;
}
case 'context.apply_compaction': {
state = applyContextCompactionRecord(state, record);
break;
}
case 'context.undo': {
const count = record['count'] as number;
if (count <= 0 || state.length === 0) break;
const { cutIndex, removedCount } = computeUndoCut(state, count);
if (cutIndex < 0 || removedCount < count) break;
state = resetFold(state.slice(0, cutIndex));
break;
}
default:
break;
}
}
return state as ContextMessage[];
}
/**
* Parse a `wire.jsonl` file. A torn final line (crash mid-flush) is dropped;
* corruption anywhere else throws so the route surfaces 50001. The leading

View file

@ -302,10 +302,11 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => {
]);
await agent.accessor.get(IAgentWireRecordService).flush();
// Live (post-compaction) context exposes only the folded summary; capture
// its id so we can assert it survives the restart unchanged.
// The live read already serves the full transcript (pre-compaction prefix
// + summary), matching v1's `/messages`. Capture the summary id so we can
// assert it survives the restart unchanged.
const livePage = await getJson<PageWire>(`/api/v1/sessions/${id}/messages?page_size=100`);
expect(livePage.body.data.items).toHaveLength(1);
expect(livePage.body.data.items).toHaveLength(4);
const liveSummaryId = livePage.body.data.items[0]!.id;
// Restart the server on the same homeDir → the session is cold for the next

View file

@ -4,7 +4,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IEventService } from '@moonshot-ai/agent-core-v2';
import { IEventService, ISessionLifecycleService } from '@moonshot-ai/agent-core-v2';
import { sessionWarningsResponseSchema } from '@moonshot-ai/protocol';
import { type RunningServer, startServer } from '../src/start';
@ -323,6 +323,24 @@ describe('server-v2 /api/v1/sessions', () => {
expect(got.body.data.archived).toBe(true);
});
it('cold-loads a persisted session on :undo instead of 40401', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const id = created.body.data.id;
// Drop the live handle so the session is persisted-but-cold (index + disk
// only) — the state right after opening a session in the web UI before any
// prompt has been sent. Before the fix, `:undo` resolved the main agent via
// `lifecycle.get` (memory only) and reported 40401 "session does not exist".
await (server as RunningServer).core.accessor.get(ISessionLifecycleService).close(id);
const res = await postJson<{ messages: unknown }>(`/api/v1/sessions/${id}:undo`, { count: 1 });
// Cold-loaded successfully: the empty history yields "nothing to undo"
// (40911), not the pre-fix "session does not exist" (40401).
expect(res.body.code).toBe(40911);
expect(res.body.msg).toMatch(/nothing to undo/i);
});
it('rejects an unsupported action suffix (40001)', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });

View file

@ -146,16 +146,18 @@ describe('server-v2 /api/v1 skills', () => {
expect(body.msg).toMatch(/does not exist/);
});
it('returns 40401 with an activation hint for a persisted but not activated session', async () => {
it('cold-loads a persisted but not live (archived) session and lists skills', async () => {
const id = await createSession();
// Archiving removes the session from the live map but keeps it in the index.
const archived = await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`);
expect(archived.body.code).toBe(0);
const { body } = await getJson<null>(`/api/v1/sessions/${id}/skills`);
expect(body.code).toBe(40401);
expect(body.msg).toMatch(/not activated/);
expect(body.msg).toMatch(/activate it first/);
// Cold-loaded on demand (matches v1's `resumeSession` in SkillService):
// listing skills succeeds instead of reporting "not activated".
const { body } = await getJson<{ skills: SkillWire[] }>(`/api/v1/sessions/${id}/skills`);
expect(body.code).toBe(0);
const skills = listSkillsResponseSchema.parse(body.data).skills;
expect(skills.some((s) => s.name === 'update-config')).toBe(true);
});
it('lists builtin skills projected to the wire shape', async () => {

View file

@ -24,7 +24,6 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
loadSnapshotConfig,
reduceContextRecords,
readWireRecords,
SnapshotNotFoundError,
SnapshotReader,
@ -242,7 +241,7 @@ describe('SnapshotReader.read', () => {
expect((tool.content[0] as { tool_call_id: string }).tool_call_id).toBe('call_1');
});
it('reduces context.apply_compaction to [summary, ...tail]', async () => {
it('keeps the full history across context.apply_compaction and appends a summary marker', async () => {
const f = await makeFixtureAsync();
await seedSession(f, 'sess_compact');
await writeWire(f.sessionDir('sess_compact'), [
@ -257,10 +256,11 @@ describe('SnapshotReader.read', () => {
]);
const snap = await f.reader.read('sess_compact');
const texts = snap.messages.items.map((m) => (m.content[0] as { text: string }).text);
expect(texts).toEqual(['summary', 'after']);
expect(texts).toEqual(['old-1', 'old-2', 'summary', 'after']);
expect(snap.messages.items[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } });
});
it('reduces v1-shaped string summary compaction records', async () => {
it('keeps the full history across v1-shaped string summary compaction records', async () => {
const f = await makeFixtureAsync();
await seedSession(f, 'sess_compact_v1');
await writeWire(f.sessionDir('sess_compact_v1'), [
@ -278,11 +278,11 @@ describe('SnapshotReader.read', () => {
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' } });
expect(texts).toEqual(['old-1', 'old-2', 'summary', 'after']);
expect(messages[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } });
});
it('reduces new compaction records to kept user messages followed by contextSummary', async () => {
it('keeps compacted-away assistant messages and uses the raw summary as the marker', async () => {
const f = await makeFixtureAsync();
await seedSession(f, 'sess_compact_kept_users');
await writeWire(f.sessionDir('sess_compact_kept_users'), [
@ -309,12 +309,49 @@ describe('SnapshotReader.read', () => {
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' } });
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user', 'user']);
expect(texts).toEqual(['old user', 'old assistant', 'recent user', 'raw summary']);
expect(messages[3]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } });
});
it('honors context.clear and context.undo', async () => {
it('preserves the pre-compaction assistant reply after a later undo', async () => {
// Regression: send A, /compact, send B, undo. The snapshot must still show
// A's assistant reply (compaction folds only the live context; the
// transcript keeps the full history).
const f = await makeFixtureAsync();
await seedSession(f, 'sess_compact_undo');
const assistant = (text: string): ContextMessage => ({
role: 'assistant',
content: [{ type: 'text', text }],
toolCalls: [],
});
await writeWire(f.sessionDir('sess_compact_undo'), [
{ type: 'context.append_message', message: userMessage('message A') },
{ type: 'context.append_message', message: assistant('reply A') },
{
type: 'context.apply_compaction',
summary: 'summary text',
contextSummary: 'model-facing summary',
compactedCount: 2,
tokensBefore: 100,
tokensAfter: 20,
keptUserMessageCount: 1,
},
{ type: 'context.append_message', message: userMessage('message B') },
{ type: 'context.append_message', message: assistant('reply B') },
{ type: 'context.undo', count: 1 },
]);
const snap = await f.reader.read('sess_compact_undo');
const messages = snap.messages.items;
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']);
expect(messages.map((m) => (m.content[0] as { text: string }).text)).toEqual([
'message A',
'reply A',
'summary text',
]);
});
it('keeps pre-clear messages in the transcript and lets undo remove the tail', async () => {
const f = await makeFixtureAsync();
await seedSession(f, 'sess_ops');
await writeWire(f.sessionDir('sess_ops'), [
@ -322,8 +359,10 @@ describe('SnapshotReader.read', () => {
{ type: 'context.append_message', message: userMessage('b') },
{ type: 'context.clear' },
{ type: 'context.append_message', message: userMessage('c') },
{ type: 'context.undo', count: 1 },
]);
expect((await f.reader.read('sess_ops')).messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual(['c']);
// /clear keeps prior messages for display; undo removes the post-clear tail (c).
expect((await f.reader.read('sess_ops')).messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual(['a', 'b']);
});
it('caps the page at 100 and flags has_more', async () => {
@ -395,95 +434,6 @@ describe('SnapshotReader.read', () => {
});
});
// ─── pure helpers ─────────────────────────────────────────────────────────
describe('reduceContextRecords', () => {
it('ignores unknown records and the metadata envelope', () => {
const out = reduceContextRecords([
{ type: 'metadata', protocol_version: '1.4' },
{ type: 'turn.started', turnId: 1 },
{ type: 'context.append_message', message: userMessage('x') },
]);
expect(out).toHaveLength(1);
});
it('applies context.splice', () => {
const out = reduceContextRecords([
{ type: 'context.append_message', message: userMessage('a') },
{ type: 'context.splice', start: 1, deleteCount: 0, messages: [userMessage('b')] },
]);
expect(out).toHaveLength(2);
expect((out[1]!.content[0] as { text: string }).text).toBe('b');
});
it('folds context.append_loop_event (step/content/tool) into messages', () => {
const out = reduceContextRecords([
{ type: 'context.append_message', message: userMessage('q') },
{ type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1' } },
{
type: 'context.append_loop_event',
event: { type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'hello' } },
},
{
type: 'context.append_loop_event',
event: {
type: 'tool.call',
stepUuid: 's1',
toolCallId: 'call_1',
name: 'Bash',
args: { command: 'echo hi' },
},
},
{
type: 'context.append_loop_event',
event: { type: 'tool.result', toolCallId: 'call_1', result: { output: 'hi' } },
},
{ type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1' } },
]);
expect(out.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']);
expect((out[1]!.content[0] as { text: string }).text).toBe('hello');
expect(out[1]!.toolCalls).toHaveLength(1);
expect(out[1]!.toolCalls[0]!.id).toBe('call_1');
expect(out[1]!.partial).toBeUndefined();
expect(out[2]!.toolCallId).toBe('call_1');
});
it('defers context.append_message until the open tool exchange closes', () => {
const out = reduceContextRecords([
{ type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1' } },
{
type: 'context.append_loop_event',
event: { type: 'tool.call', stepUuid: 's1', toolCallId: 'call_1', name: 'Bash', args: {} },
},
// Arrives while the tool exchange is still open — must not land between
// the assistant and its tool result.
{ type: 'context.append_message', message: userMessage('injected') },
{
type: 'context.append_loop_event',
event: { type: 'tool.result', toolCallId: 'call_1', result: { output: 'ok' } },
},
{ type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1' } },
]);
expect(out.map((m) => m.role)).toEqual(['assistant', 'tool', 'user']);
expect((out[2]!.content[0] as { text: string }).text).toBe('injected');
});
it('synthesizes an interrupted result for a tool call left open at a step boundary', () => {
const out = reduceContextRecords([
{ type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1' } },
{
type: 'context.append_loop_event',
event: { type: 'tool.call', stepUuid: 's1', toolCallId: 'call_1', name: 'Bash', args: {} },
},
// No tool.result before the next step begins — the dangling call is closed.
{ type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's2' } },
{ type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's2' } },
]);
expect(out.map((m) => m.role)).toEqual(['assistant', 'tool', 'assistant']);
expect(out[1]!.isError).toBe(true);
});
});
describe('readWireRecords', () => {
it('drops a torn final line but throws on mid-file corruption', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kimi-wire-'));