kimi-code/packages/kap-server/src/services/transcript/transcriptService.ts
Luyu Cheng 58b74cfeab
Some checks are pending
CI / lint (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Publish VS Code extension / Publish VSIX to marketplaces (push) Waiting to run
fix(transcript): preserve turn prompt identity (#3377)
* fix(transcript): preserve turn prompt identity

* fix(transcript): backfill active prompt identity

* fix(transcript): seed late-bound turn identity

* fix(transcript): persist turn prompt identity

* fix(transcript): skip undone prompt identities

* fix(transcript): hide undone continuation turns

* docs(agent-core): update wire manifest

* fix(transcript): match cold turns by prompt identity

* fix(transcript): match internal prompt turns by origin

* fix(transcript): align undo anchors with context

* fix(transcript): prioritize cold turn matches

* fix(transcript): retire unmatched turn boundaries

* style(transcript): align cold matcher
2026-08-31 06:41:56 +08:00

751 lines
26 KiB
TypeScript

import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import {
IAgentLifecycleService,
IAgentPromptService,
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<void>;
readonly agentBackfills: Map<string, Promise<void>>;
readonly opsJournals: Map<string, AgentOpsJournal>;
}
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<string, LiveEntry>();
private readonly opsListeners = new Map<
string,
Set<(event: TranscriptChangeEvent, seq: number) => void>
>();
private readonly healTimers = new Map<string, { ordinals: Set<number>; 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),
);
} 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(),
});
return store;
}
async whenReady(sessionId: string): Promise<void> {
await this.live.get(sessionId)?.ready;
}
async ensureAgentHistory(sessionId: string, agentId: string): Promise<void> {
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<void> {
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<void> {
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).status();
if (status?.state !== 'running' || status.activeTurnId === undefined) return undefined;
const promptService = agent?.accessor.get(IAgentPromptService);
const activePromptId = promptService?.list().active?.id;
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);
const promptService = agent === undefined ? undefined : agent.accessor.get(IAgentPromptService);
const queue = promptService?.list();
if (queue === undefined) return [];
const ops: TranscriptOperation[] = [];
if (queue.active !== undefined) {
ops.push({
op: 'prompt.upsert',
prompt: {
promptId: queue.active.id,
status: 'running',
userMessageId: queue.active.userMessageId,
content: projectPromptContentParts(queue.active.message.content),
createdAt: queue.active.createdAt,
},
});
}
for (const pending of queue.pending) {
ops.push({
op: 'prompt.upsert',
prompt: {
promptId: pending.id,
status: 'queued',
userMessageId: pending.userMessageId,
content: projectPromptContentParts(pending.message.content),
createdAt: pending.createdAt,
},
});
}
return ops;
}
private async healEndedTurns(
sessionId: string,
agentId: string,
ordinals: ReadonlySet<number>,
): Promise<void> {
const entry = this.live.get(sessionId);
if (entry === undefined) return;
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;
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<AgentDescriptor[] | undefined> {
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<AgentTranscriptSnapshot | undefined> {
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<ReturnType<typeof readWireRecords>>;
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<string>();
const steeredContents = new Map<string, Map<string, number>>();
const anchorStack: { taskIdsSnapshot: Set<string> }[] = [];
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()!;
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) });
}
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 = steeredContents.get(key) ?? new Map<string, number>();
byKind.set(kind, (byKind.get(kind) ?? 0) + 1);
steeredContents.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);
}
}
const base = groupMessagesIntoSnapshot(
messages,
sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined,
);
const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base);
const status = getLiveSessionById(this.deps.core.accessor, sessionId)
?.accessor.get(IAgentLifecycleService)
.handleOf(agentId)
?.accessor.get(IAgentLoopService)
.status();
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<boolean> {
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<TranscriptTurn['state']> = 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<string> {
const superseded = new Set<string>();
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;
}