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(); +}