962 lines
32 KiB
TypeScript
962 lines
32 KiB
TypeScript
/**
|
||
* Trajectory list fold: expand assistant blocks, attach usage to Message,
|
||
* own-duration times, in-flight partial/runningCalls, and group descriptions.
|
||
*/
|
||
import type {
|
||
AssistantBlock,
|
||
AssistantMessageNode,
|
||
CodeSubCall,
|
||
ConversationSnapshot,
|
||
RequestInspectionSnapshot,
|
||
RequestPromptChange,
|
||
RequestView,
|
||
ToolResultNode,
|
||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||
import type {
|
||
TrajectoryCellProps,
|
||
TrajectorySourceBlock,
|
||
} from './trajectory-record.ts'
|
||
|
||
/** One Message or Step group inside a turn. */
|
||
export interface TrajectoryGroupModel {
|
||
title: string
|
||
description?: string
|
||
cells: readonly TrajectoryCellProps[]
|
||
}
|
||
|
||
/** One sticky turn, or a standalone compaction section between turns. */
|
||
export interface TrajectoryTurnModel {
|
||
turn: number | null
|
||
groups: readonly TrajectoryGroupModel[]
|
||
}
|
||
|
||
/** Snapshot slice the trajectory view folds. */
|
||
export interface TrajectoryLayoutInput {
|
||
nodes: ConversationSnapshot['nodes']
|
||
partial: ConversationSnapshot['partial']
|
||
runningCalls: ConversationSnapshot['runningCalls']
|
||
requests?: readonly RequestView[]
|
||
callSchemas?: RequestInspectionSnapshot['callSchemas']
|
||
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
|
||
codeDispatches: ConversationSnapshot['codeDispatches']
|
||
}
|
||
|
||
interface UsageLike {
|
||
inputTokens?: number
|
||
cacheReadTokens?: number
|
||
cacheWriteTokens?: number
|
||
outputTokens?: number
|
||
reasoningTokens?: number
|
||
}
|
||
|
||
/** Cell plus absolute ms for group wall-span descriptions. */
|
||
interface LaidCell {
|
||
cell: TrajectoryCellProps
|
||
absTime: number | null
|
||
toolName?: string
|
||
callId?: string
|
||
}
|
||
|
||
interface LaidGroup {
|
||
title: string
|
||
laid: LaidCell[]
|
||
}
|
||
|
||
interface TurnBucket {
|
||
groups: LaidGroup[]
|
||
}
|
||
|
||
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
|
||
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
|
||
|
||
const PREVIEW_SOURCE_CHARACTERS = 2_048
|
||
const PREVIEW_OUTPUT_CHARACTERS = 512
|
||
|
||
type InputNode = Extract<
|
||
ConversationSnapshot['nodes'][number],
|
||
{ kind: 'user' | 'steering' | 'context' }
|
||
>
|
||
|
||
type OrderedLayoutEntry =
|
||
| {
|
||
kind: 'node'
|
||
seq: number
|
||
node: ConversationSnapshot['nodes'][number]
|
||
nodeIndex: number
|
||
}
|
||
| {
|
||
kind: 'compaction'
|
||
seq: number
|
||
request: CompactionRequestView
|
||
}
|
||
| {
|
||
kind: 'system'
|
||
seq: number
|
||
request: AssistantRequestView
|
||
change: RequestPromptChange
|
||
}
|
||
| {
|
||
kind: 'request'
|
||
seq: number
|
||
request: AssistantRequestView
|
||
}
|
||
|
||
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||
return entry.kind === 'system' && entry.change.kind === 'initial'
|
||
? Number.NEGATIVE_INFINITY
|
||
: entry.seq
|
||
}
|
||
|
||
function inputCellDetail(node: InputNode): Pick<
|
||
TrajectoryCellProps,
|
||
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
|
||
> {
|
||
return {
|
||
text: summarizeContent(node.content),
|
||
sourceSeq: node.seq,
|
||
messageSource: node.source,
|
||
inputDetail: detailContent(node.content),
|
||
sourceBlocks: node.content.map(block => sourceBlock(block)),
|
||
timeSeconds: 0,
|
||
startedAt: finiteTime(node.time),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fold a snapshot into turn → Message/Step groups with expanded cells.
|
||
* @param input - nodes plus in-flight partial/runningCalls.
|
||
* @returns turns ordered by first appearance.
|
||
*/
|
||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||
const {
|
||
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
||
} = input
|
||
const resultByCall = indexResults(nodes)
|
||
const emittedCallIds = indexAssistantCallIds(nodes)
|
||
const callStartById = new Map<string, number>()
|
||
for (const result of resultByCall.values()) {
|
||
const startedAt = finiteTime(result.callTime)
|
||
if (startedAt !== null) callStartById.set(result.callId, startedAt)
|
||
}
|
||
for (const call of runningCalls) {
|
||
const startedAt = finiteTime(call.time)
|
||
if (startedAt !== null) callStartById.set(call.callId, startedAt)
|
||
}
|
||
const turns = new Map<number, TurnBucket>()
|
||
const standaloneCompactions: TurnBucket[] = []
|
||
let index = 0
|
||
let prevAbsTime: number | null = null
|
||
let lastAssistantTurn: number | null = null
|
||
|
||
const bucket = (turn: number) => {
|
||
let entry = turns.get(turn)
|
||
if (entry === undefined) {
|
||
entry = { groups: [] }
|
||
turns.set(turn, entry)
|
||
}
|
||
return entry
|
||
}
|
||
|
||
const pushMessage = (turn: number, laid: LaidCell) => {
|
||
const groups = bucket(turn).groups
|
||
const last = groups.at(-1)
|
||
if (last?.title === 'Message') {
|
||
last.laid.push(laid)
|
||
return
|
||
}
|
||
groups.push({ title: 'Message', laid: [laid] })
|
||
}
|
||
const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => {
|
||
if (laid.length === 0) return
|
||
const groups = bucket(turn).groups
|
||
const title = `Step ${step}`
|
||
const existing = groups.find(group => group.title === title)
|
||
if (existing !== undefined) {
|
||
existing.laid.push(...laid)
|
||
return
|
||
}
|
||
groups.push({ title, laid: [...laid] })
|
||
}
|
||
|
||
const representedRequests = new Set<string>()
|
||
for (const node of nodes) {
|
||
if (node.kind === 'assistant' && node.step > 0) {
|
||
representedRequests.add(`${node.turn}\u0000${node.step}`)
|
||
}
|
||
}
|
||
if (partial !== null && partial.step > 0) {
|
||
representedRequests.add(`${partial.turn}\u0000${partial.step}`)
|
||
}
|
||
for (const call of runningCalls) {
|
||
if (call.step > 0) representedRequests.add(`${call.turn}\u0000${call.step}`)
|
||
}
|
||
|
||
const entries: OrderedLayoutEntry[] = [
|
||
...nodes.map((node, nodeIndex) => ({
|
||
kind: 'node' as const,
|
||
seq: node.seq,
|
||
node,
|
||
nodeIndex,
|
||
})),
|
||
...requests
|
||
.filter((request): request is CompactionRequestView =>
|
||
request.purpose === 'compaction')
|
||
.map(request => ({
|
||
kind: 'compaction' as const,
|
||
seq: request.startSeq,
|
||
request,
|
||
})),
|
||
...requests.flatMap(request => request.purpose !== 'assistant'
|
||
|| request.promptChange === undefined
|
||
|| request.prompt === undefined
|
||
? []
|
||
: [{
|
||
kind: 'system' as const,
|
||
seq: request.promptChange.seq,
|
||
request,
|
||
change: request.promptChange,
|
||
}]),
|
||
...requests
|
||
.filter((request): request is AssistantRequestView =>
|
||
request.purpose === 'assistant')
|
||
.filter(request =>
|
||
!representedRequests.has(`${request.turn}\u0000${request.step}`),
|
||
)
|
||
.map(request => ({
|
||
kind: 'request' as const,
|
||
seq: request.startSeq,
|
||
request,
|
||
})),
|
||
].sort((left, right) => layoutEntryOrder(left) - layoutEntryOrder(right))
|
||
|
||
for (const entry of entries) {
|
||
if (entry.kind === 'request') {
|
||
const { request } = entry
|
||
pushStep(request.turn, request.step, [{
|
||
absTime: finiteTime(request.startedAt),
|
||
cell: {
|
||
index: ++index,
|
||
kind: 'message',
|
||
text: '',
|
||
sourceSeq: request.startSeq,
|
||
requestOnly: true,
|
||
timeSeconds: request.completedAt === null
|
||
? null
|
||
: durationSeconds(request.completedAt, request.startedAt),
|
||
startedAt: finiteTime(request.startedAt),
|
||
...(request.status === 'error' ? { isError: true } : {}),
|
||
},
|
||
}])
|
||
prevAbsTime = finiteTime(request.completedAt)
|
||
?? finiteTime(request.startedAt)
|
||
?? prevAbsTime
|
||
continue
|
||
}
|
||
if (entry.kind === 'system') {
|
||
const { change, request } = entry
|
||
const turn = change.kind === 'initial'
|
||
? firstVisibleTurn(nodes, partial)
|
||
: enclosingPromptTurn(nodes, change.seq, partial)
|
||
pushMessage(turn, {
|
||
absTime: finiteTime(change.time),
|
||
cell: {
|
||
index: ++index,
|
||
kind: 'system',
|
||
text: promptChangeLabel(change),
|
||
sourceSeq: change.seq,
|
||
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
|
||
...(change.previous === undefined
|
||
? {}
|
||
: { previousPromptDetail: change.previous }),
|
||
timeSeconds: 0,
|
||
startedAt: finiteTime(change.time),
|
||
},
|
||
})
|
||
prevAbsTime = finiteTime(change.time) ?? prevAbsTime
|
||
continue
|
||
}
|
||
if (entry.kind === 'compaction') {
|
||
const request = entry.request
|
||
const rawOutput = request.rawOutput ?? request.summary
|
||
const thinkingDetail = rawOutput === undefined
|
||
? ''
|
||
: detailReasoning(rawOutput)
|
||
const cell: TrajectoryCellProps = {
|
||
index: ++index,
|
||
kind: 'compacted',
|
||
text: request.status === 'running'
|
||
? 'Compacting context…'
|
||
: request.status === 'error'
|
||
? request.error ?? 'Compaction failed'
|
||
: request.summary === undefined
|
||
? 'Context compacted'
|
||
: summarizeContent(request.summary),
|
||
sourceSeq: request.startSeq,
|
||
...(request.summary === undefined
|
||
? {}
|
||
: {
|
||
outputDetail: detailContent(request.summary),
|
||
outputBlocks: request.summary.map(block => sourceBlock(block)),
|
||
}),
|
||
...(thinkingDetail === '' ? {} : { thinkingDetail }),
|
||
...(rawOutput === undefined
|
||
? {}
|
||
: { sourceBlocks: rawOutput.map(block => sourceBlock(block)) }),
|
||
...(request.status === 'error' ? { isError: true } : {}),
|
||
timeSeconds: request.completedAt === null
|
||
? null
|
||
: durationSeconds(request.completedAt, request.startedAt),
|
||
startedAt: finiteTime(request.startedAt),
|
||
}
|
||
attachUsage(cell, request.usage as UsageLike | undefined)
|
||
const compaction: TurnBucket = {
|
||
groups: [{
|
||
title: `Compaction ${request.startSeq}`,
|
||
laid: [{
|
||
absTime: finiteTime(request.startedAt),
|
||
cell,
|
||
}],
|
||
}],
|
||
}
|
||
if (request.turn === null) standaloneCompactions.push(compaction)
|
||
else bucket(request.turn).groups.push(...compaction.groups)
|
||
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
|
||
continue
|
||
}
|
||
const { node, nodeIndex: i } = entry
|
||
if (node.kind === 'user' || node.kind === 'steering') {
|
||
// user/message has no turn on the wire; enclose it in the next assistant
|
||
// (or partial) turn, else open the turn after the last assistant.
|
||
const turn = node.kind === 'steering'
|
||
? node.turn
|
||
: enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||
pushMessage(turn, {
|
||
absTime: finiteTime(node.time),
|
||
cell: {
|
||
index: ++index,
|
||
kind: 'user',
|
||
...inputCellDetail(node),
|
||
opensTurn: node.kind === 'user',
|
||
},
|
||
})
|
||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||
continue
|
||
}
|
||
if (node.kind === 'assistant') {
|
||
const laidList = withSubCalls(
|
||
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
|
||
codeDispatches,
|
||
)
|
||
if (node.step > 0) pushStep(node.turn, node.step, laidList)
|
||
else for (const laid of laidList) pushMessage(node.turn, laid)
|
||
const last = laidList[laidList.length - 1]
|
||
if (last !== undefined) index = last.cell.index
|
||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||
lastAssistantTurn = node.turn
|
||
continue
|
||
}
|
||
if (node.kind === 'context') {
|
||
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||
pushMessage(turn, {
|
||
absTime: finiteTime(node.time),
|
||
cell: {
|
||
index: ++index,
|
||
kind: 'context',
|
||
...inputCellDetail(node),
|
||
},
|
||
})
|
||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||
continue
|
||
}
|
||
if (node.kind === 'compaction') {
|
||
// Chat owns the human-facing compaction marker. It contributes no
|
||
// duplicate trajectory cell, but still advances the duration cursor.
|
||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||
continue
|
||
}
|
||
if (node.kind === 'tool-result') {
|
||
if (!emittedCallIds.has(node.callId)) {
|
||
const toolName = node.call?.name
|
||
const laidList: LaidCell[] = [{
|
||
absTime: finiteTime(node.callTime ?? node.time),
|
||
...(toolName !== undefined ? { toolName } : {}),
|
||
callId: node.callId,
|
||
cell: {
|
||
index: ++index,
|
||
kind: 'tool',
|
||
sourceSeq: node.seq,
|
||
text: node.call !== null
|
||
? summarizeCall(node.call.name, node.call.argsRaw)
|
||
: summarizeResult(node),
|
||
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
|
||
outputDetail: detailResult(node),
|
||
outputBlocks: node.content.map(block => sourceBlock(block)),
|
||
result: summarizeResult(node),
|
||
callId: node.callId,
|
||
isError: node.isError,
|
||
timeSeconds: durationSeconds(node.time, node.callTime),
|
||
startedAt: finiteTime(node.callTime),
|
||
},
|
||
}]
|
||
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
|
||
laidList.push(laid)
|
||
index = laid.cell.index
|
||
}
|
||
pushStep(0, 1, laidList)
|
||
}
|
||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||
}
|
||
}
|
||
|
||
if (partial !== null) {
|
||
const fake: AssistantMessageNode = {
|
||
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
|
||
turn: partial.turn, step: partial.step, blocks: partial.blocks,
|
||
}
|
||
const laidList = expandAssistant(
|
||
fake,
|
||
index + 1,
|
||
prevAbsTime,
|
||
resultByCall,
|
||
callStartById,
|
||
{ streaming: true },
|
||
)
|
||
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
|
||
else for (const laid of laidList) pushMessage(partial.turn, laid)
|
||
const last = laidList[laidList.length - 1]
|
||
if (last !== undefined) index = last.cell.index
|
||
}
|
||
|
||
const seenCalls = collectCallIds(turns)
|
||
for (const call of runningCalls) {
|
||
if (seenCalls.has(call.callId)) continue
|
||
const laidList: LaidCell[] = [{
|
||
absTime: null,
|
||
toolName: call.name,
|
||
callId: call.callId,
|
||
cell: {
|
||
index: ++index,
|
||
kind: 'tool',
|
||
text: summarizeCall(call.name, call.argsRaw),
|
||
inputDetail: call.argsRaw,
|
||
callId: call.callId,
|
||
timeSeconds: null,
|
||
startedAt: finiteTime(call.time),
|
||
},
|
||
}]
|
||
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
|
||
laidList.push(laid)
|
||
index = laid.cell.index
|
||
}
|
||
if (call.step > 0) pushStep(call.turn, call.step, laidList)
|
||
else for (const laid of laidList) pushMessage(call.turn, laid)
|
||
}
|
||
|
||
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||
const prologue = turns.get(0)
|
||
if (prologue !== undefined) {
|
||
turns.delete(0)
|
||
const emptyTurn = (): TurnBucket => ({ groups: [] })
|
||
const first = turns.get(1) ?? emptyTurn()
|
||
first.groups = [...prologue.groups, ...first.groups]
|
||
turns.set(1, first)
|
||
}
|
||
|
||
for (const entry of [...turns.values(), ...standaloneCompactions]) {
|
||
for (const group of entry.groups) {
|
||
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
|
||
}
|
||
}
|
||
|
||
return [
|
||
...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry)),
|
||
...standaloneCompactions.map(entry => toTurnModel(null, entry)),
|
||
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
|
||
}
|
||
|
||
function attachToolSchema(
|
||
laid: LaidCell,
|
||
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
|
||
): void {
|
||
if (laid.callId === undefined || callSchemas === undefined) return
|
||
const schema = callSchemas.get(laid.callId)
|
||
if (schema === undefined) return
|
||
laid.cell.schemaDetail = JSON.stringify(schema, null, 2)
|
||
}
|
||
|
||
function toTurnModel(
|
||
turn: number | null,
|
||
entry: TurnBucket,
|
||
): TrajectoryTurnModel {
|
||
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
|
||
const description = groupDescription(laid)
|
||
return {
|
||
title,
|
||
...(description !== undefined ? { description } : {}),
|
||
cells: laid.map(l => l.cell),
|
||
}
|
||
})
|
||
return { turn, groups }
|
||
}
|
||
|
||
/** Chronological section position from the fold's monotonically assigned cell indexes. */
|
||
function firstCellIndex(turn: TrajectoryTurnModel): number {
|
||
return Math.min(
|
||
...turn.groups.flatMap(group => group.cells.map(cell => cell.index)),
|
||
Number.POSITIVE_INFINITY,
|
||
)
|
||
}
|
||
|
||
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
|
||
function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||
const parts: string[] = []
|
||
// Tool rows contribute start (absTime) and end (start + own duration) so a
|
||
// single Tool cell still spans call→result for the group wall clock.
|
||
const times: number[] = []
|
||
for (const l of laid) {
|
||
if (l.absTime === null || !Number.isFinite(l.absTime)) continue
|
||
times.push(l.absTime)
|
||
if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) {
|
||
times.push(l.absTime + l.cell.timeSeconds * 1000)
|
||
}
|
||
}
|
||
if (times.length >= 2) {
|
||
const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000)
|
||
if (span !== undefined) parts.push(span)
|
||
} else if (times.length === 1) {
|
||
const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds
|
||
const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined
|
||
if (span !== undefined) parts.push(span)
|
||
}
|
||
const tools = new Map<string, number>()
|
||
for (const l of laid) {
|
||
if (l.toolName === undefined || l.cell.kind !== 'tool') continue
|
||
tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1)
|
||
}
|
||
for (const [name, count] of tools) {
|
||
parts.push(count > 1 ? `${name}×${count}` : name)
|
||
}
|
||
return parts.length === 0 ? undefined : parts.join(' ')
|
||
}
|
||
|
||
function formatGroupDuration(seconds: number): string | undefined {
|
||
if (!Number.isFinite(seconds)) return undefined
|
||
const rounded = Math.round(seconds * 10) / 10
|
||
if (Number.isInteger(rounded)) return `${rounded} s`
|
||
return `${rounded.toFixed(1)} s`
|
||
}
|
||
|
||
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
|
||
function durationSeconds(later: number, earlier: number | null): number | null {
|
||
if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null
|
||
return Math.max(0, (later - earlier) / 1000)
|
||
}
|
||
|
||
/** Epoch-ms usable as an absolute time, else null. */
|
||
function finiteTime(time: number | null | undefined): number | null {
|
||
return typeof time === 'number' && Number.isFinite(time) ? time : null
|
||
}
|
||
|
||
function expandAssistant(
|
||
node: AssistantMessageNode,
|
||
startIndex: number,
|
||
prevAbsTime: number | null,
|
||
results: Map<string, ToolResultNode>,
|
||
callStarts: ReadonlyMap<string, number>,
|
||
opts?: { streaming?: boolean },
|
||
): LaidCell[] {
|
||
const out: LaidCell[] = []
|
||
let index = startIndex - 1
|
||
const usage = node.usage as UsageLike | undefined
|
||
const streaming = opts?.streaming === true
|
||
const recordedStart = finiteTime(node.timing?.stepStartTime)
|
||
const messageDuration = streaming
|
||
? null
|
||
: durationSeconds(node.time, recordedStart ?? prevAbsTime)
|
||
const nodeAbs = streaming ? null : finiteTime(node.time)
|
||
const messageText = node.blocks
|
||
.filter(block => block.kind === 'text' && (!streaming || block.text !== ''))
|
||
.map(block => block.kind === 'text' ? block.text : '')
|
||
.join('\n\n')
|
||
const thinkingText = node.blocks
|
||
.filter(block => block.kind === 'reasoning' && (!streaming || block.text !== ''))
|
||
.map(block => block.kind === 'reasoning' ? block.text : '')
|
||
.join('\n\n')
|
||
const message: TrajectoryCellProps = {
|
||
index: ++index,
|
||
kind: 'message',
|
||
sourceSeq: node.seq,
|
||
text: messageText !== ''
|
||
? summarizeText(messageText)
|
||
: thinkingText !== ''
|
||
? summarizeText(thinkingText)
|
||
: summarizeAssistantActivity(node.blocks),
|
||
...(messageText !== '' ? { outputDetail: messageText } : {}),
|
||
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
|
||
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
|
||
timeSeconds: messageDuration,
|
||
startedAt: recordedStart,
|
||
}
|
||
attachUsage(message, usage)
|
||
message.assistantMetrics = {
|
||
timingRecorded: node.timing !== undefined,
|
||
stepStartTime: node.timing?.stepStartTime ?? null,
|
||
firstTokenTime: node.timing?.firstTokenTime ?? null,
|
||
completedTime: streaming ? null : finiteTime(node.time),
|
||
usageProvided: usage !== undefined,
|
||
outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null,
|
||
}
|
||
out.push({ absTime: nodeAbs, cell: message })
|
||
|
||
for (const block of node.blocks) {
|
||
// Text and reasoning belong to the one Assistant record emitted above.
|
||
if (block.kind !== 'tool-call') continue
|
||
const result = results.get(block.callId)
|
||
const toolDuration = streaming || result === undefined
|
||
? null
|
||
: durationSeconds(result.time, result.callTime)
|
||
const callAbs = finiteTime(callStarts.get(block.callId))
|
||
out.push({
|
||
absTime: callAbs,
|
||
toolName: block.name,
|
||
callId: block.callId,
|
||
cell: {
|
||
index: ++index, kind: 'tool',
|
||
text: summarizeCall(block.name, block.argsRaw),
|
||
inputDetail: block.argsRaw,
|
||
callId: block.callId,
|
||
...(result !== undefined
|
||
? {
|
||
outputDetail: detailResult(result),
|
||
outputBlocks: result.content.map(block => sourceBlock(block)),
|
||
result: summarizeResult(result),
|
||
isError: result.isError,
|
||
}
|
||
: {}),
|
||
timeSeconds: toolDuration,
|
||
startedAt: callAbs,
|
||
},
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
|
||
const tools = new Map<string, number>()
|
||
for (const block of blocks) {
|
||
if (block.kind !== 'tool-call') continue
|
||
tools.set(block.name, (tools.get(block.name) ?? 0) + 1)
|
||
}
|
||
if (tools.size > 0) {
|
||
return 'Tool call only'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function promptChangeLabel(change: RequestPromptChange): string {
|
||
if (change.kind === 'initial') return 'Initial System Prompt'
|
||
if (change.kind === 'system') return 'System Prompt Updated'
|
||
if (change.kind === 'tools') return 'Tools Updated'
|
||
return 'System Prompt and Tools Updated'
|
||
}
|
||
|
||
function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
|
||
switch (block.kind) {
|
||
case 'text': return { type: 'text', content: block.text }
|
||
case 'reasoning': return { type: 'thinking', content: block.text }
|
||
case 'tool-call': return {
|
||
type: 'tool-call',
|
||
content: block.argsRaw,
|
||
callId: block.callId,
|
||
toolName: block.name,
|
||
}
|
||
case 'other': return sourceBlock(block.block)
|
||
}
|
||
}
|
||
|
||
function sourceBlock(value: unknown): TrajectorySourceBlock {
|
||
if (typeof value !== 'object' || value === null) {
|
||
return { type: 'unknown', content: stringifySourceValue(value) }
|
||
}
|
||
const block = value as Record<string, unknown>
|
||
const type = typeof block.type === 'string' ? block.type : 'unknown'
|
||
if (typeof block.text === 'string') {
|
||
return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
|
||
}
|
||
const imageSrc = sourceImage(block)
|
||
const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
|
||
return {
|
||
type,
|
||
content: imageSrc === undefined ? stringifySourceValue(value) : '',
|
||
...(imageSrc !== undefined ? { imageSrc } : {}),
|
||
...(imageAlt !== undefined ? { imageAlt } : {}),
|
||
}
|
||
}
|
||
|
||
function sourceImage(block: Record<string, unknown>): string | undefined {
|
||
if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
|
||
for (const candidate of [block.url, block.image_url]) {
|
||
if (typeof candidate === 'string') return safeImageSource(candidate)
|
||
}
|
||
if (typeof block.data === 'string') {
|
||
const mediaType = [block.mimeType, block.mediaType, block.media_type]
|
||
.find((candidate): candidate is string => typeof candidate === 'string')
|
||
?? 'image/png'
|
||
return safeImageSource(
|
||
block.data.startsWith('data:')
|
||
? block.data
|
||
: `data:${mediaType};base64,${block.data}`,
|
||
)
|
||
}
|
||
if (typeof block.source !== 'object' || block.source === null) return undefined
|
||
const source = block.source as Record<string, unknown>
|
||
if (typeof source.url === 'string') return safeImageSource(source.url)
|
||
if (typeof source.data !== 'string') return undefined
|
||
const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
|
||
return safeImageSource(`data:${mediaType};base64,${source.data}`)
|
||
}
|
||
|
||
function safeImageSource(value: string): string | undefined {
|
||
if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
|
||
try {
|
||
const protocol = new URL(value).protocol
|
||
return protocol === 'http:' || protocol === 'https:' ? value : undefined
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
function stringifySourceValue(value: unknown): string {
|
||
const json = JSON.stringify(value, null, 2)
|
||
return json || String(value)
|
||
}
|
||
|
||
/**
|
||
* Turn that encloses a user/message: next assistant/steering turn, else the
|
||
* in-flight partial, else the turn after the last finalized assistant (or 1).
|
||
*/
|
||
function enclosingUserTurn(
|
||
nodes: ConversationSnapshot['nodes'],
|
||
userIndex: number,
|
||
partial: ConversationSnapshot['partial'],
|
||
lastAssistantTurn: number | null,
|
||
): number {
|
||
for (let i = userIndex + 1; i < nodes.length; i++) {
|
||
const n = nodes[i]
|
||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||
if (n === undefined) continue
|
||
if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
|
||
}
|
||
if (partial !== null) return partial.turn
|
||
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
|
||
return 1
|
||
}
|
||
|
||
function enclosingPromptTurn(
|
||
nodes: ConversationSnapshot['nodes'],
|
||
seq: number,
|
||
partial: ConversationSnapshot['partial'],
|
||
): number {
|
||
const next = nodes.find(node =>
|
||
node.seq > seq && node.kind === 'assistant' && node.step > 0)
|
||
if (next?.kind === 'assistant') return next.turn
|
||
return partial?.turn ?? 1
|
||
}
|
||
|
||
/** Earliest raw turn represented by the selected trajectory branch. */
|
||
function firstVisibleTurn(
|
||
nodes: ConversationSnapshot['nodes'],
|
||
partial: ConversationSnapshot['partial'],
|
||
): number {
|
||
const turns = nodes.flatMap(node =>
|
||
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
|
||
? [node.turn]
|
||
: [],
|
||
)
|
||
if (partial !== null && partial.turn > 0) turns.push(partial.turn)
|
||
return turns.length === 0 ? 1 : Math.min(...turns)
|
||
}
|
||
|
||
/** Copy provider usage onto a Message cell when present. */
|
||
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
|
||
if (usage === undefined) return
|
||
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
|
||
if (usage.cacheReadTokens !== undefined) cell.cacheRead = usage.cacheReadTokens
|
||
if (usage.cacheWriteTokens !== undefined) cell.cacheWrite = usage.cacheWriteTokens
|
||
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
|
||
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
|
||
}
|
||
|
||
function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> {
|
||
const map = new Map<string, ToolResultNode>()
|
||
for (const node of nodes) {
|
||
if (node.kind === 'tool-result') map.set(node.callId, node)
|
||
}
|
||
return map
|
||
}
|
||
|
||
function indexAssistantCallIds(nodes: ConversationSnapshot['nodes']): ReadonlySet<string> {
|
||
const ids = new Set<string>()
|
||
for (const node of nodes) {
|
||
if (node.kind !== 'assistant') continue
|
||
for (const block of node.blocks) {
|
||
if (block.kind === 'tool-call') ids.add(block.callId)
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
function collectCallIds(
|
||
turns: Map<number, TurnBucket>,
|
||
): Set<string> {
|
||
const ids = new Set<string>()
|
||
for (const entry of turns.values()) {
|
||
for (const group of entry.groups) {
|
||
for (const laid of group.laid) {
|
||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||
}
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
|
||
|
||
/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
|
||
function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
|
||
if (codeDispatches.size === 0) return laidList
|
||
const out: LaidCell[] = []
|
||
let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
|
||
for (const laid of laidList) {
|
||
out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
|
||
if (laid.callId === undefined) continue
|
||
for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
|
||
out.push(sub)
|
||
index = sub.cell.index
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
|
||
function expandSubCalls(
|
||
subs: readonly CodeSubCall[] | undefined,
|
||
startIndex: number,
|
||
): LaidCell[] {
|
||
if (subs === undefined || subs.length === 0) return []
|
||
const out: LaidCell[] = []
|
||
let index = startIndex
|
||
for (const sub of subs) {
|
||
const settled = 'kind' in sub
|
||
out.push({
|
||
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
|
||
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
|
||
callId: sub.callId,
|
||
cell: {
|
||
index: ++index,
|
||
kind: 'subtool',
|
||
callId: sub.callId,
|
||
text: settled
|
||
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
|
||
: summarizeCall(sub.name, sub.argsRaw),
|
||
...(settled
|
||
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
|
||
: { inputDetail: sub.argsRaw }),
|
||
...(settled
|
||
? {
|
||
outputDetail: detailResult(sub),
|
||
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
||
result: summarizeResult(sub),
|
||
isError: sub.isError,
|
||
}
|
||
: {}),
|
||
// PR3's start/settle pair carries per-sub-call wall time; a running
|
||
// (unsettled) or pre-pair log entry shows the em dash.
|
||
timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
|
||
startedAt: settled
|
||
? finiteTime(sub.callTime)
|
||
: finiteTime(sub.time),
|
||
},
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function summarizeCall(name: string, argsRaw: string): string {
|
||
const args = trajectoryPreviewText(argsRaw)
|
||
if (args === '') return name
|
||
return `${name} · ${args}`
|
||
}
|
||
|
||
function summarizeResult(node: ToolResultNode): string {
|
||
if (node.isError) {
|
||
return node.error?.code ?? 'error'
|
||
}
|
||
for (const block of node.content) {
|
||
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
|
||
return summarizeText(block.text)
|
||
}
|
||
}
|
||
return 'No output'
|
||
}
|
||
|
||
function detailResult(node: ToolResultNode): string {
|
||
if (node.isError) {
|
||
return node.error === undefined
|
||
? 'error'
|
||
: `${node.error.name}: ${node.error.code}`
|
||
}
|
||
const text = node.content
|
||
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
||
.map(block => block.type === 'text' ? block.text : '')
|
||
.join('\n')
|
||
if (text !== '') return text
|
||
if (
|
||
node.content.length === 0
|
||
|| node.content.every(block =>
|
||
block.type === 'text' && (typeof block.text !== 'string' || block.text === ''))
|
||
) return 'No output'
|
||
return JSON.stringify(node.content, null, 2)
|
||
}
|
||
|
||
function detailContent(content: readonly { type: string; text?: string }[]): string {
|
||
return content
|
||
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
||
.map(block => block.text ?? '')
|
||
.join('\n')
|
||
}
|
||
|
||
function detailReasoning(content: readonly { type: string; text?: string }[]): string {
|
||
return content
|
||
.filter(block => block.type === 'reasoning' && typeof block.text === 'string')
|
||
.map(block => block.text ?? '')
|
||
.join('\n')
|
||
}
|
||
|
||
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
|
||
for (const block of content) {
|
||
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function summarizeText(text: string): string {
|
||
return trajectoryPreviewText(text)
|
||
}
|
||
|
||
/**
|
||
* Build a bounded one-line ledger preview without parsing the complete Markdown document.
|
||
* Full source remains on the cell for the inspector.
|
||
* @param text - Untrusted message, reasoning, payload, or result text.
|
||
* @returns A compact preview capped independently from the retained source.
|
||
*/
|
||
export function trajectoryPreviewText(text: string): string {
|
||
const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS)
|
||
const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
|
||
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
|
||
return source.length < text.length || preview.length < compact.length
|
||
? `${preview}…`
|
||
: preview
|
||
}
|