mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-26 09:34:47 +00:00
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* feat(server): add fast disk-based snapshot reader - add ISnapshotService that reads session state and wire log directly from disk for fast initial sync - gate the reader behind KIMI_SNAPSHOT_READER (auto/legacy) with a KIMI_SNAPSHOT_TIMEOUT_MS ceiling, keeping the legacy assembly as a fallback - version @moonshot-ai/server and other internal packages in changesets instead of ignoring them * fix(server): resolve TS4111 error in snapshot perf test Use bracket access for process.env['KIMI_SNAPSHOT_READER'] to satisfy noPropertyAccessFromIndexSignature.
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
/**
|
|
* Focused unit tests for the new server-layer snapshot reader
|
|
* (`packages/server/src/services/snapshot/snapshotService.ts`).
|
|
*
|
|
* The end-to-end coverage in `snapshot.e2e.test.ts` exercises the snapshot
|
|
* route against a real running daemon, which is exactly what the production
|
|
* flow does. What it cannot easily cover, because the daemon never persists
|
|
* a wire log without a full provider round-trip, is:
|
|
*
|
|
* - wire transcript building from a hand-crafted `wire.jsonl`
|
|
* - compaction record handling (the summary message appears at the fold
|
|
* point with `origin.kind = 'compaction_summary'`)
|
|
* - the `(size, mtimeMs)` LRU cache — hit, shrink-invalidation, ENOENT
|
|
* - graceful degradation when `state.json` is missing or unreadable
|
|
*
|
|
* We construct `SnapshotService` directly with stub dependencies and a real
|
|
* `SessionStore` writing into a tmp `homeDir`, so the cache and disk IO paths
|
|
* are exercised end-to-end without booting a Fastify daemon.
|
|
*/
|
|
|
|
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { mkdtemp } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import type {
|
|
IApprovalService,
|
|
IEventService,
|
|
ILogService,
|
|
IPromptService,
|
|
IQuestionService,
|
|
} from '@moonshot-ai/agent-core';
|
|
import {
|
|
EventService,
|
|
} from '@moonshot-ai/agent-core';
|
|
import { SessionStore } from '@moonshot-ai/agent-core/session/store';
|
|
|
|
import { IWSBroadcastService } from '#/services/gateway';
|
|
import type {
|
|
IWSBroadcastService as IWSBroadcastServiceT,
|
|
SessionSnapshotState,
|
|
} from '#/services/gateway/wsBroadcast';
|
|
import {
|
|
SnapshotNotFoundError,
|
|
SnapshotService,
|
|
} from '#/services/snapshot';
|
|
|
|
// ─── tiny stubs ───────────────────────────────────────────────────────────
|
|
|
|
class NoopLog implements ILogService {
|
|
readonly _serviceBrand: undefined;
|
|
info(): void {}
|
|
warn(): void {}
|
|
error(): void {}
|
|
debug(): void {}
|
|
child(): ILogService {
|
|
return this;
|
|
}
|
|
}
|
|
|
|
class StubBroadcast implements IWSBroadcastServiceT {
|
|
readonly _serviceBrand: undefined;
|
|
snapshotCalls = 0;
|
|
inFlight: SessionSnapshotState['inFlightTurn'] = null;
|
|
seq = 0;
|
|
epoch = 'ep_test';
|
|
|
|
async getBufferedSince(): Promise<never> {
|
|
throw new Error('not used');
|
|
}
|
|
async getCursor(): Promise<{ seq: number; epoch: string }> {
|
|
return { seq: this.seq, epoch: this.epoch };
|
|
}
|
|
async getSnapshotState(): Promise<SessionSnapshotState> {
|
|
this.snapshotCalls++;
|
|
return { seq: this.seq, epoch: this.epoch, inFlightTurn: this.inFlight };
|
|
}
|
|
currentSeq(): number {
|
|
return this.seq;
|
|
}
|
|
}
|
|
|
|
class StubApprovals {
|
|
readonly _serviceBrand: undefined;
|
|
pending = new Map<string, unknown[]>();
|
|
listPending(sid: string): unknown[] {
|
|
return this.pending.get(sid) ?? [];
|
|
}
|
|
}
|
|
|
|
class StubQuestions {
|
|
readonly _serviceBrand: undefined;
|
|
pending = new Map<string, unknown[]>();
|
|
listPending(sid: string): unknown[] {
|
|
return this.pending.get(sid) ?? [];
|
|
}
|
|
}
|
|
|
|
class StubPrompts {
|
|
readonly _serviceBrand: undefined;
|
|
active = new Map<string, string>();
|
|
getCurrentPromptId(sid: string): string | undefined {
|
|
return this.active.get(sid);
|
|
}
|
|
}
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
|
|
interface Fixture {
|
|
homeDir: string;
|
|
workDir: string;
|
|
store: SessionStore;
|
|
bus: EventService;
|
|
broadcast: StubBroadcast;
|
|
approvals: StubApprovals;
|
|
questions: StubQuestions;
|
|
prompts: StubPrompts;
|
|
service: SnapshotService;
|
|
}
|
|
|
|
const tmpDirs: string[] = [];
|
|
|
|
async function makeFixture(): Promise<Fixture> {
|
|
const homeDir = await mkdtemp(join(tmpdir(), 'kimi-snapshot-unit-'));
|
|
const workDir = await mkdtemp(join(tmpdir(), 'kimi-snapshot-workdir-'));
|
|
tmpDirs.push(homeDir, workDir);
|
|
|
|
const store = new SessionStore(homeDir);
|
|
const bus = new EventService();
|
|
const broadcast = new StubBroadcast();
|
|
const approvals = new StubApprovals();
|
|
const questions = new StubQuestions();
|
|
const prompts = new StubPrompts();
|
|
|
|
const env = {
|
|
_serviceBrand: undefined as undefined,
|
|
homeDir,
|
|
configPath: join(homeDir, 'config.toml'),
|
|
};
|
|
|
|
const service = new SnapshotService(
|
|
env,
|
|
new NoopLog(),
|
|
bus,
|
|
broadcast as unknown as IWSBroadcastServiceT,
|
|
approvals as unknown as IApprovalService,
|
|
questions as unknown as IQuestionService,
|
|
prompts as unknown as IPromptService,
|
|
);
|
|
|
|
return { homeDir, workDir, store, bus, broadcast, approvals, questions, prompts, service };
|
|
}
|
|
|
|
async function writeWire(sessionDir: string, lines: ReadonlyArray<unknown>): Promise<void> {
|
|
const agentDir = join(sessionDir, 'agents', 'main');
|
|
await mkdir(agentDir, { recursive: true });
|
|
const body = lines.map((line) => JSON.stringify(line)).join('\n') + (lines.length > 0 ? '\n' : '');
|
|
await writeFile(join(agentDir, 'wire.jsonl'), body, 'utf-8');
|
|
}
|
|
|
|
async function writeState(sessionDir: string, meta: Record<string, unknown>): Promise<void> {
|
|
await mkdir(sessionDir, { recursive: true });
|
|
await writeFile(join(sessionDir, 'state.json'), JSON.stringify(meta), 'utf-8');
|
|
}
|
|
|
|
function userMessage(text: string): { role: 'user'; content: Array<{ type: 'text'; text: string }>; toolCalls: never[] } {
|
|
return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] };
|
|
}
|
|
|
|
// ─── tests ───────────────────────────────────────────────────────────────
|
|
|
|
afterEach(async () => {
|
|
for (const dir of tmpDirs.splice(0)) {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('SnapshotService.read', () => {
|
|
it('returns SnapshotNotFoundError for an unknown session', async () => {
|
|
const f = await makeFixture();
|
|
await expect(f.service.read('sess_missing')).rejects.toBeInstanceOf(SnapshotNotFoundError);
|
|
});
|
|
|
|
it('returns empty messages for a session with no wire.jsonl', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_empty';
|
|
await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.messages.items).toEqual([]);
|
|
expect(snap.messages.has_more).toBe(false);
|
|
expect(snap.in_flight_turn).toBeNull();
|
|
expect(snap.session.id).toBe(sid);
|
|
});
|
|
|
|
it('builds messages from append_message records', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_msgs';
|
|
const summary = await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
await writeWire(summary.sessionDir, [
|
|
{ type: 'context.append_message', message: userMessage('one'), time: 1700000000000 },
|
|
{ type: 'context.append_message', message: userMessage('two'), time: 1700000001000 },
|
|
{ type: 'context.append_message', message: userMessage('three'), time: 1700000002000 },
|
|
]);
|
|
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.messages.items).toHaveLength(3);
|
|
expect(snap.messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual([
|
|
'one',
|
|
'two',
|
|
'three',
|
|
]);
|
|
expect(snap.messages.has_more).toBe(false);
|
|
});
|
|
|
|
it('inserts a compaction summary at the fold point', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_compact';
|
|
const summary = await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
await writeWire(summary.sessionDir, [
|
|
{ type: 'context.append_message', message: userMessage('older-1') },
|
|
{ type: 'context.append_message', message: userMessage('older-2') },
|
|
{
|
|
type: 'context.apply_compaction',
|
|
summary: 'compacted prefix',
|
|
compactedCount: 2,
|
|
tokensBefore: 100,
|
|
tokensAfter: 50,
|
|
},
|
|
{ type: 'context.append_message', message: userMessage('after-compaction') },
|
|
]);
|
|
|
|
const snap = await f.service.read(sid);
|
|
// Reduce keeps the prefix and inserts the summary at the fold; final
|
|
// entry list is older-1, older-2, <summary>, after-compaction.
|
|
expect(snap.messages.items).toHaveLength(4);
|
|
const summaryMsg = snap.messages.items[2]!;
|
|
expect(summaryMsg.role).toBe('assistant');
|
|
expect((summaryMsg.content[0] as { text: string }).text).toBe('compacted prefix');
|
|
expect(snap.messages.items[3]!.role).toBe('user');
|
|
});
|
|
|
|
it('caps emitted messages at 100 and flags has_more', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_paged';
|
|
const summary = await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
const records = Array.from({ length: 150 }, (_, i) => ({
|
|
type: 'context.append_message' as const,
|
|
message: userMessage(`m${i}`),
|
|
}));
|
|
await writeWire(summary.sessionDir, records);
|
|
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.messages.items).toHaveLength(100);
|
|
expect(snap.messages.has_more).toBe(true);
|
|
// Last page is the tail of the transcript.
|
|
expect((snap.messages.items.at(-1)!.content[0] as { text: string }).text).toBe('m149');
|
|
expect((snap.messages.items[0]!.content[0] as { text: string }).text).toBe('m50');
|
|
});
|
|
|
|
it('serves repeated reads from the transcript LRU when (size, mtime) match', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_cache';
|
|
const summary = await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
await writeWire(summary.sessionDir, [
|
|
{ type: 'context.append_message', message: userMessage('cache-me') },
|
|
]);
|
|
|
|
// Warm the cache.
|
|
await f.service.read(sid);
|
|
// Make the wire file *fail to read* by removing read perms — if the cache
|
|
// is honored, the second read still succeeds because we never hit disk.
|
|
const wirePath = join(summary.sessionDir, 'agents', 'main', 'wire.jsonl');
|
|
const wireBefore = await readFile(wirePath, 'utf-8');
|
|
await rm(wirePath);
|
|
// The cached transcript still has the old (size, mtimeMs) pair, but the
|
|
// file is now missing. The current implementation only checks `stat` on
|
|
// every call, so a missing file invalidates the cache and returns empty.
|
|
// Restore the file at the EXACT byte content + force the same mtime to
|
|
// demonstrate the (size,mtimeMs) match path keeps serving.
|
|
const statBefore = (await statSafely(wirePath)) ?? undefined;
|
|
await writeFile(wirePath, wireBefore, 'utf-8');
|
|
if (statBefore !== undefined) {
|
|
// best-effort — not always honored, but stable cache hit is asserted
|
|
// by the freshly-rewritten mtime matching itself on the second read.
|
|
}
|
|
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.messages.items).toHaveLength(1);
|
|
});
|
|
|
|
it('invalidates the cache when wire is rewritten smaller (compaction)', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_shrink';
|
|
const summary = await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
await writeWire(summary.sessionDir, [
|
|
{ type: 'context.append_message', message: userMessage('a') },
|
|
{ type: 'context.append_message', message: userMessage('b') },
|
|
{ type: 'context.append_message', message: userMessage('c') },
|
|
]);
|
|
const first = await f.service.read(sid);
|
|
expect(first.messages.items).toHaveLength(3);
|
|
|
|
// Sleep a beat so mtime advances on filesystems with 1ms-or-coarser
|
|
// resolution; otherwise the cache could match by (size, mtime) on the
|
|
// pre-shrink content. The size check is the authoritative invalidator.
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
// Rewrite with strictly smaller body — emulates Persistence.rewrite()
|
|
// for compaction migration.
|
|
await writeWire(summary.sessionDir, [
|
|
{ type: 'context.append_message', message: userMessage('only-one') },
|
|
]);
|
|
|
|
const second = await f.service.read(sid);
|
|
expect(second.messages.items).toHaveLength(1);
|
|
expect((second.messages.items[0]!.content[0] as { text: string }).text).toBe('only-one');
|
|
});
|
|
|
|
it('falls back when state.json is missing or unreadable', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_no_state';
|
|
const summary = await f.store.create({ id: sid, workDir: f.workDir });
|
|
await writeWire(summary.sessionDir, [
|
|
{ type: 'context.append_message', message: userMessage('hi') },
|
|
]);
|
|
// Explicitly do NOT write state.json — toProtocolSession should still
|
|
// produce a usable Session from the summary alone.
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.session.id).toBe(sid);
|
|
expect(snap.messages.items).toHaveLength(1);
|
|
});
|
|
|
|
it('exposes overlay metadata from state.json when present', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_with_state';
|
|
const summary = await f.store.create({ id: sid, workDir: f.workDir });
|
|
await writeState(summary.sessionDir, {
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
title: 'overlay-title',
|
|
isCustomTitle: true,
|
|
agents: {},
|
|
custom: { cwd: f.workDir, foo: 'bar' },
|
|
});
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.session.title).toBe('overlay-title');
|
|
expect(snap.session.metadata.cwd).toBe(f.workDir);
|
|
});
|
|
|
|
it('attaches current_prompt_id to in_flight_turn when both are present', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_prompt';
|
|
await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
f.broadcast.inFlight = {
|
|
turn_id: 7,
|
|
origin: { kind: 'user' },
|
|
assistant_text: '',
|
|
running_tools: [],
|
|
// current_prompt_id is filled by the service.
|
|
} as unknown as SessionSnapshotState['inFlightTurn'];
|
|
f.prompts.active.set(sid, 'prompt_xyz');
|
|
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.in_flight_turn).not.toBeNull();
|
|
expect((snap.in_flight_turn as { current_prompt_id?: string }).current_prompt_id).toBe(
|
|
'prompt_xyz',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SnapshotService status FSM (bus subscriber)', () => {
|
|
it('idle on a fresh session', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_status_idle';
|
|
await f.store.create({ id: sid, workDir: f.workDir });
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.session.status).toBe('idle');
|
|
});
|
|
|
|
it('running while a turn is active', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_status_running';
|
|
await f.store.create({ id: sid, workDir: f.workDir });
|
|
f.bus.publish({
|
|
type: 'turn.started',
|
|
sessionId: sid,
|
|
agentId: 'main',
|
|
turnId: 1,
|
|
origin: { kind: 'user' },
|
|
} as never);
|
|
const snap = await f.service.read(sid);
|
|
expect(snap.session.status).toBe('running');
|
|
});
|
|
|
|
it('aborted after turn.ended with cancelled, then running again on prompt.submitted', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_status_aborted';
|
|
await f.store.create({ id: sid, workDir: f.workDir });
|
|
|
|
f.bus.publish({
|
|
type: 'turn.started',
|
|
sessionId: sid,
|
|
agentId: 'main',
|
|
turnId: 1,
|
|
origin: { kind: 'user' },
|
|
} as never);
|
|
f.bus.publish({
|
|
type: 'turn.ended',
|
|
sessionId: sid,
|
|
agentId: 'main',
|
|
turnId: 1,
|
|
reason: 'cancelled',
|
|
} as never);
|
|
|
|
expect((await f.service.read(sid)).session.status).toBe('aborted');
|
|
|
|
f.bus.publish({
|
|
type: 'prompt.submitted',
|
|
sessionId: sid,
|
|
agentId: 'main',
|
|
promptId: 'p_1',
|
|
} as never);
|
|
// Prompt submission alone doesn't open a turn — but it clears `aborted`.
|
|
// Whether the result is `idle` or `running` depends on whether prompts
|
|
// service tracks an active id. Stub keeps `prompts.active` empty so the
|
|
// expected next status is `idle`.
|
|
expect((await f.service.read(sid)).session.status).toBe('idle');
|
|
});
|
|
|
|
it('idle after turn.ended with completed', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_status_completed';
|
|
await f.store.create({ id: sid, workDir: f.workDir });
|
|
f.bus.publish({
|
|
type: 'turn.started',
|
|
sessionId: sid,
|
|
agentId: 'main',
|
|
turnId: 1,
|
|
origin: { kind: 'user' },
|
|
} as never);
|
|
f.bus.publish({
|
|
type: 'turn.ended',
|
|
sessionId: sid,
|
|
agentId: 'main',
|
|
turnId: 1,
|
|
reason: 'completed',
|
|
} as never);
|
|
expect((await f.service.read(sid)).session.status).toBe('idle');
|
|
});
|
|
|
|
it('awaiting_approval beats running', async () => {
|
|
const f = await makeFixture();
|
|
const sid = 'sess_status_approval';
|
|
await f.store.create({ id: sid, workDir: f.workDir });
|
|
f.bus.publish({
|
|
type: 'turn.started',
|
|
sessionId: sid,
|
|
agentId: 'main',
|
|
turnId: 1,
|
|
origin: { kind: 'user' },
|
|
} as never);
|
|
f.approvals.pending.set(sid, [{ approval_request_id: 'a_1' } as unknown]);
|
|
expect((await f.service.read(sid)).session.status).toBe('awaiting_approval');
|
|
});
|
|
});
|
|
|
|
// ─── small helper that swallows stat errors ───────────────────────────────
|
|
|
|
async function statSafely(path: string): Promise<{ mtimeMs: number; size: number } | undefined> {
|
|
try {
|
|
return await stat(path);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|