Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions apps/kimi-inspect/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views:

The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx` — Audit / Agent / State / Session tabs) across two of them:

- `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan`) plus the agent Service panels.
- `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — derives ExitPlanMode plans client-side from the v3 message stream: one full read of `GET /api/v3/sessions/{id}/transcript` via `src/transcript/api.ts`'s `fetchTranscriptV3`, then `projectPlans` over the tool/interaction messages; v3 has no plan projection endpoint) plus the agent Service panels.
- `State` tab — every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`.

The **Session scope** lives in the same right dock as the `Session` tab (`src/components/SessionPane.tsx`, embedded by `RightPanel`) with two sub-tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`).
Expand All @@ -33,12 +33,10 @@ The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, d

## Chat view

The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses.
The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript v3** message stream and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (scroll, flash) path the global search view uses.

Full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library).

`/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally).
Full state comes from the socket itself: `subscribe_v3` on `/api/v1/ws` answers every (re)subscribe with a full `message.reset` snapshot (`Message[]`, grade `delta` — the whole message upserts plus the per-token `message.delta` stream), then only live frames; v3 has no paging, no watermark, and no catch-up — a reconnect or any loss signal (`resync_required`, delta gap) triggers an in-place re-subscribe whose fresh reset is the repair. Every graded agent id in the subscribe spec gets a reset, including ids with no transcript state yet (empty `messages`). An undo/clear on the server does not push a `remove` frame: after applying the removal it re-sends a full `message.reset` for that agent (`RemoveMessage` stays in the contract union, and the store still honors it). The store (`src/transcript/store.ts`, `TranscriptV3Store`) is a flat ordered `Message[]`: reset replaces wholesale, `message` upserts by (type, id), `message.delta` does offset-checked text concat, and `remove` deletes its ids plus every message cascading from a removed turn (`turn_id`). The view derives turn → step → frame groups, side-entity maps, and meta singletons from the flat list (`buildV3View`, memoized per state change), and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library). The REST v3 transcript read (`fetchTranscriptV3`, isomorphic to `message.reset`) serves on-demand consumers like the Plan lookup card; the chat pipeline itself has no REST leg.

## Transcript audit panel

The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload.
The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each `message.reset`, every `message` upsert, `message.delta` frames (consecutive deltas to the same target coalesced into one growing entry), loss signals, subscribe acks, and prompt/cancel actions — with the resulting immutable `V3TranscriptView` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload.
184 changes: 82 additions & 102 deletions apps/kimi-inspect/src/audit/audit.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
/**
* Audit-layer tests: the trail recorder, the structural diff, serialization,
* and tail-preserving truncation used by the chat view's audit panel.
*/

import { EMPTY_AGENT_STATE, type AgentState, type TranscriptTurn } from '@moonshot-ai/transcript';
import type { Message, MessageDelta, TaskMessage, TextMessage } from '@moonshot-ai/transcript';
import { describe, expect, it } from 'vitest';

import { buildV3View, EMPTY_V3_VIEW, type V3TranscriptView } 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';

function turnItem(n: number): TranscriptTurn {
const env = { session_id: 's1', agent_id: 'main' } as const;

function textMsg(id: string, text: string): TextMessage {
return { ...env, type: 'text', id, turn_id: 't1', step_id: 't1.1', role: 'assistant', text };
}

function taskMsg(id: string): TaskMessage {
return { ...env, type: 'task', id, kind: 'shell', state: 'running', detached: false, output_tail: '' };
}

function frameDelta(frameId: string, offset: number, text: string): MessageDelta {
return {
kind: 'turn',
turnId: `t${n}`,
ordinal: n,
state: 'completed',
origin: { kind: 'user' },
steps: [],
type: 'append',
...env,
target: { type: 'frame', turn_id: 't1', step_id: 't1.1', frame_id: frameId },
offset,
text,
};
}

function stateWith(items: readonly TranscriptTurn[]): AgentState {
return { ...EMPTY_AGENT_STATE, items };
function viewWith(messages: readonly Message[]): V3TranscriptView {
return buildV3View(messages);
}

// ---------------------------------------------------------------- diff

describe('diffValue', () => {
it('collapses reference-equal subtrees to unchanged without children', () => {
const shared = { a: 1, b: { c: 'x' } };
Expand All @@ -52,23 +55,24 @@ describe('diffValue', () => {
});
});

it('matches entity arrays by id instead of index', () => {
const prev = [turnItem(1), turnItem(2)];
const next = [turnItem(1), { ...turnItem(2), state: 'running' as const }, turnItem(3)];
it('matches entity arrays by id (v3 `id` field), not index', () => {
const prev = [textMsg('t1.1.f1', 'a'), textMsg('t1.1.f2', 'b')];
const next = [textMsg('t1.1.f1', 'a'), textMsg('t1.1.f2', 'B'), textMsg('t1.1.f3', 'c')];
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('state')).toMatchObject({
expect(node.children?.get('t1.1.f1')?.status).toBe('unchanged');
expect(node.children?.get('t1.1.f2')?.children?.get('text')).toMatchObject({
status: 'modified',
prev: 'completed',
value: 'running',
prev: 'b',
value: 'B',
});
expect(node.children?.get('t3')?.status).toBe('added');
expect(node.children?.get('t1.1.f3')?.status).toBe('added');
const removed = diffValue(next, prev);
expect(removed.children?.get('t1.1.f3')).toMatchObject({ status: 'removed' });
expect(removed.children?.get('t1.1.f1')?.status).toBe('unchanged');
});

it('keys steps by stepId (not their shared turnId) so siblings never collide', () => {
const step = (id: string, state: 'running' | 'completed') => ({
kind: 'step' as const,
stepId: id,
turnId: 't1',
ordinal: 1,
Expand All @@ -84,12 +88,6 @@ describe('diffValue', () => {
expect(node.children?.get('t1.2')?.status).toBe('modified');
});

it('marks removed array elements by id', () => {
const node = diffValue([turnItem(1), turnItem(2)], [turnItem(2)]);
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');
Expand All @@ -105,54 +103,52 @@ describe('diffValue', () => {
expect(diffValue([1], { 0: 1 }).status).toBe('modified');
});

it('diffs two serialized states with meta changes visible (goal/plan fields)', () => {
const prev = serializeState(stateWith([turnItem(1)]));
const nextState: AgentState = {
...stateWith([turnItem(1)]),
meta: {
goal: { objective: 'ship it', status: 'active' },
modes: { plan: { reviewPath: '/tmp/plan.md' } },
},
};
const node: DiffNode = diffValue(prev, serializeState(nextState));
expect(node.children?.get('items')?.status).toBe('unchanged');
const meta = node.children?.get('meta');
expect(meta?.status).toBe('modified');
expect(meta?.children?.get('goal')?.status).toBe('added');
// Whole-subtree add: `modes` was absent before, so the block (plan
// included) is marked added without descending into children.
expect(meta?.children?.get('modes')?.status).toBe('added');
expect(meta?.children?.get('modes')?.children).toBeUndefined();
it('diffs two serialized views with singleton changes visible (goal/modes)', () => {
const prev = serializeState(viewWith([textMsg('t1.1.f1', 'a')]));
const next = serializeState(
viewWith([
textMsg('t1.1.f1', 'a'),
{ ...env, type: 'goal', id: 'goal', objective: 'ship it', status: 'active' },
{ ...env, type: 'modes', id: 'modes', plan_review_path: '/tmp/plan.md' },
]),
);
const node: DiffNode = diffValue(prev, next);
expect(node.children?.get('timeline')?.status).toBe('unchanged');
expect(node.children?.get('goal')?.status).toBe('added');
expect(node.children?.get('modes')?.status).toBe('added');
expect(node.children?.get('modes')?.children).toBeUndefined();
});
});

// ---------------------------------------------------------------- serialize

describe('serializeState', () => {
it('turns maps into sorted plain objects and sets into arrays', () => {
const state: AgentState = {
...EMPTY_AGENT_STATE,
tasks: new Map([
[
'b-task',
{ taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' },
],
[
'a-task',
{ taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' },
],
]),
pendingInteractions: new Set(['z', 'a']),
};
const out = serializeState(state);
it('turns maps into sorted plain objects, sets into arrays, and hoists timeline ids', () => {
const view = viewWith([
taskMsg('b-task'),
taskMsg('a-task'),
{ ...env, type: 'turn_start', id: 't1', ordinal: 1, origin: { kind: 'user' } },
{ ...env, type: 'marker', id: 'm1', marker: 'undo' },
{
...env,
type: 'interaction',
id: 'z',
interaction_kind: 'approval',
state: 'pending',
},
{
...env,
type: 'interaction',
id: 'a',
interaction_kind: 'question',
state: 'pending',
},
]);
const out = serializeState(view);
expect(Object.keys(out.tasks as Record<string, unknown>)).toEqual(['a-task', 'b-task']);
expect(out.pendingInteractions).toEqual(['a', 'z']);
expect(out.hasMoreOlder).toBe(false);
expect(out.timeline.map((entry) => entry.id)).toEqual(['t1', 'm1']);
});
});

// ---------------------------------------------------------------- truncate

describe('tailTrunc', () => {
it('returns short strings unchanged', () => {
expect(tailTrunc('hello')).toBe('hello');
Expand All @@ -168,41 +164,25 @@ describe('tailTrunc', () => {
});
});

// ---------------------------------------------------------------- trail

describe('AuditTrail', () => {
const page = {
items: [turnItem(1)],
hasMoreOlder: false,
tasks: [],
interactions: [],
attachments: [],
todos: [],
meta: {},
pendingInteractions: [],
};

it('records entries with increasing indices, timestamps, and state references', () => {
const trail = new AuditTrail();
const s1 = stateWith([turnItem(1)]);
const s2 = stateWith([turnItem(1), turnItem(2)]);
trail.recordRest({ pageSize: 30 }, 'replace', page, s1);
trail.recordOps([{ op: 'turn.upsert', turn: turnItem(2) }], 'live', '2026-01-01T00:00:00Z', s2);
const s1 = viewWith([textMsg('t1.1.f1', 'a')]);
const s2 = viewWith([textMsg('t1.1.f1', 'ab')]);
trail.recordReset([textMsg('t1.1.f1', 'a')], '2026-01-01T00:00:00Z', s1);
trail.recordMessage(textMsg('t1.1.f1', 'ab'), '2026-01-01T00:00:01Z', s2);
trail.recordDelta(frameDelta('t1.1.f1', 2, 'c'), undefined, s2);
trail.recordDelta(frameDelta('t1.1.f1', 3, 'd'), undefined, s2);
trail.recordEvent('prompt', 'hello', s2);
trail.recordReset(
{ items: [], tasks: [], interactions: [], attachments: [], todos: [], prompts: [], meta: {} },
false,
undefined,
s2,
);

const entries = trail.getEntries();
expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ops', 'event', 'reset']);
expect(entries.map((entry) => entry.kind)).toEqual(['reset', 'message', 'delta', 'event']);
expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3]);
expect(entries[0]!.state).toBe(s1);
expect(entries[1]!.state).toBe(s2);
expect(entries[1]).toMatchObject({ delivery: 'live', envelopeAt: '2026-01-01T00:00:00Z' });
expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' });
expect(entries[1]).toMatchObject({ envelopeAt: '2026-01-01T00:00:01Z' });
expect(entries[2]).toMatchObject({ chunks: 2, text: 'cd' });
expect(entries[3]).toMatchObject({ event: 'prompt', detail: 'hello' });
expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe(
true,
);
Expand All @@ -215,18 +195,18 @@ describe('AuditTrail', () => {
const unsubscribe = trail.subscribe(() => {
notified += 1;
});
trail.recordEvent('cancel', undefined, EMPTY_AGENT_STATE);
trail.recordEvent('gap', undefined, EMPTY_AGENT_STATE);
trail.recordEvent('cancel', undefined, EMPTY_V3_VIEW);
trail.recordEvent('gap', undefined, EMPTY_V3_VIEW);
expect(notified).toBe(2);
unsubscribe();
trail.recordEvent('resync', undefined, EMPTY_AGENT_STATE);
trail.recordEvent('resync', undefined, EMPTY_V3_VIEW);
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_AGENT_STATE);
trail.recordEvent('prompt', `p${i}`, EMPTY_V3_VIEW);
}
const entries = trail.getEntries();
expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES);
Expand Down
30 changes: 1 addition & 29 deletions apps/kimi-inspect/src/audit/diff.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
/**
* Structural diff over serialized `AgentState` values (see `serialize.ts`).
*
* The audit panel diffs two adjacent, immutable states. Because the store
* is copy-on-write, untouched subtrees share references — the reference
* equality fast path below collapses them to `unchanged` without walking.
*
* Arrays of transcript entities are matched by their id field (turnId,
* stepId, frameId, …) rather than by index, so an upsert in the middle of
* the timeline does not turn into a cascade of spurious modifications.
*/

export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified';

export interface DiffNode {
Expand All @@ -26,23 +14,7 @@ export interface DiffNode {
readonly children?: ReadonlyMap<string, DiffNode>;
}

/**
* Id fields checked in priority order — MOST SPECIFIC FIRST. A step carries
* both `turnId` and `stepId`, and a frame can carry `taskId` alongside its
* `frameId`; matching the wrong one mislabels the node and, worse, collides
* siblings in the children map (two steps of one turn both keyed `t1`).
*/
const ID_FIELDS = [
'frameId',
'stepId',
'interactionId',
'attachmentId',
'todoId',
'markerId',
'refId',
'turnId',
'taskId',
] as const;
const ID_FIELDS = ['id', 'stepId', 'turnId'] as const;

function elementId(element: unknown): string | undefined {
if (typeof element !== 'object' || element === null) return undefined;
Expand Down
Loading
Loading