import type { UserPromptOrigin } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; import { join } from 'node:path'; import { readFile } from 'node:fs/promises'; import { IAgentLifecycleService, IAgentContextMemoryService, IFlagService, ISessionIndex, ISessionManager, ISessionMetadata, IAgentLoopService, TOWER_FLAG_ID, followSessionLifecycles, getLiveSessionById, isTowerFeatureAssembled, isUndoAnchor, reduceContextTranscript, type ContextMessage, type IDisposable, type Scope, type SessionMeta, } from '@moonshot-ai/agent-core-v2'; import { TowerStore, resolveTowerRepoRoot, } from '@moonshot-ai/agent-core-v2/features/tower/protocol/index'; import { TranscriptStore, foldWireRecordFacts, groupMessagesIntoSnapshot, isPlainAgentId, type AgentDescriptor, type ActivityMeta, type AgentTranscript, type AgentTranscriptSnapshot, type TranscriptChangeEvent, type TranscriptMarker, type TranscriptOperation, type TranscriptTaskRef, type TranscriptTurn, } from '@moonshot-ai/transcript'; import { readWireRecords, type ContextRecord } from './wireRecords'; import { toWireQuestion } from '../../protocol/question-wire'; import { projectPromptContentParts } from '../messages/messageProjection'; import { bindSessionTranscript, descriptorFromMeta, type TranscriptBinding, type TranscriptBindingLogger, } from './coreBinding'; const SESSIONS_ROOT = 'sessions'; const AGENTS_DIR = 'agents'; const MAIN_AGENT_ID = 'main'; const WIRE_FILE = 'wire.jsonl'; const STATE_FILE = 'state.json'; export interface TranscriptServiceDeps { readonly homeDir: string; readonly core: Scope; readonly logger?: TranscriptBindingLogger; } interface LiveEntry { readonly store: TranscriptStore; readonly binding: TranscriptBinding; readonly ready: Promise; readonly agentBackfills: Map>; readonly opsJournals: Map; readonly undoGenerations: Map; } interface AgentOpsJournal { nextSeq: number; batches: { seq: number; ops: TranscriptOperation[] }[]; } export const TRANSCRIPT_OPS_JOURNAL_CAPACITY = 2000; export interface TranscriptOpsCatchup { readonly batches: readonly { seq: number; ops: readonly TranscriptOperation[] }[]; readonly latestSeq: number; readonly complete: boolean; } export class TranscriptService { private readonly live = new Map(); private readonly opsListeners = new Map< string, Set<(event: TranscriptChangeEvent, seq: number) => void> >(); private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); constructor(private readonly deps: TranscriptServiceDeps) { followSessionLifecycles(deps.core.accessor, (service) => { const d1 = service.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); const d2 = service.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); return { dispose: () => { d1.dispose(); d2.dispose(); }, }; }); } forSessionLive(sessionId: string): TranscriptStore | undefined { const existing = this.live.get(sessionId); if (existing !== undefined) { if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) { return existing.store; } this.dropSession(sessionId); return undefined; } const session = getLiveSessionById(this.deps.core.accessor, sessionId); if (session === undefined) return undefined; const store = new TranscriptStore(sessionId); let binding: TranscriptBinding; try { binding = bindSessionTranscript( store, session, this.deps.logger, (event) => this.handleLiveOps(sessionId, event), (agentId) => this.rebuildAfterUndo(sessionId, agentId), ); } catch (error) { if (error instanceof Error && error.message === 'InstantiationService has been disposed') { return undefined; } throw error; } this.live.set(sessionId, { store, binding, ready: (async () => { await this.backfillMain(sessionId, store); if (this.live.get(sessionId)?.store === store) { binding.seedPendingInteractions(MAIN_AGENT_ID); } })(), agentBackfills: new Map(), opsJournals: new Map(), undoGenerations: new Map(), }); return store; } async whenReady(sessionId: string): Promise { await this.live.get(sessionId)?.ready; } async ensureAgentHistory(sessionId: string, agentId: string): Promise { if (agentId === MAIN_AGENT_ID) return this.whenReady(sessionId); const entry = this.live.get(sessionId); if (entry === undefined) return; await entry.ready; let backfill = entry.agentBackfills.get(agentId); if (backfill === undefined) { backfill = this.backfillAgent(sessionId, entry.store, agentId); entry.agentBackfills.set(agentId, backfill); } await backfill; if (this.live.get(sessionId)?.store === entry.store) { entry.binding.seedPendingInteractions(agentId); } } private async backfillMain(sessionId: string, store: TranscriptStore): Promise { await this.backfillAgent(sessionId, store, MAIN_AGENT_ID); if (this.live.get(sessionId)?.store !== store) return; try { const session = getLiveSessionById(this.deps.core.accessor, sessionId); const meta = await session?.accessor.get(ISessionMetadata).read(); for (const [agentId, agentMeta] of Object.entries(meta?.agents ?? {})) { store.describeAgent(descriptorFromMeta(agentId, agentMeta)); } } catch { } } private async backfillAgent(sessionId: string, store: TranscriptStore, agentId: string): Promise { let snapshot: AgentTranscriptSnapshot | undefined; try { snapshot = await this.readColdSnapshot(sessionId, agentId); } catch (error) { this.deps.logger?.warn( { sessionId, agentId, err: error instanceof Error ? error.message : error }, 'transcript: history backfill failed, continuing without it', ); } if (this.live.get(sessionId)?.store !== store) return; const transcript = store.ensureAgent(agentId); if (snapshot !== undefined) { const superseded = supersededColdAttachmentIds(snapshot, transcript); const ops = snapshotToOps(snapshot, (turn) => healTurnOps(turn, transcript.getTurn(turn.turnId)), ).filter( (op) => op.op !== 'attachment.upsert' || !superseded.has(op.attachment.attachmentId), ); const overlay = this.liveTurnOverlay(sessionId, agentId, transcript, snapshot); if (overlay !== undefined) ops.push(overlay, { op: 'meta.merge', meta: { activity: 'turn' } }); ops.push(...this.livePromptBackfill(sessionId, agentId)); const result = transcript.apply(ops); if (result.gap !== undefined) { this.deps.logger?.warn({ sessionId, agentId, gap: result.gap }, 'transcript: backfill append gap'); } this.dispatchOps(sessionId, { agentId, ops }); } const existing = store.agents().find((d) => d.agentId === agentId); const hasContent = snapshot !== undefined && (snapshot.items.length > 0 || snapshot.tasks.length > 0); if (existing !== undefined || hasContent) { store.describeAgent({ agentId, type: existing?.type ?? (agentId === MAIN_AGENT_ID ? 'main' : 'sub'), parentAgentId: existing?.parentAgentId, label: existing?.label, createdAt: existing?.createdAt, }); } } onSessionOps( sessionId: string, listener: (event: TranscriptChangeEvent, seq: number) => void, ): IDisposable | undefined { if (this.forSessionLive(sessionId) === undefined) return undefined; let listeners = this.opsListeners.get(sessionId); if (listeners === undefined) { listeners = new Set(); this.opsListeners.set(sessionId, listeners); } listeners.add(listener); return { dispose: () => { const entry = this.opsListeners.get(sessionId); if (entry === undefined) return; entry.delete(listener); if (entry.size === 0) this.opsListeners.delete(sessionId); }, }; } private dispatchOps(sessionId: string, event: TranscriptChangeEvent): void { const seq = this.journalOps(sessionId, event); const listeners = this.opsListeners.get(sessionId); if (listeners === undefined) return; for (const listener of listeners) { try { listener(event, seq); } catch { } } } private journalOps(sessionId: string, event: TranscriptChangeEvent): number { const entry = this.live.get(sessionId); if (entry === undefined) return 0; let journal = entry.opsJournals.get(event.agentId); if (journal === undefined) { journal = { nextSeq: 1, batches: [] }; entry.opsJournals.set(event.agentId, journal); } const seq = journal.nextSeq++; journal.batches.push({ seq, ops: [...event.ops] }); if (journal.batches.length > TRANSCRIPT_OPS_JOURNAL_CAPACITY) journal.batches.shift(); return seq; } getSeqWatermark(sessionId: string, agentId: string): number { const journal = this.live.get(sessionId)?.opsJournals.get(agentId); return journal === undefined ? 0 : journal.nextSeq - 1; } getOpsSince( sessionId: string, agentId: string, sinceSeq: number, ): TranscriptOpsCatchup | undefined { if (this.forSessionLive(sessionId) === undefined) return undefined; const journal = this.live.get(sessionId)?.opsJournals.get(agentId); const latestSeq = journal === undefined ? 0 : journal.nextSeq - 1; if (sinceSeq > latestSeq) return { batches: [], latestSeq, complete: false }; const batches = journal?.batches.filter((batch) => batch.seq > sinceSeq) ?? []; const oldest = journal?.batches[0]?.seq; const complete = batches.length === 0 || (oldest !== undefined && oldest <= sinceSeq + 1); return { batches, latestSeq, complete }; } private handleLiveOps(sessionId: string, event: TranscriptChangeEvent): void { this.dispatchOps(sessionId, event); for (const op of event.ops) { if (op.op === 'turn.upsert' && TERMINAL_TURN_STATES.has(op.turn.state)) { this.scheduleTurnHeal(sessionId, event.agentId, op.turn.ordinal); } } } private scheduleTurnHeal(sessionId: string, agentId: string, ordinal: number): void { const key = `${sessionId}:${agentId}`; const existing = this.healTimers.get(key); if (existing !== undefined) { existing.ordinals.add(ordinal); existing.timer.refresh(); return; } const ordinals = new Set([ordinal]); const timer = setTimeout(() => { this.healTimers.delete(key); void this.healEndedTurns(sessionId, agentId, ordinals); }, TURN_HEAL_DEBOUNCE_MS); timer.unref(); this.healTimers.set(key, { ordinals, timer }); } private liveTurnOverlay( sessionId: string, agentId: string, transcript: AgentTranscript, snapshot: AgentTranscriptSnapshot, ): TranscriptOperation | undefined { const session = getLiveSessionById(this.deps.core.accessor, sessionId); const agent = session === undefined ? undefined : session.accessor.get(IAgentLifecycleService).handleOf(agentId); const status = agent?.accessor.get(IAgentLoopService).snapshot(); if (status?.state !== 'running' || status.activeTurnId === undefined) return undefined; const activePromptId = status.activePromptId; const ordinal = status.activeTurnId; const turnId = `t${ordinal}`; const existing = transcript.getTurn(turnId); const snapshotTurn = snapshot.items.find( (item): item is TranscriptTurn => item.kind === 'turn' && item.ordinal === ordinal, ); return { op: 'turn.upsert', turn: { kind: 'turn', turnId, ordinal, state: 'running', triggerPromptId: existing?.triggerPromptId ?? snapshotTurn?.triggerPromptId ?? activePromptId, origin: existing?.origin ?? snapshotTurn?.origin ?? { kind: 'other' }, prompt: existing?.prompt ?? snapshotTurn?.prompt, attachmentIds: existing?.attachmentIds ?? snapshotTurn?.attachmentIds, startedAt: existing?.startedAt ?? snapshotTurn?.startedAt, }, }; } private livePromptBackfill(sessionId: string, agentId: string): TranscriptOperation[] { const agent = getLiveSessionById(this.deps.core.accessor, sessionId) ?.accessor.get(IAgentLifecycleService) .handleOf(agentId); if (agent === undefined) return []; const loop = agent.accessor.get(IAgentLoopService); const snapshot = loop.snapshot(); const ops: TranscriptOperation[] = []; const activeHandle = snapshot.activePromptId === undefined ? undefined : loop.promptHandle(snapshot.activePromptId); if (activeHandle !== undefined) { const activeOrigin = activeHandle.message.origin; ops.push({ op: 'prompt.upsert', prompt: { promptId: activeHandle.id, status: 'running', userMessageId: activeHandle.userMessageId, content: projectPromptContentParts(activeHandle.message.content), createdAt: activeHandle.createdAt, clientMetadata: activeOrigin?.kind === 'user' || activeOrigin?.kind === 'skill_activation' ? activeOrigin.clientMetadata : undefined, }, }); } for (const item of snapshot.queue) { if (item.meta?.tracked !== true) continue; ops.push({ op: 'prompt.upsert', prompt: { promptId: item.meta?.promptId ?? '', status: 'queued', userMessageId: item.meta?.userMessageId ?? '', content: projectPromptContentParts(item.message.content), createdAt: item.meta?.createdAt ?? '', clientMetadata: (item.meta?.origin as UserPromptOrigin | undefined)?.clientMetadata, }, }); } return ops; } private async rebuildAfterUndo(sessionId: string, agentId: string): Promise { const entry = this.live.get(sessionId); if (entry === undefined) return; entry.undoGenerations.set(agentId, (entry.undoGenerations.get(agentId) ?? 0) + 1); const key = `${sessionId}:${agentId}`; const pending = this.healTimers.get(key); if (pending !== undefined) { clearTimeout(pending.timer); this.healTimers.delete(key); } await entry.ready; await entry.agentBackfills.get(agentId); let snapshot: AgentTranscriptSnapshot | undefined; for (let attempt = 0; attempt < 2; attempt++) { try { snapshot = await this.readColdSnapshot(sessionId, agentId); if (snapshot !== undefined) break; } catch (error) { this.deps.logger?.warn( { sessionId, agentId, err: error instanceof Error ? error.message : error }, 'transcript: undo history read failed', ); } } if (snapshot === undefined) { const agent = getLiveSessionById(this.deps.core.accessor, sessionId) ?.accessor.get(IAgentLifecycleService).handleOf(agentId); if (agent !== undefined) { const current = entry.store.ensureAgent(agentId).snapshot(); const retained = groupMessagesIntoSnapshot(agent.accessor.get(IAgentContextMemoryService).get()); snapshot = { ...current, items: retained.items, attachments: retained.attachments, prompts: [] }; } } if (snapshot === undefined || this.live.get(sessionId) !== entry) return; const ops: TranscriptOperation[] = [{ op: 'reset', agentId, snapshot }]; entry.store.ensureAgent(agentId).apply(ops); this.dispatchOps(sessionId, { agentId, ops }); } private async healEndedTurns( sessionId: string, agentId: string, ordinals: ReadonlySet, ): Promise { const entry = this.live.get(sessionId); if (entry === undefined) return; const generation = entry.undoGenerations.get(agentId) ?? 0; let snapshot: AgentTranscriptSnapshot | undefined; try { snapshot = await this.readColdSnapshot(sessionId, agentId); } catch (error) { this.deps.logger?.warn( { sessionId, agentId, err: error instanceof Error ? error.message : error }, 'transcript: post-turn heal failed, continuing without it', ); return; } if (snapshot === undefined || this.live.get(sessionId)?.store !== entry.store) return; if ((entry.undoGenerations.get(agentId) ?? 0) !== generation) return; const transcript = entry.store.getAgent(agentId); if (transcript === undefined) return; const turnOps: TranscriptOperation[] = []; for (const item of snapshot.items) { if (item.kind !== 'turn' || !ordinals.has(item.ordinal)) continue; turnOps.push(...healTurnOps(item, transcript.getTurn(item.turnId))); } if (turnOps.length === 0) return; const superseded = supersededColdAttachmentIds(snapshot, transcript); const ops: TranscriptOperation[] = [ ...snapshot.attachments .filter((attachment) => !superseded.has(attachment.attachmentId)) .map((attachment) => ({ op: 'attachment.upsert' as const, attachment, })), ...turnOps, ]; transcript.apply(ops); this.dispatchOps(sessionId, { agentId, ops }); } async readColdRoster(sessionId: string): Promise { const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); if (summary === undefined) return undefined; let meta: SessionMeta; try { const raw = await readFile( join(this.deps.homeDir, SESSIONS_ROOT, summary.workspaceId, sessionId, STATE_FILE), 'utf-8', ); meta = JSON.parse(raw) as SessionMeta; } catch { return []; } return Object.entries(meta.agents ?? {}).map(([agentId, agentMeta]) => descriptorFromMeta(agentId, agentMeta), ); } async readColdSnapshot( sessionId: string, agentId: string = MAIN_AGENT_ID, ): Promise { const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); if (summary === undefined) return undefined; if (!isPlainAgentId(agentId)) { return groupMessagesIntoSnapshot([]); } const wirePath = join( this.deps.homeDir, SESSIONS_ROOT, summary.workspaceId, sessionId, AGENTS_DIR, agentId, WIRE_FILE, ); let records: Awaited>; try { records = await readWireRecords(wirePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return groupMessagesIntoSnapshot([]); } throw error; } const messages = [...reduceContextTranscript(records).entries]; const taskOriginTurnTaskIds = new Set(); const steeredContents = new Map>(); const pendingSteers = new Map>(); const matchedSteers: { key: string; kind: string }[] = []; const anchorStack: { taskIdsSnapshot: Set; steerCount: number }[] = []; let anchorFloor = 0; let sawTurnPrompt = false; for (const record of records) { if (record.type === 'context.undo') { const count = typeof record['count'] === 'number' ? (record['count'] as number) : 0; for (let i = 0; i < count && anchorStack.length > anchorFloor; i++) { const popped = anchorStack.pop()!; matchedSteers.length = popped.steerCount; taskOriginTurnTaskIds.clear(); for (const id of popped.taskIdsSnapshot) taskOriginTurnTaskIds.add(id); } continue; } if (record.type === 'context.clear') { anchorFloor = anchorStack.length; continue; } if (record.type === 'context.append_message') { const message = (record as { message?: ContextMessage }).message; if (message !== undefined && isUndoAnchor(message)) { anchorStack.push({ taskIdsSnapshot: new Set(taskOriginTurnTaskIds), steerCount: matchedSteers.length }); } if (message?.role === 'user') { const key = JSON.stringify(message.content); const kind = message.origin?.kind ?? 'user'; const pendingByKind = pendingSteers.get(key); const remaining = pendingByKind?.get(kind) ?? 0; if (remaining > 0) { pendingByKind!.set(kind, remaining - 1); matchedSteers.push({ key, kind }); } } continue; } if (record.type === 'turn.steer') { const input = record['input']; if (Array.isArray(input)) { const key = JSON.stringify(input); const steerOrigin = (record as { origin?: { kind?: unknown } }).origin?.kind; const kind = typeof steerOrigin === 'string' ? steerOrigin : 'user'; const byKind = pendingSteers.get(key) ?? new Map(); byKind.set(kind, (byKind.get(kind) ?? 0) + 1); pendingSteers.set(key, byKind); } continue; } if (record.type !== 'turn.prompt') continue; sawTurnPrompt = true; const origin = (record as { origin?: { kind?: unknown; taskId?: unknown } }).origin; if (origin === undefined) continue; if ( (origin.kind === 'task' || origin.kind === 'background_task') && typeof origin.taskId === 'string' ) { taskOriginTurnTaskIds.add(origin.taskId); } } for (const steer of matchedSteers) { const byKind = steeredContents.get(steer.key) ?? new Map(); byKind.set(steer.kind, (byKind.get(steer.kind) ?? 0) + 1); steeredContents.set(steer.key, byKind); } const base = groupMessagesIntoSnapshot( messages, sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined, ); const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base, { resolvePlanRevisionKey: (key) => join(SESSIONS_ROOT, summary.workspaceId, sessionId, AGENTS_DIR, agentId, key), }); const status = getLiveSessionById(this.deps.core.accessor, sessionId) ?.accessor.get(IAgentLifecycleService) .handleOf(agentId) ?.accessor.get(IAgentLoopService) .snapshot(); const activity: ActivityMeta = status?.state === 'running' ? 'turn' : 'idle'; const snapshot = { ...folded, meta: { ...folded.meta, activity } }; if (snapshot.meta.modes?.tower === undefined) return snapshot; const flags = this.deps.core.accessor.get(IFlagService); if ( agentId === MAIN_AGENT_ID && flags.enabled(TOWER_FLAG_ID) && isTowerFeatureAssembled(flags) && (await this.coldTowerOwnedHere(sessionId, summary.cwd)) ) { return snapshot; } const modes = { ...snapshot.meta.modes, tower: undefined }; const cleared = modes.plan === undefined && modes.swarm === undefined && modes.tower === undefined; return { ...snapshot, meta: { ...snapshot.meta, modes: cleared ? undefined : modes } }; } private async coldTowerOwnedHere(sessionId: string, cwd: string | undefined): Promise { if (cwd === undefined) return true; const owner = await new TowerStore(resolveTowerRepoRoot(cwd)) .load() .then((state) => state.sessionId, () => undefined); if (owner === undefined || owner === sessionId) return true; return this.deps.core.accessor.get(ISessionManager).get(owner) === undefined; } dropSession(sessionId: string): void { this.opsListeners.delete(sessionId); for (const [key, pending] of this.healTimers) { if (key.startsWith(`${sessionId}:`)) { clearTimeout(pending.timer); this.healTimers.delete(key); } } const entry = this.live.get(sessionId); if (entry === undefined) return; this.live.delete(sessionId); entry.binding.dispose(); } } export function snapshotToOps( snapshot: AgentTranscriptSnapshot, turnOps: (turn: TranscriptTurn) => TranscriptOperation[] = snapshotTurnOps, ): TranscriptOperation[] { const ops: TranscriptOperation[] = []; const pending: (TranscriptMarker | TranscriptTaskRef)[] = []; let lastTurnOrdinal: number | undefined; const flushPending = (beforeTurn?: number): void => { for (const item of pending) { ops.push( item.kind === 'marker' ? { op: 'marker.upsert', item, beforeTurn } : { op: 'taskref.upsert', item, beforeTurn }, ); } pending.length = 0; }; for (const item of snapshot.items) { if (item.kind === 'turn') { flushPending(item.ordinal); lastTurnOrdinal = item.ordinal; ops.push(...turnOps(item)); } else { pending.push(item); } } flushPending(lastTurnOrdinal === undefined ? undefined : lastTurnOrdinal + 1); for (const attachment of snapshot.attachments) { ops.push({ op: 'attachment.upsert', attachment }); } for (const task of snapshot.tasks) { ops.push({ op: 'task.upsert', task }); } ops.push({ op: 'meta.merge', meta: snapshot.meta }); return ops; } export function snapshotTurnOps(turn: TranscriptTurn): TranscriptOperation[] { const ops: TranscriptOperation[] = []; const { steps, ...header } = turn; ops.push({ op: 'turn.upsert', turn: header }); for (const step of steps) { const { frames, ...stepHeader } = step; ops.push({ op: 'step.upsert', turnId: turn.turnId, step: stepHeader }); for (const frame of frames) { ops.push({ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }); } } return ops; } const TURN_HEAL_DEBOUNCE_MS = 250; const TERMINAL_TURN_STATES: ReadonlySet = new Set([ 'completed', 'failed', 'cancelled', ]); function projectQuestionInteractionRecords( records: readonly ContextRecord[], sessionId: string, ): ContextRecord[] { return records.map((record) => { if (record.type !== 'interaction.request' || record['kind'] !== 'question') return record; const id = record['id']; const request = record['request']; const time = record['time']; if (typeof id !== 'string' || typeof time !== 'number' || !Number.isFinite(time)) { return record; } if (request === null || typeof request !== 'object') return record; try { const innerToolCallId = (request as { toolCallId?: unknown }).toolCallId; const toolCallId = typeof record['toolCallId'] === 'string' ? record['toolCallId'] : typeof innerToolCallId === 'string' ? innerToolCallId : undefined; return { ...record, toolCallId, request: toWireQuestion({ id, createdAt: time, payload: request }, sessionId), }; } catch { return record; } }); } function supersededColdAttachmentIds( snapshot: AgentTranscriptSnapshot, transcript: AgentTranscript, ): ReadonlySet { const superseded = new Set(); for (const item of snapshot.items) { if (item.kind !== 'turn' || item.attachmentIds === undefined) continue; const live = transcript.getTurn(item.turnId); if (live?.attachmentIds === undefined || live.attachmentIds.length === 0) continue; for (const id of item.attachmentIds) superseded.add(id); } return superseded; } export function healTurnOps( snapshotTurn: TranscriptTurn, liveTurn: TranscriptTurn | undefined, ): TranscriptOperation[] { const { steps, ...header } = snapshotTurn; const ops: TranscriptOperation[] = []; if (liveTurn === undefined) { ops.push({ op: 'turn.upsert', turn: header }); for (const step of steps) { const { frames, ...stepHeader } = step; ops.push({ op: 'step.upsert', turnId: snapshotTurn.turnId, step: stepHeader }); for (const frame of frames) { ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); } } return ops; } ops.push({ op: 'turn.upsert', turn: { ...header, state: liveTurn.state, triggerPromptId: liveTurn.triggerPromptId ?? header.triggerPromptId, prompt: liveTurn.prompt ?? header.prompt, attachmentIds: liveTurn.attachmentIds ?? header.attachmentIds, startedAt: liveTurn.startedAt ?? header.startedAt, endedAt: liveTurn.endedAt ?? header.endedAt, }, }); for (const step of steps) { const liveStep = liveTurn.steps.find((entry) => entry.stepId === step.stepId); const { frames, ...stepHeader } = step; if (liveStep === undefined) { ops.push({ op: 'step.upsert', turnId: snapshotTurn.turnId, step: stepHeader }); for (const frame of frames) { ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); } continue; } for (const frame of frames) { const liveFrame = liveStep.frames.find((entry) => entry.frameId === frame.frameId); if (frame.kind === 'tool') { const liveTool = liveFrame?.kind === 'tool' ? liveFrame : undefined; const liveHasOutcome = liveTool !== undefined && (liveTool.output !== undefined || liveTool.error !== undefined); const snapshotHasOutcome = frame.output !== undefined || frame.error !== undefined; if (liveTool !== undefined && (liveHasOutcome || !snapshotHasOutcome)) continue; ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame: liveTool === undefined ? frame : { ...frame, display: liveTool.display ?? frame.display, agentRefs: liveTool.agentRefs ?? frame.agentRefs, approvalId: liveTool.approvalId ?? frame.approvalId, }, }); continue; } if (frame.kind !== 'text' && frame.kind !== 'thinking') continue; if ( liveFrame !== undefined && liveFrame.kind === frame.kind && (liveFrame.kind === 'text' || liveFrame.kind === 'thinking') && liveFrame.text.length >= frame.text.length ) { continue; } ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); } } return ops; }