afb6ee2c9b
formatElapsedSeconds rounded every duration to one decimal, so calls under 50 ms rendered as 0 s. Durations below one second now show integer milliseconds (29 ms); at or above one second the tenth-of-a-second label is unchanged (1.5 s). Step group descriptions reuse the same formatter. The details-panel Duration rows toggle between the readable label and exact milliseconds on click, mirroring the StartedAt toggle; the shared text-selection guard is extracted for both. tabular-nums is dropped from detail values and the toggle buttons because SF's tnum feature widens the decimal point and leaves excessive space after it.
1024 lines
35 KiB
TypeScript
1024 lines
35 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'
|
||
import { formatElapsedSeconds } 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))
|
||
}
|
||
|
||
/**
|
||
* Append the changing in-flight assistant cells to a stable finalized layout.
|
||
* @param turns - Finalized layout derived with an empty-block partial anchor.
|
||
* @param partial - Current in-flight assistant projection.
|
||
* @param lastIndex - Highest cell index in the finalized layout.
|
||
* @returns The original layout without a partial, otherwise a layout sharing every unaffected turn.
|
||
*/
|
||
export function appendTrajectoryPartialLayout(
|
||
turns: readonly TrajectoryTurnModel[],
|
||
partial: ConversationSnapshot['partial'],
|
||
lastIndex: number,
|
||
): readonly TrajectoryTurnModel[] {
|
||
if (partial === null) return turns
|
||
const partialTurn = deriveTrajectoryLayout({
|
||
nodes: [],
|
||
partial,
|
||
runningCalls: [],
|
||
codeDispatches: new Map(),
|
||
}).at(0)
|
||
if (partialTurn === undefined) return turns
|
||
const streamed: TrajectoryTurnModel = {
|
||
...partialTurn,
|
||
groups: partialTurn.groups.map(group => ({
|
||
...group,
|
||
cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })),
|
||
})),
|
||
}
|
||
const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn)
|
||
if (turnIndex === -1) return [...turns, streamed]
|
||
const current = turns[turnIndex]
|
||
/* v8 ignore next -- findIndex proved the dense array position exists. */
|
||
if (current === undefined) return turns
|
||
const groups = [...current.groups]
|
||
for (const streamedGroup of streamed.groups) {
|
||
const groupIndex = groups.findIndex(group => group.title === streamedGroup.title)
|
||
if (groupIndex === -1) {
|
||
groups.push(streamedGroup)
|
||
continue
|
||
}
|
||
const group = groups[groupIndex]
|
||
/* v8 ignore next -- findIndex proved the dense array position exists. */
|
||
if (group === undefined) continue
|
||
const streamedCallIds = new Set(
|
||
streamedGroup.cells.flatMap(cell => cell.callId === undefined ? [] : [cell.callId]),
|
||
)
|
||
groups[groupIndex] = {
|
||
...streamedGroup,
|
||
cells: [
|
||
...group.cells.filter(cell =>
|
||
cell.requestOnly !== true
|
||
&& (cell.callId === undefined || !streamedCallIds.has(cell.callId)),
|
||
),
|
||
...streamedGroup.cells,
|
||
],
|
||
}
|
||
}
|
||
const updated = [...turns]
|
||
updated[turnIndex] = { ...current, groups }
|
||
return updated
|
||
}
|
||
|
||
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
|
||
return formatElapsedSeconds(seconds)
|
||
}
|
||
|
||
/** 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[] {
|
||
if (opts?.streaming === true && node.blocks.length === 0) return []
|
||
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,
|
||
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
|
||
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
|
||
}
|