mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-11-01 06:20:30 +00:00
* feat(kap-server): push approvals and questions as interaction events
Replace event.approval.* / event.question.* with a single volatile
interaction event carrying the draft Interaction shape. Every subscriber
of a session receives it, including interactions raised by sub-agents
whose transcripts are subscribed. After each subscribe ack the server
sends the session's pending interactions to that connection, so clients
rebuild their list on (re)subscribe instead of relying on journal replay.
Map engine-cancelled questions to cancelled instead of answered.
* docs: describe the interaction event in the server API reference
* chore: add changeset for the interaction event
* feat(kap-server): flatten interaction frames and unify the response route
Send `interaction` as a flat, unsequenced frame `{ type, session_id, agent_id,
event_created_at, interaction_id, kind, status, ... }` outside the event
envelope, add approval `expires_at`, and register the frame in the WS schema
and AsyncAPI document.
Replace `POST /sessions/{sid}/approvals/{id}`, `POST /sessions/{sid}/questions/{id}`
and `:dismiss` with `POST /sessions/{sid}/interactions/{id}`, whose body is
discriminated by `kind` and whose result is `{ outcome, ended_at }`; dismissing a
question now succeeds with code 0. Error codes become 40404 (interaction not
found), 40902 (interaction ended) and 40001 (kind mismatch).
* chore: update the interaction changeset for the unified response route
* feat(kap-server): remove the pending approval and question list routes
Pending interactions now reach clients only through the interaction frames
sent after each subscribe ack, so drop GET /sessions/{sid}/approvals and
GET /sessions/{sid}/questions with their schemas, docs and harness helpers.
The wire helpers still used by the WS broadcaster and the snapshot route move
to protocol/interaction-wire.ts.
* chore: note the removed list routes in the interaction changeset
* fix(transcript): require the engine cancellation shape for cancelled interactions
* refactor(kap-server): derive interaction outcomes in one place
2682 lines
108 KiB
TypeScript
2682 lines
108 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { filterOpsForGrade, isAppendOnly, redactSnapshotForGrade } from '#/granularity/filterOps';
|
|
import { detachGrades, gradeFor, needsResetOnTransition } from '#/granularity/grade';
|
|
import { paginateTurns } from '#/pagination/paginate';
|
|
import { ViewRegistry } from '#/view/registry';
|
|
import { projectTranscriptUserOrigin } from '#/contract/origin';
|
|
import { groupMessagesIntoSnapshot, type HistoryContentPart } from '#/history/groupTurns';
|
|
import { foldWireRecordFacts, type HistoryWireRecord } from '#/history/foldFacts';
|
|
import {
|
|
transcriptOperationSchema,
|
|
transcriptQuerySchema,
|
|
transcriptResponseSchema,
|
|
transcriptGradeSpecSchema,
|
|
transcriptUserOriginSchema,
|
|
} from '#/contract/schema';
|
|
import type { TranscriptItem } from '#/model/item';
|
|
import type { AgentTranscriptSnapshot, TranscriptOperation } from '#/ops/operation';
|
|
|
|
const idLabel = (i: TranscriptItem): string =>
|
|
i.kind === 'turn' ? i.turnId : i.kind === 'marker' ? i.markerId : i.refId;
|
|
|
|
describe('client metadata in transcript user origins', () => {
|
|
it('retains user-invoked single skill frame metadata without exposing model-triggered activations as user input', () => {
|
|
const origin = { kind: 'skill_activation', trigger: 'user-slash', skillName: 'example-skill', skillArgs: 'args', clientMetadata: [{ display_text: 'Save button' }] };
|
|
expect(transcriptUserOriginSchema.parse(projectTranscriptUserOrigin(origin))).toEqual(origin);
|
|
expect(projectTranscriptUserOrigin({ ...origin, trigger: 'model-tool' })).toBeUndefined();
|
|
});
|
|
|
|
it('rebuilds a user turn payload without server-local paths', () => {
|
|
const clientMetadata = [{ display_text: 'Visible prompt' }];
|
|
const origin = {
|
|
kind: 'user',
|
|
clientMetadata,
|
|
skillActivations: [{ activationId: 'a1', skillName: 'deploy', skillArgs: 'now', skillPath: '/private/deploy/SKILL.md' }],
|
|
attachments: [{ name: 'notes.pdf', mediaType: 'application/pdf', size: 42, path: '/private/notes.pdf' }],
|
|
};
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'rendered skill' }, { type: 'text', text: 'visible prompt' }], toolCalls: [], origin },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'reply' }], toolCalls: [] },
|
|
]);
|
|
const turn = snapshot.items.find((item) => item.kind === 'turn');
|
|
expect(turn?.origin).toEqual({ kind: 'user', payload: { kind: 'user', clientMetadata, skillActivations: [{ skillName: 'deploy', skillArgs: 'now' }] } });
|
|
expect(JSON.stringify(turn)).not.toContain('/private/');
|
|
expect(JSON.stringify(snapshot.attachments)).not.toContain('/private/');
|
|
});
|
|
|
|
it('keeps opening prompt metadata when rebuilding history turns', () => {
|
|
const clientMetadata = [{ kimi_code_composer: { version: 1, doc: { type: 'doc' } } }];
|
|
const origin = { kind: 'user', clientMetadata };
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'visible prompt' }], toolCalls: [], origin },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'reply' }], toolCalls: [] },
|
|
]);
|
|
const turn = snapshot.items.find((item) => item.kind === 'turn');
|
|
expect(turn?.origin).toEqual({ kind: 'user', payload: { kind: 'user', clientMetadata } });
|
|
expect(turn?.prompt).toBe('visible prompt');
|
|
});
|
|
|
|
it('projects and validates independent document snapshots without losing their nested fields', () => {
|
|
const clientMetadata = [
|
|
{ kimi_code_composer: { version: 1, doc: { type: 'doc', content: [{ type: 'paragraph' }] }, captureIds: ['capture-a'] } },
|
|
{ kimi_code_composer: { version: 1, captureIds: ['capture-b'] } },
|
|
];
|
|
const projected = projectTranscriptUserOrigin({ kind: 'user', clientMetadata });
|
|
expect(transcriptUserOriginSchema.parse(projected)).toEqual({ kind: 'user', clientMetadata });
|
|
expect(projectTranscriptUserOrigin({ kind: 'user' })).toStrictEqual({ kind: 'user' });
|
|
expect(projectTranscriptUserOrigin({ kind: 'injection', clientMetadata })).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('user slash skill activations as transcript origins', () => {
|
|
it('projects a user-invoked activation and rejects a model-triggered one', () => {
|
|
const origin = { kind: 'skill_activation', trigger: 'user-slash', skillName: 'example-skill', skillArgs: 'args' };
|
|
expect(transcriptUserOriginSchema.parse(projectTranscriptUserOrigin(origin))).toEqual(origin);
|
|
expect(projectTranscriptUserOrigin({ ...origin, trigger: 'model-tool' })).toBeUndefined();
|
|
expect(projectTranscriptUserOrigin({ ...origin, skillName: '' })).toBeUndefined();
|
|
expect(projectTranscriptUserOrigin({ kind: 'user' })).toStrictEqual({ kind: 'user' });
|
|
});
|
|
});
|
|
|
|
const turnOp = (n: number): TranscriptOperation => ({
|
|
op: 'turn.upsert',
|
|
turn: { kind: 'turn', turnId: `t${n}`, ordinal: n, state: 'running', origin: { kind: 'user' } },
|
|
});
|
|
|
|
const stepOp: TranscriptOperation = {
|
|
op: 'step.upsert',
|
|
turnId: 't1',
|
|
step: { kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'running' },
|
|
};
|
|
|
|
const frameOp: TranscriptOperation = {
|
|
op: 'frame.upsert',
|
|
turnId: 't1',
|
|
stepId: 't1.1',
|
|
frame: { kind: 'text', frameId: 't1.1.f1', role: 'assistant', text: 'full' },
|
|
};
|
|
|
|
const appendOp: TranscriptOperation = {
|
|
op: 'append',
|
|
target: { type: 'frame', turnId: 't1', stepId: 't1.1', frameId: 't1.1.f1' },
|
|
offset: 0,
|
|
text: 'chunk',
|
|
};
|
|
|
|
const promptOp: TranscriptOperation = {
|
|
op: 'prompt.upsert',
|
|
prompt: { promptId: 'p1', status: 'queued', createdAt: '2026-07-22T00:00:00.000Z' },
|
|
};
|
|
|
|
describe('granularity', () => {
|
|
const ops: TranscriptOperation[] = [
|
|
turnOp(1),
|
|
stepOp,
|
|
frameOp,
|
|
appendOp,
|
|
promptOp,
|
|
{ op: 'meta.merge', meta: { activity: 'turn' } },
|
|
];
|
|
|
|
it('off admits nothing', () => {
|
|
expect(filterOpsForGrade('off', ops)).toEqual([]);
|
|
});
|
|
|
|
it('turn admits headers and global state only', () => {
|
|
expect(filterOpsForGrade('turn', ops).map((op) => op.op)).toEqual([
|
|
'turn.upsert',
|
|
'prompt.upsert',
|
|
'meta.merge',
|
|
]);
|
|
});
|
|
|
|
it('block admits step/frame upserts but no appends', () => {
|
|
expect(filterOpsForGrade('block', ops).map((op) => op.op)).toEqual([
|
|
'turn.upsert',
|
|
'step.upsert',
|
|
'frame.upsert',
|
|
'prompt.upsert',
|
|
'meta.merge',
|
|
]);
|
|
});
|
|
|
|
it('delta admits everything', () => {
|
|
expect(filterOpsForGrade('delta', ops)).toHaveLength(ops.length);
|
|
});
|
|
|
|
it('gradeFor resolves agent override over wildcard default', () => {
|
|
const spec = { '*': 'turn', main: 'delta' } as const;
|
|
expect(gradeFor(spec, 'main')).toBe('delta');
|
|
expect(gradeFor(spec, 'sub-1')).toBe('turn');
|
|
expect(gradeFor(undefined, 'main')).toBe('off');
|
|
});
|
|
|
|
it('upgrade needs reset, downgrade does not', () => {
|
|
expect(needsResetOnTransition('turn', 'delta')).toBe(true);
|
|
expect(needsResetOnTransition('delta', 'turn')).toBe(false);
|
|
});
|
|
|
|
it('detachGrades writes explicit off so a wildcard default cannot resurrect the agent', () => {
|
|
expect(detachGrades({ '*': 'delta' }, ['main'])).toEqual({ '*': 'delta', main: 'off' });
|
|
expect(detachGrades({ '*': 'delta', main: 'turn' }, ['main'])).toEqual({
|
|
'*': 'delta',
|
|
main: 'off',
|
|
});
|
|
});
|
|
|
|
it('detachGrades deletes a listed wildcard entry', () => {
|
|
expect(detachGrades({ '*': 'delta', main: 'turn' }, ['*'])).toEqual({ main: 'turn' });
|
|
});
|
|
|
|
it('detachGrades collapses an all-off spec to undefined', () => {
|
|
expect(detachGrades({ main: 'delta' }, ['main'])).toBeUndefined();
|
|
expect(detachGrades({ '*': 'delta', main: 'off' }, ['*'])).toBeUndefined();
|
|
expect(detachGrades(undefined, ['main'])).toBeUndefined();
|
|
});
|
|
|
|
it('append-only batches are volatile-safe', () => {
|
|
expect(isAppendOnly([appendOp])).toBe(true);
|
|
expect(isAppendOnly([appendOp, frameOp])).toBe(false);
|
|
});
|
|
|
|
it('redactSnapshotForGrade strips step detail below block, keeps it at block+', () => {
|
|
const snapshot: AgentTranscriptSnapshot = {
|
|
items: [
|
|
{
|
|
kind: 'turn',
|
|
turnId: 't1',
|
|
ordinal: 1,
|
|
state: 'completed',
|
|
origin: { kind: 'user' },
|
|
prompt: 'hi',
|
|
steps: [
|
|
{
|
|
kind: 'step',
|
|
stepId: 't1.1',
|
|
turnId: 't1',
|
|
ordinal: 1,
|
|
state: 'completed',
|
|
frames: [{ kind: 'text', frameId: 't1.1.f1', role: 'assistant', text: 'body' }],
|
|
},
|
|
],
|
|
},
|
|
{ kind: 'marker', markerId: 'm1', marker: 'skill' },
|
|
],
|
|
tasks: [],
|
|
interactions: [
|
|
{
|
|
interactionId: 'appr-1',
|
|
interactionKind: 'approval' as const,
|
|
toolCallId: 'c1',
|
|
state: 'pending' as const,
|
|
},
|
|
],
|
|
attachments: [
|
|
{ attachmentId: 'att_1', mediaType: 'image/png', source: { kind: 'url' as const, url: 'https://example.com/a.png' } },
|
|
],
|
|
todos: [{ todoId: 'todo', items: [{ title: 'write tests', status: 'in_progress' as const }] }],
|
|
prompts: [{ promptId: 'p1', status: 'running' as const, createdAt: '2026-07-22T00:00:00.000Z' }],
|
|
meta: {},
|
|
};
|
|
const turnGrade = redactSnapshotForGrade('turn', snapshot);
|
|
expect(turnGrade.interactions).toHaveLength(1);
|
|
expect(turnGrade.attachments).toHaveLength(1);
|
|
expect(turnGrade.todos).toHaveLength(1);
|
|
expect(turnGrade.prompts).toHaveLength(1);
|
|
const turn = turnGrade.items[0];
|
|
expect(turn?.kind === 'turn' && turn.steps).toEqual([]);
|
|
expect(turn?.kind === 'turn' && turn.prompt).toBe('hi');
|
|
expect(turnGrade.items[1]?.kind).toBe('marker');
|
|
expect(redactSnapshotForGrade('block', snapshot)).toBe(snapshot);
|
|
expect(redactSnapshotForGrade('delta', snapshot)).toBe(snapshot);
|
|
});
|
|
});
|
|
|
|
describe('paginateTurns', () => {
|
|
const items: TranscriptItem[] = [
|
|
{ kind: 'marker', markerId: 'm0', marker: 'goal' },
|
|
...[1, 2, 3, 4, 5].flatMap((n): TranscriptItem[] => [
|
|
{
|
|
kind: 'turn',
|
|
turnId: `t${n}`,
|
|
ordinal: n,
|
|
state: 'completed',
|
|
origin: { kind: 'user' },
|
|
steps: [],
|
|
},
|
|
{ kind: 'marker', markerId: `m${n}`, marker: 'skill' },
|
|
]),
|
|
];
|
|
|
|
it('default page is the newest N turns with trailing segment items', () => {
|
|
const page = paginateTurns(items, { pageSize: 2 });
|
|
expect(page.items.map(idLabel)).toEqual(['t4', 'm4', 't5', 'm5']);
|
|
expect(page.hasMore).toBe(true);
|
|
});
|
|
|
|
it('before_turn pages toward older turns; head marker rides the oldest segment', () => {
|
|
const page = paginateTurns(items, { beforeTurn: 't4', pageSize: 2 });
|
|
expect(page.items.map(idLabel)).toEqual(['t2', 'm2', 't3', 'm3']);
|
|
expect(page.hasMore).toBe(true);
|
|
|
|
const oldest = paginateTurns(items, { beforeTurn: 't2', pageSize: 5 });
|
|
expect(oldest.items[0]).toEqual({ kind: 'marker', markerId: 'm0', marker: 'goal' });
|
|
expect(oldest.hasMore).toBe(false);
|
|
});
|
|
|
|
it('after_turn pages toward newer turns without the head unit', () => {
|
|
const page = paginateTurns(items, { afterTurn: 't3', pageSize: 2 });
|
|
expect(page.items.map(idLabel)).toEqual(['t4', 'm4', 't5', 'm5']);
|
|
expect(page.hasMore).toBe(false);
|
|
});
|
|
|
|
it('keeps head non-turn items with the newest page when turns exactly fill it', () => {
|
|
const page = paginateTurns(items, { pageSize: 5 });
|
|
expect(page.items[0]).toEqual({ kind: 'marker', markerId: 'm0', marker: 'goal' });
|
|
expect(page.items.map(idLabel)).toEqual(['m0', 't1', 'm1', 't2', 'm2', 't3', 'm3', 't4', 'm4', 't5', 'm5']);
|
|
expect(page.hasMore).toBe(false);
|
|
});
|
|
|
|
it('returns a marker-only timeline as one page with nothing older', () => {
|
|
const only = paginateTurns([{ kind: 'marker', markerId: 'm0', marker: 'goal' }], { pageSize: 3 });
|
|
expect(only.items.map(idLabel)).toEqual(['m0']);
|
|
expect(only.hasMore).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ViewRegistry', () => {
|
|
it('dispatches on view ?? name, origin.kind and marker keys', () => {
|
|
const registry = new ViewRegistry<string>({ fallbackTool: 'generic' });
|
|
registry.registerTool('read', 'readRenderer');
|
|
registry.registerTool('swarm', 'swarmRenderer');
|
|
registry.registerInput('cron', 'cronInput');
|
|
registry.registerMarker('goal', 'goalMarker');
|
|
|
|
expect(
|
|
registry.resolveTool({ kind: 'tool', frameId: 'f', toolCallId: 'c1', name: 'Read', state: 'done' }),
|
|
).toBe('readRenderer');
|
|
expect(
|
|
registry.resolveTool({ kind: 'tool', frameId: 'f', toolCallId: 'c2', name: 'AgentSwarm', view: 'swarm', state: 'running' }),
|
|
).toBe('swarmRenderer');
|
|
expect(
|
|
registry.resolveTool({ kind: 'tool', frameId: 'f', toolCallId: 'c3', name: 'Bash', state: 'running' }),
|
|
).toBe('generic');
|
|
expect(registry.resolveInput({ kind: 'cron' })).toBe('cronInput');
|
|
expect(registry.resolveInput({ kind: 'user' })).toBeUndefined();
|
|
expect(registry.resolveMarker('goal')).toBe('goalMarker');
|
|
});
|
|
});
|
|
|
|
describe('contract schemas', () => {
|
|
it('roundtrips every op kind', () => {
|
|
const ops: TranscriptOperation[] = [
|
|
{ op: 'reset', agentId: 'main', snapshot: { items: [], tasks: [], interactions: [], attachments: [], todos: [], prompts: [], meta: {}, hasMoreOlder: true } },
|
|
turnOp(1),
|
|
stepOp,
|
|
frameOp,
|
|
appendOp,
|
|
{ op: 'marker.upsert', item: { kind: 'marker', markerId: 'm1', marker: 'goal' } },
|
|
{ op: 'taskref.upsert', item: { kind: 'taskref', refId: 'r1', taskId: 'task1' } },
|
|
{ op: 'task.upsert', task: { taskId: 'task1', kind: 'shell', state: 'running', detached: false, outputTail: '' } },
|
|
{
|
|
op: 'interaction.upsert',
|
|
interaction: { interactionId: 'appr-1', interactionKind: 'approval', toolCallId: 'c1', state: 'pending' },
|
|
},
|
|
{
|
|
op: 'attachment.upsert',
|
|
attachment: { attachmentId: 'att_1', mediaType: 'image/png', source: { kind: 'file', fileId: 'f1' } },
|
|
},
|
|
{ op: 'todo.upsert', todo: { todoId: 'todo', items: [{ title: 'x', status: 'done' }] } },
|
|
promptOp,
|
|
{ op: 'meta.merge', meta: { goal: { objective: 'x', status: 'active' } } },
|
|
{ op: 'items.remove', ids: ['t1'] },
|
|
];
|
|
for (const op of ops) {
|
|
expect(transcriptOperationSchema.parse(op)).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('roundtrips ops carrying the extended wire detail', () => {
|
|
const usage = { inputOther: 10, output: 5, inputCacheRead: 3, inputCacheCreation: 2 };
|
|
const ops: TranscriptOperation[] = [
|
|
{
|
|
op: 'reset',
|
|
agentId: 'main',
|
|
snapshot: {
|
|
items: [],
|
|
tasks: [],
|
|
interactions: [],
|
|
attachments: [],
|
|
todos: [],
|
|
prompts: [
|
|
{
|
|
promptId: 'p1',
|
|
status: 'completed',
|
|
userMessageId: 'u1',
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
createdAt: '2026-07-22T00:00:00.000Z',
|
|
finishedAt: '2026-07-22T00:01:00.000Z',
|
|
steeredAt: '2026-07-22T00:00:30.000Z',
|
|
},
|
|
],
|
|
meta: {
|
|
agent: {
|
|
model: 'k2',
|
|
thinkingEffort: 'high',
|
|
usage: { byModel: { k2: usage }, currentTurn: usage, total: usage },
|
|
contextTokens: 1234,
|
|
maxContextTokens: 128000,
|
|
contextUsage: 0.01,
|
|
permission: 'auto',
|
|
phase: { kind: 'retrying', turnId: 1, step: 1, stepId: 't1.1', failedAttempt: 1, nextAttempt: 2, maxAttempts: 3, delayMs: 500, since: 1000 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
op: 'turn.upsert',
|
|
turn: {
|
|
kind: 'turn', turnId: 't1', triggerPromptId: 'prompt-1', ordinal: 1, state: 'failed', origin: { kind: 'user' },
|
|
usage: { inputTokens: 12, outputTokens: 5, cachedTokens: 3 },
|
|
durationMs: 1500,
|
|
error: 'boom',
|
|
},
|
|
},
|
|
{
|
|
op: 'step.upsert',
|
|
turnId: 't1',
|
|
step: {
|
|
kind: 'step', stepId: 't1.1', turnId: 't1', ordinal: 1, state: 'interrupted',
|
|
usage,
|
|
finishReason: 'stop',
|
|
llmTiming: {
|
|
llmFirstTokenLatencyMs: 120,
|
|
llmStreamDurationMs: 900,
|
|
llmRequestBuildMs: 5,
|
|
llmServerFirstTokenMs: 110,
|
|
llmServerDecodeMs: 700,
|
|
llmClientConsumeMs: 950,
|
|
llmClientBlockedMs: 25,
|
|
},
|
|
retry: { failedAttempt: 1, nextAttempt: 2, maxAttempts: 3, delayMs: 500, errorName: 'RateLimit', errorMessage: 'slow down', statusCode: 429 },
|
|
endReason: 'aborted',
|
|
endMessage: 'user pressed escape',
|
|
},
|
|
},
|
|
{
|
|
op: 'frame.upsert',
|
|
turnId: 't1',
|
|
stepId: 't1.1',
|
|
frame: {
|
|
kind: 'tool', frameId: 't1.1.c1', toolCallId: 'c1', name: 'Bash', state: 'running',
|
|
inputText: '{"command":"ls',
|
|
progress: { kind: 'progress', text: 'half', percent: 50, customKind: 'bar', customData: { x: 1 } },
|
|
},
|
|
},
|
|
{
|
|
op: 'task.upsert',
|
|
task: {
|
|
taskId: 'task1', kind: 'subagent', state: 'completed', detached: false, outputTail: '',
|
|
resultSummary: 'scanned 12 files',
|
|
error: 'partial failure',
|
|
stateReason: 'waiting for input',
|
|
usage,
|
|
},
|
|
},
|
|
{
|
|
op: 'meta.merge',
|
|
meta: { agent: { model: 'k2', phase: { kind: 'ended', turnId: 1, reason: 'completed', durationMs: 1500, at: 2000 } } },
|
|
},
|
|
];
|
|
for (const op of ops) {
|
|
expect(transcriptOperationSchema.parse(op)).toEqual(op);
|
|
}
|
|
});
|
|
|
|
it('accepts provenance only on user text frames', () => {
|
|
const base = {
|
|
op: 'frame.upsert',
|
|
turnId: 't1',
|
|
stepId: 't1.1',
|
|
} as const;
|
|
expect(transcriptOperationSchema.safeParse({
|
|
...base,
|
|
frame: {
|
|
kind: 'text',
|
|
frameId: 't1.1.f1',
|
|
role: 'user',
|
|
text: 'steered in',
|
|
origin: { kind: 'user', skillActivations: [{ skillName: 'review', skillArgs: 'strict' }] },
|
|
},
|
|
}).success).toBe(true);
|
|
expect(transcriptOperationSchema.safeParse({
|
|
...base,
|
|
frame: {
|
|
kind: 'text',
|
|
frameId: 't1.1.f2',
|
|
role: 'assistant',
|
|
text: 'reply',
|
|
origin: { kind: 'user' },
|
|
},
|
|
}).success).toBe(false);
|
|
});
|
|
|
|
it('rejects mutually exclusive cursors and bad grades', () => {
|
|
expect(() => transcriptGradeSpecSchema.parse({ '*': 'stream' })).toThrow();
|
|
const ok = transcriptResponseSchema.safeParse({
|
|
agent_id: 'main',
|
|
items: [],
|
|
has_more: false,
|
|
tasks: [],
|
|
interactions: [],
|
|
attachments: [],
|
|
todos: [],
|
|
meta: {},
|
|
agents: [{ agentId: 'main', type: 'main' }],
|
|
pending_interactions: [],
|
|
});
|
|
expect(ok.success).toBe(true);
|
|
});
|
|
|
|
it('rejects path-hostile agent ids in the transcript query', () => {
|
|
const base = { agent_id: 'main', before_turn: undefined, after_turn: undefined, page_size: undefined };
|
|
expect(transcriptQuerySchema.safeParse({ ...base, agent_id: 'sub-1' }).success).toBe(true);
|
|
expect(transcriptQuerySchema.safeParse({ ...base, agent_id: '01HF7YAT31J7SMRT1QXGJWKR8D' }).success).toBe(true);
|
|
for (const hostile of ['../main', '..\\main', '..', 'a/b', 'a\\b', '.', 'a\0b', 'x'.repeat(200)]) {
|
|
expect(transcriptQuerySchema.safeParse({ ...base, agent_id: hostile }).success).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('groupMessagesIntoSnapshot (cold path)', () => {
|
|
const snapshotOf = (...parts: HistoryContentPart[]): AgentTranscriptSnapshot =>
|
|
groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: parts, toolCalls: [], origin: { kind: 'user' } },
|
|
]);
|
|
|
|
it('groups flat messages into turns with folded tool results', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'system', content: [{ type: 'text', text: 'sys' }] },
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nnoise\n</hook_result>',
|
|
meta: { contentType: 'text/xml', source: 'user prompt submit hook' },
|
|
},
|
|
{ type: 'text', text: 'hello' },
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'user' },
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'think', think: 'hmm' }, { type: 'text', text: 'checking' }],
|
|
toolCalls: [{ id: 'c1', name: 'Read', arguments: '{"path":"/a"}' }],
|
|
},
|
|
{ role: 'tool', content: [{ type: 'text', text: 'file body' }], toolCallId: 'c1', toolCalls: [] },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'done' }],
|
|
toolCalls: [],
|
|
},
|
|
{ role: 'user', content: [{ type: 'text', text: 'next' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'summary of old' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'compaction_summary' },
|
|
},
|
|
{ role: 'user', content: [{ type: 'text', text: 'after' }], toolCalls: [], origin: { kind: 'user' } },
|
|
]);
|
|
|
|
const kinds = snapshot.items.map((i) => i.kind);
|
|
expect(kinds).toEqual(['marker', 'turn', 'turn', 'marker', 'turn']);
|
|
const hookMarker = snapshot.items[0];
|
|
if (hookMarker?.kind !== 'marker') throw new Error('expected marker');
|
|
expect(hookMarker.marker).toBe('hook');
|
|
expect(hookMarker.payload).toEqual({ hookEvent: 'UserPromptSubmit', content: 'noise' });
|
|
const firstTurn = snapshot.items[1];
|
|
if (firstTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(firstTurn.prompt).toBe('hello');
|
|
expect(firstTurn.steps).toHaveLength(2);
|
|
const tool = firstTurn.steps[0]?.frames.find((f) => f.kind === 'tool');
|
|
expect(tool?.kind === 'tool' && tool.output).toBe('file body');
|
|
expect(tool?.kind === 'tool' && tool.input).toEqual({ path: '/a' });
|
|
const marker = snapshot.items[3];
|
|
expect(marker?.kind === 'marker' && marker.marker).toBe('compaction');
|
|
});
|
|
|
|
it('carries assistant message usage and timing onto the step', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'done' }],
|
|
toolCalls: [],
|
|
usage: { inputOther: 10, output: 20, inputCacheRead: 30, inputCacheCreation: 40 },
|
|
llmTiming: { llmFirstTokenLatencyMs: 800, llmStreamDurationMs: 5000 },
|
|
},
|
|
]);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps[0]?.usage).toEqual({
|
|
inputOther: 10,
|
|
output: 20,
|
|
inputCacheRead: 30,
|
|
inputCacheCreation: 40,
|
|
});
|
|
expect(turn.steps[0]?.llmTiming).toEqual({
|
|
llmFirstTokenLatencyMs: 800,
|
|
llmStreamDurationMs: 5000,
|
|
});
|
|
});
|
|
|
|
it('folds task-notification user messages into the current turn instead of opening their own', () => {
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'run it' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'starting' }],
|
|
toolCalls: [{ id: 'c1', name: 'Bash', arguments: '{"command":"ls"}' }],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [{
|
|
type: 'text',
|
|
text: '<notification id="task:task-9:completed" category="task" type="task.completed" source_kind="background_task" source_id="task-9">\nTitle: Background agent completed\nSeverity: info\ninspect done.\n</notification>',
|
|
}],
|
|
toolCalls: [],
|
|
origin: { kind: 'task', taskId: 'task-9' } as { kind: string },
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: '<notification id="task:task-8:completed"></notification>' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'task', taskId: 'task-8' } as { kind: string },
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'continuing' }],
|
|
toolCalls: [],
|
|
},
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
|
|
const turns = snapshot.items.filter((i) => i.kind === 'turn');
|
|
expect(turns).toHaveLength(1);
|
|
const turn = turns[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const userFrames = turn.steps
|
|
.flatMap((step) => step.frames)
|
|
.filter((f) => f.kind === 'text' && f.role === 'user');
|
|
expect(userFrames.map((f) => f.kind === 'text' && f.taskId)).toEqual(['task-9', 'task-8']);
|
|
expect(userFrames[0]).toMatchObject({ text: 'Background agent completed\ninspect done.' });
|
|
const assistantTexts = turn.steps
|
|
.flatMap((step) => step.frames)
|
|
.filter((f) => f.kind === 'text' && f.role === 'assistant')
|
|
.map((f) => f.kind === 'text' && f.text);
|
|
expect(assistantTexts).toEqual(['starting', 'continuing']);
|
|
expect(turn.steps).toHaveLength(2);
|
|
expect(turn.steps[1]?.frames.map((f) => f.kind === 'text' && f.role)).toEqual([
|
|
'user',
|
|
'user',
|
|
'assistant',
|
|
]);
|
|
});
|
|
|
|
it('folds a user message whose content matches a turn.steer record into the current turn as a user frame', () => {
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{ id: 'm-steer', role: 'user', content: [{ type: 'text', text: 'steered in' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] },
|
|
],
|
|
{ steeredByMessageId: new Map([['m-steer', ['p2']]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((i) => i.kind)).toEqual(['turn']);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps).toHaveLength(2);
|
|
expect(turn.steps[1]?.frames[0]).toMatchObject({
|
|
kind: 'text',
|
|
role: 'user',
|
|
text: 'steered in',
|
|
promptIds: ['p2'],
|
|
});
|
|
});
|
|
|
|
it('keeps a trailing steered message visible by appending it to the last step', () => {
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nnoise\n</hook_result>',
|
|
meta: { contentType: 'text/xml', source: 'user prompt submit hook' },
|
|
},
|
|
{ type: 'text', text: 'steered in' },
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'user' },
|
|
},
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((i) => i.kind)).toEqual(['turn', 'marker']);
|
|
const hookMarker = snapshot.items[1];
|
|
if (hookMarker?.kind !== 'marker') throw new Error('expected marker');
|
|
expect(hookMarker.marker).toBe('hook');
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const lastStep = turn.steps.at(-1);
|
|
expect(lastStep?.frames.at(-1)).toMatchObject({
|
|
kind: 'text',
|
|
role: 'user',
|
|
text: 'steered in',
|
|
});
|
|
});
|
|
|
|
it('flushes a pending steer into the closing turn when a new turn opens before any reply', () => {
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{ role: 'user', content: [{ type: 'text', text: 'steered in' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'user', content: [{ type: 'text', text: 'next question' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] },
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) },
|
|
);
|
|
|
|
const turns = snapshot.items.filter((i) => i.kind === 'turn');
|
|
expect(turns).toHaveLength(2);
|
|
const first = turns[0];
|
|
if (first?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(first.steps.at(-1)?.frames.at(-1)).toMatchObject({
|
|
kind: 'text',
|
|
role: 'user',
|
|
text: 'steered in',
|
|
});
|
|
const second = turns[1];
|
|
if (second?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(second.prompt).toBe('next question');
|
|
});
|
|
|
|
it('still opens its own turn for a mid-conversation user message unknown to the steer map', () => {
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{ role: 'user', content: [{ type: 'text', text: 'plain follow-up' }], toolCalls: [], origin: { kind: 'user' } },
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((i) => i.kind)).toEqual(['turn', 'turn']);
|
|
});
|
|
|
|
it('stops folded notification text before child output blocks', () => {
|
|
const xml = [
|
|
'<notification id="task:task-9:completed" category="task" type="task.completed" source_kind="background_task" source_id="task-9">',
|
|
'Title: Background agent completed',
|
|
'Severity: info',
|
|
'inspect done.',
|
|
'<output-file>/tmp/out.log</output-file>',
|
|
'/tmp/out.log',
|
|
'full output here',
|
|
'</notification>',
|
|
].join('\n');
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] },
|
|
{ role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user');
|
|
expect(frame).toMatchObject({ text: 'Background agent completed\ninspect done.' });
|
|
});
|
|
|
|
it('stops folded notification text before an inline answer block', () => {
|
|
const xml = [
|
|
'<notification id="task:question-1:completed" category="task" type="task.completed" source_kind="background_task" source_id="question-1">',
|
|
'Title: Background question answered',
|
|
'Severity: info',
|
|
'The user answered "Which database?".',
|
|
'<answer>',
|
|
'{"answers":{"Which database?":"Postgres"}}',
|
|
'</answer>',
|
|
'</notification>',
|
|
].join('\n');
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] },
|
|
{ role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'question-1' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user');
|
|
expect(frame).toMatchObject({ text: 'Background question answered\nThe user answered "Which database?".' });
|
|
});
|
|
|
|
it('buffers a folded notification into the next step, or drops it when no turn is open yet', () => {
|
|
const xml = [
|
|
'<notification id="task:task-9:completed" category="task" type="task.completed" source_kind="background_task" source_id="task-9">',
|
|
'Title: Background agent completed',
|
|
'Severity: info',
|
|
'early done.',
|
|
'</notification>',
|
|
].join('\n');
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] },
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps).toHaveLength(1);
|
|
expect(turn.steps[0]?.frames.map((f) => f.kind === 'text' && f.role)).toEqual([
|
|
'user',
|
|
'assistant',
|
|
]);
|
|
expect(turn.steps[0]?.frames[0]).toMatchObject({
|
|
text: 'Background agent completed\nearly done.',
|
|
taskId: 'task-9',
|
|
});
|
|
|
|
const orphan = groupMessagesIntoSnapshot(
|
|
[
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: '<notification id="n1"></notification>' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'task', taskId: 'task-9' } as { kind: string },
|
|
},
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
expect(orphan.items).toHaveLength(0);
|
|
});
|
|
|
|
it('keeps body lines that start with an angle bracket but are not output blocks', () => {
|
|
const xml = [
|
|
'<notification id="task:task-9:completed" category="task" type="task.completed" source_kind="background_task" source_id="task-9">',
|
|
'Title: Background agent completed',
|
|
'Severity: info',
|
|
'first line.',
|
|
'<div>pasted markup</div>',
|
|
'last line.',
|
|
'</notification>',
|
|
].join('\n');
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] },
|
|
{ role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user');
|
|
expect(frame).toMatchObject({
|
|
text: 'Background agent completed\nfirst line.\n<div>pasted markup</div>\nlast line.',
|
|
});
|
|
});
|
|
|
|
it('parses only the leading header lines, keeping later Title-like lines as body', () => {
|
|
const xml = [
|
|
'<notification id="task:task-9:completed" category="task" type="task.completed" source_kind="background_task" source_id="task-9">',
|
|
'Title: Background agent completed',
|
|
'Severity: info',
|
|
'first line.',
|
|
'Title: details',
|
|
'last line.',
|
|
'</notification>',
|
|
].join('\n');
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] },
|
|
{ role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user');
|
|
expect(frame).toMatchObject({
|
|
text: 'Background agent completed\nfirst line.\nTitle: details\nlast line.',
|
|
});
|
|
});
|
|
|
|
it('keeps a body whose first line starts with Severity:', () => {
|
|
const xml = [
|
|
'<notification id="task:task-9:completed" category="task" type="task.completed" source_kind="background_task" source_id="task-9">',
|
|
'Title: Background agent completed',
|
|
'Severity: info',
|
|
'Severity: this line is the task description, not a header',
|
|
'last line.',
|
|
'</notification>',
|
|
].join('\n');
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] },
|
|
{ role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set() },
|
|
);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user');
|
|
expect(frame).toMatchObject({
|
|
text: 'Background agent completed\nSeverity: this line is the task description, not a header\nlast line.',
|
|
});
|
|
});
|
|
|
|
it('expands a bundled prompt into per-skill markers and a caller-text turn', () => {
|
|
const origin = {
|
|
kind: 'user',
|
|
skillActivations: [
|
|
{ activationId: 'act-1', skillName: 'review' },
|
|
{ activationId: 'act-2', skillName: 'security', skillArgs: 'src/app.ts' },
|
|
],
|
|
} as { kind: string };
|
|
const variants: readonly (readonly HistoryContentPart[])[] = [
|
|
[
|
|
{ type: 'text', text: 'rendered review block' },
|
|
{ type: 'text', text: 'rendered security block' },
|
|
{ type: 'text', text: 'please /skill:review and /skill:security' },
|
|
],
|
|
[
|
|
{
|
|
type: 'text',
|
|
text: 'rendered review block',
|
|
meta: { source: 'skill activation', activationId: 'act-1' },
|
|
},
|
|
{
|
|
type: 'text',
|
|
text: 'rendered security block',
|
|
meta: { source: 'skill activation', activationId: 'act-2' },
|
|
},
|
|
{ type: 'text', text: 'please /skill:review and /skill:security' },
|
|
],
|
|
];
|
|
for (const content of variants) {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content, toolCalls: [], origin },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
]);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['marker', 'marker', 'turn']);
|
|
const first = snapshot.items[0];
|
|
expect(first?.kind === 'marker' && first.marker).toBe('skill');
|
|
expect(first?.kind === 'marker' && first.payload).toMatchObject({
|
|
text: 'rendered review block',
|
|
origin: { kind: 'skill_activation', trigger: 'user-slash', skillName: 'review' },
|
|
});
|
|
const second = snapshot.items[1];
|
|
expect(second?.kind === 'marker' && second.payload).toMatchObject({
|
|
text: 'rendered security block',
|
|
origin: { skillName: 'security', skillArgs: 'src/app.ts' },
|
|
});
|
|
const turn = snapshot.items[2];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.prompt).toBe('please /skill:review and /skill:security');
|
|
expect(turn.steps).toHaveLength(1);
|
|
}
|
|
});
|
|
|
|
it('maps media parts on the opening user message to attachment entities, dropping base64 bytes', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'what is this? [Image #1]' },
|
|
{ type: 'image', source: { kind: 'base64', media_type: 'image/png', data: 'aGVsbG8=' }, name: 'inline.png' },
|
|
{ type: 'image', source: { kind: 'url', url: 'https://example.com/pic.png' }, name: 'remote.png' },
|
|
{ type: 'image', source: { kind: 'file', file_id: 'file_8' }, name: 'stored.png' },
|
|
{ type: 'file', file_id: 'file_9', name: 'notes.txt', media_type: 'text/plain', size: 128 },
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'user' },
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a screenshot' }], toolCalls: [] },
|
|
]);
|
|
|
|
expect(snapshot.attachments).toHaveLength(4);
|
|
expect(snapshot.attachments[0]).toMatchObject({
|
|
attachmentId: 'att_1',
|
|
mediaType: 'image/png',
|
|
name: 'inline.png',
|
|
source: undefined,
|
|
});
|
|
expect(snapshot.attachments[1]).toMatchObject({
|
|
attachmentId: 'att_2',
|
|
name: 'remote.png',
|
|
source: { kind: 'url', url: 'https://example.com/pic.png' },
|
|
});
|
|
expect(snapshot.attachments[2]).toMatchObject({
|
|
attachmentId: 'att_3',
|
|
name: 'stored.png',
|
|
source: { kind: 'file', fileId: 'file_8' },
|
|
});
|
|
expect(snapshot.attachments[3]).toMatchObject({
|
|
attachmentId: 'att_4',
|
|
mediaType: 'text/plain',
|
|
name: 'notes.txt',
|
|
source: { kind: 'file', fileId: 'file_9' },
|
|
});
|
|
const firstTurn = snapshot.items[0];
|
|
if (firstTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(firstTurn.attachmentIds).toEqual(['att_1', 'att_2', 'att_3', 'att_4']);
|
|
});
|
|
|
|
it('folds origin file attachments on the opening user message into path-sourced entities', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Attached file "report.pdf" (application/pdf, 128 bytes): /data/report.pdf — open it with the Read tool',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
origin: {
|
|
kind: 'user',
|
|
attachments: [
|
|
{ name: 'report.pdf', mediaType: 'application/pdf', size: 128, path: '/data/report.pdf' },
|
|
],
|
|
} as { kind: string },
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
]);
|
|
|
|
expect(snapshot.attachments).toEqual([
|
|
{
|
|
attachmentId: 'att_1',
|
|
mediaType: 'application/pdf',
|
|
name: 'report.pdf',
|
|
size: 128,
|
|
},
|
|
]);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.attachmentIds).toEqual(['att_1']);
|
|
});
|
|
|
|
it('folds origin file attachments on a skill activation message into path-sourced entities', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'User activated the skill "update-config".',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
origin: {
|
|
kind: 'skill_activation',
|
|
activationId: 'act_1',
|
|
skillName: 'update-config',
|
|
trigger: 'user-slash',
|
|
attachments: [
|
|
{ name: 'note.txt', mediaType: 'text/plain', size: 21, path: '/data/note.txt' },
|
|
],
|
|
} as { kind: string },
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
|
|
]);
|
|
|
|
expect(snapshot.attachments).toEqual([
|
|
{
|
|
attachmentId: 'att_1',
|
|
mediaType: 'text/plain',
|
|
name: 'note.txt',
|
|
size: 21,
|
|
},
|
|
]);
|
|
const turn = snapshot.items[1];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.attachmentIds).toEqual(['att_1']);
|
|
});
|
|
|
|
it('maps persisted kimi-file media refs to attachments', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'video_url',
|
|
videoUrl: { url: 'kimi-file://file_1', name: 'clip.mp4' },
|
|
} as HistoryContentPart,
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'user' },
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'what is this?' },
|
|
{
|
|
type: 'image_url',
|
|
imageUrl: { url: 'kimi-file://file_2?path=%2Fcache%2Fshot.png', name: 'shot.png' },
|
|
} as HistoryContentPart,
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'user' },
|
|
},
|
|
]);
|
|
|
|
expect(snapshot.attachments).toEqual([
|
|
{
|
|
attachmentId: 'att_1',
|
|
mediaType: 'video/*',
|
|
name: 'clip.mp4',
|
|
source: { kind: 'session_media', fileId: 'file_1' },
|
|
},
|
|
{
|
|
attachmentId: 'att_2',
|
|
mediaType: 'image/*',
|
|
name: 'shot.png',
|
|
source: { kind: 'session_media', fileId: 'file_2' },
|
|
},
|
|
]);
|
|
const videoTurn = snapshot.items[0];
|
|
const imageTurn = snapshot.items[1];
|
|
if (videoTurn?.kind !== 'turn' || imageTurn?.kind !== 'turn') {
|
|
throw new Error('expected turns');
|
|
}
|
|
expect(videoTurn.attachmentIds).toEqual(['att_1']);
|
|
expect(imageTurn.attachmentIds).toEqual(['att_2']);
|
|
expect(videoTurn.prompt).toBe('');
|
|
expect(imageTurn.prompt).toBe('what is this?');
|
|
});
|
|
|
|
it('projects a daemon ref in a user-slash turn-opening input like a plain user turn', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: '/gen-docs ' },
|
|
{
|
|
type: 'image_url',
|
|
imageUrl: { url: 'kimi-file://file_4?path=%2Fcache%2Fshot.png' },
|
|
} as HistoryContentPart,
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'skill_activation', trigger: 'user-slash', skillName: 'gen-docs' } as {
|
|
kind: string;
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(snapshot.attachments).toEqual([
|
|
{
|
|
attachmentId: 'att_1',
|
|
mediaType: 'image/*',
|
|
source: { kind: 'session_media', fileId: 'file_4' },
|
|
},
|
|
]);
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['marker', 'turn']);
|
|
const marker = snapshot.items[0];
|
|
if (marker?.kind !== 'marker') throw new Error('expected marker');
|
|
expect((marker.payload as { text: string }).text).toBe('/gen-docs ');
|
|
const turn = snapshot.items[1];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.prompt).toBe('/gen-docs ');
|
|
expect(turn.attachmentIds).toEqual(['att_1']);
|
|
});
|
|
|
|
it('keeps a kimi-file ref as a nameless attachment and inline tags as user text', () => {
|
|
const snapshot = snapshotOf(
|
|
{ type: 'text', text: 'open <image path="/tmp/other.png"></image> please' },
|
|
{
|
|
type: 'image_url',
|
|
imageUrl: { url: 'kimi-file://file_3' },
|
|
} as HistoryContentPart,
|
|
);
|
|
|
|
expect(snapshot.attachments).toEqual([
|
|
{
|
|
attachmentId: 'att_1',
|
|
mediaType: 'image/*',
|
|
source: { kind: 'session_media', fileId: 'file_3' },
|
|
},
|
|
]);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.attachmentIds).toEqual(['att_1']);
|
|
expect(turn.prompt).toBe('open <image path="/tmp/other.png"></image> please');
|
|
});
|
|
|
|
it('keeps a legacy tag+ref pair as prompt text plus the ref-derived attachment', () => {
|
|
const snapshot = snapshotOf(
|
|
{ type: 'text', text: '<image path="/cache/shot.png"></image>' },
|
|
{
|
|
type: 'image_url',
|
|
imageUrl: { url: 'kimi-file://file_5?path=%2Fcache%2Fshot.png' },
|
|
} as HistoryContentPart,
|
|
);
|
|
|
|
expect(snapshot.attachments).toEqual([
|
|
{
|
|
attachmentId: 'att_1',
|
|
mediaType: 'image/*',
|
|
source: { kind: 'session_media', fileId: 'file_5' },
|
|
},
|
|
]);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.prompt).toBe('<image path="/cache/shot.png"></image>');
|
|
expect(turn.attachmentIds).toEqual(['att_1']);
|
|
});
|
|
|
|
it('keeps standalone <media path> tags as prompt text', () => {
|
|
const snapshot = snapshotOf(
|
|
{ type: 'text', text: '<image path="/cache/shot.png">' },
|
|
{ type: 'text', text: '<image path="/cache/shot.png" content_type="image/png"></image>' },
|
|
{ type: 'text', text: '<video path="/cache/clip.mp4">' },
|
|
);
|
|
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.prompt).toBe(
|
|
'<image path="/cache/shot.png"><image path="/cache/shot.png" content_type="image/png"></image><video path="/cache/clip.mp4">',
|
|
);
|
|
expect(turn.attachmentIds).toBeUndefined();
|
|
expect(snapshot.attachments).toEqual([]);
|
|
});
|
|
|
|
it('keeps cold tool calls running until a result is persisted', () => {
|
|
const pending = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'run it' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [], toolCalls: [{ id: 'c1', name: 'Bash', arguments: '{}' }] },
|
|
]);
|
|
const pendingTurn = pending.items[0];
|
|
if (pendingTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
const pendingTool = pendingTurn.steps[0]?.frames.find((f) => f.kind === 'tool');
|
|
expect(pendingTool?.kind === 'tool' && pendingTool.state).toBe('running');
|
|
|
|
const done = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'run it' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [], toolCalls: [{ id: 'c1', name: 'Bash', arguments: '{}' }] },
|
|
{ role: 'tool', content: [{ type: 'text', text: 'done.txt' }], toolCallId: 'c1', toolCalls: [] },
|
|
]);
|
|
const doneTurn = done.items[0];
|
|
if (doneTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
const doneTool = doneTurn.steps[0]?.frames.find((f) => f.kind === 'tool');
|
|
expect(doneTool?.kind === 'tool' && doneTool.state).toBe('done');
|
|
expect(doneTool?.kind === 'tool' && doneTool.output).toBe('done.txt');
|
|
});
|
|
|
|
it('opens a turn for subagent run prompts recorded as system triggers', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'scan the repo' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'system_trigger', name: 'subagent' } as { kind: string },
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'scanning' }], toolCalls: [] },
|
|
]);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'turn']);
|
|
const subTurn = snapshot.items[1];
|
|
if (subTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(subTurn.ordinal).toBe(1);
|
|
expect(subTurn.origin.kind).toBe('other');
|
|
expect(subTurn.prompt).toBe('scan the repo');
|
|
expect(subTurn.steps).toHaveLength(1);
|
|
});
|
|
|
|
it('starts a new turn for user-slash skill activations, keeps other triggers as markers only', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'skill body' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'skill_activation', trigger: 'user-slash', skillName: 'gen-docs' } as {
|
|
kind: string;
|
|
},
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'docs done' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'nested skill body' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'skill_activation', trigger: 'model-tool', skillName: 'x' } as {
|
|
kind: string;
|
|
},
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'still same turn' }], toolCalls: [] },
|
|
]);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker', 'turn', 'marker']);
|
|
const slashTurn = snapshot.items[2];
|
|
if (slashTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(slashTurn.ordinal).toBe(1);
|
|
expect(slashTurn.origin.kind).toBe('other');
|
|
expect(slashTurn.prompt).toBe('skill body');
|
|
expect(slashTurn.steps).toHaveLength(2);
|
|
});
|
|
|
|
it('keeps model-tool skill activations as markers even when their content matches a steer record', () => {
|
|
const skillContent = [{ type: 'text', text: 'skill body' }];
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: skillContent,
|
|
toolCalls: [],
|
|
origin: { kind: 'skill_activation', trigger: 'model-tool', skillName: 'write-tui' } as {
|
|
kind: string;
|
|
},
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'used the skill' }], toolCalls: [] },
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify(skillContent), new Map([['skill_activation', 1]])]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']);
|
|
const marker = snapshot.items[1];
|
|
if (marker?.kind !== 'marker') throw new Error('expected marker');
|
|
expect(marker.marker).toBe('skill');
|
|
});
|
|
|
|
it('still folds cron-origin steers into the running turn by content match', () => {
|
|
const cronContent = [{ type: 'text', text: 'cron tick' }];
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: cronContent,
|
|
toolCalls: [],
|
|
origin: { kind: 'cron_job', jobId: 'job1' } as { kind: string },
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] },
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify(cronContent), new Map([['cron_job', 1]])]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps).toHaveLength(2);
|
|
expect(turn.steps[1]?.frames[0]).toMatchObject({
|
|
kind: 'text',
|
|
role: 'user',
|
|
text: 'cron tick',
|
|
});
|
|
});
|
|
|
|
it('still folds user-slash skill activations into the running turn by content match', () => {
|
|
const slashContent = [{ type: 'text', text: 'slash skill body' }];
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: slashContent,
|
|
toolCalls: [],
|
|
origin: { kind: 'skill_activation', trigger: 'user-slash', skillName: 'gen-docs' } as {
|
|
kind: string;
|
|
},
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] },
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify(slashContent), new Map([['skill_activation', 1]])]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps).toHaveLength(2);
|
|
expect(turn.steps[1]?.frames[0]).toMatchObject({
|
|
kind: 'text',
|
|
role: 'user',
|
|
text: 'slash skill body',
|
|
});
|
|
});
|
|
|
|
it('consumes the steer count for marker-only activations so a later identical prompt opens its own turn', () => {
|
|
const shared = [{ type: 'text', text: 'same text' }];
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: shared,
|
|
toolCalls: [],
|
|
origin: { kind: 'skill_activation', trigger: 'model-tool', skillName: 'x' } as {
|
|
kind: string;
|
|
},
|
|
},
|
|
{ role: 'user', content: shared, toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] },
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify(shared), new Map([['skill_activation', 1]])]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker', 'turn']);
|
|
});
|
|
|
|
it('does not consume a user steer count for a compaction summary with identical content', () => {
|
|
const shared = [{ type: 'text', text: 'same text' }];
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: shared,
|
|
toolCalls: [],
|
|
origin: { kind: 'compaction_summary' } as { kind: string },
|
|
},
|
|
{ role: 'user', content: shared, toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] },
|
|
],
|
|
{ steeredContents: new Map([[JSON.stringify(shared), new Map([['user', 1]])]]) },
|
|
);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']);
|
|
const turn = snapshot.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
const steered = turn.steps
|
|
.flatMap((step) => step.frames)
|
|
.filter((frame) => frame.kind === 'text' && frame.role === 'user');
|
|
expect(steered).toHaveLength(1);
|
|
});
|
|
|
|
it('starts a promptless turn for turn-opening system triggers (goal continuation)', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'continue the goal' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'system_trigger', name: 'goal_continuation' } as { kind: string },
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'continued' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'mode reminder' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'injection', variant: 'permission_mode' } as { kind: string },
|
|
},
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'still same turn' }], toolCalls: [] },
|
|
]);
|
|
|
|
expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'turn']);
|
|
const [first, second] = snapshot.items;
|
|
if (first?.kind !== 'turn' || second?.kind !== 'turn') throw new Error('expected turns');
|
|
expect(first.ordinal).toBe(0);
|
|
expect(first.steps.map((step) => step.frames.map((frame) => frame.kind))).toEqual([['text']]);
|
|
expect(second.ordinal).toBe(1);
|
|
expect(second.origin.kind).toBe('other');
|
|
expect(second.prompt).toBeUndefined();
|
|
expect(second.steps).toHaveLength(2);
|
|
});
|
|
|
|
it('hides injected user messages and maps cron origins', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'secret context' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'injection' },
|
|
},
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'run report' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'cron_job', jobId: 'job1' } as { kind: string },
|
|
},
|
|
]);
|
|
expect(snapshot.items).toHaveLength(2);
|
|
const cronTurn = snapshot.items[1];
|
|
expect(cronTurn?.kind === 'turn' && cronTurn.origin.kind).toBe('cron');
|
|
});
|
|
|
|
it('opens a task turn for a notification with a task-origin turn.started in the wire', () => {
|
|
const snapshot = groupMessagesIntoSnapshot(
|
|
[
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'answer' }],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'task done' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'task', taskId: 'task-9' } as { kind: string },
|
|
},
|
|
],
|
|
{ taskOriginTurnTaskIds: new Set(['task-9']) },
|
|
);
|
|
|
|
const taskTurn = snapshot.items[1];
|
|
if (taskTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(taskTurn.origin).toMatchObject({ kind: 'task', taskId: 'task-9' });
|
|
});
|
|
|
|
it('maps legacy background_task origins to task turns, preserving the taskId', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'task done' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'background_task', taskId: 'b83rhswvs' } as { kind: string },
|
|
},
|
|
]);
|
|
const taskTurn = snapshot.items[1];
|
|
if (taskTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(taskTurn.origin).toMatchObject({ kind: 'task', taskId: 'b83rhswvs' });
|
|
});
|
|
});
|
|
|
|
describe('foldWireRecordFacts (cold facts)', () => {
|
|
const baseWithMarker = (): AgentTranscriptSnapshot =>
|
|
groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'summary of old' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'compaction_summary' },
|
|
},
|
|
]);
|
|
|
|
it('folds terminal subagent outcomes, background promotion, and a reused member generation', () => {
|
|
const spawned = (subagentId: string, time: number): HistoryWireRecord => ({
|
|
type: 'subagent.spawned', subagentId, subagentName: 'explore', parentAgentId: 'main',
|
|
parentToolCallId: 'swarm', runInBackground: false, time,
|
|
});
|
|
const folded = foldWireRecordFacts([
|
|
spawned('failed', 1000),
|
|
{ type: 'subagent.failed', subagentId: 'failed', error: 'offline', time: 2000 },
|
|
spawned('cancelled', 1000),
|
|
{ type: 'subagent.cancelled', subagentId: 'cancelled', time: 2000 },
|
|
spawned('reused', 1000),
|
|
{ type: 'subagent.completed', subagentId: 'reused', resultSummary: 'old', time: 2000 },
|
|
spawned('reused', 3000),
|
|
{ ...spawned('background', 1000), taskId: 'task-1', runInBackground: true },
|
|
{ type: 'subagent.completed', subagentId: 'background', resultSummary: 'done', usage: { inputOther: 5, output: 7, inputCacheRead: 1, inputCacheCreation: 2 }, time: 2000 },
|
|
{ type: 'task.terminated', info: { taskId: 'task-1', kind: 'agent', status: 'completed', agentId: 'background', detached: true, startedAt: 1000, endedAt: 2000 } },
|
|
{ ...spawned('foreign', 1000), parentAgentId: 'other' },
|
|
], baseWithMarker(), { agentId: 'main' });
|
|
expect(folded.tasks).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ taskId: 'failed', state: 'failed', error: 'offline' }),
|
|
expect.objectContaining({ taskId: 'cancelled', state: 'killed' }),
|
|
expect.objectContaining({ taskId: 'reused', state: 'running', startedAt: new Date(3000).toISOString() }),
|
|
expect.objectContaining({ taskId: 'task-1', state: 'completed', resultSummary: 'done', detached: true, usage: { inputOther: 5, output: 7, inputCacheRead: 1, inputCacheCreation: 2 } }),
|
|
]));
|
|
expect(folded.tasks).toHaveLength(4);
|
|
expect(folded.tasks.find((task) => task.taskId === 'reused')?.resultSummary).toBeUndefined();
|
|
expect(folded.tasks.find((task) => task.taskId === 'failed')?.usage).toBeUndefined();
|
|
});
|
|
|
|
it('adopts the spawn placeholder into the task registered afterwards', () => {
|
|
const folded = foldWireRecordFacts([
|
|
{ type: 'subagent.spawned', subagentId: 'child', subagentName: 'explore', parentAgentId: 'main', parentToolCallId: 'tower', runInBackground: false, description: 'review the diff', model: 'k2', time: 1000 },
|
|
{ type: 'task.started', info: { taskId: 'task-9', kind: 'agent', status: 'running', agentId: 'child', detached: false, startedAt: 1100 }, time: 1100 },
|
|
{ type: 'subagent.completed', subagentId: 'child', resultSummary: 'done', usage: { inputOther: 3, output: 4, inputCacheRead: 0, inputCacheCreation: 0 }, time: 2000 },
|
|
], baseWithMarker(), { agentId: 'main' });
|
|
expect(folded.tasks).toEqual([
|
|
expect.objectContaining({
|
|
taskId: 'task-9', agentId: 'child', state: 'completed', resultSummary: 'done',
|
|
description: 'review the diff', model: 'k2',
|
|
startedAt: new Date(1000).toISOString(),
|
|
usage: { inputOther: 3, output: 4, inputCacheRead: 0, inputCacheCreation: 0 },
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('terminalises a background member spawned before its task was registered', () => {
|
|
const folded = foldWireRecordFacts([
|
|
{ type: 'subagent.spawned', subagentId: 'worker', subagentName: 'tower-worker', parentAgentId: 'main', parentToolCallId: 'tower-spawn', runInBackground: true, description: 'build the feature', time: 1000 },
|
|
{ type: 'task.started', info: { taskId: 'task-7', kind: 'agent', status: 'running', agentId: 'worker', detached: true, startedAt: 1100 }, time: 1100 },
|
|
{ type: 'task.terminated', info: { taskId: 'task-7', kind: 'agent', status: 'lost', agentId: 'worker', detached: true, startedAt: 1100, endedAt: 3000 }, time: 3000 },
|
|
], baseWithMarker(), { agentId: 'main' });
|
|
expect(folded.tasks).toEqual([
|
|
expect.objectContaining({ taskId: 'task-7', agentId: 'worker', state: 'lost', detached: true, description: 'build the feature' }),
|
|
]);
|
|
});
|
|
|
|
it('keeps a terminal subagent record in sync with a placeholder left by an earlier generation', () => {
|
|
const folded = foldWireRecordFacts([
|
|
{ type: 'subagent.spawned', subagentId: 'worker', subagentName: 'explore', parentAgentId: 'main', parentToolCallId: 'agent-a', runInBackground: false, time: 1000 },
|
|
{ type: 'subagent.spawned', subagentId: 'worker', subagentName: 'explore', parentAgentId: 'main', parentToolCallId: 'agent-b', runInBackground: false, taskId: 'task-7', time: 2000 },
|
|
{ type: 'subagent.failed', subagentId: 'worker', error: 'offline', time: 3000 },
|
|
], baseWithMarker(), { agentId: 'main' });
|
|
expect(folded.tasks).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ taskId: 'task-7', state: 'failed', error: 'offline' }),
|
|
expect.objectContaining({ taskId: 'worker', state: 'failed', error: 'offline' }),
|
|
]));
|
|
expect(folded.tasks).toHaveLength(2);
|
|
});
|
|
|
|
it('returns the base snapshot unchanged when no fact records exist (old sessions)', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'metadata', protocol_version: '1.5', created_at: 1 },
|
|
{ type: 'context.append_message', message: { role: 'user' }, time: 1 },
|
|
],
|
|
base,
|
|
);
|
|
expect(folded).toEqual(base);
|
|
expect(folded.items).toBe(base.items);
|
|
});
|
|
|
|
it('folds todo records into the global todo document, last write wins', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'tools.update_store', key: 'todo', value: [{ title: 'old', status: 'pending' }], time: 1000 },
|
|
{ type: 'tools.update_store', key: 'other', value: [{ title: 'ignored', status: 'done' }], time: 2000 },
|
|
{
|
|
type: 'tools.update_store',
|
|
key: 'todo',
|
|
value: [
|
|
{ title: 'write tests', status: 'in_progress' },
|
|
{ title: 'ship', status: 'pending' },
|
|
{ title: 'malformed' },
|
|
],
|
|
time: 3000,
|
|
},
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.todos).toEqual([
|
|
{
|
|
todoId: 'todo',
|
|
items: [
|
|
{ title: 'write tests', status: 'in_progress' },
|
|
{ title: 'ship', status: 'pending' },
|
|
],
|
|
updatedAt: new Date(3000).toISOString(),
|
|
},
|
|
]);
|
|
expect(folded.items).toBe(base.items);
|
|
});
|
|
|
|
it('folds goal create/update/clear into meta.goal with markers, last write wins', () => {
|
|
const base = baseWithMarker();
|
|
expect(base.items.some((item) => item.kind === 'marker' && item.markerId === 'm1')).toBe(true);
|
|
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'goal.create',
|
|
goalId: 'g1',
|
|
objective: 'fix the bug',
|
|
completionCriterion: 'tests pass',
|
|
time: 1000,
|
|
},
|
|
{ type: 'goal.update', status: 'blocked', reason: 'stuck', tokensUsed: 1200, time: 2000 },
|
|
{ type: 'goal.update', budgetLimits: { tokenBudget: 50000 }, time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.meta.goal).toEqual({
|
|
objective: 'fix the bug',
|
|
status: 'blocked',
|
|
completionCriterion: 'tests pass',
|
|
budgetUsed: 1200,
|
|
budgetLimit: 50000,
|
|
});
|
|
const goalMarkers = folded.items.filter(
|
|
(item) => item.kind === 'marker' && item.marker === 'goal',
|
|
);
|
|
expect(goalMarkers.map((item) => item.kind === 'marker' && item.markerId)).toEqual([
|
|
'm2',
|
|
'm3',
|
|
'm4',
|
|
]);
|
|
expect(goalMarkers[0]).toMatchObject({ at: new Date(1000).toISOString() });
|
|
expect(folded.items.slice(0, base.items.length)).toEqual(base.items);
|
|
|
|
const cleared = foldWireRecordFacts(
|
|
[
|
|
{ type: 'goal.create', goalId: 'g1', objective: 'fix the bug', time: 1000 },
|
|
{ type: 'goal.clear', time: 2000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(cleared.meta.goal).toBeUndefined();
|
|
});
|
|
|
|
it('marks user-cancelled turns with interruption markers, skipping unattributable cancels', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.cancel', turnId: 0, target: 'active', reason: 'user_cancelled', time: 1000 },
|
|
{ type: 'turn.cancel', turnId: 0, target: 'active', reason: 'user_cancelled', time: 1500 },
|
|
{ type: 'turn.cancel', turnId: 1, target: 'queued', reason: 'user_cancelled', time: 2000 },
|
|
{ type: 'turn.cancel', turnId: 2, target: 'active', reason: 'aborted', time: 3000 },
|
|
{ type: 'turn.cancel', turnId: 4, target: 'active', reason: 'aborted', time: 3500 },
|
|
{ type: 'turn.cancel', turnId: 4, target: 'active', reason: 'user_cancelled', time: 3600 },
|
|
{ type: 'turn.cancel', turnId: 3, target: 'active', time: 4000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(
|
|
folded.items.filter((item) => item.kind === 'marker' && item.marker === 'interruption'),
|
|
).toEqual([
|
|
{
|
|
kind: 'marker',
|
|
markerId: 'm2',
|
|
marker: 'interruption',
|
|
payload: { turnId: 0, target: 'active', reason: 'user_cancelled' },
|
|
at: new Date(1000).toISOString(),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('folds plan/swarm mode records into meta.modes with enter/exit markers', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'plan_mode.enter', id: 'plan-1', time: 1000 },
|
|
{ type: 'swarm_mode.enter', trigger: { kind: 'task' }, time: 2000 },
|
|
{ type: 'plan_mode.exit', id: 'plan-1', time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.meta.modes).toEqual({ swarm: {} });
|
|
const markers = folded.items
|
|
.filter((item) => item.kind === 'marker')
|
|
.map((item) => item.kind === 'marker' && item.marker);
|
|
expect(markers).toEqual(['compaction', 'plan.enter', 'swarm.enter', 'plan.exit']);
|
|
|
|
const cancelled = foldWireRecordFacts(
|
|
[
|
|
{ type: 'plan_mode.enter', id: 'plan-1', time: 1000 },
|
|
{ type: 'plan_mode.cancel', time: 2000 },
|
|
{ type: 'swarm_mode.enter', trigger: { kind: 'tool' }, time: 3000 },
|
|
{ type: 'swarm_mode.exit', time: 4000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(cancelled.meta.modes).toEqual({});
|
|
|
|
const stillPlanning = foldWireRecordFacts(
|
|
[{ type: 'plan_mode.enter', id: 'plan-1', time: 1000 }],
|
|
base,
|
|
);
|
|
expect(stillPlanning.meta.modes).toEqual({ plan: {} });
|
|
});
|
|
|
|
it('keeps a model-started swarm that an older version never closed out of meta.modes', () => {
|
|
const base = baseWithMarker();
|
|
const markersOf = (snapshot: AgentTranscriptSnapshot) =>
|
|
snapshot.items
|
|
.filter((item) => item.kind === 'marker')
|
|
.map((item) => item.kind === 'marker' && item.marker);
|
|
|
|
const modelStarted = foldWireRecordFacts(
|
|
[{ type: 'swarm_mode.enter', agentId: 'main', trigger: 'tool', time: 1000 }],
|
|
base,
|
|
);
|
|
expect(modelStarted.meta.modes?.swarm).toBeUndefined();
|
|
expect(markersOf(modelStarted)).toEqual(['compaction', 'swarm.enter']);
|
|
|
|
const userStarted = foldWireRecordFacts(
|
|
[{ type: 'swarm_mode.enter', agentId: 'main', trigger: 'manual', time: 1000 }],
|
|
base,
|
|
);
|
|
expect(userStarted.meta.modes).toEqual({ swarm: {} });
|
|
});
|
|
|
|
it('folds tower mode records into meta.modes without markers', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'tower_mode.enter', time: 1000 },
|
|
{ type: 'tower_mode.exit', time: 2000 },
|
|
{ type: 'tower_mode.enter', time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.meta.modes).toEqual({ tower: {} });
|
|
|
|
const exited = foldWireRecordFacts(
|
|
[
|
|
{ type: 'tower_mode.enter', time: 1000 },
|
|
{ type: 'tower_mode.exit', time: 2000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(exited.meta.modes).toEqual({});
|
|
});
|
|
|
|
it('folds plan.revision records into the plan badge and a timeline marker', () => {
|
|
const base = baseWithMarker();
|
|
const revision = {
|
|
type: 'plan.revision',
|
|
id: 'plan-1',
|
|
version: 2,
|
|
key: 'plan/plan-1/v2.md',
|
|
sha256: 'deadbeef',
|
|
bytes: 512,
|
|
time: 2000,
|
|
};
|
|
const folded = foldWireRecordFacts(
|
|
[{ type: 'plan_mode.enter', id: 'plan-1', time: 1000 }, revision],
|
|
base,
|
|
{ resolvePlanRevisionKey: (key) => `sessions/w/s/agents/main/${key}` },
|
|
);
|
|
expect(folded.meta.modes).toEqual({
|
|
plan: { reviewPath: 'sessions/w/s/agents/main/plan/plan-1/v2.md', version: 2 },
|
|
});
|
|
const revisionMarkers = folded.items.filter(
|
|
(item) => item.kind === 'marker' && item.marker === 'plan.revision',
|
|
);
|
|
expect(revisionMarkers).toEqual([
|
|
{
|
|
kind: 'marker',
|
|
markerId: 'm3',
|
|
marker: 'plan.revision',
|
|
payload: {
|
|
id: 'plan-1',
|
|
version: 2,
|
|
path: 'sessions/w/s/agents/main/plan/plan-1/v2.md',
|
|
sha256: 'deadbeef',
|
|
bytes: 512,
|
|
},
|
|
at: new Date(2000).toISOString(),
|
|
},
|
|
]);
|
|
|
|
const exited = foldWireRecordFacts(
|
|
[
|
|
{ type: 'plan_mode.enter', id: 'plan-1', time: 1000 },
|
|
revision,
|
|
{ type: 'plan_mode.exit', id: 'plan-1', time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(exited.meta.modes?.plan).toBeUndefined();
|
|
expect(
|
|
exited.items.filter((item) => item.kind === 'marker' && item.marker === 'plan.revision'),
|
|
).toHaveLength(1);
|
|
|
|
const reentered = foldWireRecordFacts(
|
|
[
|
|
{ type: 'plan_mode.enter', id: 'plan-1', time: 1000 },
|
|
revision,
|
|
{ type: 'plan_mode.exit', id: 'plan-1', time: 3000 },
|
|
{ type: 'plan_mode.enter', id: 'plan-2', time: 4000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(reentered.meta.modes).toEqual({ plan: {} });
|
|
});
|
|
|
|
it('folds legacy plan.revision path records without a key', () => {
|
|
const base = baseWithMarker();
|
|
const legacy = {
|
|
type: 'plan.revision',
|
|
id: 'plan-1',
|
|
version: 1,
|
|
path: 'sessions/w/s/agents/main/plan/plan-1/v1.md',
|
|
sha256: 'deadbeef',
|
|
bytes: 256,
|
|
time: 2000,
|
|
};
|
|
const folded = foldWireRecordFacts(
|
|
[{ type: 'plan_mode.enter', id: 'plan-1', time: 1000 }, legacy],
|
|
base,
|
|
);
|
|
expect(folded.meta.modes).toEqual({
|
|
plan: { reviewPath: 'sessions/w/s/agents/main/plan/plan-1/v1.md', version: 1 },
|
|
});
|
|
const revisionMarkers = folded.items.filter(
|
|
(item) => item.kind === 'marker' && item.marker === 'plan.revision',
|
|
);
|
|
expect(revisionMarkers).toEqual([
|
|
{
|
|
kind: 'marker',
|
|
markerId: 'm3',
|
|
marker: 'plan.revision',
|
|
payload: {
|
|
id: 'plan-1',
|
|
version: 1,
|
|
path: 'sessions/w/s/agents/main/plan/plan-1/v1.md',
|
|
sha256: 'deadbeef',
|
|
bytes: 256,
|
|
},
|
|
at: new Date(2000).toISOString(),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('folds task records into task entities and timeline taskrefs', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'task.started',
|
|
info: {
|
|
taskId: 'task_1',
|
|
kind: 'process',
|
|
description: 'pnpm test',
|
|
status: 'running',
|
|
startedAt: 1000,
|
|
endedAt: null,
|
|
},
|
|
time: 1000,
|
|
},
|
|
{
|
|
type: 'task.started',
|
|
info: {
|
|
taskId: 'task_2',
|
|
kind: 'agent',
|
|
description: 'scan the repo',
|
|
status: 'running',
|
|
detached: false,
|
|
agentId: 'sub-1',
|
|
startedAt: 2000,
|
|
endedAt: null,
|
|
},
|
|
time: 2000,
|
|
},
|
|
{
|
|
type: 'task.terminated',
|
|
info: {
|
|
taskId: 'task_1',
|
|
kind: 'process',
|
|
description: 'pnpm test',
|
|
status: 'completed',
|
|
startedAt: 1000,
|
|
endedAt: 5000,
|
|
},
|
|
outputTail: '42 passed',
|
|
time: 5000,
|
|
},
|
|
],
|
|
base,
|
|
);
|
|
|
|
expect(folded.tasks).toEqual([
|
|
{
|
|
taskId: 'task_1',
|
|
kind: 'shell',
|
|
state: 'completed',
|
|
detached: true,
|
|
description: 'pnpm test',
|
|
agentId: undefined,
|
|
outputTail: '42 passed',
|
|
startedAt: new Date(1000).toISOString(),
|
|
endedAt: new Date(5000).toISOString(),
|
|
},
|
|
{
|
|
taskId: 'task_2',
|
|
kind: 'subagent',
|
|
state: 'running',
|
|
detached: false,
|
|
description: 'scan the repo',
|
|
agentId: 'sub-1',
|
|
outputTail: '',
|
|
startedAt: new Date(2000).toISOString(),
|
|
endedAt: undefined,
|
|
},
|
|
]);
|
|
const refs = folded.items.filter((item) => item.kind === 'taskref');
|
|
expect(refs).toEqual([
|
|
{ kind: 'taskref', refId: 'ref-task_1', taskId: 'task_1', at: new Date(1000).toISOString() },
|
|
{ kind: 'taskref', refId: 'ref-task_2', taskId: 'task_2', at: new Date(2000).toISOString() },
|
|
]);
|
|
});
|
|
|
|
it('maps unknown task kinds to other and survives malformed task records', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'task.started',
|
|
info: { taskId: 'task_q', kind: 'question', status: 'running', startedAt: 1000, endedAt: null },
|
|
time: 1000,
|
|
},
|
|
{ type: 'task.started', time: 2000 },
|
|
{ type: 'task.terminated', info: { kind: 'process' }, time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.tasks).toHaveLength(1);
|
|
expect(folded.tasks[0]).toMatchObject({ taskId: 'task_q', kind: 'other', state: 'running' });
|
|
});
|
|
|
|
it('folds interaction request/resolved into entities with terminal states', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'interaction.request',
|
|
id: 'apr-1',
|
|
kind: 'approval',
|
|
toolCallId: 'call_1',
|
|
request: { toolName: 'Bash' },
|
|
time: 1000,
|
|
},
|
|
{
|
|
type: 'interaction.request',
|
|
id: 'q-1',
|
|
kind: 'question',
|
|
request: { questions: [] },
|
|
time: 2000,
|
|
},
|
|
{
|
|
type: 'interaction.resolved',
|
|
id: 'apr-1',
|
|
response: { decision: 'approved', scope: 'session' },
|
|
time: 3000,
|
|
},
|
|
{ type: 'interaction.resolved', id: 'q-1', response: null, time: 4000 },
|
|
{
|
|
type: 'interaction.request',
|
|
id: 'q-2',
|
|
kind: 'question',
|
|
request: { questions: [] },
|
|
time: 5000,
|
|
},
|
|
{
|
|
type: 'interaction.resolved',
|
|
id: 'q-2',
|
|
response: { cancelled: true, reason: 'turn_ended' },
|
|
time: 6000,
|
|
},
|
|
{
|
|
type: 'interaction.request',
|
|
id: 'q-3',
|
|
kind: 'question',
|
|
request: { questions: [] },
|
|
time: 7000,
|
|
},
|
|
{
|
|
type: 'interaction.resolved',
|
|
id: 'q-3',
|
|
response: { cancelled: true },
|
|
time: 8000,
|
|
},
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.interactions).toEqual([
|
|
{
|
|
interactionId: 'apr-1',
|
|
interactionKind: 'approval',
|
|
toolCallId: 'call_1',
|
|
state: 'approved',
|
|
request: { toolName: 'Bash' },
|
|
response: { decision: 'approved', scope: 'session' },
|
|
},
|
|
{
|
|
interactionId: 'q-1',
|
|
interactionKind: 'question',
|
|
toolCallId: undefined,
|
|
state: 'dismissed',
|
|
request: { questions: [] },
|
|
response: null,
|
|
},
|
|
{
|
|
interactionId: 'q-2',
|
|
interactionKind: 'question',
|
|
toolCallId: undefined,
|
|
state: 'cancelled',
|
|
request: { questions: [] },
|
|
response: { cancelled: true, reason: 'turn_ended' },
|
|
},
|
|
{
|
|
interactionId: 'q-3',
|
|
interactionKind: 'question',
|
|
toolCallId: undefined,
|
|
state: 'answered',
|
|
request: { questions: [] },
|
|
response: { cancelled: true },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('cancels interactions still pending at the end of the scan (crash == cancelled)', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'interaction.request',
|
|
id: 'apr-9',
|
|
kind: 'approval',
|
|
request: { toolCallId: 'call_9', toolName: 'Write' },
|
|
time: 1000,
|
|
},
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.interactions).toHaveLength(1);
|
|
expect(folded.interactions[0]).toMatchObject({
|
|
interactionId: 'apr-9',
|
|
state: 'cancelled',
|
|
toolCallId: 'call_9',
|
|
});
|
|
expect(folded.interactions.every((entity) => entity.state !== 'pending')).toBe(true);
|
|
});
|
|
|
|
it('skips user_tool interactions like the live path', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'interaction.request', id: 'ut-1', kind: 'user_tool', request: {}, time: 1000 },
|
|
{ type: 'interaction.resolved', id: 'ut-1', response: {}, time: 2000 },
|
|
] satisfies HistoryWireRecord[],
|
|
base,
|
|
);
|
|
expect(folded.interactions).toEqual([]);
|
|
});
|
|
|
|
it('folds turn.ended records into the matching turn items', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'turn.ended',
|
|
turnId: 0,
|
|
reason: 'failed',
|
|
error: { code: 'provider.overloaded', message: 'Overloaded', name: 'APIStatusError', retryable: true },
|
|
durationMs: 1234,
|
|
time: 5000,
|
|
},
|
|
],
|
|
base,
|
|
);
|
|
const turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.state).toBe('failed');
|
|
expect(turn.error).toBe('Overloaded');
|
|
expect(turn.durationMs).toBe(1234);
|
|
expect(turn.endedAt).toBe(new Date(5000).toISOString());
|
|
expect(folded.items).toHaveLength(base.items.length);
|
|
});
|
|
|
|
it('applies turn.ended records last-wins per turn, folds blocked into failed, and ignores malformed records', () => {
|
|
const base = baseWithMarker();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'boom' }, time: 1000 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'cancelled', durationMs: 10, time: 2000 },
|
|
],
|
|
base,
|
|
);
|
|
const turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.state).toBe('cancelled');
|
|
expect(turn.error).toBeUndefined();
|
|
expect(turn.durationMs).toBe(10);
|
|
expect(turn.endedAt).toBe(new Date(2000).toISOString());
|
|
|
|
const blocked = foldWireRecordFacts(
|
|
[{ type: 'turn.ended', turnId: 0, reason: 'blocked', time: 3000 }],
|
|
base,
|
|
);
|
|
const blockedTurn = blocked.items[0];
|
|
if (blockedTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(blockedTurn.state).toBe('failed');
|
|
|
|
const malformed = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.ended', turnId: 9, reason: 'cancelled', time: 1000 },
|
|
{ type: 'turn.ended', reason: 'completed', time: 2000 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'not-a-reason', time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
const malformedTurn = malformed.items[0];
|
|
if (malformedTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(malformedTurn.state).toBe('completed');
|
|
expect(malformedTurn.endedAt).toBe(new Date(3000).toISOString());
|
|
expect(malformed.items).toHaveLength(base.items.length);
|
|
});
|
|
|
|
it('realigns rebuilt turns to wire turn ids with holes and restores startedAt', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ role: 'user', id: 'p5', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'reply one' }], toolCalls: [] },
|
|
{ role: 'user', id: 'p7', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'reply two' }], toolCalls: [] },
|
|
]);
|
|
expect(base.items.map((item) => item.kind === 'turn' && item.turnId)).toEqual(['t0', 't1']);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', turnId: 5, promptId: 'p5', origin: { kind: 'user' }, time: 1000 },
|
|
{ type: 'agent.turn.started', turnId: 5, time: 1100 },
|
|
{ type: 'turn.ended', turnId: 5, reason: 'completed', time: 2000 },
|
|
{ type: 'turn.prompt', turnId: 7, promptId: 'p7', origin: { kind: 'user' }, time: 3000 },
|
|
{ type: 'turn.ended', turnId: 7, reason: 'completed', time: 4000 },
|
|
],
|
|
base,
|
|
);
|
|
const [first, second] = folded.items;
|
|
if (first?.kind !== 'turn' || second?.kind !== 'turn') throw new Error('expected turns');
|
|
expect(first).toMatchObject({
|
|
turnId: 't5',
|
|
ordinal: 5,
|
|
state: 'completed',
|
|
startedAt: new Date(1100).toISOString(),
|
|
endedAt: new Date(2000).toISOString(),
|
|
});
|
|
expect(second).toMatchObject({
|
|
turnId: 't7',
|
|
ordinal: 7,
|
|
state: 'completed',
|
|
startedAt: new Date(3000).toISOString(),
|
|
});
|
|
expect(first.steps[0]?.stepId).toBe('t5.1');
|
|
expect(first.steps[0]?.frames.every((frame) => frame.frameId.startsWith('t5.1.'))).toBe(true);
|
|
expect(second.steps[0]?.stepId).toBe('t7.1');
|
|
});
|
|
|
|
it('marks a turn without terminal events as wedged once a later turn starts, and wedgifies a dangling tail unless it is live', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ role: 'user', id: 'p5', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'reply one' }], toolCalls: [] },
|
|
{ role: 'user', id: 'p6', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'reply two' }], toolCalls: [] },
|
|
{ role: 'user', id: 'p7', content: [{ type: 'text', text: 'three' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'reply three' }], toolCalls: [] },
|
|
]);
|
|
const records: HistoryWireRecord[] = [
|
|
{ type: 'turn.prompt', turnId: 5, promptId: 'p5', origin: { kind: 'user' }, time: 1000 },
|
|
{ type: 'agent.turn.started', turnId: 5, time: 1100 },
|
|
{ type: 'turn.prompt', turnId: 6, promptId: 'p6', origin: { kind: 'user' }, time: 2000 },
|
|
{ type: 'turn.ended', turnId: 6, reason: 'completed', time: 2500 },
|
|
{ type: 'turn.prompt', turnId: 7, promptId: 'p7', origin: { kind: 'user' }, time: 3000 },
|
|
{ type: 'turn.ended', turnId: 7, reason: 'completed', time: 4000 },
|
|
];
|
|
const folded = foldWireRecordFacts(records, base);
|
|
const wedged = folded.items[0];
|
|
if (wedged?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(wedged).toMatchObject({
|
|
turnId: 't5',
|
|
ordinal: 5,
|
|
state: 'failed',
|
|
wedged: true,
|
|
startedAt: new Date(1100).toISOString(),
|
|
});
|
|
expect(wedged.endedAt).toBeUndefined();
|
|
expect(folded.items.map((item) => item.kind === 'turn' && item.state)).toEqual([
|
|
'failed',
|
|
'completed',
|
|
'completed',
|
|
]);
|
|
|
|
const tailOpen = records.slice(0, -1);
|
|
const dangling = foldWireRecordFacts(tailOpen, base);
|
|
const tail = dangling.items[2];
|
|
if (tail?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(tail).toMatchObject({ turnId: 't7', state: 'failed', wedged: true });
|
|
expect(tail.endedAt).toBeUndefined();
|
|
|
|
const live = foldWireRecordFacts(tailOpen, base, { activeWireTurnId: 7 });
|
|
const liveTail = live.items[2];
|
|
if (liveTail?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(liveTail).toMatchObject({ turnId: 't7', ordinal: 7, state: 'completed' });
|
|
expect(liveTail.wedged).toBeUndefined();
|
|
});
|
|
|
|
const baseWithSteps = (): AgentTranscriptSnapshot =>
|
|
groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'first' }], toolCalls: [] },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'second' }], toolCalls: [] },
|
|
]);
|
|
|
|
it('folds turn.step.interrupted records into the matching step of the matching turn', () => {
|
|
const base = baseWithSteps();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'turn.step.interrupted',
|
|
turnId: 0,
|
|
step: 2,
|
|
reason: 'user_cancelled',
|
|
message: 'stopped by user',
|
|
time: 4000,
|
|
},
|
|
{ type: 'turn.ended', turnId: 0, reason: 'cancelled', time: 5000 },
|
|
],
|
|
base,
|
|
);
|
|
const turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps).toHaveLength(2);
|
|
expect(turn.steps[0]).toMatchObject({ ordinal: 1, state: 'completed' });
|
|
expect(turn.steps[0]?.endReason).toBeUndefined();
|
|
expect(turn.steps[1]).toMatchObject({
|
|
ordinal: 2,
|
|
state: 'interrupted',
|
|
endReason: 'user_cancelled',
|
|
endMessage: 'stopped by user',
|
|
endedAt: new Date(4000).toISOString(),
|
|
});
|
|
expect(turn.state).toBe('cancelled');
|
|
expect(folded.items).toHaveLength(base.items.length);
|
|
});
|
|
|
|
it('applies turn.step.interrupted last-wins per step and skips malformed records', () => {
|
|
const base = baseWithSteps();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.step.interrupted', turnId: 0, step: 1, reason: 'aborted', message: 'first', time: 1000 },
|
|
{ type: 'turn.step.interrupted', turnId: 0, step: 1, reason: 'max_steps', time: 2000 },
|
|
],
|
|
base,
|
|
);
|
|
const turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps[0]).toMatchObject({
|
|
ordinal: 1,
|
|
state: 'interrupted',
|
|
endReason: 'max_steps',
|
|
endedAt: new Date(2000).toISOString(),
|
|
});
|
|
expect(turn.steps[0]?.endMessage).toBeUndefined();
|
|
expect(turn.steps[1]?.state).toBe('completed');
|
|
|
|
const skipped = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.step.interrupted', turnId: 9, step: 1, reason: 'error', time: 1000 },
|
|
{ type: 'turn.step.interrupted', turnId: 0, step: 1, time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
const skippedTurn = skipped.items[0];
|
|
if (skippedTurn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(skippedTurn.steps.map((step) => step.state)).toEqual(['completed', 'completed']);
|
|
expect(skippedTurn.steps.every((step) => step.endReason === undefined)).toBe(true);
|
|
});
|
|
|
|
it('creates the interrupted step when the cold tree has no step with that ordinal', () => {
|
|
const base = baseWithSteps();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'turn.step.interrupted',
|
|
turnId: 0,
|
|
step: 3,
|
|
reason: 'error',
|
|
message: 'all retries exhausted',
|
|
time: 2000,
|
|
},
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', time: 3000 },
|
|
],
|
|
base,
|
|
);
|
|
const turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps.map((step) => step.ordinal)).toEqual([1, 2, 3]);
|
|
expect(turn.steps[2]).toEqual({
|
|
kind: 'step',
|
|
stepId: 't0.3',
|
|
turnId: 't0',
|
|
ordinal: 3,
|
|
state: 'interrupted',
|
|
frames: [],
|
|
endedAt: new Date(2000).toISOString(),
|
|
endReason: 'error',
|
|
endMessage: 'all retries exhausted',
|
|
});
|
|
});
|
|
|
|
it('creates the interrupted step for a turn that produced no steps at all', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.step.interrupted', turnId: 0, step: 1, reason: 'user_cancelled', time: 1000 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'cancelled', time: 2000 },
|
|
],
|
|
base,
|
|
);
|
|
const turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.steps).toHaveLength(1);
|
|
expect(turn.steps[0]).toMatchObject({
|
|
stepId: 't0.1',
|
|
ordinal: 1,
|
|
state: 'interrupted',
|
|
endReason: 'user_cancelled',
|
|
endedAt: new Date(1000).toISOString(),
|
|
});
|
|
expect(turn.steps[0]?.endMessage).toBeUndefined();
|
|
});
|
|
|
|
it('ignores turn.step.retrying records in the cold fold', () => {
|
|
const base = baseWithSteps();
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{
|
|
type: 'turn.step.retrying',
|
|
turnId: 0,
|
|
step: 1,
|
|
failedAttempt: 1,
|
|
nextAttempt: 2,
|
|
maxAttempts: 10,
|
|
delayMs: 500,
|
|
errorName: 'APIStatusError',
|
|
errorMessage: 'Overloaded',
|
|
statusCode: 429,
|
|
time: 1000,
|
|
},
|
|
],
|
|
base,
|
|
);
|
|
expect(folded).toEqual(base);
|
|
expect(folded.items).toBe(base.items);
|
|
});
|
|
|
|
it('matches durable turns by prompt identity after a context-only blocked prompt', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ id: 'prompt-live', role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'failed later' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', turnId: 2, input: [{ type: 'text', text: 'run' }], origin: { kind: 'user' }, promptId: 'prompt-live', time: 1 },
|
|
{ type: 'turn.ended', turnId: 2, reason: 'failed', error: { message: 'later failure' }, time: 2 },
|
|
],
|
|
base,
|
|
);
|
|
expect(folded.items).toHaveLength(2);
|
|
const blocked = folded.items[0];
|
|
const live = folded.items[1];
|
|
if (blocked?.kind !== 'turn' || live?.kind !== 'turn') throw new Error('expected turns');
|
|
expect(blocked.triggerPromptId).toBe('prompt-blocked');
|
|
expect(blocked.error).toBeUndefined();
|
|
expect(live.triggerPromptId).toBe('prompt-live');
|
|
expect(live.state).toBe('failed');
|
|
expect(live.error).toBe('later failure');
|
|
});
|
|
|
|
it('recovers legacy boundary identity past a context-only blocked prompt', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ id: 'prompt-real', role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'failed later' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 1 },
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'run' }], origin: { kind: 'user' }, time: 2 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-real', role: 'user', origin: { kind: 'user' } }, time: 3 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'legacy failure' }, time: 4 },
|
|
],
|
|
base,
|
|
);
|
|
const blocked = folded.items[0];
|
|
const real = folded.items[1];
|
|
if (blocked?.kind !== 'turn' || real?.kind !== 'turn') throw new Error('expected turns');
|
|
expect(blocked.error).toBeUndefined();
|
|
expect(real.triggerPromptId).toBe('prompt-real');
|
|
expect(real.state).toBe('failed');
|
|
expect(real.error).toBe('legacy failure');
|
|
});
|
|
|
|
it('retires an unmatched legacy empty boundary before a later blocked prompt', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'partial' }], toolCalls: [] },
|
|
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [], origin: { kind: 'user' }, time: 1 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'empty failure' }, time: 2 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 3 },
|
|
],
|
|
base,
|
|
);
|
|
const empty = folded.items[0];
|
|
const blocked = folded.items[1];
|
|
if (empty?.kind !== 'turn' || blocked?.kind !== 'turn') throw new Error('expected turns');
|
|
expect(empty.state).toBe('failed');
|
|
expect(empty.error).toBe('empty failure');
|
|
expect(blocked.triggerPromptId).toBe('prompt-blocked');
|
|
expect(blocked.error).toBeUndefined();
|
|
});
|
|
|
|
it('folds terminal facts for an empty prompt turn without a context identity', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'partial' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [], origin: { kind: 'user' }, promptId: 'prompt-empty', time: 1 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'empty failure' }, time: 2 },
|
|
],
|
|
base,
|
|
);
|
|
const turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(turn.triggerPromptId).toBeUndefined();
|
|
expect(turn.state).toBe('failed');
|
|
expect(turn.error).toBe('empty failure');
|
|
});
|
|
|
|
it('does not let an empty prompt claim a later system continuation turn', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'internal-prompt', role: 'user', content: [{ type: 'text', text: 'continue' }], toolCalls: [], origin: { kind: 'system_trigger', name: 'goal_continuation' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'stopped' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [], origin: { kind: 'user' }, promptId: 'prompt-empty', time: 1 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'empty failure' }, time: 2 },
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'continue' }], origin: { kind: 'system_trigger', name: 'goal_continuation' }, promptId: 'internal-prompt', time: 3 },
|
|
{ type: 'turn.ended', turnId: 1, reason: 'cancelled', durationMs: 10, time: 4 },
|
|
],
|
|
base,
|
|
);
|
|
const system = folded.items[0];
|
|
if (system?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(system.triggerPromptId).toBeUndefined();
|
|
expect(system.state).toBe('cancelled');
|
|
expect(system.durationMs).toBe(10);
|
|
expect(system.error).toBeUndefined();
|
|
});
|
|
|
|
it('matches a system turn with internal prompt identity past a context-only blocked prompt', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ id: 'internal-prompt', role: 'user', content: [{ type: 'text', text: 'continue' }], toolCalls: [], origin: { kind: 'system_trigger', name: 'goal_continuation' } as { kind: string } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'continuation failed' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'continue' }], origin: { kind: 'system_trigger', name: 'goal_continuation' }, promptId: 'internal-prompt', time: 1 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'system failure' }, time: 2 },
|
|
],
|
|
base,
|
|
);
|
|
const blocked = folded.items[0];
|
|
const system = folded.items[1];
|
|
if (blocked?.kind !== 'turn' || system?.kind !== 'turn') throw new Error('expected turns');
|
|
expect(blocked.triggerPromptId).toBe('prompt-blocked');
|
|
expect(blocked.error).toBeUndefined();
|
|
expect(system.triggerPromptId).toBeUndefined();
|
|
expect(system.state).toBe('failed');
|
|
expect(system.error).toBe('system failure');
|
|
});
|
|
|
|
it('maps turn.ended around hidden retry turns replayed from the turn-clock records', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
|
|
{ id: 'prompt-2', role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a2' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, promptId: 'prompt-1', time: 1 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'completed', time: 2 },
|
|
{ type: 'turn.prompt', input: [], origin: { kind: 'retry' }, time: 3 },
|
|
{ type: 'turn.ended', turnId: 1, reason: 'failed', error: { message: 'retry boom' }, time: 4 },
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'prompt-2', time: 5 },
|
|
{ type: 'turn.ended', turnId: 2, reason: 'cancelled', durationMs: 20, time: 6 },
|
|
],
|
|
base,
|
|
);
|
|
const first = folded.items[0];
|
|
if (first?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(first.state).toBe('completed');
|
|
expect(first.triggerPromptId).toBe('prompt-1');
|
|
const second = folded.items[1];
|
|
if (second?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(second.state).toBe('cancelled');
|
|
expect(second.triggerPromptId).toBe('prompt-2');
|
|
expect(second.error).toBeUndefined();
|
|
expect(second.durationMs).toBe(20);
|
|
});
|
|
|
|
it('maps turn.ended across queued-then-cancelled turn reservations', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
|
|
{ id: 'prompt-2', role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a2' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, time: 1 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'completed', time: 2 },
|
|
{ type: 'turn.cancel', turnId: 1, target: 'queued', time: 3 },
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'prompt-2', time: 4 },
|
|
{ type: 'turn.ended', turnId: 2, reason: 'failed', error: { message: 'boom' }, time: 5 },
|
|
],
|
|
base,
|
|
);
|
|
const second = folded.items[1];
|
|
if (second?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(second.state).toBe('failed');
|
|
expect(second.error).toBe('boom');
|
|
expect(second.triggerPromptId).toBe('prompt-2');
|
|
});
|
|
|
|
it('skips undone turn boundaries when mapping prompt ids and turn endings', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
|
|
{ id: 'prompt-3', role: 'user', content: [{ type: 'text', text: 'replacement' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a3' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, promptId: 'prompt-1', time: 1 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-1', role: 'user', origin: { kind: 'user' } }, time: 1.5 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'completed', time: 2 },
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'undone' }], origin: { kind: 'user' }, promptId: 'prompt-2', time: 3 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-2', role: 'user', origin: { kind: 'user' } }, time: 3.5 },
|
|
{ type: 'turn.ended', turnId: 1, reason: 'completed', time: 4 },
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'continue' }], origin: { kind: 'system_trigger', name: 'goal_continuation' }, time: 5 },
|
|
{ type: 'turn.ended', turnId: 2, reason: 'completed', time: 6 },
|
|
{ type: 'context.undo', count: 1, time: 7 },
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'replacement' }], origin: { kind: 'user' }, promptId: 'prompt-3', time: 8 },
|
|
{ type: 'turn.ended', turnId: 3, reason: 'failed', error: { message: 'replacement failed' }, time: 9 },
|
|
],
|
|
base,
|
|
);
|
|
const replacement = folded.items[1];
|
|
if (replacement?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(replacement.triggerPromptId).toBe('prompt-3');
|
|
expect(replacement.state).toBe('failed');
|
|
expect(replacement.error).toBe('replacement failed');
|
|
});
|
|
|
|
it('counts context-only blocked prompts as undo anchors', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-real', role: 'user', content: [{ type: 'text', text: 'real' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'failed' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'real' }], origin: { kind: 'user' }, promptId: 'prompt-real', time: 1 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-real', role: 'user', origin: { kind: 'user' } }, time: 2 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'real failure' }, time: 3 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 4 },
|
|
{ type: 'context.undo', count: 1, time: 5 },
|
|
],
|
|
base,
|
|
);
|
|
const real = folded.items[0];
|
|
if (real?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(real.triggerPromptId).toBe('prompt-real');
|
|
expect(real.state).toBe('failed');
|
|
expect(real.error).toBe('real failure');
|
|
});
|
|
|
|
it('does not consume a pending identity boundary for a different context-only anchor', () => {
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ id: 'prompt-real', role: 'user', content: [{ type: 'text', text: 'real' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'failed' }], toolCalls: [] },
|
|
]);
|
|
const folded = foldWireRecordFacts(
|
|
[
|
|
{ type: 'turn.prompt', input: [{ type: 'text', text: 'real' }], origin: { kind: 'user' }, promptId: 'prompt-real', time: 1 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 2 },
|
|
{ type: 'context.undo', count: 1, time: 3 },
|
|
{ type: 'context.append_message', message: { id: 'prompt-real', role: 'user', origin: { kind: 'user' } }, time: 4 },
|
|
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'real failure' }, time: 5 },
|
|
],
|
|
base,
|
|
);
|
|
const real = folded.items[0];
|
|
if (real?.kind !== 'turn') throw new Error('expected turn');
|
|
expect(real.state).toBe('failed');
|
|
expect(real.error).toBe('real failure');
|
|
});
|
|
});
|