diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx
index b5a74e1dd..7f0334071 100644
--- a/app/src/features/conversations/Conversations.tsx
+++ b/app/src/features/conversations/Conversations.tsx
@@ -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 => {
diff --git a/app/src/features/conversations/derived/derivedRestore.render.test.tsx b/app/src/features/conversations/derived/derivedRestore.render.test.tsx
new file mode 100644
index 000000000..02490e8ec
--- /dev/null
+++ b/app/src/features/conversations/derived/derivedRestore.render.test.tsx
@@ -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({ui});
+}
+
+/** 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(
+
+ );
+
+ // 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');
+ });
+});
diff --git a/app/src/features/conversations/derived/mapDisplayItems.test.ts b/app/src/features/conversations/derived/mapDisplayItems.test.ts
new file mode 100644
index 000000000..881af64dc
--- /dev/null
+++ b/app/src/features/conversations/derived/mapDisplayItems.test.ts
@@ -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);
+ });
+});
diff --git a/app/src/features/conversations/derived/mapDisplayItems.ts b/app/src/features/conversations/derived/mapDisplayItems.ts
new file mode 100644
index 000000000..3e9e9adef
--- /dev/null
+++ b/app/src/features/conversations/derived/mapDisplayItems.ts
@@ -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;
+ /** `requestId -> processing transcript` (narration / thinking / tool ptr). */
+ transcripts: Record;
+ /**
+ * 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;
+}
+
+/** 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, 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();
+ const turns = new Map();
+ 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();
+
+ 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 = {};
+ const transcripts: Record = {};
+ 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 });
+}
diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx
index d8c7fc0e5..da8643790 100644
--- a/app/src/providers/ChatRuntimeProvider.tsx
+++ b/app/src/providers/ChatRuntimeProvider.tsx
@@ -39,6 +39,7 @@ import {
clearProcessingForThread,
clearStreamingAssistantForThread,
endInferenceTurn,
+ fetchAndHydrateDerivedTranscript,
markInferenceTurnStreaming,
parseToolFailure,
recordChatTurnUsage,
@@ -76,7 +77,7 @@ import {
setActiveThread,
setSelectedThread,
} from '../store/threadSlice';
-import { IS_PROD } from '../utils/config';
+import { DERIVED_TRANSCRIPT_ENABLED, IS_PROD } from '../utils/config';
const logChatRuntime = debug('openhuman:chat-runtime');
const USER_FACING_AGENT_ERROR_MESSAGE =
@@ -443,6 +444,14 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
await flushQueuedFollowups(event.thread_id);
dispatch(endInferenceTurn({ threadId: event.thread_id }));
dispatch(clearThreadInferenceActive(event.thread_id));
+ // Live-turn seam: the turn just settled and its line was appended to the
+ // append-only transcript. Invalidate/refresh the thread's derived
+ // settled-turn trails so the next reopen is fresh. The just-finished turn
+ // is the newest, so the derived hydration skips it — this never fights the
+ // live anchor, and it does not touch any socket delta handler.
+ if (DERIVED_TRANSCRIPT_ENABLED) {
+ void dispatch(fetchAndHydrateDerivedTranscript(event.thread_id));
+ }
};
rtLog('subscribe_chat_events', { socket: socketStatus });
diff --git a/app/src/services/api/threadApi.test.ts b/app/src/services/api/threadApi.test.ts
index 605d78f1d..67e8baeaa 100644
--- a/app/src/services/api/threadApi.test.ts
+++ b/app/src/services/api/threadApi.test.ts
@@ -221,4 +221,39 @@ describe('threadApi', () => {
params: { runId: 'sub-1', afterSequence: 0 },
});
});
+
+ it('fetches the derived transcript page with pagination controls', async () => {
+ const pageData = {
+ threadId: 'thread-1',
+ items: [{ kind: 'turnBoundary', requestId: 'req-1' }],
+ total: 1,
+ hasMore: false,
+ hasTranscript: true,
+ };
+ mockCallCoreRpc.mockResolvedValueOnce({ data: pageData });
+
+ const { threadApi } = await import('./threadApi');
+ const result = await threadApi.getDerivedTranscript('thread-1', { cursor: '10', limit: 50 });
+
+ expect(mockCallCoreRpc).toHaveBeenLastCalledWith({
+ method: 'openhuman.threads_transcript_get',
+ params: { thread_id: 'thread-1', cursor: '10', limit: 50 },
+ });
+ expect(result).toEqual(pageData);
+ });
+
+ it('fetches the derived transcript with default (undefined) pagination when omitted', async () => {
+ mockCallCoreRpc.mockResolvedValueOnce({
+ data: { threadId: 'thread-1', items: [], total: 0, hasMore: false, hasTranscript: false },
+ });
+
+ const { threadApi } = await import('./threadApi');
+ const result = await threadApi.getDerivedTranscript('thread-1');
+
+ expect(mockCallCoreRpc).toHaveBeenLastCalledWith({
+ method: 'openhuman.threads_transcript_get',
+ params: { thread_id: 'thread-1', cursor: undefined, limit: undefined },
+ });
+ expect(result.hasTranscript).toBe(false);
+ });
});
diff --git a/app/src/services/api/threadApi.ts b/app/src/services/api/threadApi.ts
index bedb5f5b8..bf01d34bc 100644
--- a/app/src/services/api/threadApi.ts
+++ b/app/src/services/api/threadApi.ts
@@ -1,5 +1,9 @@
import debug from 'debug';
+import type {
+ DerivedTranscriptGetOptions,
+ DerivedTranscriptPage,
+} from '../../types/derivedTranscript';
import type {
PurgeResultData,
Thread,
@@ -271,4 +275,25 @@ export const threadApi = {
});
return unwrapEnvelope(response);
},
+
+ /**
+ * Transcript-derived view (Phase B/C): project the thread's append-only
+ * `session_raw/*.jsonl` source of truth into typed display items for the
+ * settled-turn restore path. Newest-first paginated; `cursor` comes from a
+ * prior page's `nextCursor`, `limit` defaults to 50 (core-clamped to 500).
+ *
+ * A thread with no persisted transcript yet returns an empty page with
+ * `hasTranscript: false` (not an error) — the caller then falls back to the
+ * legacy `turn_state_history` hydration.
+ */
+ getDerivedTranscript: async (
+ threadId: string,
+ options?: DerivedTranscriptGetOptions
+ ): Promise => {
+ const response = await callCoreRpc>({
+ method: 'openhuman.threads_transcript_get',
+ params: { thread_id: threadId, cursor: options?.cursor, limit: options?.limit },
+ });
+ return unwrapEnvelope(response);
+ },
};
diff --git a/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts b/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts
new file mode 100644
index 000000000..07bf2c63e
--- /dev/null
+++ b/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts
@@ -0,0 +1,141 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { DerivedDisplayItem, DerivedTranscriptPage } from '../../types/derivedTranscript';
+import reducer, {
+ fetchAndHydrateDerivedTranscript,
+ setStreamingAssistantForThread,
+} from '../chatRuntimeSlice';
+
+const { mockThreadApi, flag } = vi.hoisted(() => ({
+ mockThreadApi: { getDerivedTranscript: vi.fn(), getTurnStateHistory: vi.fn() },
+ flag: { enabled: true },
+}));
+
+vi.mock('../../services/api/threadApi', () => ({ threadApi: mockThreadApi }));
+vi.mock('../../utils/config', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ get DERIVED_TRANSCRIPT_ENABLED() {
+ return flag.enabled;
+ },
+ };
+});
+
+function page(items: DerivedDisplayItem[], overrides: Partial = {}) {
+ return {
+ threadId: 'thread-1',
+ items,
+ total: items.length,
+ hasMore: false,
+ hasTranscript: true,
+ ...overrides,
+ } satisfies DerivedTranscriptPage;
+}
+
+/** Newest-first page from chronological items (as the RPC returns). */
+function newestFirst(chronological: DerivedDisplayItem[]): DerivedDisplayItem[] {
+ return [...chronological].reverse();
+}
+
+beforeEach(() => {
+ flag.enabled = true;
+ mockThreadApi.getDerivedTranscript.mockReset();
+ mockThreadApi.getTurnStateHistory.mockReset();
+});
+
+describe('fetchAndHydrateDerivedTranscript', () => {
+ it('hydrates settled-turn trails from the projection, skipping the newest turn', async () => {
+ const store = configureStore({ reducer });
+ mockThreadApi.getDerivedTranscript.mockResolvedValueOnce(
+ page(
+ newestFirst([
+ { kind: 'turnBoundary', requestId: 'req-old' },
+ { kind: 'reasoning', text: 'old thought' },
+ { kind: 'toolCall', callId: 'c1', name: 'shell', status: 'success' },
+ { kind: 'turnBoundary', requestId: 'req-new' },
+ { kind: 'reasoning', text: 'newest thought' },
+ ])
+ )
+ );
+
+ await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1'));
+
+ expect(mockThreadApi.getDerivedTranscript).toHaveBeenCalledWith('thread-1', { limit: 500 });
+ expect(mockThreadApi.getTurnStateHistory).not.toHaveBeenCalled();
+ const timelines = store.getState().turnTimelinesByThread['thread-1'];
+ const transcripts = store.getState().turnTranscriptsByThread['thread-1'];
+ // Newest turn (req-new) is skipped — rendered by the live anchor.
+ expect(Object.keys(transcripts)).toEqual(['req-old']);
+ expect(timelines['req-old']).toHaveLength(1);
+ expect(transcripts['req-new']).toBeUndefined();
+ expect(timelines['req-new']).toBeUndefined();
+ });
+
+ it('falls back to turn_state history when the flag is off', async () => {
+ flag.enabled = false;
+ const store = configureStore({ reducer });
+ mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([]);
+
+ await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1'));
+
+ expect(mockThreadApi.getDerivedTranscript).not.toHaveBeenCalled();
+ expect(mockThreadApi.getTurnStateHistory).toHaveBeenCalledWith('thread-1');
+ });
+
+ it('falls back to turn_state history when the RPC errors', async () => {
+ const store = configureStore({ reducer });
+ mockThreadApi.getDerivedTranscript.mockRejectedValueOnce(new Error('boom'));
+ mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([]);
+
+ await expect(
+ store.dispatch(fetchAndHydrateDerivedTranscript('thread-1'))
+ ).resolves.toBeDefined();
+
+ expect(mockThreadApi.getTurnStateHistory).toHaveBeenCalledWith('thread-1');
+ });
+
+ it('falls back to turn_state history when the thread has no persisted transcript (legacy)', async () => {
+ const store = configureStore({ reducer });
+ mockThreadApi.getDerivedTranscript.mockResolvedValueOnce(page([], { hasTranscript: false }));
+ mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([]);
+
+ await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1'));
+
+ expect(mockThreadApi.getTurnStateHistory).toHaveBeenCalledWith('thread-1');
+ // The legacy fallback ran with empty history — no derived trails installed.
+ expect(store.getState().turnTimelinesByThread['thread-1']).toEqual({});
+ });
+
+ it('skips a turn that is currently streaming (live-turn requestId skip)', async () => {
+ const store = configureStore({ reducer });
+ // Seed a live stream on a NON-newest turn (req-mid).
+ store.dispatch(
+ setStreamingAssistantForThread({
+ threadId: 'thread-1',
+ streaming: { requestId: 'req-mid', content: 'streaming...', thinking: '' },
+ })
+ );
+ mockThreadApi.getDerivedTranscript.mockResolvedValueOnce(
+ page(
+ newestFirst([
+ { kind: 'turnBoundary', requestId: 'req-old' },
+ { kind: 'reasoning', text: 'old thought' },
+ { kind: 'turnBoundary', requestId: 'req-mid' },
+ { kind: 'reasoning', text: 'mid thought' },
+ { kind: 'turnBoundary', requestId: 'req-new' },
+ { kind: 'reasoning', text: 'new thought' },
+ ])
+ )
+ );
+
+ await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1'));
+
+ const transcripts = store.getState().turnTranscriptsByThread['thread-1'];
+ // req-new skipped (newest), req-mid skipped (streaming), req-old kept.
+ expect(Object.keys(transcripts)).toEqual(['req-old']);
+ expect(transcripts['req-mid']).toBeUndefined();
+ expect(transcripts['req-new']).toBeUndefined();
+ });
+});
diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts
index 40122ac0a..05c61d8b8 100644
--- a/app/src/store/chatRuntimeSlice.ts
+++ b/app/src/store/chatRuntimeSlice.ts
@@ -1,7 +1,9 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import debug from 'debug';
+import { mapDisplayItems } from '../features/conversations/derived/mapDisplayItems';
import { threadApi } from '../services/api/threadApi';
+import type { DerivedDisplayItem, DerivedTranscriptPage } from '../types/derivedTranscript';
import type { ThreadMessage } from '../types/thread';
import type {
AgentRun,
@@ -13,6 +15,7 @@ import type {
PersistedTurnState,
TaskBoard,
} from '../types/turnState';
+import { DERIVED_TRANSCRIPT_ENABLED } from '../utils/config';
import {
formatTimelineEntry,
isKnownClientTool,
@@ -2403,4 +2406,171 @@ export const fetchAndHydrateTurnHistory = createAsyncThunk(
}
);
+/**
+ * Initial derived-transcript page size. Sized generously (the core clamps to
+ * 500) so a reopened thread's visible turns all carry their process trail
+ * without a second round-trip. Older turns beyond this window load lazily via
+ * {@link loadOlderDerivedTranscript}.
+ */
+const DERIVED_TRANSCRIPT_INITIAL_LIMIT = 500;
+
+const derivedLog = debug('chatRuntime.derivedTranscript');
+
+/**
+ * Read the {@link ChatRuntimeState} out of an arbitrary redux root, tolerating
+ * both the app store (`state.chatRuntime`) and a bare test store whose root IS
+ * the slice state. Used only to read live-turn request ids for the skip set.
+ */
+function readChatRuntimeState(state: unknown): ChatRuntimeState | undefined {
+ if (!state || typeof state !== 'object') return undefined;
+ const root = state as Record;
+ if ('chatRuntime' in root && root.chatRuntime && typeof root.chatRuntime === 'object') {
+ return root.chatRuntime as ChatRuntimeState;
+ }
+ if ('streamingAssistantByThread' in root) {
+ return root as unknown as ChatRuntimeState;
+ }
+ return undefined;
+}
+
+/**
+ * The request ids whose derived trail must NOT be hydrated: the newest turn
+ * (rendered as the live "agent insights" anchor from `toolTimelineByThread` /
+ * the socket stream, or the `turn_state` snapshot via
+ * {@link fetchAndHydrateTurnState}) and any turn currently streaming. Mirrors
+ * `fetchAndHydrateTurnHistory`'s `history.slice(1)` newest-turn skip.
+ */
+function liveRequestIdsToSkip(
+ state: unknown,
+ threadId: string,
+ items: DerivedDisplayItem[]
+): Set {
+ const skip = new Set();
+ // Newest turn = first request id encountered walking newest-first.
+ for (const item of items) {
+ const rid =
+ item.kind === 'turnBoundary'
+ ? item.requestId
+ : 'requestId' in item
+ ? item.requestId
+ : undefined;
+ if (rid) {
+ skip.add(rid);
+ break;
+ }
+ }
+ const runtime = readChatRuntimeState(state);
+ const streamingRid = runtime?.streamingAssistantByThread[threadId]?.requestId;
+ if (streamingRid) skip.add(streamingRid);
+ for (const [rid, mappedThread] of Object.entries(runtime?.parallelRequestThreads ?? {})) {
+ if (mappedThread === threadId) skip.add(rid);
+ }
+ return skip;
+}
+
+/**
+ * Phase C settled-turn restore: hydrate past-turn process trails from the
+ * transcript-derived projection (`openhuman.threads_transcript_get`) instead of
+ * the legacy `turn_state_history` snapshot ring. Populates the SAME
+ * {@link ChatRuntimeState.turnTimelinesByThread} /
+ * {@link ChatRuntimeState.turnTranscriptsByThread} the legacy path did, so the
+ * renderers are reused unchanged. The live/most-recent turn is skipped so
+ * derived data never fights socket-fed live state.
+ *
+ * Automatic fallback to {@link fetchAndHydrateTurnHistory} when the flag is
+ * off, the RPC errors, or the thread has no persisted transcript (legacy
+ * thread). Failures never block navigation.
+ */
+export const fetchAndHydrateDerivedTranscript = createAsyncThunk(
+ 'chatRuntime/fetchAndHydrateDerivedTranscript',
+ async (threadId: string, { dispatch, getState }) => {
+ if (!DERIVED_TRANSCRIPT_ENABLED) {
+ derivedLog('disabled thread=%s -> turn_state history', threadId);
+ await dispatch(fetchAndHydrateTurnHistory(threadId));
+ return null;
+ }
+ let page: DerivedTranscriptPage;
+ try {
+ page = await threadApi.getDerivedTranscript(threadId, {
+ limit: DERIVED_TRANSCRIPT_INITIAL_LIMIT,
+ });
+ } catch (error) {
+ derivedLog('rpc failed thread=%s err=%O -> turn_state history fallback', threadId, error);
+ await dispatch(fetchAndHydrateTurnHistory(threadId));
+ return null;
+ }
+ if (!page.hasTranscript) {
+ derivedLog('no transcript thread=%s -> turn_state history fallback (legacy)', threadId);
+ await dispatch(fetchAndHydrateTurnHistory(threadId));
+ return null;
+ }
+ const skipRequestIds = liveRequestIdsToSkip(getState(), threadId, page.items);
+ const { timelines, transcripts } = mapDisplayItems(page.items, { skipRequestIds });
+ derivedLog(
+ 'hydrated thread=%s items=%d timelines=%d transcripts=%d skip=%d hasMore=%s',
+ threadId,
+ page.items.length,
+ Object.keys(timelines).length,
+ Object.keys(transcripts).length,
+ skipRequestIds.size,
+ page.hasMore
+ );
+ dispatch(setTurnTimelinesForThread({ threadId, timelines, transcripts }));
+ // TODO(pagination): when `page.hasMore`, an insights "load older" affordance
+ // should call `loadOlderDerivedTranscript` with `page.nextCursor`. No UI
+ // surfaces older past-turn trails yet, so the first (generous) page is all
+ // we hydrate today.
+ return { timelines, transcripts, nextCursor: page.nextCursor ?? null, hasMore: page.hasMore };
+ }
+);
+
+/**
+ * Load-older hook (Phase C, pagination): fetch the next (older) derived page
+ * for a thread by `cursor` and MERGE its trails into the already-hydrated
+ * {@link ChatRuntimeState.turnTimelinesByThread} / `turnTranscriptsByThread`
+ * (existing turns win — the newer page is authoritative). Wired but currently
+ * uncalled: no UI exposes a "load older insights" affordance yet.
+ */
+export const loadOlderDerivedTranscript = createAsyncThunk(
+ 'chatRuntime/loadOlderDerivedTranscript',
+ async (arg: { threadId: string; cursor: string }, { dispatch, getState }) => {
+ if (!DERIVED_TRANSCRIPT_ENABLED) return null;
+ const { threadId, cursor } = arg;
+ let page: DerivedTranscriptPage;
+ try {
+ page = await threadApi.getDerivedTranscript(threadId, {
+ cursor,
+ limit: DERIVED_TRANSCRIPT_INITIAL_LIMIT,
+ });
+ } catch (error) {
+ derivedLog('load-older rpc failed thread=%s err=%O', threadId, error);
+ return null;
+ }
+ if (!page.hasTranscript) return null;
+ const skipRequestIds = liveRequestIdsToSkip(getState(), threadId, page.items);
+ const { timelines, transcripts } = mapDisplayItems(page.items, { skipRequestIds });
+ const runtime = readChatRuntimeState(getState());
+ const mergedTimelines = { ...timelines, ...(runtime?.turnTimelinesByThread[threadId] ?? {}) };
+ const mergedTranscripts = {
+ ...transcripts,
+ ...(runtime?.turnTranscriptsByThread[threadId] ?? {}),
+ };
+ derivedLog(
+ 'load-older merged thread=%s added_timelines=%d added_transcripts=%d hasMore=%s',
+ threadId,
+ Object.keys(timelines).length,
+ Object.keys(transcripts).length,
+ page.hasMore
+ );
+ dispatch(
+ setTurnTimelinesForThread({
+ threadId,
+ timelines: mergedTimelines,
+ transcripts: mergedTranscripts,
+ })
+ );
+ return { nextCursor: page.nextCursor ?? null, hasMore: page.hasMore };
+ }
+);
+
export default chatRuntimeSlice.reducer;
diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts
index 76009c30a..fab9a65f5 100644
--- a/app/src/test/setup.ts
+++ b/app/src/test/setup.ts
@@ -202,6 +202,7 @@ vi.mock('../utils/config', () => ({
E2E_RESTART_APP_AS_RELOAD: false,
DEV_FORCE_ONBOARDING: false,
CHAT_ATTACHMENTS_ENABLED: true,
+ DERIVED_TRANSCRIPT_ENABLED: true,
SKILLS_GITHUB_REPO: 'test/skills',
GA_MEASUREMENT_ID: undefined,
OPENPANEL_API_URL: 'https://panel.tinyhumans.ai/api',
diff --git a/app/src/types/derivedTranscript.ts b/app/src/types/derivedTranscript.ts
new file mode 100644
index 000000000..e683ee698
--- /dev/null
+++ b/app/src/types/derivedTranscript.ts
@@ -0,0 +1,158 @@
+/**
+ * Wire shape of the transcript-derived view RPC
+ * (`openhuman.threads_transcript_get`, Phase B — see
+ * `src/openhuman/threads/transcript_view/types.rs`).
+ *
+ * The Rust core projects the append-only `session_raw/*.jsonl` source of truth
+ * into typed **display items** in the frontend's chat vocabulary. Phase C maps
+ * these onto the existing settled-turn renderers (`PastTurnInsights` /
+ * `ProcessingTranscriptView` / `ToolTimelineBlock` / `SubagentActivityBlock`)
+ * via `features/conversations/derived/mapDisplayItems.ts`.
+ *
+ * Serde is camelCase on the wire, so every field here is camelCase and mirrors
+ * the Rust `DisplayItem` / `TranscriptPage` serialization exactly.
+ */
+
+/**
+ * Terminal state of a projected tool call. Mirrors the Rust `ToolCallStatus`
+ * (snake_case on the wire) and the live timeline's `ToolTimelineStatus`
+ * vocabulary so the settled projection and the live stream render identically.
+ */
+export type DerivedToolCallStatus = 'running' | 'success' | 'error';
+
+/**
+ * One item in a projected transcript, in the frontend's display vocabulary.
+ * Discriminated by `kind` (camelCase discriminator from the Rust
+ * `#[serde(tag = "kind")]` enum).
+ */
+export type DerivedDisplayItem =
+ | DerivedUserMessage
+ | DerivedAssistantMessage
+ | DerivedReasoning
+ | DerivedToolCall
+ | DerivedSubagent
+ | DerivedTurnBoundary
+ | DerivedInterruptedPartial
+ | DerivedCompaction;
+
+/**
+ * A user prompt. `content` is the raw persisted content (may carry an injected
+ * `Current Date & Time:` scaffolding line); `displayContent` is the sanitized
+ * version to show, present only when it differs from raw — prefer it.
+ */
+export interface DerivedUserMessage {
+ kind: 'userMessage';
+ content: string;
+ displayContent?: string;
+ requestId?: string;
+}
+
+/**
+ * An assistant answer. `interim: true` marks a non-terminal tool-calling step
+ * within a multi-iteration turn (narration between tool calls), **not** the
+ * final answer bubble — the final (non-interim) answer stays rendered from the
+ * thread message list.
+ */
+export interface DerivedAssistantMessage {
+ kind: 'assistantMessage';
+ content: string;
+ interim?: boolean;
+ requestId?: string;
+ model?: string;
+ iteration?: number;
+}
+
+/** The model's reasoning/thinking that preceded an assistant message. */
+export interface DerivedReasoning {
+ kind: 'reasoning';
+ text: string;
+}
+
+/**
+ * Failure payload attached to an errored tool call. Minimal on the wire (the
+ * persisted transcript only records the failure plus an optional short reason);
+ * the mapper expands it into the richer `ToolFailureExplanation` shape the
+ * `ToolFailureLines` renderer consumes.
+ */
+export interface DerivedToolFailure {
+ /** Short, single-line reason for the failure, when the writer captured one. */
+ detail?: string;
+}
+
+/** A tool invocation with its paired result, when available. */
+export interface DerivedToolCall {
+ kind: 'toolCall';
+ callId: string;
+ name: string;
+ args?: unknown;
+ result?: string;
+ status: DerivedToolCallStatus;
+ /** Present only when `status` is `'error'`. */
+ failure?: DerivedToolFailure;
+}
+
+/**
+ * A delegated sub-agent run, with its own nested projected items. `requestId`
+ * anchors the whole trail to the parent turn that spawned it (derived core-side
+ * from the sub-agent's spawn timestamp vs. the parent turns' timestamp ranges);
+ * absent for legacy/CLI transcripts with no `requestId`.
+ */
+export interface DerivedSubagent {
+ kind: 'subagent';
+ id: string;
+ requestId?: string;
+ items: DerivedDisplayItem[];
+}
+
+/** A turn boundary — emitted when the `requestId` changes between lines. */
+export interface DerivedTurnBoundary {
+ kind: 'turnBoundary';
+ requestId: string;
+}
+
+/** A partial assistant answer captured when a turn was interrupted. */
+export interface DerivedInterruptedPartial {
+ kind: 'interruptedPartial';
+ text: string;
+ thinking?: string;
+}
+
+/**
+ * A context-compaction marker: the reduced set replaced everything before it.
+ * Carried for completeness; the settled-turn renderers have no compaction
+ * concept, so the Phase C mapper currently drops it.
+ */
+export interface DerivedCompaction {
+ kind: 'compaction';
+ replacedCount: number;
+ keptCount: number;
+ ts?: string;
+ requestId?: string;
+}
+
+/**
+ * One newest-first page of a thread's projected transcript. `hasTranscript` is
+ * `false` when the thread has no persisted transcript yet (legacy thread /
+ * brand-new) — the caller then falls back to the turn_state hydration path.
+ */
+export interface DerivedTranscriptPage {
+ threadId: string;
+ /** Display items for this page, **newest-first**. */
+ items: DerivedDisplayItem[];
+ /** Total top-level items available for the thread. */
+ total: number;
+ /** Opaque cursor to pass back for the next (older) page; absent at the end. */
+ nextCursor?: string;
+ /** `true` when more (older) items remain beyond this page. */
+ hasMore: boolean;
+ /** `false` when the thread has no persisted transcript yet (empty page). */
+ hasTranscript: boolean;
+}
+
+/** Optional pagination controls for {@link DerivedTranscriptPage} fetches. */
+export interface DerivedTranscriptGetOptions {
+ /** Opaque token from a prior page's `nextCursor`. */
+ cursor?: string;
+ /** Page size; core default 50, clamped to 500. */
+ limit?: number;
+}
diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts
index 6557e9a44..793d3745e 100644
--- a/app/src/utils/config.ts
+++ b/app/src/utils/config.ts
@@ -102,6 +102,22 @@ export const CHAT_ATTACHMENTS_ENABLED = import.meta.env.VITE_CHAT_ATTACHMENTS !=
export const SKILLS_GITHUB_REPO =
import.meta.env.VITE_SKILLS_GITHUB_REPO || 'tinyhumansai/openhuman-skills';
+/**
+ * Transcript-derived restore path (Phase C, `docs/plans/transcript-derived-view.md`).
+ *
+ * When **on** (default), the settled-turn process trails on thread open are
+ * hydrated from the `openhuman.threads_transcript_get` projection of the
+ * append-only `session_raw/*.jsonl` source of truth, instead of the legacy
+ * `turn_state_history` snapshot ring. Live token streaming is untouched either
+ * way — in-flight turns still render from socket-fed `chatRuntimeSlice` state.
+ *
+ * Automatic fallback to the legacy `turn_state_history` hydration when the RPC
+ * errors or reports `hasTranscript: false` (legacy threads), so the old path
+ * stays fully working. Hard-disable the whole derived path for a build with
+ * `VITE_DERIVED_TRANSCRIPT=false`.
+ */
+export const DERIVED_TRANSCRIPT_ENABLED = import.meta.env.VITE_DERIVED_TRANSCRIPT !== 'false';
+
/** Google Analytics 4 Measurement ID. Leave blank to disable GA. */
export const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
diff --git a/docs/plans/transcript-derived-view.md b/docs/plans/transcript-derived-view.md
new file mode 100644
index 000000000..47da181ba
--- /dev/null
+++ b/docs/plans/transcript-derived-view.md
@@ -0,0 +1,106 @@
+# Transcript-Derived View — Raw Session Files as Source of Truth
+
+Status: **draft — approved direction, phased implementation**
+Branch: `feat/transcript-derived-view` (stacked on `fix/transcript-restore-fidelity`)
+Companion: [`conversations-timeline-refactor.md`](conversations-timeline-refactor.md) (Phases 1–2, 4–5 landed; this plan supersedes its Phase 5 hydration story for settled turns)
+
+## Goal
+
+Stop maintaining chat state that must be *synced* with what is live. Derive the settled
+transcript from the raw session files (`session_raw/*.jsonl`), and demote every other
+store to a cache over that file. Live token streaming is untouched: in-flight turns
+render from ephemeral socket-fed state exactly as today; the file is authoritative only
+for **settled** turns.
+
+This is the Codex rollout model (one JSONL replayed into both model context and UI,
+with an explicit persistence policy) adapted to our layout, plus hermes-agent's
+soft-compaction lesson (history is never destroyed, only superseded).
+
+## Why derivation is unsafe today (must fix first)
+
+1. **Destructive compaction.** `agent/harness/session/transcript.rs::write_transcript`
+ full-rewrites the file so context reduction deletes earlier turns from disk.
+2. **Display data missing from the file**: interrupted partial answers, `request_id`
+ turn boundaries, narration items. They exist only in `turn_state` snapshots.
+3. **Internal scaffolding in message content**: channel-context prefixes on user
+ messages, tool-policy preamble in system content — must be sanitized (or tagged) at
+ projection, never shown raw.
+
+## Architecture
+
+```
+ live turn (unchanged)
+socket events ──► chatRuntimeSlice (ephemeral) ──► renderer
+ │ chat_done
+ ▼ invalidate
+settled turns:
+session_raw/{root}.jsonl ─┐
+session_raw/{root}__sub-*.jsonl ─┴─► core projection (threads.transcript_get)
+ │ mtime-keyed cache
+ ▼
+ typed display items ──► renderer (same components)
+```
+
+- **Model context** keeps reading the same file via the existing loader, now replaying
+ compaction records instead of trusting a rewritten file.
+- **`turn_state`** shrinks to live-turn crash recovery (interrupted `streamingText`
+ until the interrupted line is appended to the file, then only the in-flight turn).
+- The 20-turn retention cap stops being user-visible loss: history comes from the file.
+
+## Phases
+
+### Phase A — append-only transcript (Rust, prerequisite)
+`transcript.rs` + harness call sites:
+- Replace full-rewrite with **append-only** line writes. Context reduction appends a
+ `compaction` record `{ kind: "compaction", replacement_ids | replacement_history }`;
+ the model-context loader (`read_transcript` path) replays records to reconstruct the
+ post-compaction context; a new display reader returns *all* records.
+- Stamp `request_id` on every line of a turn (turn boundary markers); keep `iteration`,
+ `ts`, `seq` alignment with the progress-bridge envelope.
+- On turn abort/interrupt, append the partial assistant line flagged
+ `{ interrupted: true }` so the partial answer is in the file, not only in turn_state.
+- Migration: existing files are valid append-only files with zero compaction records —
+ no migration needed. Old cores reading new files must skip unknown `kind` lines
+ (verify the `_extra` flatten tolerates this; add a version field to `_meta`).
+- Tests: compaction round-trip (model context reduced, display history complete),
+ interrupted-partial append, request_id stamping, legacy-file read.
+
+### Phase B — projection RPC (Rust)
+New `threads.transcript_get(thread_id, {cursor?, limit?})` in the threads domain
+(canonical module shape: ops/schemas):
+- Resolve root transcript via `find_root_transcript_for_thread`; discover
+ `__sub-*.jsonl` children; project into typed display items:
+ `user_message | assistant_message | reasoning | tool_call {args, result, status} |
+ subagent {id, items} | turn_boundary {request_id} | interrupted_partial`.
+- Sanitize scaffolding (channel-context prefix, tool-policy preamble) at projection;
+ tag rather than mutate where ambiguity exists.
+- Cache: per-thread projection keyed on (file paths, mtimes, lengths); invalidated
+ implicitly by key change. No cache writes to disk — pure memory cache.
+- Pagination newest-first with cursor; default window sized for one screen.
+- Tests: JSON-RPC E2E (`tests/json_rpc_e2e.rs`) — write file, call RPC, assert items;
+ subagent merge; sanitization; cache-key invalidation.
+
+### Phase C — frontend switch (TS)
+- Thread-open restore path: replace `turn_state_history` hydration for settled turns
+ with `transcript_get`; map items onto the existing renderers
+ (`PastTurnInsights`/`ToolTimelineBlock`/`ProcessingTranscriptView`, bubbles).
+- Live turn: untouched (socket → chatRuntimeSlice). On `chat_done`, drop the live
+ turn's ephemeral state and refetch/append the settled projection.
+- Keep the turn_state hydration path as fallback behind a flag for one release.
+- Tests: restore renders identical item sequence to live for a scripted turn;
+ interrupted partial visible; legacy thread (no request_id) fallback.
+
+### Phase D — cleanup
+- Demote/remove `turn_state` ring retention (keep live-turn snapshot only), drop the
+ `.md` mirror or mark derived-only, delete the fallback flag.
+
+## Non-goals
+- No change to the socket streaming protocol or delta handling.
+- No SQLite consolidation (sessions.db stays an index; separate discussion).
+- Phase 3 of the timeline refactor (dedup consolidation) remains its own track.
+
+## Risks
+- **Old-core / new-file compat** — unknown `kind` lines must be skipped, not fatal.
+- **File growth** — append-only grows; mitigate later with Codex-style zstd of old
+ sessions; out of scope here.
+- **Sanitization false positives** — prefer tagging + frontend hiding over deletion.
diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs
index b3cea8dc0..0ebd629e7 100644
--- a/src/openhuman/agent/harness/session/builder/setters.rs
+++ b/src/openhuman/agent/harness/session/builder/setters.rs
@@ -511,6 +511,7 @@ impl AgentBuilder {
// `subagents` declaration against the global registry.
agent_definition_id: agent_definition_name.clone(),
session_transcript_path: None,
+ persisted_transcript_messages: Vec::new(),
session_key: {
let unix_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs
index fba8213a7..c7dd7614e 100644
--- a/src/openhuman/agent/harness/session/tests.rs
+++ b/src/openhuman/agent/harness/session/tests.rs
@@ -1481,6 +1481,81 @@ fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() {
);
}
+/// Cold-boot resume over an **append-only** transcript that carries a
+/// compaction record: the resumed model context must equal the REDUCED set the
+/// compaction installed (byte-identical to what the old full-rewrite produced),
+/// not the full pre-compaction history.
+#[test]
+fn seed_resume_replays_compaction_to_reduced_context() {
+ use super::transcript::{self, TranscriptMeta};
+ use crate::openhuman::inference::provider::ChatMessage;
+
+ let ws = tempfile::TempDir::new().expect("temp workspace");
+ let wsp = ws.path().to_path_buf();
+ let thread_id = "thr_compaction_resume";
+
+ let meta = TranscriptMeta {
+ agent_name: "orchestrator_thread-compact".to_string(),
+ agent_id: Some("orchestrator".to_string()),
+ agent_type: Some("root".to_string()),
+ dispatcher: "native".to_string(),
+ provider: None,
+ model: None,
+ created: "2026-01-01T00:00:00Z".to_string(),
+ updated: "2026-01-01T00:00:00Z".to_string(),
+ turn_count: 2,
+ input_tokens: 0,
+ output_tokens: 0,
+ cached_input_tokens: 0,
+ charged_amount_usd: 0.0,
+ thread_id: Some(thread_id.to_string()),
+ task_id: None,
+ };
+ let path = transcript::resolve_keyed_transcript_path(&wsp, "1700000000_orchestrator")
+ .expect("resolve transcript path");
+
+ // Turn 1: a full exchange. Turn 2: a context reduction (not a prefix) that
+ // must land as a compaction record.
+ let full = vec![
+ ChatMessage::system("system prompt"),
+ ChatMessage::user("q1"),
+ ChatMessage::assistant("a1"),
+ ChatMessage::user("q2"),
+ ChatMessage::assistant("a2"),
+ ];
+ transcript::append_transcript_turn(&path, &[], &full, &meta, None, None)
+ .expect("append turn 1");
+ let reduced = vec![
+ ChatMessage::system("system prompt"),
+ ChatMessage::assistant("[summary] q1/q2"),
+ ChatMessage::user("q3"),
+ ChatMessage::assistant("a3"),
+ ];
+ transcript::append_transcript_turn(&path, &full, &reduced, &meta, None, None)
+ .expect("append turn 2 (compaction)");
+
+ let mut agent = build_minimal_agent_with_definition_name(Some("some_other_agent_name"));
+ agent.workspace_dir = wsp.clone();
+
+ let loaded = agent.seed_resume_from_thread_transcript(thread_id);
+ assert!(
+ loaded,
+ "cold-boot resume must load the compacted transcript"
+ );
+ let cached = agent
+ .cached_transcript_messages
+ .as_ref()
+ .expect("cached transcript populated");
+ assert_eq!(
+ cached
+ .iter()
+ .map(|m| m.content.as_str())
+ .collect::>(),
+ vec!["system prompt", "[summary] q1/q2", "q3", "a3"],
+ "resumed context must be the reduced set the compaction installed"
+ );
+}
+
/// When no root transcript exists for the thread, the transcript resume is a
/// no-op returning `false` so the caller falls back to prose-pair seeding.
#[test]
diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs
index 4e293e382..fd73cd211 100644
--- a/src/openhuman/agent/harness/session/transcript.rs
+++ b/src/openhuman/agent/harness/session/transcript.rs
@@ -3,11 +3,53 @@
//! **Source of truth**: `session_raw/{stem}.jsonl` — a *flat* directory.
//!
//! Each JSONL file starts with a single metadata line (identified by an
-//! `_meta` key) followed by one JSON object per `ChatMessage`. On every
+//! `_meta` key) followed by one JSON object per record. On every
//! write the companion `.md` file is re-rendered for human readability
//! under `sessions/{YYYY_MM_DD}/{stem}.md`; it is **never** read back —
//! all round-trip / resume logic uses the JSONL.
//!
+//! ## Append-only log (Phase A — transcript-derived view)
+//!
+//! The JSONL is an **append-only event log**. [`write_transcript`] still
+//! full-rewrites (used by one-shot writers: migrations, sub-agent runners,
+//! tests), but the incremental session persistence path
+//! (`persist_session_transcript`) uses [`append_transcript_turn`], which
+//! never rewrites existing lines. It classifies the incoming logical
+//! message set against what was last persisted:
+//!
+//! - **Pure extension** (previously-persisted messages are a prefix of the
+//! new set): only the new tail message lines are appended.
+//! - **Reduction / rewrite** (context reduction dropped or replaced earlier
+//! turns — the previously-persisted set is *not* a prefix): a single
+//! `{"kind":"compaction","replacement":[…]}` record is appended carrying
+//! the full reduced message set. Earlier turns stay on disk untouched.
+//!
+//! Cumulative `_meta` totals are kept fresh without rewriting the file by
+//! appending a fresh `{"_meta":{…}}` line each turn; readers take the **last**
+//! `_meta` line as authoritative (line 1 remains a valid fallback for old
+//! cores that only read the header).
+//!
+//! ### Two read paths
+//!
+//! - **Model context** ([`read_transcript`] / `read_transcript_jsonl`) replays
+//! the log: message lines accumulate, a `compaction` record **replaces** the
+//! accumulator with its `replacement`, and `interrupted:true` partial lines
+//! are **skipped** (they never entered the model's context). The result is
+//! byte-identical to what the old full-rewrite approach produced.
+//! - **Display** ([`read_transcript_display`]) returns **every** record in file
+//! order — pre-compaction history, compaction markers, and interrupted
+//! partials — so the UI projection (Phase B) can render the full timeline.
+//!
+//! ### Compatibility
+//!
+//! Existing files (zero compaction records, no `version`) read identically.
+//! New record kinds and fields are additive: [`MessageLine`] carries a
+//! `#[serde(flatten)] _extra` catch-all and `MetaPayload` does not set
+//! `deny_unknown_fields`, so an **old** core reading a **new** file skips
+//! unknown-kind lines (a compaction record fails the `role`/`content`
+//! requirement and is logged+skipped) rather than crashing. The `_meta`
+//! carries a `version` field (`TRANSCRIPT_SCHEMA_VERSION`) for future readers.
+//!
//! ## Storage layout
//!
//! ```text
@@ -100,6 +142,84 @@ pub struct TurnUsage {
const TURN_USAGE_METADATA_KEY: &str = "openhuman_turn_usage";
+/// `extra_metadata` key carrying a tool-result message's failure marker. The
+/// harness folds a tool result into a `role:"tool"` message that drops the
+/// per-call failure flag (`ToolResult::is_error`), so the turn loop re-attaches
+/// the outcome here — from the captured `ToolCallOutcome` side-channel — before
+/// persistence. `extra_metadata` is `#[serde(skip_serializing)]` on
+/// [`ChatMessage`], so this never reaches the provider; the transcript writer
+/// lifts it onto the additive [`MessageLine::failure`] / `failure_detail` line
+/// fields and strips it from the persisted `extra_metadata`.
+const TOOL_FAILURE_METADATA_KEY: &str = "openhuman_tool_failure";
+
+/// Stamp a tool-result [`ChatMessage`] with its failure outcome so the
+/// transcript writer can persist an explicit failure flag. `detail` is an
+/// optional short, single-line reason (e.g. the head of the error output).
+/// No-op semantics: pass this only for genuinely failed tool calls.
+pub(crate) fn attach_tool_failure_metadata(message: &mut ChatMessage, detail: Option<&str>) {
+ let mut payload = serde_json::Map::new();
+ payload.insert("failure".to_string(), serde_json::Value::Bool(true));
+ if let Some(detail) = detail.map(str::trim).filter(|s| !s.is_empty()) {
+ payload.insert(
+ "detail".to_string(),
+ serde_json::Value::String(detail.to_string()),
+ );
+ }
+ let marker = serde_json::Value::Object(payload);
+
+ match message.extra_metadata.take() {
+ Some(serde_json::Value::Object(mut map)) => {
+ map.insert(TOOL_FAILURE_METADATA_KEY.to_string(), marker);
+ message.extra_metadata = Some(serde_json::Value::Object(map));
+ }
+ Some(existing) => {
+ let mut map = serde_json::Map::new();
+ map.insert("value".to_string(), existing);
+ map.insert(TOOL_FAILURE_METADATA_KEY.to_string(), marker);
+ message.extra_metadata = Some(serde_json::Value::Object(map));
+ }
+ None => {
+ let mut map = serde_json::Map::new();
+ map.insert(TOOL_FAILURE_METADATA_KEY.to_string(), marker);
+ message.extra_metadata = Some(serde_json::Value::Object(map));
+ }
+ }
+}
+
+/// Pop the tool-failure marker out of a cloned `extra_metadata` map, returning
+/// `Some((true, detail))` when it was present. Strips the key so it is not
+/// duplicated into the persisted `extra_metadata` alongside the top-level
+/// `failure` line field. Legacy lines without the marker return `None`.
+fn take_tool_failure(extra: &mut Option) -> Option<(bool, Option)> {
+ let serde_json::Value::Object(map) = extra.as_mut()? else {
+ return None;
+ };
+ let marker = map.remove(TOOL_FAILURE_METADATA_KEY)?;
+ // If removing the marker emptied the object, drop `extra_metadata` entirely
+ // so a legacy-identical line stays legacy-identical.
+ if map.is_empty() {
+ *extra = None;
+ }
+ let detail = marker
+ .get("detail")
+ .and_then(|d| d.as_str())
+ .map(str::to_string);
+ Some((true, detail))
+}
+
+/// Schema version stamped on the `_meta` header line. Bumped when the JSONL
+/// record shape changes in a way future readers may need to branch on. `0`
+/// (absent) denotes pre-append-only files written before this field existed.
+pub const TRANSCRIPT_SCHEMA_VERSION: u32 = 1;
+
+/// Discriminator value for a compaction record's `kind` field.
+const COMPACTION_KIND: &str = "compaction";
+
+#[allow(clippy::trivially_copy_pass_by_ref)]
+fn is_false(b: &bool) -> bool {
+ !*b
+}
+
pub(crate) fn attach_turn_usage_metadata(message: &mut ChatMessage, turn_usage: &TurnUsage) {
let Ok(payload) = serde_json::to_value(turn_usage) else {
log::warn!("[transcript] failed to serialize turn usage metadata");
@@ -199,6 +319,11 @@ struct MetaLine {
#[derive(Serialize, Deserialize)]
struct MetaPayload {
+ /// Schema version of the transcript record format (see
+ /// [`TRANSCRIPT_SCHEMA_VERSION`]). Absent (deserialises to `0`) on files
+ /// written before the append-only migration.
+ #[serde(default)]
+ version: u32,
agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_id: Option,
@@ -247,19 +372,222 @@ struct MessageLine {
iteration: Option,
#[serde(skip_serializing_if = "Option::is_none")]
ts: Option,
+ /// Turn boundary marker: the web-chat `request_id` this message belongs to,
+ /// when available. Stamped on every line of a turn so the display projection
+ /// can group a turn's messages. Absent for CLI / non-request-scoped runs.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ request_id: Option,
+ /// `true` when this line is a *partial* assistant answer captured because
+ /// the turn was interrupted/cancelled mid-stream. Present for **display
+ /// only** — the model-context reader skips these so a resumed context never
+ /// carries a truncated answer.
+ #[serde(default, skip_serializing_if = "is_false")]
+ interrupted: bool,
+ /// `true` when this tool-result line's tool call **failed**
+ /// (`ToolResult::is_error`). Additive + optional: legacy lines and every
+ /// non-tool line omit it and default to success. Lifted from the tool
+ /// message's failure metadata by [`build_message_line`]; consumed by the
+ /// display projection to render an error tool row instead of success.
+ #[serde(default, skip_serializing_if = "is_false")]
+ failure: bool,
+ /// Optional short, single-line reason for a failed tool call (the head of
+ /// the error output). Present only alongside `failure: true`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ failure_detail: Option,
/// Absorb any unknown fields so forward-compat reads don't error.
#[serde(flatten)]
_extra: HashMap,
}
+/// A compaction record: `{"kind":"compaction","replacement":[…]}`.
+///
+/// Appended when the harness reduces context (post-compaction / trim) so the
+/// model-context reader can reconstruct the reduced set without the file being
+/// destructively rewritten. `replacement` is the **full** logical message set
+/// that supersedes everything before it — an explicit replacement list
+/// (mirroring Codex's `Compacted { replacement_history }`) rather than
+/// surviving-message ids, because our writer already holds the reduced
+/// `messages` slice on each persist call and message ids are optional, so an
+/// id-reference scheme would be less robust for no gain.
+#[derive(Serialize, Deserialize)]
+struct CompactionLine {
+ kind: String,
+ replacement: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ ts: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ request_id: Option,
+ #[serde(flatten)]
+ _extra: HashMap,
+}
+
+// ── Display read types ───────────────────────────────────────────────
+
+/// One message in a display projection, carrying the turn-boundary + partial
+/// flags the model-context [`SessionTranscript`] discards.
+#[derive(Debug, Clone)]
+pub struct DisplayMessage {
+ pub message: ChatMessage,
+ /// `true` when this is an interrupted partial answer (display only).
+ pub interrupted: bool,
+ /// Turn boundary marker (`request_id`), when stamped.
+ pub request_id: Option,
+ pub iteration: Option,
+ pub ts: Option,
+ /// Usage/provenance for assistant messages that carried it.
+ pub turn_usage: Option,
+ /// Raw reasoning/thinking captured for this line, when present. Mirrors the
+ /// line's `reasoning_content` directly so it survives even on lines without
+ /// full turn-usage provenance (e.g. an interrupted partial, which carries no
+ /// provider/model/usage). Prefer this over digging into [`Self::turn_usage`]
+ /// for display: it is populated from `turn_usage.reasoning_content` too.
+ pub reasoning_content: Option,
+ /// `true` when this is a **failed** tool-result line (`ToolResult::is_error`
+ /// at execution time). The display projection renders an error tool row
+ /// instead of success. Always `false` for non-tool lines and legacy files.
+ pub failure: bool,
+ /// Optional short reason for a failed tool call (present only with
+ /// `failure: true`).
+ pub failure_detail: Option,
+}
+
+/// A compaction marker in a display projection.
+#[derive(Debug, Clone)]
+pub struct CompactionMarker {
+ /// The reduced message set this compaction installed as the new context.
+ pub replacement: Vec,
+ pub ts: Option,
+ pub request_id: Option,
+}
+
+/// One record in a display projection, in file order.
+#[derive(Debug, Clone)]
+pub enum DisplayRecord {
+ Message(DisplayMessage),
+ Compaction(CompactionMarker),
+}
+
+/// A display projection of a transcript: **all** records, including
+/// pre-compaction history, compaction markers, and interrupted partials.
+#[derive(Debug, Clone)]
+pub struct DisplaySessionTranscript {
+ pub meta: TranscriptMeta,
+ pub records: Vec,
+}
+
// ── Write ─────────────────────────────────────────────────────────────
+/// Build the serialised `_meta` header line for `meta`, stamping the current
+/// [`TRANSCRIPT_SCHEMA_VERSION`].
+fn meta_payload_from(meta: &TranscriptMeta) -> MetaPayload {
+ MetaPayload {
+ version: TRANSCRIPT_SCHEMA_VERSION,
+ agent: meta.agent_name.clone(),
+ agent_id: meta.agent_id.clone(),
+ agent_type: meta.agent_type.clone(),
+ dispatcher: meta.dispatcher.clone(),
+ provider: meta.provider.clone(),
+ model: meta.model.clone(),
+ created: meta.created.clone(),
+ updated: meta.updated.clone(),
+ turn_count: meta.turn_count,
+ input_tokens: meta.input_tokens,
+ output_tokens: meta.output_tokens,
+ cached_input_tokens: meta.cached_input_tokens,
+ charged_amount_usd: meta.charged_amount_usd,
+ thread_id: meta.thread_id.clone(),
+ task_id: meta.task_id.clone(),
+ }
+}
+
+fn meta_line_json(meta: &TranscriptMeta) -> Result {
+ let meta_line = MetaLine {
+ meta: meta_payload_from(meta),
+ };
+ serde_json::to_string(&meta_line).context("serialise transcript meta header")
+}
+
+/// Build a [`MessageLine`] for `msg`, folding in `turn_usage` (assistant rows)
+/// and stamping the `request_id` turn boundary when supplied.
+fn build_message_line(
+ msg: &ChatMessage,
+ turn_usage: Option<&TurnUsage>,
+ request_id: Option<&str>,
+ interrupted: bool,
+) -> MessageLine {
+ let assistant_usage = if msg.role == "assistant" {
+ turn_usage
+ } else {
+ None
+ };
+ // Lift any tool-failure marker off a cloned `extra_metadata` onto the
+ // additive top-level `failure` / `failure_detail` line fields, stripping it
+ // so it is not persisted twice.
+ let mut extra_metadata = msg.extra_metadata.clone();
+ let (failure, failure_detail) = match take_tool_failure(&mut extra_metadata) {
+ Some((failed, detail)) => (failed, detail),
+ None => (false, None),
+ };
+ MessageLine {
+ id: msg.id.clone(),
+ role: msg.role.clone(),
+ content: msg.content.clone(),
+ extra_metadata,
+ provider: assistant_usage.map(|tu| tu.provider.clone()),
+ model: assistant_usage.map(|tu| tu.model.clone()),
+ usage: assistant_usage.map(|tu| tu.usage.clone()),
+ reasoning_content: assistant_usage.and_then(|tu| tu.reasoning_content.clone()),
+ tool_calls: assistant_usage.and_then(|tu| {
+ if tu.tool_calls.is_empty() {
+ None
+ } else {
+ Some(tu.tool_calls.clone())
+ }
+ }),
+ iteration: assistant_usage.map(|tu| tu.iteration),
+ ts: assistant_usage.map(|tu| tu.ts.clone()),
+ request_id: request_id.map(str::to_string),
+ interrupted,
+ failure,
+ failure_detail,
+ _extra: HashMap::new(),
+ }
+}
+
+/// Serialise `messages` into JSONL message lines, attributing
+/// `last_assistant_turn_usage` (or per-message embedded usage) to the last
+/// assistant row and stamping `request_id` on every line.
+fn serialise_message_lines(
+ messages: &[ChatMessage],
+ last_assistant_turn_usage: Option<&TurnUsage>,
+ request_id: Option<&str>,
+ buf: &mut String,
+) -> Result<()> {
+ let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant");
+ for (i, msg) in messages.iter().enumerate() {
+ let turn_usage = if Some(i) == last_assistant_idx {
+ last_assistant_turn_usage
+ .cloned()
+ .or_else(|| turn_usage_from_metadata(msg))
+ } else {
+ turn_usage_from_metadata(msg)
+ };
+ let line = build_message_line(msg, turn_usage.as_ref(), request_id, false);
+ let line_json =
+ serde_json::to_string(&line).with_context(|| format!("serialise message line {i}"))?;
+ buf.push_str(&line_json);
+ buf.push('\n');
+ }
+ Ok(())
+}
+
/// Write JSONL as source of truth **and** re-render the companion `.md`.
///
/// `jsonl_path` must end in `.jsonl`; the `.md` companion is derived by
-/// swapping the extension. Full rewrite on every call (not append) so
-/// that context-reduction that removed earlier messages is reflected
-/// immediately.
+/// swapping the extension. **Full rewrite** on every call — this is the
+/// one-shot writer used by migrations, the sub-agent runners, and tests.
+/// The incremental session-persistence path uses [`append_transcript_turn`]
+/// instead, which never rewrites existing lines.
pub fn write_transcript(
jsonl_path: &Path,
messages: &[ChatMessage],
@@ -273,116 +601,219 @@ pub fn write_transcript(
// ── JSONL ────────────────────────────────────────────────────────
let mut jsonl_buf = String::new();
-
- // Line 1: meta header.
- let meta_line = MetaLine {
- meta: MetaPayload {
- agent: meta.agent_name.clone(),
- agent_id: meta.agent_id.clone(),
- agent_type: meta.agent_type.clone(),
- dispatcher: meta.dispatcher.clone(),
- provider: meta.provider.clone(),
- model: meta.model.clone(),
- created: meta.created.clone(),
- updated: meta.updated.clone(),
- turn_count: meta.turn_count,
- input_tokens: meta.input_tokens,
- output_tokens: meta.output_tokens,
- cached_input_tokens: meta.cached_input_tokens,
- charged_amount_usd: meta.charged_amount_usd,
- thread_id: meta.thread_id.clone(),
- task_id: meta.task_id.clone(),
- },
- };
- let meta_json =
- serde_json::to_string(&meta_line).context("serialise transcript meta header")?;
- jsonl_buf.push_str(&meta_json);
+ jsonl_buf.push_str(&meta_line_json(meta)?);
jsonl_buf.push('\n');
-
- // Identify the index of the last assistant message so older call sites can
- // still attach per-turn usage without embedding it on the message.
- let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant");
-
- for (i, msg) in messages.iter().enumerate() {
- let turn_usage = if Some(i) == last_assistant_idx {
- last_assistant_turn_usage
- .cloned()
- .or_else(|| turn_usage_from_metadata(msg))
- } else {
- turn_usage_from_metadata(msg)
- };
-
- let line = if msg.role == "assistant" {
- if let Some(tu) = turn_usage.as_ref() {
- MessageLine {
- id: msg.id.clone(),
- role: msg.role.clone(),
- content: msg.content.clone(),
- extra_metadata: msg.extra_metadata.clone(),
- provider: Some(tu.provider.clone()),
- model: Some(tu.model.clone()),
- usage: Some(tu.usage.clone()),
- reasoning_content: tu.reasoning_content.clone(),
- tool_calls: if tu.tool_calls.is_empty() {
- None
- } else {
- Some(tu.tool_calls.clone())
- },
- iteration: Some(tu.iteration),
- ts: Some(tu.ts.clone()),
- _extra: HashMap::new(),
- }
- } else {
- MessageLine {
- id: msg.id.clone(),
- role: msg.role.clone(),
- content: msg.content.clone(),
- extra_metadata: msg.extra_metadata.clone(),
- provider: None,
- model: None,
- usage: None,
- reasoning_content: None,
- tool_calls: None,
- iteration: None,
- ts: None,
- _extra: HashMap::new(),
- }
- }
- } else {
- MessageLine {
- id: msg.id.clone(),
- role: msg.role.clone(),
- content: msg.content.clone(),
- extra_metadata: msg.extra_metadata.clone(),
- provider: None,
- model: None,
- usage: None,
- reasoning_content: None,
- tool_calls: None,
- iteration: None,
- ts: None,
- _extra: HashMap::new(),
- }
- };
-
- let line_json =
- serde_json::to_string(&line).with_context(|| format!("serialise message line {i}"))?;
- jsonl_buf.push_str(&line_json);
- jsonl_buf.push('\n');
- }
+ serialise_message_lines(messages, last_assistant_turn_usage, None, &mut jsonl_buf)?;
fs::write(jsonl_path, jsonl_buf.as_bytes())
.with_context(|| format!("write transcript {}", jsonl_path.display()))?;
log::debug!(
- "[transcript] wrote {} messages (jsonl) to {}",
+ "[transcript] wrote {} messages (jsonl, full rewrite) to {}",
messages.len(),
jsonl_path.display()
);
- // ── Companion .md ────────────────────────────────────────────────
- // Build per-message usage index for the renderer. Embedded metadata keeps
- // older assistant iterations visible when the full JSONL is rewritten.
+ render_md_companion(jsonl_path, messages, meta, last_assistant_turn_usage);
+ Ok(())
+}
+
+/// Append this turn's delta to an **append-only** transcript, never rewriting
+/// existing lines.
+///
+/// `prev_persisted` is the logical message set the previous call left on disk
+/// (empty on the first call for a fresh file). The incoming `messages` is the
+/// current full logical set for this turn:
+///
+/// - **Pure extension** (`prev_persisted` is a prefix of `messages`): only the
+/// new tail is appended as message lines.
+/// - **Reduction / rewrite** (context reduction changed or dropped earlier
+/// turns): a single `compaction` record carrying the full reduced
+/// `messages` is appended; earlier lines are left untouched on disk.
+///
+/// A fresh `_meta` line is appended so cumulative totals stay current without a
+/// full rewrite. The `.md` companion is re-rendered from `messages` (derived
+/// view — always the reduced/current set). Returns nothing; the caller updates
+/// its tracked `prev_persisted` to `messages` on success.
+///
+/// `request_id` (when available from the web-chat path) is stamped on every
+/// appended line as a turn boundary marker.
+pub fn append_transcript_turn(
+ jsonl_path: &Path,
+ prev_persisted: &[ChatMessage],
+ messages: &[ChatMessage],
+ meta: &TranscriptMeta,
+ turn_usage: Option<&TurnUsage>,
+ request_id: Option<&str>,
+) -> Result<()> {
+ if let Some(parent) = jsonl_path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("create transcript dir {}", parent.display()))?;
+ }
+
+ let file_exists = jsonl_path.exists();
+
+ // First write for this file: create it with meta + all message lines.
+ if !file_exists {
+ let mut buf = String::new();
+ buf.push_str(&meta_line_json(meta)?);
+ buf.push('\n');
+ serialise_message_lines(messages, turn_usage, request_id, &mut buf)?;
+ fs::write(jsonl_path, buf.as_bytes())
+ .with_context(|| format!("create transcript {}", jsonl_path.display()))?;
+ log::debug!(
+ "[transcript] created append-only transcript with {} message(s) at {}",
+ messages.len(),
+ jsonl_path.display()
+ );
+ render_md_companion(jsonl_path, messages, meta, turn_usage);
+ return Ok(());
+ }
+
+ // Subsequent writes: diff against the previously-persisted logical set.
+ let common = common_prefix_len(prev_persisted, messages);
+ let mut buf = String::new();
+
+ if common == prev_persisted.len() {
+ // Pure extension — append only the new tail.
+ let tail = &messages[common..];
+ log::debug!(
+ "[transcript] append: extending on-disk set (prev={}, new={}, appending {} tail line(s)) {}",
+ prev_persisted.len(),
+ messages.len(),
+ tail.len(),
+ jsonl_path.display()
+ );
+ serialise_message_lines(tail, turn_usage, request_id, &mut buf)?;
+ } else {
+ // Reduction / rewrite — the on-disk set is no longer a prefix. Append a
+ // compaction record carrying the full reduced context so the
+ // model-context reader can replay it, without destroying earlier lines.
+ log::debug!(
+ "[transcript] append: context reduced (prev={}, new={}, common_prefix={}) — writing compaction record {}",
+ prev_persisted.len(),
+ messages.len(),
+ common,
+ jsonl_path.display()
+ );
+ let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant");
+ let replacement: Vec = messages
+ .iter()
+ .enumerate()
+ .map(|(i, msg)| {
+ let tu = if Some(i) == last_assistant_idx {
+ turn_usage
+ .cloned()
+ .or_else(|| turn_usage_from_metadata(msg))
+ } else {
+ turn_usage_from_metadata(msg)
+ };
+ build_message_line(msg, tu.as_ref(), request_id, false)
+ })
+ .collect();
+ let compaction = CompactionLine {
+ kind: COMPACTION_KIND.to_string(),
+ replacement,
+ ts: Some(chrono::Utc::now().to_rfc3339()),
+ request_id: request_id.map(str::to_string),
+ _extra: HashMap::new(),
+ };
+ let line = serde_json::to_string(&compaction).context("serialise compaction record")?;
+ buf.push_str(&line);
+ buf.push('\n');
+ }
+
+ // Refresh cumulative meta by appending a new `_meta` line (readers take the
+ // last one). Keeps append-only + O(1)-per-turn (no full-file rewrite).
+ buf.push_str(&meta_line_json(meta)?);
+ buf.push('\n');
+
+ append_bytes(jsonl_path, buf.as_bytes())?;
+ render_md_companion(jsonl_path, messages, meta, turn_usage);
+ Ok(())
+}
+
+/// Append a partial assistant answer, flagged `interrupted: true`, captured
+/// when a streaming turn was cancelled/interrupted before completion.
+///
+/// **Display only**: the model-context reader skips interrupted lines, so a
+/// resumed context never carries a truncated answer. Does not affect the
+/// caller's tracked `prev_persisted` (nothing about the logical model context
+/// changed). No-op when `partial_content` is empty.
+pub fn append_interrupted_partial(
+ jsonl_path: &Path,
+ partial_content: &str,
+ request_id: Option<&str>,
+ iteration: Option,
+ reasoning_content: Option<&str>,
+) -> Result<()> {
+ if partial_content.is_empty() {
+ return Ok(());
+ }
+ if let Some(parent) = jsonl_path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("create transcript dir {}", parent.display()))?;
+ }
+ let mut line = build_message_line(
+ &ChatMessage::assistant(partial_content),
+ None,
+ request_id,
+ true,
+ );
+ line.iteration = iteration;
+ line.reasoning_content = reasoning_content
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(str::to_string);
+ line.ts = Some(chrono::Utc::now().to_rfc3339());
+ let mut buf = serde_json::to_string(&line).context("serialise interrupted partial line")?;
+ buf.push('\n');
+ append_bytes(jsonl_path, buf.as_bytes())?;
+ log::debug!(
+ "[transcript] appended interrupted partial ({} chars, request_id={:?}) to {}",
+ partial_content.len(),
+ request_id,
+ jsonl_path.display()
+ );
+ Ok(())
+}
+
+/// Longest common prefix length between two message slices, comparing on the
+/// stable, serialised fields (`role`, `content`, `id`). `ChatMessage` does not
+/// derive `PartialEq`, and `extra_metadata` is intentionally excluded because
+/// it is enriched (turn usage) between the in-memory history and the persisted
+/// line, which must not count as a divergence.
+fn common_prefix_len(a: &[ChatMessage], b: &[ChatMessage]) -> usize {
+ a.iter()
+ .zip(b.iter())
+ .take_while(|(x, y)| x.role == y.role && x.content == y.content && x.id == y.id)
+ .count()
+}
+
+/// Append raw bytes to a file, opening in append mode (O(1), no read-back).
+fn append_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
+ use std::io::Write;
+ let mut file = fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(path)
+ .with_context(|| format!("open transcript for append {}", path.display()))?;
+ file.write_all(bytes)
+ .with_context(|| format!("append transcript {}", path.display()))?;
+ Ok(())
+}
+
+/// Re-render the derived `.md` companion from the current (reduced) message set.
+///
+/// Best-effort — the JSONL is the source of truth; a companion write failure is
+/// logged and swallowed so it can never take down state persistence.
+fn render_md_companion(
+ jsonl_path: &Path,
+ messages: &[ChatMessage],
+ meta: &TranscriptMeta,
+ last_assistant_turn_usage: Option<&TurnUsage>,
+) {
+ let last_assistant_idx = messages.iter().rposition(|m| m.role == "assistant");
let mut owned_usage: Vec<(usize, TurnUsage)> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
let usage = if Some(idx) == last_assistant_idx {
@@ -401,10 +832,6 @@ pub fn write_transcript(
.map(|(idx, usage)| (*idx, usage))
.collect();
- // The .md companion is a *derived* view — the JSONL above is the
- // source of truth. Failures here must not propagate: a readable-log
- // hiccup shouldn't take down the session's state persistence. Log
- // and move on.
let md_path = md_companion_path(jsonl_path);
if let Some(parent) = md_path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
@@ -412,7 +839,7 @@ pub fn write_transcript(
"[transcript] failed to create md companion dir {}: {err}",
parent.display()
);
- return Ok(());
+ return;
}
}
let md = render_markdown(messages, meta, &per_msg_usage);
@@ -421,15 +848,12 @@ pub fn write_transcript(
"[transcript] failed to write markdown companion {}: {err}",
md_path.display()
);
- return Ok(());
+ return;
}
-
log::debug!(
"[transcript] wrote markdown companion to {}",
md_path.display()
);
-
- Ok(())
}
// ── Read ─────────────────────────────────────────────────────────────
@@ -471,24 +895,112 @@ pub fn read_transcript(path: &Path) -> Result {
}
}
+/// Convert a parsed `MetaPayload` into the public [`TranscriptMeta`].
+fn meta_from_payload(mp: MetaPayload) -> TranscriptMeta {
+ TranscriptMeta {
+ agent_name: mp.agent,
+ agent_id: mp.agent_id,
+ agent_type: mp.agent_type,
+ dispatcher: mp.dispatcher,
+ provider: mp.provider,
+ model: mp.model,
+ created: mp.created,
+ updated: mp.updated,
+ turn_count: mp.turn_count,
+ input_tokens: mp.input_tokens,
+ output_tokens: mp.output_tokens,
+ cached_input_tokens: mp.cached_input_tokens,
+ charged_amount_usd: mp.charged_amount_usd,
+ thread_id: mp.thread_id,
+ task_id: mp.task_id,
+ }
+}
+
+/// Recover the [`TurnUsage`] a message line carried (assistant rows only).
+fn turn_usage_from_line(ml: &MessageLine) -> Option {
+ match (
+ ml.provider.clone(),
+ ml.model.clone(),
+ ml.usage.clone(),
+ ml.ts.clone(),
+ ) {
+ (Some(provider), Some(model), Some(usage), Some(ts)) if ml.role == "assistant" => {
+ Some(TurnUsage {
+ provider,
+ model,
+ usage,
+ ts,
+ reasoning_content: ml.reasoning_content.clone(),
+ tool_calls: ml.tool_calls.clone().unwrap_or_default(),
+ iteration: ml.iteration.unwrap_or_default(),
+ })
+ }
+ _ => None,
+ }
+}
+
+/// Reconstruct a [`ChatMessage`] from a message line, re-attaching turn-usage
+/// metadata so the round-trip is lossless for the model-context path.
+fn message_from_line(ml: MessageLine) -> ChatMessage {
+ let turn_usage = turn_usage_from_line(&ml);
+ let mut message = ChatMessage {
+ id: ml.id,
+ role: ml.role,
+ content: ml.content,
+ extra_metadata: ml.extra_metadata,
+ };
+ if let Some(turn_usage) = turn_usage.as_ref() {
+ attach_turn_usage_metadata(&mut message, turn_usage);
+ }
+ message
+}
+
+/// Classification of one non-empty JSONL line.
+enum LineKind {
+ Meta(MetaLine),
+ Compaction(CompactionLine),
+ Message(MessageLine),
+}
+
+/// Classify a raw line: a `_meta` header/update, a `compaction` record, or a
+/// message line. Returns `Err` only when the line is malformed for its
+/// apparent kind; the caller decides whether that is fatal (first line) or a
+/// skippable warning (later lines).
+fn classify_line(line: &str) -> Result {
+ // Cheap structural peek. Unknown/other shapes fall through to MessageLine,
+ // whose required `role`/`content` gate rejects genuinely foreign lines.
+ let value: serde_json::Value = serde_json::from_str(line)?;
+ if value.get("_meta").is_some() {
+ return serde_json::from_str::(line).map(LineKind::Meta);
+ }
+ if value.get("kind").and_then(|k| k.as_str()) == Some(COMPACTION_KIND) {
+ return serde_json::from_str::(line).map(LineKind::Compaction);
+ }
+ serde_json::from_str::(line).map(LineKind::Message)
+}
+
fn read_transcript_jsonl(path: &Path) -> Result {
let raw = fs::read_to_string(path)
.with_context(|| format!("read transcript jsonl {}", path.display()))?;
let mut meta: Option = None;
let mut messages: Vec = Vec::new();
+ let mut compactions_replayed = 0usize;
+ let mut interrupted_skipped = 0usize;
- // The JSONL format is positional: line 1 (the first non-empty line)
- // is the `_meta` header; every subsequent non-empty line is a message.
- // This avoids a substring check that could false-positive if message
- // content contains `"_meta"`.
+ // Append-only log replay (Phase A): the first non-empty line MUST be the
+ // `_meta` header; subsequent lines are messages, `compaction` records
+ // (which *replace* the accumulated context), interrupted partials (skipped
+ // for the model-context path), or refreshed `_meta` lines (last wins).
+ let mut seen_first = false;
for (line_no, line) in raw.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
- if meta.is_none() {
+ if !seen_first {
+ seen_first = true;
let ml: MetaLine = serde_json::from_str(line).map_err(|err| {
anyhow::anyhow!(
"first non-empty line of {} (line {}) is not a valid _meta object: {err}",
@@ -496,65 +1008,47 @@ fn read_transcript_jsonl(path: &Path) -> Result {
line_no + 1,
)
})?;
- let mp = ml.meta;
- meta = Some(TranscriptMeta {
- agent_name: mp.agent,
- agent_id: mp.agent_id,
- agent_type: mp.agent_type,
- dispatcher: mp.dispatcher,
- provider: mp.provider,
- model: mp.model,
- created: mp.created,
- updated: mp.updated,
- turn_count: mp.turn_count,
- input_tokens: mp.input_tokens,
- output_tokens: mp.output_tokens,
- cached_input_tokens: mp.cached_input_tokens,
- charged_amount_usd: mp.charged_amount_usd,
- thread_id: mp.thread_id,
- task_id: mp.task_id,
- });
+ meta = Some(meta_from_payload(ml.meta));
continue;
}
- // Message line.
- match serde_json::from_str::(line) {
- Ok(ml) => {
- let turn_usage = match (
- ml.provider.clone(),
- ml.model.clone(),
- ml.usage.clone(),
- ml.ts.clone(),
- ) {
- (Some(provider), Some(model), Some(usage), Some(ts))
- if ml.role == "assistant" =>
- {
- Some(TurnUsage {
- provider,
- model,
- usage,
- ts,
- reasoning_content: ml.reasoning_content.clone(),
- tool_calls: ml.tool_calls.clone().unwrap_or_default(),
- iteration: ml.iteration.unwrap_or_default(),
- })
- }
- _ => None,
- };
- let mut message = ChatMessage {
- id: ml.id,
- role: ml.role,
- content: ml.content,
- extra_metadata: ml.extra_metadata,
- };
- if let Some(turn_usage) = turn_usage.as_ref() {
- attach_turn_usage_metadata(&mut message, turn_usage);
+ match classify_line(line) {
+ Ok(LineKind::Meta(ml)) => {
+ // Refreshed cumulative meta — last one wins.
+ meta = Some(meta_from_payload(ml.meta));
+ }
+ Ok(LineKind::Compaction(cl)) => {
+ // Reduction record: the reduced context REPLACES everything
+ // accumulated so far, exactly reproducing the old full-rewrite.
+ let replacement: Vec =
+ cl.replacement.into_iter().map(message_from_line).collect();
+ log::debug!(
+ "[transcript] replay: compaction at line {} replaces {} accumulated message(s) with {} (request_id={:?}) in {}",
+ line_no + 1,
+ messages.len(),
+ replacement.len(),
+ cl.request_id,
+ path.display()
+ );
+ messages = replacement;
+ compactions_replayed += 1;
+ }
+ Ok(LineKind::Message(ml)) => {
+ if ml.interrupted {
+ // Display-only partial — never part of the model context.
+ interrupted_skipped += 1;
+ log::debug!(
+ "[transcript] replay: skipping interrupted partial line {} (display only) in {}",
+ line_no + 1,
+ path.display()
+ );
+ continue;
}
- messages.push(message);
+ messages.push(message_from_line(ml));
}
Err(err) => {
log::warn!(
- "[transcript] skipping malformed message line {} in {}: {err}",
+ "[transcript] skipping malformed/unknown record line {} in {}: {err}",
line_no + 1,
path.display()
);
@@ -570,14 +1064,119 @@ fn read_transcript_jsonl(path: &Path) -> Result {
})?;
log::debug!(
- "[transcript] loaded {} messages (jsonl) from {}",
+ "[transcript] loaded {} messages (jsonl, {} compaction(s) replayed, {} interrupted skipped) from {}",
messages.len(),
+ compactions_replayed,
+ interrupted_skipped,
path.display()
);
Ok(SessionTranscript { meta, messages })
}
+// ── Display read ──────────────────────────────────────────────────────
+
+/// Reconstruct a [`DisplayMessage`] from a message line, preserving the
+/// turn-boundary + partial flags the model-context path discards.
+fn display_message_from_line(ml: MessageLine) -> DisplayMessage {
+ let turn_usage = turn_usage_from_line(&ml);
+ let reasoning_content = ml.reasoning_content.clone().or_else(|| {
+ turn_usage
+ .as_ref()
+ .and_then(|tu| tu.reasoning_content.clone())
+ });
+ DisplayMessage {
+ interrupted: ml.interrupted,
+ request_id: ml.request_id.clone(),
+ iteration: ml.iteration,
+ ts: ml.ts.clone(),
+ turn_usage,
+ reasoning_content,
+ failure: ml.failure,
+ failure_detail: ml.failure_detail.clone(),
+ message: ChatMessage {
+ id: ml.id,
+ role: ml.role,
+ content: ml.content,
+ extra_metadata: ml.extra_metadata,
+ },
+ }
+}
+
+/// Read a transcript for **display**: returns *every* record in file order,
+/// including pre-compaction history, compaction markers, and interrupted
+/// partials — the counterpart to the model-context [`read_transcript`], which
+/// collapses the log into the reduced context.
+///
+/// `meta` reflects the newest `_meta` line (cumulative totals stay current).
+pub fn read_transcript_display(path: &Path) -> Result {
+ let raw = fs::read_to_string(path)
+ .with_context(|| format!("read transcript jsonl (display) {}", path.display()))?;
+
+ let mut meta: Option = None;
+ let mut records: Vec = Vec::new();
+ let mut seen_first = false;
+
+ for (line_no, line) in raw.lines().enumerate() {
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ if !seen_first {
+ seen_first = true;
+ let ml: MetaLine = serde_json::from_str(line).map_err(|err| {
+ anyhow::anyhow!(
+ "first non-empty line of {} (line {}) is not a valid _meta object: {err}",
+ path.display(),
+ line_no + 1,
+ )
+ })?;
+ meta = Some(meta_from_payload(ml.meta));
+ continue;
+ }
+ match classify_line(line) {
+ Ok(LineKind::Meta(ml)) => meta = Some(meta_from_payload(ml.meta)),
+ Ok(LineKind::Compaction(cl)) => {
+ let replacement = cl
+ .replacement
+ .into_iter()
+ .map(display_message_from_line)
+ .collect();
+ records.push(DisplayRecord::Compaction(CompactionMarker {
+ replacement,
+ ts: cl.ts,
+ request_id: cl.request_id,
+ }));
+ }
+ Ok(LineKind::Message(ml)) => {
+ records.push(DisplayRecord::Message(display_message_from_line(ml)));
+ }
+ Err(err) => {
+ log::warn!(
+ "[transcript] display: skipping malformed/unknown record line {} in {}: {err}",
+ line_no + 1,
+ path.display()
+ );
+ }
+ }
+ }
+
+ let meta = meta.with_context(|| {
+ format!(
+ "missing _meta header line in jsonl transcript {}",
+ path.display()
+ )
+ })?;
+
+ log::debug!(
+ "[transcript] display-loaded {} record(s) from {}",
+ records.len(),
+ path.display()
+ );
+
+ Ok(DisplaySessionTranscript { meta, records })
+}
+
/// Find the newest root `session_raw/*.jsonl` transcript whose metadata
/// declares `thread_id`.
///
@@ -661,60 +1260,65 @@ pub struct SubagentArchetypeUsage {
pub model: Option,
}
-/// Parse just the `_meta` header of a root transcript JSONL (cheap — stops at
-/// the first non-empty line).
+/// Parse the authoritative `_meta` of a root transcript JSONL.
+///
+/// Append-only files carry the immutable header on line 1 plus a refreshed
+/// `_meta` line per turn (cumulative totals). The **last** `_meta` line wins,
+/// so a multi-turn session reports its running totals — not just the first
+/// turn's. Falls back to line 1 for legacy single-header files.
fn read_transcript_meta_only(path: &Path) -> Option {
let raw = fs::read_to_string(path).ok()?;
+ let mut latest: Option = None;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
- let ml: MetaLine = serde_json::from_str(line).ok()?;
- let mp = ml.meta;
- return Some(TranscriptMeta {
- agent_name: mp.agent,
- agent_id: mp.agent_id,
- agent_type: mp.agent_type,
- dispatcher: mp.dispatcher,
- provider: mp.provider,
- model: mp.model,
- created: mp.created,
- updated: mp.updated,
- turn_count: mp.turn_count,
- input_tokens: mp.input_tokens,
- output_tokens: mp.output_tokens,
- cached_input_tokens: mp.cached_input_tokens,
- charged_amount_usd: mp.charged_amount_usd,
- thread_id: mp.thread_id,
- task_id: mp.task_id,
- });
+ if let Ok(ml) = serde_json::from_str::(line) {
+ latest = Some(meta_from_payload(ml.meta));
+ } else if latest.is_none() {
+ // The first non-empty line must be a valid meta header.
+ return None;
+ }
}
- None
+ latest
}
/// Extract the last assistant message's usage + model from a transcript JSONL.
/// Only the final assistant message of a turn carries these (see the JSONL
-/// format docs at the top of this module).
+/// format docs at the top of this module). Compaction records and refreshed
+/// `_meta` lines are skipped; a `compaction` record's `replacement` assistant
+/// rows are considered so a compacted transcript still surfaces its latest
+/// usage.
fn read_last_assistant_usage(path: &Path) -> Option<(MessageUsage, Option)> {
let raw = fs::read_to_string(path).ok()?;
let mut result = None;
- let mut seen_meta = false;
+ let mut seen_first = false;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
- if !seen_meta {
- seen_meta = true; // first non-empty line is the `_meta` header
+ if !seen_first {
+ seen_first = true; // first non-empty line is the `_meta` header
continue;
}
- if let Ok(ml) = serde_json::from_str::(line) {
- if ml.role == "assistant" {
+ match classify_line(line) {
+ Ok(LineKind::Message(ml)) if ml.role == "assistant" && !ml.interrupted => {
if let Some(usage) = ml.usage {
result = Some((usage, ml.model));
}
}
+ Ok(LineKind::Compaction(cl)) => {
+ for ml in &cl.replacement {
+ if ml.role == "assistant" {
+ if let Some(usage) = ml.usage.clone() {
+ result = Some((usage, ml.model.clone()));
+ }
+ }
+ }
+ }
+ _ => {}
}
}
result
diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs
index 333d1b977..ed649b63c 100644
--- a/src/openhuman/agent/harness/session/transcript_tests.rs
+++ b/src/openhuman/agent/harness/session/transcript_tests.rs
@@ -976,3 +976,367 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() {
assert_eq!(researcher.input_tokens, 500);
assert_eq!(researcher.runs, 1);
}
+
+// ── Phase A: append-only + compaction + display + interrupted ─────────
+
+/// A helper mirroring the in-process persist loop: track the previously
+/// persisted logical set and feed each turn through `append_transcript_turn`.
+struct AppendHarness {
+ path: std::path::PathBuf,
+ prev: Vec,
+}
+
+impl AppendHarness {
+ fn new(path: std::path::PathBuf) -> Self {
+ Self {
+ path,
+ prev: Vec::new(),
+ }
+ }
+
+ fn turn(
+ &mut self,
+ messages: &[ChatMessage],
+ meta: &TranscriptMeta,
+ usage: Option<&TurnUsage>,
+ request_id: Option<&str>,
+ ) {
+ append_transcript_turn(&self.path, &self.prev, messages, meta, usage, request_id)
+ .expect("append turn");
+ self.prev = messages.to_vec();
+ }
+}
+
+fn roles(messages: &[ChatMessage]) -> Vec<&str> {
+ messages.iter().map(|m| m.role.as_str()).collect()
+}
+
+/// Pure extension across turns: the model-context read reflects the final
+/// (growing) message set and never rewrites earlier lines.
+#[test]
+fn append_pure_extension_grows_context() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("append.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+
+ let turn1 = vec![
+ ChatMessage::system("sys"),
+ ChatMessage::user("hi"),
+ ChatMessage::assistant("hello"),
+ ];
+ h.turn(&turn1, &meta, None, None);
+
+ let mut turn2 = turn1.clone();
+ turn2.push(ChatMessage::user("again"));
+ turn2.push(ChatMessage::assistant("hello again"));
+ h.turn(&turn2, &meta, None, None);
+
+ let loaded = read_transcript(&path).unwrap();
+ assert_eq!(
+ roles(&loaded.messages),
+ vec!["system", "user", "assistant", "user", "assistant"]
+ );
+ assert_eq!(loaded.messages[4].content, "hello again");
+}
+
+/// Compaction round-trip: after a reduction, the model-context read returns the
+/// REDUCED context, while the display read returns the FULL pre-compaction
+/// history plus the compaction marker.
+#[test]
+fn compaction_round_trip_model_vs_display() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("compact.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+
+ // Three growing turns.
+ let base = vec![
+ ChatMessage::system("sys"),
+ ChatMessage::user("q1"),
+ ChatMessage::assistant("a1"),
+ ChatMessage::user("q2"),
+ ChatMessage::assistant("a2"),
+ ];
+ h.turn(&base, &meta, None, None);
+
+ // A reduction: the harness drops the earliest exchange and keeps a summary
+ // + the recent tail. This is NOT a prefix of `base`, so it must land as a
+ // compaction record.
+ let reduced = vec![
+ ChatMessage::system("sys"),
+ ChatMessage::assistant("[summary] earlier discussion about q1/q2"),
+ ChatMessage::user("q3"),
+ ChatMessage::assistant("a3"),
+ ];
+ h.turn(&reduced, &meta, None, None);
+
+ // Model-context read == the reduced set only.
+ let model = read_transcript(&path).unwrap();
+ assert_eq!(
+ model
+ .messages
+ .iter()
+ .map(|m| m.content.as_str())
+ .collect::>(),
+ vec![
+ "sys",
+ "[summary] earlier discussion about q1/q2",
+ "q3",
+ "a3"
+ ],
+ "model context must reflect the reduced set after compaction"
+ );
+
+ // Display read == full history: the 5 pre-compaction messages, then a
+ // compaction marker carrying the 4-message replacement.
+ let display = read_transcript_display(&path).unwrap();
+ let pre: Vec<&str> = display
+ .records
+ .iter()
+ .take_while(|r| matches!(r, DisplayRecord::Message(_)))
+ .filter_map(|r| match r {
+ DisplayRecord::Message(m) => Some(m.message.content.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(pre, vec!["sys", "q1", "a1", "q2", "a2"]);
+ let marker = display
+ .records
+ .iter()
+ .find_map(|r| match r {
+ DisplayRecord::Compaction(c) => Some(c),
+ _ => None,
+ })
+ .expect("display must retain the compaction marker");
+ assert_eq!(marker.replacement.len(), 4);
+ assert_eq!(
+ marker.replacement[1].message.content,
+ "[summary] earlier discussion about q1/q2"
+ );
+}
+
+/// After a compaction, a subsequent pure extension appends normally and the
+/// model-context read replays reset-then-extend to the correct final set.
+#[test]
+fn append_after_compaction_extends_reduced_set() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("compact_then_extend.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+
+ h.turn(
+ &[
+ ChatMessage::system("sys"),
+ ChatMessage::user("q1"),
+ ChatMessage::assistant("a1"),
+ ],
+ &meta,
+ None,
+ None,
+ );
+ let reduced = vec![
+ ChatMessage::system("sys"),
+ ChatMessage::assistant("[summary]"),
+ ];
+ h.turn(&reduced, &meta, None, None);
+ let mut extended = reduced.clone();
+ extended.push(ChatMessage::user("q2"));
+ extended.push(ChatMessage::assistant("a2"));
+ h.turn(&extended, &meta, None, None);
+
+ let model = read_transcript(&path).unwrap();
+ assert_eq!(
+ model
+ .messages
+ .iter()
+ .map(|m| m.content.as_str())
+ .collect::>(),
+ vec!["sys", "[summary]", "q2", "a2"]
+ );
+}
+
+/// request_id turn-boundary stamping round-trips into the display projection on
+/// every appended line of a turn.
+#[test]
+fn request_id_stamped_on_every_line() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("reqid.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+
+ let turn1 = vec![ChatMessage::system("sys"), ChatMessage::user("q1")];
+ h.turn(&turn1, &meta, None, Some("req-1"));
+
+ let mut turn2 = turn1.clone();
+ turn2.push(ChatMessage::assistant("a2"));
+ h.turn(&turn2, &meta, None, Some("req-2"));
+
+ let display = read_transcript_display(&path).unwrap();
+ let msgs: Vec<&DisplayMessage> = display
+ .records
+ .iter()
+ .filter_map(|r| match r {
+ DisplayRecord::Message(m) => Some(m),
+ _ => None,
+ })
+ .collect();
+ // turn1 wrote sys + user with req-1; turn2 appended only the assistant tail
+ // with req-2.
+ assert_eq!(msgs[0].request_id.as_deref(), Some("req-1"));
+ assert_eq!(msgs[1].request_id.as_deref(), Some("req-1"));
+ assert_eq!(msgs[2].request_id.as_deref(), Some("req-2"));
+ assert_eq!(msgs[2].message.content, "a2");
+}
+
+/// An interrupted partial is appended to the file, is visible in the display
+/// read flagged `interrupted`, and is SKIPPED by the model-context read (a
+/// resumed context never carries a truncated answer).
+#[test]
+fn interrupted_partial_display_only() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("interrupted.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+ h.turn(
+ &[ChatMessage::system("sys"), ChatMessage::user("q1")],
+ &meta,
+ None,
+ Some("req-1"),
+ );
+
+ append_interrupted_partial(
+ &path,
+ "partial answer that was cut off",
+ Some("req-1"),
+ Some(3),
+ Some("thinking that was cut off"),
+ )
+ .expect("append interrupted");
+
+ // Model context: the partial is skipped.
+ let model = read_transcript(&path).unwrap();
+ assert_eq!(roles(&model.messages), vec!["system", "user"]);
+ assert!(
+ !model.messages.iter().any(|m| m.content.contains("cut off")),
+ "interrupted partial must NOT enter the model context"
+ );
+
+ // Display: the partial is present and flagged.
+ let display = read_transcript_display(&path).unwrap();
+ let partial = display
+ .records
+ .iter()
+ .find_map(|r| match r {
+ DisplayRecord::Message(m) if m.interrupted => Some(m),
+ _ => None,
+ })
+ .expect("display must include the interrupted partial");
+ assert_eq!(partial.message.content, "partial answer that was cut off");
+ assert_eq!(partial.request_id.as_deref(), Some("req-1"));
+ assert_eq!(partial.iteration, Some(3));
+ assert_eq!(
+ partial.reasoning_content.as_deref(),
+ Some("thinking that was cut off"),
+ "interrupted partial must carry its reasoning_content"
+ );
+}
+
+/// Empty partial content is a no-op — no line is written.
+#[test]
+fn interrupted_partial_empty_is_noop() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("empty_interrupt.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+ h.turn(&[ChatMessage::user("q")], &meta, None, None);
+ append_interrupted_partial(&path, "", None, None, None).expect("noop");
+ let display = read_transcript_display(&path).unwrap();
+ assert!(display
+ .records
+ .iter()
+ .all(|r| matches!(r, DisplayRecord::Message(m) if !m.interrupted)));
+}
+
+/// A legacy file — one produced by the full-rewrite `write_transcript` with no
+/// compaction records and no `version` — reads identically under both the
+/// model-context and display readers.
+#[test]
+fn legacy_file_reads_identically() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("legacy.jsonl");
+ let messages = sample_messages();
+ let meta = sample_meta();
+ // Full-rewrite writer == the legacy shape (append-only readers must tolerate
+ // it: zero compaction records, last `_meta` == the only `_meta`).
+ write_transcript(&path, &messages, &meta, None).unwrap();
+
+ let model = read_transcript(&path).unwrap();
+ assert_eq!(model.messages.len(), messages.len());
+ assert_eq!(roles(&model.messages), roles(&messages));
+
+ let display = read_transcript_display(&path).unwrap();
+ let display_roles: Vec<&str> = display
+ .records
+ .iter()
+ .filter_map(|r| match r {
+ DisplayRecord::Message(m) => Some(m.message.role.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(display_roles, roles(&messages));
+}
+
+/// A file carrying an unknown record kind (as a future core might write) is
+/// skipped by the reader rather than crashing it.
+#[test]
+fn unknown_record_kind_is_skipped_not_fatal() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("unknown_kind.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+ h.turn(
+ &[ChatMessage::system("sys"), ChatMessage::user("q1")],
+ &meta,
+ None,
+ None,
+ );
+ // Simulate a future kind by appending a foreign record line.
+ {
+ use std::io::Write;
+ let mut f = std::fs::OpenOptions::new()
+ .append(true)
+ .open(&path)
+ .unwrap();
+ writeln!(f, "{{\"kind\":\"future_thing\",\"payload\":42}}").unwrap();
+ }
+ // Append a normal turn after the unknown line to prove reading continues.
+ let mut msgs = vec![ChatMessage::system("sys"), ChatMessage::user("q1")];
+ msgs.push(ChatMessage::assistant("a1"));
+ h.prev = vec![ChatMessage::system("sys"), ChatMessage::user("q1")];
+ h.turn(&msgs, &meta, None, None);
+
+ let model = read_transcript(&path).unwrap();
+ // The unknown record is skipped; the real messages survive.
+ assert!(model.messages.iter().any(|m| m.content == "a1"));
+ assert!(!model
+ .messages
+ .iter()
+ .any(|m| m.content.contains("future_thing")));
+}
+
+/// The `_meta` version field is stamped by the append writer and absent (0) on
+/// legacy files — but both remain readable.
+#[test]
+fn meta_version_stamped_and_optional() {
+ let dir = TempDir::new().unwrap();
+ let path = dir.path().join("version.jsonl");
+ let meta = sample_meta();
+ let mut h = AppendHarness::new(path.clone());
+ h.turn(&[ChatMessage::user("q")], &meta, None, None);
+ let raw = std::fs::read_to_string(&path).unwrap();
+ assert!(
+ raw.lines().next().unwrap().contains("\"version\":1"),
+ "append writer must stamp the schema version on the meta header"
+ );
+}
diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs
index e7ef2fdfc..5582c1e17 100644
--- a/src/openhuman/agent/harness/session/turn/core.rs
+++ b/src/openhuman/agent/harness/session/turn/core.rs
@@ -332,6 +332,67 @@ fn tool_records_from_conversation(
records
}
+/// Stamp each **failed** tool-result [`ChatMessage`] with its failure outcome
+/// before persistence, so the derived transcript view can render an error tool
+/// row instead of a false success.
+///
+/// The harness folds a tool result into a `role:"tool"` message whose native
+/// content envelope (`{"tool_call_id":…,"content":…}`) has already dropped
+/// `ToolResult::is_error`. The only structured per-call success signal is the
+/// captured [`ToolCallOutcome`] side-channel; correlate by provider call id and
+/// re-attach an additive failure marker (see
+/// `transcript::attach_tool_failure_metadata`). Non-tool messages, tool messages
+/// with no matching outcome, and successful calls are left untouched.
+fn stamp_tool_failures(
+ messages: &mut [ChatMessage],
+ tool_outcomes: &[crate::openhuman::tinyagents::ToolCallOutcome],
+) {
+ use crate::openhuman::agent::harness::session::transcript;
+ if tool_outcomes.is_empty() {
+ return;
+ }
+ for msg in messages.iter_mut() {
+ if msg.role != "tool" {
+ continue;
+ }
+ let Some(call_id) = parse_tool_call_id(&msg.content) else {
+ continue;
+ };
+ let Some(outcome) = tool_outcomes.iter().find(|o| o.call_id == call_id) else {
+ continue;
+ };
+ if outcome.success {
+ continue;
+ }
+ let detail = short_failure_detail(&outcome.content);
+ log::debug!(
+ "[transcript] stamping tool failure call_id={call_id} name={}",
+ outcome.name
+ );
+ transcript::attach_tool_failure_metadata(msg, detail.as_deref());
+ }
+}
+
+/// Extract the `tool_call_id` from a native tool-result content envelope
+/// (`{"tool_call_id":…,"content":…}`). `None` for non-envelope content (XML /
+/// P-Format dispatchers, which don't emit `role:"tool"` messages anyway).
+fn parse_tool_call_id(content: &str) -> Option {
+ let value: serde_json::Value = serde_json::from_str(content).ok()?;
+ value.get("tool_call_id")?.as_str().map(str::to_string)
+}
+
+/// Reduce a tool's error output to a short, single-line reason for display.
+fn short_failure_detail(content: &str) -> Option {
+ const MAX: usize = 160;
+ let line = content.lines().map(str::trim).find(|l| !l.is_empty())?;
+ let short: String = line.chars().take(MAX).collect();
+ if short.is_empty() {
+ None
+ } else {
+ Some(short)
+ }
+}
+
/// Rewrite the **trailing** assistant `Chat` message in `history` to `text`,
/// keeping the persisted transcript and the next turn's KV-cache prefix
/// consistent with a repaired required-output reply (issue #4117). Only the last
@@ -1465,7 +1526,11 @@ impl Agent {
},
);
- let persisted = self.tool_dispatcher.to_provider_messages(&self.history);
+ let mut persisted = self.tool_dispatcher.to_provider_messages(&self.history);
+ // Re-attach per-call failure outcomes (dropped when the engine folded
+ // each tool result into a `role:"tool"` message) so the derived
+ // transcript view renders failed tools as errors, not successes.
+ stamp_tool_failures(&mut persisted, &outcome.tool_outcomes);
// Carry the turn's provider (event channel) + effective model and usage
// into the persisted transcript meta. Passing `None` here dropped
// `provider`/`model` from every transcript (they are `TranscriptMeta`
diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs
index 9549be90f..b058469c2 100644
--- a/src/openhuman/agent/harness/session/turn/session_io.rs
+++ b/src/openhuman/agent/harness/session/turn/session_io.rs
@@ -546,8 +546,25 @@ impl Agent {
task_id: None,
};
- match transcript::write_transcript(path, messages, &meta, turn_usage) {
+ // Append-only write (Phase A, transcript-derived view): diff this turn's
+ // logical messages against the previously-persisted set tracked in
+ // memory. A pure extension appends only the new tail; a context
+ // reduction appends a `compaction` record. The file is never rewritten,
+ // so pre-compaction history survives on disk for the display projection.
+ // `request_id` (web-chat only) stamps a turn boundary on each line.
+ let prev = std::mem::take(&mut self.persisted_transcript_messages);
+ let request_id = crate::openhuman::agent::turn_origin::current_request_id();
+ match transcript::append_transcript_turn(
+ path,
+ &prev,
+ messages,
+ &meta,
+ turn_usage,
+ request_id.as_deref(),
+ ) {
Ok(()) => {
+ // Track the new persisted logical set for the next turn's diff.
+ self.persisted_transcript_messages = messages.to_vec();
// Best-effort, non-fatal dual-write into the TinyAgents store.
// Gated by the default-ON session dual-write flag
// (`OPENHUMAN_SESSION_DUAL_WRITE` is a kill switch). Only runs
@@ -556,8 +573,11 @@ impl Agent {
self.maybe_dual_write_session_store(path, messages, &meta, turn_usage);
}
Err(err) => {
+ // Restore the tracked state so a transient failure doesn't make
+ // the next turn mis-diff (and spuriously emit a compaction).
+ self.persisted_transcript_messages = prev;
log::warn!(
- "[transcript] failed to write transcript {}: {err}",
+ "[transcript] failed to append transcript {}: {err}",
path.display()
);
}
diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs
index 10bac4625..c6a6bd71f 100644
--- a/src/openhuman/agent/harness/session/types.rs
+++ b/src/openhuman/agent/harness/session/types.rs
@@ -122,9 +122,16 @@ pub struct Agent {
/// [`AgentDefinitionRegistry`]: crate::openhuman::agent::harness::definition::AgentDefinitionRegistry
pub(super) agent_definition_id: String,
/// Resolved filesystem path for this session's transcript file.
- /// Set on first write, reused for subsequent overwrites within the
+ /// Set on first write, reused for subsequent **appends** within the
/// same session.
pub(super) session_transcript_path: Option,
+ /// The logical message set most recently persisted to
+ /// `session_transcript_path`, tracked in memory so the append-only writer
+ /// can diff each turn's messages against it (pure extension → append tail;
+ /// reduction → compaction record) without re-reading the growing file.
+ /// Empty until the first persist. Each process writes its own transcript
+ /// file, so this in-memory state is always aligned with the file it owns.
+ pub(super) persisted_transcript_messages: Vec,
/// Unique transcript key for this session, formatted as
/// `"{unix_ts}_{agent_id}"`. Generated once at agent-build time so
/// every transcript write in this session uses the same filename
diff --git a/src/openhuman/agent/turn_origin.rs b/src/openhuman/agent/turn_origin.rs
index ef712b583..29e5e5548 100644
--- a/src/openhuman/agent/turn_origin.rs
+++ b/src/openhuman/agent/turn_origin.rs
@@ -134,6 +134,18 @@ pub fn current() -> Option {
AGENT_TURN_ORIGIN.try_with(|o| o.clone()).ok()
}
+/// Read the ambient web-chat `request_id` for the current turn, when one was
+/// scoped by an [`AgentTurnOrigin::WebChat`] entry point. `None` for every
+/// other origin (channel / cron / CLI / sub-agent) and outside any scope —
+/// those turns are not request-scoped, so their transcript lines carry no
+/// turn-boundary marker.
+pub fn current_request_id() -> Option {
+ match current() {
+ Some(AgentTurnOrigin::WebChat { request_id, .. }) => request_id,
+ _ => None,
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/openhuman/threads/mod.rs b/src/openhuman/threads/mod.rs
index d4ac7d9f6..5f4ed92bf 100644
--- a/src/openhuman/threads/mod.rs
+++ b/src/openhuman/threads/mod.rs
@@ -9,6 +9,7 @@ pub mod ops;
pub mod schemas;
pub mod title;
pub mod tools;
+pub mod transcript_view;
pub mod turn_state;
pub mod welcome_migration;
diff --git a/src/openhuman/threads/ops.rs b/src/openhuman/threads/ops.rs
index 4ed94914a..6ea2052e1 100644
--- a/src/openhuman/threads/ops.rs
+++ b/src/openhuman/threads/ops.rs
@@ -693,6 +693,57 @@ pub struct ThreadTokenUsageRequest {
pub thread_id: String,
}
+/// Request for [`transcript_get`]: the thread to project, plus newest-first
+/// pagination controls. `cursor` is the opaque token from a prior page's
+/// `nextCursor`; `limit` defaults to one screen.
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct TranscriptGetRequest {
+ pub thread_id: String,
+ #[serde(default)]
+ pub cursor: Option,
+ #[serde(default)]
+ pub limit: Option,
+}
+
+/// Project a thread's settled transcript (derived from `session_raw/*.jsonl`)
+/// into typed display items, newest-first paginated. Returns an empty page with
+/// `hasTranscript: false` when the thread has no persisted transcript yet.
+pub async fn transcript_get(
+ request: TranscriptGetRequest,
+) -> Result<
+ RpcOutcome>,
+ String,
+> {
+ let dir = workspace_dir().await?;
+ let thread_id = request.thread_id.trim();
+ if thread_id.is_empty() {
+ return Err("thread_id is required".to_string());
+ }
+ let page = crate::openhuman::threads::transcript_view::get_page(
+ &dir,
+ thread_id,
+ request.cursor.as_deref(),
+ request.limit,
+ );
+ let counts = counts([
+ ("items", page.items.len()),
+ ("total", page.total),
+ ("has_transcript", usize::from(page.has_transcript)),
+ ]);
+ let pagination = Some(PaginationMeta {
+ limit: request
+ .limit
+ .unwrap_or(crate::openhuman::threads::transcript_view::DEFAULT_LIMIT),
+ offset: request
+ .cursor
+ .as_deref()
+ .and_then(|c| c.trim().parse::().ok())
+ .unwrap_or(0),
+ count: page.total,
+ });
+ Ok(envelope(page, Some(counts), pagination))
+}
+
/// Aggregated token/cost usage for one thread, read back from its persisted
/// session transcripts. Seeds the UI footer when the user selects a thread so
/// the totals reflect prior turns instead of starting at zero.
diff --git a/src/openhuman/threads/schemas.rs b/src/openhuman/threads/schemas.rs
index 0a01f927e..f205806f6 100644
--- a/src/openhuman/threads/schemas.rs
+++ b/src/openhuman/threads/schemas.rs
@@ -39,6 +39,7 @@ pub fn all_controller_schemas() -> Vec {
schemas("task_board_get"),
schemas("task_board_put"),
schemas("token_usage"),
+ schemas("transcript_get"),
]
}
@@ -120,6 +121,10 @@ pub fn all_registered_controllers() -> Vec {
schema: schemas("token_usage"),
handler: handle_token_usage,
},
+ RegisteredController {
+ schema: schemas("transcript_get"),
+ handler: handle_transcript_get,
+ },
]
}
@@ -527,6 +532,38 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
+ "transcript_get" => ControllerSchema {
+ namespace: "threads",
+ function: "transcript_get",
+ description:
+ "Project a thread's settled transcript (derived from session_raw/*.jsonl) into typed display items, newest-first paginated.",
+ inputs: vec![
+ FieldSchema {
+ name: "thread_id",
+ ty: TypeSchema::String,
+ comment: "Thread identifier.",
+ required: true,
+ },
+ FieldSchema {
+ name: "cursor",
+ ty: TypeSchema::Option(Box::new(TypeSchema::String)),
+ comment: "Opaque pagination cursor from a prior page's nextCursor; absent starts at the newest item.",
+ required: false,
+ },
+ FieldSchema {
+ name: "limit",
+ ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
+ comment: "Max items to return (default 50, capped at 500).",
+ required: false,
+ },
+ ],
+ outputs: vec![FieldSchema {
+ name: "result",
+ ty: TypeSchema::Json,
+ comment: "Envelope with the newest-first page of display items, total, and nextCursor.",
+ required: true,
+ }],
+ },
_other => ControllerSchema {
namespace: "threads",
function: "unknown",
@@ -745,6 +782,13 @@ fn handle_token_usage(params: Map) -> ControllerFuture {
})
}
+fn handle_transcript_get(params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ let p = parse::(params)?;
+ to_json(ops::transcript_get(p).await?)
+ })
+}
+
// ── Helpers ──────────────────────────────────────────────────────────
fn parse(params: Map) -> Result {
diff --git a/src/openhuman/threads/schemas_tests.rs b/src/openhuman/threads/schemas_tests.rs
index ec9ed44a1..44225137c 100644
--- a/src/openhuman/threads/schemas_tests.rs
+++ b/src/openhuman/threads/schemas_tests.rs
@@ -22,6 +22,7 @@ const ALL_FUNCTIONS: &[&str] = &[
"task_board_get",
"task_board_put",
"token_usage",
+ "transcript_get",
];
#[test]
diff --git a/src/openhuman/threads/transcript_view/cache.rs b/src/openhuman/threads/transcript_view/cache.rs
new file mode 100644
index 000000000..f8b901683
--- /dev/null
+++ b/src/openhuman/threads/transcript_view/cache.rs
@@ -0,0 +1,152 @@
+//! In-memory, mtime-keyed projection cache for the transcript view.
+//!
+//! The settled transcript is derived from `session_raw/*.jsonl` on every
+//! request. Re-projecting a long thread on each page fetch is wasteful, so we
+//! memoize per-thread projections keyed on the backing files' `(path, mtime,
+//! len)` signature. An append (new turn, interrupted partial, sub-agent file)
+//! changes the signature and transparently invalidates the entry — there are
+//! **no disk writes** and no explicit invalidation call. The cache is bounded
+//! (LRU over a few dozen threads) so a long-lived core can't grow it without
+//! limit.
+
+use std::collections::HashMap;
+use std::collections::VecDeque;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::SystemTime;
+
+use super::project;
+use super::types::ProjectedTranscript;
+
+const LOG_PREFIX: &str = "[threads][transcript][cache]";
+
+/// Number of distinct threads whose projections are retained. Beyond this the
+/// least-recently-used entry is evicted.
+const CACHE_CAPACITY: usize = 32;
+
+/// Signature of one backing file — changes on any append/rewrite.
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct FileSig {
+ path: PathBuf,
+ mtime: Option,
+ len: u64,
+}
+
+fn file_sig(path: &Path) -> FileSig {
+ let (mtime, len) = match std::fs::metadata(path) {
+ Ok(meta) => (meta.modified().ok(), meta.len()),
+ Err(_) => (None, 0),
+ };
+ FileSig {
+ path: path.to_path_buf(),
+ mtime,
+ len,
+ }
+}
+
+struct CacheEntry {
+ signature: Vec,
+ projected: Arc,
+}
+
+#[derive(Default)]
+struct CacheInner {
+ entries: HashMap,
+ /// LRU order — front is least-recently-used.
+ order: VecDeque,
+}
+
+/// Bounded per-thread projection cache.
+#[derive(Default)]
+pub struct TranscriptViewCache {
+ inner: Mutex,
+}
+
+impl TranscriptViewCache {
+ /// Project `thread_id`'s transcript, serving a cached result when the
+ /// backing files are unchanged. `None` when the thread has no transcript.
+ pub fn get_or_project(
+ &self,
+ workspace_dir: &Path,
+ thread_id: &str,
+ ) -> Option> {
+ let (root_path, sub_paths) = project::resolve_files(workspace_dir, thread_id)?;
+ let signature: Vec = std::iter::once(file_sig(&root_path))
+ .chain(sub_paths.iter().map(|p| file_sig(p)))
+ .collect();
+
+ {
+ let mut inner = self.inner.lock().ok()?;
+ if let Some(entry) = inner.entries.get(thread_id) {
+ if entry.signature == signature {
+ let projected = entry.projected.clone();
+ touch(&mut inner, thread_id);
+ log::debug!(
+ "{LOG_PREFIX} hit thread={thread_id} items={}",
+ projected.items.len()
+ );
+ return Some(projected);
+ }
+ log::debug!("{LOG_PREFIX} miss (signature changed) thread={thread_id}");
+ } else {
+ log::debug!("{LOG_PREFIX} miss (cold) thread={thread_id}");
+ }
+ }
+
+ let projected = Arc::new(project::project_from_files(
+ thread_id, &root_path, &sub_paths,
+ ));
+
+ let mut inner = self.inner.lock().ok()?;
+ inner.entries.insert(
+ thread_id.to_string(),
+ CacheEntry {
+ signature,
+ projected: projected.clone(),
+ },
+ );
+ touch(&mut inner, thread_id);
+ evict_if_needed(&mut inner);
+ log::debug!(
+ "{LOG_PREFIX} stored thread={thread_id} items={} cache_size={}",
+ projected.items.len(),
+ inner.entries.len()
+ );
+ Some(projected)
+ }
+
+ #[cfg(test)]
+ fn len(&self) -> usize {
+ self.inner.lock().unwrap().entries.len()
+ }
+}
+
+/// Move `thread_id` to the most-recently-used end of the LRU order.
+fn touch(inner: &mut CacheInner, thread_id: &str) {
+ if let Some(pos) = inner.order.iter().position(|t| t == thread_id) {
+ inner.order.remove(pos);
+ }
+ inner.order.push_back(thread_id.to_string());
+}
+
+/// Evict least-recently-used entries until within [`CACHE_CAPACITY`].
+fn evict_if_needed(inner: &mut CacheInner) {
+ while inner.entries.len() > CACHE_CAPACITY {
+ let Some(victim) = inner.order.pop_front() else {
+ break;
+ };
+ inner.entries.remove(&victim);
+ log::debug!("{LOG_PREFIX} evicted thread={victim}");
+ }
+}
+
+/// Process-wide cache singleton. The transcript view is read-only and derived,
+/// so one shared cache across RPC calls is correct.
+pub fn global() -> &'static TranscriptViewCache {
+ static CACHE: OnceLock = OnceLock::new();
+ CACHE.get_or_init(TranscriptViewCache::default)
+}
+
+#[cfg(test)]
+#[path = "cache_tests.rs"]
+mod tests;
diff --git a/src/openhuman/threads/transcript_view/cache_tests.rs b/src/openhuman/threads/transcript_view/cache_tests.rs
new file mode 100644
index 000000000..6422c6f0a
--- /dev/null
+++ b/src/openhuman/threads/transcript_view/cache_tests.rs
@@ -0,0 +1,74 @@
+//! Cache hit/miss + invalidation tests for the transcript view cache.
+
+use super::TranscriptViewCache;
+use crate::openhuman::agent::harness::session::transcript;
+use std::path::Path;
+use tempfile::TempDir;
+
+fn meta_line(thread_id: &str) -> String {
+ format!(
+ r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:00Z","turn_count":1,"input_tokens":0,"output_tokens":0,"cached_input_tokens":0,"charged_amount_usd":0.0,"thread_id":"{thread_id}"}}}}"#
+ )
+}
+
+fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> std::path::PathBuf {
+ let path = transcript::resolve_keyed_transcript_path(workspace, stem).unwrap();
+ let mut buf = meta_line(thread_id);
+ buf.push('\n');
+ for line in body {
+ buf.push_str(line);
+ buf.push('\n');
+ }
+ std::fs::write(&path, buf).unwrap();
+ path
+}
+
+#[test]
+fn recomputes_when_file_grows() {
+ let dir = TempDir::new().unwrap();
+ let path = write_raw(
+ dir.path(),
+ "100_orchestrator",
+ "thr_cache",
+ &[r#"{"role":"user","content":"first"}"#],
+ );
+ let cache = TranscriptViewCache::default();
+
+ let a = cache
+ .get_or_project(dir.path(), "thr_cache")
+ .expect("first");
+ assert_eq!(a.items.len(), 1);
+
+ // Second call, unchanged file → same cached Arc (hit).
+ let b = cache
+ .get_or_project(dir.path(), "thr_cache")
+ .expect("second");
+ assert!(
+ std::sync::Arc::ptr_eq(&a, &b),
+ "unchanged file must serve cached Arc"
+ );
+
+ // Append a line → file length changes → signature invalidates → recompute.
+ let mut appended = std::fs::read_to_string(&path).unwrap();
+ appended.push_str(r#"{"role":"assistant","content":"second"}"#);
+ appended.push('\n');
+ std::fs::write(&path, appended).unwrap();
+
+ let c = cache
+ .get_or_project(dir.path(), "thr_cache")
+ .expect("third");
+ assert!(!std::sync::Arc::ptr_eq(&a, &c), "grown file must recompute");
+ assert_eq!(
+ c.items.len(),
+ 2,
+ "recomputed projection reflects the append"
+ );
+}
+
+#[test]
+fn missing_thread_returns_none() {
+ let dir = TempDir::new().unwrap();
+ let cache = TranscriptViewCache::default();
+ assert!(cache.get_or_project(dir.path(), "ghost").is_none());
+ assert_eq!(cache.len(), 0);
+}
diff --git a/src/openhuman/threads/transcript_view/mod.rs b/src/openhuman/threads/transcript_view/mod.rs
new file mode 100644
index 000000000..d17607644
--- /dev/null
+++ b/src/openhuman/threads/transcript_view/mod.rs
@@ -0,0 +1,105 @@
+//! Transcript-derived view: project the append-only `session_raw/*.jsonl`
+//! source of truth into typed display items for the chat renderer, with
+//! newest-first pagination over a bounded in-memory cache.
+//!
+//! Entry point: [`get_page`] (used by the `threads.transcript_get` RPC).
+
+mod cache;
+mod project;
+pub mod types;
+
+use std::path::Path;
+
+use serde::Serialize;
+
+pub use types::{DisplayItem, ProjectedTranscript, ToolCallStatus};
+
+const LOG_PREFIX: &str = "[threads][transcript]";
+
+/// Default page size — roughly one screen of chat items.
+pub const DEFAULT_LIMIT: usize = 50;
+/// Hard upper bound on a single page so a client can't request an unbounded
+/// projection slice.
+pub const MAX_LIMIT: usize = 500;
+
+/// One newest-first page of a thread's projected transcript.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TranscriptPage {
+ pub thread_id: String,
+ /// Display items for this page, **newest-first**.
+ pub items: Vec,
+ /// Total top-level items available for the thread.
+ pub total: usize,
+ /// Opaque cursor to pass back for the next (older) page; `null` at the end.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub next_cursor: Option,
+ /// `true` when more (older) items remain beyond this page.
+ pub has_more: bool,
+ /// `false` when the thread has no persisted transcript yet (empty page).
+ pub has_transcript: bool,
+}
+
+/// Project `thread_id`'s transcript and return one newest-first page.
+///
+/// `cursor` is the opaque token returned as `next_cursor` by a previous call
+/// (an offset from the newest item); `None`/empty starts at the newest item.
+/// `limit` defaults to [`DEFAULT_LIMIT`] and is clamped to [`MAX_LIMIT`].
+pub fn get_page(
+ workspace_dir: &Path,
+ thread_id: &str,
+ cursor: Option<&str>,
+ limit: Option,
+) -> TranscriptPage {
+ let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
+ let offset = parse_cursor(cursor);
+
+ let Some(projected) = cache::global().get_or_project(workspace_dir, thread_id) else {
+ log::debug!("{LOG_PREFIX} get_page thread={thread_id}: no transcript");
+ return TranscriptPage {
+ thread_id: thread_id.to_string(),
+ items: Vec::new(),
+ total: 0,
+ next_cursor: None,
+ has_more: false,
+ has_transcript: false,
+ };
+ };
+
+ let total = projected.items.len();
+ let start = offset.min(total);
+ let end = (offset + limit).min(total);
+ // Newest-first: item `offset` is the newest, walking backwards from the end.
+ let items: Vec = (start..end)
+ .map(|i| projected.items[total - 1 - i].clone())
+ .collect();
+ let has_more = end < total;
+ let next_cursor = has_more.then(|| end.to_string());
+
+ log::debug!(
+ "{LOG_PREFIX} get_page thread={thread_id} total={total} offset={offset} returned={} has_more={has_more}",
+ items.len()
+ );
+
+ TranscriptPage {
+ thread_id: thread_id.to_string(),
+ items,
+ total,
+ next_cursor,
+ has_more,
+ has_transcript: true,
+ }
+}
+
+/// Parse the opaque cursor into a numeric offset (0 on absent/invalid).
+fn parse_cursor(cursor: Option<&str>) -> usize {
+ cursor
+ .map(str::trim)
+ .filter(|c| !c.is_empty())
+ .and_then(|c| c.parse::().ok())
+ .unwrap_or(0)
+}
+
+#[cfg(test)]
+#[path = "tests.rs"]
+mod tests;
diff --git a/src/openhuman/threads/transcript_view/project.rs b/src/openhuman/threads/transcript_view/project.rs
new file mode 100644
index 000000000..328e56102
--- /dev/null
+++ b/src/openhuman/threads/transcript_view/project.rs
@@ -0,0 +1,499 @@
+//! Project raw session-transcript records into typed display items.
+//!
+//! Turns the append-only log's [`DisplayRecord`]s (message lines, compaction
+//! markers, interrupted partials) into the frontend's chat vocabulary
+//! ([`DisplayItem`]), sanitizing injected scaffolding as it goes. Sub-agent
+//! sibling files are discovered and nested one level deep.
+
+use std::collections::VecDeque;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use crate::openhuman::agent::harness::session::transcript::{
+ self, CompactionMarker, DisplayMessage, DisplayRecord,
+};
+
+use super::types::{DisplayItem, ProjectedTranscript, ToolCallFailure, ToolCallStatus};
+
+const LOG_PREFIX: &str = "[threads][transcript]";
+
+/// Max sub-agent nesting depth the projection descends. The plan calls for
+/// one level of recursion; we allow a small bound so a delegated worker that
+/// itself delegates still surfaces, without unbounded fan-out.
+const MAX_SUBAGENT_DEPTH: usize = 3;
+
+/// The scaffolding line injected onto every user message (see
+/// `agent::prompts::current_datetime_line`). Stripped at projection so the UI
+/// shows the user's actual words, not the per-turn time stamp.
+const DATETIME_PREFIX: &str = "Current Date & Time:";
+
+/// A legacy/alternate channel-context prefix. Kept for defensiveness; the
+/// live injector currently only prepends [`DATETIME_PREFIX`].
+const CHANNEL_CONTEXT_PREFIX: &str = "[Channel context]";
+
+/// Resolve a thread's root transcript, discover its sub-agent siblings, and
+/// project everything into display items. Returns `None` when the thread has
+/// no root transcript yet (brand-new thread / first turn not persisted).
+pub fn project_thread(workspace_dir: &Path, thread_id: &str) -> Option {
+ let (root_path, sub_paths) = resolve_files(workspace_dir, thread_id)?;
+ Some(project_from_files(thread_id, &root_path, &sub_paths))
+}
+
+/// Resolve the on-disk file set backing a thread's transcript view: the root
+/// transcript path plus every sub-agent sibling file. `None` when the thread
+/// has no root transcript yet. Exposed so the cache can key on these paths
+/// (and their mtimes/lengths) without re-projecting.
+pub fn resolve_files(workspace_dir: &Path, thread_id: &str) -> Option<(PathBuf, Vec)> {
+ let root_path = transcript::find_root_transcript_for_thread(workspace_dir, thread_id)?;
+ let root_stem = root_path.file_stem()?.to_str()?.to_string();
+ let sub_paths = discover_subagent_files(workspace_dir, &root_stem);
+ Some((root_path, sub_paths))
+}
+
+/// Project a thread from an already-resolved file set (root + sub-agent
+/// siblings). Missing/unreadable files degrade to empty rather than failing.
+pub fn project_from_files(
+ thread_id: &str,
+ root_path: &Path,
+ sub_paths: &[PathBuf],
+) -> ProjectedTranscript {
+ let root_stem = root_path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or_default()
+ .to_string();
+
+ log::debug!(
+ "{LOG_PREFIX} projecting thread={thread_id} root={} subagent_files={}",
+ root_path.display(),
+ sub_paths.len()
+ );
+
+ // Read the root display records once: they feed both the top-level items
+ // and the per-turn timestamp ranges used to anchor sub-agent trails.
+ let (mut items, segments) = match transcript::read_transcript_display(root_path) {
+ Ok(d) => (project_records(&d.records), turn_segments(&d.records)),
+ Err(err) => {
+ log::warn!(
+ "{LOG_PREFIX} failed to read root transcript {}: {err}",
+ root_path.display()
+ );
+ (Vec::new(), Vec::new())
+ }
+ };
+
+ let subagents = build_subagent_items(sub_paths, &root_stem, 0, &segments);
+ log::debug!(
+ "{LOG_PREFIX} projected thread={thread_id} top_level_items={} subagents={}",
+ items.len(),
+ subagents.len()
+ );
+ items.extend(subagents);
+
+ ProjectedTranscript {
+ thread_id: thread_id.to_string(),
+ items,
+ }
+}
+
+/// Discover every sub-agent transcript file for `root_stem` under
+/// `session_raw/`. Sub-agent stems are `{root_stem}__…`; results are sorted so
+/// the timestamp-prefixed suffixes order by creation time.
+fn discover_subagent_files(workspace_dir: &Path, root_stem: &str) -> Vec {
+ let raw_dir = workspace_dir.join("session_raw");
+ let prefix = format!("{root_stem}__");
+ let Ok(entries) = fs::read_dir(&raw_dir) else {
+ return Vec::new();
+ };
+ let mut paths: Vec = entries
+ .flatten()
+ .map(|e| e.path())
+ .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("jsonl"))
+ .filter(|p| {
+ p.file_stem()
+ .and_then(|s| s.to_str())
+ .is_some_and(|stem| stem.starts_with(&prefix))
+ })
+ .collect();
+ paths.sort();
+ paths
+}
+
+/// Build nested [`DisplayItem::Subagent`] items for the direct children of
+/// `parent_stem` among `all_sub_paths`, recursing one level per depth up to
+/// [`MAX_SUBAGENT_DEPTH`]. Attachment is flat (ordered by file timestamp): the
+/// transcript doesn't record a robust delegation-call → file link, so we nest
+/// by stem lineage rather than guessing the parent tool call.
+///
+/// Each item is anchored to a parent turn via [`anchor_request_id`] so the
+/// frontend can render the trail under the turn that spawned it rather than the
+/// most recent turn. `segments` are the root turns' start timestamps.
+fn build_subagent_items(
+ all_sub_paths: &[PathBuf],
+ parent_stem: &str,
+ depth: usize,
+ segments: &[(String, i64)],
+) -> Vec {
+ if depth >= MAX_SUBAGENT_DEPTH {
+ return Vec::new();
+ }
+ let child_prefix = format!("{parent_stem}__");
+ let mut out = Vec::new();
+ for path in all_sub_paths {
+ let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
+ continue;
+ };
+ let Some(rest) = stem.strip_prefix(&child_prefix) else {
+ continue;
+ };
+ // Direct child only — no further `__` in the remainder.
+ if rest.contains("__") {
+ continue;
+ }
+ let display = match transcript::read_transcript_display(path) {
+ Ok(d) => d,
+ Err(err) => {
+ log::warn!(
+ "{LOG_PREFIX} failed to read sub-agent transcript {}: {err}",
+ path.display()
+ );
+ continue;
+ }
+ };
+ let mut items = project_records(&display.records);
+ items.extend(build_subagent_items(
+ all_sub_paths,
+ stem,
+ depth + 1,
+ segments,
+ ));
+ // Prefer the archetype id from meta; fall back to the stem suffix.
+ let id = if display.meta.agent_name.is_empty() {
+ rest.to_string()
+ } else {
+ display.meta.agent_name.clone()
+ };
+ // Anchor to the parent turn active at the sub-agent's spawn time.
+ let request_id = anchor_request_id(child_spawn_unix(rest), segments);
+ log::debug!("{LOG_PREFIX} subagent id={id} stem={rest} anchored request_id={request_id:?}");
+ out.push(DisplayItem::Subagent {
+ id,
+ request_id,
+ items,
+ });
+ }
+ out
+}
+
+/// The root turns' start timestamps as `(request_id, unix_seconds)` in file
+/// order — one entry per turn boundary. Built from the first timestamped line
+/// of each `request_id` run. Turns whose lines carry no `request_id` or no
+/// parseable timestamp contribute nothing (legacy/CLI transcripts yield an
+/// empty list, so sub-agents there stay unanchored).
+fn turn_segments(records: &[DisplayRecord]) -> Vec<(String, i64)> {
+ let mut segments: Vec<(String, i64)> = Vec::new();
+ let mut last_request_id: Option = None;
+ for record in records {
+ let DisplayRecord::Message(msg) = record else {
+ continue;
+ };
+ let (Some(rid), Some(ts)) = (msg.request_id.as_deref(), msg.ts.as_deref()) else {
+ continue;
+ };
+ if last_request_id.as_deref() == Some(rid) {
+ continue;
+ }
+ let Some(unix) = parse_rfc3339_unix(ts) else {
+ continue;
+ };
+ segments.push((rid.to_string(), unix));
+ last_request_id = Some(rid.to_string());
+ }
+ segments
+}
+
+/// Extract a sub-agent's spawn unix timestamp (seconds) from its file-stem
+/// suffix. Stems are `{unix_ts}_{agent_id}`; the leading integer is the agent
+/// build/spawn time (see the transcript module's stem docs). `None` for
+/// non-numeric legacy stems.
+fn child_spawn_unix(stem_suffix: &str) -> Option {
+ stem_suffix
+ .split('_')
+ .next()
+ .and_then(|s| s.parse::().ok())
+}
+
+/// Parse an RFC-3339 timestamp into unix seconds.
+fn parse_rfc3339_unix(ts: &str) -> Option {
+ chrono::DateTime::parse_from_rfc3339(ts)
+ .ok()
+ .map(|dt| dt.timestamp())
+}
+
+/// Anchor a sub-agent to the parent turn that was active at its `child_unix`
+/// spawn time: the last turn segment whose start is `<= child_unix`.
+///
+/// Fallbacks (documented heuristic, since sub-agent files carry no explicit
+/// delegation-call back-link):
+/// - No segments (legacy/CLI root): `None` — the item stays unanchored and the
+/// frontend leaves it under the current turn cursor, as before.
+/// - Unknown spawn time (non-numeric stem): the newest turn (best effort).
+/// - Spawn time precedes every turn start: the first turn.
+fn anchor_request_id(child_unix: Option, segments: &[(String, i64)]) -> Option {
+ if segments.is_empty() {
+ return None;
+ }
+ let Some(child_unix) = child_unix else {
+ return segments.last().map(|(rid, _)| rid.clone());
+ };
+ let mut chosen = &segments[0];
+ for seg in segments {
+ if seg.1 <= child_unix {
+ chosen = seg;
+ }
+ }
+ Some(chosen.0.clone())
+}
+
+/// Project one file's display records into display items, in file order.
+///
+/// - System lines are dropped (they carry the tool-policy preamble and other
+/// scaffolding that must never render as a chat item).
+/// - `reasoning_content` on an assistant line becomes a [`DisplayItem::Reasoning`]
+/// preceding its message.
+/// - Assistant `tool_calls` register pending [`DisplayItem::ToolCall`]s; a later
+/// `role:"tool"` line pairs to one by id, falling back to FIFO order.
+/// - Interrupted partials and compaction markers pass through as their items.
+/// - A [`DisplayItem::TurnBoundary`] is emitted whenever `request_id` changes.
+pub fn project_records(records: &[DisplayRecord]) -> Vec {
+ let mut items: Vec = Vec::new();
+ // Pending tool calls awaiting a result line: (call_id, index into `items`).
+ let mut pending: VecDeque<(String, usize)> = VecDeque::new();
+ let mut last_request_id: Option = None;
+
+ for record in records {
+ match record {
+ DisplayRecord::Message(msg) => {
+ maybe_emit_turn_boundary(msg, &mut last_request_id, &mut items);
+ project_message(msg, &mut items, &mut pending);
+ }
+ DisplayRecord::Compaction(marker) => {
+ project_compaction(marker, &mut items);
+ // A compaction supersedes prior context; drop stale pending
+ // pairings so a post-compaction result never binds to them.
+ pending.clear();
+ }
+ }
+ }
+ items
+}
+
+fn maybe_emit_turn_boundary(
+ msg: &DisplayMessage,
+ last_request_id: &mut Option,
+ items: &mut Vec,
+) {
+ let Some(rid) = msg.request_id.as_deref() else {
+ return;
+ };
+ if last_request_id.as_deref() != Some(rid) {
+ items.push(DisplayItem::TurnBoundary {
+ request_id: rid.to_string(),
+ });
+ *last_request_id = Some(rid.to_string());
+ }
+}
+
+fn project_message(
+ msg: &DisplayMessage,
+ items: &mut Vec,
+ pending: &mut VecDeque<(String, usize)>,
+) {
+ // Interrupted partial: display-only, carries its own thinking.
+ if msg.interrupted {
+ items.push(DisplayItem::InterruptedPartial {
+ text: msg.message.content.clone(),
+ thinking: msg.reasoning_content.clone(),
+ });
+ return;
+ }
+
+ match msg.message.role.as_str() {
+ "system" => {
+ // Scaffolding (tool-policy preamble, etc.) — never a display item.
+ log::debug!("{LOG_PREFIX} sanitize: dropped system line from projection");
+ }
+ "user" => {
+ let raw = msg.message.content.clone();
+ let sanitized = sanitize_user_content(&raw);
+ if sanitized.is_some() {
+ log::debug!("{LOG_PREFIX} sanitize: stripped injected prefix from user message");
+ }
+ items.push(DisplayItem::UserMessage {
+ content: raw,
+ display_content: sanitized,
+ request_id: msg.request_id.clone(),
+ });
+ }
+ "assistant" => project_assistant(msg, items, pending),
+ "tool" => project_tool_result(msg, items, pending),
+ other => {
+ log::debug!("{LOG_PREFIX} projecting unknown role {other:?} as assistant message");
+ items.push(DisplayItem::AssistantMessage {
+ content: msg.message.content.clone(),
+ interim: false,
+ request_id: msg.request_id.clone(),
+ model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()),
+ iteration: msg.iteration,
+ });
+ }
+ }
+}
+
+fn project_assistant(
+ msg: &DisplayMessage,
+ items: &mut Vec,
+ pending: &mut VecDeque<(String, usize)>,
+) {
+ // Reasoning precedes the message it belongs to.
+ if let Some(reasoning) = msg.reasoning_content.as_deref() {
+ if !reasoning.trim().is_empty() {
+ items.push(DisplayItem::Reasoning {
+ text: reasoning.to_string(),
+ });
+ }
+ }
+
+ let tool_calls = msg
+ .turn_usage
+ .as_ref()
+ .map(|tu| tu.tool_calls.as_slice())
+ .unwrap_or_default();
+ let interim = !tool_calls.is_empty();
+
+ // The assistant's prose (if any) shows before its tool calls.
+ if !msg.message.content.trim().is_empty() {
+ items.push(DisplayItem::AssistantMessage {
+ content: msg.message.content.clone(),
+ interim,
+ request_id: msg.request_id.clone(),
+ model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()),
+ iteration: msg.iteration,
+ });
+ }
+
+ for call in tool_calls {
+ let args = parse_tool_args(&call.arguments);
+ items.push(DisplayItem::ToolCall {
+ call_id: call.id.clone(),
+ name: call.name.clone(),
+ args,
+ result: None,
+ status: ToolCallStatus::Running,
+ failure: None,
+ });
+ pending.push_back((call.id.clone(), items.len() - 1));
+ }
+}
+
+fn project_tool_result(
+ msg: &DisplayMessage,
+ items: &mut Vec,
+ pending: &mut VecDeque<(String, usize)>,
+) {
+ let result = msg.message.content.clone();
+ // A failed tool line (`ToolResult::is_error`, stamped at persistence) pairs
+ // to an error row with a failure payload instead of a false success.
+ let (status, failure) = if msg.failure {
+ (
+ ToolCallStatus::Error,
+ Some(ToolCallFailure {
+ detail: msg.failure_detail.clone(),
+ }),
+ )
+ } else {
+ (ToolCallStatus::Success, None)
+ };
+ // Pair by explicit call id first, else FIFO.
+ let idx = msg
+ .message
+ .id
+ .as_deref()
+ .and_then(|id| take_pending_by_id(pending, id))
+ .or_else(|| pending.pop_front().map(|(_, idx)| idx));
+
+ if let Some(idx) = idx {
+ if let Some(DisplayItem::ToolCall {
+ result: slot,
+ status: status_slot,
+ failure: failure_slot,
+ ..
+ }) = items.get_mut(idx)
+ {
+ *slot = Some(result);
+ *status_slot = status;
+ *failure_slot = failure;
+ return;
+ }
+ }
+
+ // Orphan result (no matching assistant tool_call recorded) — surface it as
+ // a best-effort completed tool row so the output is not lost.
+ log::debug!("{LOG_PREFIX} tool result with no pending call — emitting orphan tool row");
+ items.push(DisplayItem::ToolCall {
+ call_id: msg.message.id.clone().unwrap_or_default(),
+ name: "tool".to_string(),
+ args: None,
+ result: Some(result),
+ status,
+ failure,
+ });
+}
+
+/// Remove and return the pending entry whose call id matches `id`, if any.
+fn take_pending_by_id(pending: &mut VecDeque<(String, usize)>, id: &str) -> Option {
+ let pos = pending.iter().position(|(cid, _)| cid == id)?;
+ pending.remove(pos).map(|(_, idx)| idx)
+}
+
+fn project_compaction(marker: &CompactionMarker, items: &mut Vec) {
+ items.push(DisplayItem::Compaction {
+ replaced_count: 0,
+ kept_count: marker.replacement.len(),
+ ts: marker.ts.clone(),
+ request_id: marker.request_id.clone(),
+ });
+}
+
+/// Parse a tool call's raw argument string into JSON when possible; a
+/// non-JSON string is wrapped so the frontend still receives structured args.
+fn parse_tool_args(raw: &str) -> Option {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ match serde_json::from_str::(trimmed) {
+ Ok(value) => Some(value),
+ Err(_) => Some(serde_json::Value::String(trimmed.to_string())),
+ }
+}
+
+/// Strip the injected scaffolding prefix from a user message, returning the
+/// sanitized body only when a prefix was actually present (so the caller can
+/// tag rather than mutate — the raw `content` is preserved alongside).
+fn sanitize_user_content(content: &str) -> Option {
+ let trimmed_start = content.trim_start();
+ if trimmed_start.starts_with(DATETIME_PREFIX)
+ || trimmed_start.starts_with(CHANNEL_CONTEXT_PREFIX)
+ {
+ // The injector prepends the scaffolding line followed by a blank line,
+ // then the user's actual text. Strip the first paragraph.
+ if let Some(idx) = content.find("\n\n") {
+ let body = content[idx + 2..].to_string();
+ return Some(body);
+ }
+ // No body after the prefix — the whole message was scaffolding.
+ return Some(String::new());
+ }
+ None
+}
diff --git a/src/openhuman/threads/transcript_view/tests.rs b/src/openhuman/threads/transcript_view/tests.rs
new file mode 100644
index 000000000..4eed3a126
--- /dev/null
+++ b/src/openhuman/threads/transcript_view/tests.rs
@@ -0,0 +1,435 @@
+//! Projection + pagination + sanitization tests for the transcript view.
+
+use super::project::{project_records, project_thread};
+use super::types::{DisplayItem, ToolCallStatus};
+use super::{get_page, DEFAULT_LIMIT};
+use crate::openhuman::agent::harness::session::transcript::{self, read_transcript_display};
+use crate::openhuman::inference::provider::ChatMessage;
+use std::path::{Path, PathBuf};
+use tempfile::TempDir;
+
+fn meta_line(thread_id: &str) -> String {
+ format!(
+ r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:10Z","turn_count":1,"input_tokens":30,"output_tokens":13,"cached_input_tokens":0,"charged_amount_usd":0.003,"thread_id":"{thread_id}"}}}}"#
+ )
+}
+
+/// Write a raw JSONL transcript (meta header + given body lines) into
+/// `session_raw/{stem}.jsonl` and return the path.
+fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> PathBuf {
+ let path = transcript::resolve_keyed_transcript_path(workspace, stem).expect("resolve");
+ let mut buf = meta_line(thread_id);
+ buf.push('\n');
+ for line in body {
+ buf.push_str(line);
+ buf.push('\n');
+ }
+ std::fs::write(&path, buf).expect("write raw transcript");
+ path
+}
+
+/// A full turn: system scaffolding, a user prompt with the injected datetime
+/// prefix, an assistant tool-calling step (reasoning + tool_calls), a tool
+/// result, then the final assistant answer.
+fn full_turn_body() -> Vec<&'static str> {
+ vec![
+ r#"{"role":"system","content":"[tool-policy preamble] you may use tools ..."}"#,
+ r#"{"role":"user","content":"Current Date & Time: 2026-07-21 09:00:00 UTC\n\nWhat's the weather in NYC?","request_id":"req-1"}"#,
+ r#"{"role":"assistant","content":"Let me check.","provider":"anthropic","model":"claude-x","usage":{"input":10,"output":5,"cached_input":0,"cost_usd":0.001},"ts":"2026-07-21T09:00:01Z","reasoning_content":"I should call the weather tool.","tool_calls":[{"id":"call-1","name":"get_weather","arguments":"{\"city\":\"NYC\"}"}],"iteration":1,"request_id":"req-1"}"#,
+ r#"{"role":"tool","content":"72F and sunny","id":"call-1","request_id":"req-1"}"#,
+ r#"{"role":"assistant","content":"It's 72F and sunny in NYC.","provider":"anthropic","model":"claude-x","usage":{"input":20,"output":8,"cached_input":0,"cost_usd":0.002},"ts":"2026-07-21T09:00:02Z","iteration":2,"request_id":"req-1"}"#,
+ ]
+}
+
+#[test]
+fn projects_turn_with_tools_reasoning_and_sanitization() {
+ let dir = TempDir::new().unwrap();
+ let path = write_raw(dir.path(), "100_orchestrator", "thr_w", &full_turn_body());
+ let display = read_transcript_display(&path).unwrap();
+ let items = project_records(&display.records);
+
+ // Expected order: turnBoundary, userMessage, reasoning, assistant(interim),
+ // toolCall(paired), assistant(final). System line dropped.
+ assert_eq!(items.len(), 6, "unexpected items: {items:#?}");
+
+ match &items[0] {
+ DisplayItem::TurnBoundary { request_id } => assert_eq!(request_id, "req-1"),
+ other => panic!("expected turnBoundary, got {other:?}"),
+ }
+ match &items[1] {
+ DisplayItem::UserMessage {
+ content,
+ display_content,
+ request_id,
+ } => {
+ assert!(content.starts_with("Current Date & Time:"), "raw kept");
+ assert_eq!(
+ display_content.as_deref(),
+ Some("What's the weather in NYC?"),
+ "datetime prefix stripped into displayContent"
+ );
+ assert_eq!(request_id.as_deref(), Some("req-1"));
+ }
+ other => panic!("expected userMessage, got {other:?}"),
+ }
+ match &items[2] {
+ DisplayItem::Reasoning { text } => assert_eq!(text, "I should call the weather tool."),
+ other => panic!("expected reasoning, got {other:?}"),
+ }
+ match &items[3] {
+ DisplayItem::AssistantMessage {
+ content, interim, ..
+ } => {
+ assert_eq!(content, "Let me check.");
+ assert!(*interim, "tool-calling assistant step is interim");
+ }
+ other => panic!("expected interim assistantMessage, got {other:?}"),
+ }
+ match &items[4] {
+ DisplayItem::ToolCall {
+ call_id,
+ name,
+ args,
+ result,
+ status,
+ failure,
+ } => {
+ assert_eq!(call_id, "call-1");
+ assert_eq!(name, "get_weather");
+ assert_eq!(
+ args.as_ref()
+ .and_then(|v| v.get("city"))
+ .and_then(|v| v.as_str()),
+ Some("NYC")
+ );
+ assert_eq!(result.as_deref(), Some("72F and sunny"), "paired by id");
+ assert_eq!(*status, ToolCallStatus::Success);
+ assert!(failure.is_none(), "successful tool carries no failure");
+ }
+ other => panic!("expected toolCall, got {other:?}"),
+ }
+ match &items[5] {
+ DisplayItem::AssistantMessage {
+ content, interim, ..
+ } => {
+ assert_eq!(content, "It's 72F and sunny in NYC.");
+ assert!(!*interim, "final answer is not interim");
+ }
+ other => panic!("expected final assistantMessage, got {other:?}"),
+ }
+}
+
+#[test]
+fn projects_compaction_and_interrupted_partial() {
+ let dir = TempDir::new().unwrap();
+ let body = vec![
+ r#"{"role":"user","content":"hi","request_id":"req-1"}"#,
+ r#"{"kind":"compaction","replacement":[{"role":"user","content":"summary so far"}],"ts":"2026-07-21T09:05:00Z","request_id":"req-2"}"#,
+ r#"{"role":"assistant","content":"partial ans","interrupted":true,"reasoning_content":"mid-thought","iteration":3,"request_id":"req-2"}"#,
+ ];
+ let path = write_raw(dir.path(), "200_orchestrator", "thr_c", &body);
+ let display = read_transcript_display(&path).unwrap();
+ let items = project_records(&display.records);
+
+ let has_compaction = items.iter().any(|i| {
+ matches!(
+ i,
+ DisplayItem::Compaction { kept_count, .. } if *kept_count == 1
+ )
+ });
+ assert!(has_compaction, "compaction projected: {items:#?}");
+
+ let partial = items
+ .iter()
+ .find_map(|i| match i {
+ DisplayItem::InterruptedPartial { text, thinking } => Some((text, thinking)),
+ _ => None,
+ })
+ .expect("interrupted partial projected");
+ assert_eq!(partial.0, "partial ans");
+ assert_eq!(partial.1.as_deref(), Some("mid-thought"));
+}
+
+#[test]
+fn legacy_file_without_version_or_request_id_projects() {
+ let dir = TempDir::new().unwrap();
+ // Legacy meta: no `version`, messages carry no `request_id`.
+ let path = transcript::resolve_keyed_transcript_path(dir.path(), "300_orchestrator").unwrap();
+ let raw = concat!(
+ r#"{"_meta":{"agent":"orchestrator","dispatcher":"native","created":"2026-01-01T00:00:00Z","updated":"2026-01-01T00:00:00Z","turn_count":1,"input_tokens":0,"output_tokens":0,"cached_input_tokens":0,"charged_amount_usd":0.0,"thread_id":"thr_legacy"}}"#,
+ "\n",
+ r#"{"role":"user","content":"plain question"}"#,
+ "\n",
+ r#"{"role":"assistant","content":"plain answer"}"#,
+ "\n",
+ );
+ std::fs::write(&path, raw).unwrap();
+ let display = read_transcript_display(&path).unwrap();
+ let items = project_records(&display.records);
+
+ // No request_id → no turn boundary; user + assistant still project, and an
+ // un-prefixed user message keeps no displayContent (nothing to strip).
+ assert!(
+ !items
+ .iter()
+ .any(|i| matches!(i, DisplayItem::TurnBoundary { .. })),
+ "legacy lines have no request_id, so no boundary"
+ );
+ match items.first() {
+ Some(DisplayItem::UserMessage {
+ content,
+ display_content,
+ ..
+ }) => {
+ assert_eq!(content, "plain question");
+ assert_eq!(
+ display_content.as_deref(),
+ None,
+ "no prefix, no displayContent"
+ );
+ }
+ other => panic!("expected userMessage first, got {other:?}"),
+ }
+ assert!(items.iter().any(
+ |i| matches!(i, DisplayItem::AssistantMessage { content, .. } if content == "plain answer")
+ ));
+}
+
+#[test]
+fn subagent_file_projects_as_nested_item() {
+ let dir = TempDir::new().unwrap();
+ let root_stem = "400_orchestrator";
+ write_raw(
+ dir.path(),
+ root_stem,
+ "thr_s",
+ &[r#"{"role":"user","content":"delegate please","request_id":"req-1"}"#],
+ );
+ // Sub-agent sibling shares the root stem with a `__` suffix.
+ write_raw(
+ dir.path(),
+ &format!("{root_stem}__100_coder"),
+ "thr_s",
+ &[
+ r#"{"role":"assistant","content":"sub work done","provider":"anthropic","model":"claude-x","usage":{"input":5,"output":3,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:10:00Z","iteration":1}"#,
+ ],
+ );
+
+ let projected = project_thread(dir.path(), "thr_s").expect("project thread");
+ let subagent = projected
+ .items
+ .iter()
+ .find_map(|i| match i {
+ DisplayItem::Subagent { id, items, .. } => Some((id, items)),
+ _ => None,
+ })
+ .expect("subagent item present");
+ assert_eq!(subagent.0, "orchestrator");
+ assert!(subagent.1.iter().any(
+ |i| matches!(i, DisplayItem::AssistantMessage { content, .. } if content == "sub work done")
+ ));
+}
+
+#[test]
+fn get_page_paginates_newest_first_with_cursor() {
+ let dir = TempDir::new().unwrap();
+ // Five plain user messages → five top-level items.
+ let body: Vec = (0..5)
+ .map(|i| format!(r#"{{"role":"user","content":"msg-{i}"}}"#))
+ .collect();
+ let body_refs: Vec<&str> = body.iter().map(String::as_str).collect();
+ write_raw(dir.path(), "500_orchestrator", "thr_p", &body_refs);
+
+ // First page: newest first, limit 2 → msg-4, msg-3.
+ let page1 = get_page(dir.path(), "thr_p", None, Some(2));
+ assert_eq!(page1.total, 5);
+ assert!(page1.has_more);
+ assert_eq!(page1.items.len(), 2);
+ assert!(
+ matches!(&page1.items[0], DisplayItem::UserMessage { content, .. } if content == "msg-4")
+ );
+ assert!(
+ matches!(&page1.items[1], DisplayItem::UserMessage { content, .. } if content == "msg-3")
+ );
+
+ let cursor = page1.next_cursor.clone().expect("next cursor");
+ let page2 = get_page(dir.path(), "thr_p", Some(&cursor), Some(2));
+ assert!(
+ matches!(&page2.items[0], DisplayItem::UserMessage { content, .. } if content == "msg-2")
+ );
+
+ // Walk to the end.
+ let last = get_page(dir.path(), "thr_p", page2.next_cursor.as_deref(), Some(2));
+ assert!(!last.has_more, "final page exhausts the thread");
+ assert!(last.next_cursor.is_none());
+}
+
+#[test]
+fn failed_tool_line_projects_error_status_with_failure_payload() {
+ let dir = TempDir::new().unwrap();
+ // Assistant issues a tool call; the paired tool result line carries the
+ // additive `failure` flag (stamped at persistence from `is_error`).
+ let body = vec![
+ r#"{"role":"assistant","content":"trying","provider":"anthropic","model":"m","usage":{"input":1,"output":1,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:00:01Z","tool_calls":[{"id":"call-9","name":"shell","arguments":"{\"cmd\":\"boom\"}"}],"iteration":1,"request_id":"req-1"}"#,
+ r#"{"role":"tool","content":"error: command not found","id":"call-9","request_id":"req-1","failure":true,"failure_detail":"error: command not found"}"#,
+ ];
+ let path = write_raw(dir.path(), "600_orchestrator", "thr_f", &body);
+ let display = read_transcript_display(&path).unwrap();
+ let items = project_records(&display.records);
+
+ let tool = items
+ .iter()
+ .find_map(|i| match i {
+ DisplayItem::ToolCall {
+ status, failure, ..
+ } => Some((status, failure)),
+ _ => None,
+ })
+ .expect("toolCall projected");
+ assert_eq!(*tool.0, ToolCallStatus::Error, "failed tool → error status");
+ let failure = tool.1.as_ref().expect("failure payload present");
+ assert_eq!(failure.detail.as_deref(), Some("error: command not found"));
+}
+
+#[test]
+fn tool_failure_metadata_round_trips_write_to_display_line() {
+ // Full write path: a failed tool ChatMessage stamped with failure metadata
+ // must serialise the additive `failure` line field and read back as a failed
+ // display message — proving the harness → transcript → projection seam.
+ let dir = TempDir::new().unwrap();
+ let now = "2026-07-21T09:00:00Z".to_string();
+ let meta = transcript::TranscriptMeta {
+ agent_name: "orchestrator".into(),
+ agent_id: Some("orchestrator".into()),
+ agent_type: Some("root".into()),
+ dispatcher: "native".into(),
+ provider: Some("anthropic".into()),
+ model: Some("m".into()),
+ created: now.clone(),
+ updated: now,
+ turn_count: 1,
+ input_tokens: 0,
+ output_tokens: 0,
+ cached_input_tokens: 0,
+ charged_amount_usd: 0.0,
+ thread_id: Some("thr_rt".into()),
+ task_id: None,
+ };
+
+ let mut tool_msg = ChatMessage {
+ id: Some("call-1".into()),
+ role: "tool".into(),
+ content: r#"{"tool_call_id":"call-1","content":"boom"}"#.into(),
+ extra_metadata: None,
+ };
+ transcript::attach_tool_failure_metadata(&mut tool_msg, Some("boom: exit 1"));
+
+ let messages = vec![
+ ChatMessage {
+ id: None,
+ role: "user".into(),
+ content: "do it".into(),
+ extra_metadata: None,
+ },
+ tool_msg,
+ ];
+ let path = transcript::resolve_keyed_transcript_path(dir.path(), "700_orchestrator").unwrap();
+ transcript::write_transcript(&path, &messages, &meta, None).unwrap();
+
+ let display = read_transcript_display(&path).unwrap();
+ let failed = display
+ .records
+ .iter()
+ .find_map(|r| match r {
+ transcript::DisplayRecord::Message(m) if m.message.role == "tool" => Some(m),
+ _ => None,
+ })
+ .expect("tool display message present");
+ assert!(
+ failed.failure,
+ "failure flag survived the write/read round trip"
+ );
+ assert_eq!(failed.failure_detail.as_deref(), Some("boom: exit 1"));
+}
+
+#[test]
+fn subagent_anchors_to_parent_turn_by_spawn_timestamp() {
+ let dir = TempDir::new().unwrap();
+ let root_stem = "800_orchestrator";
+ let thread_id = "thr_anchor";
+
+ let t1 = chrono::DateTime::from_timestamp(1_000_000, 0)
+ .unwrap()
+ .to_rfc3339();
+ let t2 = chrono::DateTime::from_timestamp(2_000_000, 0)
+ .unwrap()
+ .to_rfc3339();
+
+ // Two turns: req-1 (assistant ts t1), req-2 (assistant ts t2).
+ let root_body = vec![
+ r#"{"role":"user","content":"one","request_id":"req-1"}"#.to_string(),
+ format!(
+ r#"{{"role":"assistant","content":"a1","provider":"anthropic","model":"m","usage":{{"input":1,"output":1,"cached_input":0,"cost_usd":0.0}},"ts":"{t1}","iteration":1,"request_id":"req-1"}}"#
+ ),
+ r#"{"role":"user","content":"two","request_id":"req-2"}"#.to_string(),
+ format!(
+ r#"{{"role":"assistant","content":"a2","provider":"anthropic","model":"m","usage":{{"input":1,"output":1,"cached_input":0,"cost_usd":0.0}},"ts":"{t2}","iteration":1,"request_id":"req-2"}}"#
+ ),
+ ];
+ let root_refs: Vec<&str> = root_body.iter().map(String::as_str).collect();
+ write_raw(dir.path(), root_stem, thread_id, &root_refs);
+
+ // Sub-agent stems encode the spawn unix timestamp: coder spawned during
+ // turn 1 (1_000_050), planner during turn 2 (2_000_050).
+ write_raw(
+ dir.path(),
+ &format!("{root_stem}__1000050_coder"),
+ thread_id,
+ &[r#"{"role":"assistant","content":"coder work"}"#],
+ );
+ write_raw(
+ dir.path(),
+ &format!("{root_stem}__2000050_planner"),
+ thread_id,
+ &[r#"{"role":"assistant","content":"planner work"}"#],
+ );
+
+ let projected = project_thread(dir.path(), thread_id).expect("project thread");
+ // The seeded sub-agent files share the `orchestrator` meta agent name, so
+ // key the anchoring by each sub-agent's inner work content instead of `id`.
+ let mut anchors: Vec<(String, Option)> = projected
+ .items
+ .iter()
+ .filter_map(|i| match i {
+ DisplayItem::Subagent {
+ request_id, items, ..
+ } => {
+ let marker = items.iter().find_map(|inner| match inner {
+ DisplayItem::AssistantMessage { content, .. } => Some(content.clone()),
+ _ => None,
+ })?;
+ Some((marker, request_id.clone()))
+ }
+ _ => None,
+ })
+ .collect();
+ anchors.sort();
+
+ assert_eq!(
+ anchors,
+ vec![
+ ("coder work".to_string(), Some("req-1".to_string())),
+ ("planner work".to_string(), Some("req-2".to_string())),
+ ],
+ "each sub-agent anchors to the turn active at its spawn time"
+ );
+}
+
+#[test]
+fn get_page_missing_thread_is_empty_not_error() {
+ let dir = TempDir::new().unwrap();
+ let page = get_page(dir.path(), "no_such_thread", None, Some(DEFAULT_LIMIT));
+ assert!(!page.has_transcript);
+ assert_eq!(page.total, 0);
+ assert!(page.items.is_empty());
+}
diff --git a/src/openhuman/threads/transcript_view/types.rs b/src/openhuman/threads/transcript_view/types.rs
new file mode 100644
index 000000000..6c36a09b4
--- /dev/null
+++ b/src/openhuman/threads/transcript_view/types.rs
@@ -0,0 +1,139 @@
+//! Typed display items for the transcript projection RPC
+//! (`threads.transcript_get`).
+//!
+//! These mirror the frontend's existing chat vocabulary (user/assistant
+//! bubbles, reasoning drawer, tool timeline rows, sub-agent activity) so the
+//! Phase C renderer can map them onto the same components. Serde is camelCase
+//! on the wire — the frontend reads `displayContent`, `callId`, `requestId`,
+//! etc.
+
+use serde::Serialize;
+
+/// Terminal state of a projected tool call. Mirrors the live timeline's
+/// `ToolTimelineStatus` vocabulary (`running` / `success` / `error`) so the
+/// settled projection and the live stream render identically.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ToolCallStatus {
+ /// Call issued but no result line has been paired yet.
+ Running,
+ /// A result line was paired to the call.
+ Success,
+ /// A result line the projection identified as a **failure**: the persisted
+ /// tool line carried the additive `failure` flag (stamped at turn-loop
+ /// persistence from the tool's `ToolResult::is_error` outcome). Paired with
+ /// a [`ToolCallFailure`] payload on the item.
+ Error,
+}
+
+/// Failure payload attached to an errored [`DisplayItem::ToolCall`]. Minimal by
+/// design: the persisted transcript only records that the call failed plus an
+/// optional short reason. The frontend mapper expands this into its richer
+/// `ToolFailureExplanation` shape (`class` / `category` / `causePlain` /
+/// `nextAction`) for the `ToolFailureLines` renderer.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ToolCallFailure {
+ /// Short, single-line reason for the failure, when the writer captured one.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub detail: Option,
+}
+
+/// One item in a projected transcript, in the frontend's display vocabulary.
+///
+/// `#[serde(tag = "kind")]` gives each variant a camelCase discriminator
+/// (`userMessage`, `assistantMessage`, …) and every field is camelCase.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+#[serde(
+ tag = "kind",
+ rename_all = "camelCase",
+ rename_all_fields = "camelCase"
+)]
+pub enum DisplayItem {
+ /// A user prompt. `content` is the raw persisted content (may carry the
+ /// injected `Current Date & Time:` scaffolding line); `displayContent` is
+ /// the sanitized version to show, present only when it differs from raw.
+ UserMessage {
+ content: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ display_content: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ request_id: Option,
+ },
+ /// An assistant answer. `interim: true` marks a non-terminal tool-calling
+ /// step within a multi-iteration turn (not the final answer bubble).
+ AssistantMessage {
+ content: String,
+ #[serde(default, skip_serializing_if = "is_false")]
+ interim: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ request_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ model: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ iteration: Option,
+ },
+ /// The model's reasoning/thinking that preceded an assistant message.
+ Reasoning { text: String },
+ /// A tool invocation with its paired result, when available.
+ ToolCall {
+ call_id: String,
+ name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ args: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ result: Option,
+ status: ToolCallStatus,
+ /// Present only when `status` is `Error` — the failure payload the
+ /// frontend expands for the `ToolFailureLines` renderer.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ failure: Option,
+ },
+ /// A delegated sub-agent run, with its own nested projected items.
+ ///
+ /// `request_id` anchors the whole sub-agent trail to the parent turn that
+ /// spawned it. Sub-agent transcripts are sibling files with no explicit
+ /// back-link to the delegating tool call, so the projection derives this by
+ /// matching the sub-agent's spawn timestamp (encoded in its file stem)
+ /// against the parent turns' timestamp ranges (see
+ /// `project::anchor_request_id`). Absent for legacy/CLI transcripts whose
+ /// lines carry no `request_id`.
+ Subagent {
+ id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ request_id: Option,
+ items: Vec,
+ },
+ /// A turn boundary — emitted when the `request_id` changes between lines.
+ TurnBoundary { request_id: String },
+ /// A partial assistant answer captured when a turn was interrupted.
+ InterruptedPartial {
+ text: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thinking: Option,
+ },
+ /// A context-compaction marker: the reduced set replaced everything before
+ /// it. Counts describe what the record superseded/installed.
+ Compaction {
+ replaced_count: usize,
+ kept_count: usize,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ts: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ request_id: Option,
+ },
+}
+
+#[allow(clippy::trivially_copy_pass_by_ref)]
+fn is_false(b: &bool) -> bool {
+ !*b
+}
+
+/// A projected transcript for one thread, before pagination. Chronological
+/// (file) order; the RPC layer paginates newest-first.
+#[derive(Debug, Clone)]
+pub struct ProjectedTranscript {
+ pub thread_id: String,
+ /// All top-level display items in chronological order.
+ pub items: Vec,
+}
diff --git a/src/openhuman/threads/turn_state/mirror.rs b/src/openhuman/threads/turn_state/mirror.rs
index cb0bc0c3b..aa694adb9 100644
--- a/src/openhuman/threads/turn_state/mirror.rs
+++ b/src/openhuman/threads/turn_state/mirror.rs
@@ -558,6 +558,69 @@ impl TurnStateMirror {
self.state.active_subagent = None;
self.state.updated_at = chrono::Utc::now().to_rfc3339();
self.flush();
+ self.persist_interrupted_partial();
+ }
+
+ /// Append the partial streamed answer of an interrupted turn to the session
+ /// transcript so the derived display view (Phase B) can surface it even
+ /// after the live turn_state snapshot is gone. Display-only: the
+ /// model-context reader skips `interrupted:true` lines.
+ ///
+ /// Guard: the root transcript file must already exist. An interrupted
+ /// **first** turn has no session file yet (the harness has not persisted a
+ /// turn), so there is nothing to append to — that case stays recoverable
+ /// from the turn_state snapshot alone, as today. We log and skip it.
+ fn persist_interrupted_partial(&self) {
+ let partial = self.state.streaming_text.trim();
+ if partial.is_empty() {
+ return;
+ }
+ let thread_id = self.state.thread_id.trim();
+ if thread_id.is_empty() {
+ return;
+ }
+ let workspace_dir = self.store.workspace_dir();
+ let Some(path) =
+ crate::openhuman::agent::harness::session::transcript::find_root_transcript_for_thread(
+ workspace_dir,
+ thread_id,
+ )
+ else {
+ log::debug!(
+ "{MIRROR_LOG_PREFIX} no root transcript for thread={thread_id} yet — leaving interrupted partial ({} chars) in turn_state snapshot only",
+ partial.len()
+ );
+ return;
+ };
+ let request_id = if self.state.request_id.is_empty() {
+ None
+ } else {
+ Some(self.state.request_id.as_str())
+ };
+ let thinking = self.state.thinking.trim();
+ let reasoning = if thinking.is_empty() {
+ None
+ } else {
+ Some(thinking)
+ };
+ match crate::openhuman::agent::harness::session::transcript::append_interrupted_partial(
+ &path,
+ partial,
+ request_id,
+ Some(self.state.iteration),
+ reasoning,
+ ) {
+ Ok(()) => log::debug!(
+ "{MIRROR_LOG_PREFIX} appended interrupted partial ({} chars, thinking={} chars) for thread={thread_id} request_id={} to {}",
+ partial.len(),
+ thinking.len(),
+ self.state.request_id,
+ path.display()
+ ),
+ Err(err) => log::warn!(
+ "{MIRROR_LOG_PREFIX} failed to append interrupted partial for thread={thread_id}: {err}"
+ ),
+ }
}
fn flush(&mut self) {
diff --git a/src/openhuman/threads/turn_state/mirror_tests.rs b/src/openhuman/threads/turn_state/mirror_tests.rs
index 2e46bd669..c116e99d4 100644
--- a/src/openhuman/threads/turn_state/mirror_tests.rs
+++ b/src/openhuman/threads/turn_state/mirror_tests.rs
@@ -674,3 +674,138 @@ fn subagent_transcript_persists_interleaved_prose_and_tools() {
"no snake_case fields on the wire"
);
}
+
+// ── Interrupted-partial → session transcript wiring (Task 1) ──────────
+
+use crate::openhuman::agent::harness::session::transcript::{
+ self, read_transcript, read_transcript_display, DisplayRecord, TranscriptMeta,
+};
+use crate::openhuman::inference::provider::ChatMessage;
+
+fn seed_root_transcript(workspace: &std::path::Path, thread_id: &str) -> std::path::PathBuf {
+ let stem = "100_orchestrator".to_string();
+ let path = transcript::resolve_keyed_transcript_path(workspace, &stem).expect("resolve path");
+ let meta = TranscriptMeta {
+ agent_name: "orchestrator".into(),
+ agent_id: None,
+ agent_type: Some("root".into()),
+ dispatcher: "native".into(),
+ provider: None,
+ model: None,
+ created: "2026-07-21T00:00:00Z".into(),
+ updated: "2026-07-21T00:00:00Z".into(),
+ turn_count: 1,
+ input_tokens: 0,
+ output_tokens: 0,
+ cached_input_tokens: 0,
+ charged_amount_usd: 0.0,
+ thread_id: Some(thread_id.to_string()),
+ task_id: None,
+ };
+ transcript::write_transcript(&path, &[ChatMessage::user("hello there")], &meta, None)
+ .expect("seed transcript");
+ path
+}
+
+/// When a streaming turn is interrupted and a root transcript already exists,
+/// `finish()` appends the partial streamed answer (display-only) to the file.
+#[test]
+fn finish_appends_interrupted_partial_to_existing_transcript() {
+ let dir = tempdir().expect("tempdir");
+ let thread_id = "thr_abc";
+ let path = seed_root_transcript(dir.path(), thread_id);
+
+ let store = TurnStateStore::new(dir.path().to_path_buf());
+ let mut m = TurnStateMirror::new(store, thread_id, "req-9");
+ m.observe(&AgentProgress::IterationStarted {
+ iteration: 2,
+ max_iterations: 25,
+ });
+ m.observe(&AgentProgress::ThinkingDelta {
+ delta: "hmm".into(),
+ iteration: 2,
+ });
+ m.observe(&AgentProgress::TextDelta {
+ delta: "half an ".into(),
+ iteration: 2,
+ });
+ m.observe(&AgentProgress::TextDelta {
+ delta: "answer".into(),
+ iteration: 2,
+ });
+ // No TurnCompleted — the bridge exits, marking the turn interrupted.
+ m.finish();
+
+ // Model context must NOT carry the partial.
+ let model = read_transcript(&path).expect("read model context");
+ assert!(
+ !model
+ .messages
+ .iter()
+ .any(|msg| msg.content.contains("half an answer")),
+ "interrupted partial must be excluded from the model context"
+ );
+
+ // Display projection carries the flagged partial with request_id + thinking.
+ let display = read_transcript_display(&path).expect("read display");
+ let partial = display
+ .records
+ .iter()
+ .find_map(|r| match r {
+ DisplayRecord::Message(msg) if msg.interrupted => Some(msg),
+ _ => None,
+ })
+ .expect("display must include the interrupted partial");
+ assert_eq!(partial.message.content, "half an answer");
+ assert_eq!(partial.request_id.as_deref(), Some("req-9"));
+ assert_eq!(partial.iteration, Some(2));
+ assert_eq!(partial.reasoning_content.as_deref(), Some("hmm"));
+}
+
+/// A completed turn never writes an interrupted partial.
+#[test]
+fn finish_after_completion_writes_no_partial() {
+ let dir = tempdir().expect("tempdir");
+ let thread_id = "thr_done";
+ let path = seed_root_transcript(dir.path(), thread_id);
+
+ let store = TurnStateStore::new(dir.path().to_path_buf());
+ let mut m = TurnStateMirror::new(store, thread_id, "req-done");
+ m.observe(&AgentProgress::TextDelta {
+ delta: "final answer".into(),
+ iteration: 1,
+ });
+ m.observe(&AgentProgress::TurnCompleted { iterations: 1 });
+ m.finish();
+
+ let display = read_transcript_display(&path).expect("read display");
+ assert!(
+ !display
+ .records
+ .iter()
+ .any(|r| matches!(r, DisplayRecord::Message(msg) if msg.interrupted)),
+ "a completed turn must not append an interrupted partial"
+ );
+}
+
+/// An interrupted FIRST turn (no root transcript file yet) is a no-op — the
+/// partial stays in the turn_state snapshot only, and finish() does not panic.
+#[test]
+fn finish_first_turn_without_transcript_is_noop() {
+ let dir = tempdir().expect("tempdir");
+ let store = TurnStateStore::new(dir.path().to_path_buf());
+ let mut m = TurnStateMirror::new(store, "thr_new", "req-first");
+ m.observe(&AgentProgress::TextDelta {
+ delta: "orphan partial".into(),
+ iteration: 1,
+ });
+ // Must not panic even though no session_raw transcript exists.
+ m.finish();
+ // The snapshot itself still records the interrupted turn.
+ let listed = TurnStateStore::new(dir.path().to_path_buf())
+ .get("thr_new")
+ .expect("get")
+ .expect("snapshot present");
+ assert_eq!(listed.lifecycle, TurnLifecycle::Interrupted);
+ assert_eq!(listed.streaming_text, "orphan partial");
+}
diff --git a/src/openhuman/threads/turn_state/store.rs b/src/openhuman/threads/turn_state/store.rs
index c88a89006..af8c4afb0 100644
--- a/src/openhuman/threads/turn_state/store.rs
+++ b/src/openhuman/threads/turn_state/store.rs
@@ -54,6 +54,13 @@ impl TurnStateStore {
Self { workspace_dir }
}
+ /// Workspace root this store persists under. Exposed so the mirror can
+ /// resolve sibling session transcripts (append the interrupted partial to
+ /// `session_raw/{root}.jsonl`) without re-plumbing the path.
+ pub fn workspace_dir(&self) -> &std::path::Path {
+ &self.workspace_dir
+ }
+
/// Atomically write the snapshot for `state.request_id` under
/// `state.thread_id`. On a `Completed` write, prune the thread's completed
/// turns to the newest [`COMPLETED_RETENTION`].
diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs
index 28f82bf03..a315f54da 100644
--- a/tests/json_rpc_e2e.rs
+++ b/tests/json_rpc_e2e.rs
@@ -15398,3 +15398,221 @@ async fn json_rpc_agent_meetings_generate_summary_rejects_empty_meeting_id() {
rpc_join.abort();
}
+
+/// Seed a raw append-only session transcript at
+/// `{workspace}/session_raw/{stem}.jsonl` (meta header + body lines).
+fn seed_raw_transcript(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) {
+ let raw_dir = workspace.join("session_raw");
+ std::fs::create_dir_all(&raw_dir).expect("create session_raw");
+ let meta = format!(
+ r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:10Z","turn_count":1,"input_tokens":30,"output_tokens":13,"cached_input_tokens":0,"charged_amount_usd":0.003,"thread_id":"{thread_id}"}}}}"#
+ );
+ let mut buf = meta;
+ buf.push('\n');
+ for line in body {
+ buf.push_str(line);
+ buf.push('\n');
+ }
+ std::fs::write(raw_dir.join(format!("{stem}.jsonl")), buf).expect("write transcript");
+}
+
+#[tokio::test]
+async fn json_rpc_threads_transcript_get_projects_and_paginates() {
+ let _env_lock = json_rpc_e2e_env_lock();
+ let tmp = tempdir().expect("tempdir");
+ // Config resolves the runtime workspace to `OPENHUMAN_WORKSPACE/workspace`
+ // (see resolve_config_dir_for_workspace), so seed transcripts there.
+ let workspace = tmp.path().join("workspace");
+ std::fs::create_dir_all(&workspace).expect("create workspace");
+ let workspace = workspace.as_path();
+
+ let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
+ let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
+ let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
+ let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
+
+ let thread_id = "thr_transcript_e2e";
+ let root_stem = "1000_orchestrator";
+ // A full turn: system scaffolding (dropped), user with injected datetime
+ // prefix, assistant tool-calling step (reasoning + tool_calls), tool result,
+ // final assistant answer.
+ seed_raw_transcript(
+ workspace,
+ root_stem,
+ thread_id,
+ &[
+ r#"{"role":"system","content":"[tool-policy preamble] ..."}"#,
+ r#"{"role":"user","content":"Current Date & Time: 2026-07-21 09:00:00 UTC\n\nWeather in NYC?","request_id":"req-1"}"#,
+ r#"{"role":"assistant","content":"Checking.","provider":"anthropic","model":"claude-x","usage":{"input":10,"output":5,"cached_input":0,"cost_usd":0.001},"ts":"2026-07-21T09:00:01Z","reasoning_content":"call the tool","tool_calls":[{"id":"call-1","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},{"id":"call-2","name":"get_traffic","arguments":"{\"city\":\"NYC\"}"}],"iteration":1,"request_id":"req-1"}"#,
+ r#"{"role":"tool","content":"72F sunny","id":"call-1","request_id":"req-1"}"#,
+ r#"{"role":"tool","content":"error: traffic service unavailable","id":"call-2","request_id":"req-1","failure":true,"failure_detail":"traffic service unavailable"}"#,
+ r#"{"role":"assistant","content":"72F and sunny.","provider":"anthropic","model":"claude-x","usage":{"input":20,"output":8,"cached_input":0,"cost_usd":0.002},"ts":"2026-07-21T09:00:02Z","iteration":2,"request_id":"req-1"}"#,
+ ],
+ );
+ // A sub-agent sibling sharing the root stem.
+ seed_raw_transcript(
+ workspace,
+ &format!("{root_stem}__50_coder"),
+ thread_id,
+ &[
+ r#"{"role":"assistant","content":"sub work done","provider":"anthropic","model":"claude-x","usage":{"input":5,"output":3,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:10:00Z","iteration":1}"#,
+ ],
+ );
+
+ let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
+ let rpc_base = format!("http://{rpc_addr}");
+
+ // Full projection (default limit).
+ let full = post_json_rpc(
+ &rpc_base,
+ 41_001,
+ "openhuman.threads_transcript_get",
+ json!({ "thread_id": thread_id }),
+ )
+ .await;
+ let full_result = assert_no_jsonrpc_error(&full, "threads_transcript_get");
+ let data = full_result.get("data").expect("data envelope");
+ assert_eq!(
+ data.get("threadId").and_then(Value::as_str),
+ Some(thread_id)
+ );
+ assert_eq!(
+ data.get("hasTranscript").and_then(Value::as_bool),
+ Some(true)
+ );
+
+ let items = data
+ .get("items")
+ .and_then(Value::as_array)
+ .expect("items array");
+ // 7 top-level from the root turn (turnBoundary, userMessage, reasoning,
+ // interim assistant, 2 toolCalls, final assistant) + 1 subagent = 8.
+ assert_eq!(
+ data.get("total").and_then(Value::as_u64),
+ Some(8),
+ "items: {items:#?}"
+ );
+
+ let kinds: Vec<&str> = items
+ .iter()
+ .filter_map(|i| i.get("kind").and_then(Value::as_str))
+ .collect();
+ assert!(kinds.contains(&"turnBoundary"));
+ assert!(kinds.contains(&"userMessage"));
+ assert!(kinds.contains(&"reasoning"));
+ assert!(kinds.contains(&"toolCall"));
+ assert!(kinds.contains(&"subagent"));
+
+ // Sanitization: the user message keeps raw content but exposes a stripped
+ // displayContent.
+ let user = items
+ .iter()
+ .find(|i| i.get("kind").and_then(Value::as_str) == Some("userMessage"))
+ .expect("userMessage present");
+ assert_eq!(
+ user.get("displayContent").and_then(Value::as_str),
+ Some("Weather in NYC?")
+ );
+
+ // Tool call paired with its result.
+ let tool = items
+ .iter()
+ .find(|i| i.get("callId").and_then(Value::as_str) == Some("call-1"))
+ .expect("toolCall call-1 present");
+ assert_eq!(
+ tool.get("result").and_then(Value::as_str),
+ Some("72F sunny")
+ );
+ assert_eq!(tool.get("status").and_then(Value::as_str), Some("success"));
+ assert!(
+ tool.get("failure").is_none(),
+ "successful tool has no failure"
+ );
+
+ // A failed tool result projects an error status + failure payload rather
+ // than a false success (Gap 1).
+ let failed_tool = items
+ .iter()
+ .find(|i| i.get("callId").and_then(Value::as_str) == Some("call-2"))
+ .expect("toolCall call-2 present");
+ assert_eq!(
+ failed_tool.get("status").and_then(Value::as_str),
+ Some("error")
+ );
+ assert_eq!(
+ failed_tool
+ .get("failure")
+ .and_then(|f| f.get("detail"))
+ .and_then(Value::as_str),
+ Some("traffic service unavailable")
+ );
+
+ // Pagination: newest-first, limit 2 → 2 items + a cursor.
+ let page1 = post_json_rpc(
+ &rpc_base,
+ 41_002,
+ "openhuman.threads_transcript_get",
+ json!({ "thread_id": thread_id, "limit": 2 }),
+ )
+ .await;
+ let page1_data = assert_no_jsonrpc_error(&page1, "transcript_get page1")
+ .get("data")
+ .cloned()
+ .expect("data");
+ assert_eq!(
+ page1_data
+ .get("items")
+ .and_then(Value::as_array)
+ .map(Vec::len),
+ Some(2)
+ );
+ assert_eq!(
+ page1_data.get("hasMore").and_then(Value::as_bool),
+ Some(true)
+ );
+ let cursor = page1_data
+ .get("nextCursor")
+ .and_then(Value::as_str)
+ .expect("nextCursor present")
+ .to_string();
+
+ let page2 = post_json_rpc(
+ &rpc_base,
+ 41_003,
+ "openhuman.threads_transcript_get",
+ json!({ "thread_id": thread_id, "limit": 2, "cursor": cursor }),
+ )
+ .await;
+ let page2_data = assert_no_jsonrpc_error(&page2, "transcript_get page2")
+ .get("data")
+ .cloned()
+ .expect("data");
+ assert_eq!(
+ page2_data
+ .get("items")
+ .and_then(Value::as_array)
+ .map(Vec::len),
+ Some(2),
+ "second page returns the next two items"
+ );
+
+ // Missing thread → empty page, not an error.
+ let missing = post_json_rpc(
+ &rpc_base,
+ 41_004,
+ "openhuman.threads_transcript_get",
+ json!({ "thread_id": "no_such_thread" }),
+ )
+ .await;
+ let missing_data = assert_no_jsonrpc_error(&missing, "transcript_get missing")
+ .get("data")
+ .cloned()
+ .expect("data");
+ assert_eq!(
+ missing_data.get("hasTranscript").and_then(Value::as_bool),
+ Some(false)
+ );
+ assert_eq!(missing_data.get("total").and_then(Value::as_u64), Some(0));
+
+ rpc_join.abort();
+}