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