Merge remote-tracking branch 'origin/master' into feat/web-session-log-export

# Conflicts:
#	docs/subsystems/persistence.i18n.yaml
#	docs/subsystems/persistence.md
#	docs/subsystems/persistence.zh.md
#	packages/client/ui-trajectory/README.i18n.yaml
#	packages/client/ui-trajectory/README.md
#	packages/client/ui-trajectory/README.zh.md
#	packages/client/ui-trajectory/package.json
#	packages/client/ui-trajectory/src/client/TrajectoryView.tsx
#	packages/client/ui-trajectory/src/client/index.ts
#	packages/client/ui-trajectory/tests/client-bundle.spec.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/host/apiproxy/package.json
#	pnpm-lock.yaml
This commit is contained in:
_Kerman
2026-08-11 12:42:53 +08:00
2172 changed files with 30345 additions and 15794 deletions
@@ -24,7 +24,8 @@ import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
} from './trajectory-virtual-rows.ts'
import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryTurnModel } from './layout.ts'
import { trajectoryPreviewText } from './trajectory-preview.ts'
import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
@@ -491,6 +492,21 @@ function requestKey(turn: number | null, group: string): string {
return `${turn}\u0000${group}`
}
function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap<string, number> {
const boundaries = new Map<string, number>()
for (const record of records) {
const key = requestKey(record.turn, record.group)
if (boundaries.has(key)) continue
if (requestStep(record.group) === undefined) {
if (record.groupStart) boundaries.set(key, record.cell.index)
continue
}
if (record.cell.kind === 'user' || record.cell.kind === 'context') continue
boundaries.set(key, record.cell.index)
}
return boundaries
}
function sectionLabel(turn: number | null): string {
return turn === null ? 'Between turns' : `Turn ${turn}`
}
@@ -498,16 +514,18 @@ function sectionLabel(turn: number | null): string {
function indexRequestNumbers(
records: readonly TableRecord[],
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
boundaries: ReadonlyMap<string, number>,
): ReadonlyMap<string, number> {
const numbers = new Map<string, number>()
for (const request of sessionNumbers ?? []) {
numbers.set(requestKey(request.turn, request.group), request.number)
}
let next = Math.max(0, ...numbers.values()) + 1
const boundaries = records
.filter(record => record.groupStart && requestStep(record.group) !== undefined)
const boundaryRecords = records
.filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index
&& requestStep(record.group) !== undefined)
.sort((left, right) => left.cell.index - right.cell.index)
for (const record of boundaries) {
for (const record of boundaryRecords) {
const key = requestKey(record.turn, record.group)
if (!numbers.has(key)) numbers.set(key, next++)
}
@@ -903,6 +921,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
function recordDisplayText(cell: TrajectoryCellProps): string {
if (isToolCallOnly(cell)) return ''
if (cell.previewMarkdown !== undefined) {
const preview = trajectoryPreviewText(cell.previewMarkdown)
if (cell.text === '') return preview
return preview === '' ? cell.text : `${cell.text} · ${preview}`
}
if (cell.text !== '') return cell.text
const markdown = cell.kind === 'user' || cell.kind === 'context'
? cell.inputDetail
@@ -912,6 +935,12 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
}
function recordResultText(cell: TrajectoryCellProps): string | undefined {
return cell.resultPreviewMarkdown === undefined
? cell.result
: trajectoryPreviewText(cell.resultPreviewMarkdown)
}
function toolCallTextParts(
kind: TrajectoryCellKind,
text: string,
@@ -932,6 +961,71 @@ function isToolCallOnly(cell: TrajectoryCellProps): boolean {
&& cell.text === 'Tool call only'
}
interface RecordPresentationValue {
displayText: string
listDisplayText: string
resultText: string | undefined
toolCallOnly: boolean
toolCallText: ToolCallTextParts | undefined
}
function RecordPresentation({
cell,
children,
}: {
cell: TrajectoryCellProps
children: (value: RecordPresentationValue) => ReactNode
}) {
const displayText = useMemo(
() => recordDisplayText(cell),
[
cell.kind, cell.text, cell.previewMarkdown,
cell.inputDetail, cell.outputDetail, cell.thinkingDetail,
],
)
const resultText = useMemo(
() => recordResultText(cell),
[cell.result, cell.resultPreviewMarkdown],
)
const toolCallOnly = isToolCallOnly(cell)
const toolCallText = toolCallTextParts(cell.kind, displayText)
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
return children({
displayText,
listDisplayText,
resultText,
toolCallOnly,
toolCallText,
})
}
function RecordListText({
displayText,
toolCallOnly,
toolCallText,
}: Pick<RecordPresentationValue, 'displayText' | 'toolCallOnly' | 'toolCallText'>) {
if (toolCallOnly) {
return <span className={css.toolCallOnly}>(tool call only)</span>
}
if (toolCallText === undefined) return displayText || '—'
return (
<>
<span className={css.toolCallNameTypeface}>
{toolCallText.name || '—'}
</span>
{toolCallText.args !== undefined && (
<span className={css.toolCallPayload}>
{toolCallText.args}
</span>
)}
</>
)
}
function MarkdownFragment({
text,
rendered,
@@ -1654,9 +1748,10 @@ export function TrajectoryTable({
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords])
const requestNumbers = useMemo(
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
[allRecords, sessionRequestNumbers],
() => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries),
[allRecords, requestBoundaries, sessionRequestNumbers],
)
const records = useMemo(() => {
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
@@ -2131,263 +2226,251 @@ export function TrajectoryTable({
/>
</tr>
)}
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
const displayText = recordDisplayText(record.cell)
const toolCallOnly = isToolCallOnly(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => (
<RecordPresentation
key={trajectoryVirtualRecordKey(record)}
cell={record.cell}
>
{({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => {
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
&& record.cell.index === allRecords[0]?.cell.index
const request = record.groupStart
const key = requestKey(record.turn, record.group)
const request = requestBoundaries.get(key) === record.cell.index
&& !isCollapsedSummary
&& (record.turn === null || !collapsedTurns.has(record.turn))
? requestNumbers.get(requestKey(record.turn, record.group))
: undefined
const requestInfo = request === undefined
? undefined
: sessionRequestNumbers?.find(candidate => candidate.number === request)
const requestStatus = requestInfo?.status
? requestNumbers.get(key)
: undefined
const requestInfo = request === undefined
? undefined
: sessionRequestNumbers?.find(candidate => candidate.number === request)
const requestStatus = requestInfo?.status
?? (record.cell.isError === true ? 'error' : undefined)
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
const requestBoundaryStyle: RequestBoundaryStyle = {
'--request-boundary-offset': `${requestRunIndex * 8}px`,
}
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
const requestBoundaryStyle: RequestBoundaryStyle = {
'--request-boundary-offset': `${requestRunIndex * 8}px`,
}
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.group === record.group
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
return (
<tr
key={trajectoryVirtualRecordKey(record)}
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly
? `Request ${request ?? ''}, compaction`
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
data-kind={record.cell.kind}
data-trajectory-row-key={trajectoryVirtualRecordKey(record)}
data-virtual-position={virtualizationEnabled ? position : undefined}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
data-request-only={isRequestOnly || undefined}
data-terminal-request-boundary={terminalRequestBoundary || undefined}
data-group-start={record.groupStart || undefined}
data-turn-start={record.turnStart || undefined}
data-error={record.cell.isError || undefined}
data-running={stateOf(record) === 'running' || undefined}
data-turn-end={record.turnEnd || undefined}
data-collapsed-summary={record.collapsedSummaryKind}
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
? undefined
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
onClick={isRequestOnly
? undefined
: isCollapsedSummary
? () => {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
return (
<tr
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly
? `Request ${request ?? ''}, compaction`
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
data-kind={record.cell.kind}
data-trajectory-row-key={trajectoryVirtualRecordKey(record)}
data-virtual-position={virtualizationEnabled ? position : undefined}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
data-request-only={isRequestOnly || undefined}
data-terminal-request-boundary={terminalRequestBoundary || undefined}
data-group-start={record.groupStart || undefined}
data-turn-start={record.turnStart || undefined}
data-error={record.cell.isError || undefined}
data-running={stateOf(record) === 'running' || undefined}
data-turn-end={record.turnEnd || undefined}
data-collapsed-summary={record.collapsedSummaryKind}
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
? undefined
: timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
onClick={isRequestOnly
? undefined
: isCollapsedSummary
? () => {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(trajectoryRecordId(record.cell))
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary || isRequestOnly) return
if (record.turn !== null && collapsedTurns.has(record.turn)) {
event.preventDefault()
onToggleTurn(record.turn)
} else onToggleAssistant(trajectoryRecordId(record.cell))
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary || isRequestOnly) return
if (record.turn !== null && collapsedTurns.has(record.turn)) {
event.preventDefault()
onToggleTurn(record.turn)
return
}
if (
record.cell.kind === 'message'
return
}
if (
record.cell.kind === 'message'
&& assistantToolCalls(allRecords, record.cell.index).length > 0
) {
event.preventDefault()
onToggleAssistant(trajectoryRecordId(record.cell))
return
}
if (!record.turnStart) return
if (record.turn === null) return
if (allRecords.filter(candidate =>
candidate.turn === record.turn
) {
event.preventDefault()
onToggleAssistant(trajectoryRecordId(record.cell))
return
}
if (!record.turnStart) return
if (record.turn === null) return
if (allRecords.filter(candidate =>
candidate.turn === record.turn
&& candidate.cell.requestOnly !== true
&& candidate.cell.kind !== 'system').length <= 1) return
event.preventDefault()
onToggleTurn(record.turn)
}}
onKeyDown={(event) => {
if (isRequestOnly) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
event.preventDefault()
onToggleTurn(record.turn)
} else onToggleAssistant(trajectoryRecordId(record.cell))
return
}
selectRecord(record.cell.index)
}}
>
<td className={css.event}>
{request !== undefined && (
<button
type="button"
className={requestSelected
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
: css.requestBoundaryControl}
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
data-request-run-index={requestRunIndex}
data-request-status={requestStatus}
style={requestBoundaryStyle}
onClick={(event) => {
event.stopPropagation()
selectRequest({
turn: record.turn,
group: record.group,
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{record.turn !== null
}}
onKeyDown={(event) => {
if (isRequestOnly) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(trajectoryRecordId(record.cell))
return
}
selectRecord(record.cell.index)
}}
>
<td className={css.event}>
{request !== undefined && (
<button
type="button"
className={requestSelected
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
: css.requestBoundaryControl}
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
data-request-run-index={requestRunIndex}
data-request-status={requestStatus}
style={requestBoundaryStyle}
onClick={(event) => {
event.stopPropagation()
selectRequest({
turn: record.turn,
group: record.group,
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{record.turn !== null
&& activeTurn === record.turn
&& !isInitialSystem && (
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
<span className={css.selectionRail} aria-hidden="true" />
)}
{!isCollapsedSummary
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
<span className={css.selectionRail} aria-hidden="true" />
)}
{!isCollapsedSummary
&& !isRequestOnly
&& record.turnStart && (
<span
className={sectionActive
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
aria-label={sectionLabel(record.turn)}
>
{record.turn === null
? sectionLabel(record.turn)
: (
<>
<span className={css.turnLabelFull} aria-hidden="true">
{sectionLabel(record.turn)}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
</>
)}
</span>
)}
<div className={css.eventInner}>
{!isCollapsedSummary && !isRequestOnly && (
<span
className={css.kindSlot}
>
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
className={sectionActive
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
aria-label={sectionLabel(record.turn)}
>
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
</Tooltip>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</span>
</span>
)}
</div>
</td>
<td className={css.content}>
{isRequestOnly
? null
: record.collapsedSummary !== undefined
? (
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
<span className={css.collapsedTurnEllipsis}>…</span>
<span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
</span>
)
: (
<span
className={record.cell.result === undefined ? css.contentText : css.resultPreview}
title={record.cell.result === undefined
? listDisplayText
: `${listDisplayText} → ${record.cell.result}`}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{toolCallOnly
? <span className={css.toolCallOnly}>(tool call only)</span>
: toolCallText === undefined
? listDisplayText || '—'
: (
<>
<span className={css.toolCallNameTypeface}>
{toolCallText.name || '—'}
</span>
{toolCallText.args !== undefined && (
<span className={css.toolCallPayload}>
{toolCallText.args}
</span>
)}
</>
)}
</span>
{record.cell.result !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}>→</span>
<span className={record.cell.result === 'No output'
? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText}
>
{record.cell.result}
</span>
</span>
)}
{record.turn === null
? sectionLabel(record.turn)
: (
<>
<span className={css.turnLabelFull} aria-hidden="true">
{sectionLabel(record.turn)}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
</>
)}
</span>
)}
</td>
</tr>
)
})}
<div className={css.eventInner}>
{!isCollapsedSummary && !isRequestOnly && (
<span
className={css.kindSlot}
>
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
</Tooltip>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</span>
</span>
)}
</div>
</td>
<td className={css.content}>
{isRequestOnly
? null
: record.collapsedSummary !== undefined
? (
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
<span className={css.collapsedTurnEllipsis}>…</span>
<span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
</span>
)
: (
<span
className={resultText === undefined ? css.contentText : css.resultPreview}
title={resultText === undefined
? listDisplayText
: `${listDisplayText} → ${resultText}`}
>
<span className={resultText === undefined ? undefined : css.resultRequest}>
<RecordListText
displayText={displayText}
toolCallOnly={toolCallOnly}
toolCallText={toolCallText}
/>
</span>
{resultText !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}>→</span>
<span className={resultText === 'No output'
? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText}
>
{resultText}
</span>
</span>
)}
</span>
)}
</td>
</tr>
)
}}
</RecordPresentation>
))}
{virtualBottom > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
<td
@@ -687,7 +687,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
return (
<Tooltip
key={span.index}
label={timelineTooltipLabel(span.kind, detail)}
label={() => timelineTooltipLabel(span.kind, detail)}
side="bottom"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
@@ -4,12 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
SessionHistoryFace, SnapshotStore,
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
} from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
@@ -27,10 +24,13 @@ import {
type TrajectoryTimeRange,
} from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import { TrajectorySearchIndex } from './trajectory-search-index.ts'
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
import css from './views.module.css'
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
const SEARCH_INDEX_THROTTLE_MS = 3_000
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0
@@ -64,14 +64,12 @@ function partialStructureSignature(partial: ConversationSnapshot['partial']): st
: block.kind).join('\u0000')
}
/** Session-history paging needed by the event-complete trajectory view. */
/** Session-bound controls not already supplied by the conversation view slot. */
export interface TrajectoryViewInjected {
hooks: {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadHistoryTail: (signal: AbortSignal) => Promise<void>
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
loadOlder: () => Promise<boolean>
setActualDuration: (actualDuration: boolean) => void
/** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */
exportLog: () => Promise<void>
@@ -121,86 +119,23 @@ function addUsage(
}
}
function searchableJson(value: unknown): string {
if (value === undefined) return ''
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function searchMatches(
turns: ReturnType<typeof deriveTrajectoryLayout>,
query: string,
): ReadonlySet<number> | null {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return null
const matches = new Set<number>()
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (cell.requestOnly === true) continue
const blocks = [
...(cell.sourceBlocks ?? []),
...(cell.outputBlocks ?? []),
]
const text = [
turn.turn === null ? 'between turns' : `turn ${turn.turn}`,
group.title,
cell.kind,
cell.kind === 'message' ? 'assistant' : undefined,
cell.text,
cell.inputDetail,
cell.outputDetail,
cell.thinkingDetail,
cell.schemaDetail,
cell.result,
cell.callId,
...blocks.flatMap(block => [
block.type,
block.content,
block.callId,
block.toolName,
block.imageAlt,
]),
searchableJson(cell.messageSource),
searchableJson(cell.promptDetail),
searchableJson(cell.previousPromptDetail),
].filter((value): value is string => typeof value === 'string')
.join('\n')
.toLocaleLowerCase()
if (terms.every(term => text.includes(term))) matches.add(cell.index)
}
}
}
return matches
}
function mergeSearchMatches(
finalized: ReadonlySet<number> | null,
partial: ReadonlySet<number> | null,
): ReadonlySet<number> | null {
if (finalized === null || partial === null) return null
return new Set([...finalized, ...partial])
}
export function TrajectoryView({
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, exportLog,
useSession, useDuration, loadOlder, setActualDuration, exportLog,
inspect, onInspectDone, t,
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [exporting, setExporting] = useState(false)
const [exportError, setExportError] = useState<string | null>(null)
const [searchIndex] = useState(() => new TrajectorySearchIndex())
const [searchIndexRevision, setSearchIndexRevision] = useState(0)
const searchIndexTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchIndexInitialized = useRef(false)
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
@@ -208,60 +143,20 @@ export function TrajectoryView({
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
readonly index: number
} | null>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const historyLoading = useHistory(snapshot =>
snapshot.state === 'cold' || snapshot.state === 'loading')
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
const inspection = useSession(snapshot =>
snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
const historyLoading = useSession(snapshot =>
snapshot.openState === 'loading' || snapshot.loadingOlder)
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
const nodes = inspection.eventNodes
const eventLocations = inspection.eventLocations
const historyBaseSeq = nodes[0]?.seq ?? 0
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
const controller = new AbortController()
historyControllerRef.current = controller
void loadHistoryTailRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
const interruptedNodes = inspection.interruptedNodes
const contexts = useMemo<readonly ConversationContext[]>(
() => historyContexts.length === 0
? [{ id: 0, nodes }]
: historyContexts,
[historyContexts, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
[contexts],
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of interruptedNodes) {
selected.set(node.seq, node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch.nodes, interruptedNodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
@@ -357,24 +252,25 @@ export function TrajectoryView({
return numbered
}, [
contexts, nodes, requests,
nodes, requests,
])
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes: selectedNodes,
nodes,
eventLocations,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests: selectedRequests,
requests,
callSchemas,
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas,
nodes, eventLocations, partialTurn, partialStep,
runningCalls, requests, callSchemas,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
@@ -392,31 +288,60 @@ export function TrajectoryView({
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const finalizedSearchMatches = useMemo(
() => searchMatches(finalized.turns, searchQuery),
[finalized, searchQuery],
)
const partialSearchTurns = useMemo(
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
[finalized.lastIndex, partial],
)
const searchLayouts = useMemo(
() => [finalized.turns, partialSearchTurns] as const,
[finalized, partialSearchTurns],
)
const latestSearchLayouts = useRef(searchLayouts)
latestSearchLayouts.current = searchLayouts
useEffect(() => {
if (!searchIndexInitialized.current) {
searchIndexInitialized.current = true
if (searchIndex.update(searchLayouts)) {
setSearchIndexRevision(revision => revision + 1)
}
return
}
if (searchIndexTimer.current !== null) return
searchIndexTimer.current = setTimeout(() => {
searchIndexTimer.current = null
if (searchIndex.update(latestSearchLayouts.current)) {
setSearchIndexRevision(revision => revision + 1)
}
}, SEARCH_INDEX_THROTTLE_MS)
}, [searchIndex, searchLayouts])
useEffect(() => () => {
if (searchIndexTimer.current !== null) clearTimeout(searchIndexTimer.current)
}, [])
const streamingCells = useMemo(
() => partialSearchTurns.flatMap(turn =>
turn.groups.flatMap(group => group.cells),
),
[partialSearchTurns],
)
const partialSearchMatches = useMemo(
() => searchMatches(partialSearchTurns, searchQuery),
[partialSearchTurns, searchQuery],
const searchMatchRecordIds = useMemo(
() => searchIndex.search(searchQuery),
[searchIndex, searchIndexRevision, searchQuery],
)
const searchMatchIndexes = useMemo(
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
)
const timelineRange = timelineSelection?.branchKey === currentBranch.key
? timelineSelection.range
: null
const searchMatchIndexes = useMemo(() => {
if (searchMatchRecordIds === null) return null
const indexes = new Set<number>()
for (const turns of searchLayouts) {
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (searchMatchRecordIds.has(trajectoryRecordId(cell))) indexes.add(cell.index)
}
}
}
}
return indexes
}, [searchLayouts, searchMatchRecordIds])
const timelineRange = timelineSelection
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
@@ -432,11 +357,8 @@ export function TrajectoryView({
}
}, [timelineFocusIndexes])
const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
setTimelineSelection(range === null ? null : {
branchKey: currentBranch.key,
range,
})
}, [currentBranch.key])
setTimelineSelection(range)
}, [])
const handleTimelineRecordSelect = useCallback((index: number) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
@@ -522,11 +444,8 @@ export function TrajectoryView({
}
const loadEarlierHistory = useCallback(() => {
const signal = historyControllerRef.current?.signal
return signal?.aborted === false
? loadOlderHistory(signal)
: Promise.resolve(false)
}, [loadOlderHistory])
return loadOlder()
}, [loadOlder])
const onExport = useCallback(() => {
if (exporting) return
@@ -584,7 +503,6 @@ export function TrajectoryView({
/>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={timelineTurns}
streamingCells={streamingCells}
@@ -1,122 +0,0 @@
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
/** Identity stable when older context generations are prepended. */
key: string
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
/** Seq that opened this branch; earlier requests require retained cited surface events. */
startSeq: number
/** Exact pre-rewind surface records inherited by this branch. */
retainedSurfaceSeqs: ReadonlySet<number>
}
interface MutableBranch {
id: number
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
startSeq: number
retainedSurfaceSeqs: Set<number>
}
function isCompactionCheckpoint(node: ConversationNode): boolean {
if (node.kind !== 'context') return false
const source = node.source
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}
/**
* Join context generations across compaction/rewrite operations and split only at rewind.
* @param contexts - Append-only context generations from the runtime fold.
* @returns Rewind-delimited branches in creation order.
*/
export function deriveTrajectoryContextBranches(
contexts: readonly ConversationContext[],
): readonly TrajectoryContextBranch[] {
const mutable: MutableBranch[] = []
for (const context of contexts) {
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
if (startsBranch) {
const previous = mutable.at(-1)
const retainedSurfaceSeqs = new Set(
context.nodes
.filter(node =>
context.originSeq !== undefined && node.seq < context.originSeq,
)
.map(node => node.seq),
)
const inheritedNodes = previous === undefined
? []
: [...previous.nodes.values()].filter(node =>
retainedSurfaceSeqs.has(node.seq),
)
mutable.push({
id: context.id,
key: context.origin === 'rewind' && context.originSeq !== undefined
? `rewind:${context.originSeq}`
: 'root',
contexts: [context],
latest: context,
nodes: new Map(
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
.map(node => [node.seq, node]),
),
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
retainedSurfaceSeqs,
})
continue
}
const branch = mutable.at(-1)
if (branch === undefined) continue
branch.contexts.push(context)
branch.latest = context
for (const node of context.nodes) {
if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node)
}
}
return mutable.map(branch => ({
id: branch.id,
key: branch.key,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
startSeq: branch.startSeq,
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
}))
}
/**
* Test whether a provider request belongs to one rewind branch.
* @param branch - Branch carrying the exact inherited surface event seqs.
* @param request - Provider request to classify.
* @returns Whether the request began on this branch or produced a retained surface record.
*/
export function trajectoryBranchContainsRequest(
branch: TrajectoryContextBranch,
request: RequestView,
): boolean {
if (request.startSeq >= branch.startSeq) return true
return (
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.purpose === 'compaction'
&&
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)
}
@@ -2,7 +2,7 @@
* Browser trajectory plugin contributing one entry to the conversation view
* slot without defining a service.
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -10,12 +10,18 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
// owning package) must be in the program for the register calls to type.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createTrajectoryDurationStore } from './duration-store.ts'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
import { en, NS, zh } from './locales.ts'
import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
import { en, NS, zh } from './locales.ts'
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts'
import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts'
import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts'
import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
/** Required services: the conversation view slot, the independent history source, and the locale service. */
export const inject = ['slots', 'sessionHistory', 'locale']
/** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale']
/**
* Client plugin body: register the trajectory view tab. The registration
@@ -29,6 +35,12 @@ export function apply(ctx: Context): void {
// re-registration.
const t = ctx.locale.bind(NS)
const duration = createTrajectoryDurationStore()
registerTrajectoryMessageDefinitions(ctx)
registerTrajectoryRequestHeaderDefinition(ctx)
registerTrajectoryAssistantDefinition(ctx)
registerTrajectoryToolDefinition(ctx)
registerTrajectoryCompactionDefinitions(ctx)
registerTrajectoryConversationView(ctx)
ctx.slots.inject('conversation.view', () => ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
@@ -36,11 +48,17 @@ export function apply(ctx: Context): void {
locale: NS,
label: () => t('view.trajectory'),
inject: (sessionId: SessionId): TrajectoryViewInjected => {
const history = ctx.sessionHistory.source(sessionId)
const session = ctx.sessions.binding(sessionId)?.session
if (session === undefined) {
throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`)
}
return {
hooks: { history, duration },
loadHistoryTail: signal => history.loadTail(signal),
loadOlderHistory: signal => history.loadOlder(signal),
hooks: { duration },
loadOlder: async () => {
const before = session.getSnapshot().views.get('trajectory')
await session.loadOlder()
return session.getSnapshot().views.get('trajectory') !== before
},
setActualDuration: (value) => { duration.set(value) },
exportLog: async () => {
// The host streams the ZIP (root + descendant artifacts verbatim)
@@ -5,6 +5,7 @@
import type {
AssistantBlock,
AssistantMessageNode,
ConversationLocation,
ConversationSnapshot,
RequestInspectionSnapshot,
RequestPromptChange,
@@ -12,7 +13,6 @@ import type {
ToolCallBlock,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
TrajectoryCellProps,
TrajectorySourceBlock,
@@ -35,6 +35,7 @@ export interface TrajectoryTurnModel {
/** Snapshot slice the trajectory view folds. */
export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
eventLocations?: ReadonlyMap<number, ConversationLocation>
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[]
@@ -70,12 +71,9 @@ interface TurnBucket {
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' | 'context' }
{ kind: 'user' | 'steering' | 'context' }
>
type OrderedLayoutEntry =
@@ -110,10 +108,19 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number {
function inputCellDetail(node: InputNode): Pick<
TrajectoryCellProps,
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
| 'text'
| 'previewMarkdown'
| 'sourceSeq'
| 'messageSource'
| 'inputDetail'
| 'sourceBlocks'
| 'timeSeconds'
| 'startedAt'
> {
const previewMarkdown = previewContent(node.content)
return {
text: summarizeContent(node.content),
text: '',
...(previewMarkdown === undefined ? {} : { previewMarkdown }),
sourceSeq: node.seq,
messageSource: node.source,
inputDetail: detailContent(node.content),
@@ -130,12 +137,13 @@ function inputCellDetail(node: InputNode): Pick<
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const {
nodes, partial, runningCalls, requests = [], callSchemas,
nodes, eventLocations, partial, runningCalls, requests = [], callSchemas,
} = input
const resultByCall = indexResults(nodes)
const callById = new Map<string, ToolCallBlock>(resultByCall)
for (const call of runningCalls) callById.set(call.callId, call)
const emittedCallIds = indexAssistantCallIds(nodes)
const followingAssistants = indexFollowingAssistants(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime)
@@ -180,6 +188,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
}
groups.push({ title, laid: [...laid] })
}
const pushStepInput = (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) {
groups.push({ title, laid: [...laid] })
return
}
const request = existing.laid.findIndex(entry => entry.cell.requestOnly === true)
if (request === -1) existing.laid.push(...laid)
else existing.laid.splice(request, 0, ...laid)
}
const representedRequests = new Set<string>()
for (const node of nodes) {
@@ -293,7 +314,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
? request.error ?? 'Compaction failed'
: request.summary === undefined
? 'Context compacted'
: summarizeContent(request.summary),
: '',
...(request.status === 'complete' && request.summary !== undefined
? previewContentProperty(request.summary)
: {}),
sourceSeq: request.startSeq,
...(request.summary === undefined
? {}
@@ -330,7 +354,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (node.kind === 'user') {
// 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 = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
@@ -343,6 +367,26 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'steering') {
const placement = steeringPlacement(
followingAssistants[i],
partial,
lastAssistantTurn,
eventLocations?.get(node.seq),
)
const laid = {
absTime: finiteTime(node.time),
cell: {
index: ++index,
kind: 'user' as const,
...inputCellDetail(node),
},
}
if (placement.step === undefined) pushMessage(placement.turn, laid)
else pushStepInput(placement.turn, placement.step, [laid])
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
@@ -356,7 +400,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'context') {
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
@@ -377,6 +421,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (node.kind === 'tool-result') {
if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name
const resultPreview = summarizeResult(node)
const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
@@ -386,13 +431,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index,
kind: 'tool',
sourceSeq: node.seq,
text: node.call !== null
...(node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node),
: resultAsText(resultPreview)),
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
outputDetail: detailResult(node),
outputBlocks: node.content.map(block => sourceBlock(block)),
result: summarizeResult(node),
...resultPreview,
callId: node.callId,
isError: node.isError,
timeSeconds: durationSeconds(node.time, node.callTime),
@@ -440,7 +485,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
cell: {
index: ++index,
kind: 'tool',
text: summarizeCall(call.name, call.argsRaw),
...summarizeCall(call.name, call.argsRaw),
inputDetail: call.argsRaw,
callId: call.callId,
timeSeconds: null,
@@ -650,11 +695,14 @@ function expandAssistant(
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''
? summarizeText(messageText)
text: messageText !== '' || thinkingText !== ''
? ''
: summarizeAssistantActivity(node.blocks),
...(messageText !== ''
? { previewMarkdown: messageText }
: thinkingText !== ''
? summarizeText(thinkingText)
: summarizeAssistantActivity(node.blocks),
? { previewMarkdown: thinkingText }
: {}),
...(messageText !== '' ? { outputDetail: messageText } : {}),
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
@@ -681,6 +729,7 @@ function expandAssistant(
: durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId))
const call = calls.get(block.callId)
const resultPreview = result === undefined ? undefined : summarizeResult(result)
out.push({
absTime: callAbs,
toolName: block.name,
@@ -688,14 +737,14 @@ function expandAssistant(
...(call === undefined ? {} : { subCalls: call.subCalls }),
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
...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),
...resultPreview,
isError: result.isError,
}
: {}),
@@ -808,22 +857,53 @@ function stringifySourceValue(value: unknown): string {
* in-flight partial, else the turn after the last finalized assistant (or 1).
*/
function enclosingUserTurn(
nodes: ConversationSnapshot['nodes'],
userIndex: number,
followingAssistant: AssistantMessageNode | undefined,
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') return n.turn
}
if (followingAssistant !== undefined) return followingAssistant.turn
if (partial !== null) return partial.turn
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
return 1
}
function steeringPlacement(
followingAssistant: AssistantMessageNode | undefined,
partial: ConversationSnapshot['partial'],
lastAssistantTurn: number | null,
location: ConversationLocation | undefined,
): { turn: number; step?: number } {
if (location?.kind === 'step') {
return { turn: location.turn.turn, step: location.step.step }
}
const locatedTurn = location?.kind === 'turn' ? location.turn.turn : undefined
if (followingAssistant !== undefined
&& (locatedTurn === undefined || followingAssistant.turn === locatedTurn)) {
return {
turn: followingAssistant.turn,
...(followingAssistant.step > 0 ? { step: followingAssistant.step } : {}),
}
}
if (partial !== null && (locatedTurn === undefined || partial.turn === locatedTurn)) {
return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) }
}
if (locatedTurn !== undefined) return { turn: locatedTurn }
return { turn: lastAssistantTurn ?? 1 }
}
function indexFollowingAssistants(
nodes: ConversationSnapshot['nodes'],
): readonly (AssistantMessageNode | undefined)[] {
const following = new Array<AssistantMessageNode | undefined>(nodes.length)
let assistant: AssistantMessageNode | undefined
for (let index = nodes.length - 1; index >= 0; index--) {
following[index] = assistant
const node = nodes[index]
if (node?.kind === 'assistant') assistant = node
}
return following
}
function enclosingPromptTurn(
nodes: ConversationSnapshot['nodes'],
seq: number,
@@ -919,6 +999,7 @@ function expandSubCalls(
let index = startIndex
for (const sub of subs) {
const settled = 'kind' in sub
const resultPreview = settled ? summarizeResult(sub) : undefined
const laid: LaidCell = {
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
@@ -927,9 +1008,11 @@ function expandSubCalls(
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
? summarizeCall(sub.call.name, sub.call.argsRaw)
: resultAsText(resultPreview))
: summarizeCall(sub.name, sub.argsRaw)),
...(settled
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
: { inputDetail: sub.argsRaw }),
@@ -937,7 +1020,7 @@ function expandSubCalls(
? {
outputDetail: detailResult(sub),
outputBlocks: sub.content.map(block => sourceBlock(block)),
result: summarizeResult(sub),
...resultPreview,
isError: sub.isError,
}
: {}),
@@ -958,22 +1041,39 @@ function expandSubCalls(
return out
}
function summarizeCall(name: string, argsRaw: string): string {
const args = trajectoryPreviewText(argsRaw)
if (args === '') return name
return `${name} · ${args}`
function summarizeCall(
name: string,
argsRaw: string,
): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> {
return {
text: name,
...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }),
}
}
function summarizeResult(node: ToolResultNode): string {
function summarizeResult(
node: ToolResultNode,
): Pick<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> {
if (node.isError) {
return node.error?.code ?? 'error'
return { result: 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 { result: '', resultPreviewMarkdown: block.text }
}
}
return 'No output'
return { result: 'No output' }
}
function resultAsText(
result: Pick<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> | undefined,
): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> {
return {
text: result?.result ?? '',
...(result?.resultPreviewMarkdown === undefined
? {}
: { previewMarkdown: result.resultPreviewMarkdown }),
}
}
function detailResult(node: ToolResultNode): string {
@@ -1009,28 +1109,18 @@ function detailReasoning(content: readonly { type: string; text?: string }[]): s
.join('\n')
}
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
function previewContent(
content: readonly { type: string; text?: string }[],
): string | undefined {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
if (block.type === 'text' && typeof block.text === 'string') return block.text
}
return ''
return undefined
}
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
function previewContentProperty(
content: readonly { type: string; text?: string }[],
): Pick<TrajectoryCellProps, 'previewMarkdown'> {
const previewMarkdown = previewContent(content)
return previewMarkdown === undefined ? {} : { previewMarkdown }
}
@@ -0,0 +1,405 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
toAssistantBlocks,
} from '@deepseek-ai/dsh-client-runtime/client'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
interface UsageValue {
readonly inputTokens: number
readonly outputTokens: number
readonly cacheReadTokens?: number
readonly cacheWriteTokens?: number
readonly reasoningTokens?: number
}
interface RetryValue {
readonly message: string
readonly retry: number
readonly maxRetries?: number
readonly delayMs: number
}
interface AssistantState {
readonly turn: number
readonly step: number
readonly startSeq: number
readonly startTime: number
readonly started: boolean
readonly sawChunk: boolean
readonly blocks: readonly (AssistantBlock | undefined)[]
readonly firstVisibleSeq: number | undefined
readonly firstVisibleTime: number | undefined
readonly firstTokenTime: number | undefined
readonly final: ConversationMatch | undefined
readonly usage: UsageValue | undefined
readonly retry: RetryValue | undefined
readonly stepEnd: ConversationMatch | undefined
}
function initialState(
turn: number,
step: number,
startSeq: number,
startTime: number,
started: boolean,
): AssistantState {
return {
turn,
step,
startSeq,
startTime,
started,
sawChunk: false,
blocks: [],
firstVisibleSeq: undefined,
firstVisibleTime: undefined,
firstTokenTime: undefined,
final: undefined,
usage: undefined,
retry: undefined,
stepEnd: undefined,
}
}
function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] {
return blocks.filter((block): block is AssistantBlock => block !== undefined)
}
function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
return blocks.some((block) => {
if (block.kind === 'tool-call') return false
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
})
}
function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean {
return blocks.some((block) => {
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
})
}
function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue {
return {
inputTokens: (current?.inputTokens ?? 0) + next.inputTokens,
outputTokens: (current?.outputTokens ?? 0) + next.outputTokens,
...(current?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
? {}
: { cacheReadTokens: (current?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0) }),
...(current?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
? {}
: { cacheWriteTokens: (current?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0) }),
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
}
}
function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState {
if (match.event.type !== 'assistant/chunk') return state
const chunk = match.event.data.chunk
if (chunk.type === 'usage') {
return { ...state, sawChunk: true, usage: addUsage(state.usage, chunk.usage) }
}
const blocks = [...state.blocks]
switch (chunk.type) {
case 'block-start':
blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
break
case 'text-delta': {
const previous = blocks[chunk.index]
blocks[chunk.index] = {
kind: 'text',
text: (previous?.kind === 'text' ? previous.text : '') + chunk.text,
}
break
}
case 'reasoning-delta': {
const previous = blocks[chunk.index]
blocks[chunk.index] = {
kind: 'reasoning',
text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text,
}
break
}
case 'tool-call-delta': {
const previous = blocks[chunk.index]
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
blocks[chunk.index] = {
kind: 'tool-call',
callId: base.callId || String(chunk.id),
name: chunk.name ?? base.name,
argsRaw: base.argsRaw + chunk.argumentsDelta,
}
break
}
case 'block-end':
blocks[chunk.index] = toAssistantBlock(chunk.block)
break
default:
return { ...state, sawChunk: true }
}
const visible = hasVisibleContent(compactBlocks(blocks))
return {
...state,
sawChunk: true,
blocks,
...(visible && state.firstVisibleSeq === undefined
? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
: {}),
...(isTokenDelta(chunk) && state.firstTokenTime === undefined
? { firstTokenTime: match.event.time }
: {}),
}
}
function closedBoundary(
context: ConversationNodeContext<AssistantState>,
): { seq: number; time: number } | undefined {
if (context.state?.stepEnd?.event.type === 'step/end') return context.state.stepEnd.event
const location: ConversationLocation | undefined = context.start?.location
?? context.matches.at(-1)?.location
if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
if ((location?.kind === 'step' || location?.kind === 'turn')
&& location.turn.status === 'closed') return location.turn.end
return undefined
}
function fallbackState(context: ConversationNodeContext<AssistantState>): AssistantState | undefined {
let state: AssistantState | undefined
for (const match of context.matches) {
const event = match.event
if (event.type === 'assistant/chunk') {
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
state = updateChunk(state, match)
} else if (event.type === 'assistant/message') {
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
state = {
...state,
blocks: toAssistantBlocks(event.data.message.content),
final: match,
usage: state.usage ?? event.data.usage,
}
} else if (event.type === 'step/end' && state !== undefined) {
state = { ...state, stepEnd: match }
}
}
return state
}
function finalNode(
state: AssistantState,
context: ConversationNodeContext<AssistantState>,
): AssistantMessageNode | undefined {
const final = state.final
if (final?.event.type === 'assistant/message') {
const event = final.event
return {
kind: 'assistant',
seq: event.seq,
time: event.time,
turn: state.turn,
step: state.step,
blocks: toAssistantBlocks(event.data.message.content),
usage: event.data.usage,
provenance: {
provider: event.data.message.source.provider,
model: event.data.message.source.model,
},
timing: {
stepStartTime: state.started ? state.startTime : null,
firstTokenTime: state.firstTokenTime ?? null,
completedTime: event.time,
},
}
}
const boundary = closedBoundary(context)
const blocks = compactBlocks(state.blocks)
if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
return {
kind: 'assistant',
seq: boundary.seq - 0.9,
time: boundary.time,
turn: state.turn,
step: state.step,
blocks,
interrupted: true,
}
}
function assistantRequest(
state: AssistantState,
node: AssistantMessageNode | undefined,
boundary: { seq: number; time: number } | undefined,
): Extract<RequestView, { purpose: 'assistant' }> | undefined {
if (!state.started) return undefined
const status = node !== undefined && node.interrupted !== true
? 'complete'
: state.retry !== undefined || boundary !== undefined ? 'error' : 'running'
return {
purpose: 'assistant',
startSeq: state.startSeq,
turn: state.turn,
step: state.step,
startedAt: state.startTime,
completedAt: node?.time ?? boundary?.time ?? null,
status,
...(state.retry === undefined
? {}
: {
error: state.retry.message,
retry: state.retry.retry,
...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }),
retryDelayMs: state.retry.delayMs,
}),
...(node === undefined || node.interrupted === true
? {}
: {
resultSeq: node.seq,
...(node.provenance === undefined ? {} : { provenance: node.provenance }),
}),
...(state.usage === undefined ? {} : { usage: state.usage }),
}
}
/** Trajectory-owned Assistant streaming, settlement, and request lifecycle. */
const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState> = {
kind: 'trajectory-assistant-step',
target: 'trajectory',
match: (event) => {
if (event.type === 'step/start') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
}
if (event.type === 'assistant/chunk'
|| event.type === 'assistant/message'
|| event.type === 'llm/retry'
|| event.type === 'step/end') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'step/start') {
throw new Error('trajectory-assistant-step start requires step/start')
}
return initialState(
match.event.data.turn,
match.event.data.step,
match.event.seq,
match.event.time,
true,
)
},
update: (context, match) => {
if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
if (match.event.type === 'assistant/message') {
return {
...context.state,
blocks: toAssistantBlocks(match.event.data.message.content),
final: match,
usage: context.state.usage ?? match.event.data.usage,
}
}
if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
if (match.event.type !== 'llm/retry') return context.state
const data = match.event.data
return {
...initialState(
context.state.turn,
context.state.step,
context.state.startSeq,
context.state.startTime,
true,
),
firstTokenTime: context.state.firstTokenTime,
usage: context.state.usage,
retry: {
message: displayFailureMessage(data.failure),
retry: data.retry,
...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}),
delayMs: data.delayMs,
},
}
},
publication: (match) => {
if (match.event.type === 'step/start') return 'none'
if (match.event.type !== 'assistant/chunk') return 'immediate'
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
},
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const node = finalNode(state, context)
const boundary = closedBoundary(context)
const partial: PartialAssistant | null = node === undefined && boundary === undefined && state.sawChunk
? { turn: state.turn, step: state.step, blocks: compactBlocks(state.blocks) }
: null
const request = assistantRequest(state, node, boundary)
if (node === undefined && partial === null && request === undefined) return null
return trajectoryNode(context, state.startSeq, {
kind: 'assistant',
...(node === undefined ? {} : { node }),
partial,
...(request === undefined ? {} : { request }),
})
},
}
interface TurnEndState {
readonly turn: number
readonly seq: number
readonly time: number
readonly error?: string
}
const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
kind: 'trajectory-turn-end',
target: 'trajectory',
match: event => event.type === 'turn/end'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match) => {
if (match.event.type !== 'turn/end') {
throw new Error('trajectory-turn-end start requires turn/end')
}
const reason = match.event.data.reason
return {
turn: match.event.data.turn,
seq: match.event.seq,
time: match.event.time,
...(reason.kind === 'error' ? { error: displayFailureMessage(reason.error) } : {}),
}
},
update: context => context.state,
buildViewNode: context => context.state === undefined
? null
: trajectoryNode(context, context.state.seq, {
kind: 'turn-end',
turn: context.state.turn,
time: context.state.time,
...(context.state.error === undefined ? {} : { error: context.state.error }),
}),
}
/* jscpd:ignore-end */
/**
* Register the Trajectory Assistant lifecycle.
*
* @param ctx - Plugin context receiving the Definitions.
*/
export function registerTrajectoryAssistantDefinition(ctx: Context): void {
ctx.conversationEvents.register(trajectoryAssistantDefinition)
ctx.conversationEvents.register(trajectoryTurnEndDefinition)
}
@@ -0,0 +1,143 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeDefinition, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-compact/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
interface CompactionState {
readonly start: ConversationMatch
readonly summary?: ConversationMatch
readonly end?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
function checkpointId(
event: Parameters<ConversationNodeDefinition['match']>[0],
): string | undefined {
if (event.type !== 'user/message') return undefined
const source = event.data.source as unknown as {
readonly kind?: unknown
readonly plugin?: unknown
readonly compactionId?: unknown
}
return source.kind === 'plugin' && source.plugin === 'compact'
&& typeof source.compactionId === 'string' && source.compactionId !== ''
? source.compactionId
: undefined
}
function eventCompactionId(
event: Parameters<ConversationNodeDefinition['match']>[0],
): string | undefined {
if (event.type !== 'compact/start'
&& event.type !== 'compact/summary'
&& event.type !== 'compact/end') return undefined
const value: unknown = event.data.compactionId
return typeof value === 'string' && value !== '' ? value : undefined
}
function requestFromState(
state: CompactionState,
): Extract<RequestView, { purpose: 'compaction' }> | undefined {
const start = state.start.event
if (start.type !== 'compact/start') return undefined
const summary = state.summary?.event
const end = state.end?.event
const checkpoint = state.checkpoint?.event
return {
purpose: 'compaction',
startSeq: start.seq,
turn: start.data.turn,
step: 0,
startedAt: start.time,
completedAt: end?.type === 'compact/end' ? end.time : null,
status: end?.type !== 'compact/end'
? 'running'
: end.data.error === undefined ? 'complete' : 'error',
...(end?.type === 'compact/end' && end.data.error !== undefined
? { error: end.data.error }
: {}),
...(summary?.type !== 'compact/summary'
? {}
: {
resultSeq: summary.seq,
summary: summary.data.summary,
...(summary.data.rawOutput === undefined ? {} : { rawOutput: summary.data.rawOutput }),
provenance: { provider: summary.data.provider, model: summary.data.model },
requestConfig: {
provider: summary.data.provider,
model: summary.data.model,
purpose: 'compaction',
...(summary.data.maxTokens === undefined ? {} : { maxTokens: summary.data.maxTokens }),
},
...(summary.data.usage === undefined ? {} : { usage: summary.data.usage }),
}),
...(checkpoint?.type === 'user/message' ? { replacementSeq: checkpoint.seq } : {}),
}
}
const trajectoryCompactionDefinition: ConversationNodeDefinition<CompactionState> = {
kind: 'trajectory-compaction',
target: 'trajectory',
match: (event) => {
const compactId = eventCompactionId(event)
if (compactId !== undefined) {
return { id: compactId, role: event.type === 'compact/start' ? 'start' : 'update' }
}
const checkpoint = checkpointId(event)
return checkpoint === undefined ? null : { id: checkpoint, role: 'update' }
},
start: (_context, match) => {
if (match.event.type !== 'compact/start') {
throw new Error('trajectory-compaction start requires compact/start')
}
return { start: match }
},
update: (context, match) => {
if (match.event.type === 'compact/summary') return { ...context.state, summary: match }
if (match.event.type === 'compact/end') return { ...context.state, end: match }
return checkpointId(match.event) === undefined
? context.state
: { ...context.state, checkpoint: match }
},
buildViewNode: (context) => {
if (context.state === undefined) return null
const request = requestFromState(context.state)
return request === undefined
? null
: trajectoryNode(context, request.startSeq, { kind: 'compaction', request })
},
}
interface SessionEndState {
readonly seq: number
readonly time: number
}
const trajectorySessionEndDefinition: ConversationNodeDefinition<SessionEndState> = {
kind: 'trajectory-session-end',
target: 'trajectory',
match: event => event.type === 'session/end-seed'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match) => ({ seq: match.event.seq, time: match.event.time }),
update: context => context.state,
buildViewNode: context => context.state === undefined
? null
: trajectoryNode(context, context.state.seq, {
kind: 'session-end',
seq: context.state.seq,
time: context.state.time,
}),
}
/**
* Register Trajectory compaction requests and session boundaries.
*
* @param ctx - Plugin context receiving the Definitions.
*/
export function registerTrajectoryCompactionDefinitions(ctx: Context): void {
ctx.conversationEvents.register(trajectoryCompactionDefinition)
ctx.conversationEvents.register(trajectorySessionEndDefinition)
}
@@ -0,0 +1,75 @@
import type {
AssistantMessageNode, ConversationLocation, ConversationNode,
ConversationPromptSnapshot, ConversationViewNode, PartialAssistant,
RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Request-header facts retained by the Trajectory target. */
export interface TrajectoryRequestHeaderState {
readonly seq: number
readonly time: number
readonly prompt: ConversationPromptSnapshot
readonly change?: RequestPromptChange
readonly location: ConversationLocation
}
/** One independently assembled contribution to the legacy Trajectory ledger. */
export type TrajectoryContribution =
| {
readonly kind: 'node'
readonly node: ConversationNode
}
| {
readonly kind: 'assistant'
readonly node?: AssistantMessageNode
readonly partial: PartialAssistant | null
readonly request?: Extract<RequestView, { purpose: 'assistant' }>
}
| {
readonly kind: 'tool'
readonly root: ToolCallBlock
}
| {
readonly kind: 'request-header'
readonly header: TrajectoryRequestHeaderState
}
| {
readonly kind: 'compaction'
readonly request: Extract<RequestView, { purpose: 'compaction' }>
}
| {
readonly kind: 'session-end'
readonly seq: number
readonly time: number
}
| {
readonly kind: 'turn-end'
readonly turn: number
readonly time: number
readonly error?: string
}
/** Target envelope consumed by the Trajectory snapshot builder. */
export interface TrajectoryConversationViewNode extends ConversationViewNode {
readonly target: 'trajectory'
readonly anchorSeq: number
readonly location: ConversationLocation
readonly data: TrajectoryContribution
}
/** Stage-oriented Trajectory data assembled from registered business Contexts. */
export interface TrajectorySnapshot {
readonly eventNodes: readonly ConversationNode[]
readonly eventLocations: ReadonlyMap<number, ConversationLocation>
readonly requests: readonly RequestView[]
readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
readonly partial: PartialAssistant | null
readonly runningCalls: readonly RunningToolCall[]
}
declare module '@deepseek-ai/dsh-client-runtime/client' {
interface ConversationViewSnapshotMap {
/** Independently assembled data consumed by the Trajectory view. */
trajectory: TrajectorySnapshot
}
}
@@ -0,0 +1,28 @@
import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {
TrajectoryContribution, TrajectoryConversationViewNode,
} from './trajectory-contract.ts'
/**
* Wrap one contribution in the Engine-owned target envelope.
*
* @param context - Context that owns the contribution identity.
* @param anchorSeq - Sequence used to order the contribution.
* @param data - Trajectory-specific contribution payload.
* @returns The contribution wrapped as a Trajectory view node.
*/
export function trajectoryNode(
context: ConversationNodeContext,
anchorSeq: number,
data: TrajectoryContribution,
): TrajectoryConversationViewNode {
return {
key: context.key,
kind: context.kind,
id: context.id,
target: 'trajectory',
anchorSeq,
location: context.start?.location ?? { kind: 'unresolved' },
data,
}
}
@@ -0,0 +1,122 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext,
SteeringMessageNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
contextForm, contextProvenance,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-agent/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
interface InboxIdentity {
readonly id: string
}
interface InboxSplice {
readonly start: number
readonly removedCount?: number
readonly inserted: readonly InboxIdentity[]
readonly outcome?: 'canceled'
}
interface InboxState {
readonly pending: readonly InboxIdentity[]
readonly claimed: ReadonlySet<string>
}
type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
function applySplice(
previous: ConversationPreviousContext<InboxState> | undefined,
splice: InboxSplice,
): InboxState {
const pending = [...(previous?.state.pending ?? [])]
const claimed = new Set(previous?.state.claimed ?? [])
const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
for (const identity of splice.inserted) claimed.delete(identity.id)
if (splice.outcome !== 'canceled') {
for (const identity of removed) claimed.add(identity.id)
}
return { pending, claimed }
}
const trajectoryInboxDefinition: ConversationNodeDefinition<InboxState> = {
kind: 'trajectory-inbox-next-step',
match: event => event.type === 'agent/inbox/spliced'
&& event.data.target === 'next-step'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
if (match.event.type !== 'agent/inbox/spliced') {
throw new Error('trajectory-inbox-next-step start requires agent/inbox/spliced')
}
return applySplice(
reader.previous<InboxState>('trajectory-inbox-next-step'),
match.event.data,
)
},
update: context => context.state,
publication: () => 'none',
}
const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'trajectory-input-message',
target: 'trajectory',
match: event => event.type === 'user/message'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
if (match.event.type !== 'user/message') {
throw new Error('trajectory-input-message start requires user/message')
}
const event = match.event
if (event.data.source.kind !== 'user') {
return {
kind: 'context',
seq: event.seq,
time: event.time,
content: event.data.content,
source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
const claimed = reader.previous<InboxState>('trajectory-inbox-next-step')
?.state.claimed.has(String(event.data.id)) === true
return claimed
? {
kind: 'steering',
messageId: event.data.id,
seq: event.seq,
time: event.time,
content: event.data.content,
source: event.data.source,
}
: {
kind: 'user',
seq: event.seq,
time: event.time,
content: event.data.content,
source: event.data.source,
}
},
update: context => context.state,
buildViewNode: context => context.state === undefined
? null
: trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }),
}
/* jscpd:ignore-end */
/**
* Register Trajectory-owned inbox classification and message records.
*
* @param ctx - Plugin context receiving the Definitions.
*/
export function registerTrajectoryMessageDefinitions(ctx: Context): void {
ctx.conversationEvents.register(trajectoryInboxDefinition)
ctx.conversationEvents.register(trajectoryMessageDefinition)
}
@@ -0,0 +1,20 @@
/** Bounded Markdown-to-text projection shared by trajectory consumers. */
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
/**
* Build a bounded one-line preview without parsing the complete Markdown document.
* @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
}
@@ -40,8 +40,10 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** Projection-stable identity when no single source event owns the record lifecycle. */
recordId?: string
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
/** Non-Markdown summary or prefix; CSS ellipsis when it overflows. */
text: string
/** Raw Markdown source converted into the single-line summary at its consumer. */
previewMarkdown?: string
/** Whether this user record opens a new model turn. */
opensTurn?: boolean
/** Source session-event seq for cross-record navigation. */
@@ -71,6 +73,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
assistantMetrics?: AssistantMetricDetail
/** Tool-only result summary paired with the call in the same record. */
result?: string
/** Raw Markdown source converted into the tool-result summary at its consumer. */
resultPreviewMarkdown?: string
/** Tool call id used to link message source blocks to tool records. */
callId?: string
/** Tool-only result failure state. */
@@ -112,7 +116,8 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string {
*/
export function formatDurationMillis(milliseconds: number | null): string {
if (milliseconds === null || !Number.isFinite(milliseconds)) return '—'
return `${Math.round(milliseconds).toLocaleString('en-US')} ms`
const integer = String(Math.round(milliseconds))
return `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} ms`
}
/**
@@ -0,0 +1,80 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot,
RequestPromptChange,
} from '@deepseek-ai/dsh-client-runtime/client'
import { trajectoryNode } from './trajectory-definition-common.ts'
import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts'
function requestPrompt(match: ConversationMatch): ConversationPromptSnapshot {
if (match.event.type !== 'request/header') {
throw new Error('trajectory-request-header start requires request/header')
}
const header = match.event.data.header
const tools: unknown = header.tools
return {
config: header.config,
system: header.system ?? '',
tools: Array.isArray(tools) ? tools as ConversationPromptSnapshot['tools'] : [],
}
}
function promptChange(
previous: ConversationPromptSnapshot | undefined,
prompt: ConversationPromptSnapshot,
match: ConversationMatch,
): RequestPromptChange | undefined {
if (match.event.type !== 'request/header') return undefined
if (previous === undefined && match.event.data.reason !== 'initial') return undefined
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous !== undefined && !systemChanged && !toolsChanged) return undefined
return {
seq: match.event.seq,
time: match.event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged ? 'system' : 'tools',
...(previous === undefined ? {} : { previous }),
}
}
const trajectoryRequestHeaderDefinition: ConversationNodeDefinition<TrajectoryRequestHeaderState> = {
kind: 'trajectory-request-header',
target: 'trajectory',
match: event => event.type === 'request/header'
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
const prompt = requestPrompt(match)
const previous = reader.previous<TrajectoryRequestHeaderState>('trajectory-request-header')
?.state.prompt
const change = promptChange(previous, prompt, match)
return {
seq: match.event.seq,
time: match.event.time,
prompt,
location: match.location,
...(change === undefined ? {} : { change }),
}
},
update: context => context.state,
buildViewNode: context => context.state === undefined
? null
: trajectoryNode(context, context.state.seq, {
kind: 'request-header',
header: context.state,
}),
}
/**
* Register Trajectory request-header facts.
*
* @param ctx - Plugin context receiving the Definition.
*/
export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void {
ctx.conversationEvents.register(trajectoryRequestHeaderDefinition)
}
@@ -0,0 +1,133 @@
/** Incremental full-text index for the trajectory ledger. */
import type { TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryCellProps } from './trajectory-record.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import { trajectoryPreviewText } from './trajectory-preview.ts'
interface SearchEntry {
readonly sources: readonly string[]
readonly text: string
}
function searchableJson(value: unknown): string {
if (value === undefined) return ''
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function sameSources(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function markdownPreview(cell: TrajectoryCellProps): string {
if (cell.previewMarkdown === undefined) return ''
const preview = trajectoryPreviewText(cell.previewMarkdown)
if (cell.text === '') return preview
return preview === '' ? cell.text : `${cell.text} · ${preview}`
}
function resultPreview(cell: TrajectoryCellProps): string {
return cell.resultPreviewMarkdown === undefined
? cell.result ?? ''
: trajectoryPreviewText(cell.resultPreviewMarkdown)
}
function recordSources(
turn: number | null,
group: string,
cell: TrajectoryCellProps,
): readonly string[] {
const blocks = [
...(cell.sourceBlocks ?? []),
...(cell.outputBlocks ?? []),
]
return [
turn === null ? 'between turns' : `turn ${turn}`,
group,
cell.kind,
cell.kind === 'message' ? 'assistant' : '',
cell.text,
cell.previewMarkdown ?? '',
cell.inputDetail ?? '',
cell.outputDetail ?? '',
cell.thinkingDetail ?? '',
cell.schemaDetail ?? '',
cell.result ?? '',
cell.resultPreviewMarkdown ?? '',
cell.callId ?? '',
...blocks.flatMap(block => [
block.type,
block.content,
block.callId ?? '',
block.toolName ?? '',
block.imageAlt ?? '',
]),
searchableJson(cell.messageSource),
searchableJson(cell.promptDetail),
searchableJson(cell.previousPromptDetail),
]
}
/** Session-view-local index that reparses Markdown only when one record's source changes. */
export class TrajectorySearchIndex {
private readonly entries = new Map<string, SearchEntry>()
private layouts: readonly (readonly TrajectoryTurnModel[])[] | undefined
/**
* Incrementally synchronize one or more current trajectory layout slices.
* @param layouts - Finalized and optional streaming layouts from the same view.
* @returns Whether the indexed layout version changed.
*/
update(layouts: readonly (readonly TrajectoryTurnModel[])[]): boolean {
if (this.layouts === layouts) return false
this.layouts = layouts
const seen = new Set<string>()
for (const turns of layouts) {
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (cell.requestOnly === true) continue
const id = trajectoryRecordId(cell)
const sources = recordSources(turn.turn, group.title, cell)
const previous = this.entries.get(id)
const entry = previous !== undefined && sameSources(previous.sources, sources)
? previous
: {
sources,
text: [
...sources,
markdownPreview(cell),
resultPreview(cell),
].join('\n').toLocaleLowerCase(),
}
this.entries.set(id, entry)
seen.add(id)
}
}
}
}
for (const id of this.entries.keys()) {
if (!seen.has(id)) this.entries.delete(id)
}
return true
}
/**
* Match a query against the latest committed index version.
* @param query - Space-separated case-insensitive search terms.
* @returns Matching stable record identities, or `null` without a query.
*/
search(query: string): ReadonlySet<string> | null {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return null
const matches = new Set<string>()
for (const [id, entry] of this.entries) {
if (terms.every(term => entry.text.includes(term))) matches.add(id)
}
return matches
}
}
@@ -0,0 +1,284 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
AssistantMessageNode, ConversationNode, ConversationPromptSnapshot,
ConversationViewBuilder, ConversationViewDefinition, RequestView,
ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
TrajectoryConversationViewNode, TrajectoryRequestHeaderState,
TrajectorySnapshot,
} from './trajectory-contract.ts'
const EMPTY_LIST: readonly never[] = []
type AssistantRequest = Extract<RequestView, { purpose: 'assistant' }>
type ToolSchema = ConversationPromptSnapshot['tools'][number]
/** Stable empty target used until a Session has assembled Trajectory records. */
export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
eventNodes: EMPTY_LIST,
eventLocations: new Map(),
requests: EMPTY_LIST,
callSchemas: new Map(),
partial: null,
runningCalls: EMPTY_LIST,
}
function stepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function headerStepKey(header: TrajectoryRequestHeaderState): string | undefined {
const location = header.location
return location.kind === 'step'
? stepKey(location.turn.turn, location.step.step)
: undefined
}
function headerFor(
request: AssistantRequest,
headersByStep: ReadonlyMap<string, TrajectoryRequestHeaderState>,
previous: TrajectoryRequestHeaderState | undefined,
): TrajectoryRequestHeaderState | undefined {
return headersByStep.get(stepKey(request.turn, request.step))
?? (previous !== undefined && previous.seq < request.startSeq ? previous : undefined)
}
function applyHeader(
request: AssistantRequest,
header: TrajectoryRequestHeaderState | undefined,
includeChange: boolean,
): AssistantRequest {
return header === undefined
? request
: {
...request,
prompt: header.prompt,
requestConfig: header.prompt.config,
...(includeChange && header.change !== undefined ? { promptChange: header.change } : {}),
}
}
function withRequestConfig(
node: AssistantMessageNode,
prompt: ConversationPromptSnapshot | undefined,
): AssistantMessageNode {
return prompt === undefined ? node : { ...node, requestConfig: prompt.config }
}
function captureSchemas(
block: ToolCallBlock,
toolsByName: ReadonlyMap<string, ToolSchema>,
output: Map<string, ToolSchema>,
): void {
const name = 'kind' in block ? block.call?.name : block.name
const schema = name === undefined ? undefined : toolsByName.get(name)
if (schema !== undefined) output.set(block.callId, schema)
for (const child of block.subCalls) captureSchemas(child, toolsByName, output)
}
function indexTools(tools: readonly ToolSchema[]): ReadonlyMap<string, ToolSchema> {
return new Map(tools.map(tool => [tool.name, tool]))
}
function interruptCompactions(
requests: RequestView[],
boundaries: readonly { seq: number; time: number }[],
): void {
let nextRequest = 0
const runningCompactions: number[] = []
for (const boundary of boundaries) {
while (nextRequest < requests.length) {
const request = requests[nextRequest]
if (request === undefined || request.startSeq >= boundary.seq) break
if (request.purpose === 'compaction' && request.status === 'running') {
runningCompactions.push(nextRequest)
}
nextRequest++
}
let index = runningCompactions.pop()
while (index !== undefined && requests[index]?.status !== 'running') {
index = runningCompactions.pop()
}
if (index === undefined) continue
const request = requests[index]
if (request?.purpose !== 'compaction') continue
requests[index] = {
...request,
completedAt: boundary.time,
status: 'error',
error: 'Compaction was interrupted before completion.',
}
}
}
function applyTurnErrors(
requests: RequestView[],
endings: readonly { turn: number; time: number; error?: string }[],
): void {
const lastAssistantByTurn = new Map<number, number>()
for (const [index, request] of requests.entries()) {
if (request.purpose === 'assistant') lastAssistantByTurn.set(request.turn, index)
}
for (const ending of endings) {
if (ending.error === undefined) continue
const index = lastAssistantByTurn.get(ending.turn)
if (index === undefined) continue
const request = requests[index]
if (request?.purpose !== 'assistant') continue
requests[index] = {
...request,
completedAt: request.completedAt ?? ending.time,
status: 'error',
error: ending.error,
}
}
}
/** Simple keyed adapter retaining the old Trajectory snapshot and stage layout. */
export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
TrajectoryConversationViewNode,
TrajectorySnapshot
> {
private readonly nodes = new Map<string, TrajectoryConversationViewNode>()
private readonly positions = new Map<string, number>()
private contributions: TrajectoryConversationViewNode[] = []
readonly empty = EMPTY_TRAJECTORY_SNAPSHOT
replace(input: {
readonly nodes: readonly TrajectoryConversationViewNode[]
}): TrajectorySnapshot {
this.nodes.clear()
for (const node of input.nodes) this.nodes.set(node.key, node)
this.rebuildContributions()
return this.snapshot()
}
apply(input: {
readonly upserts: readonly TrajectoryConversationViewNode[]
}): TrajectorySnapshot {
let structural = false
for (const node of input.upserts) {
const previous = this.nodes.get(node.key)
this.nodes.set(node.key, node)
if (previous === undefined || previous.anchorSeq !== node.anchorSeq) {
structural = true
continue
}
const position = this.positions.get(node.key)
if (position === undefined) structural = true
else this.contributions[position] = node
}
if (structural) this.rebuildContributions()
return this.snapshot()
}
private snapshot(): TrajectorySnapshot {
const headersByStep = new Map<string, TrajectoryRequestHeaderState>()
for (const contribution of this.contributions) {
if (contribution.data.kind !== 'request-header') continue
const key = headerStepKey(contribution.data.header)
if (key !== undefined) headersByStep.set(key, contribution.data.header)
}
const finalized: ConversationNode[] = []
const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
const requests: RequestView[] = []
const boundaries: { seq: number; time: number }[] = []
const turnEndings: { turn: number; time: number; error?: string }[] = []
const callSchemas = new Map<string, ToolSchema>()
const consumedPromptChanges = new Set<number>()
let previousHeader: TrajectoryRequestHeaderState | undefined
let previousTools: ReadonlyMap<string, ToolSchema> = new Map()
let partial: TrajectorySnapshot['partial'] = null
const runningCalls: TrajectorySnapshot['runningCalls'][number][] = []
for (const contribution of this.contributions) {
const data = contribution.data
if (data.kind === 'request-header') {
previousHeader = data.header
previousTools = indexTools(data.header.prompt.tools)
continue
}
if (data.kind === 'node') {
finalized.push(data.node)
eventLocations.set(data.node.seq, contribution.location)
continue
}
if (data.kind === 'assistant') {
const header = data.request === undefined
? undefined
: headerFor(data.request, headersByStep, previousHeader)
if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt))
if (data.partial !== null) partial = data.partial
if (data.request !== undefined) {
const includeChange = header?.change !== undefined
&& !consumedPromptChanges.has(header.seq)
requests.push(applyHeader(data.request, header, includeChange))
if (includeChange) consumedPromptChanges.add(header.seq)
}
continue
}
if (data.kind === 'tool') {
if ('kind' in data.root) finalized.push(data.root)
else runningCalls.push(data.root)
if (previousHeader !== undefined && previousHeader.seq < contribution.anchorSeq) {
captureSchemas(data.root, previousTools, callSchemas)
}
continue
}
if (data.kind === 'compaction') {
requests.push(data.request)
continue
}
if (data.kind === 'session-end') {
boundaries.push({ seq: data.seq, time: data.time })
continue
}
turnEndings.push({
turn: data.turn,
time: data.time,
...(data.error === undefined ? {} : { error: data.error }),
})
}
requests.sort((left, right) => left.startSeq - right.startSeq)
interruptCompactions(requests, boundaries)
applyTurnErrors(requests, turnEndings)
finalized.sort((left, right) => left.seq - right.seq)
const eventNodes = finalized
return {
eventNodes,
eventLocations,
requests,
callSchemas,
partial,
runningCalls,
}
}
private rebuildContributions(): void {
this.contributions = [...this.nodes.values()]
.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
this.positions.clear()
for (const [index, contribution] of this.contributions.entries()) {
this.positions.set(contribution.key, index)
}
}
}
/** Trajectory target factory preserving the existing stage-oriented view model. */
export const trajectoryViewDefinition: ConversationViewDefinition<
TrajectoryConversationViewNode,
TrajectorySnapshot
> = {
target: 'trajectory',
create: () => new TrajectorySnapshotBuilder(),
}
/**
* Register the stage-oriented Trajectory target builder.
*
* @param ctx - Plugin context receiving the view Definition.
*/
export function registerTrajectoryConversationView(ctx: Context): void {
ctx.conversationViews.register(trajectoryViewDefinition)
}
@@ -0,0 +1,273 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-tools/types'
import { trajectoryNode } from './trajectory-definition-common.ts'
/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
* state machines independent; see ../../../../../.agents/notes/implemented/
* architecture/2026-08-09-client-conversation-node-assembly.md. */
const MAX_DEPTH = 256
interface ToolState {
readonly rootId: string
readonly calls: ReadonlyMap<string, ToolCallBlock>
readonly children: ReadonlyMap<string, readonly string[]>
readonly parents: ReadonlyMap<string, string>
}
interface DispatchData {
readonly parentCallId: string
readonly subCallId: string
readonly name: string
readonly arguments: unknown
readonly isError?: boolean
readonly content?: ToolResultNode['content']
}
function rootCall(match: ConversationMatch): RunningToolCall {
if (match.event.type !== 'tool/call') {
throw new Error('trajectory-tool-call start requires tool/call')
}
return {
callId: String(match.event.data.callId),
name: match.event.data.name,
argsRaw: match.event.data.arguments,
turn: match.event.data.turn,
step: match.event.data.step,
time: match.event.time,
callView: match.view?.for === 'call' ? match.view.view : null,
subCalls: [],
}
}
function rootResult(
match: ConversationMatch,
previous?: RunningToolCall,
): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
const result = match.event.data.message.content[0]
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
callId: String(match.event.data.message.source.callId),
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
callTime: previous?.time ?? null,
content: result.content,
isError: result.isError === true,
...(match.event.data.error === undefined ? {} : { error: match.event.data.error }),
meta: match.event.data.meta,
callView: previous?.callView ?? null,
resultView: match.view?.for === 'result' ? match.view.view : null,
subCalls: [],
}
}
function locationTurn(match: ConversationMatch): number {
return match.location.kind === 'step' || match.location.kind === 'turn'
? match.location.turn.turn
: 0
}
function locationStep(match: ConversationMatch): number {
return match.location.kind === 'step' ? match.location.step.step : 0
}
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
return {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: locationTurn(match),
step: locationStep(match),
time: match.event.time,
callView: null,
subCalls: [],
}
}
function childResult(
match: ConversationMatch,
data: DispatchData,
previous?: ToolCallBlock,
): ToolResultNode {
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: previous === undefined || 'kind' in previous ? null : previous.time,
content: data.content ?? [],
isError: data.isError === true,
callView: null,
resultView: null,
subCalls: [],
}
}
function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
if (parent === child || state.parents.has(child)) return false
let cursor: string | undefined = parent
let parentDepth = 0
const ancestors = new Set<string>()
while (cursor !== undefined) {
if (cursor === child || ancestors.has(cursor)) return false
ancestors.add(cursor)
parentDepth++
cursor = state.parents.get(cursor)
}
const pending = [{ callId: child, depth: 1 }]
const descendants = new Set<string>()
let subtreeDepth = 0
for (const candidate of pending) {
if (descendants.has(candidate.callId)) return false
descendants.add(candidate.callId)
subtreeDepth = Math.max(subtreeDepth, candidate.depth)
for (const nested of state.children.get(candidate.callId) ?? []) {
pending.push({ callId: nested, depth: candidate.depth + 1 })
}
}
return parentDepth + subtreeDepth <= MAX_DEPTH
}
function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
const event = match.event
if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state
const data = event.data
const parentId = String(data.parentCallId)
const childId = String(data.subCallId)
const siblings = state.children.get(parentId) ?? []
const index = siblings.indexOf(childId)
if (index < 0 && !acceptsEdge(state, parentId, childId)) return state
if (event.type === 'tool/code-dispatch-start' && index >= 0) return state
const calls = new Map(state.calls)
calls.set(childId, event.type === 'tool/code-dispatch-start'
? childCall(match, data)
: childResult(match, data, calls.get(childId)))
if (index >= 0) return { ...state, calls }
const children = new Map(state.children)
children.set(parentId, [...siblings, childId])
const parents = new Map(state.parents)
parents.set(childId, parentId)
return { ...state, calls, children, parents }
}
function interruption(
context: ConversationNodeContext<ToolState>,
): { seq: number; time: number } | undefined {
const location = context.start?.location
if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
if ((location?.kind === 'step' || location?.kind === 'turn')
&& location.turn.status === 'closed') return location.turn.end
return undefined
}
function projectCall(
state: ToolState,
callId: string,
interruptedAt: { seq: number; time: number } | undefined,
visited = new Set<string>(),
depth = 1,
): ToolCallBlock | undefined {
const block = state.calls.get(callId)
if (block === undefined) return undefined
if (visited.has(callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] }
const nextVisited = new Set(visited)
nextVisited.add(callId)
const subCalls = (state.children.get(callId) ?? [])
.flatMap((childId) => {
const child = projectCall(state, childId, interruptedAt, nextVisited, depth + 1)
return child === undefined ? [] : [child]
})
if ('kind' in block || interruptedAt === undefined) return { ...block, subCalls }
return {
kind: 'tool-result',
seq: interruptedAt.seq - 0.8,
time: interruptedAt.time,
callId: block.callId,
call: { name: block.name, argsRaw: block.argsRaw },
callTime: block.time,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: block.callView,
resultView: null,
subCalls,
}
}
function fallbackState(context: ConversationNodeContext<ToolState>): ToolState | undefined {
const resultMatch = context.matches.find(match => match.event.type === 'tool/result')
const root = resultMatch === undefined ? undefined : rootResult(resultMatch)
if (root === undefined) return undefined
let state: ToolState = {
rootId: root.callId,
calls: new Map([[root.callId, root]]),
children: new Map(),
parents: new Map(),
}
for (const match of context.matches) state = updateDispatch(state, match)
return state
}
/** Trajectory-owned root Tool lifecycle with nested Code Dispatch calls. */
const trajectoryToolDefinition: ConversationNodeDefinition<ToolState> = {
kind: 'trajectory-tool-call',
target: 'trajectory',
match: (event) => {
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
if (event.type === 'tool/result') {
return { id: String(event.data.message.source.callId), role: 'update' }
}
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
const rootCallId: unknown = event.data.rootCallId
return typeof rootCallId === 'string' && rootCallId !== ''
? { id: rootCallId, role: 'update' }
: null
}
return null
},
start: (_context, match) => {
const root = rootCall(match)
return {
rootId: root.callId,
calls: new Map([[root.callId, root]]),
children: new Map(),
parents: new Map(),
}
},
update: (context, match) => {
if (match.event.type !== 'tool/result') return updateDispatch(context.state, match)
const previous = context.state.calls.get(context.state.rootId)
const running = previous !== undefined && !('kind' in previous) ? previous : undefined
const result = rootResult(match, running)
if (result === undefined) return context.state
const calls = new Map(context.state.calls)
calls.set(context.state.rootId, result)
return { ...context.state, calls }
},
buildViewNode: (context) => {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const root = projectCall(state, state.rootId, interruption(context))
if (root === undefined) return null
const anchorSeq = context.start?.event.seq
?? ('kind' in root ? root.seq : context.matches[0]?.event.seq ?? 0)
return trajectoryNode(context, anchorSeq, { kind: 'tool', root })
},
}
/* jscpd:ignore-end */
/**
* Register the Trajectory Tool lifecycle.
*
* @param ctx - Plugin context receiving the Definition.
*/
export function registerTrajectoryToolDefinition(ctx: Context): void {
ctx.conversationEvents.register(trajectoryToolDefinition)
}
@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-trajectory'