/**
* StateTree rendering tests (SSR via renderToStaticMarkup — no DOM needed).
* Locks in two audit-panel readability rules:
* 1. Unchanged subtrees collapse into `{ …N }` rows — never a one-line
* compact-JSON dump (the copy-on-write fast path gives them no diff
* children, and rendering the raw value destroyed the layout).
* 2. Whole-subtree adds expand into fully fielded, indented tree rows.
*/
import type { AssistantMessage, StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { diffValue } from '../../audit/diff';
import { serializeState } from '../../audit/serialize';
import { EMPTY_CHAT_STATE, type ChatState } from '../../transcript/store';
import { plainNode, StateTree } from './StateTree';
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, text?: string): TurnMessage {
return {
type: 'turn',
session_id: 's1',
agent_id: 'main',
timestamp: ts(),
turn_id: `t${n}`,
ordinal: n,
status: 'completed',
origin: { kind: 'user' },
user_message_id: text,
};
}
function stepMsg(stepId: string): StepMessage {
return {
type: 'step',
session_id: 's1',
agent_id: 'main',
timestamp: ts(),
step_id: stepId,
turn_id: stepId.split('.')[0] ?? 't0',
ordinal: Number(stepId.split('.')[1] ?? '1'),
status: 'running',
};
}
function assistantMsg(stepId: string, text: string): AssistantMessage {
return {
type: 'assistant',
session_id: 's1',
agent_id: 'main',
timestamp: ts(),
message_id: `${stepId}.a0`,
turn_id: stepId.split('.')[0] ?? 't0',
step_id: stepId,
status: 'streaming',
text,
};
}
type FlatMessage = TurnMessage | StepMessage | AssistantMessage;
function stateWithTimeline(items: readonly FlatMessage[]): ChatState {
return {
...EMPTY_CHAT_STATE,
entries: items.map((message) => ({
key:
message.type === 'turn'
? `turn:${message.turn_id}`
: message.type === 'step'
? `step:${message.step_id}`
: `assistant:${message.message_id}`,
message,
})),
};
}
describe('StateTree', () => {
it('collapses unchanged subtrees instead of dumping compact JSON', () => {
const t0 = turnMsg(0, 'PROMPT_ZERO');
const prev = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE')]);
const next: ChatState = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE_V2')]);
const html = renderToStaticMarkup(
,
);
// No one-line JSON blob anywhere.
expect(html).not.toContain('{"type"');
// The unchanged turn t0 stays folded: its marker is not rendered…
expect(html).not.toContain('PROMPT_ZERO');
// …while the modified turn opens and shows old → new.
expect(html).toContain('PROMPT_ONE_V2');
expect(html).toContain('PROMPT_ONE');
expect(html).toContain('→');
});
it('expands whole-subtree adds into full field rows (all keys, no JSON dump)', () => {
const root = diffValue(
serializeState(EMPTY_CHAT_STATE),
serializeState(stateWithTimeline([turnMsg(0, 'HELLO')])),
);
const html = renderToStaticMarkup();
expect(html).not.toContain('{"type"');
for (const field of ['turn_id', 'ordinal', 'status', 'origin', 'timestamp', 'agent_id']) {
expect(html).toContain(field);
}
expect(html).toContain('HELLO');
});
it('expands added subtrees with id-based keys and renders closing braces', () => {
const html = renderToStaticMarkup(
,
);
// Array children are keyed by their ids, not #indices.
expect(html).toContain('t0.1');
expect(html).toContain('t0.1.a0');
expect(html).not.toContain('#0');
// Open containers end with an explicit closing brace row.
expect(html).toContain(']');
expect(html).toContain('}');
});
it('plain state mode opens to defaultDepth and shows all top-level fields', () => {
const html = renderToStaticMarkup(
,
);
for (const field of ['timeline', 'interactions', 'tasks', 'todos', 'hasMoreOlder']) {
expect(html).toContain(field);
}
expect(html).not.toContain('{"type"');
});
it('collapses multiline strings into a hover-preview button', () => {
const root = plainNode({ note: 'line one\nline two\nline three', single: 'one-liner' });
const html = renderToStaticMarkup();
// The multiline value renders as a compact button: first-line preview +
// line count, with the remaining lines NOT inlined into the tree (they
// only appear in the hover panel, which needs a real mouse to open).
expect(html).toContain('"line one" ⏎ 3');
expect(html).not.toContain('line two');
expect(html).not.toContain('line three');
// Single-line strings keep the inline rendering.
expect(html).toContain('"one-liner"');
});
});