feat(transcript): derived transcript view — append-only session log as source of truth (#5086)

This commit is contained in:
Steven Enamakel
2026-07-21 16:03:01 +03:00
committed by GitHub
parent 49a0d3b1d5
commit 00846fdfd0
35 changed files with 4698 additions and 207 deletions
@@ -103,7 +103,7 @@ import {
clearRuntimeForThread,
clearThreadSendPending,
enqueueFollowup,
fetchAndHydrateTurnHistory,
fetchAndHydrateDerivedTranscript,
fetchAndHydrateTurnState,
hydrateThreadUsage,
markSubagentCancelled,
@@ -776,8 +776,12 @@ const Conversations = ({
if (selectedThreadId) {
void dispatch(loadThreadMessages(selectedThreadId));
void dispatch(fetchAndHydrateTurnState(selectedThreadId));
// Per-turn history: each past answer's own process trail (Phase 5).
void dispatch(fetchAndHydrateTurnHistory(selectedThreadId));
// Per-turn history: each past answer's own process trail. Phase C derives
// this from the append-only transcript projection
// (`threads_transcript_get`), auto-falling back to the legacy
// `turn_state_history` hydration when the derived path is off, errors, or
// the thread has no persisted transcript (legacy thread).
void dispatch(fetchAndHydrateDerivedTranscript(selectedThreadId));
void threadApi
.getTaskBoard(selectedThreadId)
.then(board => {
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';
import { store } from '../../../store';
import type { DerivedDisplayItem } from '../../../types/derivedTranscript';
import { PastTurnInsights } from '../components/PastTurnInsights';
import { mapDisplayItems } from './mapDisplayItems';
function renderInStore(ui: React.ReactNode) {
return render(<Provider store={store}>{ui}</Provider>);
}
/** Newest-first page from chronological items (as the RPC returns). */
function newestFirst(chronological: DerivedDisplayItem[]): DerivedDisplayItem[] {
return [...chronological].reverse();
}
describe('derived transcript restore (mapper → PastTurnInsights)', () => {
it('renders a restored turn with reasoning, tool rows, and a sub-agent trail', () => {
// A settled turn's projected display items, exactly as `threads_transcript_get`
// returns them (newest-first). This is a NON-newest turn so the mapper keeps it.
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'userMessage', content: 'research this', requestId: 'req-1' },
{ kind: 'reasoning', text: 'planning the research' },
{ kind: 'assistantMessage', content: 'searching now', interim: true, requestId: 'req-1' },
{
kind: 'toolCall',
callId: 'c1',
name: 'read_file',
args: { path: 'notes.md' },
result: 'ok',
status: 'success',
},
{
kind: 'subagent',
id: 'researcher',
items: [
{ kind: 'reasoning', text: 'child reasoning trail' },
{
kind: 'toolCall',
callId: 'child-1',
name: 'web_search',
args: { q: 'topic' },
result: 'hits',
status: 'success',
},
],
},
{ kind: 'assistantMessage', content: 'the final answer', requestId: 'req-1' },
// A later turn so req-1 is not the newest (which the mapper would skip).
{ kind: 'turnBoundary', requestId: 'req-2' },
{ kind: 'reasoning', text: 'newest turn thought' },
];
const { timelines, transcripts } = mapDisplayItems(newestFirst(chronological));
renderInStore(
<PastTurnInsights entries={timelines['req-1']} transcript={transcripts['req-1']} />
);
// Reasoning replays.
expect(screen.getByTestId('processing-thinking').textContent).toContain(
'planning the research'
);
// Interim narration replays (not the final answer — that renders from the message).
expect(screen.getByTestId('processing-transcript').textContent).toContain('searching now');
expect(screen.getByTestId('processing-transcript').textContent).not.toContain(
'the final answer'
);
// Tool rows render.
expect(screen.getAllByTestId('processing-tool-row').length).toBeGreaterThan(0);
// The sub-agent's own reasoning trail renders beneath.
const subagents = screen.getByTestId('past-turn-subagents');
expect(subagents.textContent).toContain('child reasoning trail');
});
});
@@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest';
import type { DerivedDisplayItem } from '../../../types/derivedTranscript';
import { mapDisplayItems } from './mapDisplayItems';
/**
* Build a newest-first page (as the RPC returns) from chronological items — the
* mapper is responsible for reversing back to display order.
*/
function newestFirst(chronological: DerivedDisplayItem[]): DerivedDisplayItem[] {
return [...chronological].reverse();
}
describe('mapDisplayItems', () => {
it('projects reasoning + interim narration + tool call for one turn, skipping the final answer', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'userMessage', content: 'hello', requestId: 'req-1' },
{ kind: 'reasoning', text: 'let me think' },
{ kind: 'assistantMessage', content: 'looking it up', interim: true, requestId: 'req-1' },
{
kind: 'toolCall',
callId: 'call-a',
name: 'shell',
args: { cmd: 'ls' },
result: 'file.txt',
status: 'success',
},
{ kind: 'assistantMessage', content: 'here is the answer', requestId: 'req-1' },
];
const { timelines, transcripts, interrupted } = mapDisplayItems(newestFirst(chronological));
// The final (non-interim) answer and the user text are NOT emitted — they
// render from the thread message list.
expect(interrupted).toEqual([]);
expect(Object.keys(transcripts)).toEqual(['req-1']);
expect(transcripts['req-1']).toEqual([
{ kind: 'thinking', round: 0, seq: 0, text: 'let me think' },
{ kind: 'narration', round: 0, seq: 1, text: 'looking it up' },
{ kind: 'toolCall', round: 0, seq: 2, callId: 'call-a' },
]);
expect(timelines['req-1']).toEqual([
expect.objectContaining({
id: 'call-a',
name: 'shell',
seq: 2,
status: 'success',
argsBuffer: JSON.stringify({ cmd: 'ls' }),
result: 'file.txt',
}),
]);
});
it('preserves chronological (issue) order when reversing a newest-first page across turns', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'reasoning', text: 'turn one thought' },
{ kind: 'turnBoundary', requestId: 'req-2' },
{ kind: 'reasoning', text: 'turn two thought' },
];
const { transcripts } = mapDisplayItems(newestFirst(chronological));
expect(Object.keys(transcripts).sort()).toEqual(['req-1', 'req-2']);
expect(transcripts['req-1']).toEqual([
{ kind: 'thinking', round: 0, seq: 0, text: 'turn one thought' },
]);
expect(transcripts['req-2']).toEqual([
{ kind: 'thinking', round: 0, seq: 0, text: 'turn two thought' },
]);
});
it('maps a running (unpaired) tool call to a settled cancelled row', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'toolCall', callId: 'call-x', name: 'shell', status: 'running' },
];
const { timelines } = mapDisplayItems(newestFirst(chronological));
expect(timelines['req-1'][0]).toEqual(
expect.objectContaining({ id: 'call-x', status: 'cancelled' })
);
});
it('maps an error tool call to an error row', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'toolCall', callId: 'call-e', name: 'shell', status: 'error', result: 'boom' },
];
const { timelines } = mapDisplayItems(newestFirst(chronological));
expect(timelines['req-1'][0]).toEqual(
expect.objectContaining({ id: 'call-e', status: 'error', result: 'boom' })
);
});
it('maps a failed tool call onto a ToolFailureExplanation for ToolFailureLines', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{
kind: 'toolCall',
callId: 'call-e',
name: 'shell',
status: 'error',
result: 'boom',
failure: { detail: 'exit 1: command not found' },
},
];
const { timelines } = mapDisplayItems(newestFirst(chronological));
const row = timelines['req-1'][0];
expect(row.status).toBe('error');
expect(row.failure).toBeDefined();
// The wire detail becomes the `causePlain` the ToolFailureLines renderer
// shows for an unrecognised failure class.
expect(row.failure?.causePlain).toBe('exit 1: command not found');
expect(typeof row.failure?.class).toBe('string');
expect(typeof row.failure?.nextAction).toBe('string');
});
it('falls back to the tool result as failure cause when no detail was captured', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{
kind: 'toolCall',
callId: 'call-e',
name: 'shell',
status: 'error',
result: 'raw error text',
failure: {},
},
];
const { timelines } = mapDisplayItems(newestFirst(chronological));
expect(timelines['req-1'][0].failure?.causePlain).toBe('raw error text');
});
it('derives displayName/detail for a tool row (parity with turn_state rows)', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{
kind: 'toolCall',
callId: 'c1',
name: 'shell',
args: { command: 'ls -la' },
result: 'ok',
status: 'success',
},
];
const { timelines } = mapDisplayItems(newestFirst(chronological));
const row = timelines['req-1'][0];
expect(typeof row.displayName).toBe('string');
expect(row.displayName?.length ?? 0).toBeGreaterThan(0);
});
it('anchors a subagent to its own requestId, not the current turn cursor', () => {
// The subagent item is appended after both turns (as the projection emits
// it) but belongs to req-1 via its core-derived requestId.
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'reasoning', text: 'turn one' },
{ kind: 'turnBoundary', requestId: 'req-2' },
{ kind: 'reasoning', text: 'turn two' },
{
kind: 'subagent',
id: 'coder',
requestId: 'req-1',
items: [{ kind: 'assistantMessage', content: 'sub done', iteration: 1 }],
},
];
const { timelines } = mapDisplayItems(newestFirst(chronological));
expect(timelines['req-1']?.some(e => e.name === 'subagent:coder')).toBe(true);
expect(timelines['req-2']?.some(e => e.name === 'subagent:coder')).toBeFalsy();
});
it('projects a subagent item into a timeline row carrying its activity + transcript', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{
kind: 'subagent',
id: 'researcher',
items: [
{ kind: 'reasoning', text: 'child thinking' },
{ kind: 'assistantMessage', content: 'child answer', iteration: 1 },
{
kind: 'toolCall',
callId: 'child-call',
name: 'web_search',
args: { q: 'x' },
result: 'hits',
status: 'success',
},
],
},
];
const { timelines } = mapDisplayItems(newestFirst(chronological));
const row = timelines['req-1'][0];
expect(row.name).toBe('subagent:researcher');
expect(row.subagent).toBeDefined();
expect(row.subagent?.agentId).toBe('researcher');
expect(row.subagent?.toolCalls).toEqual([
expect.objectContaining({ callId: 'child-call', toolName: 'web_search', status: 'success' }),
]);
expect(row.subagent?.transcript).toEqual([
{ kind: 'thinking', text: 'child thinking' },
{ kind: 'text', iteration: 1, text: 'child answer' },
expect.objectContaining({ kind: 'tool', callId: 'child-call', toolName: 'web_search' }),
]);
});
it('collects interrupted partials by requestId and never emits them as trail items', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'interruptedPartial', text: 'half an ans', thinking: 'mid thought' },
];
const { transcripts, timelines, interrupted } = mapDisplayItems(newestFirst(chronological));
expect(interrupted).toEqual([
{ requestId: 'req-1', content: 'half an ans', thinking: 'mid thought' },
]);
expect(transcripts['req-1']).toBeUndefined();
expect(timelines['req-1']).toBeUndefined();
});
it('drops compaction markers (no settled-turn renderer)', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{ kind: 'compaction', replacedCount: 3, keptCount: 1 },
{ kind: 'reasoning', text: 'after compaction' },
];
const { transcripts } = mapDisplayItems(newestFirst(chronological));
expect(transcripts['req-1']).toEqual([
{ kind: 'thinking', round: 0, seq: 0, text: 'after compaction' },
]);
});
it('does not emit the final assistant or user text as trail items (dedupe vs thread messages)', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{
kind: 'userMessage',
content: 'a question',
displayContent: 'a question',
requestId: 'req-1',
},
{ kind: 'assistantMessage', content: 'a final answer', requestId: 'req-1' },
];
const { transcripts, timelines } = mapDisplayItems(newestFirst(chronological));
expect(transcripts['req-1']).toBeUndefined();
expect(timelines['req-1']).toBeUndefined();
});
it('omits skipped request ids entirely', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-old' },
{ kind: 'reasoning', text: 'old thought' },
{ kind: 'turnBoundary', requestId: 'req-live' },
{ kind: 'reasoning', text: 'live thought' },
];
const { transcripts } = mapDisplayItems(newestFirst(chronological), {
skipRequestIds: new Set(['req-live']),
});
expect(Object.keys(transcripts)).toEqual(['req-old']);
expect(transcripts['req-live']).toBeUndefined();
});
it('carries the assistant iteration onto the turn round for its items', () => {
const chronological: DerivedDisplayItem[] = [
{ kind: 'turnBoundary', requestId: 'req-1' },
{
kind: 'assistantMessage',
content: 'step',
interim: true,
iteration: 2,
requestId: 'req-1',
},
{ kind: 'toolCall', callId: 'c1', name: 'shell', status: 'success' },
];
const { transcripts, timelines } = mapDisplayItems(newestFirst(chronological));
expect(transcripts['req-1'][0]).toEqual(
expect.objectContaining({ kind: 'narration', round: 2 })
);
expect(timelines['req-1'][0].round).toBe(2);
});
});
@@ -0,0 +1,371 @@
/**
* Phase C mapper: project the transcript-derived RPC's newest-first
* {@link DerivedDisplayItem}s onto the **existing** settled-turn renderer
* models, keyed by producing `requestId` — the exact shapes
* `fetchAndHydrateTurnHistory` produces from the legacy `turn_state_history`
* snapshot ring, so `PastTurnInsights` / `ProcessingTranscriptView` /
* `ToolTimelineBlock` / `SubagentActivityBlock` are reused unchanged.
*
* Division of labour (matches how `turnTimelinesByThread` / `PastTurnInsights`
* anchor today):
* - **Final assistant text and user text are NOT emitted here** — they stay
* rendered from the thread message list (`threads_messages_list`). This
* mapper only produces the *process trail* (reasoning, interim narration,
* tool cards, sub-agent trails) that renders above a past answer.
* - Items are grouped per `requestId` (from `turnBoundary` markers and each
* message's own `requestId`) so the caller can anchor a turn's trail to its
* first agent message by `requestId`.
*
* Live streaming is untouched: the caller skips the live/most-recent turn's
* `requestId` so derived data never fights socket-fed `chatRuntimeSlice` state.
*/
import debug from 'debug';
import type {
ProcessingTranscriptItem,
SubagentActivity,
SubagentToolCallEntry,
SubagentTranscriptItem,
ToolFailureExplanation,
ToolTimelineEntry,
ToolTimelineEntryStatus,
} from '../../../store/chatRuntimeSlice';
import type {
DerivedDisplayItem,
DerivedToolCall,
DerivedToolCallStatus,
DerivedToolFailure,
} from '../../../types/derivedTranscript';
import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting';
const log = debug('conversations.derived.mapDisplayItems');
/** A partial assistant answer left behind by an interrupted turn. */
export interface DerivedInterruptedAnswer {
requestId: string;
content: string;
thinking: string;
}
/**
* Per-thread settled-turn process trails derived from the transcript
* projection, ready to feed `setTurnTimelinesForThread` (timelines +
* transcripts) and, for completeness, any interrupted partials found.
*/
export interface MappedTranscript {
/** `requestId -> tool timeline rows` for each settled turn. */
timelines: Record<string, ToolTimelineEntry[]>;
/** `requestId -> processing transcript` (narration / thinking / tool ptr). */
transcripts: Record<string, ProcessingTranscriptItem[]>;
/**
* Interrupted partials found in the derived data, in chronological order.
* Exposed for a future phase; the hydration thunk deliberately leaves the
* live/current-turn interrupted partial owned by the `turn_state` snapshot
* path so it never fights live state.
*/
interrupted: DerivedInterruptedAnswer[];
}
/** Options controlling {@link mapDisplayItems}. */
export interface MapDisplayItemsOptions {
/**
* Request ids to omit from the output entirely — the live/most-recent turn
* (rendered from socket-fed state / the turn_state snapshot) and any turn
* currently streaming. Their trails must not double-render against the live
* anchor.
*/
skipRequestIds?: ReadonlySet<string>;
}
/** Map the Rust `ToolCallStatus` onto the timeline status vocabulary. A settled
* turn whose tool row is still `running` had no result line paired (the turn
* was interrupted before completion) — settle it to `cancelled` (terminal,
* muted, non-pulsing), mirroring `settleOrphanedTimelineEntry`. */
function timelineStatusFromDerived(status: DerivedToolCallStatus): ToolTimelineEntryStatus {
switch (status) {
case 'success':
return 'success';
case 'error':
return 'error';
case 'running':
default:
return 'cancelled';
}
}
/** Same mapping for a sub-agent child tool call. */
function subagentToolStatus(status: DerivedToolCallStatus): ToolTimelineEntryStatus {
return timelineStatusFromDerived(status);
}
/**
* Expand the minimal wire {@link DerivedToolFailure} into the richer
* {@link ToolFailureExplanation} the `ToolFailureLines` renderer consumes,
* matching `turn_state`'s `PersistedToolFailure` shape. The projection only
* records that a call failed plus an optional short reason, so we synthesise an
* unlocalized `Unknown`/`Recoverable` explanation whose `causePlain` carries the
* captured detail (or the tool's error output) — `ToolFailureLines` falls back
* to `causePlain`/`nextAction` for unrecognised classes.
*/
function toFailureExplanation(
failure: DerivedToolFailure | undefined,
result: string | undefined
): ToolFailureExplanation | undefined {
if (!failure) return undefined;
const causePlain = failure.detail?.trim() || result?.trim() || 'The tool reported an error.';
return {
class: 'Unknown',
category: 'Recoverable',
recoverable: true,
causePlain,
nextAction: 'Review the tool output and try again.',
};
}
function stringifyArgs(args: unknown): string | undefined {
if (args === undefined || args === null) return undefined;
if (typeof args === 'string') return args;
try {
return JSON.stringify(args);
} catch {
return undefined;
}
}
/**
* Build a {@link SubagentActivity} from a `subagent` display item's nested
* items. The nested vocabulary (reasoning / assistantMessage / toolCall)
* projects onto the sub-agent transcript (`thinking` / `text` / `tool`) plus a
* flat `toolCalls` list — exactly what `SubagentActivityBlock` reads.
*/
function buildSubagentActivity(id: string, items: DerivedDisplayItem[]): SubagentActivity {
const toolCalls: SubagentToolCallEntry[] = [];
const transcript: SubagentTranscriptItem[] = [];
for (const item of items) {
switch (item.kind) {
case 'reasoning':
if (item.text.trim()) {
transcript.push({ kind: 'thinking', text: item.text });
}
break;
case 'assistantMessage':
// A sub-agent's own answer text is part of its inline trail (there is
// no separate message bubble for a delegated worker).
if (item.content.trim()) {
transcript.push({ kind: 'text', iteration: item.iteration, text: item.content });
}
break;
case 'toolCall': {
const status = subagentToolStatus(item.status);
toolCalls.push({
callId: item.callId,
toolName: item.name,
status,
args: item.args,
result: item.result,
});
transcript.push({
kind: 'tool',
callId: item.callId,
toolName: item.name,
status,
args: item.args,
result: item.result,
});
break;
}
// Nested sub-agents, turn boundaries, interrupted partials and compaction
// markers do not surface inside a sub-agent block — the projection nests
// deeper sub-agents as their own top-level items.
default:
break;
}
}
return { taskId: id, agentId: id, status: 'completed', toolCalls, transcript };
}
/** Mutable per-turn accumulator. */
interface TurnAccumulator {
entries: ToolTimelineEntry[];
transcript: ProcessingTranscriptItem[];
seq: number;
round: number;
}
function ensureTurn(turns: Map<string, TurnAccumulator>, requestId: string): TurnAccumulator {
let turn = turns.get(requestId);
if (!turn) {
turn = { entries: [], transcript: [], seq: 0, round: 0 };
turns.set(requestId, turn);
}
return turn;
}
/**
* Map a newest-first page of derived display items into per-`requestId`
* settled-turn trails. Returns empty maps when nothing anchors to a `requestId`
* (e.g. a legacy thread whose lines carry no `requestId`).
*/
export function mapDisplayItems(
items: DerivedDisplayItem[],
options: MapDisplayItemsOptions = {}
): MappedTranscript {
const skip = options.skipRequestIds ?? new Set<string>();
const turns = new Map<string, TurnAccumulator>();
const interrupted: DerivedInterruptedAnswer[] = [];
// The RPC returns items newest-first; walk chronologically so `seq` reflects
// issue order and turn boundaries advance forward.
const chronological = [...items].reverse();
let currentRequestId: string | undefined;
const skipped = new Set<string>();
for (const item of chronological) {
switch (item.kind) {
case 'turnBoundary':
currentRequestId = item.requestId;
break;
case 'userMessage':
// User text renders from the thread message list; only advance the
// turn cursor so following items anchor to this turn.
if (item.requestId) currentRequestId = item.requestId;
break;
case 'assistantMessage': {
if (item.requestId) currentRequestId = item.requestId;
if (item.iteration !== undefined && currentRequestId && !skip.has(currentRequestId)) {
ensureTurn(turns, currentRequestId).round = item.iteration;
}
// Final (non-interim) answer renders from the thread message; only
// interim narration belongs to the process trail.
if (!item.interim) break;
if (!currentRequestId || skip.has(currentRequestId)) {
if (currentRequestId) skipped.add(currentRequestId);
break;
}
const text = item.content.trim();
if (!text) break;
const turn = ensureTurn(turns, currentRequestId);
turn.transcript.push({
kind: 'narration',
round: turn.round,
seq: turn.seq++,
text: item.content,
});
break;
}
case 'reasoning': {
if (!currentRequestId || skip.has(currentRequestId)) {
if (currentRequestId) skipped.add(currentRequestId);
break;
}
if (!item.text.trim()) break;
const turn = ensureTurn(turns, currentRequestId);
turn.transcript.push({
kind: 'thinking',
round: turn.round,
seq: turn.seq++,
text: item.text,
});
break;
}
case 'toolCall': {
if (!currentRequestId || skip.has(currentRequestId)) {
if (currentRequestId) skipped.add(currentRequestId);
break;
}
const turn = ensureTurn(turns, currentRequestId);
pushToolCall(turn, item);
break;
}
case 'subagent': {
// Anchor to the turn the sub-agent was spawned in (core-derived
// `requestId`), not the current cursor — sub-agent items are appended
// after all root items, so the cursor is the last turn by then.
const anchorRequestId = item.requestId ?? currentRequestId;
if (!anchorRequestId || skip.has(anchorRequestId)) {
if (anchorRequestId) skipped.add(anchorRequestId);
break;
}
const turn = ensureTurn(turns, anchorRequestId);
const activity = buildSubagentActivity(item.id, item.items);
turn.entries.push({
id: `subagent:${item.id}`,
name: `subagent:${item.id}`,
round: turn.round,
seq: turn.seq++,
status: 'success',
subagent: activity,
});
break;
}
case 'interruptedPartial':
if (currentRequestId) {
interrupted.push({
requestId: currentRequestId,
content: item.text,
thinking: item.thinking ?? '',
});
}
break;
// Compaction markers have no settled-turn renderer — drop them.
case 'compaction':
default:
break;
}
}
const timelines: Record<string, ToolTimelineEntry[]> = {};
const transcripts: Record<string, ProcessingTranscriptItem[]> = {};
for (const [requestId, turn] of turns) {
if (turn.entries.length > 0) timelines[requestId] = turn.entries;
if (turn.transcript.length > 0) transcripts[requestId] = turn.transcript;
}
log(
'mapped turns=%d timelines=%d transcripts=%d interrupted=%d skipped=%d',
turns.size,
Object.keys(timelines).length,
Object.keys(transcripts).length,
interrupted.length,
skipped.size
);
return { timelines, transcripts, interrupted };
}
/** Push a tool-call display item as both a timeline entry and a transcript
* pointer sharing one `seq`, so the tool row orders consistently among the
* turn's narration / thinking. */
function pushToolCall(turn: TurnAccumulator, item: DerivedToolCall): void {
const seq = turn.seq++;
const entry: ToolTimelineEntry = {
id: item.callId,
name: item.name,
round: turn.round,
seq,
status: timelineStatusFromDerived(item.status),
argsBuffer: stringifyArgs(item.args),
result: item.result,
};
// A failed tool renders its "why / next" explanation via `ToolFailureLines`.
const failure = toFailureExplanation(item.failure, item.result);
if (failure) entry.failure = failure;
// Derive the human label + detail from tool name + args (the same TS
// formatter the live path runs), so settled rows carry `displayName`/`detail`
// at parity with `turn_state` rows instead of being unlabelled.
const formatted = formatTimelineEntry(entry);
entry.displayName = formatted.title;
if (formatted.detail !== undefined) entry.detail = formatted.detail;
turn.entries.push(entry);
turn.transcript.push({ kind: 'toolCall', round: turn.round, seq, callId: item.callId });
}