kimi-code/packages/transcript/test/store.test.ts
qer 0f44537c13
feat(agent-core-v2): turn /tower into a mode parallel to plan mode (#3099)
* feat(agent-core-v2): turn /tower into a mode parallel to plan mode

* feat(agent-core-v2): align /tower command semantics with the original skill behavior

* fix(tui): harden the /tower command against mid-turn objectives, legacy engines, and stale status

* fix(agent-core-v2): keep restored tower state inert while the feature flag is off

* fix(agent-core-v2): include TowerInit in the mode tool overlay and report flag-gated tower state

* feat(agent-core-v2): expose tower control tools statically and make the mode injection history-derived

* fix(tui): warn that tower mode needs a restart after a live flag flip; drop redundant undefined from SessionStatus mode fields

* fix(agent-core-v2): let tower mode exit clear persisted state while the flag is off

* fix(agent-core-v2): emit the tower exit reminder through a disabled flag and drop the redundant REST state comparison

* fix(tui): show the Tower mode status row only when the experiment is available

* fix(agent-core-v2): confine tower mode to the main agent and always re-assert it for objectives

* feat(kap-server): project tower mode into transcript modes and reassert explicit toggles

* fix(agent-core-v2): fold the main-agent invariant into the effective tower state

* fix(kap-server): gate the cold tower mode badge behind the experiment flag

* fix(agent-core-v2): reapply the tower tool overlay after a profile bind; fix(kap-server): clear cold tower badges on non-main agents

* fix(protocol): mirror towerMode and tower_mode in the shared zod schemas

* test(agent-core-v2): adapt tower tests to the agent lifecycle context architecture

* chore(agent-core-v2): regenerate the wire manifest; test(node-sdk): look up the main agent via findAgentHandle

* fix(agent-core-v2): exit a replayed tower mode when the workspace belongs to another session

* fix(agent-core-v2): carry tower ownership in the enter record so forks clear inherited mode

* fix(agent-core-v2): apply the tower overlay before dispatching enter and repair it on status updates

* fix(agent-core-v2): validate store ownership before entering tower mode

* fix(agent-core-v2): claim the repository tower owner at enter; fix(tui): confirm mode activation before reporting success

* fix(agent-core-v2): make the first tower claim exclusive and refuse adoption over a live owner

* feat(agent-core-v2): guard tower adoption and teardown with a cross-process ownership lease

* revert(agent-core-v2): drop the cross-process lease and enter-time claim, keep ownership checks process-local

* fix(agent-core): hide the v2-only tower flag from the legacy experiments list

* fix(agent-core-v2): keep tower mode inert until the tower feature is assembled

A live /experiments flip refreshes the flag but cannot re-run App-scope
feature assembly, so the tower tools/profile stay unregistered until a
restart. Gate enter()/isActive on the assembly fact and say so in the
TUI error. Also resolve the AGENTS.md conflict block committed by the
merge, and widen the TUI experimentalFlag type to string now that flags
live in two registries.

* fix(kap-server): gate the cold tower badge on tower feature assembly

Same live-flip gap as the mode machinery: a persisted tower_mode.enter
plus a flag enabled without a restart would still show the badge while
the feature is inert. Require isTowerFeatureAssembled() alongside the
flag, re-exported from agent-core-v2.

* fix(agent-core-v2): liveness-aware tower entry and per-App assembly state

enter() now mirrors TowerInit's adoption rule: a stored owner blocks
entry only while that session is live in this process, so a new session
can enter the mode and reach TowerInit to adopt a stale tower. The
assembly marker is keyed by each App's flag service (WeakSet) instead of
process-global module state, so coexisting Apps no longer leak assembly
into one another.

* fix(agent-core-v2): keep stale-owner adoption across resume; reject tower updates that do not take

exitForeignTower now treats a stored owner as foreign only while that
session is live, so a mode entered by adopting a dead owner's tower
survives close/restart. The TUI validates the model prerequisite before
enabling tower for an objective, and the REST agent_config path throws
session.tower_mode_invalid when enter() did not take effect instead of
acknowledging a no-op.

* fix(agent-core-v2): clear the tower assembly marker when the feature unloads

Register the WeakSet cleanup via the feature's onDispose so the
capability follows the managed unit's lifecycle — after
unprovideUnit('tower'), isActive/enter() no longer treat the retracted
tool set as assembled.

* fix(agent-core-v2): publish tower deactivation on gate loss; reject refused setTowerMode in the SDK

isActive now reconciles its projection: restore and feature-manager unit
changes publish AgentStatusUpdated({ towerMode: false }) when the
persisted mode lost a gate (flag off at runtime, feature unloaded), so
live transcript badges and TUI state stop showing an inert mode. The
SDK's setTowerMode(true) verifies the effective state and throws
session.tower_mode_invalid, matching the REST path.

* fix(agent-core-v2): reconcile tower projection on config changes and cold ownership moves

The last unreconciled gate inputs: a live setConfig writing the
experimental section (no session reload, no units event) now republishes
towerMode:false through the config-change subscription, and the cold
transcript badge mirrors the same ownership/liveness rule as enter() —
retained while the store owner is this session or no live session, cleared
once a live session elsewhere owns the tower.

* fix(agent-core-v2): reconcile the tower projection in both directions

The OFF-only reconcile left a hole: re-enabling the flag in the same
process made isActive true again with no towerMode:true publish, and
enter() could not heal it (it early-returns when already effective).
The projection now tracks the last published state and emits both
false→true and true→false transitions from the same restore/units/config
triggers; direct publishes (enter/exit/restoreTowerTools) keep the
tracker in sync.

* fix(agent-core-v2): validate forked tower ownership even while the flag is off

exitForeignTower's flag short-circuit let a fork restored while the
experiment was disabled keep its inherited enter record; re-enabling the
flag later revived both source and fork over the same store. Ownership
validation is flag-independent state hygiene, so restore now runs it
regardless of the effective flag — a live foreign owner clears the
fork's persisted mode before any gate can rise again.

* fix(agent-core-v2): veto tower tools while the tower experiment is off

With the feature assembled and the tool overlay active, disabling the
flag live left TowerInit/TowerTeardown callable — they have no flag
check — so prompts could still mutate or dismantle .tower/ while the
experiment reported disabled. A dedicated onBeforeExecuteTool hook now
denies every tower tool whenever the flag is off, mirroring the TodoList
veto.

* fix(agent-core-v2): keep tower worker write isolation when the flag turns off

The worker Write/Edit guard is identity-scoped (tower-worker profile),
not feature-activity-scoped: disabling the experiment live must not let
already-spawned detached workers write into the main checkout or other
worktrees. The guard no longer checks the flag; the tower-tool veto
added earlier covers protocol access instead.

* test(agent-core-v2): make tower tests hermetic for CI

Three CI-only failures: towerService git fixtures committed without a
repo-local identity (CI has no global gitconfig), the node-sdk tower
positive tests relied on the developer shell's
KIMI_CODE_EXPERIMENTAL_FLAG=1 master switch instead of enabling the
tower flag explicitly, and the legacy harness experimental-features
expectation still listed the tower entry removed from the v1 registry.
2026-08-21 14:47:37 +08:00

587 lines
21 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AgentTranscript } from '#/store/agentTranscript';
import { TranscriptStore } from '#/store/transcriptStore';
import { appendAtOffset } from '#/ops/apply';
import type {
FrameUpsertOp,
TurnUpsertOp,
TranscriptOperation,
} from '#/ops/operation';
import type { ThinkingFrame, ToolCallFrame } from '#/model/frame';
import type { TranscriptInteraction } from '#/model/interaction';
import type { TranscriptItem } from '#/model/item';
function itemLabel(item: TranscriptItem): string {
if (item.kind === 'turn') return item.turnId;
if (item.kind === 'marker') return item.markerId;
return item.refId;
}
const turn1: TurnUpsertOp = {
op: 'turn.upsert',
turn: { kind: 'turn', turnId: 't1', ordinal: 1, state: 'running', origin: { kind: 'user' }, prompt: 'hi' },
};
const doneThinking: FrameUpsertOp = {
op: 'frame.upsert',
turnId: 't1',
stepId: 't1.1',
frame: { kind: 'thinking', frameId: 't1.1.f1', text: 'ponder' } satisfies ThinkingFrame,
};
function toolFrame(state: ToolCallFrame['state'], output?: unknown): TranscriptOperation[] {
return [
turn1,
{
op: 'step.upsert',
turnId: 't1',
step: { kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'running' },
},
{
op: 'frame.upsert',
turnId: 't1',
stepId: 't1.1',
frame: {
kind: 'tool',
frameId: 't1.1.call_1',
toolCallId: 'call_1',
name: 'Read',
state,
input: { path: '/a' },
output,
} satisfies ToolCallFrame,
},
];
}
describe('AgentTranscript', () => {
it('applies turn/step/frame and keeps a self-consistent snapshot', () => {
const tx = new AgentTranscript('main');
tx.apply(toolFrame('running'));
const items = tx.getItems();
expect(items).toHaveLength(1);
const turn = items[0];
expect(turn?.kind).toBe('turn');
if (turn?.kind !== 'turn') return;
expect(turn.steps).toHaveLength(1);
expect(turn.steps[0]?.frames.map((f) => f.kind)).toEqual(['tool']);
});
it('auto-vivifies missing parents so any op order stays self-consistent', () => {
const tx = new AgentTranscript('main');
tx.apply([
{
op: 'frame.upsert',
turnId: 't9',
stepId: 't9.2',
frame: { kind: 'thinking', frameId: 't9.2.f1', text: 'x' },
},
]);
const turn = tx.getTurn('t9');
expect(turn?.ordinal).toBe(9);
expect(turn?.steps[0]?.stepId).toBe('t9.2');
});
it('upserts are idempotent under duplication in causal order', () => {
const ops: TranscriptOperation[] = [
turn1,
{
op: 'step.upsert',
turnId: 't1',
step: { kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'running' },
},
doneThinking,
{
op: 'step.upsert',
turnId: 't1',
step: { kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'completed' },
},
{ op: 'turn.upsert', turn: { ...turn1.turn, state: 'completed' } },
];
const a = new AgentTranscript('main');
a.apply(ops);
const b = new AgentTranscript('main');
b.apply([...ops, ...ops]);
b.apply(ops);
expect(b.getItems()).toEqual(a.getItems());
});
it('appends text chunks by offset; gaps stay un-applied and signalled', () => {
const tx = new AgentTranscript('main');
tx.apply([
turn1,
{
op: 'frame.upsert',
turnId: 't1',
stepId: 't1.1',
frame: { kind: 'text', frameId: 't1.1.f1', role: 'assistant', text: '' },
},
]);
const gap = tx.apply([
{ op: 'append', target: { type: 'frame', turnId: 't1', stepId: 't1.1', frameId: 't1.1.f1' }, offset: 5, text: 'late' },
]);
expect(gap.gap).toEqual({
target: { type: 'frame', turnId: 't1', stepId: 't1.1', frameId: 't1.1.f1' },
expected: 0,
got: 5,
});
const ok = tx.apply([
{ op: 'append', target: { type: 'frame', turnId: 't1', stepId: 't1.1', frameId: 't1.1.f1' }, offset: 0, text: 'hello ' },
{ op: 'append', target: { type: 'frame', turnId: 't1', stepId: 't1.1', frameId: 't1.1.f1' }, offset: 6, text: 'world' },
]);
expect(ok.gap).toBeUndefined();
const turn = tx.getTurn('t1');
const frame = turn?.steps[0]?.frames[0];
expect(frame?.kind === 'text' && frame.text).toBe('hello world');
const dup = tx.apply([
{ op: 'append', target: { type: 'frame', turnId: 't1', stepId: 't1.1', frameId: 't1.1.f1' }, offset: 6, text: 'world' },
]);
expect(dup.accepted).toHaveLength(0);
});
it('appendAtOffset matches web alignDelta semantics', () => {
expect(appendAtOffset('abc', 3, 'd')).toEqual({ text: 'abcd', changed: true });
expect(appendAtOffset('abc', 1, 'bc').changed).toBe(false);
expect(appendAtOffset('abc', 1, 'bcd')).toEqual({ text: 'abcd', changed: true });
expect(appendAtOffset('abc', 5, 'x').gap).toEqual({ expected: 3, got: 5 });
});
it('appendAtOffset treats a mismatched overlap as a gap, never a rewrite', () => {
const result = appendAtOffset('hello', 2, ' world');
expect(result.text).toBe('hello');
expect(result.gap).toEqual({ expected: 5, got: 2 });
expect(appendAtOffset('hello wo', 6, 'world')).toEqual({ text: 'hello world', changed: true });
});
it('tracks pending interactions as a derived index (entity channel)', () => {
const tx = new AgentTranscript('main');
const interaction = (state: TranscriptInteraction['state']): TranscriptInteraction => ({
interactionId: 'appr-1',
interactionKind: 'approval',
toolCallId: 'call-1',
state,
});
tx.apply([turn1, { op: 'interaction.upsert', interaction: interaction('pending') }]);
expect(tx.listPendingInteractions()).toEqual(['appr-1']);
tx.apply([{ op: 'interaction.upsert', interaction: interaction('approved') }]);
expect(tx.listPendingInteractions()).toEqual([]);
const unanchored = (state: TranscriptInteraction['state']): TranscriptInteraction => ({
interactionId: 'appr-2',
interactionKind: 'question',
state,
});
tx.apply([{ op: 'interaction.upsert', interaction: unanchored('pending') }]);
expect(tx.listPendingInteractions()).toEqual(['appr-2']);
tx.apply([{ op: 'interaction.upsert', interaction: unanchored('answered') }]);
expect(tx.listPendingInteractions()).toEqual([]);
});
it('upserts attachment and todo entities idempotently', () => {
const tx = new AgentTranscript('main');
const attachment = {
attachmentId: 'att_1',
mediaType: 'image/png',
source: { kind: 'url' as const, url: 'https://example.com/a.png' },
};
const todo = { todoId: 'todo', items: [{ title: 'x', status: 'pending' as const }] };
const first = tx.apply([
{ op: 'attachment.upsert', attachment },
{ op: 'todo.upsert', todo },
]);
expect(first.accepted).toHaveLength(2);
const second = tx.apply([
{ op: 'attachment.upsert', attachment },
{ op: 'todo.upsert', todo },
]);
expect(second.accepted).toHaveLength(0);
expect(tx.getAttachment('att_1')?.mediaType).toBe('image/png');
expect(tx.getTodo('todo')?.items).toHaveLength(1);
tx.apply([{ op: 'todo.upsert', todo: { ...todo, items: [] } }]);
expect(tx.getTodo('todo')?.items).toHaveLength(0);
});
it('upserts prompt queue entities by id, idempotently', () => {
const tx = new AgentTranscript('main');
const queued = {
promptId: 'p1',
status: 'queued' as const,
userMessageId: 'u1',
createdAt: '2026-07-22T00:00:00.000Z',
};
expect(tx.apply([{ op: 'prompt.upsert', prompt: queued }]).accepted).toHaveLength(1);
expect(tx.apply([{ op: 'prompt.upsert', prompt: queued }]).accepted).toHaveLength(0);
const running = { ...queued, status: 'running' as const, steeredAt: '2026-07-22T00:00:01.000Z' };
expect(tx.apply([{ op: 'prompt.upsert', prompt: running }]).accepted).toHaveLength(1);
expect(tx.getPrompt('p1')?.status).toBe('running');
expect(tx.getPrompt('p1')?.steeredAt).toBe('2026-07-22T00:00:01.000Z');
const snapshot = tx.snapshot();
expect(snapshot.prompts).toEqual([running]);
const fresh = new AgentTranscript('main');
fresh.receive([{ op: 'reset', agentId: 'main', snapshot }]);
expect(fresh.getPrompt('p1')).toEqual(running);
expect([...fresh.getPrompts().keys()]).toEqual(['p1']);
});
it('step upserts carry usage/timing and the terminal header clears retry', () => {
const tx = new AgentTranscript('main');
tx.apply([
turn1,
{
op: 'step.upsert',
turnId: 't1',
step: {
kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'running',
retry: { failedAttempt: 1, nextAttempt: 2, maxAttempts: 3, delayMs: 500, errorName: 'RateLimit', errorMessage: 'slow down' },
},
},
]);
expect(tx.getTurn('t1')?.steps[0]?.retry?.errorName).toBe('RateLimit');
const completed = tx.apply([
{
op: 'step.upsert',
turnId: 't1',
step: {
kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'completed',
usage: { inputOther: 10, output: 5, inputCacheRead: 3, inputCacheCreation: 2 },
finishReason: 'stop',
timing: { llmFirstTokenLatencyMs: 120 },
},
},
]);
expect(completed.accepted).toHaveLength(1);
const step = tx.getTurn('t1')?.steps[0];
expect(step?.usage?.output).toBe(5);
expect(step?.timing?.llmFirstTokenLatencyMs).toBe(120);
expect(step?.retry).toBeUndefined();
});
it('turn upserts carry durationMs and the terminal error', () => {
const tx = new AgentTranscript('main');
tx.apply([turn1]);
const failed = tx.apply([
{ op: 'turn.upsert', turn: { ...turn1.turn, state: 'failed', durationMs: 1500, error: 'boom' } },
]);
expect(failed.accepted).toHaveLength(1);
const turn = tx.getTurn('t1');
expect(turn?.durationMs).toBe(1500);
expect(turn?.error).toBe('boom');
});
it('tool frames keep streamed inputText and the newest progress update', () => {
const tx = new AgentTranscript('main');
tx.apply(toolFrame('running'));
const streamed = (frame: Partial<ToolCallFrame> & Pick<ToolCallFrame, 'inputText' | 'state'>): TranscriptOperation => ({
op: 'frame.upsert',
turnId: 't1',
stepId: 't1.1',
frame: {
kind: 'tool', frameId: 't1.1.call_1', toolCallId: 'call_1', name: 'Read',
...frame,
},
});
expect(tx.apply([streamed({ inputText: '{"path"', state: 'running' })]).accepted).toHaveLength(1);
tx.apply([streamed({ inputText: '{"path":"/a"}', state: 'running' })]);
tx.apply([
streamed({ inputText: '{"path":"/a"}', state: 'running', input: { path: '/a' } }),
streamed({
inputText: '{"path":"/a"}',
state: 'running',
input: { path: '/a' },
progress: { kind: 'progress', percent: 50 },
}),
]);
const frame = tx.getTurn('t1')?.steps[0]?.frames.find((f) => f.kind === 'tool');
expect(frame?.kind === 'tool' && frame.input).toEqual({ path: '/a' });
expect(frame?.kind === 'tool' && frame.inputText).toBe('{"path":"/a"}');
expect(frame?.kind === 'tool' && frame.progress).toEqual({ kind: 'progress', percent: 50 });
});
it('task upserts carry resultSummary/error/stateReason/usage', () => {
const tx = new AgentTranscript('main');
tx.apply([
{ op: 'task.upsert', task: { taskId: 'task1', kind: 'subagent', state: 'running', detached: false, outputTail: '' } },
]);
const done = tx.apply([
{
op: 'task.upsert',
task: {
taskId: 'task1', kind: 'subagent', state: 'completed', detached: false, outputTail: '',
resultSummary: 'scanned 12 files',
usage: { inputOther: 100, output: 40, inputCacheRead: 10, inputCacheCreation: 5 },
},
},
]);
expect(done.accepted).toHaveLength(1);
const task = tx.getTask('task1');
expect(task?.resultSummary).toBe('scanned 12 files');
expect(task?.usage?.inputOther).toBe(100);
});
it('items.remove clears anchored interactions and their pending entries', () => {
const tx = new AgentTranscript('main');
tx.apply([
turn1,
{
op: 'frame.upsert',
turnId: 't1',
stepId: 't1.1',
frame: {
kind: 'tool',
frameId: 't1.1.call-9',
toolCallId: 'call-9',
name: 'Bash',
state: 'running',
},
},
{
op: 'interaction.upsert',
interaction: {
interactionId: 'appr-9',
interactionKind: 'approval',
toolCallId: 'call-9',
state: 'pending',
},
},
]);
expect(tx.listPendingInteractions()).toEqual(['appr-9']);
tx.apply([{ op: 'items.remove', ids: ['t1'] }]);
expect(tx.getItems()).toHaveLength(0);
expect(tx.getInteraction('appr-9')).toBeUndefined();
expect(tx.listPendingInteractions()).toEqual([]);
});
it('receive() equals full reset seed; snapshot windowing keeps newest turns', () => {
const tx = new AgentTranscript('main');
for (let n = 1; n <= 5; n += 1) {
tx.apply([
{ op: 'marker.upsert', item: { kind: 'marker', markerId: `m${n}`, marker: 'goal' } },
{
op: 'turn.upsert',
turn: { kind: 'turn', turnId: `t${n}`, ordinal: n, state: 'completed', origin: { kind: 'user' } },
},
]);
}
const snapshot = tx.snapshot({ tailTurns: 2 });
expect(snapshot.hasMoreOlder).toBe(true);
expect(snapshot.items.filter((i) => i.kind === 'turn').map((i) => i.kind === 'turn' && i.turnId)).toEqual(['t4', 't5']);
expect(snapshot.items.filter((i) => i.kind === 'marker').length).toBeGreaterThan(0);
const fresh = new AgentTranscript('main');
fresh.receive([{ op: 'reset', agentId: 'main', snapshot }]);
expect(fresh.getItems()).toEqual(snapshot.items);
expect(fresh.hasMoreOlder).toBe(true);
});
it('onChange emits accepted ops once per apply batch', () => {
const tx = new AgentTranscript('main');
const seen: string[] = [];
tx.onChange((event) => {
seen.push(...event.ops.map((op) => op.op));
});
tx.apply([turn1, turn1]);
expect(seen).toEqual(['turn.upsert']);
});
it('task upsert + append keeps output tail globally, detached flips freely', () => {
const tx = new AgentTranscript('main');
tx.apply([
{ op: 'task.upsert', task: { taskId: 'task1', kind: 'shell', state: 'running', detached: false, outputTail: '' } },
{ op: 'append', target: { type: 'task', taskId: 'task1' }, offset: 0, text: 'line1\n' },
{ op: 'task.upsert', task: { taskId: 'task1', kind: 'shell', state: 'running', detached: true, outputTail: 'line1\n' } },
]);
const task = tx.getTask('task1');
expect(task?.detached).toBe(true);
expect(task?.outputTail).toBe('line1\n');
});
it('meta.merge merges goal/modes shallowly', () => {
const tx = new AgentTranscript('main');
tx.apply([
{ op: 'meta.merge', meta: { goal: { objective: 'ship it', status: 'active' } } },
{ op: 'meta.merge', meta: { modes: { plan: { reviewPath: '/p' } } } },
]);
expect(tx.getMeta().goal?.status).toBe('active');
expect(tx.getMeta().modes?.plan?.reviewPath).toBe('/p');
});
it('meta.merge clears a mode badge on null and keeps absent keys', () => {
const tx = new AgentTranscript('main');
tx.apply([{ op: 'meta.merge', meta: { modes: { plan: {}, swarm: {}, tower: {} } } }]);
tx.apply([{ op: 'meta.merge', meta: { modes: { plan: null } } }]);
expect(tx.getMeta().modes).toEqual({ swarm: {}, tower: {} });
tx.apply([{ op: 'meta.merge', meta: { modes: { swarm: null } } }]);
expect(tx.getMeta().modes).toEqual({ tower: {} });
tx.apply([{ op: 'meta.merge', meta: { modes: { tower: null } } }]);
expect(tx.getMeta().modes).toBeUndefined();
});
it('meta.merge shallow-merges the agent status key one level deep', () => {
const tx = new AgentTranscript('main');
tx.apply([
{ op: 'meta.merge', meta: { agent: { model: 'k2', permission: 'auto' } } },
{ op: 'meta.merge', meta: { agent: { contextTokens: 1234 } } },
]);
expect(tx.getMeta().agent).toEqual({ model: 'k2', permission: 'auto', contextTokens: 1234 });
tx.apply([
{ op: 'meta.merge', meta: { agent: { model: 'k3', phase: { kind: 'idle' } } } },
]);
expect(tx.getMeta().agent).toEqual({
model: 'k3',
permission: 'auto',
contextTokens: 1234,
phase: { kind: 'idle' },
});
tx.apply([{ op: 'meta.merge', meta: { activity: 'turn' } }]);
expect(tx.getMeta().agent?.model).toBe('k3');
expect(tx.getMeta().activity).toBe('turn');
});
it('snapshot immutability: later applies do not mutate earlier reads', () => {
const tx = new AgentTranscript('main');
tx.apply(toolFrame('running'));
const before = tx.getItems();
tx.apply(toolFrame('done', 'content'));
const beforeFrame = before[0]?.kind === 'turn' ? before[0].steps[0]?.frames[0] : undefined;
expect(beforeFrame?.kind === 'tool' && beforeFrame.state).toBe('running');
});
it('places anchored standalone items before their following turn, not at the end', () => {
const tx = new AgentTranscript('main');
tx.apply([
{
op: 'turn.upsert',
turn: { kind: 'turn', turnId: 't2', ordinal: 2, state: 'running', origin: { kind: 'user' } },
},
]);
tx.apply([
{
op: 'turn.upsert',
turn: { kind: 'turn', turnId: 't0', ordinal: 0, state: 'completed', origin: { kind: 'user' } },
},
{
op: 'marker.upsert',
item: { kind: 'marker', markerId: 'm1', marker: 'skill' },
beforeTurn: 1,
},
{
op: 'turn.upsert',
turn: { kind: 'turn', turnId: 't1', ordinal: 1, state: 'completed', origin: { kind: 'user' } },
},
{
op: 'taskref.upsert',
item: { kind: 'taskref', refId: 'r1', taskId: 'bash-1' },
beforeTurn: 2,
},
]);
expect(tx.getItems().map(itemLabel)).toEqual(['t0', 'm1', 't1', 'r1', 't2']);
});
it('anchors a standalone item before the very first turn; re-applies stay in place', () => {
const tx = new AgentTranscript('main');
tx.apply([
{
op: 'turn.upsert',
turn: { kind: 'turn', turnId: 't0', ordinal: 0, state: 'completed', origin: { kind: 'user' } },
},
{
op: 'marker.upsert',
item: { kind: 'marker', markerId: 'm0', marker: 'compaction' },
beforeTurn: 0,
},
]);
expect(tx.getItems()[0]?.kind).toBe('marker');
tx.apply([
{
op: 'marker.upsert',
item: { kind: 'marker', markerId: 'm0', marker: 'compaction', payload: { v: 1 } },
beforeTurn: 0,
},
]);
const items = tx.getItems();
expect(items).toHaveLength(2);
expect(items[0]?.kind).toBe('marker');
});
it('appends standalone items without an anchor at the end (live order)', () => {
const tx = new AgentTranscript('main');
tx.apply([turn1, { op: 'marker.upsert', item: { kind: 'marker', markerId: 'm9', marker: 'notice' } }]);
const items = tx.getItems();
expect(items.at(-1)?.kind).toBe('marker');
});
it('re-applies tool frames when metadata-only fields change', () => {
const tx = new AgentTranscript('main');
tx.apply(toolFrame('running'));
const corrected: TranscriptOperation[] = [
turn1,
{
op: 'step.upsert',
turnId: 't1',
step: { kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'running' },
},
{
op: 'frame.upsert',
turnId: 't1',
stepId: 't1.1',
frame: {
kind: 'tool',
frameId: 't1.1.call_1',
toolCallId: 'call_1',
name: 'Read',
state: 'running',
input: { path: '/b' },
} satisfies ToolCallFrame,
},
];
tx.apply(corrected);
const turn = tx.getTurn('t1');
const frame = turn?.steps[0]?.frames.find((f) => f.kind === 'tool');
expect(frame?.kind === 'tool' && frame.input).toEqual({ path: '/b' });
});
});
describe('TranscriptStore', () => {
it('lazily creates agent transcripts and tracks the roster', () => {
const store = new TranscriptStore('s1');
expect(store.getAgent('main')).toBeUndefined();
const tx = store.ensureAgent('main', { agentId: 'main', type: 'main' });
expect(store.getAgent('main')).toBe(tx);
const rosters: number[] = [];
store.onRosterChange((agents) => rosters.push(agents.length));
store.ensureAgent('sub-1', { agentId: 'sub-1', type: 'sub', parentAgentId: 'main' });
store.removeAgent('sub-1');
expect(rosters).toEqual([2, 1]);
expect(store.agents().map((a) => a.agentId)).toEqual(['main']);
});
it('markDisposed stamps disposedAt on the existing descriptor only', () => {
const store = new TranscriptStore('s1');
store.ensureAgent('main', { agentId: 'main', type: 'main' });
store.markDisposed('ghost', '2026-07-20T00:00:00.000Z');
expect(store.agents().map((a) => a.agentId)).toEqual(['main']);
const rosters: Array<readonly string[]> = [];
store.onRosterChange((agents) => rosters.push(agents.map((a) => a.agentId)));
store.markDisposed('main', '2026-07-20T01:00:00.000Z');
expect(rosters).toEqual([['main']]);
expect(store.agents()[0]).toMatchObject({
agentId: 'main',
type: 'main',
disposedAt: '2026-07-20T01:00:00.000Z',
});
store.markDisposed('main', '2026-07-20T02:00:00.000Z');
expect(store.agents()[0]?.disposedAt).toBe('2026-07-20T01:00:00.000Z');
expect(rosters).toHaveLength(1);
});
});