mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 15:16:07 +00:00
* feat(agent-core-v2): support grouped multi-skill prompt submissions Add IAgentSkillService.promptWithSkills: one or more skill activations are validated up front (an unknown or empty submission rejects with no side effects), recorded with a shared submissionId, and enqueued ahead of the prompt through the prompt queue's messagesBefore support, so the whole group materializes atomically as a single turn. Undo cuts, the transcript projection, and the undo precheck treat the group as one unit (stopping at the next anchor even when submission ids collide); hook-result messages are skipped like injections during those walks. Submit hooks run against every message of the group, and user-slash skill activations count as user-submitted content for the UserPromptSubmit hook's origin filter. Surface it through the contract layers: protocol gains submissionId on the user / skill_activation origins and on the skill.activated event (kap-server zod mirrored), klient exposes agentSkillContract.promptWithSkills with parity assertions, and the SDK grows session.promptWithSkills — implemented on the v2 engine and rejecting loudly on the deprecated v1 engine, which is otherwise untouched. * fix(agent-core-v2): reject empty skill lists in grouped prompt submissions - Validate that promptWithSkills receives at least one skill, enforced in the engine and as a non-empty constraint in the klient wire schema. - Restore the released versions and changelog sections for agent-core-v2, klient, and node-sdk that the branch cut had reverted. - Move statement-level narration into the owning file headers per the package comment conventions. - Align the hook-result undo tests with the reachable record ordering (hook results are recorded before the group materializes). * refactor(agent-core-v2): bundle grouped skill activations into the prompt message Replace the submissionId-correlated message group with a single bundled user message: the rendered skill blocks precede the caller's parts in the content, and every activation's metadata rides the prompt origin's new skillActivations field. The bundle is one anchor by construction, so undo needs no group-cutting logic and the messagesBefore prompt seam disappears; the submit hook fires once per submission. skill.activated still fires per skill (transient ops, live-only); resume rebuilds the per-skill view from the prompt origin. Contract chain (protocol, kap-server, klient, node-sdk) drops submissionId accordingly. * fix(agent-core-v2): keep bundled skill blocks out of prompt-facing projections - The transcript cold rebuild expands a bundled prompt's origin skillActivations back into per-skill markers (the live path already projects them from skill.activated events). - turn.started.prompt, the session title excerpt source, and the fork lastPrompt now derive from the caller's own parts, excluding the rendered skill blocks the engine prepends to the bundled content. - Drop the redundant undefined unions from the new origin fields. - Move the activateSkill test narration into the file header.
1238 lines
47 KiB
TypeScript
1238 lines
47 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 { groupMessagesIntoSnapshot } from '#/history/groupTurns';
|
|
import { foldWireRecordFacts, type HistoryWireRecord } from '#/history/foldFacts';
|
|
import {
|
|
transcriptOperationSchema,
|
|
transcriptQuerySchema,
|
|
transcriptResponseSchema,
|
|
transcriptGradeSpecSchema,
|
|
} 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;
|
|
|
|
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', () => {
|
|
// prompt.upsert is a global entity like interaction.upsert: coarse
|
|
// subscribers see queue state too.
|
|
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);
|
|
// Global entities flow at 'turn' grade untouched.
|
|
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', () => {
|
|
// Head unit + exactly pageSize turns: the unit is not a turn slot — the
|
|
// newest page carries it and reports nothing older (a segment-counted
|
|
// page would drop it and hallucinate an older marker-only page).
|
|
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', () => {
|
|
// Every field the projection fills beyond the original model: step
|
|
// usage/finishReason/timing/retry/endReason/endMessage, turn
|
|
// durationMs/error, tool inputText/progress, task
|
|
// resultSummary/error/stateReason/usage, meta.agent, snapshot prompts.
|
|
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', 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',
|
|
timing: {
|
|
llmFirstTokenLatencyMs: 120,
|
|
llmStreamDurationMs: 900,
|
|
llmRequestBuildMs: 5,
|
|
llmServerFirstTokenMs: 110,
|
|
llmServerDecodeMs: 700,
|
|
llmClientConsumeMs: 950,
|
|
},
|
|
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('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)', () => {
|
|
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: '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(['turn', 'turn', 'marker', 'turn']);
|
|
const firstTurn = snapshot.items[0];
|
|
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[2];
|
|
expect(marker?.kind === 'marker' && marker.marker).toBe('compaction');
|
|
});
|
|
|
|
it('expands a bundled prompt into per-skill markers and a caller-text turn', () => {
|
|
const snapshot = groupMessagesIntoSnapshot([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'rendered review block' },
|
|
{ type: 'text', text: 'rendered security block' },
|
|
{ type: 'text', text: 'please /skill:review and /skill:security' },
|
|
],
|
|
toolCalls: [],
|
|
origin: {
|
|
kind: 'user',
|
|
skillActivations: [
|
|
{ activationId: 'act-1', skillName: 'review' },
|
|
{ activationId: 'act-2', skillName: 'security', skillArgs: 'src/app.ts' },
|
|
],
|
|
} as { kind: string },
|
|
},
|
|
{ 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=' } },
|
|
{ type: 'image', source: { kind: 'url', url: 'https://example.com/pic.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(3);
|
|
expect(snapshot.attachments[0]).toMatchObject({
|
|
attachmentId: 'att_1',
|
|
mediaType: 'image/png',
|
|
source: undefined, // base64 bytes never ship
|
|
});
|
|
expect(snapshot.attachments[1]).toMatchObject({
|
|
attachmentId: 'att_2',
|
|
source: { kind: 'url', url: 'https://example.com/pic.png' },
|
|
});
|
|
expect(snapshot.attachments[2]).toMatchObject({
|
|
attachmentId: 'att_3',
|
|
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']);
|
|
});
|
|
|
|
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');
|
|
// No tool message yet: in-flight / approval-gated, not done.
|
|
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: [] },
|
|
]);
|
|
|
|
// A subagent's run prompt launches its own engine turn — the response
|
|
// must not fold into the previous turn. The run prompt itself is internal
|
|
// steering text: the boundary lands promptless.
|
|
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).toBeUndefined();
|
|
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: [] },
|
|
]);
|
|
|
|
// A user-slash activation is a real prompt (engine `isRealUserPrompt`):
|
|
// marker AND its own turn — the response must not fold into the previous
|
|
// turn. A model-tool activation is mid-turn context: marker only.
|
|
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('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: [] },
|
|
]);
|
|
|
|
// The continuation opened a real engine turn: the grouping must advance
|
|
// (0-based ordinals stay aligned with the engine) instead of folding the
|
|
// continuation output into the visible user turn. A mid-turn injection
|
|
// still folds away without splitting the turn.
|
|
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('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('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);
|
|
// Nothing appended: the base items array is reused as-is.
|
|
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(),
|
|
},
|
|
]);
|
|
// Facts append no items for todos.
|
|
expect(folded.items).toBe(base.items);
|
|
});
|
|
|
|
it('folds goal create/update/clear into meta.goal with markers, last write wins', () => {
|
|
const base = baseWithMarker();
|
|
// The base carries one compaction marker (`m1`) — folded markers must
|
|
// continue the numbering instead of colliding.
|
|
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() });
|
|
// Markers append after the base items, in record order.
|
|
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 },
|
|
// A queued cancel left no visible residue — no marker.
|
|
{ type: 'turn.cancel', turnId: 1, target: 'queued', reason: 'user_cancelled', time: 2000 },
|
|
// Programmatic aborts surface through their own outlets — no marker.
|
|
{ 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 },
|
|
// Records written before the reason field existed cannot be attributed.
|
|
{ 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,
|
|
);
|
|
// Plan exited, swarm still active; cold badges are the bare `{}` the live
|
|
// path projects (no persisted review path / trigger detail).
|
|
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('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,
|
|
path: 'agents/main/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,
|
|
);
|
|
// Still active: the badge carries the revision reference.
|
|
expect(folded.meta.modes).toEqual({
|
|
plan: { reviewPath: '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: 'agents/main/plan/plan-1/v2.md',
|
|
sha256: 'deadbeef',
|
|
bytes: 512,
|
|
},
|
|
at: new Date(2000).toISOString(),
|
|
},
|
|
]);
|
|
|
|
// Exit clears the badge; the revision marker stays in the timeline.
|
|
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);
|
|
|
|
// A re-enter after the exit starts a bare badge again (no stale revision).
|
|
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 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',
|
|
// Legacy records omit `detached` — treated as detached.
|
|
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',
|
|
// Never terminated: still running, no end.
|
|
state: 'running',
|
|
detached: false,
|
|
description: 'scan the repo',
|
|
agentId: 'sub-1',
|
|
outputTail: '',
|
|
startedAt: new Date(2000).toISOString(),
|
|
endedAt: undefined,
|
|
},
|
|
]);
|
|
// One taskref per started task, appended after the base items in record order.
|
|
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 },
|
|
],
|
|
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,
|
|
},
|
|
]);
|
|
});
|
|
|
|
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',
|
|
// The anchor is read from the request payload when the record carries
|
|
// no top-level toolCallId (mirrors the live path).
|
|
toolCallId: 'call_9',
|
|
});
|
|
// No ghost pendings, ever.
|
|
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');
|
|
// The base grouping hardcodes 'completed'; the record rewrites it.
|
|
expect(turn.state).toBe('failed');
|
|
// Only the error's message rides the transcript turn (mirrors the live path).
|
|
expect(turn.error).toBe('Overloaded');
|
|
expect(turn.durationMs).toBe(1234);
|
|
expect(turn.endedAt).toBe(new Date(5000).toISOString());
|
|
// Turn ends append no items.
|
|
expect(folded.items).toHaveLength(base.items.length);
|
|
});
|
|
|
|
it('applies turn.ended records last-wins per turn and folds blocked into failed', () => {
|
|
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');
|
|
// The last record replaces the whole terminal upsert — no earlier error.
|
|
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');
|
|
});
|
|
|
|
it('ignores turn.ended records with unknown turn ids or malformed payloads', () => {
|
|
const base = baseWithMarker();
|
|
const folded = 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 turn = folded.items[0];
|
|
if (turn?.kind !== 'turn') throw new Error('expected turn');
|
|
// An unrecognized reason keeps the grouping default; only the timestamp lands.
|
|
expect(turn.state).toBe('completed');
|
|
expect(turn.endedAt).toBe(new Date(3000).toISOString());
|
|
expect(folded.items).toHaveLength(base.items.length);
|
|
});
|
|
|
|
it('maps turn.ended around hidden retry turns replayed from the turn-clock records', () => {
|
|
// Engine turns: 0 = user "one", 1 = retry (hidden — a real newTurn with
|
|
// no context messages), 2 = user "two". The base grouping sees only the
|
|
// two user turns, ordinals 0 and 1.
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
|
|
{ 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.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' }, 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');
|
|
const second = folded.items[1];
|
|
if (second?.kind !== 'turn') throw new Error('expected turn');
|
|
// The retry's failed/error must NOT bleed into the later user turn —
|
|
// it gets its own record (turnId 2 → ordinal 1).
|
|
expect(second.state).toBe('cancelled');
|
|
expect(second.error).toBeUndefined();
|
|
expect(second.durationMs).toBe(20);
|
|
});
|
|
|
|
it('maps turn.ended across queued-then-cancelled turn reservations', () => {
|
|
// Engine turns: 0 = user "one", 1 = reserved then cancelled while queued
|
|
// (never started, no prompt, no messages), 2 = user "two".
|
|
const base = groupMessagesIntoSnapshot([
|
|
{ role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
|
|
{ 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' }, 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');
|
|
// turnId 2 maps past the cancelled reservation onto ordinal 1.
|
|
expect(second.state).toBe('failed');
|
|
expect(second.error).toBe('boom');
|
|
});
|
|
});
|