fix(ui-trajectory): tighten conversation assembly contracts

This commit is contained in:
imccyu
2026-08-11 03:29:04 +08:00
parent b3d3e423f2
commit 62f5d05039
27 changed files with 525 additions and 534 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
2026-07-27-trajectory-inspection-ledger.md: c09213d35e984ca717d283d45259f61d413407a3 2026-07-27-trajectory-inspection-ledger.md: c46b9dbc564a8c3c83792335427614c92a015fce
2026-07-27-trajectory-inspection-ledger.zh.md: 9d2c615dea5b0774201118a0b0abb9862a228690 2026-07-27-trajectory-inspection-ledger.zh.md: 20811f7a23fe1c9ee69dce24c975f6343eaff6df
@@ -12,17 +12,17 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** **Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.**
- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. - The ledger keeps materialized business records in Session Event order within the loaded window. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests.
- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. - Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector.
- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. - Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack.
- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus.
- Session owns one contiguous Event window, paging state, live gap repair, and reconnect rebuild. Chat and Trajectory register separate business Definitions against the shared `ConversationNodeAssembler`; Trajectory reads its target snapshot from `Session.views` and requests one older Session page when the user reaches the loaded range's top. Its Definitions and target builder derive event order, context lineage, schema index, and Requests without making those structures part of the Chat snapshot. - Session owns one contiguous Event window, paging state, live gap repair, and reconnect rebuild. Chat and Trajectory register separate business Definitions against the shared `ConversationNodeAssembler`; Trajectory reads its target snapshot from `Session.views` and requests one older Session page when the user reaches the loaded range's top. Its Definitions and target builder derive event order, the schema index, and Requests without making those structures part of the Chat snapshot.
- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive. - Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive.
- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas.
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data.
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older Session page is pending. - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older Session page is pending.
- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
- Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window. - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window.
- Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger. - Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger.
@@ -53,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
## Consequences ## Consequences
Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport work, virtualization bounds mounted row elements, exact-ID dispatch avoids re-folding unrelated business Contexts, and animation-frame publication caps streaming snapshot frequency. The retained stage-oriented target builder may still perform work proportional to the loaded materialized Nodes for a publication; this migration does not add a stronger Trajectory-specific complexity guarantee. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions appear as the current materialized business records in sequence with surrounding history. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport work, virtualization bounds mounted row elements, exact-ID dispatch avoids re-folding unrelated business Contexts, and animation-frame publication caps streaming snapshot frequency. The retained stage-oriented target builder may still perform work proportional to the loaded materialized Nodes for a publication; this migration does not add a stronger Trajectory-specific complexity guarantee. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition.
@@ -12,17 +12,17 @@ Status: implemented
**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。**
- 记录表在`rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 - 记录表在已加载窗口内按 Session Event 顺序展示物化后的业务记录。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。
- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。
- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。
- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。
- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 DefinitionTrajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、上下文谱系、schema 索引和请求,无须把这些结构放进 Chat snapshot。 - Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 DefinitionTrajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、schema 索引和请求,无须把这些结构放进 Chat snapshot。
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
- 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录。 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录。
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
- token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。
- 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。 - 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。
@@ -53,4 +53,4 @@ Status: implemented
## 后果 ## 后果
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输工作,虚拟化限制已挂载的行元素数量,精确 ID 分发避免重新 fold 无关业务 Contextanimation-frame 发布则限制流式 snapshot 频率。保留的 stage-oriented target builder 在一次发布中仍可能执行与已加载物化 Node 数量成比例的工作;本次迁移不额外承诺更强的 Trajectory 专属复杂度。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩会作为当前物化的业务记录,按顺序出现在周边历史中。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输工作,虚拟化限制已挂载的行元素数量,精确 ID 分发避免重新 fold 无关业务 Contextanimation-frame 发布则限制流式 snapshot 频率。保留的 stage-oriented target builder 在一次发布中仍可能执行与已加载物化 Node 数量成比例的工作;本次迁移不额外承诺更强的 Trajectory 专属复杂度。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-conversation-node.md # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-conversation-node.md
adding-a-conversation-node.md: ea4ec73eb109af6b0e4c7cf50fc8692942c75dd4 adding-a-conversation-node.md: c1965dc8a3081eebb8c1026ac53d2f7b8964edb7
adding-a-conversation-node.zh.md: 4b9a8049e2f1d060ec4bc3334036559b989ea562 adding-a-conversation-node.zh.md: 92445e1432369a4e42cc372b5d5869c3cdeada4a
+4 -3
View File
@@ -120,6 +120,7 @@ function viewData(state: ReviewState): ReviewChatData {
const reviewDefinition: ConversationNodeDefinition<ReviewState> = { const reviewDefinition: ConversationNodeDefinition<ReviewState> = {
kind: 'review-job', kind: 'review-job',
target: 'chat',
match: (event) => { match: (event) => {
if (event.type === 'review/start') { if (event.type === 'review/start') {
return { id: String(event.data.reviewId), role: 'start' } return { id: String(event.data.reviewId), role: 'start' }
@@ -161,8 +162,8 @@ const reviewDefinition: ConversationNodeDefinition<ReviewState> = {
value: viewData(context.state), value: viewData(context.state),
} }
}, },
buildViewNode: (context, target) => { buildViewNode: (context) => {
if (target !== 'chat' || context.state === undefined) return null if (context.state === undefined) return null
return { return {
key: context.key, key: context.key,
kind: 'review-job', kind: 'review-job',
@@ -196,7 +197,7 @@ export function apply(ctx: ClientContext): void {
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`. `buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
`buildViewNode(context, target)` materializes the final target-specific Node. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. `target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`.
## 3. Query an earlier business Context only at start ## 3. Query an earlier business Context only at start
@@ -120,6 +120,7 @@ function viewData(state: ReviewState): ReviewChatData {
const reviewDefinition: ConversationNodeDefinition<ReviewState> = { const reviewDefinition: ConversationNodeDefinition<ReviewState> = {
kind: 'review-job', kind: 'review-job',
target: 'chat',
match: (event) => { match: (event) => {
if (event.type === 'review/start') { if (event.type === 'review/start') {
return { id: String(event.data.reviewId), role: 'start' } return { id: String(event.data.reviewId), role: 'start' }
@@ -161,8 +162,8 @@ const reviewDefinition: ConversationNodeDefinition<ReviewState> = {
value: viewData(context.state), value: viewData(context.state),
} }
}, },
buildViewNode: (context, target) => { buildViewNode: (context) => {
if (target !== 'chat' || context.state === undefined) return null if (context.state === undefined) return null
return { return {
key: context.key, key: context.key,
kind: 'review-job', kind: 'review-job',
@@ -196,7 +197,7 @@ export function apply(ctx: ClientContext): void {
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 `buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。
`buildViewNode(context, target)` 物化最终的目标专用 Node。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 `target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。
## 3. 只在 start 时查询更早的业务 Context ## 3. 只在 start 时查询更早的业务 Context
@@ -37,8 +37,8 @@ class TestEventDefinitions {
definitions: readonly ConversationNodeDefinition[], definitions: readonly ConversationNodeDefinition[],
fallback?: ConversationNodeDefinition, fallback?: ConversationNodeDefinition,
) { ) {
this.definitions = definitions.map(asChatDefinition) this.definitions = definitions
this.fallback = fallback === undefined ? undefined : asChatDefinition(fallback) this.fallback = fallback
} }
entries(): readonly ConversationNodeDefinition[] { entries(): readonly ConversationNodeDefinition[] {
@@ -50,12 +50,6 @@ class TestEventDefinitions {
} }
} }
function asChatDefinition(definition: ConversationNodeDefinition): ConversationNodeDefinition {
return definition.buildViewNode === undefined || definition.target !== undefined
? definition
: { ...definition, target: 'chat' }
}
class TestViewDefinitions { class TestViewDefinitions {
constructor(readonly definitions: readonly ConversationViewDefinition[]) {} constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
@@ -118,6 +112,17 @@ function node(
} }
} }
function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
return {
kind: 'fallback',
target: 'chat',
match: event => ({ id: String(event.seq), role: 'start' }),
start,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
}
describe('ConversationNodeAssembler', () => { describe('ConversationNodeAssembler', () => {
it('appends through an exact business-id Context without replaying unrelated Contexts', () => { it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
const starts = vi.fn(( const starts = vi.fn((
@@ -137,6 +142,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: starts, start: starts,
update: updates, update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -188,6 +194,7 @@ describe('ConversationNodeAssembler', () => {
matchCollections.add(context.matches) matchCollections.add(context.matches)
return updates(context) return updates(context)
}, },
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -223,6 +230,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: starts, start: starts,
update: updates, update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -262,6 +270,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: () => ({ settled: false }), start: () => ({ settled: false }),
update: updates, update: updates,
target: 'chat',
buildViewNode: context => node(context, context.state ?? { pendingStart: true }), buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -295,6 +304,7 @@ describe('ConversationNodeAssembler', () => {
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
start: () => null, start: () => null,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: () => null, buildViewNode: () => null,
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -316,6 +326,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: () => null, buildViewNode: () => null,
} }
const consumerStart = vi.fn(( const consumerStart = vi.fn((
@@ -330,6 +341,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: consumerStart, start: consumerStart,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -359,6 +371,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: (_context, match) => match.event.seq, start: (_context, match) => match.event.seq,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: () => null, buildViewNode: () => null,
} }
const consumer: ConversationNodeDefinition<number> = { const consumer: ConversationNodeDefinition<number> = {
@@ -368,6 +381,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1, start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -412,6 +426,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: consumerStart, start: consumerStart,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -440,6 +455,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: () => 1, start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value, update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null, buildViewNode: () => null,
} }
const consumerStart = vi.fn(( const consumerStart = vi.fn((
@@ -454,6 +470,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: consumerStart, start: consumerStart,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -483,6 +500,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: () => 1, start: () => 1,
update: (_context, match) => (match.event.data as unknown as { value: number }).value, update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null, buildViewNode: () => null,
} }
const sourceX: ConversationNodeDefinition<number> = { const sourceX: ConversationNodeDefinition<number> = {
@@ -494,6 +512,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: () => 10, start: () => 10,
update: (_context, match) => (match.event.data as unknown as { value: number }).value, update: (_context, match) => (match.event.data as unknown as { value: number }).value,
target: 'chat',
buildViewNode: () => null, buildViewNode: () => null,
} }
const middle: ConversationNodeDefinition<number> = { const middle: ConversationNodeDefinition<number> = {
@@ -506,6 +525,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-x')?.state ?? 0) + (reader.previous<number>('diamond-x')?.state ?? 0)
), ),
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const consumer: ConversationNodeDefinition<number> = { const consumer: ConversationNodeDefinition<number> = {
@@ -518,6 +538,7 @@ describe('ConversationNodeAssembler', () => {
+ (reader.previous<number>('diamond-b')?.state ?? 0) + (reader.previous<number>('diamond-b')?.state ?? 0)
), ),
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -553,6 +574,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: starts, start: starts,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -624,6 +646,7 @@ describe('ConversationNodeAssembler', () => {
value: { valueSeenFromStep: stepValue ?? -1 }, value: { valueSeenFromStep: stepValue ?? -1 },
} }
}, },
target: 'chat',
buildViewNode: (context) => { buildViewNode: (context) => {
const location = context.start?.location const location = context.start?.location
if (location?.kind !== 'step') return null if (location?.kind !== 'step') return null
@@ -661,6 +684,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: () => null, start: () => null,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind === 'turn' buildViewNode: context => node(context, context.start?.location.kind === 'turn'
? context.start.location.turn.steps.length ? context.start.location.turn.steps.length
: -1), : -1),
@@ -716,6 +740,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: () => null, start: () => null,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: (context) => { buildViewNode: (context) => {
const location = context.start?.location const location = context.start?.location
const data = location?.kind === 'step' const data = location?.kind === 'step'
@@ -749,6 +774,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: () => null, start: () => null,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.start?.location.kind), buildViewNode: context => node(context, context.start?.location.kind),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -775,6 +801,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: () => null, start: () => null,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: (context) => { buildViewNode: (context) => {
const location = context.start?.location const location = context.start?.location
return node(context, location?.kind === 'step' return node(context, location?.kind === 'step'
@@ -807,6 +834,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: () => null, start: () => null,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: (context) => { buildViewNode: (context) => {
const location = context.start?.location const location = context.start?.location
return node(context, location?.kind === 'step' return node(context, location?.kind === 'step'
@@ -841,6 +869,7 @@ describe('ConversationNodeAssembler', () => {
: null, : null,
start: seen, start: seen,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -856,24 +885,64 @@ describe('ConversationNodeAssembler', () => {
expect(seen).toHaveBeenCalledTimes(2) expect(seen).toHaveBeenCalledTimes(2)
}) })
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => { it('invokes the fallback when only a State-only Definition claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-state',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('invokes the fallback when only another target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed-trajectory',
target: 'trajectory',
match: event => (event.type as string) === 'command/run'
? { id: 'claimed', role: 'start' }
: null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
assembler.flush()
expect(fallbackStart).toHaveBeenCalledOnce()
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
})
it('suppresses the fallback when the same target claims an event', () => {
const fallbackStart = vi.fn(() => 'fallback') const fallbackStart = vi.fn(() => 'fallback')
const claimed: ConversationNodeDefinition<null> = { const claimed: ConversationNodeDefinition<null> = {
kind: 'claimed', kind: 'claimed',
target: 'chat',
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
start: () => null, start: () => null,
update: context => context.state, update: context => context.state,
buildViewNode: () => null, buildViewNode: () => null,
} }
const fallback: ConversationNodeDefinition<string> = {
kind: 'fallback',
match: event => ({ id: String(event.seq), role: 'start' }),
start: fallbackStart,
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([claimed], fallback), new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
new TestViewDefinitions([testView()]), new TestViewDefinitions([testView()]),
) )
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
@@ -893,6 +962,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: () => true, start: () => true,
update: () => false, update: () => false,
target: 'chat',
buildViewNode: context => context.state === true ? node(context, true) : null, buildViewNode: context => context.state === true ? node(context, true) : null,
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -915,6 +985,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: () => undefined, start: () => undefined,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: () => null, buildViewNode: () => null,
} }
const startAssembler = new ConversationNodeAssembler( const startAssembler = new ConversationNodeAssembler(
@@ -934,6 +1005,7 @@ describe('ConversationNodeAssembler', () => {
}, },
start: () => true, start: () => true,
update: () => undefined as never, update: () => undefined as never,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const updateAssembler = new ConversationNodeAssembler( const updateAssembler = new ConversationNodeAssembler(
@@ -954,6 +1026,7 @@ describe('ConversationNodeAssembler', () => {
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
start: (_context, match) => match.event.seq, start: (_context, match) => match.event.seq,
update: context => context.state, update: context => context.state,
target: 'chat',
buildViewNode: context => node(context, context.state), buildViewNode: context => node(context, context.state),
} }
const assembler = new ConversationNodeAssembler( const assembler = new ConversationNodeAssembler(
@@ -72,6 +72,40 @@ describe('Conversation registries', () => {
expect(events.fallbackEntry()).toBeUndefined() expect(events.fallbackEntry()).toBeUndefined()
}) })
it('rejects rendering Definitions that omit either target or builder', async () => {
const { events } = await bootRegistries()
const targetOnly: ConversationNodeDefinition<null> = {
kind: 'target-only',
target: 'chat',
match: () => null,
start: () => null,
update: context => context.state,
}
const builderOnly: ConversationNodeDefinition<null> = {
kind: 'builder-only',
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
})
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
const { events } = await bootRegistries()
const fallback: ConversationNodeDefinition<null> = {
kind: 'state-only-fallback',
match: () => null,
start: () => null,
update: context => context.state,
}
expect(() => events.registerFallback(fallback))
.toThrow('conversation fallback Definition must declare a target')
})
it('rejects duplicate view targets and disposes a view registration once', async () => { it('rejects duplicate view targets and disposes a view registration once', async () => {
const { views } = await bootRegistries() const { views } = await bootRegistries()
const definition = viewDefinition('chat') const definition = viewDefinition('chat')
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 75bd9ddf452634460be01e1b89cd5a1a14a1593f README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
README.zh.md: b5cd53dd50e43b96e2e832c96cb7e93f859c1993 README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md) 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 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 context lineage and 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. 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 ## Model Experience
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文 [English](README.md) | 中文
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。 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。
## 模型体验 ## 模型体验
@@ -48,19 +48,25 @@
"diff": "^9.0.0" "diff": "^9.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@types/react": "~18.3.1", "@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0", "@types/react-dom": "~18.3.0",
"@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis": "workspace:^",
@@ -4,13 +4,9 @@ import { useCallback, useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type { import type {
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, AssistantBlock, AssistantMessageNode, ConversationSnapshot,
SnapshotStore, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
trajectoryNodeIdentity,
} from './context-branches.ts'
import { import {
TrajectoryTable, TrajectoryTable,
type TrajectoryRequestNumber, type TrajectoryRequestNumber,
@@ -190,10 +186,7 @@ export function TrajectoryView({
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS) const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] = const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS) useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{ const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const actualDuration = useDuration(value => value) const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false) const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -215,41 +208,8 @@ export function TrajectoryView({
const runningCalls = inspection.runningCalls const runningCalls = inspection.runningCalls
const requests = inspection.requests const requests = inspection.requests
const callSchemas = inspection.callSchemas 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 => [trajectoryNodeIdentity(node), node]))
for (const node of interruptedNodes) {
selected.set(trajectoryNodeIdentity(node), 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 requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>() 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) { for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node) assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
@@ -345,24 +305,24 @@ export function TrajectoryView({
return numbered return numbered
}, [ }, [
contexts, nodes, requests, nodes, requests,
]) ])
const partialTurn = partial?.turn ?? null const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null const partialStep = partial?.step ?? null
const finalized = useMemo(() => { const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({ const turns = deriveTrajectoryLayout({
nodes: selectedNodes, nodes,
partial: partialTurn === null || partialStep === null partial: partialTurn === null || partialStep === null
? null ? null
: { turn: partialTurn, step: partialStep, blocks: [] }, : { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls, runningCalls,
requests: selectedRequests, requests,
callSchemas, callSchemas,
}) })
return { turns, lastIndex: lastCellIndex(turns) } return { turns, lastIndex: lastCellIndex(turns) }
}, [ }, [
selectedNodes, partialTurn, partialStep, nodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, runningCalls, requests, callSchemas,
]) ])
const timelinePartialSignature = partialStructureSignature(partial) const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
@@ -402,9 +362,7 @@ export function TrajectoryView({
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches), () => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches], [finalizedSearchMatches, partialSearchMatches],
) )
const timelineRange = timelineSelection?.branchKey === currentBranch.key const timelineRange = timelineSelection
? timelineSelection.range
: null
const timelineFocusIndexes = useMemo( const timelineFocusIndexes = useMemo(
() => timelineRange === null () => timelineRange === null
? null ? null
@@ -420,11 +378,8 @@ export function TrajectoryView({
} }
}, [timelineFocusIndexes]) }, [timelineFocusIndexes])
const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => { const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
setTimelineSelection(range === null ? null : { setTimelineSelection(range)
branchKey: currentBranch.key, }, [])
range,
})
}, [currentBranch.key])
const handleTimelineRecordSelect = useCallback((index: number) => { const handleTimelineRecordSelect = useCallback((index: number) => {
setTimelineSelection(null) setTimelineSelection(null)
setTimelineRecordSelection({ index }) setTimelineRecordSelection({ index })
@@ -547,7 +502,6 @@ export function TrajectoryView({
/> />
<div className={css.ledger}> <div className={css.ledger}>
<TrajectoryTable <TrajectoryTable
key={currentBranch.key}
requestNumbers={requestNumbers} requestNumbers={requestNumbers}
turns={timelineTurns} turns={timelineTurns}
streamingCells={streamingCells} streamingCells={streamingCells}
@@ -1,135 +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<string, ConversationNode>
startSeq: number
retainedSurfaceSeqs: Set<number>
}
/**
* Resolve the identity used while coalescing one trajectory branch.
* Synthetic tool interruptions share their closing boundary seq, so their
* call ids distinguish parallel roots without inventing false event order.
* @param node - projected conversation node.
* @returns branch-local semantic identity.
*/
export function trajectoryNodeIdentity(node: ConversationNode): string {
return node.kind === 'tool-result'
? `tool-result\u0000${String(node.seq)}\u0000${node.callId}`
: `seq\u0000${String(node.seq)}`
}
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 => [trajectoryNodeIdentity(node), 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(trajectoryNodeIdentity(node), 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)
)
}
@@ -45,9 +45,9 @@ export function apply(ctx: Context): void {
return { return {
hooks: { duration }, hooks: { duration },
loadOlder: async () => { loadOlder: async () => {
const hadMore = session.getSnapshot().hasMore const before = session.getSnapshot().views.get('trajectory')
await session.loadOlder() await session.loadOlder()
return hadMore return session.getSnapshot().views.get('trajectory') !== before
}, },
setActualDuration: (value) => { duration.set(value) }, setActualDuration: (value) => { duration.set(value) },
} }
@@ -9,6 +9,9 @@ import {
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { trajectoryNode } from './trajectory-definition-common.ts' 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 { interface UsageValue {
readonly inputTokens: number readonly inputTokens: number
readonly outputTokens: number readonly outputTokens: number
@@ -389,6 +392,7 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition<TurnEndState> = {
...(context.state.error === undefined ? {} : { error: context.state.error }), ...(context.state.error === undefined ? {} : { error: context.state.error }),
}), }),
} }
/* jscpd:ignore-end */
/** /**
* Register the Trajectory Assistant lifecycle. * Register the Trajectory Assistant lifecycle.
@@ -1,5 +1,5 @@
import type { import type {
AssistantMessageNode, ConversationContext, ConversationLocation, ConversationNode, AssistantMessageNode, ConversationLocation, ConversationNode,
ConversationPromptSnapshot, ConversationViewNode, PartialAssistant, ConversationPromptSnapshot, ConversationViewNode, PartialAssistant,
RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock, RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
@@ -59,10 +59,8 @@ export interface TrajectoryConversationViewNode extends ConversationViewNode {
/** Stage-oriented Trajectory data assembled from registered business Contexts. */ /** Stage-oriented Trajectory data assembled from registered business Contexts. */
export interface TrajectorySnapshot { export interface TrajectorySnapshot {
readonly eventNodes: readonly ConversationNode[] readonly eventNodes: readonly ConversationNode[]
readonly contexts: readonly ConversationContext[]
readonly requests: readonly RequestView[] readonly requests: readonly RequestView[]
readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]> readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
readonly interruptedNodes: readonly ConversationNode[]
readonly partial: PartialAssistant | null readonly partial: PartialAssistant | null
readonly runningCalls: readonly RunningToolCall[] readonly runningCalls: readonly RunningToolCall[]
} }
@@ -1,22 +1,8 @@
import type { import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client'
ConversationLocation, ConversationNodeContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { import type {
TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryContribution, TrajectoryConversationViewNode,
} from './trajectory-contract.ts' } from './trajectory-contract.ts'
/**
* Resolve the best loaded Location for one target-local Context.
*
* @param context - Context whose loaded matches provide the Location.
* @returns The start Location, first-match Location, or unresolved fallback.
*/
export function trajectoryContextLocation(
context: ConversationNodeContext,
): ConversationLocation {
return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
}
/** /**
* Wrap one contribution in the Engine-owned target envelope. * Wrap one contribution in the Engine-owned target envelope.
* *
@@ -9,6 +9,9 @@ import {
import type {} from '@deepseek-ai/dsh-agent/types' import type {} from '@deepseek-ai/dsh-agent/types'
import { trajectoryNode } from './trajectory-definition-common.ts' 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 { interface InboxIdentity {
readonly id: string readonly id: string
} }
@@ -106,6 +109,7 @@ const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
? null ? null
: trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }), : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }),
} }
/* jscpd:ignore-end */
/** /**
* Register Trajectory-owned inbox classification and message records. * Register Trajectory-owned inbox classification and message records.
@@ -10,17 +10,14 @@ import type {
} from './trajectory-contract.ts' } from './trajectory-contract.ts'
const EMPTY_LIST: readonly never[] = [] const EMPTY_LIST: readonly never[] = []
const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }]
type AssistantRequest = Extract<RequestView, { purpose: 'assistant' }> type AssistantRequest = Extract<RequestView, { purpose: 'assistant' }>
type ToolSchema = ConversationPromptSnapshot['tools'][number] type ToolSchema = ConversationPromptSnapshot['tools'][number]
/** Stable empty target used until a Session has assembled Trajectory records. */ /** Stable empty target used until a Session has assembled Trajectory records. */
export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
eventNodes: EMPTY_LIST, eventNodes: EMPTY_LIST,
contexts: EMPTY_CONTEXTS,
requests: EMPTY_LIST, requests: EMPTY_LIST,
callSchemas: new Map(), callSchemas: new Map(),
interruptedNodes: EMPTY_LIST,
partial: null, partial: null,
runningCalls: EMPTY_LIST, runningCalls: EMPTY_LIST,
} }
@@ -249,10 +246,8 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
const eventNodes = finalized const eventNodes = finalized
return { return {
eventNodes, eventNodes,
contexts: [{ id: 0, nodes: eventNodes }],
requests, requests,
callSchemas, callSchemas,
interruptedNodes: EMPTY_LIST,
partial, partial,
runningCalls, runningCalls,
} }
@@ -6,6 +6,9 @@ import type {
import type {} from '@deepseek-ai/dsh-tools/types' import type {} from '@deepseek-ai/dsh-tools/types'
import { trajectoryNode } from './trajectory-definition-common.ts' 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 const MAX_DEPTH = 256
interface ToolState { interface ToolState {
@@ -109,11 +112,26 @@ function childResult(
function acceptsEdge(state: ToolState, parent: string, child: string): boolean { function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
if (parent === child || state.parents.has(child)) return false if (parent === child || state.parents.has(child)) return false
let cursor: string | undefined = parent let cursor: string | undefined = parent
for (let depth = 0; cursor !== undefined && depth <= MAX_DEPTH; depth++) { let parentDepth = 0
if (cursor === child) return false 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) cursor = state.parents.get(cursor)
} }
return cursor === undefined 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 { function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
@@ -243,6 +261,7 @@ const trajectoryToolDefinition: ConversationNodeDefinition<ToolState> = {
return trajectoryNode(context, anchorSeq, { kind: 'tool', root }) return trajectoryNode(context, anchorSeq, { kind: 'tool', root })
}, },
} }
/* jscpd:ignore-end */
/** /**
* Register the Trajectory Tool lifecycle. * Register the Trajectory Tool lifecycle.
@@ -84,9 +84,15 @@ describe('tsdown client artifact', () => {
ctx.provide('sessions', { binding: () => undefined }) ctx.provide('sessions', { binding: () => undefined })
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await() 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(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() await fiber.dispose()
expect(slots.entries('conversation.view')).toHaveLength(0) 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 () => { it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {
@@ -1,129 +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 interruptedTool(callId: string): ConversationNode {
return {
kind: 'tool-result',
seq: 19.2,
time: 20,
callId,
call: { name: 'parallel', argsRaw: '{}' },
callTime: 10,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: null,
resultView: null,
subCalls: [],
}
}
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)
})
it('retains parallel tool interruptions that share one closing boundary', () => {
const branch = deriveTrajectoryContextBranches([{
id: 0,
nodes: [interruptedTool('call-a'), interruptedTool('call-b')],
}])[0]
expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined))
.toEqual(['call-a', 'call-b'])
})
})
@@ -0,0 +1,276 @@
import type { Context } from '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 current = snapshot(assembler([
at(1, 'agent/inbox/spliced', {
target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
}),
at(2, 'agent/inbox/spliced', {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
}),
at(3, 'user/message', {
id: 'm1',
role: 'user',
content: [{ type: 'text', text: 'steer here' }],
source: { kind: 'user' },
}),
at(4, 'turn/start', { turn: 1 }),
at(5, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'test', model: 'test' },
system: 'system prompt',
tools: [],
},
}),
at(6, 'step/start', { turn: 1, step: 1 }),
at(7, 'assistant/message', {
turn: 1,
step: 1,
message: assistantMessage('assistant-1', 'first'),
}),
at(8, 'step/end', { turn: 1, step: 1 }),
at(9, 'step/start', { turn: 1, step: 2 }),
at(10, 'assistant/message', {
turn: 1,
step: 2,
message: assistantMessage('assistant-2', 'second'),
}),
]))
expect(current.eventNodes.find(node => node.seq === 3)?.kind).toBe('steering')
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])
})
})
@@ -75,10 +75,8 @@ function historySnapshot(
): ConversationSnapshot { ): ConversationSnapshot {
const trajectory: TrajectorySnapshot = { const trajectory: TrajectorySnapshot = {
eventNodes: nodes, eventNodes: nodes,
contexts: [{ id: 0, nodes }],
requests: [], requests: [],
callSchemas: new Map(), callSchemas: new Map(),
interruptedNodes: [],
partial: null, partial: null,
runningCalls: [], runningCalls: [],
...inspection, ...inspection,
@@ -191,7 +189,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
const fiber = ctx.plugin({ inject: [...inject], apply }) const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await() await fiber.await()
return { ctx, slots, fiber, loadOlder } return { ctx, slots, fiber, loadOlder, sessionStore }
} }
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */ /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -294,8 +292,16 @@ describe('plugin registration', () => {
it('fiber disposal removes the tab and leaves chat standing', async () => { it('fiber disposal removes the tab and leaves chat standing', async () => {
const b = await bench() 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() await b.fiber.dispose()
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) 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 () => { it('shares one browser-wide duration preference across session injections', async () => {
@@ -315,6 +321,23 @@ describe('plugin registration', () => {
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true') expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull() 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', () => { describe('tab switching in ConversationRoot', () => {
@@ -1083,7 +1106,7 @@ describe('timeline projection', () => {
}) })
}) })
describe('TrajectoryView branches', () => { describe('TrajectoryView state', () => {
it('persists the duration preference through the runtime snapshot-store seam', () => { it('persists the duration preference through the runtime snapshot-store seam', () => {
const firstDuration = createTrajectoryDurationStore() const firstDuration = createTrajectoryDurationStore()
const commonProps = { const commonProps = {
@@ -1116,109 +1139,6 @@ describe('TrajectoryView branches', () => {
.toBe('true') .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()}
useSession={bindSnapshotSelector(store)}
loadOlder={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()}
useSession={bindSnapshotSelector(store)}
loadOlder={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', () => { it('keeps ledger and timeline selection on the same event after prepend', () => {
const older = { const older = {
kind: 'user', seq: 1, time: 1_000, kind: 'user', seq: 1, time: 1_000,
@@ -1249,46 +1169,6 @@ describe('TrajectoryView branches', () => {
)).toBeTruthy() )).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()}
useSession={bindSnapshotSelector(store)}
loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)
expect(screen.getByText('partial response retained')).toBeTruthy()
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
})
}) })
describe('node half', () => { describe('node half', () => {
@@ -20,6 +20,15 @@
{ {
"path": "../runtime" "path": "../runtime"
}, },
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../compact/compact"
},
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
} }
+9
View File
@@ -2813,6 +2813,9 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-client-runtime': '@deepseek-ai/dsh-client-runtime':
specifier: workspace:^ specifier: workspace:^
version: link:../runtime version: link:../runtime
@@ -2825,9 +2828,15 @@ importers:
'@deepseek-ai/dsh-client-ui-slots': '@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^ specifier: workspace:^
version: link:../ui-slots version: link:../ui-slots
'@deepseek-ai/dsh-compact':
specifier: workspace:^
version: link:../../compact/compact
'@deepseek-ai/dsh-invariants': '@deepseek-ai/dsh-invariants':
specifier: workspace:^ specifier: workspace:^
version: link:../../support/invariants version: link:../../support/invariants
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@types/react': '@types/react':
specifier: ~18.3.1 specifier: ~18.3.1
version: 18.3.31 version: 18.3.31