Merge remote-tracking branch 'origin/fix/human-transcript-projection' into fix/web-transcript-projection
# Conflicts: # apps/web/tests/snapshots/seeded-history/ui.expected.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/fold-adapter.ts # packages/client/runtime/tests/fold-adapter.spec.ts # packages/client/ui-trajectory/src/client/layout.ts # packages/client/ui-trajectory/src/client/spans.ts # packages/compact/compact/README.i18n.yaml
This commit is contained in:
@@ -3,12 +3,19 @@
|
||||
* 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 type { TrajectoryCellProps } from './TrajectoryCell.tsx'
|
||||
import type {
|
||||
TrajectoryCellProps,
|
||||
TrajectorySourceBlock,
|
||||
} from './trajectory-record.ts'
|
||||
|
||||
/** One Message or Step group inside a turn. */
|
||||
export interface TrajectoryGroupModel {
|
||||
@@ -28,12 +35,16 @@ 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
|
||||
}
|
||||
@@ -46,15 +57,85 @@ interface LaidCell {
|
||||
callId?: string
|
||||
}
|
||||
|
||||
interface LaidGroup {
|
||||
title: string
|
||||
laid: LaidCell[]
|
||||
}
|
||||
|
||||
interface TurnBucket {
|
||||
groups: LaidGroup[]
|
||||
}
|
||||
|
||||
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: RequestView
|
||||
}
|
||||
| {
|
||||
kind: 'system'
|
||||
seq: number
|
||||
request: RequestView
|
||||
change: RequestPromptChange
|
||||
}
|
||||
| {
|
||||
kind: 'request'
|
||||
seq: number
|
||||
request: RequestView
|
||||
}
|
||||
|
||||
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, codeDispatches } = input
|
||||
const {
|
||||
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
|
||||
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>()
|
||||
let index = 0
|
||||
let prevAbsTime: number | null = null
|
||||
let lastAssistantTurn: number | null = null
|
||||
@@ -62,26 +143,171 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
const bucket = (turn: number) => {
|
||||
let entry = turns.get(turn)
|
||||
if (entry === undefined) {
|
||||
entry = { message: [], steps: new Map() }
|
||||
entry = { groups: [] }
|
||||
turns.set(turn, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const pushMessage = (turn: number, laid: LaidCell) => {
|
||||
bucket(turn).message.push(laid)
|
||||
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: LaidCell) => {
|
||||
const steps = bucket(turn).steps
|
||||
const list = steps.get(step) ?? []
|
||||
list.push(laid)
|
||||
steps.set(step, list)
|
||||
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] })
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = 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 (node === undefined) continue
|
||||
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.purpose === 'compaction')
|
||||
.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
|
||||
? []
|
||||
: [{
|
||||
kind: 'system' as const,
|
||||
seq: request.promptChange.seq,
|
||||
request,
|
||||
change: request.promptChange,
|
||||
}]),
|
||||
...requests
|
||||
.filter(request => 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)
|
||||
bucket(request.turn).groups.push({
|
||||
title: `Compaction ${request.startSeq}`,
|
||||
laid: [{
|
||||
absTime: finiteTime(request.startedAt),
|
||||
cell,
|
||||
}],
|
||||
})
|
||||
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.
|
||||
@@ -91,50 +317,76 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index, kind: 'user', text: summarizeContent(node.content),
|
||||
timeSeconds: 0,
|
||||
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), codeDispatches)
|
||||
for (const laid of laidList) {
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laid)
|
||||
else pushMessage(node.turn, laid)
|
||||
}
|
||||
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' || node.kind === 'compaction') {
|
||||
// No trajectory cell, but transcript metadata still advances the duration cursor.
|
||||
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 (!callEmittedInAssistant(nodes, node.callId)) {
|
||||
const toolName = node.call?.name
|
||||
pushStep(0, 1, {
|
||||
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)) {
|
||||
pushStep(0, 1, laid)
|
||||
laidList.push(laid)
|
||||
index = laid.cell.index
|
||||
}
|
||||
pushStep(0, 1, laidList)
|
||||
}
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
}
|
||||
@@ -145,11 +397,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
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, { streaming: true })
|
||||
for (const laid of laidList) {
|
||||
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
|
||||
else pushMessage(partial.turn, laid)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -157,7 +414,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
const seenCalls = collectCallIds(turns)
|
||||
for (const call of runningCalls) {
|
||||
if (seenCalls.has(call.callId)) continue
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, {
|
||||
const laidList: LaidCell[] = [{
|
||||
absTime: null,
|
||||
toolName: call.name,
|
||||
callId: call.callId,
|
||||
@@ -165,63 +422,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
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)) {
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
|
||||
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 = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
|
||||
message: [],
|
||||
steps: new Map(),
|
||||
})
|
||||
const emptyTurn = (): TurnBucket => ({ groups: [] })
|
||||
const first = turns.get(1) ?? emptyTurn()
|
||||
first.message = [...prologue.message, ...first.message]
|
||||
for (const [step, cells] of prologue.steps) {
|
||||
const existing = first.steps.get(step) ?? []
|
||||
first.steps.set(step, [...cells, ...existing])
|
||||
}
|
||||
first.groups = [...prologue.groups, ...first.groups]
|
||||
turns.set(1, first)
|
||||
}
|
||||
|
||||
for (const entry of turns.values()) {
|
||||
for (const group of entry.groups) {
|
||||
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
|
||||
}
|
||||
}
|
||||
|
||||
return [...turns.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([turn, entry]) => toTurnModel(turn, entry))
|
||||
}
|
||||
|
||||
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,
|
||||
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
|
||||
entry: TurnBucket,
|
||||
): TrajectoryTurnModel {
|
||||
const groups: TrajectoryGroupModel[] = []
|
||||
if (entry.message.length > 0) {
|
||||
const description = groupDescription(entry.message)
|
||||
groups.push({
|
||||
title: 'Message',
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: entry.message.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
|
||||
const laid = entry.steps.get(step) ?? []
|
||||
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
|
||||
const description = groupDescription(laid)
|
||||
groups.push({
|
||||
title: `Step ${step}`,
|
||||
return {
|
||||
title,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: laid.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return { turn, groups }
|
||||
}
|
||||
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
|
||||
/** 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
|
||||
@@ -256,8 +517,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||
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`
|
||||
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. */
|
||||
@@ -267,8 +528,8 @@ function durationSeconds(later: number, earlier: number | null): number | null {
|
||||
}
|
||||
|
||||
/** Epoch-ms usable as an absolute time, else null. */
|
||||
function finiteTime(time: number): number | null {
|
||||
return Number.isFinite(time) ? time : null
|
||||
function finiteTime(time: number | null | undefined): number | null {
|
||||
return typeof time === 'number' && Number.isFinite(time) ? time : null
|
||||
}
|
||||
|
||||
function expandAssistant(
|
||||
@@ -276,66 +537,175 @@ function expandAssistant(
|
||||
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 messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
|
||||
const recordedStart = finiteTime(node.timing?.stepStartTime)
|
||||
const messageDuration = streaming
|
||||
? null
|
||||
: durationSeconds(node.time, recordedStart ?? prevAbsTime)
|
||||
const nodeAbs = streaming ? null : finiteTime(node.time)
|
||||
let usageAttached = false
|
||||
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) {
|
||||
// Reasoning blocks are skipped: no block-level clock, so no Think cell.
|
||||
if (block.kind === 'reasoning') continue
|
||||
if (block.kind === 'text') {
|
||||
if (block.text === '' && streaming) continue
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: summarizeText(block.text),
|
||||
timeSeconds: messageDuration,
|
||||
}
|
||||
if (!usageAttached) {
|
||||
attachUsage(cell, usage)
|
||||
usageAttached = usage !== undefined
|
||||
}
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
continue
|
||||
}
|
||||
if (block.kind === 'tool-call') {
|
||||
const result = results.get(block.callId)
|
||||
const toolDuration = streaming || result === undefined
|
||||
? null
|
||||
: durationSeconds(result.time, result.callTime)
|
||||
const callAbs = streaming
|
||||
? null
|
||||
: (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
|
||||
? result.callTime
|
||||
: nodeAbs)
|
||||
out.push({
|
||||
absTime: callAbs,
|
||||
toolName: block.name,
|
||||
// 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,
|
||||
cell: {
|
||||
index: ++index, kind: 'tool',
|
||||
text: summarizeCall(block.name, block.argsRaw),
|
||||
timeSeconds: toolDuration,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length === 0 && !streaming) {
|
||||
// Reasoning-only / empty success still owns provider usage on the Message row.
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
|
||||
}
|
||||
attachUsage(cell, usage)
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
...(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).
|
||||
@@ -357,10 +727,37 @@ function enclosingUserTurn(
|
||||
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
|
||||
}
|
||||
@@ -382,15 +779,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st
|
||||
}
|
||||
|
||||
function collectCallIds(
|
||||
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
|
||||
turns: Map<number, TurnBucket>,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const entry of turns.values()) {
|
||||
for (const laid of entry.message) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
for (const list of entry.steps.values()) {
|
||||
for (const laid of list) {
|
||||
for (const group of entry.groups) {
|
||||
for (const laid of group.laid) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
}
|
||||
@@ -433,12 +827,27 @@ function expandSubCalls(
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -448,8 +857,7 @@ function expandSubCalls(
|
||||
function summarizeCall(name: string, argsRaw: string): string {
|
||||
const args = argsRaw.replace(/\s+/g, ' ').trim()
|
||||
if (args === '') return name
|
||||
const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args
|
||||
return `${name} · ${clipped}`
|
||||
return `${name} · ${args}`
|
||||
}
|
||||
|
||||
function summarizeResult(node: ToolResultNode): string {
|
||||
@@ -461,7 +869,40 @@ function summarizeResult(node: ToolResultNode): string {
|
||||
return summarizeText(block.text)
|
||||
}
|
||||
}
|
||||
return node.call?.name ?? node.callId
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user