kimi-code/apps/vis/server/test/lib/session-store.test.ts
Kai efdf8a1b2d
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 / Publish native release assets (push) Blocked by required conditions
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Release (push) Waiting to run
feat(vis): faithful wire.jsonl rendering + built-in kimi vis command (#788)
* feat: polish vis

* feat: add 'kimi vis' command for session visualization

* fix(vis): drop metadata app_version/resumed removed upstream

#786 stopped recording resume version metadata, so those fields no
longer exist on the metadata wire record. The vis-into-typecheck wiring
caught the stale field reads after merging main; drop them from the
metadata headline.

* fix(vis): drop unnecessary return-await in startVisServer

oxlint typescript-eslint(return-await) flags returning an awaited
promise outside try/catch; return the promise directly.

* fix(vis): green CI — tolerate unbuilt embedded asset + bump nix pnpmDeps hash

- handleVis: wrap the embedded-SPA dynamic import in try/catch. The value
  module is generated at build time (prebuild); in contexts without a build
  (tests run pnpm test, not build) only the .d.ts type stub exists, so the
  runtime import throws. Tolerate it and fall back to filesystem serving.
- flake.nix: update the fetchPnpmDeps hash after adding the vis-web /
  vis-server / vite-plugin-singlefile dependencies.

* refactor(vis): drop redundant alwaysBundle in tsdown config

#775's single-entry build (codeSplitting: false) already bundles
everything not declared in dependencies/peerDependencies. hono /
@hono/node-server (transitive via vis-server) and @moonshot-ai/vis-server
(a devDependency) are all undeclared there, so they bundle by default —
the explicit alwaysBundle was redundant. Verified the emitted main.mjs is
still fully self-contained and 'kimi vis' serves.

* fix(vis): address review — context-token resets, IPv6 url, marker-safe indexing

- contextTokens now mirrors agent-core on lifecycle records: 0 on
  context.clear, tokensAfter on context.apply_compaction (was only
  updated from step.end.usage, leaving a stale live fill after a
  clear/compaction).
- start.ts brackets IPv6 hosts in the returned url (http://[::1]:port/);
  hostForUrl moved to config.ts and shared with the startup banner.
- compaction slice + micro-compaction blanking now index over real
  history entries only, so synthetic undo/clear UI markers no longer
  offset agent-core's compactedCount / cutoff.

* chore: add changeset for kimi vis command

* fix(vis): cross-platform single-file build + history-count micro clamp

- build-vis-asset.mjs sets VIS_SINGLEFILE via the spawn env and runs
  'vite build' directly (cross-platform), instead of the POSIX-inline-env
  'build:single' script that broke on Windows cmd; removed the now-unused
  build:single script. Fixes the win32 build path (the asset generator
  runs in the kimi-code prebuild + native bundle).
- context.undo now clamps the micro-compaction cutoff by history-entry
  count (excluding synthetic undo/clear markers) instead of messages.length,
  mirroring agent-core undo() -> microCompaction.reset(_history.length); a
  surviving marker no longer leaves the cutoff one too high and wrongly
  blanks a later-appended tool result.

* fix(vis): run the single-file build through a shell for Windows pnpm

The win32 native binary is built on Windows runners
(.github/workflows/_native-build.yml), which run this generator. pnpm's
launcher there is pnpm.cmd, which a bare argv exec can't resolve without
a shell. Use execSync with a single command string so the platform shell
(cmd on Windows) resolves the shim; a command string (not an args array)
avoids the args+shell deprecation. Args are static.

* fix(vis): show model-facing tool result content in the context view

agent-core normalizes tool results via toolResultOutputForModel before
they enter history (error -> '<system>ERROR: ...' prefix, empty ->
'<system>Tool output is empty.' sentinel). The projector was using the
raw ev.result.output, so the Context tab's model view showed content the
model never saw for failed/empty tool calls. Replicate that normalization
(the upstream helper is module-private) so the projected tool message
matches what the model received.
2026-06-16 16:54:14 +08:00

254 lines
12 KiB
TypeScript

// apps/vis/server/test/lib/session-store.test.ts
import { describe, it, expect, afterEach } from 'vitest';
import { buildSessionFixture } from '../fixtures/build';
import { isSafeAgentId, listSessions, readSessionDetail } from '../../src/lib/session-store';
describe('session-store', () => {
let cleanup: (() => Promise<void>) | null = null;
afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; });
it('lists native session with correct timestamps and counts', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const sessions = await listSessions(home);
expect(sessions).toHaveLength(1);
const s = sessions[0]!;
expect(s.sessionId).toBe('session_fixture');
expect(s.title).toBe('fixture: hello world');
expect(s.lastPrompt).toBe('say hi');
expect(s.agentCount).toBe(2);
expect(s.mainAgentExists).toBe(true);
expect(s.mainWireRecordCount).toBe(10); // 10 lines in main wire incl. metadata
expect(s.wireProtocolVersion).toBe('1.1');
expect(s.health).toBe('ok');
expect(s.workDir).toBe('/tmp/work');
expect(s.createdAt).toBe(Date.parse('2026-05-20T05:59:51.085Z'));
expect(s.updatedAt).toBe(Date.parse('2026-05-21T03:12:08.000Z'));
});
it('treats a v1.0 wire as healthy (vis migrates on read)', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { readFile, writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
const lines = (await readFile(wirePath, 'utf8')).split('\n');
lines[0] = JSON.stringify({ type: 'metadata', protocol_version: '1.0', created_at: 1 });
await writeFile(wirePath, lines.join('\n'));
const sessions = await listSessions(home);
expect(sessions[0]!.health).toBe('ok');
expect(sessions[0]!.wireProtocolVersion).toBe('1.0');
});
it('treats unknown protocol versions as healthy (wire-reader best-efforts)', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { readFile, writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
const lines = (await readFile(wirePath, 'utf8')).split('\n');
lines[0] = JSON.stringify({ type: 'metadata', protocol_version: '2.2', created_at: 1 });
await writeFile(wirePath, lines.join('\n'));
const sessions = await listSessions(home);
expect(sessions[0]!.health).toBe('ok');
expect(sessions[0]!.wireProtocolVersion).toBe('2.2');
});
it('falls back to empty workDir when session is not in the index', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { rm } = await import('node:fs/promises');
const { join } = await import('node:path');
await rm(join(home, 'session_index.jsonl'));
const sessions = await listSessions(home);
expect(sessions).toHaveLength(1);
expect(sessions[0]!.workDir).toBe('');
});
it('skips imported_from_kimi_cli sessions', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
// mark as imported
const { readFile, writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const state = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8'));
state.custom = { imported_from_kimi_cli: true };
await writeFile(join(sessionDir, 'state.json'), JSON.stringify(state));
const sessions = await listSessions(home);
expect(sessions).toHaveLength(0);
});
it('marks a session broken_main_wire when its wire file cannot be scanned', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { rm, mkdir } = await import('node:fs/promises');
const { join } = await import('node:path');
// Replace the wire FILE with a directory of the same name, so the
// createReadStream below will reject with EISDIR.
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
await rm(wirePath);
await mkdir(wirePath);
const sessions = await listSessions(home);
expect(sessions).toHaveLength(1);
expect(sessions[0]!.health).toBe('broken_main_wire');
expect(sessions[0]!.mainWireRecordCount).toBe(0);
});
it('marks a session broken_main_wire when the wire metadata header is malformed', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl');
// First line is not a `metadata` record — list health used to stay
// 'ok' while readAgentWire would fail on open.
await writeFile(
wirePath,
'{"type":"config.update","cwd":"/x","time":1}\n',
);
const sessions = await listSessions(home);
expect(sessions).toHaveLength(1);
expect(sessions[0]!.health).toBe('broken_main_wire');
});
it('rejects unsafe agent ids', () => {
expect(isSafeAgentId('main')).toBe(true);
expect(isSafeAgentId('agent-0')).toBe(true);
expect(isSafeAgentId('agent_0.v2')).toBe(true);
expect(isSafeAgentId('..')).toBe(false);
expect(isSafeAgentId('.')).toBe(false);
expect(isSafeAgentId('../foo')).toBe(false);
expect(isSafeAgentId('a/b')).toBe(false);
expect(isSafeAgentId('a\\b')).toBe(false);
expect(isSafeAgentId('')).toBe(false);
});
it('skips unsafe agent ids from state.json in the inventory', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { readFile, writeFile, mkdir } = await import('node:fs/promises');
const { join } = await import('node:path');
const statePath = join(sessionDir, 'state.json');
const state = JSON.parse(await readFile(statePath, 'utf8'));
state.agents['../escape'] = {
homedir: '/tmp/whatever',
type: 'sub',
parentAgentId: 'main',
};
await writeFile(statePath, JSON.stringify(state));
// Plant a wire file under the "../escape" path that would be
// reachable if isSafeAgentId failed to gate path joins.
await mkdir(join(sessionDir, '..', 'escape'), { recursive: true });
await writeFile(
join(sessionDir, '..', 'escape', 'wire.jsonl'),
'{"type":"metadata","protocol_version":"1.1","created_at":1}\n',
);
const d = await readSessionDetail(home, 'session_fixture');
expect(d!.agents.map((a) => a.agentId).sort()).toEqual(['agent-0', 'main']);
});
it('rejects session_index entries that point outside KIMI_CODE_HOME', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { writeFile, mkdir } = await import('node:fs/promises');
const { join } = await import('node:path');
// Poison the index: claim session_fixture lives at /tmp/elsewhere.
const elsewhere = '/tmp/vis-poison-test-' + Date.now();
await mkdir(elsewhere, { recursive: true });
await writeFile(
join(home, 'session_index.jsonl'),
JSON.stringify({
sessionId: 'session_fixture',
sessionDir: elsewhere,
workDir: '/somewhere',
}) + '\n',
);
// Detail must fall back to bucket scanning (legit path under home)
// rather than honour the poisoned index entry.
const d = await readSessionDetail(home, 'session_fixture');
expect(d).not.toBeNull();
expect(d!.sessionDir.startsWith(home)).toBe(true);
const { rm } = await import('node:fs/promises');
await rm(elsewhere, { recursive: true, force: true });
});
it('reports an unreadable subagent wire as wireExists=false in agent inventory', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
// Break the SUBAGENT's wire metadata; main wire stays intact so the
// session itself remains healthy.
await writeFile(
join(sessionDir, 'agents', 'agent-0', 'wire.jsonl'),
'not even json\n',
);
const d = await readSessionDetail(home, 'session_fixture');
expect(d).not.toBeNull();
const sub = d!.agents.find((a) => a.agentId === 'agent-0')!;
expect(sub.wireExists).toBe(false);
expect(sub.wireRecordCount).toBe(0);
});
it('exposes the canonical session directory in detail responses', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const d = await readSessionDetail(home, 'session_fixture');
expect(d!.sessionDir).toBe(sessionDir);
});
it('returns broken-state detail consistent with the listed broken summary', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
await writeFile(join(sessionDir, 'state.json'), '{ this is not json');
const summaries = await listSessions(home);
expect(summaries).toHaveLength(1);
expect(summaries[0]!.health).toBe('broken_state');
const d = await readSessionDetail(home, 'session_fixture');
expect(d).not.toBeNull();
expect(d!.state).toBeNull();
expect(d!.workDir).toBe('/tmp/work');
// Even with state.json broken, the on-disk agent directories should
// still be inventoried so users can open wire/context.
expect(d!.agents.map((a) => a.agentId).sort()).toEqual(['agent-0', 'main']);
const main = d!.agents.find((a) => a.agentId === 'main')!;
expect(main.wireExists).toBe(true);
expect(main.wireRecordCount).toBe(10);
});
it('reads session detail with full agent inventory', async () => {
const { home, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const d = await readSessionDetail(home, 'session_fixture');
expect(d).not.toBeNull();
expect(d!.workDir).toBe('/tmp/work');
expect(d!.agents.map((a) => a.agentId).sort()).toEqual(['agent-0', 'main']);
const main = d!.agents.find((a) => a.agentId === 'main')!;
expect(main.type).toBe('main');
expect(main.parentAgentId).toBeNull();
expect(main.wireExists).toBe(true);
expect(main.wireRecordCount).toBe(10);
const sub = d!.agents.find((a) => a.agentId === 'agent-0')!;
expect(sub.parentAgentId).toBe('main');
});
it('surfaces swarmItem from state.json onto AgentInfo (null when absent)', async () => {
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
cleanup = c;
const { readFile, writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const statePath = join(sessionDir, 'state.json');
const state = JSON.parse(await readFile(statePath, 'utf8'));
state.agents['agent-0'].swarmItem = 'task A';
await writeFile(statePath, JSON.stringify(state));
const d = await readSessionDetail(home, 'session_fixture');
expect(d).not.toBeNull();
const sub = d!.agents.find((a) => a.agentId === 'agent-0')!;
expect(sub.swarmItem).toBe('task A');
// main has no swarmItem in state.json → null, not undefined.
const main = d!.agents.find((a) => a.agentId === 'main')!;
expect(main.swarmItem).toBeNull();
});
});