kimi-code / apps /kimi-inspect /src /audit /audit.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
5710dd0 verified
Raw History Blame Contribute Delete
9.26 kB
/**
* Audit-layer tests: the trail recorder, the structural diff, serialization,
* and tail-preserving truncation used by the chat view's audit panel.
*/
import type { StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol';
import { describe, expect, it } from 'vitest';
import { EMPTY_CHAT_STATE, type ChatState } from '../transcript/store';
import { diffValue, type DiffNode } from './diff';
import { serializeState } from './serialize';
import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail';
import { tailTrunc } from './truncate';
const T0 = Date.parse('2026-01-01T00:00:00.000Z');
let tick = 0;
function ts(): number {
tick += 1;
return T0 + tick * 1000;
}
function turnMsg(n: number, status: 'running' | 'completed' = 'completed'): TurnMessage {
return {
type: 'turn',
session_id: 's1',
agent_id: 'main',
timestamp: ts(),
turn_id: `t${n}`,
ordinal: n,
status,
origin: { kind: 'user' },
};
}
function stepMsg(stepId: string, status: 'running' | 'completed'): StepMessage {
return {
type: 'step',
session_id: 's1',
agent_id: 'main',
timestamp: ts(),
step_id: stepId,
turn_id: stepId.split('.')[0] ?? 't1',
ordinal: Number(stepId.split('.')[1] ?? '1'),
status,
};
}
function stateWithTimeline(items: readonly (TurnMessage | StepMessage)[]): ChatState {
return {
...EMPTY_CHAT_STATE,
entries: items.map((message) => ({
key: message.type === 'turn' ? `turn:${message.turn_id}` : `step:${message.step_id}`,
message,
})),
};
}
// ---------------------------------------------------------------- diff
describe('diffValue', () => {
it('collapses reference-equal subtrees to unchanged without children', () => {
const shared = { a: 1, b: { c: 'x' } };
const node = diffValue({ v: shared }, { v: shared });
expect(node.status).toBe('unchanged');
expect(node.children?.get('v')?.children).toBeUndefined();
});
it('marks added, removed, and modified object keys', () => {
const node = diffValue(
{ keep: 1, gone: 'x', changed: 'a' },
{ keep: 1, fresh: true, changed: 'b' },
);
expect(node.status).toBe('modified');
expect(node.children?.get('keep')?.status).toBe('unchanged');
expect(node.children?.get('fresh')?.status).toBe('added');
expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' });
expect(node.children?.get('changed')).toMatchObject({
status: 'modified',
prev: 'a',
value: 'b',
});
});
it('matches entity arrays by id instead of index', () => {
const t1 = turnMsg(1);
const t2 = turnMsg(2);
const prev = [t1, t2];
const next = [t1, { ...t2, status: 'running' as const }, turnMsg(3)];
const node = diffValue(prev, next);
expect(node.children?.get('t1')?.status).toBe('unchanged');
expect(node.children?.get('t2')?.status).toBe('modified');
expect(node.children?.get('t2')?.children?.get('status')).toMatchObject({
status: 'modified',
prev: 'completed',
value: 'running',
});
expect(node.children?.get('t3')?.status).toBe('added');
});
it('keys steps by step_id (not their shared turn_id) so siblings never collide', () => {
const done = stepMsg('t1.1', 'completed');
const node = diffValue(
[done, stepMsg('t1.2', 'completed')],
[done, stepMsg('t1.2', 'running')],
);
expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']);
expect(node.children?.get('t1.1')?.status).toBe('unchanged');
expect(node.children?.get('t1.2')?.status).toBe('modified');
});
it('marks removed array elements by id', () => {
const t2 = turnMsg(2);
const node = diffValue([turnMsg(1), t2], [t2]);
expect(node.children?.get('t1')).toMatchObject({ status: 'removed' });
expect(node.children?.get('t2')?.status).toBe('unchanged');
});
it('marks whole-subtree adds/removes without descending', () => {
const added = diffValue(undefined, { nested: { deep: 1 } });
expect(added.status).toBe('added');
expect(added.children).toBeUndefined();
const removed = diffValue({ nested: 1 }, undefined);
expect(removed.status).toBe('removed');
expect(removed.children).toBeUndefined();
});
it('treats type changes as leaf modifications', () => {
expect(diffValue('1', 1).status).toBe('modified');
expect(diffValue(null, {}).status).toBe('modified');
expect(diffValue([1], { 0: 1 }).status).toBe('modified');
});
it('diffs two serialized states with session.state changes visible', () => {
const base = stateWithTimeline([turnMsg(1)]);
const prev = serializeState(base);
const nextState: ChatState = {
...base,
sessionState: {
type: 'session.state',
session_id: 's1',
timestamp: ts(),
status: 'running',
goal: { objective: 'ship it', status: 'active' },
modes: { plan: { review_path: '/tmp/plan.md' } },
},
};
const node: DiffNode = diffValue(prev, serializeState(nextState));
expect(node.children?.get('timeline')?.status).toBe('unchanged');
const sessionState = node.children?.get('sessionState');
expect(sessionState?.status).toBe('added');
expect(sessionState?.children).toBeUndefined();
});
});
// ---------------------------------------------------------------- serialize
describe('serializeState', () => {
it('turns maps into sorted plain objects and flattens the timeline', () => {
const state: ChatState = {
...EMPTY_CHAT_STATE,
entries: stateWithTimeline([turnMsg(1)]).entries,
tasks: new Map([
[
'b-task',
{
type: 'task',
session_id: 's1',
agent_id: 'main',
timestamp: ts(),
task_id: 'b-task',
kind: 'shell',
status: 'running',
detached: false,
output_tail: '',
},
],
[
'a-task',
{
type: 'task',
session_id: 's1',
agent_id: 'main',
timestamp: ts(),
task_id: 'a-task',
kind: 'tool',
status: 'completed',
detached: false,
output_tail: '',
},
],
]),
};
const out = serializeState(state);
expect(Object.keys(out.tasks)).toEqual(['a-task', 'b-task']);
expect(out.timeline.map((m) => (m.type === 'turn' ? m.turn_id : ''))).toEqual(['t1']);
expect(out.hasMoreOlder).toBe(false);
});
});
// ---------------------------------------------------------------- truncate
describe('tailTrunc', () => {
it('returns short strings unchanged', () => {
expect(tailTrunc('hello')).toBe('hello');
expect(tailTrunc('x'.repeat(500))).toBe('x'.repeat(500));
});
it('keeps the tail of long strings and reports the total length', () => {
const value = 'head-padding'.repeat(100) + 'THE-TAIL';
const out = tailTrunc(value, 50);
expect(out).toContain(`${value.length} chars total`);
expect(out.endsWith('THE-TAIL')).toBe(true);
expect(out).not.toContain('head-padding'.repeat(10));
});
});
// ---------------------------------------------------------------- trail
describe('AuditTrail', () => {
it('records entries with increasing indices, timestamps, and state references', () => {
const trail = new AuditTrail();
const s1 = stateWithTimeline([turnMsg(1)]);
const s2 = stateWithTimeline([turnMsg(1), turnMsg(2)]);
trail.recordRest({ pageSize: 500 }, 'replace', 1, { turn_id: 't1', step_id: 't1.1' }, s1);
trail.recordWs(turnMsg(2, 'running'), s2);
trail.recordEvent('prompt', 'hello', s2);
const entries = trail.getEntries();
expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ws', 'event']);
expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2]);
expect(entries[0]!.state).toBe(s1);
expect(entries[1]!.state).toBe(s2);
expect(entries[0]).toMatchObject({ mode: 'replace', messageCount: 1 });
expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' });
expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe(
true,
);
expect(entries.every((entry) => entry.summary.length > 0)).toBe(true);
});
it('notifies subscribers on each record', () => {
const trail = new AuditTrail();
let notified = 0;
const unsubscribe = trail.subscribe(() => {
notified += 1;
});
trail.recordEvent('cancel', undefined, EMPTY_CHAT_STATE);
trail.recordEvent('ack', undefined, EMPTY_CHAT_STATE);
expect(notified).toBe(2);
unsubscribe();
trail.recordEvent('reconnect', undefined, EMPTY_CHAT_STATE);
expect(notified).toBe(2);
});
it('drops the oldest entries beyond the cap while indices keep increasing', () => {
const trail = new AuditTrail();
for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) {
trail.recordEvent('prompt', `p${i}`, EMPTY_CHAT_STATE);
}
const entries = trail.getEntries();
expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES);
expect(entries[0]!.index).toBe(10);
expect(entries.at(-1)!.index).toBe(AUDIT_TRAIL_MAX_ENTRIES + 9);
});
});