import type { AttachmentId, InteractionId, PromptId, TaskId, TodoId, TurnId } from '../model/ids'; import { turnOrdinal } from '../model/ids'; import type { TranscriptAttachment } from '../model/attachment'; import type { TranscriptFrame } from '../model/frame'; import type { TranscriptInteraction } from '../model/interaction'; import type { TranscriptItem } from '../model/item'; import type { TranscriptMeta, TranscriptMetaMerge } from '../model/meta'; import type { TranscriptPrompt } from '../model/prompt'; import type { TranscriptTask } from '../model/task'; import type { TranscriptTodo } from '../model/todo'; import type { TranscriptStep, TranscriptTurn } from '../model/turn'; import type { AppendOp, TranscriptOperation, TurnHeader, StepHeader, } from './operation'; export interface AgentState { readonly items: readonly TranscriptItem[]; readonly tasks: ReadonlyMap; readonly interactions: ReadonlyMap; readonly attachments: ReadonlyMap; readonly todos: ReadonlyMap; readonly prompts: ReadonlyMap; readonly meta: TranscriptMeta; readonly pendingInteractions: ReadonlySet; readonly hasMoreOlder: boolean; } export const EMPTY_AGENT_STATE: AgentState = { items: [], tasks: new Map(), interactions: new Map(), attachments: new Map(), todos: new Map(), prompts: new Map(), meta: {}, pendingInteractions: new Set(), hasMoreOlder: false, }; export interface ApplyResult { readonly state: AgentState; readonly changed: boolean; readonly gap?: { readonly expected: number; readonly got: number }; } export function applyOperation(state: AgentState, op: TranscriptOperation): ApplyResult { switch (op.op) { case 'reset': return applyReset(state, op); case 'turn.upsert': return applyTurnUpsert(state, op.turn); case 'step.upsert': return applyStepUpsert(state, op.turnId, op.step); case 'frame.upsert': return applyFrameUpsert(state, op); case 'append': return applyAppend(state, op); case 'marker.upsert': return applyItemUpsert(state, op.item, op.item.markerId, op.beforeTurn); case 'taskref.upsert': return applyItemUpsert(state, op.item, op.item.refId, op.beforeTurn); case 'task.upsert': return applyTaskUpsert(state, op.task); case 'interaction.upsert': return applyInteractionUpsert(state, op.interaction); case 'attachment.upsert': return applyAttachmentUpsert(state, op.attachment); case 'todo.upsert': return applyTodoUpsert(state, op.todo); case 'prompt.upsert': return applyPromptUpsert(state, op.prompt); case 'meta.merge': return applyMetaMerge(state, op.meta); case 'items.remove': return applyItemsRemove(state, op.ids); } } function applyReset(state: AgentState, op: Extract): ApplyResult { const pending = new Set(); for (const interaction of op.snapshot.interactions) { if (interaction.state === 'pending') pending.add(interaction.interactionId); } return { state: { items: op.snapshot.items, tasks: new Map(op.snapshot.tasks.map((task) => [task.taskId, task])), interactions: new Map( op.snapshot.interactions.map((interaction) => [interaction.interactionId, interaction]), ), attachments: new Map( op.snapshot.attachments.map((attachment) => [attachment.attachmentId, attachment]), ), todos: new Map(op.snapshot.todos.map((todo) => [todo.todoId, todo])), prompts: new Map(op.snapshot.prompts.map((prompt) => [prompt.promptId, prompt])), meta: op.snapshot.meta, pendingInteractions: pending, hasMoreOlder: op.snapshot.hasMoreOlder ?? false, }, changed: true, }; } function turnHeaderToTurn(header: TurnHeader, steps: readonly TranscriptStep[]): TranscriptTurn { return { ...header, kind: 'turn', steps: [...steps] }; } function skeletonTurn(turnId: TurnId): TranscriptTurn { return { kind: 'turn', turnId, ordinal: turnOrdinal(turnId), state: 'running', origin: { kind: 'other' }, steps: [], }; } function skeletonStep(stepId: string, turnId: TurnId): TranscriptStep { const ordinal = Number(stepId.slice(turnId.length + 1)) || 0; return { kind: 'step', stepId, turnId, ordinal, state: 'running', frames: [] }; } function getTurn(state: AgentState, turnId: TurnId): TranscriptTurn | undefined { const item = state.items.find((entry) => entry.kind === 'turn' && entry.turnId === turnId); return item?.kind === 'turn' ? item : undefined; } function insertTurn(items: readonly TranscriptItem[], turn: TranscriptTurn): readonly TranscriptItem[] { const next = [...items]; let at = next.length; for (let i = 0; i < next.length; i += 1) { const entry = next[i]; if (entry?.kind === 'turn' && entry.ordinal > turn.ordinal) { at = i; break; } } next.splice(at, 0, turn); return next; } function replaceTurn( items: readonly TranscriptItem[], turnId: TurnId, fn: (turn: TranscriptTurn) => TranscriptTurn, ): readonly TranscriptItem[] { return items.map((entry) => entry.kind === 'turn' && entry.turnId === turnId ? fn(entry) : entry, ); } function applyTurnUpsert(state: AgentState, header: TurnHeader): ApplyResult { const existing = getTurn(state, header.turnId); if (existing) { if (turnEquals(existing, header)) return { state, changed: false }; return { state: { ...state, items: replaceTurn(state.items, header.turnId, (turn) => turnHeaderToTurn(header, turn.steps), ), }, changed: true, }; } return { state: { ...state, items: insertTurn(state.items, turnHeaderToTurn(header, [])) }, changed: true, }; } function turnEquals(turn: TranscriptTurn, header: TurnHeader): boolean { return ( turn.ordinal === header.ordinal && turn.triggerPromptId === header.triggerPromptId && turn.state === header.state && turn.prompt === header.prompt && turn.attachmentIds === header.attachmentIds && turn.startedAt === header.startedAt && turn.endedAt === header.endedAt && turn.origin.kind === header.origin.kind && turn.origin.payload === header.origin.payload && turn.usage === header.usage && turn.durationMs === header.durationMs && turn.error === header.error ); } function applyStepUpsert(state: AgentState, turnId: TurnId, header: StepHeader): ApplyResult { const turn = getTurn(state, turnId) ?? skeletonTurn(turnId); const stepIndex = turn.steps.findIndex((step) => step.stepId === header.stepId); let steps: readonly TranscriptStep[]; let changed = true; if (stepIndex >= 0) { const current = turn.steps[stepIndex]; if (current && stepEquals(current, header)) { changed = false; steps = turn.steps; } else { steps = turn.steps.map((step) => step.stepId === header.stepId ? { ...header, kind: 'step' as const, frames: step.frames } : step, ); } } else { steps = [...turn.steps, { ...header, kind: 'step' as const, frames: [] }].toSorted( (a, b) => a.ordinal - b.ordinal, ); } if (!changed) return { state, changed: false }; const nextTurn: TranscriptTurn = { ...turn, steps: [...steps] }; const items = getTurn(state, turnId) ? replaceTurn(state.items, turnId, () => nextTurn) : insertTurn(state.items, nextTurn); return { state: { ...state, items }, changed: true }; } function stepEquals(step: TranscriptStep, header: StepHeader): boolean { return ( step.ordinal === header.ordinal && step.state === header.state && step.startedAt === header.startedAt && step.endedAt === header.endedAt && step.usage === header.usage && step.finishReason === header.finishReason && step.timing === header.timing && step.retry === header.retry && step.endReason === header.endReason && step.endMessage === header.endMessage ); } function applyFrameUpsert( state: AgentState, op: Extract, ): ApplyResult { const turn = getTurn(state, op.turnId) ?? skeletonTurn(op.turnId); const step = turn.steps.find((entry) => entry.stepId === op.stepId) ?? skeletonStep(op.stepId, op.turnId); const existing = step.frames.findIndex((frame) => frame.frameId === op.frame.frameId); let frames: readonly TranscriptFrame[]; if (existing >= 0) { const current = step.frames[existing]; if (current !== undefined && frameEquals(current, op.frame)) { return { state, changed: false }; } frames = step.frames.map((frame) => (frame.frameId === op.frame.frameId ? op.frame : frame)); } else { frames = [...step.frames, op.frame]; } const nextStep: TranscriptStep = { ...step, frames: [...frames] }; const steps = turn.steps.some((entry) => entry.stepId === op.stepId) ? turn.steps.map((entry) => (entry.stepId === op.stepId ? nextStep : entry)) : [...turn.steps, nextStep].toSorted((a, b) => a.ordinal - b.ordinal); const nextTurn: TranscriptTurn = { ...turn, steps }; const items = getTurn(state, op.turnId) ? replaceTurn(state.items, op.turnId, () => nextTurn) : insertTurn(state.items, nextTurn); return { state: { ...state, items }, changed: true, }; } function frameEquals(a: TranscriptFrame, b: TranscriptFrame): boolean { if (a.kind !== b.kind) return false; if (a.kind === 'text' && b.kind === 'text') { return ( a.text === b.text && a.role === b.role && a.attachmentIds === b.attachmentIds && a.taskId === b.taskId ); } if (a.kind === 'thinking' && b.kind === 'thinking') return a.text === b.text; if (a.kind === 'tool' && b.kind === 'tool') { return ( a.state === b.state && a.toolCallId === b.toolCallId && a.name === b.name && a.view === b.view && a.input === b.input && a.output === b.output && a.display === b.display && a.error === b.error && a.inputText === b.inputText && a.progress === b.progress && a.taskId === b.taskId && a.approvalId === b.approvalId && a.todoId === b.todoId && a.agentRefs === b.agentRefs ); } if (a.kind === 'notice' && b.kind === 'notice') { return a.message === b.message && a.level === b.level && a.detail === b.detail; } return false; } function applyAppend(state: AgentState, op: AppendOp): ApplyResult { if (op.target.type === 'task') return applyTaskAppend(state, op); const { turnId, stepId, frameId } = op.target; const turn = getTurn(state, turnId); const step = turn?.steps.find((entry) => entry.stepId === stepId); const frame = step?.frames.find((entry) => entry.frameId === frameId); if (!turn || !step || !frame || (frame.kind !== 'text' && frame.kind !== 'thinking')) { return { state, changed: false, gap: { expected: 0, got: op.offset } }; } const merged = appendAtOffset(frame.text, op.offset, op.text); if (merged.gap) return { state, changed: false, gap: merged.gap }; if (!merged.changed) return { state, changed: false }; const nextFrame = { ...frame, text: merged.text }; const nextStep: TranscriptStep = { ...step, frames: step.frames.map((entry) => (entry.frameId === frameId ? nextFrame : entry)), }; const nextTurn: TranscriptTurn = { ...turn, steps: turn.steps.map((entry) => (entry.stepId === stepId ? nextStep : entry)), }; return { state: { ...state, items: replaceTurn(state.items, turnId, () => nextTurn) }, changed: true, }; } function applyTaskAppend(state: AgentState, op: AppendOp): ApplyResult { if (op.target.type !== 'task') throw new Error('unreachable'); const taskId = op.target.taskId; const task = state.tasks.get(taskId); const current = task?.outputTail ?? ''; const merged = appendAtOffset(current, op.offset, op.text); if (merged.gap) return { state, changed: false, gap: merged.gap }; if (!merged.changed) return { state, changed: false }; const nextTask: TranscriptTask = task ? { ...task, outputTail: merged.text } : { taskId, kind: 'other', state: 'running', detached: false, outputTail: merged.text }; const tasks = new Map(state.tasks); tasks.set(taskId, nextTask); return { state: { ...state, tasks }, changed: true }; } export function appendAtOffset( local: string, offset: number, chunk: string, ): { text: string; changed: boolean; gap?: { expected: number; got: number } } { if (offset > local.length) return { text: local, changed: false, gap: { expected: local.length, got: offset } }; if (local.slice(offset, offset + chunk.length) === chunk) { return { text: local, changed: false }; } const overlap = local.length - offset; if (local.slice(offset) !== chunk.slice(0, overlap)) { return { text: local, changed: false, gap: { expected: local.length, got: offset } }; } const novel = overlap > 0 ? chunk.slice(overlap) : chunk; if (novel.length === 0) return { text: local, changed: false }; return { text: local.slice(0, offset) + chunk, changed: true }; } function applyItemUpsert( state: AgentState, item: TranscriptItem, id: string, beforeTurn?: number, ): ApplyResult { const exists = state.items.some((entry) => itemIdOf(entry) === id); if (exists) { let changed = false; const items = state.items.map((entry) => { if (itemIdOf(entry) !== id) return entry; if (entry === item) return entry; changed = true; return item; }); if (!changed) return { state, changed: false }; return { state: { ...state, items }, changed: true }; } if (beforeTurn !== undefined) { const items = [...state.items]; let at = items.length; for (let i = 0; i < items.length; i += 1) { const entry = items[i]; if (entry?.kind === 'turn' && entry.ordinal >= beforeTurn) { at = i; break; } } items.splice(at, 0, item); return { state: { ...state, items }, changed: true }; } return { state: { ...state, items: [...state.items, item] }, changed: true }; } function itemIdOf(item: TranscriptItem): string { switch (item.kind) { case 'turn': return item.turnId; case 'marker': return item.markerId; case 'taskref': return item.refId; } } function applyItemsRemove(state: AgentState, ids: readonly string[]): ApplyResult { const drop = new Set(ids); const removedTurns = state.items.filter( (entry): entry is TranscriptTurn => entry.kind === 'turn' && drop.has(entry.turnId), ); const items = state.items.filter((entry) => !drop.has(itemIdOf(entry))); if (items.length === state.items.length) return { state, changed: false }; let pending = state.pendingInteractions; let interactions = state.interactions; if (removedTurns.length > 0) { const anchoredToolCallIds = new Set(); const nextPending = new Set(pending); const deadEntityIds = new Set(); for (const turn of removedTurns) { for (const step of turn.steps) { for (const frame of step.frames) { if (frame.kind === 'tool') anchoredToolCallIds.add(frame.toolCallId); } } } for (const interaction of interactions.values()) { if (interaction.toolCallId !== undefined && anchoredToolCallIds.has(interaction.toolCallId)) { deadEntityIds.add(interaction.interactionId); nextPending.delete(interaction.interactionId); } } if (deadEntityIds.size > 0) { const nextInteractions = new Map(interactions); for (const id of deadEntityIds) nextInteractions.delete(id); interactions = nextInteractions; } pending = nextPending; } return { state: { ...state, items, interactions, pendingInteractions: pending }, changed: true }; } function applyTaskUpsert(state: AgentState, task: TranscriptTask): ApplyResult { const current = state.tasks.get(task.taskId); if (current && taskEquals(current, task)) return { state, changed: false }; const tasks = new Map(state.tasks); tasks.set(task.taskId, task); return { state: { ...state, tasks }, changed: true }; } function applyInteractionUpsert( state: AgentState, interaction: TranscriptInteraction, ): ApplyResult { const current = state.interactions.get(interaction.interactionId); if (current && interactionEquals(current, interaction)) return { state, changed: false }; const interactions = new Map(state.interactions); interactions.set(interaction.interactionId, interaction); let pending = state.pendingInteractions; if (interaction.state === 'pending') { if (!pending.has(interaction.interactionId)) { const next = new Set(pending); next.add(interaction.interactionId); pending = next; } } else if (pending.has(interaction.interactionId)) { const next = new Set(pending); next.delete(interaction.interactionId); pending = next; } return { state: { ...state, interactions, pendingInteractions: pending }, changed: true }; } function interactionEquals(a: TranscriptInteraction, b: TranscriptInteraction): boolean { return ( a.interactionKind === b.interactionKind && a.toolCallId === b.toolCallId && a.state === b.state && a.request === b.request && a.response === b.response ); } function applyAttachmentUpsert( state: AgentState, attachment: TranscriptAttachment, ): ApplyResult { const current = state.attachments.get(attachment.attachmentId); if (current && attachmentEquals(current, attachment)) return { state, changed: false }; const attachments = new Map(state.attachments); attachments.set(attachment.attachmentId, attachment); return { state: { ...state, attachments }, changed: true }; } function attachmentEquals(a: TranscriptAttachment, b: TranscriptAttachment): boolean { return ( a.mediaType === b.mediaType && a.name === b.name && a.size === b.size && a.source === b.source && a.placeholder === b.placeholder ); } function applyTodoUpsert(state: AgentState, todo: TranscriptTodo): ApplyResult { const current = state.todos.get(todo.todoId); if (current && todoEquals(current, todo)) return { state, changed: false }; const todos = new Map(state.todos); todos.set(todo.todoId, todo); return { state: { ...state, todos }, changed: true }; } function todoEquals(a: TranscriptTodo, b: TranscriptTodo): boolean { return a.items === b.items && a.updatedAt === b.updatedAt; } function applyPromptUpsert(state: AgentState, prompt: TranscriptPrompt): ApplyResult { const current = state.prompts.get(prompt.promptId); if (current && promptEquals(current, prompt)) return { state, changed: false }; const prompts = new Map(state.prompts); prompts.set(prompt.promptId, prompt); return { state: { ...state, prompts }, changed: true }; } function promptEquals(a: TranscriptPrompt, b: TranscriptPrompt): boolean { return ( a.status === b.status && a.userMessageId === b.userMessageId && a.content === b.content && a.createdAt === b.createdAt && a.finishedAt === b.finishedAt && a.steeredAt === b.steeredAt ); } function taskEquals(a: TranscriptTask, b: TranscriptTask): boolean { return ( a.kind === b.kind && a.state === b.state && a.detached === b.detached && a.description === b.description && a.agentId === b.agentId && a.outputTail === b.outputTail && a.startedAt === b.startedAt && a.endedAt === b.endedAt && a.resultSummary === b.resultSummary && a.error === b.error && a.stateReason === b.stateReason && a.usage === b.usage ); } function applyMetaMerge(state: AgentState, meta: TranscriptMetaMerge): ApplyResult { const modes = meta.modes !== undefined ? { plan: meta.modes.plan === null ? undefined : (meta.modes.plan ?? state.meta.modes?.plan), swarm: meta.modes.swarm === null ? undefined : (meta.modes.swarm ?? state.meta.modes?.swarm), tower: meta.modes.tower === null ? undefined : (meta.modes.tower ?? state.meta.modes?.tower), } : state.meta.modes; const agent = meta.agent !== undefined ? { ...state.meta.agent, ...meta.agent } : state.meta.agent; const next: TranscriptMeta = { goal: meta.goal === null ? undefined : (meta.goal ?? state.meta.goal), activity: meta.activity ?? state.meta.activity, modes: modes !== undefined && modes.plan === undefined && modes.swarm === undefined && modes.tower === undefined ? undefined : modes, agent, }; if ( next.goal === state.meta.goal && next.activity === state.meta.activity && next.modes === state.meta.modes && next.agent === state.meta.agent ) { return { state, changed: false }; } return { state: { ...state, meta: next }, changed: true }; }