Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: 5b8c0cd111c272007212fea0d2435c5fab2360ab
|
||||
README.zh.md: 9aaa02ccd9d50b0f0b23e9a53ea9b1048d0e513f
|
||||
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
|
||||
README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge).
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-trajectory",
|
||||
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-trajectory"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,12 +29,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
@@ -39,22 +48,28 @@
|
||||
"diff": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
|
||||
@@ -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 } 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
|
||||
@@ -46,6 +46,7 @@ function timelineBlock(block: AssistantBlock): AssistantBlock {
|
||||
switch (block.kind) {
|
||||
case 'text': return { kind: 'text', text: '' }
|
||||
case 'reasoning': return { kind: 'reasoning', text: '' }
|
||||
case 'image': return block
|
||||
case 'tool-call': return {
|
||||
kind: 'tool-call',
|
||||
callId: block.callId,
|
||||
@@ -63,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
|
||||
}
|
||||
|
||||
@@ -118,84 +117,21 @@ 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,
|
||||
useSession, useDuration, loadOlder, setActualDuration,
|
||||
inspect, onInspectDone,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
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 [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
|
||||
@@ -203,60 +139,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)
|
||||
@@ -352,24 +248,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
|
||||
@@ -387,31 +284,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
|
||||
@@ -427,11 +353,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 })
|
||||
@@ -517,11 +440,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])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-conversation-composer-overlay="">
|
||||
@@ -557,7 +477,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,16 +2,22 @@
|
||||
* 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: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// 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 { 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'
|
||||
|
||||
/** Required services: the conversation view slot and independent history source. */
|
||||
export const inject = ['slots', 'sessionHistory']
|
||||
/** Required services: the conversation slot, registries, and ordinary Session paging. */
|
||||
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -20,17 +26,29 @@ export const inject = ['slots', 'sessionHistory']
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
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',
|
||||
order: 10,
|
||||
label: '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) },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
@@ -736,6 +785,12 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
|
||||
callId: block.callId,
|
||||
toolName: block.name,
|
||||
}
|
||||
// Attachment refs carry no fetchable bytes, so the record shows the
|
||||
// durable metadata instead of an inline preview.
|
||||
case 'image': return {
|
||||
type: 'image',
|
||||
content: stringifySourceValue(block.attachment),
|
||||
}
|
||||
case 'other': return sourceBlock(block.block)
|
||||
}
|
||||
}
|
||||
@@ -802,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,
|
||||
@@ -913,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,
|
||||
@@ -921,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 }),
|
||||
@@ -931,7 +1020,7 @@ function expandSubCalls(
|
||||
? {
|
||||
outputDetail: detailResult(sub),
|
||||
outputBlocks: sub.content.map(block => sourceBlock(block)),
|
||||
result: summarizeResult(sub),
|
||||
...resultPreview,
|
||||
isError: sub.isError,
|
||||
}
|
||||
: {}),
|
||||
@@ -952,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 {
|
||||
@@ -1003,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'
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationViewRegistry, SlotsService,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
@@ -61,26 +63,36 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
|
||||
expect(surface.inject).toEqual([
|
||||
'slots', 'conversationEvents', 'conversationViews', 'sessions',
|
||||
])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||
const { surface } = await loadArtifact()
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
// The conversation entry's role: the ring must be declared before riders land.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin reads sessionHistory for its per-session history source;
|
||||
// slot availability is tracked by slots.inject.
|
||||
ctx.provide('sessionHistory', {})
|
||||
// Paging is session-owned; this registration-only probe never renders the
|
||||
// entry, so the binding stays deliberately empty.
|
||||
ctx.provide('sessions', { binding: () => undefined })
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
const events = ctx.get('conversationEvents') as ConversationEventRegistry
|
||||
const views = ctx.get('conversationViews') as ConversationViewRegistry
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
|
||||
expect(events.entries().length).toBeGreaterThan(0)
|
||||
expect(views.entries()).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.view')).toHaveLength(0)
|
||||
expect(events.entries()).toEqual([])
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ConversationContext, ConversationNode, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches,
|
||||
trajectoryBranchContainsRequest,
|
||||
} from '../src/client/context-branches.ts'
|
||||
|
||||
const checkpoint = {
|
||||
kind: 'context',
|
||||
seq: 100,
|
||||
time: 100,
|
||||
content: [],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: null,
|
||||
} as ConversationNode
|
||||
|
||||
const abandoned = {
|
||||
kind: 'assistant',
|
||||
seq: 20,
|
||||
time: 20,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'abandoned' }],
|
||||
} as ConversationNode
|
||||
|
||||
const current = {
|
||||
kind: 'user',
|
||||
seq: 110,
|
||||
time: 110,
|
||||
content: [{ type: 'text', text: 'rewound' }],
|
||||
source: { kind: 'plugin', plugin: 'rewind' },
|
||||
} as ConversationNode
|
||||
|
||||
function request(
|
||||
purpose: RequestView['purpose'],
|
||||
startSeq: number,
|
||||
resultSeq?: number,
|
||||
replacementSeq?: number,
|
||||
): RequestView {
|
||||
const base = {
|
||||
startSeq,
|
||||
startedAt: startSeq,
|
||||
completedAt: startSeq + 1,
|
||||
status: 'complete' as const,
|
||||
...(resultSeq === undefined ? {} : { resultSeq }),
|
||||
}
|
||||
return purpose === 'assistant'
|
||||
? { ...base, purpose, turn: 1, step: 1 }
|
||||
: {
|
||||
...base,
|
||||
purpose,
|
||||
turn: 1,
|
||||
step: 0,
|
||||
...(replacementSeq === undefined ? {} : { replacementSeq }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('trajectory context branches', () => {
|
||||
it('inherits nodes and requests by retained surface position rather than seq cutoff', () => {
|
||||
const contexts: ConversationContext[] = [
|
||||
{ id: 0, nodes: [checkpoint, abandoned] },
|
||||
{
|
||||
id: 1,
|
||||
parentId: 0,
|
||||
origin: 'rewind',
|
||||
originSeq: 110,
|
||||
nodes: [checkpoint, current],
|
||||
},
|
||||
]
|
||||
const branches = deriveTrajectoryContextBranches(contexts)
|
||||
const successor = branches[1]!
|
||||
|
||||
expect(successor.key).toBe('rewind:110')
|
||||
expect(successor.nodes.map(node => node.seq)).toEqual([110])
|
||||
expect(trajectoryBranchContainsRequest(
|
||||
successor,
|
||||
request('assistant', 10, 20),
|
||||
)).toBe(false)
|
||||
expect(trajectoryBranchContainsRequest(
|
||||
successor,
|
||||
request('compaction', 90, 95, 100),
|
||||
)).toBe(true)
|
||||
expect(trajectoryBranchContainsRequest(
|
||||
successor,
|
||||
request('assistant', 111),
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps branch identity when prepended generations shift local ids', () => {
|
||||
const branch = (id: number) => deriveTrajectoryContextBranches([{
|
||||
id,
|
||||
origin: 'rewind',
|
||||
originSeq: 110,
|
||||
nodes: [current],
|
||||
}])[0]
|
||||
|
||||
expect(branch(1)?.key).toBe(branch(9)?.key)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
|
||||
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
|
||||
import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-message-definitions.ts'
|
||||
import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts'
|
||||
import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts'
|
||||
import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts'
|
||||
|
||||
const DEFINITIONS: ConversationNodeDefinition[] = []
|
||||
const registrationContext = {
|
||||
conversationEvents: {
|
||||
register: (definition: ConversationNodeDefinition) => {
|
||||
DEFINITIONS.push(definition)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
|
||||
registerTrajectoryMessageDefinitions(registrationContext)
|
||||
registerTrajectoryRequestHeaderDefinition(registrationContext)
|
||||
registerTrajectoryAssistantDefinition(registrationContext)
|
||||
registerTrajectoryToolDefinition(registrationContext)
|
||||
registerTrajectoryCompactionDefinitions(registrationContext)
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return DEFINITIONS
|
||||
}
|
||||
|
||||
fallbackEntry(): undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] {
|
||||
return [trajectoryViewDefinition]
|
||||
}
|
||||
}
|
||||
|
||||
function at(
|
||||
seq: number,
|
||||
type: string,
|
||||
data: unknown,
|
||||
extra: Record<string, unknown> = {},
|
||||
): ConversationEventInput {
|
||||
return {
|
||||
event: {
|
||||
seq,
|
||||
time: 1_700_000_000_000 + seq,
|
||||
type,
|
||||
data,
|
||||
...extra,
|
||||
} as unknown as ConversationEventInput['event'],
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions(),
|
||||
new TestViewDefinitions(),
|
||||
)
|
||||
value.replaceWindow(events, false)
|
||||
value.flush()
|
||||
return value
|
||||
}
|
||||
|
||||
function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot {
|
||||
const current = value.snapshot('trajectory') as TrajectorySnapshot | undefined
|
||||
if (current === undefined) throw new Error('trajectory view was not registered')
|
||||
return current
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string) {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'test', model: 'test' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('Trajectory conversation Definitions', () => {
|
||||
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
|
||||
}),
|
||||
at(4, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(snapshot(value).partial?.blocks).toEqual([{ kind: 'text', text: 'first attempt' }])
|
||||
expect(snapshot(value).requests).toMatchObject([{
|
||||
purpose: 'assistant',
|
||||
status: 'running',
|
||||
usage: { inputTokens: 10, outputTokens: 3 },
|
||||
}])
|
||||
|
||||
value.append(at(5, 'llm/retry', {
|
||||
retryId: 'retry-1',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
mode: 'normal',
|
||||
policyKey: 'test-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 25,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary failure' },
|
||||
}))
|
||||
value.append(at(6, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
|
||||
}))
|
||||
value.append(at(7, 'step/end', { turn: 1, step: 1 }))
|
||||
value.flush()
|
||||
|
||||
const settled = snapshot(value)
|
||||
expect(settled.partial).toBeNull()
|
||||
expect(settled.eventNodes).toMatchObject([{
|
||||
kind: 'assistant',
|
||||
seq: 6.1,
|
||||
interrupted: true,
|
||||
blocks: [{ kind: 'text', text: 'second attempt' }],
|
||||
}])
|
||||
expect(settled.requests).toMatchObject([{
|
||||
purpose: 'assistant',
|
||||
status: 'error',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 25,
|
||||
usage: { inputTokens: 10, outputTokens: 3 },
|
||||
}])
|
||||
})
|
||||
|
||||
it('keeps parallel interrupted roots and nests Code Dispatch results', () => {
|
||||
const current = snapshot(assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool/call', {
|
||||
turn: 1, step: 1, callId: 'root-a', name: 'code', arguments: '{}',
|
||||
}),
|
||||
at(4, 'tool/call', {
|
||||
turn: 1, step: 1, callId: 'root-b', name: 'parallel', arguments: '{}',
|
||||
}),
|
||||
at(5, 'tool/code-dispatch-start', {
|
||||
rootCallId: 'root-a',
|
||||
parentCallId: 'root-a',
|
||||
subCallId: 'child',
|
||||
name: 'read',
|
||||
arguments: { path: 'README.md' },
|
||||
}),
|
||||
at(6, 'tool/code-dispatch', {
|
||||
rootCallId: 'root-a',
|
||||
parentCallId: 'root-a',
|
||||
subCallId: 'child',
|
||||
name: 'read',
|
||||
arguments: { path: 'README.md' },
|
||||
content: [{ type: 'text', text: 'contents' }],
|
||||
}),
|
||||
at(7, 'step/end', { turn: 1, step: 1 }),
|
||||
]))
|
||||
|
||||
const tools = current.eventNodes.filter(node => node.kind === 'tool-result')
|
||||
expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b'])
|
||||
expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{
|
||||
kind: 'tool-result',
|
||||
callId: 'child',
|
||||
call: { name: 'read' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => {
|
||||
const current = snapshot(assembler([
|
||||
at(1, 'compact/start', { compactionId: 'complete', turn: null }),
|
||||
at(2, 'compact/summary', {
|
||||
compactionId: 'complete',
|
||||
turn: null,
|
||||
summary: 'summary',
|
||||
provider: 'test',
|
||||
model: 'test',
|
||||
maxTokens: 100,
|
||||
usage: { inputTokens: 20, outputTokens: 5 },
|
||||
}),
|
||||
at(3, 'user/message', {
|
||||
id: 'checkpoint',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'summary checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' },
|
||||
}),
|
||||
at(4, 'compact/end', { compactionId: 'complete', turn: null }),
|
||||
at(5, 'compact/start', { compactionId: 'orphan', turn: null }),
|
||||
at(6, 'session/end-seed', {}),
|
||||
]))
|
||||
|
||||
expect(current.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 1,
|
||||
status: 'complete',
|
||||
resultSeq: 2,
|
||||
replacementSeq: 3,
|
||||
summary: 'summary',
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 5,
|
||||
status: 'error',
|
||||
completedAt: 1_700_000_000_006,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('classifies claimed inbox input as steering and consumes one inherited prompt change', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'test', model: 'test' },
|
||||
system: 'system prompt',
|
||||
tools: [],
|
||||
},
|
||||
}),
|
||||
at(3, 'step/start', { turn: 1, step: 1 }),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('assistant-1', 'first'),
|
||||
}),
|
||||
at(5, 'step/end', { turn: 1, step: 1 }),
|
||||
at(6, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
|
||||
}),
|
||||
at(7, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
}),
|
||||
at(8, 'step/start', { turn: 1, step: 2 }),
|
||||
])
|
||||
value.append(at(9, 'user/message', {
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'steer here' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
value.flush()
|
||||
|
||||
const steering = snapshot(value)
|
||||
expect(steering.eventNodes.find(node => node.seq === 9)?.kind).toBe('steering')
|
||||
expect(steering.eventLocations.get(9)).toMatchObject({
|
||||
kind: 'step',
|
||||
turn: { turn: 1 },
|
||||
step: { step: 2 },
|
||||
})
|
||||
|
||||
value.append(at(10, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: assistantMessage('assistant-2', 'second'),
|
||||
}))
|
||||
value.flush()
|
||||
const current = snapshot(value)
|
||||
|
||||
expect(current.requests.map(request => request.purpose === 'assistant'
|
||||
? request.prompt?.system
|
||||
: undefined)).toEqual(['system prompt', 'system prompt'])
|
||||
expect(current.requests.map(request => request.purpose === 'assistant'
|
||||
? request.promptChange?.kind
|
||||
: undefined)).toEqual(['initial', undefined])
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
ConversationSnapshot, RequestView,
|
||||
ConversationLocation, ConversationSnapshot, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
|
||||
@@ -84,7 +84,10 @@ describe('deriveTrajectoryLayout', () => {
|
||||
input: 10, output: 20, think: 5, timeSeconds: 5,
|
||||
})
|
||||
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
|
||||
expect(tool?.text).toBe('bash · {"command":"ls"}')
|
||||
expect(tool).toMatchObject({
|
||||
text: 'bash',
|
||||
previewMarkdown: '{"command":"ls"}',
|
||||
})
|
||||
expect(tool?.timeSeconds).toBe(1.3)
|
||||
})
|
||||
|
||||
@@ -99,7 +102,10 @@ describe('deriveTrajectoryLayout', () => {
|
||||
})
|
||||
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
|
||||
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
|
||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||
kind: 'tool',
|
||||
text: 'bash',
|
||||
previewMarkdown: '{"command":"pwd"}',
|
||||
timeSeconds: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,7 +138,8 @@ describe('deriveTrajectoryLayout', () => {
|
||||
expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
|
||||
index: 2,
|
||||
kind: 'message',
|
||||
text: 'streaming',
|
||||
text: '',
|
||||
previewMarkdown: 'streaming',
|
||||
timeSeconds: null,
|
||||
}])
|
||||
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
|
||||
@@ -222,8 +229,120 @@ describe('deriveTrajectoryLayout', () => {
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns.map(t => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
|
||||
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([
|
||||
'first',
|
||||
'ok1',
|
||||
])
|
||||
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([
|
||||
'second',
|
||||
'ok2',
|
||||
])
|
||||
})
|
||||
|
||||
it('places steering in its resolved step instead of the turn-opening Message group', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'start' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'first step' }],
|
||||
},
|
||||
{
|
||||
kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
|
||||
content: [{ type: 'text', text: 'change direction' }], source: null,
|
||||
},
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'text', text: 'second step' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const data = { get: () => undefined }
|
||||
const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
|
||||
const turn = {
|
||||
turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
|
||||
}
|
||||
const eventLocations = new Map<number, ConversationLocation>([[
|
||||
3,
|
||||
{ kind: 'step', turn, step },
|
||||
]])
|
||||
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes,
|
||||
eventLocations,
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
})
|
||||
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(turns[0]?.groups.map(group => group.title)).toEqual([
|
||||
'Message', 'Step 1', 'Step 2',
|
||||
])
|
||||
expect(turns[0]?.groups[2]?.cells).toMatchObject([
|
||||
{ kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
|
||||
{ kind: 'message', previewMarkdown: 'second step', sourceSeq: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a running request boundary after steering input', () => {
|
||||
const nodes = [{
|
||||
kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
|
||||
content: [{ type: 'text', text: 'change direction' }], source: null,
|
||||
}] as unknown as ConversationSnapshot['nodes']
|
||||
const data = { get: () => undefined }
|
||||
const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
|
||||
const turn = {
|
||||
turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
|
||||
}
|
||||
const eventLocations = new Map<number, ConversationLocation>([[
|
||||
3,
|
||||
{ kind: 'step', turn, step },
|
||||
]])
|
||||
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes,
|
||||
eventLocations,
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
requests: [{
|
||||
purpose: 'assistant',
|
||||
startSeq: 2,
|
||||
turn: 1,
|
||||
step: 2,
|
||||
startedAt: 2_000,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
}],
|
||||
})
|
||||
|
||||
expect(turns[0]?.groups[0]?.cells).toMatchObject([
|
||||
{ kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
|
||||
{ kind: 'message', requestOnly: true, sourceSeq: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the following assistant step while a historical window lacks steering Location', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
|
||||
content: [{ type: 'text', text: 'change direction' }], source: null,
|
||||
},
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3,
|
||||
blocks: [{ kind: 'text', text: 'continued' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
|
||||
expect(turns[0]).toMatchObject({
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 3',
|
||||
cells: [
|
||||
{ kind: 'user', previewMarkdown: 'change direction' },
|
||||
{ kind: 'message', previewMarkdown: 'continued' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('places standalone compaction chronologically in its own between-turn section', () => {
|
||||
@@ -263,7 +382,8 @@ describe('deriveTrajectoryLayout', () => {
|
||||
cells: [{
|
||||
kind: 'compacted',
|
||||
sourceSeq: 3,
|
||||
text: 'standalone summary',
|
||||
text: '',
|
||||
previewMarkdown: 'standalone summary',
|
||||
}],
|
||||
}])
|
||||
})
|
||||
@@ -279,7 +399,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
text: '…', input: 11, output: 22, think: 3,
|
||||
text: '', previewMarkdown: '…', input: 11, output: 22, think: 3,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -296,9 +416,8 @@ describe('deriveTrajectoryLayout', () => {
|
||||
const message = turns[0]?.groups.flatMap(group => group.cells)
|
||||
.find(cell => cell.kind === 'message')
|
||||
|
||||
expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
|
||||
expect(message?.text.endsWith('…')).toBe(true)
|
||||
expect(message?.text.length).toBeLessThanOrEqual(513)
|
||||
expect(message?.text).toBe('')
|
||||
expect(message?.previewMarkdown).toBe(thinking)
|
||||
expect(message?.thinkingDetail).toBe(thinking)
|
||||
})
|
||||
|
||||
@@ -331,7 +450,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
|
||||
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
|
||||
const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done')
|
||||
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
|
||||
expect(message?.timeSeconds).toBe(0.5)
|
||||
// Context remains inspectable in trajectory; the Chat marker is not duplicated.
|
||||
@@ -394,7 +513,9 @@ describe('run_code sub-dispatch cells', () => {
|
||||
expect(cells[0]?.text).toBe('Tool call only')
|
||||
// Sequential indexes across the interleave; durations from the pair times.
|
||||
expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4])
|
||||
expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
||||
expect(cells[2]).toMatchObject({
|
||||
text: 'bash', previewMarkdown: '{"x":1}', timeSeconds: 1,
|
||||
})
|
||||
expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
|
||||
})
|
||||
|
||||
@@ -405,7 +526,9 @@ describe('run_code sub-dispatch cells', () => {
|
||||
}
|
||||
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
|
||||
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
|
||||
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
|
||||
expect(sub).toMatchObject({
|
||||
text: 'grep', previewMarkdown: '{"pattern":"x"}', timeSeconds: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('recursively flattens nested child calls immediately after their parent', () => {
|
||||
|
||||
237
packages/client/ui-trajectory/tests/snapshot-builder.spec.ts
Normal file
237
packages/client/ui-trajectory/tests/snapshot-builder.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState,
|
||||
} from '../src/client/trajectory-contract.ts'
|
||||
import { TrajectorySnapshotBuilder } from '../src/client/trajectory-snapshot-builder.ts'
|
||||
|
||||
function assistantRequest(startSeq: number, step: number): Extract<RequestView, { purpose: 'assistant' }> {
|
||||
return {
|
||||
purpose: 'assistant',
|
||||
startSeq,
|
||||
turn: 1,
|
||||
step,
|
||||
startedAt: startSeq,
|
||||
completedAt: startSeq + 1,
|
||||
status: 'complete',
|
||||
}
|
||||
}
|
||||
|
||||
function contribution(
|
||||
key: string,
|
||||
anchorSeq: number,
|
||||
data: TrajectoryContribution,
|
||||
): TrajectoryConversationViewNode {
|
||||
return {
|
||||
key, kind: key, id: key, target: 'trajectory', anchorSeq,
|
||||
location: { kind: 'session' },
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] {
|
||||
const data = { get: () => undefined }
|
||||
const stepLocation = {
|
||||
turn,
|
||||
step,
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
status: 'unknown' as const,
|
||||
data,
|
||||
}
|
||||
const turnLocation = {
|
||||
turn,
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
status: 'unknown' as const,
|
||||
steps: [stepLocation],
|
||||
data,
|
||||
}
|
||||
return { kind: 'step', turn: turnLocation, step: stepLocation }
|
||||
}
|
||||
|
||||
function compactionRequest(startSeq: number): Extract<RequestView, { purpose: 'compaction' }> {
|
||||
return {
|
||||
purpose: 'compaction',
|
||||
startSeq,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: startSeq,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
}
|
||||
}
|
||||
|
||||
describe('TrajectorySnapshotBuilder', () => {
|
||||
it('inherits one request header across requests without repeating its prompt change', () => {
|
||||
const prompt = {
|
||||
config: { provider: 'test', model: 'test' },
|
||||
system: 'one initial prompt',
|
||||
tools: [],
|
||||
}
|
||||
const nodes: TrajectoryConversationViewNode[] = [
|
||||
{
|
||||
key: 'header',
|
||||
kind: 'trajectory-request-header',
|
||||
id: '2',
|
||||
target: 'trajectory',
|
||||
anchorSeq: 2,
|
||||
location: { kind: 'session' },
|
||||
data: {
|
||||
kind: 'request-header',
|
||||
header: {
|
||||
seq: 2,
|
||||
time: 2,
|
||||
prompt,
|
||||
change: { seq: 2, time: 2, kind: 'initial' },
|
||||
location: { kind: 'session' },
|
||||
},
|
||||
},
|
||||
},
|
||||
...[assistantRequest(3, 1), assistantRequest(5, 2)].map(request => ({
|
||||
key: `assistant:${request.step}`,
|
||||
kind: 'trajectory-assistant-step',
|
||||
id: `1:${request.step}`,
|
||||
target: 'trajectory' as const,
|
||||
anchorSeq: request.startSeq,
|
||||
location: { kind: 'session' as const },
|
||||
data: { kind: 'assistant' as const, partial: null, request },
|
||||
})),
|
||||
]
|
||||
|
||||
const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
|
||||
|
||||
expect(snapshot.requests.map(request => request.purpose === 'assistant'
|
||||
? request.prompt?.system
|
||||
: undefined)).toEqual(['one initial prompt', 'one initial prompt'])
|
||||
expect(snapshot.requests.map(request => request.purpose === 'assistant'
|
||||
? request.promptChange?.kind
|
||||
: undefined)).toEqual(['initial', undefined])
|
||||
})
|
||||
|
||||
it('indexes exact step headers and the active tool schema without backward scans', () => {
|
||||
const basePrompt = {
|
||||
config: { provider: 'test', model: 'base' },
|
||||
system: 'base prompt',
|
||||
tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }],
|
||||
}
|
||||
const exactPrompt = {
|
||||
config: { provider: 'test', model: 'exact' },
|
||||
system: 'exact prompt',
|
||||
tools: [{ name: 'edit', description: 'Edit', parameters: { type: 'object' } }],
|
||||
}
|
||||
const nodes: TrajectoryConversationViewNode[] = [
|
||||
contribution('header:base', 2, {
|
||||
kind: 'request-header',
|
||||
header: {
|
||||
seq: 2,
|
||||
time: 2,
|
||||
prompt: basePrompt,
|
||||
change: { seq: 2, time: 2, kind: 'initial' },
|
||||
location: { kind: 'session' },
|
||||
},
|
||||
}),
|
||||
contribution('assistant:1', 3, {
|
||||
kind: 'assistant',
|
||||
partial: null,
|
||||
request: assistantRequest(3, 1),
|
||||
}),
|
||||
contribution('assistant:2', 5, {
|
||||
kind: 'assistant',
|
||||
partial: null,
|
||||
request: assistantRequest(5, 2),
|
||||
}),
|
||||
contribution('header:exact', 6, {
|
||||
kind: 'request-header',
|
||||
header: {
|
||||
seq: 6,
|
||||
time: 6,
|
||||
prompt: exactPrompt,
|
||||
change: { seq: 6, time: 6, kind: 'system', previous: basePrompt },
|
||||
location: stepLocation(1, 2),
|
||||
},
|
||||
}),
|
||||
contribution('tool', 7, {
|
||||
kind: 'tool',
|
||||
root: {
|
||||
callId: 'call-edit',
|
||||
name: 'edit',
|
||||
argsRaw: '{}',
|
||||
turn: 1,
|
||||
step: 2,
|
||||
time: 7,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
|
||||
|
||||
expect(snapshot.requests.map(request => request.purpose === 'assistant'
|
||||
? request.prompt?.system
|
||||
: undefined)).toEqual(['base prompt', 'exact prompt'])
|
||||
expect(snapshot.callSchemas.get('call-edit')).toEqual(exactPrompt.tools[0])
|
||||
})
|
||||
|
||||
it('applies session boundaries and turn errors with linear request indexes', () => {
|
||||
const nodes: TrajectoryConversationViewNode[] = [
|
||||
...[assistantRequest(1, 1), assistantRequest(3, 2)].map(request => contribution(
|
||||
`assistant:${request.step}`,
|
||||
request.startSeq,
|
||||
{ kind: 'assistant', partial: null, request },
|
||||
)),
|
||||
contribution('turn-end', 5, {
|
||||
kind: 'turn-end',
|
||||
turn: 1,
|
||||
time: 5,
|
||||
error: 'turn failed',
|
||||
}),
|
||||
contribution('compact:10', 10, {
|
||||
kind: 'compaction',
|
||||
request: compactionRequest(10),
|
||||
}),
|
||||
contribution('compact:12', 12, {
|
||||
kind: 'compaction',
|
||||
request: compactionRequest(12),
|
||||
}),
|
||||
contribution('session-end:14', 14, { kind: 'session-end', seq: 14, time: 14 }),
|
||||
contribution('session-end:16', 16, { kind: 'session-end', seq: 16, time: 16 }),
|
||||
]
|
||||
|
||||
const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
|
||||
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{ purpose: 'assistant', step: 1, status: 'complete' },
|
||||
{ purpose: 'assistant', step: 2, status: 'error', error: 'turn failed' },
|
||||
{ purpose: 'compaction', startSeq: 10, status: 'error', completedAt: 16 },
|
||||
{ purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps cached contribution order across content updates and structural inserts', () => {
|
||||
const builder = new TrajectorySnapshotBuilder()
|
||||
const first = contribution('assistant:1', 1, {
|
||||
kind: 'assistant', partial: null, request: assistantRequest(1, 1),
|
||||
})
|
||||
const last = contribution('assistant:3', 5, {
|
||||
kind: 'assistant', partial: null, request: assistantRequest(5, 3),
|
||||
})
|
||||
expect(builder.replace({ nodes: [last, first] }).requests.map(request => request.startSeq))
|
||||
.toEqual([1, 5])
|
||||
|
||||
const updatedLast = contribution('assistant:3', 5, {
|
||||
kind: 'assistant',
|
||||
partial: null,
|
||||
request: { ...assistantRequest(5, 3), status: 'error', error: 'failed' },
|
||||
})
|
||||
expect(builder.apply({ upserts: [updatedLast] }).requests.map(request => request.startSeq))
|
||||
.toEqual([1, 5])
|
||||
|
||||
const middle = contribution('assistant:2', 3, {
|
||||
kind: 'assistant', partial: null, request: assistantRequest(3, 2),
|
||||
})
|
||||
expect(builder.apply({ upserts: [middle] }).requests.map(request => request.startSeq))
|
||||
.toEqual([1, 3, 5])
|
||||
})
|
||||
})
|
||||
@@ -308,6 +308,43 @@ describe('TrajectoryTable', () => {
|
||||
expect(screen.getByText('Request #2')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('places the request boundary after leading steering input', () => {
|
||||
const turns: readonly TrajectoryTurnModel[] = [{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 2',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'user',
|
||||
sourceSeq: 3,
|
||||
text: 'change direction',
|
||||
timeSeconds: 0,
|
||||
}, {
|
||||
index: 2,
|
||||
kind: 'message',
|
||||
sourceSeq: 4,
|
||||
text: 'continued',
|
||||
timeSeconds: 1,
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<TrajectoryTable
|
||||
turns={turns}
|
||||
requestNumbers={[{
|
||||
seq: 2,
|
||||
turn: 1,
|
||||
step: 2,
|
||||
group: 'Step 2',
|
||||
number: 1,
|
||||
}]}
|
||||
{...FOLD_PROPS}
|
||||
/>)
|
||||
|
||||
const request = screen.getByRole('button', { name: 'Request #1' })
|
||||
expect(request.closest('tr')?.getAttribute('aria-label')).toContain('ASSISTANT')
|
||||
})
|
||||
|
||||
it('follows appended records only while the ledger is already at the bottom', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
const tablePane = screen.getByRole('table').parentElement as HTMLElement
|
||||
|
||||
@@ -7,18 +7,20 @@
|
||||
* event ledger with its timing overview, and fiber disposal removes the tab.
|
||||
* Timeline projection and inclusive focus edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import {
|
||||
ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore,
|
||||
EMPTY_CHAT_SNAPSHOT,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
|
||||
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
ConversationSnapshot, RequestView,
|
||||
SessionId, SessionListState, SnapshotStore, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import {
|
||||
@@ -35,9 +37,11 @@ import {
|
||||
TrajectoryView, type TrajectoryViewInjected,
|
||||
} from '../src/client/TrajectoryView.tsx'
|
||||
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
|
||||
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
|
||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
const sessionSnapshots = new WeakMap<SlotsService, SnapshotStore<ConversationSnapshot>>()
|
||||
const tConversation: ConversationSessionHeaderProps['t'] =
|
||||
key => (conversationZh as Record<string, string>)[key] ?? key
|
||||
|
||||
@@ -67,37 +71,54 @@ const NODES = [
|
||||
|
||||
function historySnapshot(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
inspection: Partial<SessionHistoryInspection> = {},
|
||||
): SessionHistorySnapshot {
|
||||
inspection: Partial<TrajectorySnapshot> = {},
|
||||
): ConversationSnapshot {
|
||||
const trajectory: TrajectorySnapshot = {
|
||||
eventNodes: nodes,
|
||||
eventLocations: new Map(),
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
...inspection,
|
||||
}
|
||||
return {
|
||||
state: 'ready',
|
||||
error: null,
|
||||
hasMore: false,
|
||||
baseSeq: nodes[0]?.seq ?? 0,
|
||||
inspection: {
|
||||
eventNodes: nodes,
|
||||
contexts: [{ id: 0, nodes }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [],
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
...inspection,
|
||||
sessionId: SID,
|
||||
views: {
|
||||
get: target => target === 'trajectory' ? trajectory : undefined,
|
||||
},
|
||||
chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes,
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
partial: trajectory.partial,
|
||||
runningCalls: trajectory.runningCalls,
|
||||
pending: [],
|
||||
queue: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
composerPhase: 'active',
|
||||
removed: false,
|
||||
openState: 'open',
|
||||
openError: null,
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
promptError: null,
|
||||
blank: nodes.length === 0,
|
||||
lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneHistory(
|
||||
snapshot: SessionHistorySnapshot,
|
||||
snapshot: ConversationSnapshot,
|
||||
): Pick<
|
||||
ComponentProps<typeof TrajectoryView>,
|
||||
'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
|
||||
'useSession' | 'loadOlder'
|
||||
> {
|
||||
const store = createSnapshotStore(snapshot)
|
||||
return {
|
||||
useHistory: bindSnapshotSelector(store),
|
||||
loadHistoryTail: () => Promise.resolve(),
|
||||
loadOlderHistory: () => Promise.resolve(false),
|
||||
useSession: bindSnapshotSelector(store),
|
||||
loadOlder: () => Promise.resolve(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,11 +133,8 @@ function standaloneDuration(): Pick<
|
||||
}
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore({
|
||||
nodes, pending: [], partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
const store = createSnapshotStore(historySnapshot(nodes))
|
||||
return { store, useSession: bindSnapshotSelector(store) }
|
||||
}
|
||||
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
@@ -149,16 +167,19 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
||||
const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
|
||||
const historyStore = createSnapshotStore(snapshot)
|
||||
const history: SessionHistoryFace = {
|
||||
sessionId: SID,
|
||||
getSnapshot: () => historyStore.getSnapshot(),
|
||||
subscribe: listener => historyStore.subscribe(listener),
|
||||
loadTail: loadHistoryTail,
|
||||
loadOlder: loadOlderHistory,
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
const sessionStore = createSnapshotStore(snapshot)
|
||||
const session = {
|
||||
getSnapshot: () => sessionStore.getSnapshot(),
|
||||
subscribe: (listener: () => void) => sessionStore.subscribe(listener),
|
||||
loadOlder,
|
||||
}
|
||||
await ctx.plugin(ConversationEventRegistry).await()
|
||||
await ctx.plugin(ConversationViewRegistry).await()
|
||||
ctx.provide('sessions', {
|
||||
binding: () => ({ session }),
|
||||
})
|
||||
sessionSnapshots.set(slots, sessionStore)
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
@@ -167,10 +188,9 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
ctx.provide('sessionHistory', { source: () => history })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
|
||||
return { ctx, slots, fiber, loadOlder, sessionStore }
|
||||
}
|
||||
|
||||
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
||||
@@ -181,21 +201,20 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
|
||||
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
pending: [],
|
||||
openState: 'open' as const, hasMore: true, loadingOlder: false,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes))
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot)
|
||||
const chat = createChatStore().create()
|
||||
const views = {
|
||||
list: () => tabsOf(slots),
|
||||
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}
|
||||
const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never
|
||||
const inputActions = { setDraft: vi.fn(), submit: vi.fn() }
|
||||
const useInput = bindSnapshotSelector(createSnapshotStore({
|
||||
draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
|
||||
})) as never
|
||||
const inputActions = {
|
||||
setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(),
|
||||
}
|
||||
// Minimal outlet twin: resolve the ring entry by the `only` filter and
|
||||
// render it with the session standard kit (what SlotOutlet does for a
|
||||
// list-kind session slot, minus machinery).
|
||||
@@ -211,10 +230,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
? (() => {
|
||||
const trajectory = injected as TrajectoryViewInjected
|
||||
return {
|
||||
loadHistoryTail: trajectory.loadHistoryTail,
|
||||
loadOlderHistory: trajectory.loadOlderHistory,
|
||||
loadOlder: trajectory.loadOlder,
|
||||
setActualDuration: trajectory.setActualDuration,
|
||||
useHistory: bindSnapshotSelector(trajectory.hooks.history),
|
||||
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||
}
|
||||
})()
|
||||
@@ -256,6 +273,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
views={views}
|
||||
releaseSessionImages={vi.fn()}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
bindDraftMirror={() => () => {}}
|
||||
@@ -275,8 +293,16 @@ describe('plugin registration', () => {
|
||||
|
||||
it('fiber disposal removes the tab and leaves chat standing', async () => {
|
||||
const b = await bench()
|
||||
const events = b.ctx.get('conversationEvents') as ConversationEventRegistry
|
||||
const views = b.ctx.get('conversationViews') as ConversationViewRegistry
|
||||
expect(events.entries().length).toBeGreaterThan(0)
|
||||
expect(views.entries()).toHaveLength(1)
|
||||
|
||||
await b.fiber.dispose()
|
||||
|
||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||
expect(events.entries()).toEqual([])
|
||||
expect(views.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it('shares one browser-wide duration preference across session injections', async () => {
|
||||
@@ -296,6 +322,23 @@ describe('plugin registration', () => {
|
||||
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
|
||||
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports whether loading older history changed the Trajectory snapshot', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.slots.entries('conversation.view')
|
||||
.find(candidate => candidate.options.id === 'trajectory')
|
||||
const injectEntry = entry!.inject as unknown as (
|
||||
sessionId: SessionId,
|
||||
) => TrajectoryViewInjected
|
||||
const injected = injectEntry(SID)
|
||||
|
||||
expect(await injected.loadOlder()).toBe(false)
|
||||
|
||||
b.loadOlder.mockImplementationOnce(async () => {
|
||||
b.sessionStore.set(historySnapshot([...NODES]))
|
||||
})
|
||||
expect(await injected.loadOlder()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
@@ -317,13 +360,9 @@ describe('tab switching in ConversationRoot', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
||||
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.loadHistoryTail).toHaveBeenCalledOnce()
|
||||
})
|
||||
const signal = b.loadHistoryTail.mock.calls[0]?.[0]
|
||||
expect(signal?.aborted).toBe(false)
|
||||
expect(b.loadOlder).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(b.loadOlder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
||||
@@ -1068,7 +1107,7 @@ describe('timeline projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryView branches', () => {
|
||||
describe('TrajectoryView state', () => {
|
||||
it('persists the duration preference through the runtime snapshot-store seam', () => {
|
||||
const firstDuration = createTrajectoryDurationStore()
|
||||
const commonProps = {
|
||||
@@ -1101,111 +1140,6 @@ describe('TrajectoryView branches', () => {
|
||||
.toBe('true')
|
||||
})
|
||||
|
||||
it('renders only the selected rewind branch while retaining session-global requests', () => {
|
||||
const retained = {
|
||||
kind: 'user',
|
||||
seq: 1,
|
||||
time: 1_000,
|
||||
content: [{ type: 'text', text: 'retained user' }],
|
||||
source: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const abandoned = {
|
||||
kind: 'assistant',
|
||||
seq: 3,
|
||||
time: 3_000,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'abandoned response' }],
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const current = {
|
||||
kind: 'assistant',
|
||||
seq: 5,
|
||||
time: 5_000,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'current response' }],
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const request = (startSeq: number, turn: number): RequestView => ({
|
||||
purpose: 'assistant',
|
||||
startSeq,
|
||||
turn,
|
||||
step: 1,
|
||||
startedAt: startSeq * 1_000,
|
||||
completedAt: startSeq * 1_000 + 100,
|
||||
status: 'complete',
|
||||
})
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained, abandoned, current],
|
||||
{
|
||||
eventNodes: [retained, abandoned, current],
|
||||
contexts: [
|
||||
{ id: 0, nodes: [retained, abandoned] },
|
||||
{
|
||||
id: 1,
|
||||
parentId: 0,
|
||||
origin: 'rewind' as const,
|
||||
originSeq: 4,
|
||||
nodes: [retained, current],
|
||||
},
|
||||
],
|
||||
requests: [request(2, 1), request(4, 2)],
|
||||
callSchemas: new Map(),
|
||||
},
|
||||
))
|
||||
|
||||
const view = render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('abandoned response')).toBeNull()
|
||||
expect(screen.getByText('current response')).toBeTruthy()
|
||||
expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy()
|
||||
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not remount the ledger when prepending shifts a rewind generation id', () => {
|
||||
const current = {
|
||||
kind: 'assistant',
|
||||
seq: 5,
|
||||
time: 5_000,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'stable rewind response' }],
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const snapshot = (id: number) => historySnapshot([current], {
|
||||
contexts: [{
|
||||
id,
|
||||
origin: 'rewind' as const,
|
||||
originSeq: 4,
|
||||
nodes: [current],
|
||||
}],
|
||||
})
|
||||
const store = createSnapshotStore(snapshot(1))
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('row', { name: /stable rewind response/ })
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
|
||||
act(() => { store.set(snapshot(2)) })
|
||||
|
||||
expect(screen.getByRole('row', { name: /stable rewind response/ })
|
||||
.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps ledger and timeline selection on the same event after prepend', () => {
|
||||
const older = {
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
@@ -1220,9 +1154,8 @@ describe('TrajectoryView branches', () => {
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
useSession={bindSnapshotSelector(store)}
|
||||
loadOlder={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
|
||||
@@ -1237,47 +1170,6 @@ describe('TrajectoryView branches', () => {
|
||||
)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
|
||||
const retained = {
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'stop the task' }], source: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const interruptedAssistant = {
|
||||
kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'partial response retained' }],
|
||||
interrupted: true,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const interruptedTool = {
|
||||
kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call',
|
||||
call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900,
|
||||
content: [], isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: null, resultView: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained],
|
||||
{
|
||||
eventNodes: [retained],
|
||||
contexts: [{ id: 0, nodes: [retained] }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [interruptedAssistant, interruptedTool],
|
||||
},
|
||||
))
|
||||
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('partial response retained')).toBeTruthy()
|
||||
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node half', () => {
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user